diff --git a/plugins/module_utils/k8s/resource.py b/plugins/module_utils/k8s/resource.py new file mode 100644 index 00000000..69e83e86 --- /dev/null +++ b/plugins/module_utils/k8s/resource.py @@ -0,0 +1,129 @@ +# Copyright: (c) 2021, Red Hat | Ansible +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +import os +from typing import cast, Dict, Iterable, List, Optional, Union + +from ansible.module_utils.six import string_types + +try: + import yaml +except ImportError: + # Handled in module setup + pass + + +class ResourceDefinition(dict): + """Representation of a resource definition. + + This is a thin wrapper around a dictionary representation of a resource + definition, with a few properties defined for conveniently accessing the + commonly used fields. + """ + + @property + def kind(self) -> Optional[str]: + return self.get("kind") + + @property + def api_version(self) -> Optional[str]: + return self.get("apiVersion") + + @property + def namespace(self) -> Optional[str]: + metadata = self.get("metadata", {}) + return metadata.get("namespace") + + @property + def name(self) -> Optional[str]: + metadata = self.get("metadata", {}) + return metadata.get("name") + + +def create_definitions(params: Dict) -> List[ResourceDefinition]: + """Create a list of ResourceDefinitions from module inputs. + + This will take the module's inputs and return a list of ResourceDefintion + objects. The resource definitions returned by this function should be as + complete a definition as we can create based on the input. Any *List kinds + will be removed and replaced by the resources contained in it. + """ + if params.get("resource_definition"): + d = cast(Union[str, List, Dict], params.get("resource_definition")) + definitions = from_yaml(d) + elif params.get("src"): + d = cast(str, params.get("src")) + definitions = from_file(d) + else: + # We'll create an empty definition and let merge_params set values + # from the module parameters. + definitions = [{}] + + resource_definitions: List[Dict] = [] + for definition in definitions: + merge_params(definition, params) + kind = cast(Optional[str], definition.get("kind")) + if kind and kind.endswith("List"): + resource_definitions += flatten_list_kind(definition, params) + else: + resource_definitions.append(definition) + return list(map(ResourceDefinition, resource_definitions)) + + +def from_yaml(definition: Union[str, List, Dict]) -> Iterable[Dict]: + """Load resource definitions from a yaml definition.""" + definitions: List[Dict] = [] + if isinstance(definition, string_types): + definitions += yaml.safe_load_all(definition) + elif isinstance(definition, list): + for item in definition: + if isinstance(item, string_types): + definitions += yaml.safe_load_all(item) + else: + definitions.append(item) + else: + definition = cast(Dict, definition) + definitions.append(definition) + return filter(None, definitions) + + +def from_file(filepath: str) -> Iterable[Dict]: + """Load resource definitions from a path to a yaml file.""" + path = os.path.normpath(filepath) + with open(path, "r") as f: + definitions = list(yaml.safe_load_all(f)) + return filter(None, definitions) + + +def merge_params(definition: Dict, params: Dict) -> Dict: + """Merge module parameters with the resource definition. + + Fields in the resource definition take precedence over module parameters. + """ + definition.setdefault("kind", params.get("kind")) + definition.setdefault("apiVersion", params.get("api_version")) + metadata = definition.setdefault("metadata", {}) + # The following should only be set if we have values for them + if params.get("namespace"): + metadata.setdefault("namespace", params.get("namespace")) + if params.get("name"): + metadata.setdefault("name", params.get("name")) + if params.get("generate_name"): + metadata.setdefault("generateName", params.get("generate_name")) + return definition + + +def flatten_list_kind(definition: Dict, params: Dict) -> List[Dict]: + """Replace *List kind with the items it contains. + + This will take a definition for a *List resource and return a list of + definitions for the items contained within the List. + """ + items = [] + kind = cast(str, definition.get("kind"))[:-4] + api_version = definition.get("apiVersion") + for item in definition.get("items", []): + item.setdefault("kind", kind) + item.setdefault("apiVersion", api_version) + items.append(merge_params(item, params)) + return items diff --git a/tests/unit/module_utils/fixtures/definitions.yml b/tests/unit/module_utils/fixtures/definitions.yml new file mode 100644 index 00000000..d627bd5c --- /dev/null +++ b/tests/unit/module_utils/fixtures/definitions.yml @@ -0,0 +1,11 @@ +kind: Pod +apiVersion: v1 +metadata: + name: foo + namespace: bar +--- +kind: ConfigMap +apiVersion: v1 +metadata: + name: foo + namespace: bar diff --git a/tests/unit/module_utils/test_resource.py b/tests/unit/module_utils/test_resource.py new file mode 100644 index 00000000..8d7beda1 --- /dev/null +++ b/tests/unit/module_utils/test_resource.py @@ -0,0 +1,187 @@ +import os +from pathlib import Path + +from ansible_collections.kubernetes.core.plugins.module_utils.k8s.resource import ( + create_definitions, + flatten_list_kind, + from_file, + from_yaml, + merge_params, +) + + +def test_create_definitions_loads_from_definition(): + params = { + "resource_definition": { + "kind": "Pod", + "apiVersion": "v1", + "metadata": {"name": "foo", "namespace": "bar"}, + } + } + results = create_definitions(params) + assert len(results) == 1 + assert results[0].kind == "Pod" + assert results[0].api_version == "v1" + assert results[0].name == "foo" + assert results[0].namespace == "bar" + + +def test_create_definitions_loads_from_file(): + current = Path(os.path.dirname(os.path.abspath(__file__))) + params = {"src": current / "fixtures/definitions.yml"} + results = create_definitions(params) + assert len(results) == 2 + assert results[0].kind == "Pod" + assert results[1].kind == "ConfigMap" + + +def test_create_definitions_loads_from_params(): + params = { + "kind": "Pod", + "api_version": "v1", + "name": "foo", + "namespace": "foobar", + } + results = create_definitions(params) + assert len(results) == 1 + assert results[0] == { + "kind": "Pod", + "apiVersion": "v1", + "metadata": {"name": "foo", "namespace": "foobar"}, + } + + +def test_create_definitions_loads_list_kind(): + params = { + "resource_definition": { + "kind": "PodList", + "apiVersion": "v1", + "items": [ + {"kind": "Pod", "metadata": {"name": "foo"}}, + {"kind": "Pod", "metadata": {"name": "bar"}}, + ], + } + } + results = create_definitions(params) + assert len(results) == 2 + assert results[0].name == "foo" + assert results[1].name == "bar" + + +def test_merge_params_does_not_overwrite(): + definition = { + "kind": "Pod", + "apiVersion": "v1", + "metadata": {"name": "foo", "namespace": "bar"}, + } + params = { + "kind": "Service", + "api_version": "v2", + "name": "baz", + "namespace": "gaz", + } + result = merge_params(definition, params) + assert result == definition + + +def test_merge_params_adds_module_params(): + params = { + "kind": "Pod", + "api_version": "v1", + "namespace": "bar", + "generate_name": "foo-", + } + result = merge_params({}, params) + assert result == { + "kind": "Pod", + "apiVersion": "v1", + "metadata": {"generateName": "foo-", "namespace": "bar"}, + } + + +def test_from_yaml_loads_string_docs(): + definition = """ +kind: Pod +apiVersion: v1 +metadata: + name: foo + namespace: bar +--- +kind: ConfigMap +apiVersion: v1 +metadata: + name: foo + namespace: bar +""" + result = list(from_yaml(definition)) + assert result[0]["kind"] == "Pod" + assert result[1]["kind"] == "ConfigMap" + + +def test_from_yaml_loads_list(): + definition = [ + """ + kind: Pod + apiVersion: v1 + metadata: + name: foo + namespace: bar + """, + """ + kind: ConfigMap + apiVersion: v1 + metadata: + name: foo + namespace: bar + """, + { + "kind": "Pod", + "apiVersion": "v1", + "metadata": {"name": "baz", "namespace": "bar"}, + }, + ] + result = list(from_yaml(definition)) + assert len(result) == 3 + assert result[0]["kind"] == "Pod" + assert result[1]["kind"] == "ConfigMap" + assert result[2]["metadata"]["name"] == "baz" + + +def test_from_yaml_loads_dictionary(): + definition = { + "kind": "Pod", + "apiVersion": "v1", + "metadata": {"name": "foo", "namespace": "bar"}, + } + result = list(from_yaml(definition)) + assert result[0]["kind"] == "Pod" + + +def test_from_file_loads_definitions(): + current = Path(os.path.dirname(os.path.abspath(__file__))) + result = list(from_file(current / "fixtures/definitions.yml")) + assert result[0]["kind"] == "Pod" + assert result[1]["kind"] == "ConfigMap" + + +def test_flatten_list_kind_flattens(): + definition = { + "kind": "PodList", + "apiVersion": "v1", + "items": [ + {"kind": "Pod", "metadata": {"name": "foo"}}, + {"kind": "Pod", "metadata": {"name": "bar"}}, + ], + } + result = flatten_list_kind(definition, {"namespace": "foobar"}) + assert len(result) == 2 + + assert result[0]["kind"] == "Pod" + assert result[0]["apiVersion"] == "v1" + assert result[0]["metadata"]["name"] == "foo" + assert result[0]["metadata"]["namespace"] == "foobar" + + assert result[1]["kind"] == "Pod" + assert result[1]["apiVersion"] == "v1" + assert result[1]["metadata"]["name"] == "bar" + assert result[1]["metadata"]["namespace"] == "foobar"