diff --git a/cloudinit/config/schemas/schema-network-config-v1.json b/cloudinit/config/schemas/schema-network-config-v1.json index 99e8c68b55b..6058e6c07a5 100644 --- a/cloudinit/config/schemas/schema-network-config-v1.json +++ b/cloudinit/config/schemas/schema-network-config-v1.json @@ -40,6 +40,10 @@ "accept-ra": { "type": "boolean", "description": "Whether to accept IPv6 Router Advertisements (RA) on this interface. If unset, it will not be rendered" + }, + "keep_configuration": { + "type": "boolean", + "description": "Designate the connection as 'critical to the system', meaning that special care will be taken not to release the assigned IP when the daemon is restarted. (only recognized by Netplan renderer)." } } }, diff --git a/cloudinit/net/netplan.py b/cloudinit/net/netplan.py index 50af17694d5..816deb45883 100644 --- a/cloudinit/net/netplan.py +++ b/cloudinit/net/netplan.py @@ -437,6 +437,8 @@ def _render_content(self, network_state: NetworkState) -> str: "set-name": ifname, "match": ifcfg.get("match", None), } + if "keep_configuration" in ifcfg: + eth["critical"] = ifcfg["keep_configuration"] if eth["match"] is None: macaddr = ifcfg.get("mac_address", None) if macaddr is not None: diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py index 2bdd2b614c0..9fd51e42916 100644 --- a/cloudinit/net/network_state.py +++ b/cloudinit/net/network_state.py @@ -428,6 +428,7 @@ def handle_physical(self, command): "accept-ra": accept_ra, "wakeonlan": wakeonlan, "optional": optional, + "keep_configuration": command.get("keep_configuration"), } ) iface_key = command.get("config_id", command.get("name")) diff --git a/cloudinit/sources/DataSourceOracle.py b/cloudinit/sources/DataSourceOracle.py index 988fc7d2d07..02b8ec53227 100644 --- a/cloudinit/sources/DataSourceOracle.py +++ b/cloudinit/sources/DataSourceOracle.py @@ -296,6 +296,10 @@ def network_config(self): # this is v1 if self._is_iscsi_root(): self._network_config = self._get_iscsi_config() + logging.debug( + "Instance is using iSCSI root, setting primary NIC as critical" + ) + self._network_config["config"][0]["keep_configuration"] = True if not self._has_network_config(): LOG.warning( "Could not obtain network configuration from initramfs. " @@ -380,65 +384,41 @@ def _add_network_config_from_opc_imds(self, set_primary: bool = False): else: network = ipaddress.ip_network(vnic_dict["subnetCidrBlock"]) - if self._network_config["version"] == 1: - if is_primary: - if is_ipv6_only: - subnets = [{"type": "dhcp6"}] - else: - subnets = [{"type": "dhcp"}] + if is_primary: + if is_ipv6_only: + subnets = [{"type": "dhcp6"}] else: - subnets = [] - if vnic_dict.get("privateIp"): - subnets.append( - { - "type": "static", - "address": ( - f"{vnic_dict['privateIp']}/" - f"{network.prefixlen}" - ), - } - ) - if vnic_dict.get("ipv6Addresses"): - subnets.append( - { - "type": "static", - "address": ( - f"{vnic_dict['ipv6Addresses'][0]}/" - f"{network.prefixlen}" - ), - } - ) - interface_config = { - "name": name, - "type": "physical", - "mac_address": mac_address, - "mtu": MTU, - "subnets": subnets, - } - self._network_config["config"].append(interface_config) - elif self._network_config["version"] == 2: - # Why does this elif exist??? - # Are there plans to switch to v2? - interface_config = { - "mtu": MTU, - "match": {"macaddress": mac_address}, - } - self._network_config["ethernets"][name] = interface_config - - interface_config["dhcp6"] = is_primary and is_ipv6_only - interface_config["dhcp4"] = is_primary and not is_ipv6_only - if not is_primary: - interface_config["addresses"] = [] - if vnic_dict.get("privateIp"): - interface_config["addresses"].append( - f"{vnic_dict['privateIp']}/{network.prefixlen}" - ) - if vnic_dict.get("ipv6Addresses"): - interface_config["addresses"].append( - f"{vnic_dict['ipv6Addresses'][0]}/" - f"{network.prefixlen}" - ) - self._network_config["ethernets"][name] = interface_config + subnets = [{"type": "dhcp"}] + else: + subnets = [] + if vnic_dict.get("privateIp"): + subnets.append( + { + "type": "static", + "address": ( + f"{vnic_dict['privateIp']}/" + f"{network.prefixlen}" + ), + } + ) + if vnic_dict.get("ipv6Addresses"): + subnets.append( + { + "type": "static", + "address": ( + f"{vnic_dict['ipv6Addresses'][0]}/" + f"{network.prefixlen}" + ), + } + ) + interface_config = { + "name": name, + "type": "physical", + "mac_address": mac_address, + "mtu": MTU, + "subnets": subnets, + } + self._network_config["config"].append(interface_config) class DataSourceOracleNet(DataSourceOracle): diff --git a/tests/integration_tests/datasources/test_oci_networking.py b/tests/integration_tests/datasources/test_oci_networking.py index 9b807927668..10ba27bb0ec 100644 --- a/tests/integration_tests/datasources/test_oci_networking.py +++ b/tests/integration_tests/datasources/test_oci_networking.py @@ -157,3 +157,46 @@ def test_oci_networking_system_cfg(client: IntegrationInstance, tmpdir): netplan_cfg = yaml.safe_load(netplan_yaml) expected_netplan_cfg = yaml.safe_load(SYSTEM_CFG) assert expected_netplan_cfg == netplan_cfg + + +@pytest.mark.skipif(PLATFORM != "oci", reason="Test is OCI specific") +def test_oci_keep_configuration_networking_config( + session_cloud: IntegrationCloud, +): + """ + Test to ensure the keep_configuration is applied on Oracle ISCSI instances. + + This test launches a Baremetal OCI instance so that ISCSI is used, and + checks that the primary systemd network configuration file contains the + 'KeepConfiguration=true' directive, which indicates that the network + configuration is preserved as expected. + + Assertions: + - At least one netplan file exists under '/run/systemd/network'. + - The primary systemd network configuration file includes the + 'KeepConfiguration=true' directive. + - The netplan configuration includes the 'critical: true' directive. + """ + with session_cloud.launch( + launch_kwargs={ + "instance_type": "BM.Optimized3.36", + }, + ) as client: + r = client.execute("ls /run/systemd/network/10-netplan-*.network") + assert r.ok, ( + "No netplan files found under /run/systemd/network. We are looking" + " for netplan files here to check that the underlying " + "'KeepConfiguration=true' directive is actually being applied to " + "the systemd network configuration." + ) + primary_systemd_file: str = r.stdout.strip().splitlines()[0] + systemd_config = client.read_from_file(primary_systemd_file) + assert ( + "KeepConfiguration=true" in systemd_config + ), f"KeepConfiguration=true not found in {primary_systemd_file}" + netplan_config = client.read_from_file( + "/etc/netplan/50-cloud-init.yaml", + ) + assert ( + "critical: true" in netplan_config + ), "critical: true not found in netplan config" diff --git a/tests/unittests/sources/test_oracle.py b/tests/unittests/sources/test_oracle.py index d773acf7717..871a9e1b38b 100644 --- a/tests/unittests/sources/test_oracle.py +++ b/tests/unittests/sources/test_oracle.py @@ -485,48 +485,6 @@ def test_imds_nic_setup_v1(self, set_primary, oracle_ds): assert "10.0.0.231/24" == secondary_cfg["subnets"][0]["address"] assert "static" == secondary_cfg["subnets"][0]["type"] - @pytest.mark.parametrize( - "set_primary", - [True, False], - ) - def test_secondary_nic_v2(self, set_primary, oracle_ds): - oracle_ds._vnics_data = json.loads(OPC_VM_SECONDARY_VNIC_RESPONSE) - oracle_ds._network_config = { - "version": 2, - "ethernets": {"primary": {"nic": {}}}, - } - with mock.patch( - f"{DS_PATH}.get_interfaces_by_mac", - return_value={ - "02:00:17:05:d1:db": "ens3", - "00:00:17:02:2b:b1": "ens4", - }, - ): - oracle_ds._add_network_config_from_opc_imds( - set_primary=set_primary - ) - - nic_cfg = oracle_ds.network_config["ethernets"] - if set_primary: - assert "ens3" in nic_cfg - primary_cfg = nic_cfg["ens3"] - - assert primary_cfg["dhcp4"] is True - assert primary_cfg["dhcp6"] is False - assert "02:00:17:05:d1:db" == primary_cfg["match"]["macaddress"] - assert 9000 == primary_cfg["mtu"] - assert "addresses" not in primary_cfg - - assert "ens4" in nic_cfg - secondary_cfg = nic_cfg["ens4"] - assert secondary_cfg["dhcp4"] is False - assert secondary_cfg["dhcp6"] is False - assert "00:00:17:02:2b:b1" == secondary_cfg["match"]["macaddress"] - assert 9000 == secondary_cfg["mtu"] - - assert 1 == len(secondary_cfg["addresses"]) - assert "10.0.0.231/24" == secondary_cfg["addresses"][0] - @pytest.mark.parametrize( "set_primary", [ @@ -578,53 +536,6 @@ def test_imds_nic_setup_v1_ipv6_only(self, set_primary, oracle_ds): ) assert "static" == secondary_cfg["subnets"][0]["type"] - @pytest.mark.parametrize( - "set_primary", - [True, False], - ) - def test_secondary_nic_v2_ipv6_only(self, set_primary, oracle_ds): - oracle_ds._vnics_data = json.loads( - OPC_VM_IPV6_ONLY_SECONDARY_VNIC_RESPONSE - ) - oracle_ds._network_config = { - "version": 2, - "ethernets": {"primary": {"nic": {}}}, - } - with mock.patch( - f"{DS_PATH}.get_interfaces_by_mac", - return_value={ - "02:00:17:0d:6b:be": "ens3", - "02:00:17:18:f6:ff": "ens4", - }, - ): - oracle_ds._add_network_config_from_opc_imds( - set_primary=set_primary - ) - - nic_cfg = oracle_ds.network_config["ethernets"] - if set_primary: - assert "ens3" in nic_cfg - primary_cfg = nic_cfg["ens3"] - - assert primary_cfg["dhcp4"] is False - assert primary_cfg["dhcp6"] is True - assert "02:00:17:0d:6b:be" == primary_cfg["match"]["macaddress"] - assert 9000 == primary_cfg["mtu"] - assert "addresses" not in primary_cfg - - assert "ens4" in nic_cfg - secondary_cfg = nic_cfg["ens4"] - assert secondary_cfg["dhcp4"] is False - assert secondary_cfg["dhcp6"] is False - assert "02:00:17:18:f6:ff" == secondary_cfg["match"]["macaddress"] - assert 9000 == secondary_cfg["mtu"] - - assert 1 == len(secondary_cfg["addresses"]) - assert ( - "2603:c020:400d:5d7e:aacc:8e5f:3b1b:3a4a/128" - == secondary_cfg["addresses"][0] - ) - @pytest.mark.parametrize("error_add_network", [None, Exception]) @pytest.mark.parametrize( "configure_secondary_nics",