Package: release.debian.org Severity: normal Tags: bookworm User: release.debian....@packages.debian.org Usertags: pu X-Debbugs-Cc: cloud-i...@packages.debian.org Control: affects -1 + src:cloud-init
[ Reason ] Cloud-init is a package typically built in to cloud VM images. It provides functionality to facilitate launch-time customization of VMs based on user-defined configuration data provided to the VM by various supported mechanisms. This configuration data may contain network configuration, which cloud-init translates from its custom representation to supported configuration for one of several supported backends (e.g. ifupdown, systemd-networkd, and netplan). The cloud-init package in bookworm contains a bug that prevents it from generating valid systemd-networkd configuration from certain valid input. [ Impact ] Users are left with a broken IPv6 network configuration when attempting to configure static dualstack networking. [ Tests ] * Unit test coverage in the upstream patch * Repro case provided in the Debian bug [ Risks ] The fix has been in sid/trixie since early this year and is pretty well tested at this point. The patch cherry-picks cleanly to the bookworm version, and passes testing, so the risk should be small. Worst case scenario is that the change introduces a new regression that breaks previously working network configuration scenarios for cloud users. [ Checklist ] [*] *all* changes are documented in the d/changelog [*] I reviewed all changes and I approve them [*] attach debdiff against the package in (old)stable [*] the issue is verified as fixed in unstable [ Changes ] Upstream fix is cherry-picked from https://github.com/canonical/cloud-init/commit/f75be2ebe15b0dc78092fe47b1ef8d506607e9da
diff -Nru cloud-init-22.4.2/debian/changelog cloud-init-22.4.2/debian/changelog --- cloud-init-22.4.2/debian/changelog 2024-05-28 16:23:38.000000000 -0400 +++ cloud-init-22.4.2/debian/changelog 2024-09-17 11:08:48.000000000 -0400 @@ -1,3 +1,9 @@ +cloud-init (22.4.2-1+deb12u2) bookworm; urgency=medium + + * networkd: Add support for multiple [Route] sections (Closes: #1052535) + + -- Noah Meyerhans <no...@debian.org> Tue, 17 Sep 2024 11:08:48 -0400 + cloud-init (22.4.2-1+deb12u1) bookworm; urgency=medium * Add Conflicts/Replaces relationship on cloud-init-22.4.2 diff -Nru cloud-init-22.4.2/debian/patches/networkd_Add_support_for_multiple_Route_sections.patch cloud-init-22.4.2/debian/patches/networkd_Add_support_for_multiple_Route_sections.patch --- cloud-init-22.4.2/debian/patches/networkd_Add_support_for_multiple_Route_sections.patch 1969-12-31 19:00:00.000000000 -0500 +++ cloud-init-22.4.2/debian/patches/networkd_Add_support_for_multiple_Route_sections.patch 2024-09-17 11:08:48.000000000 -0400 @@ -0,0 +1,215 @@ +From f75be2ebe15b0dc78092fe47b1ef8d506607e9da Mon Sep 17 00:00:00 2001 +From: Nigel Kukard <nkuk...@lbsd.net> +Date: Wed, 7 Dec 2022 23:29:52 +0000 +Subject: [PATCH] networkd: Add support for multiple [Route] sections (#1868) + +Networkd supports multiple [Route] sections within the same file. +Currently all [Route] section tags are squashed into one and if there +is a default gateway it means defining a device route is not possible +as the target is set to the default gateway. + +This patch adds support for multiple [Route] sections allowing us to +support device routes. This is done by tracking each route in the route +list individually and ensuring the key-value pairs are maintained within +their respective [Route] section. This both maintains backwards +compatibility with previous behavior and allows the specification of +routes with no destination IP, causing the destination to be added with +a device target. +--- + cloudinit/net/networkd.py | 51 ++++++++++++++++++++++--- + tests/unittests/net/test_networkd.py | 57 +++++++++++++++++++++++++++- + 2 files changed, 101 insertions(+), 7 deletions(-) + +Index: cloud-init/cloudinit/net/networkd.py +=================================================================== +--- cloud-init.orig/cloudinit/net/networkd.py ++++ cloud-init/cloudinit/net/networkd.py +@@ -28,7 +28,7 @@ class CfgParser: + "DHCPv4": [], + "DHCPv6": [], + "Address": [], +- "Route": [], ++ "Route": {}, + } + ) + +@@ -40,6 +40,22 @@ class CfgParser: + self.conf_dict[k] = list(dict.fromkeys(self.conf_dict[k])) + self.conf_dict[k].sort() + ++ def update_route_section(self, sec, rid, key, val): ++ """ ++ For each route section we use rid as a key, this allows us to isolate ++ this route from others on subsequent calls. ++ """ ++ for k in self.conf_dict.keys(): ++ if k == sec: ++ if rid not in self.conf_dict[k]: ++ self.conf_dict[k][rid] = [] ++ self.conf_dict[k][rid].append(key + "=" + str(val)) ++ # remove duplicates from list ++ self.conf_dict[k][rid] = list( ++ dict.fromkeys(self.conf_dict[k][rid]) ++ ) ++ self.conf_dict[k][rid].sort() ++ + def get_final_conf(self): + contents = "" + for k, v in sorted(self.conf_dict.items()): +@@ -50,6 +66,12 @@ class CfgParser: + contents += "[" + k + "]\n" + contents += e + "\n" + contents += "\n" ++ elif k == "Route": ++ for n in sorted(v): ++ contents += "[" + k + "]\n" ++ for e in sorted(v[n]): ++ contents += e + "\n" ++ contents += "\n" + else: + contents += "[" + k + "]\n" + for e in sorted(v): +@@ -112,7 +134,11 @@ class Renderer(renderer.Renderer): + if "mtu" in iface and iface["mtu"]: + cfg.update_section(sec, "MTUBytes", iface["mtu"]) + +- def parse_routes(self, conf, cfg: CfgParser): ++ def parse_routes(self, rid, conf, cfg: CfgParser): ++ """ ++ Parse a route and use rid as a key in order to isolate the route from ++ others in the route dict. ++ """ + sec = "Route" + route_cfg_map = { + "gateway": "Gateway", +@@ -130,11 +156,12 @@ class Renderer(renderer.Renderer): + continue + if k == "network": + v += prefix +- cfg.update_section(sec, route_cfg_map[k], v) ++ cfg.update_route_section(sec, rid, route_cfg_map[k], v) + + def parse_subnets(self, iface, cfg: CfgParser): + dhcp = "no" + sec = "Network" ++ rid = 0 + for e in iface.get("subnets", []): + t = e["type"] + if t == "dhcp4" or t == "dhcp": +@@ -149,7 +176,10 @@ class Renderer(renderer.Renderer): + dhcp = "yes" + if "routes" in e and e["routes"]: + for i in e["routes"]: +- self.parse_routes(i, cfg) ++ # Use "r" as a dict key prefix for this route to isolate ++ # it from other sources of routes ++ self.parse_routes(f"r{rid}", i, cfg) ++ rid = rid + 1 + if "address" in e: + subnet_cfg_map = { + "address": "Address", +@@ -163,7 +193,12 @@ class Renderer(renderer.Renderer): + v += "/" + str(e["prefix"]) + cfg.update_section("Address", subnet_cfg_map[k], v) + elif k == "gateway": +- cfg.update_section("Route", subnet_cfg_map[k], v) ++ # Use "a" as a dict key prefix for this route to ++ # isolate it from other sources of routes ++ cfg.update_route_section( ++ "Route", f"a{rid}", subnet_cfg_map[k], v ++ ) ++ rid = rid + 1 + elif k == "dns_nameservers" or k == "dns_search": + cfg.update_section(sec, subnet_cfg_map[k], " ".join(v)) + +@@ -280,8 +315,12 @@ class Renderer(renderer.Renderer): + dhcp = self.parse_subnets(iface, cfg) + self.parse_dns(iface, cfg, ns) + ++ rid = 0 + for route in ns.iter_routes(): +- self.parse_routes(route, cfg) ++ # Use "c" as a dict key prefix for this route to isolate it ++ # from other sources of routes ++ self.parse_routes(f"c{rid}", route, cfg) ++ rid = rid + 1 + + if ns.version == 2: + name: Optional[str] = iface["name"] +Index: cloud-init/tests/unittests/net/test_networkd.py +=================================================================== +--- cloud-init.orig/tests/unittests/net/test_networkd.py ++++ cloud-init/tests/unittests/net/test_networkd.py +@@ -195,10 +195,54 @@ Domains=rgrunbla.github.beta.tailscale.n + + [Route] + Gateway=10.0.0.1 ++ ++[Route] + Gateway=2a01:4f8:10a:19d2::2 + + """ + ++V2_CONFIG_MULTI_SUBNETS = """ ++network: ++ version: 2 ++ ethernets: ++ eth0: ++ addresses: ++ - 192.168.1.1/24 ++ - fec0::1/64 ++ gateway4: 192.168.254.254 ++ gateway6: "fec0::ffff" ++ routes: ++ - to: 169.254.1.1/32 ++ - to: "fe80::1/128" ++""" ++ ++V2_CONFIG_MULTI_SUBNETS_RENDERED = """\ ++[Address] ++Address=192.168.1.1/24 ++ ++[Address] ++Address=fec0::1/64 ++ ++[Match] ++Name=eth0 ++ ++[Network] ++DHCP=no ++ ++[Route] ++Gateway=192.168.254.254 ++ ++[Route] ++Gateway=fec0::ffff ++ ++[Route] ++Destination=169.254.1.1/32 ++ ++[Route] ++Destination=fe80::1/128 ++ ++""" ++ + + class TestNetworkdRenderState: + def _parse_network_state_from_config(self, config): +@@ -307,5 +351,16 @@ class TestNetworkdRenderState: + + assert rendered_content["eth0"] == V1_CONFIG_MULTI_SUBNETS_RENDERED + ++ def test_networkd_render_v2_multi_subnets(self): ++ """ ++ Ensure a device with multiple subnets gets correctly rendered. ++ ++ Per systemd-networkd docs, [Route] can only contain a single instance ++ of Gateway. ++ """ ++ with mock.patch("cloudinit.net.get_interfaces_by_mac"): ++ ns = self._parse_network_state_from_config(V2_CONFIG_MULTI_SUBNETS) ++ renderer = networkd.Renderer() ++ rendered_content = renderer._render_content(ns) + +-# vi: ts=4 expandtab ++ assert rendered_content["eth0"] == V2_CONFIG_MULTI_SUBNETS_RENDERED diff -Nru cloud-init-22.4.2/debian/patches/series cloud-init-22.4.2/debian/patches/series --- cloud-init-22.4.2/debian/patches/series 2024-05-28 16:23:38.000000000 -0400 +++ cloud-init-22.4.2/debian/patches/series 2024-09-17 11:08:48.000000000 -0400 @@ -3,3 +3,4 @@ 0009-Drop-all-unused-extended-version-handling.patch 0012-Fix-message-when-a-local-is-missing.patch 0001-config-Support-APT-automated-mirror-selection.patch +networkd_Add_support_for_multiple_Route_sections.patch