diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py index 32c5a4c7151..2bdd2b614c0 100644 --- a/cloudinit/net/network_state.py +++ b/cloudinit/net/network_state.py @@ -47,6 +47,7 @@ "set-name", "wakeonlan", "accept-ra", + "optional", ] NET_CONFIG_TO_V2: Dict[str, Dict[str, Any]] = { @@ -409,6 +410,9 @@ def handle_physical(self, command): wakeonlan = command.get("wakeonlan", None) if wakeonlan is not None: wakeonlan = util.is_true(wakeonlan) + optional = command.get("optional", None) + if optional is not None: + optional = util.is_true(optional) iface.update( { "config_id": command.get("config_id"), @@ -423,6 +427,7 @@ def handle_physical(self, command): "subnets": subnets, "accept-ra": accept_ra, "wakeonlan": wakeonlan, + "optional": optional, } ) iface_key = command.get("config_id", command.get("name")) @@ -747,7 +752,7 @@ def handle_ethernets(self, command): driver = match.get("driver", None) if driver: phy_cmd["params"] = {"driver": driver} - for key in ["mtu", "match", "wakeonlan", "accept-ra"]: + for key in ["mtu", "match", "wakeonlan", "accept-ra", "optional"]: if key in cfg: phy_cmd[key] = cfg[key] diff --git a/cloudinit/net/networkd.py b/cloudinit/net/networkd.py index 7a511288077..345935d68a4 100644 --- a/cloudinit/net/networkd.py +++ b/cloudinit/net/networkd.py @@ -122,6 +122,9 @@ def generate_link_section(self, iface, cfg: CfgParser): if "mtu" in iface and iface["mtu"]: cfg.update_section(sec, "MTUBytes", iface["mtu"]) + if "optional" in iface and iface["optional"]: + cfg.update_section(sec, "RequiredForOnline", "no") + def parse_routes(self, rid, conf, cfg: CfgParser): """ Parse a route and use rid as a key in order to isolate the route from diff --git a/doc/rtd/reference/network-config-format-v2.rst b/doc/rtd/reference/network-config-format-v2.rst index 615122cf73c..b8792fce2d3 100644 --- a/doc/rtd/reference/network-config-format-v2.rst +++ b/doc/rtd/reference/network-config-format-v2.rst @@ -264,6 +264,14 @@ The MTU key represents a device's Maximum Transmission Unit, the largest size packet or frame, specified in octets (eight-bit bytes), that can be sent in a packet- or frame-based network. Specifying ``mtu`` is optional. +``optional: <(bool)>`` +------------------------ + +Mark a device as not required for booting. By default networkd will wait for +all configured interfaces to be configured before continuing to boot. This +option causes networkd to not wait for the interface. This is only supported +by networkd. The default is false. + ``nameservers: <(mapping)>`` ---------------------------- diff --git a/tests/unittests/net/test_networkd.py b/tests/unittests/net/test_networkd.py index 15708f51f54..598a5d1d95b 100644 --- a/tests/unittests/net/test_networkd.py +++ b/tests/unittests/net/test_networkd.py @@ -10,6 +10,35 @@ from cloudinit import safeyaml from cloudinit.net import network_state, networkd +V2_CONFIG_OPTIONAL = """\ +network: + version: 2 + ethernets: + eth0: + optional: true + eth1: + optional: false +""" + +V2_CONFIG_OPTIONAL_RENDERED_ETH0 = """[Link] +RequiredForOnline=no + +[Match] +Name=eth0 + +[Network] +DHCP=no + +""" + +V2_CONFIG_OPTIONAL_RENDERED_ETH1 = """[Match] +Name=eth1 + +[Network] +DHCP=no + +""" + V2_CONFIG_SET_NAME = """\ network: version: 2 @@ -452,6 +481,17 @@ def _parse_network_state_from_config(self, config): config = yaml.safe_load(config) return network_state.parse_net_config_data(config["network"]) + def test_networkd_render_with_optional(self): + with mock.patch("cloudinit.net.get_interfaces_by_mac"): + ns = self._parse_network_state_from_config(V2_CONFIG_OPTIONAL) + renderer = networkd.Renderer() + rendered_content = renderer._render_content(ns) + + assert "eth0" in rendered_content + assert rendered_content["eth0"] == V2_CONFIG_OPTIONAL_RENDERED_ETH0 + assert "eth1" in rendered_content + assert rendered_content["eth1"] == V2_CONFIG_OPTIONAL_RENDERED_ETH1 + def test_networkd_render_with_set_name(self): with mock.patch("cloudinit.net.get_interfaces_by_mac"): ns = self._parse_network_state_from_config(V2_CONFIG_SET_NAME)