diff --git a/plugins/modules/set_containers.py b/plugins/modules/set_containers.py new file mode 100644 index 000000000..eca41bbe1 --- /dev/null +++ b/plugins/modules/set_containers.py @@ -0,0 +1,587 @@ +#!/usr/bin/python + +# Copyright: (c) 2026, Red Hat +# GNU General Public License v3.0+ (see COPYING or +# https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: set_containers + +short_description: Generate and optionally apply an OpenStackVersion CR + +description: +- Generates an OpenStackVersion custom resource that controls which container + images OpenStack services, AnsibleEE, and EDPM nodes use. +- With C(state=present) the CR file is written and optionally applied to the + cluster with C(oc apply -f). +- With C(state=absent) the CR is optionally deleted from the cluster and the + local file is removed. +- Module-level C(registry), C(org), C(name_prefix), and C(tag) act as + defaults for all image URL construction. Every C(images) entry can override + any of those four fields individually, so callers only need to specify what + differs from the module defaults. +- C(include_openstack=true) populates the full standard set of OpenStack + service images using the module-level (or per-image) defaults. +- The C(images) list handles everything else and can also transparently + override individual images from the C(include_openstack) set. Overrides are + merged on top; unspecified images keep their default values. +- URL resolution order for each C(images) entry. + 1. C(full_registry) is set - used as the complete URL. + 2. C(container_suffix) is set - URL built from the effective + C(registry/org/name_prefix-suffix:tag) (per-image values take precedence + over module-level values). + 3. Partial overrides only (e.g. C(tag) alone) - the standard suffix for the + field C(name) is looked up in the built-in OpenStack image table and the + URL is rebuilt with only the specified fields changed. Useful for + overriding a single image from the C(include_openstack) set without + repeating the suffix. +requirements: + - PyYAML + - oc (only when apply=true or state=absent) +options: + state: + description: + - C(present) writes the CR file and optionally applies it. + - C(absent) optionally deletes it from the cluster then removes the file. + type: str + choices: [present, absent] + default: present + apply: + description: + - When C(true) and C(state=present), run C(oc apply -f dest_path). + type: bool + default: false + namespace: + description: + - Kubernetes namespace for the OpenStackVersion CR. + type: str + default: openstack + metadata_name: + description: + - Name field of the OpenStackVersion CR metadata. + type: str + default: controlplane + dest_path: + description: + - Absolute path where the generated CR YAML file is written. + required: true + type: path + registry: + description: + - Default container registry host (e.g. C(quay.io)). + - Required when C(include_openstack=true) or any C(images) entry needs to + build a URL from parts and does not provide its own C(registry). + type: str + org: + description: + - Default registry namespace or organisation. + - Required under the same conditions as C(registry). + type: str + tag: + description: + - Default container image tag. + - Required under the same conditions as C(registry). + type: str + name_prefix: + description: + - Default container image name prefix prepended before the suffix when + building an image URL. + type: str + default: openstack + include_openstack: + description: + - Populate the full set of standard OpenStack service container images + using the module-level C(registry), C(org), C(name_prefix), and C(tag). + When C(true) those four parameters are required. + type: bool + default: false + images: + description: + - List of container image specifications added to or overriding + C(spec.customContainerImages). + - Entries are merged on top of images produced by C(include_openstack). + - Each entry can override the module-level C(registry), C(org), + C(name_prefix), and C(tag) individually. Only the fields that differ + from the module defaults need to be specified. + type: list + elements: dict + default: [] + suboptions: + name: + description: + - CR field name, e.g. C(ironicPythonAgentImage) or C(cinderVolumeImages). + When only partial overrides are given with no C(container_suffix), + this name is looked up in the built-in OpenStack suffix table to + rebuild the URL. + type: str + required: true + full_registry: + description: + - Complete image URL including the tag. When set, all other image- + building fields are ignored. Mutually exclusive with C(container_suffix). + type: str + container_suffix: + description: + - Image name suffix. The URL is built as + C(registry/org/name_prefix-container_suffix:tag) using the effective + per-image or module-level values. Mutually exclusive with C(full_registry). + type: str + registry: + description: + - Per-image registry override. Falls back to the module-level C(registry). + type: str + org: + description: + - Per-image organisation override. Falls back to the module-level C(org). + type: str + name_prefix: + description: + - Per-image name prefix override. Falls back to the module-level C(name_prefix). + type: str + tag: + description: + - Per-image tag override. Falls back to the module-level C(tag). + type: str + backends: + description: + - List of backend names. When present the CR field becomes a dict + mapping each backend name to its image URL + (e.g. C(cinderVolumeImages) or C(manilaShareImages)). + - Each item is either a plain string (inherits the parent image URL) + or a dict with C(name) plus any subset of C(full_registry), + C(container_suffix), C(registry), C(org), C(name_prefix), C(tag). + type: list + elements: raw + kubeconfig: + description: + - Path to the kubeconfig file used when running C(oc) commands. + - Falls back to the C(KUBECONFIG) environment variable when not set. + type: path + +author: + - Arx Cruz (@arxcruz) +""" + +EXAMPLES = r""" +- name: Generate CR with the full OpenStack image set (no apply) + cifmw.general.set_containers: + metadata_name: controlplane + dest_path: /home/zuul/ci-framework-data/artifacts/manifests/set_containers.yml + registry: quay.io + org: openstack-k8s-operators + tag: current-podified + include_openstack: true + +- name: Full set, override barbican tag and add AnsibleEE, then apply + cifmw.general.set_containers: + metadata_name: controlplane + dest_path: /home/zuul/ci-framework-data/artifacts/manifests/set_containers.yml + registry: quay.io + org: openstack-k8s-operators + tag: current-podified + include_openstack: true + apply: true + kubeconfig: /home/zuul/.kube/config + images: + # override only the tag - suffix is looked up from the built-in table + - name: barbicanAPIImage + tag: my-hsm-tag + - name: barbicanWorkerImage + tag: my-hsm-tag + # pull from a different registry with a different prefix + - name: octaviaAPIImage + registry: mirror.example.com + org: myorg + name_prefix: rhosp + tag: "18.0" + # full URL override + - name: ansibleeeImage + full_registry: quay.rdoproject.org/openstack-k8s-operators/openstack-ansibleee-runner:current-podified + +- name: Watcher and cinder/manila backends (no include_openstack) + cifmw.general.set_containers: + dest_path: /tmp/set_containers.yml + registry: quay.io + org: openstack-k8s-operators + tag: current-podified + images: + - name: watcherAPIImage + container_suffix: watcher-api + - name: watcherApplierImage + container_suffix: watcher-applier + - name: watcherDecisionEngineImage + container_suffix: watcher-decision-engine + - name: cinderVolumeImages + container_suffix: cinder-volume + backends: + - default + - name: netapp + full_registry: registry.example.com/netapp/cinder-volume:24.1 + - name: hpe + name_prefix: hpe + org: hpe-storage + tag: "1.2.3" + - name: manilaShareImages + container_suffix: manila-share + backends: + - share1 + - share2 + +- name: Remove the CR from the cluster and delete the local file + cifmw.general.set_containers: + state: absent + dest_path: /home/zuul/ci-framework-data/artifacts/manifests/set_containers.yml + kubeconfig: /home/zuul/.kube/config +""" + +RETURN = r""" +dest_path: + description: Absolute path to the generated CR file. + type: str + returned: when state=present +changed: + description: Whether the module made any changes. + type: bool + returned: always +""" + +import os + +from ansible.module_utils.basic import AnsibleModule + +try: + import yaml + + HAS_YAML = True +except ImportError: + HAS_YAML = False + +# Maps every standard OpenStack CR field name to its container image suffix. +# Used when an images entry specifies only partial overrides (e.g. tag only) +# with no container_suffix or full_registry, enabling transparent per-field +# overrides without repeating the suffix. +_OPENSTACK_SUFFIXES = { + "aodhAPIImage": "aodh-api", + "aodhEvaluatorImage": "aodh-evaluator", + "aodhListenerImage": "aodh-listener", + "aodhNotifierImage": "aodh-notifier", + "barbicanAPIImage": "barbican-api", + "barbicanKeystoneListenerImage": "barbican-keystone-listener", + "barbicanWorkerImage": "barbican-worker", + "ceilometerCentralImage": "ceilometer-central", + "ceilometerComputeImage": "ceilometer-compute", + "ceilometerIpmiImage": "ceilometer-ipmi", + "ceilometerNotificationImage": "ceilometer-notification", + "cinderAPIImage": "cinder-api", + "cinderBackupImage": "cinder-backup", + "cinderSchedulerImage": "cinder-scheduler", + "cinderVolumeImage": "cinder-volume", + "cloudkittyAPIImage": "cloudkitty-api", + "cloudkittyProcImage": "cloudkitty-processor", + "designateAPIImage": "designate-api", + "designateBackendbind9Image": "designate-backend-bind9", + "designateCentralImage": "designate-central", + "designateMdnsImage": "designate-mdns", + "designateProducerImage": "designate-producer", + "designateUnboundImage": "unbound", + "designateWorkerImage": "designate-worker", + "edpmFrrImage": "frr", + "edpmIscsidImage": "iscsid", + "edpmLogrotateCrondImage": "cron", + "edpmMultipathdImage": "multipathd", + "edpmNeutronDhcpAgentImage": "neutron-dhcp-agent", + "edpmNeutronMetadataAgentImage": "neutron-metadata-agent-ovn", + "edpmNeutronOvnAgentImage": "neutron-ovn-agent", + "edpmNeutronSriovAgentImage": "neutron-sriov-agent", + "edpmOvnBgpAgentImage": "ovn-bgp-agent", + "glanceAPIImage": "glance-api", + "heatAPIImage": "heat-api", + "heatCfnapiImage": "heat-api-cfn", + "heatEngineImage": "heat-engine", + "horizonImage": "horizon", + "infraDnsmasqImage": "neutron-server", + "infraMemcachedImage": "memcached", + "ironicAPIImage": "ironic-api", + "ironicConductorImage": "ironic-conductor", + "ironicInspectorImage": "ironic-inspector", + "ironicNeutronAgentImage": "ironic-neutron-agent", + "ironicPxeImage": "ironic-pxe", + "keystoneAPIImage": "keystone", + "manilaAPIImage": "manila-api", + "manilaSchedulerImage": "manila-scheduler", + "manilaShareImage": "manila-share", + "mariadbImage": "mariadb", + "neutronAPIImage": "neutron-server", + "novaAPIImage": "nova-api", + "novaComputeImage": "nova-compute", + "novaConductorImage": "nova-conductor", + "novaNovncImage": "nova-novncproxy", + "novaSchedulerImage": "nova-scheduler", + "octaviaAPIImage": "octavia-api", + "octaviaHealthmanagerImage": "octavia-health-manager", + "octaviaHousekeepingImage": "octavia-housekeeping", + "octaviaWorkerImage": "octavia-worker", + "openstackClientImage": "openstackclient", + "ovnControllerImage": "ovn-controller", + "ovnControllerOvsImage": "ovn-base", + "ovnNbDbclusterImage": "ovn-nb-db-server", + "ovnNorthdImage": "ovn-northd", + "ovnSbDbclusterImage": "ovn-sb-db-server", + "placementAPIImage": "placement-api", + "rabbitmqImage": "rabbitmq", + "swiftAccountImage": "swift-account", + "swiftContainerImage": "swift-container", + "swiftObjectImage": "swift-object", + "swiftProxyImage": "swift-proxy-server", + "testTempestImage": "tempest-all", +} + + +def _effective(per_image_val, module_val): + """Return the per-image value when explicitly set (including ""), otherwise the module-level default.""" + return per_image_val if per_image_val is not None else module_val + + +def _build_url(registry, org, prefix, suffix, tag): + name = "{}-{}".format(prefix, suffix) if prefix else suffix + return "{}/{}/{}:{}".format(registry, org, name, tag) + + +def _resolve_image(spec, mod_registry, mod_org, mod_prefix, mod_tag, module=None): + """Resolve the image URL for one spec dict. + + Resolution order: + 1. full_registry -> returned as-is. + 2. container_suffix -> URL built from effective registry/org/prefix/suffix/tag. + 3. Partial overrides only -> suffix looked up from _OPENSTACK_SUFFIXES by + field name, URL rebuilt with the overridden fields substituted in. + Returns None when no URL can be resolved. + """ + if spec.get("full_registry"): + return spec["full_registry"] + + reg = _effective(spec.get("registry"), mod_registry) + org = _effective(spec.get("org"), mod_org) + prefix = _effective(spec.get("name_prefix"), mod_prefix) + tag = _effective(spec.get("tag"), mod_tag) + suffix = spec.get("container_suffix") + + if not suffix: + suffix = _OPENSTACK_SUFFIXES.get(spec["name"]) + if not suffix: + if module: + module.fail_json( + msg=( + "images entry '{}': no container_suffix given and the field " + "name is not in the standard OpenStack image set. " + "Provide container_suffix or full_registry." + ).format(spec["name"]) + ) + return None + + return _build_url(reg, org, prefix, suffix, tag) + + +def _resolve_backend( + backend, parent_url, mod_registry, mod_org, mod_prefix, mod_tag, module=None +): + """Return (backend_name, image_url) for one item in a backends list.""" + if isinstance(backend, str): + return backend, parent_url + name = backend.get("name") + if not name: + if module: + module.fail_json(msg="backends entry is a dict but has no 'name' key") + return None, parent_url + url = _resolve_image(backend, mod_registry, mod_org, mod_prefix, mod_tag, module) + return name, (url if url is not None else parent_url) + + +def _build_openstack_images(registry, org, prefix, tag): + return { + field: _build_url(registry, org, prefix, suffix, tag) + for field, suffix in _OPENSTACK_SUFFIXES.items() + } + + +def _build_cr(params, module=None): + mod_registry = params["registry"] + mod_org = params["org"] + mod_prefix = params["name_prefix"] + mod_tag = params["tag"] + + images = {} + + if params["include_openstack"]: + images.update( + _build_openstack_images(mod_registry, mod_org, mod_prefix, mod_tag) + ) + + for spec in params.get("images") or []: + name = spec["name"] + backends = spec.get("backends") + parent_url = _resolve_image( + spec, mod_registry, mod_org, mod_prefix, mod_tag, module + ) + + if backends is not None: + backend_dict = {} + for backend in backends: + bname, burl = _resolve_backend( + backend, + parent_url, + mod_registry, + mod_org, + mod_prefix, + mod_tag, + module, + ) + backend_dict[bname] = burl + images[name] = backend_dict + elif parent_url is not None: + images[name] = parent_url + + return { + "apiVersion": "core.openstack.org/v1beta1", + "kind": "OpenStackVersion", + "metadata": { + "name": params["metadata_name"], + "namespace": params["namespace"], + }, + "spec": {"customContainerImages": images}, + } + + +def _needs_module_registry(params): + """Return True when module-level registry/org/tag may be needed as fallbacks.""" + if params["include_openstack"]: + return True + for spec in params.get("images") or []: + if not spec.get("full_registry"): + if not (spec.get("registry") and spec.get("org") and spec.get("tag")): + return True + for backend in spec.get("backends") or []: + if isinstance(backend, dict) and not backend.get("full_registry"): + if not ( + backend.get("registry") + and backend.get("org") + and backend.get("tag") + ): + return True + return False + + +def _run_oc(module, args, kubeconfig): + oc_bin = module.get_bin_path("oc", required=True) + environ_update = {"KUBECONFIG": kubeconfig} if kubeconfig else None + rc, out, err = module.run_command([oc_bin] + args, environ_update=environ_update) + return rc, out, err + + +def run_module(): + module_args = dict( + state=dict(type="str", choices=["present", "absent"], default="present"), + apply=dict(type="bool", default=False), + namespace=dict(type="str", default="openstack"), + metadata_name=dict(type="str", default="controlplane"), + dest_path=dict(type="path", required=True), + registry=dict(type="str", default=None), + org=dict(type="str", default=None), + tag=dict(type="str", default=None), + name_prefix=dict(type="str", default="openstack"), + include_openstack=dict(type="bool", default=False), + images=dict( + type="list", + elements="dict", + default=[], + options=dict( + name=dict(type="str", required=True), + full_registry=dict(type="str", default=None), + container_suffix=dict(type="str", default=None), + registry=dict(type="str", default=None), + org=dict(type="str", default=None), + name_prefix=dict(type="str", default=None), + tag=dict(type="str", default=None), + backends=dict(type="list", elements="raw", default=None), + ), + ), + kubeconfig=dict(type="path", default=None), + ) + + module = AnsibleModule(argument_spec=module_args, supports_check_mode=True) + + if not HAS_YAML: + module.fail_json(msg="PyYAML is required for this module.") + + state = module.params["state"] + dest_path = module.params["dest_path"] + kubeconfig = module.params["kubeconfig"] + result = dict(changed=False, dest_path=dest_path) + + if state == "absent": + if not os.path.exists(dest_path): + module.exit_json(**result) + if not module.check_mode: + if kubeconfig and module.params["apply"]: + rc, out, err = _run_oc(module, ["delete", "-f", dest_path], kubeconfig) + if rc != 0: + module.fail_json( + msg="oc delete failed", rc=rc, stdout=out, stderr=err + ) + os.remove(dest_path) + result["changed"] = True + module.exit_json(**result) + + # state == "present" + if _needs_module_registry(module.params): + for param in ("registry", "org", "tag"): + if not module.params.get(param): + module.fail_json( + msg=( + "'{}' is required as a module-level default when " + "include_openstack=true or any images entry does not " + "provide its own value for that field" + ).format(param) + ) + + for spec in module.params.get("images") or []: + if spec.get("full_registry") and spec.get("container_suffix"): + module.fail_json( + msg="images entry '{}': full_registry and container_suffix are mutually exclusive".format( + spec["name"] + ) + ) + + cr = _build_cr(module.params, module) + cr_yaml = yaml.dump(cr, default_flow_style=False, sort_keys=False) + + existing = None + if os.path.exists(dest_path): + with open(dest_path, "r") as fh: + existing = fh.read() + + if cr_yaml != existing: + result["changed"] = True + if not module.check_mode: + dest_dir = os.path.dirname(dest_path) + if dest_dir: + os.makedirs(dest_dir, exist_ok=True) + with open(dest_path, "w") as fh: + fh.write(cr_yaml) + + if module.params["apply"] and not module.check_mode: + rc, out, err = _run_oc(module, ["apply", "-f", dest_path], kubeconfig) + if rc != 0: + module.fail_json(msg="oc apply failed", rc=rc, stdout=out, stderr=err) + result["changed"] = True + + module.exit_json(**result) + + +if __name__ == "__main__": + run_module() diff --git a/roles/cifmw_setup/tasks/deploy_architecture.yml b/roles/cifmw_setup/tasks/deploy_architecture.yml index afde76814..22301852a 100644 --- a/roles/cifmw_setup/tasks/deploy_architecture.yml +++ b/roles/cifmw_setup/tasks/deploy_architecture.yml @@ -209,13 +209,53 @@ - operator - edpm_bootstrap -- name: Update containers in deployed OSP operators - vars: - cifmw_update_containers_metadata: controlplane - ansible.builtin.include_role: - name: update_containers +- name: Set containers for deployed OSP operators + cifmw.general.set_containers: + dest_path: >- + {{ + cifmw_set_containers_dest_path | default( + (cifmw_basedir, 'artifacts', 'manifests', 'set_containers.yml') | + ansible.builtin.path_join + ) + }} + # TODO: remove the default() fallbacks below once all callers in ci-framework-jobs + # are migrated from cifmw_update_containers_* to cifmw_set_containers_*. + # Scenario-specific variables that also require explicit per-scenario migration: + # cifmw_update_containers_cindervolumes -> cifmw_set_containers_images (backends) + # cifmw_update_containers_manilashares -> cifmw_set_containers_images (backends) + # cifmw_update_containers_watcher -> cifmw_set_containers_extra_images + # cifmw_update_containers_edpm_image_url -> cifmw_set_containers_images (name_prefix: "") + # cifmw_update_containers_ipa_image_url -> cifmw_set_containers_images (name_prefix: "") + registry: "{{ cifmw_set_containers_registry | default(cifmw_update_containers_registry | default(omit)) }}" + org: "{{ cifmw_set_containers_org | default(cifmw_update_containers_org | default(omit)) }}" + tag: "{{ cifmw_set_containers_tag | default(cifmw_update_containers_tag | default(omit)) }}" + include_openstack: >- + {{ + cifmw_set_containers_include_openstack | + default(cifmw_update_containers_openstack | default(false)) | bool + }} + apply: >- + {{ + cifmw_set_containers_apply | + default(cifmw_update_containers | default(false)) | bool + }} + images: >- + {{ + (cifmw_set_containers_images | default([])) + + (cifmw_set_containers_extra_images | default([])) + + ( + [{'name': 'osContainerImage', 'full_registry': cifmw_update_containers_edpm_image_url}] + if cifmw_update_containers_edpm_image_url is defined else [] + ) + + ( + [{'name': 'ironicPythonAgentImage', 'full_registry': cifmw_update_containers_ipa_image_url}] + if cifmw_update_containers_ipa_image_url is defined else [] + ) + # TODO: remove the edpm_image_url and ipa_image_url fallbacks above once all + # callers are migrated to cifmw_set_containers_images with name_prefix: "". + }} tags: - - update_containers + - set_containers - edpm_bootstrap when: cifmw_ci_gen_kustomize_values_deployment_version is not defined diff --git a/tests/unit/modules/test_set_containers.py b/tests/unit/modules/test_set_containers.py new file mode 100644 index 000000000..795f29b39 --- /dev/null +++ b/tests/unit/modules/test_set_containers.py @@ -0,0 +1,428 @@ +# Copyright Red Hat, Inc. +# Apache License Version 2.0 + +from __future__ import absolute_import, division, print_function + +import unittest +from unittest.mock import MagicMock, mock_open, patch + +import yaml + +from ansible_collections.cifmw.general.tests.unit.utils import ( + AnsibleExitJson, + AnsibleFailJson, + ModuleBaseTestCase, + set_module_args, +) +from ansible_collections.cifmw.general.plugins.modules import set_containers +from ansible_collections.cifmw.general.plugins.modules.set_containers import ( + _build_cr, + _build_url, + _resolve_backend, + _resolve_image, +) + +_MOD = "ansible_collections.cifmw.general.plugins.modules.set_containers" + +REG = "quay.io" +ORG = "openstack-k8s-operators" +PREFIX = "openstack" +TAG = "current-podified" + + +class TestBuildUrl(unittest.TestCase): + def test_with_prefix(self): + self.assertEqual( + _build_url(REG, ORG, PREFIX, "nova-api", TAG), + "quay.io/openstack-k8s-operators/openstack-nova-api:current-podified", + ) + + def test_empty_prefix_no_dash(self): + url = _build_url(REG, ORG, "", "ironic-python-agent", TAG) + self.assertEqual( + url, + "quay.io/openstack-k8s-operators/ironic-python-agent:current-podified", + ) + self.assertNotIn("/-", url) + + +class TestResolveImage(unittest.TestCase): + def test_full_registry_returned_as_is(self): + spec = dict(name="novaAPIImage", full_registry="custom.io/ns/nova:v1") + self.assertEqual( + _resolve_image(spec, REG, ORG, PREFIX, TAG), + "custom.io/ns/nova:v1", + ) + + def test_container_suffix_builds_url(self): + spec = dict(name="novaAPIImage", container_suffix="nova-api") + self.assertEqual( + _resolve_image(spec, REG, ORG, PREFIX, TAG), + "quay.io/openstack-k8s-operators/openstack-nova-api:current-podified", + ) + + def test_suffix_lookup_from_table(self): + spec = dict(name="keystoneAPIImage") + self.assertEqual( + _resolve_image(spec, REG, ORG, PREFIX, TAG), + "quay.io/openstack-k8s-operators/openstack-keystone:current-podified", + ) + + def test_partial_override_tag_only(self): + spec = dict(name="barbicanAPIImage", tag="my-hsm-tag") + self.assertEqual( + _resolve_image(spec, REG, ORG, PREFIX, TAG), + "quay.io/openstack-k8s-operators/openstack-barbican-api:my-hsm-tag", + ) + + def test_per_image_registry_org_prefix_tag(self): + spec = dict( + name="octaviaAPIImage", + registry="mirror.example.com", + org="myorg", + name_prefix="rhosp", + tag="18.0", + ) + self.assertEqual( + _resolve_image(spec, REG, ORG, PREFIX, TAG), + "mirror.example.com/myorg/rhosp-octavia-api:18.0", + ) + + def test_empty_name_prefix_no_dash(self): + spec = dict( + name="osContainerImage", + container_suffix="edpm-hardened-uefi", + name_prefix="", + ) + url = _resolve_image(spec, REG, ORG, PREFIX, TAG) + self.assertEqual( + url, + "quay.io/openstack-k8s-operators/edpm-hardened-uefi:current-podified", + ) + self.assertNotIn("/-", url) + + def test_unknown_name_without_suffix_returns_none(self): + spec = dict(name="unknownCustomImage") + self.assertIsNone(_resolve_image(spec, REG, ORG, PREFIX, TAG)) + + def test_unknown_name_without_suffix_calls_fail_json(self): + module = MagicMock() + _resolve_image( + dict(name="unknownCustomImage"), REG, ORG, PREFIX, TAG, module=module + ) + module.fail_json.assert_called_once() + self.assertIn("unknownCustomImage", module.fail_json.call_args[1]["msg"]) + + +class TestResolveBackend(unittest.TestCase): + _parent = "quay.io/openstack-k8s-operators/openstack-cinder-volume:current-podified" + + def test_string_backend_uses_parent_url(self): + name, url = _resolve_backend("default", self._parent, REG, ORG, PREFIX, TAG) + self.assertEqual(name, "default") + self.assertEqual(url, self._parent) + + def test_dict_backend_full_registry(self): + backend = dict( + name="netapp", full_registry="registry.example.com/netapp/cinder:24.1" + ) + name, url = _resolve_backend(backend, self._parent, REG, ORG, PREFIX, TAG) + self.assertEqual(name, "netapp") + self.assertEqual(url, "registry.example.com/netapp/cinder:24.1") + + def test_dict_backend_container_suffix_with_tag_override(self): + backend = dict(name="lvm", container_suffix="cinder-volume", tag="v2") + name, url = _resolve_backend(backend, self._parent, REG, ORG, PREFIX, TAG) + self.assertEqual(name, "lvm") + self.assertEqual( + url, "quay.io/openstack-k8s-operators/openstack-cinder-volume:v2" + ) + + def test_dict_backend_unresolvable_falls_back_to_parent(self): + backend = dict(name="unknown-backend") + name, url = _resolve_backend(backend, self._parent, REG, ORG, PREFIX, TAG) + self.assertEqual(name, "unknown-backend") + self.assertEqual(url, self._parent) + + def test_dict_backend_missing_name_without_module_returns_none(self): + backend = dict(container_suffix="cinder-volume") + name, url = _resolve_backend(backend, self._parent, REG, ORG, PREFIX, TAG) + self.assertIsNone(name) + self.assertEqual(url, self._parent) + + def test_dict_backend_missing_name_with_module_calls_fail_json(self): + mock_module = MagicMock() + backend = dict(container_suffix="cinder-volume") + _resolve_backend(backend, self._parent, REG, ORG, PREFIX, TAG, mock_module) + mock_module.fail_json.assert_called_once() + self.assertIn("name", mock_module.fail_json.call_args[1]["msg"]) + + +class TestBuildCr(unittest.TestCase): + def _params(self, **kwargs): + p = dict( + state="present", + apply=False, + namespace="openstack", + metadata_name="controlplane", + dest_path="/tmp/cr.yml", + registry=REG, + org=ORG, + tag=TAG, + name_prefix=PREFIX, + include_openstack=False, + images=[], + kubeconfig=None, + ) + p.update(kwargs) + return p + + def test_cr_structure(self): + cr = _build_cr(self._params()) + self.assertEqual(cr["apiVersion"], "core.openstack.org/v1beta1") + self.assertEqual(cr["kind"], "OpenStackVersion") + self.assertEqual(cr["metadata"]["name"], "controlplane") + self.assertEqual(cr["metadata"]["namespace"], "openstack") + + def test_include_openstack_populates_all_standard_images(self): + cr = _build_cr(self._params(include_openstack=True)) + images = cr["spec"]["customContainerImages"] + self.assertEqual( + images["novaAPIImage"], + "quay.io/openstack-k8s-operators/openstack-nova-api:current-podified", + ) + self.assertEqual( + images["keystoneAPIImage"], + "quay.io/openstack-k8s-operators/openstack-keystone:current-podified", + ) + self.assertIn("swiftProxyImage", images) + self.assertIn("barbicanAPIImage", images) + + def test_images_list_without_include_openstack(self): + cr = _build_cr( + self._params( + images=[ + dict(name="novaAPIImage", container_suffix="nova-api"), + ] + ) + ) + images = cr["spec"]["customContainerImages"] + self.assertEqual(list(images.keys()), ["novaAPIImage"]) + self.assertEqual( + images["novaAPIImage"], + "quay.io/openstack-k8s-operators/openstack-nova-api:current-podified", + ) + + def test_images_list_overrides_include_openstack(self): + cr = _build_cr( + self._params( + include_openstack=True, + images=[dict(name="novaAPIImage", tag="my-override")], + ) + ) + images = cr["spec"]["customContainerImages"] + self.assertEqual( + images["novaAPIImage"], + "quay.io/openstack-k8s-operators/openstack-nova-api:my-override", + ) + self.assertEqual( + images["keystoneAPIImage"], + "quay.io/openstack-k8s-operators/openstack-keystone:current-podified", + ) + + def test_backends_produce_nested_dict(self): + cr = _build_cr( + self._params( + images=[ + dict( + name="cinderVolumeImages", + container_suffix="cinder-volume", + backends=[ + "lvm", + dict( + name="netapp", + full_registry="registry.example.com/netapp/cinder:1.0", + ), + ], + ) + ] + ) + ) + cinder = cr["spec"]["customContainerImages"]["cinderVolumeImages"] + self.assertIsInstance(cinder, dict) + self.assertEqual( + cinder["lvm"], + "quay.io/openstack-k8s-operators/openstack-cinder-volume:current-podified", + ) + self.assertEqual(cinder["netapp"], "registry.example.com/netapp/cinder:1.0") + + def test_empty_name_prefix(self): + cr = _build_cr( + self._params( + images=[ + dict( + name="osContainerImage", + container_suffix="edpm-hardened-uefi", + name_prefix="", + ), + ] + ) + ) + url = cr["spec"]["customContainerImages"]["osContainerImage"] + self.assertEqual( + url, + "quay.io/openstack-k8s-operators/edpm-hardened-uefi:current-podified", + ) + + def test_custom_namespace_and_name(self): + cr = _build_cr(self._params(namespace="custom-ns", metadata_name="myplane")) + self.assertEqual(cr["metadata"]["namespace"], "custom-ns") + self.assertEqual(cr["metadata"]["name"], "myplane") + + +class TestRunModule(ModuleBaseTestCase): + def _args(self, **kwargs): + args = dict(dest_path="/tmp/set_containers.yml", registry=REG, org=ORG, tag=TAG) + args.update(kwargs) + return args + + def test_present_writes_new_file(self): + set_module_args(self._args()) + with patch(_MOD + ".os.path.exists", return_value=False), patch( + _MOD + ".os.makedirs" + ), patch("builtins.open", mock_open()): + with self.assertRaises(AnsibleExitJson) as cm: + set_containers.run_module() + self.assertTrue(cm.exception.args[0]["changed"]) + + def test_present_no_change_when_file_identical(self): + params = dict( + state="present", + apply=False, + namespace="openstack", + metadata_name="controlplane", + dest_path="/tmp/set_containers.yml", + registry=REG, + org=ORG, + tag=TAG, + name_prefix="openstack", + include_openstack=False, + images=[], + kubeconfig=None, + ) + existing = yaml.dump( + _build_cr(params), default_flow_style=False, sort_keys=False + ) + + set_module_args(self._args()) + with patch(_MOD + ".os.path.exists", return_value=True), patch( + "builtins.open", mock_open(read_data=existing) + ): + with self.assertRaises(AnsibleExitJson) as cm: + set_containers.run_module() + self.assertFalse(cm.exception.args[0]["changed"]) + + def test_present_apply_calls_oc(self): + set_module_args(self._args(apply=True)) + with patch(_MOD + ".os.path.exists", return_value=False), patch( + _MOD + ".os.makedirs" + ), patch("builtins.open", mock_open()), patch( + _MOD + "._run_oc", return_value=(0, "", "") + ) as mock_oc: + with self.assertRaises(AnsibleExitJson) as cm: + set_containers.run_module() + mock_oc.assert_called_once() + self.assertEqual(mock_oc.call_args[0][1][0], "apply") + self.assertTrue(cm.exception.args[0]["changed"]) + + def test_present_apply_oc_failure_fails(self): + set_module_args(self._args(apply=True)) + with patch(_MOD + ".os.path.exists", return_value=False), patch( + _MOD + ".os.makedirs" + ), patch("builtins.open", mock_open()), patch( + _MOD + "._run_oc", return_value=(1, "", "oc: command not found") + ): + with self.assertRaises(AnsibleFailJson) as cm: + set_containers.run_module() + self.assertIn("oc apply failed", cm.exception.args[0]["msg"]) + + def test_absent_file_missing_is_noop(self): + set_module_args(self._args(state="absent")) + with patch(_MOD + ".os.path.exists", return_value=False): + with self.assertRaises(AnsibleExitJson) as cm: + set_containers.run_module() + self.assertFalse(cm.exception.args[0]["changed"]) + + def test_absent_removes_existing_file(self): + set_module_args(self._args(state="absent")) + with patch(_MOD + ".os.path.exists", return_value=True), patch( + _MOD + ".os.remove" + ) as mock_rm: + with self.assertRaises(AnsibleExitJson) as cm: + set_containers.run_module() + mock_rm.assert_called_once_with("/tmp/set_containers.yml") + self.assertTrue(cm.exception.args[0]["changed"]) + + def test_absent_with_kubeconfig_and_apply_calls_oc_delete(self): + set_module_args( + self._args(state="absent", apply=True, kubeconfig="/home/zuul/.kube/config") + ) + with patch(_MOD + ".os.path.exists", return_value=True), patch( + _MOD + ".os.remove" + ), patch(_MOD + "._run_oc", return_value=(0, "", "")) as mock_oc: + with self.assertRaises(AnsibleExitJson) as cm: + set_containers.run_module() + mock_oc.assert_called_once() + self.assertEqual(mock_oc.call_args[0][1][0], "delete") + self.assertTrue(cm.exception.args[0]["changed"]) + + def test_absent_with_kubeconfig_but_no_apply_skips_oc_delete(self): + set_module_args( + self._args( + state="absent", apply=False, kubeconfig="/home/zuul/.kube/config" + ) + ) + with patch(_MOD + ".os.path.exists", return_value=True), patch( + _MOD + ".os.remove" + ), patch(_MOD + "._run_oc", return_value=(0, "", "")) as mock_oc: + with self.assertRaises(AnsibleExitJson) as cm: + set_containers.run_module() + mock_oc.assert_not_called() + self.assertTrue(cm.exception.args[0]["changed"]) + + def test_validation_missing_registry_fails(self): + set_module_args(dict(dest_path="/tmp/cr.yml", include_openstack=True)) + with self.assertRaises(AnsibleFailJson) as cm: + set_containers.run_module() + self.assertIn("registry", cm.exception.args[0]["msg"]) + + def test_validation_full_registry_and_suffix_exclusive(self): + set_module_args( + self._args( + images=[ + dict( + name="novaAPIImage", + full_registry="quay.io/ns/nova:v1", + container_suffix="nova-api", + ) + ] + ) + ) + with patch(_MOD + ".os.path.exists", return_value=False): + with self.assertRaises(AnsibleFailJson) as cm: + set_containers.run_module() + self.assertIn("mutually exclusive", cm.exception.args[0]["msg"]) + + def test_include_openstack_with_images_override(self): + set_module_args( + self._args( + include_openstack=True, + images=[dict(name="novaAPIImage", tag="special-tag")], + ) + ) + with patch(_MOD + ".os.path.exists", return_value=False), patch( + _MOD + ".os.makedirs" + ), patch("builtins.open", mock_open()): + with self.assertRaises(AnsibleExitJson) as cm: + set_containers.run_module() + self.assertTrue(cm.exception.args[0]["changed"])