From 3ad86d6b927937bf597216832fdc991a96936456 Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Thu, 18 Jun 2026 19:11:14 +0500 Subject: [PATCH 1/6] Add arm64 MicroOVN support --- cloud/etc/deploy-microovn/main.tf | 106 +++++++++++- cloud/etc/deploy-microovn/variables.tf | 8 +- sunbeam-python/sunbeam/core/ovn.py | 38 +++-- sunbeam-python/sunbeam/provider/maas/steps.py | 39 ++--- sunbeam-python/sunbeam/steps/microovn.py | 158 ++++++++++++++++-- .../unit/sunbeam/provider/maas/test_maas.py | 36 +++- .../tests/unit/sunbeam/steps/test_microovn.py | 106 +++++++++++- 7 files changed, 435 insertions(+), 56 deletions(-) diff --git a/cloud/etc/deploy-microovn/main.tf b/cloud/etc/deploy-microovn/main.tf index 4eabbf17a..8c9d68720 100644 --- a/cloud/etc/deploy-microovn/main.tf +++ b/cloud/etc/deploy-microovn/main.tf @@ -56,7 +56,12 @@ 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 + units = ( + length(var.microovn_machine_ids) == 0 + && length(var.microovn_arm64_machine_ids) == 0 + ) ? 1 : ( + length(var.microovn_machine_ids) == 0 ? 0 : null + ) charm { name = "microovn" @@ -70,6 +75,25 @@ resource "juju_application" "microovn" { endpoint_bindings = var.endpoint_bindings } +resource "juju_application" "microovn_arm64" { + count = length(var.microovn_arm64_machine_ids) > 0 ? 1 : 0 + name = "microovn-arm64" + model_uuid = data.juju_model.machine_model.uuid + machines = toset(var.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 @@ -145,6 +169,82 @@ resource "juju_integration" "microovn-openstack-network-agents" { } } +resource "juju_integration" "microovn_arm64_microcluster_token_distributor" { + count = length(var.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_certs" { + count = (var.ca-offer-url != null && length(var.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(var.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_openstack_network_agents" { + count = length(var.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.name + endpoint = "juju-info" + } +} + +resource "juju_integration" "microovn_arm64_to_ovn_proxy" { + count = ( + length(var.microovn_arm64_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 model_uuid = data.juju_model.machine_model.uuid @@ -171,6 +271,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 c9182dc6a..38c330a51 100644 --- a/cloud/etc/deploy-microovn/variables.tf +++ b/cloud/etc/deploy-microovn/variables.tf @@ -82,7 +82,13 @@ 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 = [] } diff --git a/sunbeam-python/sunbeam/core/ovn.py b/sunbeam-python/sunbeam/core/ovn.py index 9cc3fbd9b..e9923aab7 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,38 @@ 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_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_amd64(self) -> list[str]: + """Get amd64 machine IDs for MicroOVN.""" + return self.get_machines(DEFAULT_ARCHITECTURE) + + def get_machines_arm64(self) -> list[str]: + """Get arm64 machine IDs for MicroOVN (e.g. DPU network nodes).""" + return self.get_machines(ARM64_ARCHITECTURE) + 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 d619d1b56..011399f51 100644 --- a/sunbeam-python/sunbeam/provider/maas/steps.py +++ b/sunbeam-python/sunbeam/provider/maas/steps.py @@ -114,6 +114,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.""" @@ -1587,25 +1603,10 @@ 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, - f"Machine {node['name']} already exists in model" - f" {self.model} with id {id}," - f" expected the id {node['machineid']}.", - ) - if ( - node["systemid"] != machine.instance_id - and node["systemid"] != "" - ): - return Result( - ResultType.FAILED, - f"Machine {node['name']} already exists in model" - f" {self.model} with systemid {machine.instance_id}," - f" expected the systemid {node['systemid']}.", - ) - if node_machine_id == -1: + nodes_to_update.append(node) + elif node_machine_id == -1: nodes_to_update.append(node) nodes_to_deploy.remove(node) break @@ -1671,7 +1672,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 bf8842b19..2dcd71654 100644 --- a/sunbeam-python/sunbeam/steps/microovn.py +++ b/sunbeam-python/sunbeam/steps/microovn.py @@ -40,6 +40,7 @@ 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" @@ -90,10 +91,14 @@ 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] + machine_ids = self.ovn_manager.get_machines_amd64() + arm64_machine_ids = self.ovn_manager.get_machines_arm64() + # Always set both keys so stale clusterd tfvars are cleared on re-apply. + extra_tfvars["microovn_machine_ids"] = machine_ids + extra_tfvars["microovn_arm64_machine_ids"] = arm64_machine_ids + distributor_ids = machine_ids or arm64_machine_ids + if distributor_ids: + extra_tfvars["token_distributor_machine_ids"] = distributor_ids[:1] extra_tfvars.update( { @@ -139,6 +144,57 @@ def extra_tfvars(self) -> dict: return extra_tfvars + def _applications_to_wait(self) -> list[str]: + """Return Juju application names that should be ready after deploy.""" + apps: list[str] = [] + if self.ovn_manager.get_machines_amd64(): + apps.append(APPLICATION) + if self.ovn_manager.get_machines_arm64(): + apps.append(ARM64_APPLICATION) + return apps + + @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 amd64 and arm64 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.""" @@ -150,6 +206,10 @@ 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", + "-target=juju_integration.microovn_arm64_openstack_network_agents", ] @@ -225,13 +285,20 @@ def run(self, context: StepContext) -> Result: ) except TerraformException as e: return Result(ResultType.FAILED, str(e)) + + apps_to_wait: list[str] = [] + if self.ovn_manager.get_machines_amd64(): + apps_to_wait.append(APPLICATION) + if self.ovn_manager.get_machines_arm64(): + apps_to_wait.append(ARM64_APPLICATION) 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)) @@ -255,11 +322,71 @@ 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 amd64 and arm64 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 = {} + + for application in (APPLICATION, ARM64_APPLICATION): + 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 amd64 and arm64 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.""" @@ -291,19 +418,24 @@ 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 + application = ( + ARM64_APPLICATION if arch == ovn.ARM64_ARCHITECTURE else APPLICATION + ) 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) + app = self.jhelper.get_application(application, 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"{application} application has not been deployed yet", ) - for unit_name, unit in application.units.items(): + for unit_name, unit in app.units.items(): if unit.machine == self.machine_id: LOG.debug( "Unit %s is deployed on machine: %s", unit_name, self.machine_id 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 ed84c3d1e..b3fe9ea53 100644 --- a/sunbeam-python/tests/unit/sunbeam/provider/maas/test_maas.py +++ b/sunbeam-python/tests/unit/sunbeam/provider/maas/test_maas.py @@ -1205,18 +1205,38 @@ 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 - msg = ( - "Machine test_node already exists in model test_model with id 2," - " expected the id 1." - ) - assert result.message == msg + 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_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": 55, + "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 2f2899fc7..0dc04ebc3 100644 --- a/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py +++ b/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py @@ -67,15 +67,50 @@ def test_extra_tfvars( "ca-offer-url": "provider:admin/default.ca", } basic_deployment.get_tfhelper.return_value = openstack_tfhelper - ovn_manager.get_machines.return_value = ["1", "2"] + ovn_manager.get_machines_amd64.return_value = ["1", "2"] + ovn_manager.get_machines_arm64.return_value = [] 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"] == ["1", "2"] + assert extra_tfvars["microovn_arm64_machine_ids"] == [] assert extra_tfvars["token_distributor_machine_ids"] == ["1"] + def test_extra_tfvars_arm64_dpu( + self, deploy_microovn_step, basic_deployment, basic_client, ovn_manager + ): + openstack_tfhelper = Mock() + openstack_tfhelper.output.return_value = {} + basic_deployment.get_tfhelper.return_value = openstack_tfhelper + ovn_manager.get_machines_amd64.return_value = ["1", "2"] + ovn_manager.get_machines_arm64.return_value = ["52"] + + extra_tfvars = deploy_microovn_step.extra_tfvars() + + assert extra_tfvars["microovn_machine_ids"] == ["1", "2"] + assert extra_tfvars["microovn_arm64_machine_ids"] == ["52"] + assert extra_tfvars["token_distributor_machine_ids"] == ["1"] + + def test_extra_tfvars_arm64_only( + 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_amd64.return_value = [] + ovn_manager.get_machines_arm64.return_value = ["52"] + + extra_tfvars = deploy_microovn_step.extra_tfvars() + + assert ( + "microovn_machine_ids" not in extra_tfvars + or extra_tfvars.get("microovn_machine_ids") == [] + ) + assert extra_tfvars["microovn_arm64_machine_ids"] == ["52"] + assert extra_tfvars["token_distributor_machine_ids"] == ["52"] + def test_extra_tfvars_no_network_nodes( self, deploy_microovn_step, basic_deployment, basic_client, ovn_manager ): @@ -84,13 +119,17 @@ def test_extra_tfvars_no_network_nodes( "ca-offer-url": "provider:admin/default.ca", } basic_deployment.get_tfhelper.return_value = openstack_tfhelper - ovn_manager.get_machines.return_value = [] + ovn_manager.get_machines_amd64.return_value = [] + ovn_manager.get_machines_arm64.return_value = [] 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"] == [] + assert extra_tfvars["microovn_arm64_machine_ids"] == [] + assert "token_distributor_machine_ids" not in extra_tfvars def test_extra_tfvars_network_agents_endpoint_bindings( self, deploy_microovn_step, basic_deployment, ovn_manager @@ -99,7 +138,8 @@ 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_amd64.return_value = [] + ovn_manager.get_machines_arm64.return_value = [] extra_tfvars = deploy_microovn_step.extra_tfvars() @@ -107,6 +147,23 @@ 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_amd64.return_value = [] + ovn_manager.get_machines_arm64.return_value = ["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_amd64.return_value = ["1", "2"] + ovn_manager.get_machines_arm64.return_value = ["54"] + + assert deploy_microovn_step._applications_to_wait() == [ + "microovn", + "microovn-arm64", + ] + class TestReapplyMicroOVNOptionalIntegrationsStep: @pytest.fixture @@ -140,6 +197,10 @@ 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.microovn_arm64_openstack_network_agents", ] assert extra_args == expected_args @@ -178,6 +239,42 @@ 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_unit_not_on_machine( self, basic_client, @@ -235,6 +332,7 @@ def reapply_microovn_terraform_step( ): manager = Mock() manager.get_roles_for_microovn.return_value = {ovn.Role.NETWORK} + manager.get_machines_arm64.return_value = [] return ReapplyMicroOVNTerraformPlanStep( basic_client, basic_tfhelper, From 730d48931222b3799473d88a519d833dce154580 Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Tue, 30 Jun 2026 12:11:04 +0500 Subject: [PATCH 2/6] Fix merge conflicts --- sunbeam-python/pyproject.toml | 1 + 1 file changed, 1 insertion(+) 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"] From 634957a838d145d67340604e31f8d10525e90468 Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Tue, 30 Jun 2026 12:18:16 +0500 Subject: [PATCH 3/6] Fix MicroOVN optional integration test --- sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py b/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py index e95a245ac..7d4b5e170 100644 --- a/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py +++ b/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py @@ -201,6 +201,7 @@ def test_tf_apply_extra_args(self, reapply_microovn_step): "-target=juju_integration.microovn_arm64_certs", "-target=juju_integration.microovn_arm64_ovsdb_cms", "-target=juju_integration.microovn_arm64_openstack_network_agents", + "-target=juju_integration.role-distributor-microovn", ] assert extra_args == expected_args @@ -517,4 +518,4 @@ def test_run(self, basic_client, step_context): result = step.run(step_context) assert result.result_type == ResultType.COMPLETED mock_write.assert_called_once_with(basic_client, mock_config) - assert mock_config.provider == ovn.OvnProvider.OVN_K8S \ No newline at end of file + assert mock_config.provider == ovn.OvnProvider.OVN_K8S From 5ccfac3191da8fdbf7506677ea71fc60f21894b3 Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Wed, 15 Jul 2026 12:43:03 +0500 Subject: [PATCH 4/6] feat: consolidate arm64 network agents into microovn plan --- cloud/etc/deploy-microovn/main.tf | 85 ++++++--- cloud/etc/deploy-microovn/variables.tf | 14 +- sunbeam-python/sunbeam/core/ovn.py | 43 ++++- sunbeam-python/sunbeam/steps/microovn.py | 144 +++++++++++----- .../sunbeam/steps/upgrades/intra_channel.py | 4 +- sunbeam-python/sunbeam/versions.py | 6 +- .../tests/unit/sunbeam/steps/test_microovn.py | 162 ++++++++++++++---- 7 files changed, 344 insertions(+), 114 deletions(-) diff --git a/cloud/etc/deploy-microovn/main.tf b/cloud/etc/deploy-microovn/main.tf index 0a508e742..bb5294057 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,13 +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 - && length(var.microovn_arm64_machine_ids) == 0 - ) ? 1 : ( - length(var.microovn_machine_ids) == 0 ? 0 : 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" @@ -76,10 +102,10 @@ resource "juju_application" "microovn" { } resource "juju_application" "microovn_arm64" { - count = length(var.microovn_arm64_machine_ids) > 0 ? 1 : 0 + count = length(local.microovn_arm64_machine_ids) > 0 ? 1 : 0 name = "microovn-arm64" model_uuid = data.juju_model.machine_model.uuid - machines = toset(var.microovn_arm64_machine_ids) + machines = length(local.microovn_arm64_machine_ids) == 0 ? null : toset(local.microovn_arm64_machine_ids) constraints = "arch=arm64" charm { @@ -170,7 +196,7 @@ resource "juju_integration" "microovn-openstack-network-agents" { } resource "juju_integration" "microovn_arm64_microcluster_token_distributor" { - count = length(var.microovn_arm64_machine_ids) > 0 ? 1 : 0 + count = length(local.microovn_arm64_machine_ids) > 0 ? 1 : 0 model_uuid = data.juju_model.machine_model.uuid application { @@ -184,52 +210,53 @@ resource "juju_integration" "microovn_arm64_microcluster_token_distributor" { } } -resource "juju_integration" "microovn_arm64_certs" { - count = (var.ca-offer-url != null && length(var.microovn_arm64_machine_ids) > 0) ? 1 : 0 +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 = "certificates" + endpoint = "juju-info" } application { - offer_url = var.ca-offer-url + name = juju_application.openstack-network-agents-arm64[0].name + endpoint = "juju-info" } } -resource "juju_integration" "microovn_arm64_ovsdb_cms" { - count = (var.ovn-relay-offer-url != null && length(var.microovn_arm64_machine_ids) > 0) ? 1 : 0 +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 = "ovsdb-external" + endpoint = "certificates" } application { - offer_url = var.ovn-relay-offer-url + offer_url = var.ca-offer-url } } -resource "juju_integration" "microovn_arm64_openstack_network_agents" { - count = length(var.microovn_arm64_machine_ids) > 0 ? 1 : 0 +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 = "juju-info" + endpoint = "ovsdb-external" } application { - name = juju_application.openstack-network-agents.name - endpoint = "juju-info" + offer_url = var.ovn-relay-offer-url } } resource "juju_integration" "microovn_arm64_to_ovn_proxy" { count = ( - length(var.microovn_arm64_machine_ids) > 0 + 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 @@ -246,7 +273,13 @@ resource "juju_integration" "microovn_arm64_to_ovn_proxy" { } 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 { @@ -277,4 +310,4 @@ output "microovn-arm64-application-name" { output "ovsdb-cms-offer" { value = try(juju_offer.ovsdb-cms[0].url, null) -} \ No newline at end of file +} diff --git a/cloud/etc/deploy-microovn/variables.tf b/cloud/etc/deploy-microovn/variables.tf index 6f41a8ce2..adcb23ba5 100644 --- a/cloud/etc/deploy-microovn/variables.tf +++ b/cloud/etc/deploy-microovn/variables.tf @@ -21,7 +21,7 @@ variable "charm_microovn_config" { variable "charm_openstack_network_agents_channel" { description = "Operator channel for openstack-network-agents deployment" type = string - default = "2026.1/stable" + default = null } variable "charm_openstack_network_agents_revision" { @@ -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) @@ -93,6 +99,12 @@ variable "microovn_arm64_machine_ids" { 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/sunbeam/core/ovn.py b/sunbeam-python/sunbeam/core/ovn.py index e9923aab7..baeee31b6 100644 --- a/sunbeam-python/sunbeam/core/ovn.py +++ b/sunbeam-python/sunbeam/core/ovn.py @@ -116,6 +116,29 @@ def _list_microovn_nodes(self) -> list[dict]: nodes += self.client.cluster.list_nodes_by_role("control") 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. @@ -132,13 +155,19 @@ def get_machines(self, architecture: str | None = None) -> list[str]: machine_ids.add(str(machineid)) return sorted(machine_ids) - def get_machines_amd64(self) -> list[str]: - """Get amd64 machine IDs for MicroOVN.""" - return self.get_machines(DEFAULT_ARCHITECTURE) - - def get_machines_arm64(self) -> list[str]: - """Get arm64 machine IDs for MicroOVN (e.g. DPU network nodes).""" - return self.get_machines(ARM64_ARCHITECTURE) + 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 diff --git a/sunbeam-python/sunbeam/steps/microovn.py b/sunbeam-python/sunbeam/steps/microovn.py index 80b648e38..25db48238 100644 --- a/sunbeam-python/sunbeam/steps/microovn.py +++ b/sunbeam-python/sunbeam/steps/microovn.py @@ -46,6 +46,32 @@ 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: @@ -63,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.""" @@ -112,14 +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_amd64() - arm64_machine_ids = self.ovn_manager.get_machines_arm64() - # Always set both keys so stale clusterd tfvars are cleared on re-apply. - extra_tfvars["microovn_machine_ids"] = machine_ids - extra_tfvars["microovn_arm64_machine_ids"] = arm64_machine_ids - distributor_ids = machine_ids or arm64_machine_ids - if distributor_ids: - extra_tfvars["token_distributor_machine_ids"] = distributor_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( { @@ -142,40 +211,21 @@ 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.""" - apps: list[str] = [] - if self.ovn_manager.get_machines_amd64(): - apps.append(APPLICATION) - if self.ovn_manager.get_machines_arm64(): - apps.append(ARM64_APPLICATION) - return apps + 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), @@ -184,7 +234,7 @@ def _applications_to_wait(self) -> list[str]: retry_error_callback=convert_retry_failure_as_result, ) def run(self, context: StepContext) -> Result: - """Apply terraform and wait for amd64 and arm64 MicroOVN applications.""" + """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) @@ -233,7 +283,6 @@ def tf_apply_extra_args(self) -> list[str]: "-target=juju_integration.microovn_arm64_microcluster_token_distributor", "-target=juju_integration.microovn_arm64_certs", "-target=juju_integration.microovn_arm64_ovsdb_cms", - "-target=juju_integration.microovn_arm64_openstack_network_agents", ] if _role_distributor_application_name(self.jhelper, self.model): extra_args.append("-target=juju_integration.role-distributor-microovn") @@ -313,11 +362,8 @@ def run(self, context: StepContext) -> Result: except TerraformException as e: return Result(ResultType.FAILED, str(e)) - apps_to_wait: list[str] = [] - if self.ovn_manager.get_machines_amd64(): - apps_to_wait.append(APPLICATION) - if self.ovn_manager.get_machines_arm64(): - apps_to_wait.append(ARM64_APPLICATION) + machines_by_arch = self.ovn_manager.get_machines_by_architecture() + apps_to_wait = _microovn_applications_to_wait(machines_by_arch) try: for application in apps_to_wait: self.jhelper.wait_application_ready( @@ -356,7 +402,7 @@ def get_unit_timeout(self) -> int: return MICROOVN_UNIT_TIMEOUT def is_skip(self, context: StepContext) -> Result: - """Find MicroOVN units to remove across amd64 and arm64 applications.""" + """Find MicroOVN units to remove across architecture-specific applications.""" if len(self.names) == 0: return Result(ResultType.SKIPPED) @@ -372,7 +418,13 @@ def is_skip(self, context: StepContext) -> Result: to_remove_node_ids = {str(node["machineid"]) for node in filtered_nodes} self.units_to_remove_by_app = {} - for application in (APPLICATION, ARM64_APPLICATION): + 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: @@ -391,7 +443,7 @@ def is_skip(self, context: StepContext) -> Result: return Result(ResultType.COMPLETED) def run(self, context: StepContext) -> Result: - """Remove MicroOVN units from amd64 and arm64 applications.""" + """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(): @@ -446,23 +498,21 @@ def is_skip(self, context: StepContext) -> Result: node = self.client.cluster.get_node_info(self.node) self.machine_id = str(node.get("machineid")) arch = node.get("arch") or ovn.DEFAULT_ARCHITECTURE - application = ( - ARM64_APPLICATION if arch == ovn.ARM64_ARCHITECTURE else APPLICATION - ) + app_name = _microovn_application_name(arch) except NodeNotExistInClusterException: LOG.debug("Machine %s does not exist, skipping", self.node) return Result(ResultType.SKIPPED) try: - app = 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, - f"{application} application has not been deployed yet", + f"{app_name} application has not been deployed yet", ) - for unit_name, unit in app.units.items(): + for unit_name, unit in application.units.items(): if unit.machine == self.machine_id: LOG.debug( "Unit %s is deployed on machine: %s", unit_name, self.machine_id 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/steps/test_microovn.py b/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py index d31decf94..b101fb02a 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 @@ -85,49 +86,119 @@ def test_extra_tfvars( "ca-offer-url": "provider:admin/default.ca", } basic_deployment.get_tfhelper.return_value = openstack_tfhelper - ovn_manager.get_machines_amd64.return_value = ["1", "2"] - ovn_manager.get_machines_arm64.return_value = [] + 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 "microovn_machine_ids" in extra_tfvars - assert extra_tfvars["microovn_machine_ids"] == ["1", "2"] - assert extra_tfvars["microovn_arm64_machine_ids"] == [] + 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, basic_client, ovn_manager + self, + deploy_microovn_step, + basic_deployment, + basic_client, + basic_jhelper, + ovn_manager, ): openstack_tfhelper = Mock() openstack_tfhelper.output.return_value = {} basic_deployment.get_tfhelper.return_value = openstack_tfhelper - ovn_manager.get_machines_amd64.return_value = ["1", "2"] - ovn_manager.get_machines_arm64.return_value = ["52"] + 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 extra_tfvars["microovn_machine_ids"] == ["1", "2"] - assert extra_tfvars["microovn_arm64_machine_ids"] == ["52"] + 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["charm_openstack_network_agents_arm64_revision"] == 120 def test_extra_tfvars_arm64_only( - self, deploy_microovn_step, basic_deployment, ovn_manager + self, deploy_microovn_step, basic_deployment, basic_jhelper, ovn_manager ): openstack_tfhelper = Mock() openstack_tfhelper.output.return_value = {} basic_deployment.get_tfhelper.return_value = openstack_tfhelper - ovn_manager.get_machines_amd64.return_value = [] - ovn_manager.get_machines_arm64.return_value = ["52"] + 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 ( - "microovn_machine_ids" not in extra_tfvars - or extra_tfvars.get("microovn_machine_ids") == [] + 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_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_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["microovn_arm64_machine_ids"] == ["52"] - assert extra_tfvars["token_distributor_machine_ids"] == ["52"] + 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 @@ -137,17 +208,15 @@ def test_extra_tfvars_no_network_nodes( "ca-offer-url": "provider:admin/default.ca", } basic_deployment.get_tfhelper.return_value = openstack_tfhelper - ovn_manager.get_machines_amd64.return_value = [] - ovn_manager.get_machines_arm64.return_value = [] + ovn_manager.get_machines_by_architecture.return_value = {} 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"] == [] - assert extra_tfvars["microovn_arm64_machine_ids"] == [] - assert "token_distributor_machine_ids" not in extra_tfvars + 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 @@ -156,8 +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_amd64.return_value = [] - ovn_manager.get_machines_arm64.return_value = [] + ovn_manager.get_machines_by_architecture.return_value = {} extra_tfvars = deploy_microovn_step.extra_tfvars() @@ -166,22 +234,33 @@ def test_extra_tfvars_network_agents_endpoint_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_amd64.return_value = [] - ovn_manager.get_machines_arm64.return_value = ["54"] + 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_amd64.return_value = ["1", "2"] - ovn_manager.get_machines_arm64.return_value = ["54"] + 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 @@ -218,7 +297,6 @@ def test_tf_apply_extra_args(self, reapply_microovn_step): "-target=juju_integration.microovn_arm64_microcluster_token_distributor", "-target=juju_integration.microovn_arm64_certs", "-target=juju_integration.microovn_arm64_ovsdb_cms", - "-target=juju_integration.microovn_arm64_openstack_network_agents", "-target=juju_integration.role-distributor-microovn", ] assert extra_args == expected_args @@ -294,6 +372,25 @@ def test_is_skip_success_arm64( 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, @@ -351,7 +448,10 @@ def reapply_microovn_terraform_step( ): manager = Mock() manager.get_roles_for_microovn.return_value = {ovn.Role.NETWORK} - manager.get_machines_arm64.return_value = [] + manager.get_machines_by_architecture.return_value = { + ovn.DEFAULT_ARCHITECTURE: ["1"], + ovn.ARM64_ARCHITECTURE: [], + } return ReapplyMicroOVNTerraformPlanStep( basic_client, basic_tfhelper, From 4b62b277932df0cca37fac484fe0a23acaed13b1 Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Wed, 15 Jul 2026 18:08:06 +0500 Subject: [PATCH 5/6] refactor: restore machine-id guards and network-agents channel default --- cloud/etc/deploy-microovn/variables.tf | 2 +- sunbeam-python/sunbeam/provider/maas/steps.py | 19 +++++++++++++++++-- .../unit/sunbeam/provider/maas/test_maas.py | 11 +++++++---- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/cloud/etc/deploy-microovn/variables.tf b/cloud/etc/deploy-microovn/variables.tf index adcb23ba5..66387158d 100644 --- a/cloud/etc/deploy-microovn/variables.tf +++ b/cloud/etc/deploy-microovn/variables.tf @@ -21,7 +21,7 @@ variable "charm_microovn_config" { variable "charm_openstack_network_agents_channel" { description = "Operator channel for openstack-network-agents deployment" type = string - default = null + default = "2026.1/stable" } variable "charm_openstack_network_agents_revision" { diff --git a/sunbeam-python/sunbeam/provider/maas/steps.py b/sunbeam-python/sunbeam/provider/maas/steps.py index 65eaec8e1..219d2523f 100644 --- a/sunbeam-python/sunbeam/provider/maas/steps.py +++ b/sunbeam-python/sunbeam/provider/maas/steps.py @@ -1600,8 +1600,23 @@ def is_skip(self, context: StepContext) -> Result: for id, machine in juju_machines.items(): if _node_matches_juju_machine(node, machine): if int(id) != node_machine_id and node_machine_id != -1: - nodes_to_update.append(node) - elif node_machine_id == -1: + return Result( + ResultType.FAILED, + f"Machine {node['name']} already exists in model" + f" {self.model} with id {id}," + f" expected the id {node['machineid']}.", + ) + if ( + node["systemid"] != machine.instance_id + and node["systemid"] != "" + ): + return Result( + ResultType.FAILED, + f"Machine {node['name']} already exists in model" + f" {self.model} with systemid {machine.instance_id}," + f" expected the systemid {node['systemid']}.", + ) + if node_machine_id == -1: nodes_to_update.append(node) nodes_to_deploy.remove(node) break 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 954f62a49..17a477589 100644 --- a/sunbeam-python/tests/unit/sunbeam/provider/maas/test_maas.py +++ b/sunbeam-python/tests/unit/sunbeam/provider/maas/test_maas.py @@ -1294,9 +1294,12 @@ def test_is_skip_with_existing_machine_id( "2": Mock(hostname="test_node", instance_id="abc") } 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 + assert result.result_type == ResultType.FAILED + msg = ( + "Machine test_node already exists in model test_model with id 2," + " expected the id 1." + ) + assert result.message == msg def test_is_skip_matches_dpu_by_system_id_when_hostname_differs( self, maas_deploy_machines_step, step_context @@ -1304,7 +1307,7 @@ def test_is_skip_matches_dpu_by_system_id_when_hostname_differs( maas_deploy_machines_step.client.cluster.list_nodes.return_value = [ { "name": "pc8a-rb3-n4-dpu", - "machineid": 55, + "machineid": -1, "systemid": "8fhqbs", "is_dpu": True, } From a9aa90ba099e6bbba20dbab3afad3515502d1bb5 Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Thu, 16 Jul 2026 15:11:01 +0500 Subject: [PATCH 6/6] fix: restore role-distributor integrations for microovn apps --- cloud/etc/deploy-microovn/main.tf | 33 +++++++++++++++++++ sunbeam-python/sunbeam/steps/microovn.py | 3 ++ .../tests/unit/sunbeam/steps/test_microovn.py | 21 ++++++++++++ 3 files changed, 57 insertions(+) diff --git a/cloud/etc/deploy-microovn/main.tf b/cloud/etc/deploy-microovn/main.tf index bb5294057..42b5f69a4 100644 --- a/cloud/etc/deploy-microovn/main.tf +++ b/cloud/etc/deploy-microovn/main.tf @@ -195,6 +195,21 @@ resource "juju_integration" "microovn-openstack-network-agents" { } } +resource "juju_integration" "role-distributor-microovn" { + count = var.role_distributor_application_name != null ? 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.name + endpoint = "role-assignment" + } +} + 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 @@ -225,6 +240,24 @@ resource "juju_integration" "microovn_arm64_openstack_network_agents" { } } +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 diff --git a/sunbeam-python/sunbeam/steps/microovn.py b/sunbeam-python/sunbeam/steps/microovn.py index 25db48238..1a7cb4e31 100644 --- a/sunbeam-python/sunbeam/steps/microovn.py +++ b/sunbeam-python/sunbeam/steps/microovn.py @@ -286,6 +286,9 @@ def tf_apply_extra_args(self) -> list[str]: ] 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 diff --git a/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py b/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py index b101fb02a..9d4e171ea 100644 --- a/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py +++ b/sunbeam-python/tests/unit/sunbeam/steps/test_microovn.py @@ -298,9 +298,30 @@ def test_tf_apply_extra_args(self, reapply_microovn_step): "-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 + def test_tf_apply_extra_args_omits_role_distributor_when_missing( + self, reapply_microovn_step + ): + reapply_microovn_step.tfhelper.output.return_value = {} + reapply_microovn_step.jhelper.get_application.side_effect = ( + ApplicationNotFoundException("Application missing from model: 'test-model'") + ) + + extra_args = reapply_microovn_step.tf_apply_extra_args() + + assert extra_args == [ + "-target=juju_integration.microovn-microcluster-token-distributor", + "-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", + ] + class TestEnableMicroOVNStep: @pytest.fixture