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

Reply via email to