diff --git a/cloud/etc/deploy-microovn/main.tf b/cloud/etc/deploy-microovn/main.tf index 9c2fb82e7..42b5f69a4 100644 --- a/cloud/etc/deploy-microovn/main.tf +++ b/cloud/etc/deploy-microovn/main.tf @@ -18,6 +18,18 @@ data "juju_model" "machine_model" { uuid = var.machine_model_uuid } +locals { + microovn_machine_ids_by_architecture = merge( + { + amd64 = var.microovn_machine_ids + arm64 = var.microovn_arm64_machine_ids + }, + var.microovn_machine_ids_by_architecture, + ) + microovn_machine_ids = lookup(local.microovn_machine_ids_by_architecture, "amd64", []) + microovn_arm64_machine_ids = lookup(local.microovn_machine_ids_by_architecture, "arm64", []) +} + resource "juju_application" "openstack-network-agents" { name = "openstack-network-agents" model_uuid = data.juju_model.machine_model.uuid @@ -36,6 +48,25 @@ resource "juju_application" "openstack-network-agents" { endpoint_bindings = var.openstack_network_agents_endpoint_bindings } +resource "juju_application" "openstack-network-agents-arm64" { + count = length(local.microovn_arm64_machine_ids) > 0 ? 1 : 0 + name = "openstack-network-agents-arm64" + model_uuid = data.juju_model.machine_model.uuid + + charm { + name = "openstack-network-agents" + channel = var.charm_openstack_network_agents_channel + base = "ubuntu@24.04" + revision = var.charm_openstack_network_agents_arm64_revision + } + + config = merge({ + use-data-binding = true + }, var.charm_openstack_network_agents_config) + + endpoint_bindings = var.openstack_network_agents_endpoint_bindings +} + resource "juju_application" "microcluster-token-distributor" { name = "microcluster-token-distributor" model_uuid = data.juju_model.machine_model.uuid @@ -55,8 +86,8 @@ resource "juju_application" "microcluster-token-distributor" { resource "juju_application" "microovn" { name = "microovn" model_uuid = data.juju_model.machine_model.uuid - machines = length(var.microovn_machine_ids) == 0 ? null : toset(var.microovn_machine_ids) - units = length(var.microovn_machine_ids) == 0 ? 1 : null + machines = length(local.microovn_machine_ids) == 0 ? null : toset(local.microovn_machine_ids) + units = length(local.microovn_machine_ids) == 0 ? 0 : null charm { name = "microovn" @@ -70,6 +101,25 @@ resource "juju_application" "microovn" { endpoint_bindings = var.endpoint_bindings } +resource "juju_application" "microovn_arm64" { + count = length(local.microovn_arm64_machine_ids) > 0 ? 1 : 0 + name = "microovn-arm64" + model_uuid = data.juju_model.machine_model.uuid + machines = length(local.microovn_arm64_machine_ids) == 0 ? null : toset(local.microovn_arm64_machine_ids) + constraints = "arch=arm64" + + charm { + name = "microovn" + channel = var.charm_microovn_channel + base = "ubuntu@24.04" + # Arm64 publishes a different revision than amd64 on the same channel. + } + + config = var.charm_microovn_config + + endpoint_bindings = var.endpoint_bindings +} + resource "juju_application" "sunbeam-ovn-proxy" { name = "sunbeam-ovn-proxy" model_uuid = data.juju_model.machine_model.uuid @@ -160,8 +210,109 @@ resource "juju_integration" "role-distributor-microovn" { } } +resource "juju_integration" "microovn_arm64_microcluster_token_distributor" { + count = length(local.microovn_arm64_machine_ids) > 0 ? 1 : 0 + model_uuid = data.juju_model.machine_model.uuid + + application { + name = juju_application.microovn_arm64[0].name + endpoint = "cluster" + } + + application { + name = juju_application.microcluster-token-distributor.name + endpoint = "microcluster-cluster" + } +} + +resource "juju_integration" "microovn_arm64_openstack_network_agents" { + count = length(local.microovn_arm64_machine_ids) > 0 ? 1 : 0 + model_uuid = data.juju_model.machine_model.uuid + + application { + name = juju_application.microovn_arm64[0].name + endpoint = "juju-info" + } + + application { + name = juju_application.openstack-network-agents-arm64[0].name + endpoint = "juju-info" + } +} + +resource "juju_integration" "role-distributor-microovn-arm64" { + count = ( + var.role_distributor_application_name != null + && length(local.microovn_arm64_machine_ids) > 0 + ) ? 1 : 0 + model_uuid = data.juju_model.machine_model.uuid + + application { + name = var.role_distributor_application_name + endpoint = "role-assignment" + } + + application { + name = juju_application.microovn_arm64[0].name + endpoint = "role-assignment" + } +} + +resource "juju_integration" "microovn_arm64_certs" { + count = (var.ca-offer-url != null && length(local.microovn_arm64_machine_ids) > 0) ? 1 : 0 + model_uuid = data.juju_model.machine_model.uuid + + application { + name = juju_application.microovn_arm64[0].name + endpoint = "certificates" + } + + application { + offer_url = var.ca-offer-url + } +} + +resource "juju_integration" "microovn_arm64_ovsdb_cms" { + count = (var.ovn-relay-offer-url != null && length(local.microovn_arm64_machine_ids) > 0) ? 1 : 0 + model_uuid = data.juju_model.machine_model.uuid + + application { + name = juju_application.microovn_arm64[0].name + endpoint = "ovsdb-external" + } + + application { + offer_url = var.ovn-relay-offer-url + } +} + +resource "juju_integration" "microovn_arm64_to_ovn_proxy" { + count = ( + length(local.microovn_arm64_machine_ids) > 0 + && length(local.microovn_machine_ids) == 0 + && length(juju_application.sunbeam-ovn-proxy.*.name) > 0 + ) ? 1 : 0 + model_uuid = data.juju_model.machine_model.uuid + + application { + name = juju_application.microovn_arm64[0].name + endpoint = "ovsdb" + } + + application { + name = juju_application.sunbeam-ovn-proxy[0].name + endpoint = "ovsdb" + } +} + resource "juju_integration" "microovn-to-ovn-proxy" { - count = length(juju_application.sunbeam-ovn-proxy.*.name) > 0 ? 1 : 0 + count = ( + length(juju_application.sunbeam-ovn-proxy.*.name) > 0 + && ( + length(local.microovn_machine_ids) > 0 + || length(local.microovn_arm64_machine_ids) == 0 + ) + ) ? 1 : 0 model_uuid = data.juju_model.machine_model.uuid application { @@ -186,6 +337,10 @@ output "microovn-application-name" { value = juju_application.microovn.name } +output "microovn-arm64-application-name" { + value = try(juju_application.microovn_arm64[0].name, null) +} + output "ovsdb-cms-offer" { value = try(juju_offer.ovsdb-cms[0].url, null) } diff --git a/cloud/etc/deploy-microovn/variables.tf b/cloud/etc/deploy-microovn/variables.tf index 42be63915..66387158d 100644 --- a/cloud/etc/deploy-microovn/variables.tf +++ b/cloud/etc/deploy-microovn/variables.tf @@ -30,6 +30,12 @@ variable "charm_openstack_network_agents_revision" { default = null } +variable "charm_openstack_network_agents_arm64_revision" { + description = "Operator channel revision for arm64 openstack-network-agents deployment" + type = number + default = null +} + variable "charm_openstack_network_agents_config" { description = "Operator config for openstack-network-agents deployment" type = map(string) @@ -82,11 +88,23 @@ variable "charm_sunbeam_ovn_proxy_config" { } variable "microovn_machine_ids" { - description = "List of machine ids to include" + description = "List of amd64 machine ids to include" + type = list(string) + default = [] +} + +variable "microovn_arm64_machine_ids" { + description = "List of arm64 machine ids to include (e.g. DPU network nodes)" type = list(string) default = [] } +variable "microovn_machine_ids_by_architecture" { + description = "MicroOVN machine ids grouped by architecture" + type = map(list(string)) + default = {} +} + variable "token_distributor_machine_ids" { description = "List of machine ids to include" type = list(string) diff --git a/sunbeam-python/pyproject.toml b/sunbeam-python/pyproject.toml index cbe8bbaa4..643d014a8 100644 --- a/sunbeam-python/pyproject.toml +++ b/sunbeam-python/pyproject.toml @@ -791,6 +791,7 @@ ignore = [ "S606", # same as above "S605", # process with a shell - possible security issue; this should be investigated by the project for possible exploitation "S607", #start process with a partial path - this should be a project level decision + "D421", # property docstrings commonly use verb-style wording in this codebase ] extend-ignore = ["E203"] diff --git a/sunbeam-python/sunbeam/core/ovn.py b/sunbeam-python/sunbeam/core/ovn.py index 9cc3fbd9b..baeee31b6 100644 --- a/sunbeam-python/sunbeam/core/ovn.py +++ b/sunbeam-python/sunbeam/core/ovn.py @@ -26,6 +26,8 @@ class OvnConfig(pydantic.BaseModel): DEFAULT_PROVIDER = OvnProvider.OVN_K8S +DEFAULT_ARCHITECTURE = "amd64" +ARM64_ARCHITECTURE = "arm64" def load_provider_config(client: Client) -> OvnConfig: @@ -106,22 +108,67 @@ def is_microovn_necessary_maas( else: return nb_network > 0 - def get_machines(self) -> list[str]: - """Get the list of machine IDs for the OVN provider. - - :return: list of machine IDs as strings - """ + def _list_microovn_nodes(self) -> list[dict]: + """Collect cluster nodes that should run MicroOVN.""" nodes = self.client.cluster.list_nodes_by_role("network") if self.get_provider() == OvnProvider.MICROOVN: nodes += self.client.cluster.list_nodes_by_role("compute") nodes += self.client.cluster.list_nodes_by_role("control") - machine_ids = { - str(node.get("machineid")) - for node in nodes - if node.get("machineid") not in (-1, None) - } + return nodes + + def get_token_distributor_machines( + self, provider: OvnProvider | None = None + ) -> list[str]: + """Get machine IDs for MicroOVN helper applications.""" + provider = provider or self.get_provider() + roles = [Role.NETWORK] + if provider == OvnProvider.MICROOVN: + roles = [Role.CONTROL, Role.COMPUTE, Role.NETWORK] + + for role in roles: + machine_ids: set[str] = set() + for node in self.client.cluster.list_nodes_by_role(role.name.lower()): + machineid = node.get("machineid") + if machineid in (-1, None): + continue + arch = node.get("arch") or DEFAULT_ARCHITECTURE + if arch == DEFAULT_ARCHITECTURE: + machine_ids.add(str(machineid)) + if machine_ids: + return sorted(machine_ids) + + return [] + + def get_machines(self, architecture: str | None = None) -> list[str]: + """Get machine IDs for MicroOVN, optionally filtered by architecture. + + :param architecture: if set, only return machines with this arch + :return: list of machine IDs as strings + """ + machine_ids: set[str] = set() + for node in self._list_microovn_nodes(): + machineid = node.get("machineid") + if machineid in (-1, None): + continue + arch = node.get("arch") or DEFAULT_ARCHITECTURE + if architecture is None or arch == architecture: + machine_ids.add(str(machineid)) return sorted(machine_ids) + def get_machines_by_architecture(self) -> dict[str, list[str]]: + """Get MicroOVN machine IDs grouped by architecture.""" + machine_ids_by_arch: dict[str, set[str]] = {} + for node in self._list_microovn_nodes(): + machineid = node.get("machineid") + if machineid in (-1, None): + continue + arch = node.get("arch") or DEFAULT_ARCHITECTURE + machine_ids_by_arch.setdefault(arch, set()).add(str(machineid)) + return { + arch: sorted(machine_ids) + for arch, machine_ids in machine_ids_by_arch.items() + } + def get_control_plane_tfvars( self, deployment: Deployment, jhelper: JujuHelper ) -> dict: diff --git a/sunbeam-python/sunbeam/provider/maas/steps.py b/sunbeam-python/sunbeam/provider/maas/steps.py index 4ec239a5b..219d2523f 100644 --- a/sunbeam-python/sunbeam/provider/maas/steps.py +++ b/sunbeam-python/sunbeam/provider/maas/steps.py @@ -113,6 +113,22 @@ def _partition_nodes_by_dpu(nodes: list[dict]) -> tuple[list[dict], list[dict]]: return non_dpu, dpu +def _node_matches_juju_machine(node: dict, machine) -> bool: + """Return True if a clusterd node corresponds to a Juju machine. + + Custom DPU images may report a template hostname (e.g. packer-ubuntu) + while clusterd stores the MAAS hostname. Fall back to system ID and + Juju display name when hostnames differ. + """ + if node["name"] == machine.hostname: + return True + systemid = node.get("systemid") or "" + if systemid and systemid == machine.instance_id: + return True + display_name = getattr(machine, "display_name", None) + return bool(display_name and node["name"] == display_name) + + class AddMaasDeployment(BaseStep): """Perform various checks and add MAAS-backed deployment.""" @@ -1582,7 +1598,7 @@ def is_skip(self, context: StepContext) -> Result: nodes_to_deploy.remove(node) continue for id, machine in juju_machines.items(): - if node["name"] == machine.hostname: + if _node_matches_juju_machine(node, machine): if int(id) != node_machine_id and node_machine_id != -1: return Result( ResultType.FAILED, @@ -1666,7 +1682,7 @@ def _sync_updated_node_machine_ids(self) -> None: for node in self.nodes_to_update: LOG.debug("Updating machine %s in model %s", node["name"], self.model) for machine_id, machine in machines.items(): - if machine.hostname == node["name"]: + if _node_matches_juju_machine(node, machine): self.client.cluster.update_node_info( node["name"], machineid=int(machine_id) ) diff --git a/sunbeam-python/sunbeam/steps/microovn.py b/sunbeam-python/sunbeam/steps/microovn.py index ef687cd03..1a7cb4e31 100644 --- a/sunbeam-python/sunbeam/steps/microovn.py +++ b/sunbeam-python/sunbeam/steps/microovn.py @@ -39,12 +39,39 @@ CONFIG_KEY = "TerraformVarsMicroovnPlan" CONFIG_DISKS_KEY = "TerraformVarsMicroovn" APPLICATION = "microovn" +ARM64_APPLICATION = "microovn-arm64" MICROOVN_APP_TIMEOUT = 1200 MICROOVN_UNIT_TIMEOUT = 1200 AGENT_APP = "openstack-network-agents" ROLE_DISTRIBUTOR_APP = "role-distributor" +def _microovn_application_name(architecture: str) -> str: + """Return the MicroOVN application name for an architecture.""" + if architecture == ovn.DEFAULT_ARCHITECTURE: + return APPLICATION + return f"{APPLICATION}-{architecture}" + + +def _microovn_architecture_sort_key(architecture: str) -> tuple[bool, str]: + """Prefer the default architecture, then keep remaining apps deterministic.""" + return architecture != ovn.DEFAULT_ARCHITECTURE, architecture + + +def _microovn_applications_to_wait( + machines_by_architecture: dict[str, list[str]], +) -> list[str]: + """Return MicroOVN application names with machines assigned.""" + return [ + _microovn_application_name(architecture) + for architecture in sorted( + machines_by_architecture, + key=_microovn_architecture_sort_key, + ) + if machines_by_architecture[architecture] + ] + + def _role_distributor_application_name(jhelper: JujuHelper, model: str) -> str | None: """Return role-distributor application name when deployed.""" try: @@ -62,6 +89,35 @@ def _microovn_accepted_statuses(ovn_manager: ovn.OvnManager) -> list[str]: return statuses +def _openstack_network_agents_tfvars( + deployment: Deployment, + manifest: Manifest, +) -> dict[str, Any]: + """Return common openstack-network-agents Terraform variables.""" + tfvars: dict[str, Any] = { + "charm_openstack_network_agents_config": { + "snap-channel": versions.OPENSTACK_CHANNEL + }, + "openstack_network_agents_endpoint_bindings": [ + {"space": deployment.get_space(Networks.MANAGEMENT)}, + { + "endpoint": "data", + "space": deployment.get_space(Networks.DATA), + }, + ], + } + + agent_charm_manifest: CharmManifest | None = manifest.core.software.charms.get( + AGENT_APP + ) + if agent_charm_manifest and agent_charm_manifest.config: + tfvars["charm_openstack_network_agents_config"].update( + agent_charm_manifest.config + ) + + return tfvars + + class DeployMicroOVNApplicationStep(DeployMachineApplicationStep): """Deploy MicroOVN application using Terraform.""" @@ -111,10 +167,28 @@ def extra_tfvars(self) -> dict: offer: openstack_tf_output.get(offer) for offer in juju_offers } - machine_ids = self.ovn_manager.get_machines() - if machine_ids: - extra_tfvars["microovn_machine_ids"] = machine_ids - extra_tfvars["token_distributor_machine_ids"] = machine_ids[:1] + machines_by_arch = self.ovn_manager.get_machines_by_architecture() + extra_tfvars["microovn_machine_ids_by_architecture"] = machines_by_arch + distributor_ids = self.ovn_manager.get_token_distributor_machines( + ovn.OvnProvider.MICROOVN + ) + extra_tfvars["token_distributor_machine_ids"] = distributor_ids[:1] + + # Juju does not resolve per-arch revisions for subordinates, so pin the + # arm64 revision explicitly from the manifest-configured channel. + if machines_by_arch.get(ovn.ARM64_ARCHITECTURE): + agent_charm_manifest = self.manifest.core.software.charms.get(AGENT_APP) + channel = ( + agent_charm_manifest.channel + if agent_charm_manifest and agent_charm_manifest.channel + else versions.OPENSTACK_CHANNEL + ) + revisions = self.jhelper.get_available_charm_revisions(AGENT_APP, channel) + arm64_revision = revisions.get(ovn.ARM64_ARCHITECTURE) + if arm64_revision is not None: + extra_tfvars["charm_openstack_network_agents_arm64_revision"] = ( + arm64_revision + ) extra_tfvars.update( { @@ -137,32 +211,64 @@ def extra_tfvars(self) -> dict: "space": self.deployment.get_space(Networks.INTERNAL), }, ], - "charm_openstack_network_agents_config": { - "snap-channel": versions.OPENSTACK_CHANNEL - }, "role_distributor_application_name": ( _role_distributor_application_name(self.jhelper, self.model) ), - "openstack_network_agents_endpoint_bindings": [ - {"space": self.deployment.get_space(Networks.MANAGEMENT)}, - { - "endpoint": "data", - "space": self.deployment.get_space(Networks.DATA), - }, - ], } ) - - agent_charm_manifest: CharmManifest | None = ( - self.manifest.core.software.charms.get(AGENT_APP) + extra_tfvars.update( + _openstack_network_agents_tfvars(self.deployment, self.manifest) ) - if agent_charm_manifest and agent_charm_manifest.config: - extra_tfvars["charm_openstack_network_agents_config"].update( - agent_charm_manifest.config - ) return extra_tfvars + def _applications_to_wait(self) -> list[str]: + """Return Juju application names that should be ready after deploy.""" + machines_by_arch = self.ovn_manager.get_machines_by_architecture() + return _microovn_applications_to_wait(machines_by_arch) + + @tenacity.retry( + wait=tenacity.wait_fixed(60), + stop=tenacity.stop_after_delay(300), + retry=tenacity.retry_if_exception_type(TerraformStateLockedException), + retry_error_callback=convert_retry_failure_as_result, + ) + def run(self, context: StepContext) -> Result: + """Apply terraform and wait for MicroOVN applications.""" + try: + extra_tfvars = self.extra_tfvars() + extra_tfvars["machine_model_uuid"] = self.jhelper.get_model_uuid(self.model) + + self.tfhelper.update_tfvars_and_apply_tf( + self.client, + self.manifest, + tfvar_config=self.config, + override_tfvars=extra_tfvars, + tf_apply_extra_args=self.tf_apply_extra_args(), + reporter=context.reporter, + ) + except TerraformException as e: + return Result(ResultType.FAILED, str(e)) + + if not self.wait_for_readiness: + return Result(ResultType.COMPLETED) + + accepted_status = self.get_accepted_application_status() + timeout = self.get_application_timeout() + for application in self._applications_to_wait(): + try: + self.jhelper.wait_application_ready( + application, + self.model, + accepted_status=accepted_status, + timeout=timeout, + ) + except TimeoutError as e: + LOG.warning("Application %r is not ready: %r", application, e) + return Result(ResultType.FAILED, str(e)) + + return Result(ResultType.COMPLETED) + class ReapplyMicroOVNOptionalIntegrationsStep(DeployMicroOVNApplicationStep): """Reapply MicroOVN optional integrations using Terraform.""" @@ -174,9 +280,15 @@ def tf_apply_extra_args(self) -> list[str]: "-target=juju_integration.microovn-certs", "-target=juju_integration.microovn-ovsdb-cms", "-target=juju_integration.microovn-openstack-network-agents", + "-target=juju_integration.microovn_arm64_microcluster_token_distributor", + "-target=juju_integration.microovn_arm64_certs", + "-target=juju_integration.microovn_arm64_ovsdb_cms", ] if _role_distributor_application_name(self.jhelper, self.model): extra_args.append("-target=juju_integration.role-distributor-microovn") + extra_args.append( + "-target=juju_integration.role-distributor-microovn-arm64" + ) return extra_args @@ -252,13 +364,17 @@ def run(self, context: StepContext) -> Result: ) except TerraformException as e: return Result(ResultType.FAILED, str(e)) + + machines_by_arch = self.ovn_manager.get_machines_by_architecture() + apps_to_wait = _microovn_applications_to_wait(machines_by_arch) try: - self.jhelper.wait_application_ready( - APPLICATION, - self.model, - accepted_status=statuses, - timeout=MICROOVN_UNIT_TIMEOUT, - ) + for application in apps_to_wait: + self.jhelper.wait_application_ready( + application, + self.model, + accepted_status=statuses, + timeout=MICROOVN_UNIT_TIMEOUT, + ) except TimeoutError as e: LOG.warning("Timed out waiting for reapplying MicroOVN: %r", e) return Result(ResultType.FAILED, str(e)) @@ -282,11 +398,77 @@ def __init__( "Remove MicroOVN unit", "Removing MicroOVN unit from machine", ) + self.units_to_remove_by_app: dict[str, set[str]] = {} def get_unit_timeout(self) -> int: """Return unit timeout in seconds.""" return MICROOVN_UNIT_TIMEOUT + def is_skip(self, context: StepContext) -> Result: + """Find MicroOVN units to remove across architecture-specific applications.""" + if len(self.names) == 0: + return Result(ResultType.SKIPPED) + + nodes: list[dict] = self.client.cluster.list_nodes() + filtered_nodes = list(filter(lambda node: node["name"] in self.names, nodes)) + if len(filtered_nodes) != len(self.names): + filtered_node_names = [node["name"] for node in filtered_nodes] + missing_nodes = set(self.names) - set(filtered_node_names) + LOG.debug( + "Nodes do not exist in cluster database: %s", ",".join(missing_nodes) + ) + + to_remove_node_ids = {str(node["machineid"]) for node in filtered_nodes} + self.units_to_remove_by_app = {} + + applications = { + _microovn_application_name(node.get("arch") or ovn.DEFAULT_ARCHITECTURE) + for node in filtered_nodes + } + applications.update((APPLICATION, ARM64_APPLICATION)) + + for application in sorted(applications): + try: + app = self.jhelper.get_application(application, self.model) + except ApplicationNotFoundException: + LOG.debug("Application %r has not been deployed yet", application) + continue + + for unit_name, unit in app.units.items(): + if unit.machine in to_remove_node_ids: + self.units_to_remove_by_app.setdefault(application, set()).add( + unit_name + ) + + if not self.units_to_remove_by_app: + return Result(ResultType.SKIPPED) + + return Result(ResultType.COMPLETED) + + def run(self, context: StepContext) -> Result: + """Remove MicroOVN units from architecture-specific applications.""" + try: + self.update_status(context, "Removing units") + for application, units in self.units_to_remove_by_app.items(): + for unit in units: + LOG.debug("Removing unit %s from application %s", unit, application) + self.jhelper.remove_unit(application, unit, self.model) + self.update_status(context, "Waiting for units to be removed") + self.jhelper.wait_units_gone( + list(units), self.model, self.get_unit_timeout() + ) + self.jhelper.wait_application_ready( + application, + self.model, + accepted_status=["active", "unknown"], + timeout=self.get_unit_timeout(), + ) + except (ApplicationNotFoundException, TimeoutError) as e: + LOG.warning("Failed to remove MicroOVN units: %r", e) + return Result(ResultType.FAILED, str(e)) + + return Result(ResultType.COMPLETED) + class EnableMicroOVNStep(BaseStep, JujuStepHelper): """Enable MicroOVN service.""" @@ -318,16 +500,19 @@ def is_skip(self, context: StepContext) -> Result: try: node = self.client.cluster.get_node_info(self.node) self.machine_id = str(node.get("machineid")) + arch = node.get("arch") or ovn.DEFAULT_ARCHITECTURE + app_name = _microovn_application_name(arch) except NodeNotExistInClusterException: LOG.debug("Machine %s does not exist, skipping", self.node) return Result(ResultType.SKIPPED) try: - application = self.jhelper.get_application(APPLICATION, self.model) + application = self.jhelper.get_application(app_name, self.model) except ApplicationNotFoundException as e: LOG.debug("MicroOVN application is not found: %r", e) return Result( - ResultType.SKIPPED, "microovn application has not been deployed yet" + ResultType.SKIPPED, + f"{app_name} application has not been deployed yet", ) for unit_name, unit in application.units.items(): diff --git a/sunbeam-python/sunbeam/steps/upgrades/intra_channel.py b/sunbeam-python/sunbeam/steps/upgrades/intra_channel.py index c5024f14d..d831fb29f 100644 --- a/sunbeam-python/sunbeam/steps/upgrades/intra_channel.py +++ b/sunbeam-python/sunbeam/steps/upgrades/intra_channel.py @@ -35,7 +35,9 @@ EnsureL2AdvertisementByHostStep, ) from sunbeam.steps.microceph import DeployMicrocephApplicationStep -from sunbeam.steps.microovn import DeployMicroOVNApplicationStep +from sunbeam.steps.microovn import ( + DeployMicroOVNApplicationStep, +) from sunbeam.steps.mysql import MySQLCharmUpgradeStep, ReapplyMySQLTerraformPlanStep from sunbeam.steps.openstack import ( OpenStackPatchLoadBalancerServicesIPPoolStep, diff --git a/sunbeam-python/sunbeam/versions.py b/sunbeam-python/sunbeam/versions.py index c042f1552..d51b43d38 100644 --- a/sunbeam-python/sunbeam/versions.py +++ b/sunbeam-python/sunbeam/versions.py @@ -270,7 +270,11 @@ class VarMap(TypedDict, total=False): "revision": "charm_sunbeam_ovn_proxy_revision", "config": "charm_sunbeam_ovn_proxy_config", }, - } + }, + # resolved arm64 revision. + "preserve": [ + "charm_openstack_network_agents_arm64_revision", + ], } DEPLOY_OPENSTACK_HYPERVISOR_TFVAR_MAP: VarMap = { "charms": { diff --git a/sunbeam-python/tests/unit/sunbeam/provider/maas/test_maas.py b/sunbeam-python/tests/unit/sunbeam/provider/maas/test_maas.py index a28ed4f12..17a477589 100644 --- a/sunbeam-python/tests/unit/sunbeam/provider/maas/test_maas.py +++ b/sunbeam-python/tests/unit/sunbeam/provider/maas/test_maas.py @@ -1288,10 +1288,10 @@ def test_is_skip_with_existing_machine_id( self, maas_deploy_machines_step, step_context ): maas_deploy_machines_step.client.cluster.list_nodes.return_value = [ - {"name": "test_node", "machineid": 1} + {"name": "test_node", "machineid": 1, "systemid": "abc"} ] maas_deploy_machines_step.jhelper.get_machines.return_value = { - "2": Mock(hostname="test_node") + "2": Mock(hostname="test_node", instance_id="abc") } result = maas_deploy_machines_step.is_skip(step_context) assert result.result_type == ResultType.FAILED @@ -1301,6 +1301,29 @@ def test_is_skip_with_existing_machine_id( ) assert result.message == msg + def test_is_skip_matches_dpu_by_system_id_when_hostname_differs( + self, maas_deploy_machines_step, step_context + ): + maas_deploy_machines_step.client.cluster.list_nodes.return_value = [ + { + "name": "pc8a-rb3-n4-dpu", + "machineid": -1, + "systemid": "8fhqbs", + "is_dpu": True, + } + ] + maas_deploy_machines_step.jhelper.get_machines.return_value = { + "54": Mock( + hostname="packer-ubuntu", + instance_id="8fhqbs", + display_name="pc8a-rb3-n4-dpu", + ) + } + result = maas_deploy_machines_step.is_skip(step_context) + assert result.result_type == ResultType.COMPLETED + assert maas_deploy_machines_step.nodes_to_deploy == [] + assert len(maas_deploy_machines_step.nodes_to_update) == 1 + def test_is_skip_with_nodes_to_deploy( self, maas_deploy_machines_step, step_context ): diff --git a/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py b/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py index d12216497..9d4e171ea 100644 --- a/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py +++ b/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py @@ -29,6 +29,7 @@ def test_node(): def ovn_manager(): mock_manager = Mock() mock_manager.get_roles_for_microovn.return_value = [] + mock_manager.get_token_distributor_machines.return_value = [] yield mock_manager @@ -78,6 +79,27 @@ def test_get_accepted_application_status_excludes_blocked_for_microovn_provider( assert statuses == ["active", "unknown"] def test_extra_tfvars( + self, deploy_microovn_step, basic_deployment, basic_client, ovn_manager + ): + openstack_tfhelper = Mock() + openstack_tfhelper.output.return_value = { + "ca-offer-url": "provider:admin/default.ca", + } + basic_deployment.get_tfhelper.return_value = openstack_tfhelper + ovn_manager.get_machines_by_architecture.return_value = { + ovn.DEFAULT_ARCHITECTURE: ["1", "2"], + } + ovn_manager.get_token_distributor_machines.return_value = ["1"] + + extra_tfvars = deploy_microovn_step.extra_tfvars() + + assert "ca-offer-url" in extra_tfvars + assert extra_tfvars["microovn_machine_ids_by_architecture"] == { + ovn.DEFAULT_ARCHITECTURE: ["1", "2"], + } + assert extra_tfvars["token_distributor_machine_ids"] == ["1"] + + def test_extra_tfvars_arm64_dpu( self, deploy_microovn_step, basic_deployment, @@ -86,55 +108,115 @@ def test_extra_tfvars( ovn_manager, ): openstack_tfhelper = Mock() - openstack_tfhelper.output.return_value = { - "ca-offer-url": "provider:admin/default.ca", - } + openstack_tfhelper.output.return_value = {} basic_deployment.get_tfhelper.return_value = openstack_tfhelper - ovn_manager.get_machines.return_value = ["1", "2"] - basic_jhelper.get_application.return_value = Mock() + ovn_manager.get_machines_by_architecture.return_value = { + ovn.DEFAULT_ARCHITECTURE: ["1", "2"], + ovn.ARM64_ARCHITECTURE: ["52"], + } + ovn_manager.get_token_distributor_machines.return_value = ["1"] + basic_jhelper.get_available_charm_revisions.return_value = { + ovn.DEFAULT_ARCHITECTURE: 119, + ovn.ARM64_ARCHITECTURE: 120, + } extra_tfvars = deploy_microovn_step.extra_tfvars() - assert "ca-offer-url" in extra_tfvars - assert "microovn_machine_ids" in extra_tfvars - assert set(extra_tfvars["microovn_machine_ids"]) == {"1", "2"} + assert extra_tfvars["microovn_machine_ids_by_architecture"] == { + ovn.DEFAULT_ARCHITECTURE: ["1", "2"], + ovn.ARM64_ARCHITECTURE: ["52"], + } assert extra_tfvars["token_distributor_machine_ids"] == ["1"] - assert extra_tfvars["role_distributor_application_name"] == "role-distributor" + assert extra_tfvars["charm_openstack_network_agents_arm64_revision"] == 120 - def test_extra_tfvars_no_network_nodes( - self, deploy_microovn_step, basic_deployment, basic_client, ovn_manager + def test_extra_tfvars_arm64_only( + self, deploy_microovn_step, basic_deployment, basic_jhelper, ovn_manager ): openstack_tfhelper = Mock() - openstack_tfhelper.output.return_value = { - "ca-offer-url": "provider:admin/default.ca", - } + openstack_tfhelper.output.return_value = {} basic_deployment.get_tfhelper.return_value = openstack_tfhelper - ovn_manager.get_machines.return_value = [] + ovn_manager.get_machines_by_architecture.return_value = { + ovn.ARM64_ARCHITECTURE: ["52"], + } + ovn_manager.get_token_distributor_machines.return_value = ["0"] + basic_jhelper.get_available_charm_revisions.return_value = { + ovn.ARM64_ARCHITECTURE: 120, + } extra_tfvars = deploy_microovn_step.extra_tfvars() - assert "ca-offer-url" in extra_tfvars - assert "endpoint_bindings" in extra_tfvars - assert extra_tfvars["ca-offer-url"] == "provider:admin/default.ca" + assert extra_tfvars["microovn_machine_ids_by_architecture"] == { + ovn.ARM64_ARCHITECTURE: ["52"], + } + assert extra_tfvars["token_distributor_machine_ids"] == ["0"] + assert extra_tfvars["charm_openstack_network_agents_arm64_revision"] == 120 - def test_extra_tfvars_disables_role_distributor_when_missing( + def test_extra_tfvars_arm64_uses_manifest_channel( self, deploy_microovn_step, basic_deployment, basic_jhelper, + basic_manifest, ovn_manager, ): openstack_tfhelper = Mock() openstack_tfhelper.output.return_value = {} basic_deployment.get_tfhelper.return_value = openstack_tfhelper - ovn_manager.get_machines.return_value = ["1"] - basic_jhelper.get_application.side_effect = ApplicationNotFoundException( - "Application missing from model: 'test-model'" + ovn_manager.get_machines_by_architecture.return_value = { + ovn.ARM64_ARCHITECTURE: ["52"], + } + ovn_manager.get_token_distributor_machines.return_value = ["0"] + # Channel configured for openstack-network-agents in the manifest. + agent_manifest = Mock() + agent_manifest.channel = "2026.1/edge" + agent_manifest.config = None + basic_manifest.core.software.charms.get.return_value = agent_manifest + basic_jhelper.get_available_charm_revisions.return_value = { + ovn.ARM64_ARCHITECTURE: 120, + } + + extra_tfvars = deploy_microovn_step.extra_tfvars() + + basic_jhelper.get_available_charm_revisions.assert_called_once_with( + "openstack-network-agents", "2026.1/edge" ) + assert extra_tfvars["charm_openstack_network_agents_arm64_revision"] == 120 + + def test_extra_tfvars_non_default_architecture( + self, deploy_microovn_step, basic_deployment, ovn_manager + ): + openstack_tfhelper = Mock() + openstack_tfhelper.output.return_value = {} + basic_deployment.get_tfhelper.return_value = openstack_tfhelper + ovn_manager.get_machines_by_architecture.return_value = { + "s390x": ["72"], + } + ovn_manager.get_token_distributor_machines.return_value = ["0"] + + extra_tfvars = deploy_microovn_step.extra_tfvars() + + assert extra_tfvars["microovn_machine_ids_by_architecture"] == { + "s390x": ["72"], + } + assert extra_tfvars["token_distributor_machine_ids"] == ["0"] + + def test_extra_tfvars_no_network_nodes( + self, deploy_microovn_step, basic_deployment, basic_client, ovn_manager + ): + openstack_tfhelper = Mock() + openstack_tfhelper.output.return_value = { + "ca-offer-url": "provider:admin/default.ca", + } + basic_deployment.get_tfhelper.return_value = openstack_tfhelper + ovn_manager.get_machines_by_architecture.return_value = {} extra_tfvars = deploy_microovn_step.extra_tfvars() - assert extra_tfvars["role_distributor_application_name"] is None + assert "ca-offer-url" in extra_tfvars + assert "endpoint_bindings" in extra_tfvars + assert extra_tfvars["ca-offer-url"] == "provider:admin/default.ca" + assert extra_tfvars["microovn_machine_ids_by_architecture"] == {} + assert extra_tfvars["token_distributor_machine_ids"] == [] def test_extra_tfvars_network_agents_endpoint_bindings( self, deploy_microovn_step, basic_deployment, ovn_manager @@ -143,7 +225,7 @@ def test_extra_tfvars_network_agents_endpoint_bindings( openstack_tfhelper.output.return_value = {} basic_deployment.get_tfhelper.return_value = openstack_tfhelper basic_deployment.get_space.side_effect = lambda n: f"space-{n.value}" - ovn_manager.get_machines.return_value = [] + ovn_manager.get_machines_by_architecture.return_value = {} extra_tfvars = deploy_microovn_step.extra_tfvars() @@ -151,6 +233,34 @@ def test_extra_tfvars_network_agents_endpoint_bindings( assert {"space": "space-management"} in bindings assert {"endpoint": "data", "space": "space-data"} in bindings + def test_applications_to_wait_arm64_only(self, deploy_microovn_step, ovn_manager): + ovn_manager.get_machines_by_architecture.return_value = { + ovn.DEFAULT_ARCHITECTURE: [], + ovn.ARM64_ARCHITECTURE: ["54"], + } + + assert deploy_microovn_step._applications_to_wait() == ["microovn-arm64"] + + def test_applications_to_wait_amd64_and_arm64( + self, deploy_microovn_step, ovn_manager + ): + ovn_manager.get_machines_by_architecture.return_value = { + ovn.DEFAULT_ARCHITECTURE: ["1", "2"], + ovn.ARM64_ARCHITECTURE: ["54"], + } + + assert deploy_microovn_step._applications_to_wait() == [ + "microovn", + "microovn-arm64", + ] + + def test_applications_to_wait_non_default_architecture( + self, deploy_microovn_step, ovn_manager + ): + ovn_manager.get_machines_by_architecture.return_value = {"s390x": ["72"]} + + assert deploy_microovn_step._applications_to_wait() == ["microovn-s390x"] + class TestReapplyMicroOVNOptionalIntegrationsStep: @pytest.fixture @@ -177,7 +287,6 @@ def reapply_microovn_step( def test_tf_apply_extra_args(self, reapply_microovn_step): reapply_microovn_step.tfhelper.output.return_value = {} - reapply_microovn_step.jhelper.get_application.return_value = Mock() extra_args = reapply_microovn_step.tf_apply_extra_args() expected_args = [ @@ -185,7 +294,11 @@ def test_tf_apply_extra_args(self, reapply_microovn_step): "-target=juju_integration.microovn-certs", "-target=juju_integration.microovn-ovsdb-cms", "-target=juju_integration.microovn-openstack-network-agents", + "-target=juju_integration.microovn_arm64_microcluster_token_distributor", + "-target=juju_integration.microovn_arm64_certs", + "-target=juju_integration.microovn_arm64_ovsdb_cms", "-target=juju_integration.role-distributor-microovn", + "-target=juju_integration.role-distributor-microovn-arm64", ] assert extra_args == expected_args @@ -204,6 +317,9 @@ def test_tf_apply_extra_args_omits_role_distributor_when_missing( "-target=juju_integration.microovn-certs", "-target=juju_integration.microovn-ovsdb-cms", "-target=juju_integration.microovn-openstack-network-agents", + "-target=juju_integration.microovn_arm64_microcluster_token_distributor", + "-target=juju_integration.microovn_arm64_certs", + "-target=juju_integration.microovn_arm64_ovsdb_cms", ] @@ -241,6 +357,61 @@ def test_is_skip_application_not_found( assert result.result_type == ResultType.SKIPPED assert result.message == "microovn application has not been deployed yet" + def test_is_skip_arm64_application_not_found( + self, + basic_client, + basic_jhelper, + enable_microovn_step, + step_context, + ): + basic_client.cluster.get_node_info.return_value = { + "machineid": "52", + "arch": "arm64", + } + basic_jhelper.get_application.side_effect = ApplicationNotFoundException( + "Application not found" + ) + + result = enable_microovn_step.is_skip(step_context) + + assert result.result_type == ResultType.SKIPPED + assert result.message == "microovn-arm64 application has not been deployed yet" + + def test_is_skip_success_arm64( + self, basic_client, basic_jhelper, enable_microovn_step, step_context + ): + basic_client.cluster.get_node_info.return_value = { + "machineid": "52", + "arch": "arm64", + } + basic_jhelper.get_application.return_value = Mock( + units={"microovn-arm64/0": Mock(machine="52")} + ) + + result = enable_microovn_step.is_skip(step_context) + + assert result.result_type == ResultType.COMPLETED + assert enable_microovn_step.unit == "microovn-arm64/0" + + def test_is_skip_success_non_default_architecture( + self, basic_client, basic_jhelper, enable_microovn_step, step_context + ): + basic_client.cluster.get_node_info.return_value = { + "machineid": "72", + "arch": "s390x", + } + basic_jhelper.get_application.return_value = Mock( + units={"microovn-s390x/0": Mock(machine="72")} + ) + + result = enable_microovn_step.is_skip(step_context) + + assert result.result_type == ResultType.COMPLETED + basic_jhelper.get_application.assert_called_once_with( + "microovn-s390x", "test-model" + ) + assert enable_microovn_step.unit == "microovn-s390x/0" + def test_is_skip_unit_not_on_machine( self, basic_client, @@ -298,6 +469,10 @@ def reapply_microovn_terraform_step( ): manager = Mock() manager.get_roles_for_microovn.return_value = {ovn.Role.NETWORK} + manager.get_machines_by_architecture.return_value = { + ovn.DEFAULT_ARCHITECTURE: ["1"], + ovn.ARM64_ARCHITECTURE: [], + } return ReapplyMicroOVNTerraformPlanStep( basic_client, basic_tfhelper,