From 0a383c1152ac081fd25c4bcc73cd5b1bf6f049d6 Mon Sep 17 00:00:00 2001 From: Guillaume Boutry Date: Fri, 24 Apr 2026 19:28:41 +0200 Subject: [PATCH] feat: bind k8s cluster endpoint to internal space Move the k8s charm's cluster endpoint from the management space to the internal space so inter-node communication uses the internal network. Closes-Bug: #2144916 Assisted-By: Codex (gpt-5) Signed-off-by: Guillaume Boutry --- sunbeam-python/sunbeam/steps/k8s.py | 36 +++++------ .../unit/sunbeam/provider/maas/test_maas.py | 5 +- .../tests/unit/sunbeam/steps/test_k8s.py | 59 ++++++++++++++++--- 3 files changed, 73 insertions(+), 27 deletions(-) diff --git a/sunbeam-python/sunbeam/steps/k8s.py b/sunbeam-python/sunbeam/steps/k8s.py index 02867b0d2..24fecc472 100644 --- a/sunbeam-python/sunbeam/steps/k8s.py +++ b/sunbeam-python/sunbeam/steps/k8s.py @@ -302,6 +302,10 @@ def extra_tfvars(self) -> dict: tfvars = { "endpoint_bindings": [ {"space": self.deployment.get_space(Networks.MANAGEMENT)}, + { + "endpoint": "cluster", + "space": self.deployment.get_space(Networks.INTERNAL), + }, ], "k8s_config": self._get_k8s_config_tfvars(), } @@ -334,9 +338,9 @@ class EnsureK8SUnitsTaggedStep(BaseStep): This step ensures that every k8s node is tagged with the HOSTNAME_LABEL, to ensure sunbeam can query the correct nodes afterwards. - Match is done on management ip address. Node IP in k8s is guaranteed by - the cluster space binding, which is always bound to the management - space. + Match is done on the IP addresses from the space configured as + Networks.INTERNAL. Node IP in k8s is guaranteed by the cluster + space binding. """ def __init__( @@ -357,18 +361,16 @@ def __init__( self.fqdn = fqdn self.to_update: dict[str, str] = {} - def _get_management_ips( + def _get_cluster_ips( self, juju_machine: "jubilant.statustypes.MachineStatus" ) -> list[str]: - management_space = self.deployment.get_space(Networks.MANAGEMENT) - management_networks = self.jhelper.get_space_networks( - self.model, management_space - ) + cluster_space = self.deployment.get_space(Networks.INTERNAL) + cluster_networks = self.jhelper.get_space_networks(self.model, cluster_space) return _get_machines_space_ips( juju_machine.network_interfaces, - management_space, - management_networks, + cluster_space, + cluster_networks, ) @tenacity.retry( @@ -444,18 +446,18 @@ def _get_k8s_node_to_update( raise SunbeamException( f"{sunbeam_name!r} not found in Juju, expected id {machine_id!r}" ) - management_ips = self._get_management_ips(juju_machine) - if not management_ips: - LOG.debug("No management IPs found for machine %s", machine_id) - raise SunbeamException(f"{sunbeam_name!r} has no management IPs") + cluster_ips = self._get_cluster_ips(juju_machine) + if not cluster_ips: + LOG.debug("No cluster IPs found for machine %s", machine_id) + raise SunbeamException(f"{sunbeam_name!r} has no cluster IPs") try: - k8s_node = self._find_matching_k8s_node(sunbeam_name, management_ips) + k8s_node = self._find_matching_k8s_node(sunbeam_name, cluster_ips) except ValueError: LOG.debug( - "No matching k8s node found for %s, management IPs %s", + "No matching k8s node found for %s, cluster IPs %s", sunbeam_name, - management_ips, + cluster_ips, ) raise SunbeamException(f"{sunbeam_name} has no matching k8s node") except K8SError as e: 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 62ce5f178..960cd9a29 100644 --- a/sunbeam-python/tests/unit/sunbeam/provider/maas/test_maas.py +++ b/sunbeam-python/tests/unit/sunbeam/provider/maas/test_maas.py @@ -1555,7 +1555,10 @@ def test_extra_tfvars_with_ranges(self, deployment_k8s): step.ranges = "10.0.0.0/28" step.client.cluster.get_config.return_value = "{}" expected_tfvars = { - "endpoint_bindings": [{"space": "data"}], + "endpoint_bindings": [ + {"space": "data"}, + {"endpoint": "cluster", "space": "internal_space"}, + ], "k8s_config": { "load-balancer-cidrs": "10.0.0.0/28", "load-balancer-enabled": True, diff --git a/sunbeam-python/tests/unit/sunbeam/steps/test_k8s.py b/sunbeam-python/tests/unit/sunbeam/steps/test_k8s.py index a10f75acf..40ebf3440 100644 --- a/sunbeam-python/tests/unit/sunbeam/steps/test_k8s.py +++ b/sunbeam-python/tests/unit/sunbeam/steps/test_k8s.py @@ -15,6 +15,7 @@ from sunbeam.clusterd.service import ConfigItemNotFoundException from sunbeam.core.common import ResultType +from sunbeam.core.deployment import Networks from sunbeam.core.juju import ( ActionFailedException, ApplicationNotFoundException, @@ -29,6 +30,7 @@ K8S_CLOUD_SUFFIX, AddK8SCloudStep, AddK8SCredentialStep, + DeployK8SApplicationStep, EnsureCiliumDeviceByHostStep, EnsureDefaultL2AdvertisementMutedStep, EnsureK8SUnitsTaggedStep, @@ -64,7 +66,13 @@ def deployment_with_space(): """Deployment mock with space configuration.""" deployment = Mock() deployment.name = "test-deployment" - deployment.get_space.return_value = "management" + + def get_space(network): + if network == Networks.INTERNAL: + return "internal" + return "management" + + deployment.get_space.side_effect = get_space return deployment @@ -928,12 +936,12 @@ def test_is_skip_no_nodes_to_update(self, step, client, jhelper, step_context): jhelper.get_machines.return_value = { "1": Mock( network_interfaces={ - "eth0": Mock(space="management", ip_addresses=["10.0.0.1"]) + "eth0": Mock(space="internal", ip_addresses=["10.0.0.1"]) } ), "2": Mock( network_interfaces={ - "eth0": Mock(space="management", ip_addresses=["10.0.0.2"]) + "eth0": Mock(space="internal", ip_addresses=["10.0.0.2"]) } ), } @@ -960,12 +968,12 @@ def test_is_skip_nodes_to_update(self, step, client, jhelper, step_context): jhelper.get_machines.return_value = { "1": Mock( network_interfaces={ - "eth0": Mock(space="management", ip_addresses=["10.0.0.1"]) + "eth0": Mock(space="internal", ip_addresses=["10.0.0.1"]) } ), "2": Mock( network_interfaces={ - "eth0": Mock(space="management", ip_addresses=["10.0.0.2"]) + "eth0": Mock(space="internal", ip_addresses=["10.0.0.2"]) } ), } @@ -995,12 +1003,12 @@ def test_is_skip_nodes_to_update_with_fqdn( jhelper.get_machines.return_value = { "1": Mock( network_interfaces={ - "eth0": Mock(space="management", ip_addresses=["10.0.0.1"]) + "eth0": Mock(space="internal", ip_addresses=["10.0.0.1"]) } ), "2": Mock( network_interfaces={ - "eth0": Mock(space="management", ip_addresses=["10.0.0.2"]) + "eth0": Mock(space="internal", ip_addresses=["10.0.0.2"]) } ), } @@ -1024,12 +1032,12 @@ def test_is_skip_k8s_api_error(self, step, client, jhelper, step_context): jhelper.get_machines.return_value = { "1": Mock( network_interfaces={ - "eth0": Mock(space="management", ip_addresses=["10.0.0.1"]) + "eth0": Mock(space="internal", ip_addresses=["10.0.0.1"]) } ), "2": Mock( network_interfaces={ - "eth0": Mock(space="management", ip_addresses=["10.0.0.2"]) + "eth0": Mock(space="internal", ip_addresses=["10.0.0.2"]) } ), } @@ -1086,6 +1094,39 @@ def test_run_apply_failure(self, step): assert result.result_type == ResultType.FAILED +class TestDeployK8SApplicationStep: + @pytest.fixture + def deployment(self, deployment_with_space): + deployment_with_space.openstack_machines_model = "test-model" + return deployment_with_space + + @pytest.fixture + def manifest(self, basic_manifest): + basic_manifest.core.software.charms.get.return_value = None + return basic_manifest + + @pytest.fixture + def step(self, deployment, basic_client, basic_tfhelper, basic_jhelper, manifest): + basic_client.cluster.get_config.return_value = "{}" + return DeployK8SApplicationStep( + deployment, + basic_client, + basic_tfhelper, + basic_jhelper, + manifest, + "test-model", + ) + + def test_extra_tfvars_binds_cluster_endpoint_to_internal_space(self, step): + assert step.extra_tfvars()["endpoint_bindings"] == [ + {"space": "management"}, + {"endpoint": "cluster", "space": "internal"}, + ] + + def test_get_k8s_config_tfvars_does_not_manage_cluster_annotations(self, step): + assert "cluster-annotations" not in step._get_k8s_config_tfvars() + + class TestGetKubeClient: @pytest.fixture def client(self, basic_client):