From ce4cb4b99ccbd4b4e919b7384b674d6db25b5c9a Mon Sep 17 00:00:00 2001 From: ffeldhaus Date: Mon, 8 Dec 2025 23:03:45 +0100 Subject: [PATCH 1/2] fix(scaleway): prevent crash on missing metadata keys (#6602) Use safe `.get()` access for `private_ip` and `ipv6` metadata fields to avoid KeyErrors when these optional fields are missing. Additionally: - Ensure `ipv6` is treated as a dictionary and check for existence of required keys (`address`, `netmask`, `gateway`) before usage. - Safely iterate over `public_ips` defaulting to an empty list. Added unit tests to cover scenarios with missing keys and empty configurations. Fixes: #6602 --- cloudinit/sources/DataSourceScaleway.py | 27 ++++--- tests/unittests/sources/test_scaleway.py | 90 ++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 10 deletions(-) diff --git a/cloudinit/sources/DataSourceScaleway.py b/cloudinit/sources/DataSourceScaleway.py index b2e8740b68a..1dc98368501 100644 --- a/cloudinit/sources/DataSourceScaleway.py +++ b/cloudinit/sources/DataSourceScaleway.py @@ -322,12 +322,12 @@ def network_config(self): if self._network_config != sources.UNSET: return self._network_config - if self.metadata["private_ip"] is None: + if not self.metadata.get("private_ip"): # New method of network configuration netcfg = {} ip_cfg = {} - for ip in self.metadata["public_ips"]: + for ip in self.metadata.get("public_ips", []): # Use DHCP for primary address if ip["address"] == self.ephemeral_fixed_address: ip_cfg["dhcp4"] = True @@ -365,18 +365,23 @@ def network_config(self): "name": "%s" % self.distro.fallback_interface, } subnets = [{"type": "dhcp4"}] - if self.metadata["ipv6"]: + ipv6 = self.metadata.get("ipv6") + if ( + isinstance(ipv6, dict) + and "address" in ipv6 + and "netmask" in ipv6 + and "gateway" in ipv6 + ): subnets += [ { "type": "static", - "address": "%s" % self.metadata["ipv6"]["address"], - "netmask": "%s" % self.metadata["ipv6"]["netmask"], + "address": ipv6.get("address"), + "netmask": ipv6.get("netmask"), "routes": [ { "network": "::", "prefix": "0", - "gateway": "%s" - % self.metadata["ipv6"]["gateway"], + "gateway": ipv6.get("gateway"), } ], } @@ -391,10 +396,12 @@ def launch_index(self): return None def get_instance_id(self): - return self.metadata["id"] + return self.metadata.get("id") def get_public_ssh_keys(self): - ssh_keys = [key["key"] for key in self.metadata["ssh_public_keys"]] + ssh_keys = [ + key["key"] for key in self.metadata.get("ssh_public_keys", []) + ] akeypre = "AUTHORIZED_KEY=" plen = len(akeypre) @@ -406,7 +413,7 @@ def get_public_ssh_keys(self): return ssh_keys def get_hostname(self, fqdn=False, resolve_ip=False, metadata_only=False): - return DataSourceHostname(self.metadata["hostname"], False) + return DataSourceHostname(self.metadata.get("hostname"), False) @property def availability_zone(self): diff --git a/tests/unittests/sources/test_scaleway.py b/tests/unittests/sources/test_scaleway.py index 8a0736443e5..6abf62907d0 100644 --- a/tests/unittests/sources/test_scaleway.py +++ b/tests/unittests/sources/test_scaleway.py @@ -741,6 +741,62 @@ def test_legacy_network_config_ipv6_ok(self, m_get_cmdline, fallback_nic): } assert netcfg == resp + @mock.patch("cloudinit.distros.net.find_fallback_nic") + @mock.patch("cloudinit.util.get_cmdline") + def test_legacy_network_config_ipv6_missing_key( + self, m_get_cmdline, fallback_nic + ): + """ + network_config will ignore IPv6 config if required keys are missing + """ + m_get_cmdline.return_value = "scaleway" + fallback_nic.return_value = "ens2" + self.datasource.metadata["private_ip"] = "10.10.10.10" + # Missing 'gateway' + self.datasource.metadata["ipv6"] = { + "address": "2000:abc:4444:9876::42:999", + "netmask": "127", + } + + netcfg = self.datasource.network_config + resp = { + "version": 1, + "config": [ + { + "type": "physical", + "name": "ens2", + "subnets": [{"type": "dhcp4"}], + } + ], + } + assert netcfg == resp + + @mock.patch("cloudinit.distros.net.find_fallback_nic") + @mock.patch("cloudinit.util.get_cmdline") + def test_legacy_network_config_ipv6_empty_dict( + self, m_get_cmdline, fallback_nic + ): + """ + network_config will ignore IPv6 config if it is an empty dict + """ + m_get_cmdline.return_value = "scaleway" + fallback_nic.return_value = "ens2" + self.datasource.metadata["private_ip"] = "10.10.10.10" + self.datasource.metadata["ipv6"] = {} + + netcfg = self.datasource.network_config + resp = { + "version": 1, + "config": [ + { + "type": "physical", + "name": "ens2", + "subnets": [{"type": "dhcp4"}], + } + ], + } + assert netcfg == resp + @mock.patch("cloudinit.distros.net.find_fallback_nic") @mock.patch("cloudinit.util.get_cmdline") def test_legacy_network_config_existing(self, m_get_cmdline, fallback_nic): @@ -848,6 +904,40 @@ def test_ipmob_primary_ipv4_config_ok(self, m_get_cmdline, fallback_nic): assert netcfg == resp + @mock.patch("cloudinit.distros.net.find_fallback_nic") + @mock.patch("cloudinit.util.get_cmdline") + def test_ipmob_private_ip_unset(self, m_get_cmdline, fallback_nic): + """ + network_config will generate Version 2 config if 'private_ip' is unset + """ + m_get_cmdline.return_value = "scaleway" + fallback_nic.return_value = "ens2" + # Ensure private_ip is completely absent from metadata + if "private_ip" in self.datasource.metadata: + del self.datasource.metadata["private_ip"] + self.datasource.metadata["ipv6"] = None + self.datasource.ephemeral_fixed_address = "10.10.10.10" + self.datasource.metadata["public_ips"] = [{"address": "10.10.10.10"}] + + netcfg = self.datasource.network_config + resp = { + "version": 2, + "ethernets": { + fallback_nic.return_value: { + "routes": [ + { + "on-link": True, + "to": "169.254.42.42/32", + "via": "62.210.0.1", + } + ], + "dhcp4": True, + }, + }, + } + + assert netcfg == resp + @mock.patch("cloudinit.distros.net.find_fallback_nic") @mock.patch("cloudinit.util.get_cmdline") def test_ipmob_additional_ipv4_config_ok( From 20760532b85d255314f600c713c82dab1dc075ef Mon Sep 17 00:00:00 2001 From: ffeldhaus Date: Wed, 10 Dec 2025 23:40:19 +0100 Subject: [PATCH 2/2] fix(scaleway): handle optional IPv6 netmask/gateway and fix trailing comma --- cloudinit/sources/DataSourceScaleway.py | 46 ++++++++++++------------ tests/unittests/sources/test_scaleway.py | 43 ++++++++++++++++++++-- 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/cloudinit/sources/DataSourceScaleway.py b/cloudinit/sources/DataSourceScaleway.py index 1dc98368501..f876186f99e 100644 --- a/cloudinit/sources/DataSourceScaleway.py +++ b/cloudinit/sources/DataSourceScaleway.py @@ -342,15 +342,16 @@ def network_config(self): else: ip_cfg["routes"] = [route] else: + address_cidr = ip["address"] + if ip.get("netmask"): + address_cidr += f"/{ip['netmask']}" + if "addresses" in ip_cfg.keys(): - ip_cfg["addresses"] += ( - f'{ip["address"]}/{ip["netmask"]}', - ) + ip_cfg["addresses"] += (address_cidr,) else: - ip_cfg["addresses"] = ( - f'{ip["address"]}/{ip["netmask"]}', - ) - if ip["family"] == "inet6": + ip_cfg["addresses"] = (address_cidr,) + + if ip["family"] == "inet6" and ip.get("gateway"): route = {"via": ip["gateway"], "to": "::/0"} if "routes" in ip_cfg.keys(): ip_cfg["routes"] += [route] @@ -369,23 +370,22 @@ def network_config(self): if ( isinstance(ipv6, dict) and "address" in ipv6 - and "netmask" in ipv6 - and "gateway" in ipv6 ): - subnets += [ - { - "type": "static", - "address": ipv6.get("address"), - "netmask": ipv6.get("netmask"), - "routes": [ - { - "network": "::", - "prefix": "0", - "gateway": ipv6.get("gateway"), - } - ], - } - ] + subnet = { + "type": "static", + "address": ipv6.get("address"), + } + if ipv6.get("netmask"): + subnet["netmask"] = ipv6.get("netmask") + if ipv6.get("gateway"): + subnet["routes"] = [ + { + "network": "::", + "prefix": "0", + "gateway": ipv6.get("gateway"), + } + ] + subnets.append(subnet) netcfg["subnets"] = subnets self._network_config = {"version": 1, "config": [netcfg]} LOG.debug("network_config : %s", self._network_config) diff --git a/tests/unittests/sources/test_scaleway.py b/tests/unittests/sources/test_scaleway.py index 6abf62907d0..c2abf6d7df8 100644 --- a/tests/unittests/sources/test_scaleway.py +++ b/tests/unittests/sources/test_scaleway.py @@ -747,7 +747,7 @@ def test_legacy_network_config_ipv6_missing_key( self, m_get_cmdline, fallback_nic ): """ - network_config will ignore IPv6 config if required keys are missing + network_config will include IPv6 config even if gateway is missing """ m_get_cmdline.return_value = "scaleway" fallback_nic.return_value = "ens2" @@ -765,7 +765,14 @@ def test_legacy_network_config_ipv6_missing_key( { "type": "physical", "name": "ens2", - "subnets": [{"type": "dhcp4"}], + "subnets": [ + {"type": "dhcp4"}, + { + "type": "static", + "address": "2000:abc:4444:9876::42:999", + "netmask": "127", + }, + ], } ], } @@ -1123,3 +1130,35 @@ def test_ipmob_primary_ipv6_v4_config_ok( } assert netcfg == resp + + @mock.patch("cloudinit.distros.net.find_fallback_nic") + @mock.patch("cloudinit.util.get_cmdline") + def test_ipmob_ipv6_missing_netmask_gateway( + self, m_get_cmdline, fallback_nic + ): + """ + Generate network_config with IPv6 missing netmask and gateway + """ + m_get_cmdline.return_value = "scaleway" + fallback_nic.return_value = "ens2" + self.datasource.metadata["private_ip"] = None + self.datasource.metadata["ipv6"] = None + self.datasource.ephemeral_fixed_address = "10.10.10.10" + self.datasource.metadata["public_ips"] = [ + { + "address": "2001:aaa:aaaa:a:aaaa:aaaa:aaaa:1", + "family": "inet6", + }, + ] + + netcfg = self.datasource.network_config + resp = { + "version": 2, + "ethernets": { + fallback_nic.return_value: { + "addresses": ("2001:aaa:aaaa:a:aaaa:aaaa:aaaa:1",), + }, + }, + } + + assert netcfg == resp