diff --git a/cloudinit/sources/DataSourceOpenNebula.py b/cloudinit/sources/DataSourceOpenNebula.py index 54ee0ec6d66..52421201e71 100644 --- a/cloudinit/sources/DataSourceOpenNebula.py +++ b/cloudinit/sources/DataSourceOpenNebula.py @@ -279,9 +279,8 @@ def get_field( return default if val in (None, "") else val def gen_conf(self) -> Dict[str, Any]: - netconf: Dict[str, Any] = {"version": 2, "ethernets": {}} + netconf: Dict[str, Any] = {"version": 2, "ethernets": {}, "vlans": {}} - ethernets: Dict[str, Dict[str, Any]] = {} for mac, dev in self.ifaces.items(): mac = mac.lower() @@ -289,10 +288,18 @@ def gen_conf(self) -> Dict[str, Any]: # dev stores the current system name. c_dev = self.context_devname.get(mac, dev) - devconf: Dict[str, Any] = {} + vlan_id: Optional[str] = self.get_field(c_dev, "vlan_id") + devconf: Dict[str, Any] - # Set MAC address - devconf["match"] = {"macaddress": mac} + if vlan_id: + # Parent: just bring it up, no IP configuration + netconf["ethernets"][dev] = {"match": {"macaddress": mac}} + # VLAN sub-interface carries the actual IP config + target_name = "%s.%s" % (dev, vlan_id) + devconf = {"id": int(vlan_id), "link": dev} + else: + target_name = dev + devconf = {"match": {"macaddress": mac}} # Set IPv4 address devconf["addresses"] = [] @@ -333,9 +340,15 @@ def gen_conf(self) -> Dict[str, Any]: if extra_routes: devconf["routes"] = extra_routes - ethernets[dev] = devconf + if vlan_id: + netconf["vlans"][target_name] = devconf + else: + netconf["ethernets"][target_name] = devconf + + # Remove empty top-level sections + if not netconf["vlans"]: + del netconf["vlans"] - netconf["ethernets"] = ethernets return netconf diff --git a/doc/rtd/reference/datasources/opennebula.rst b/doc/rtd/reference/datasources/opennebula.rst index 3e31b20065b..54dfdd0c71b 100644 --- a/doc/rtd/reference/datasources/opennebula.rst +++ b/doc/rtd/reference/datasources/opennebula.rst @@ -68,6 +68,7 @@ the OpenNebula documentation. ETH_DNS ETH_SEARCH_DOMAIN ETH_MTU + ETH_VLAN_ID ETH_IP6 ETH_IP6_ULA ETH_IP6_PREFIX_LENGTH @@ -76,6 +77,11 @@ the OpenNebula documentation. Static `network configuration`_. +When ``ETH_VLAN_ID`` is set, ``cloud-init`` creates an 802.1Q VLAN +sub-interface named ``.`` (e.g. ``eth0.100``) and assigns +all IP configuration to that sub-interface. The parent interface is +brought up with no address. + ``ETH_ROUTES`` is a comma-separated list of static routes in the form ``NETWORK via GATEWAY``. For example:: diff --git a/tests/unittests/sources/test_opennebula.py b/tests/unittests/sources/test_opennebula.py index 10ccdea2ed3..97bc92a9ce0 100644 --- a/tests/unittests/sources/test_opennebula.py +++ b/tests/unittests/sources/test_opennebula.py @@ -975,6 +975,83 @@ def test_multiple_nics(self): assert expected == net.gen_conf() + # --- ETHx_VLAN_ID tests --- + + @mock.patch(DS_PATH + ".get_physical_nics_by_mac") + def test_gen_conf_vlan_id(self, m_get_phys_by_mac): + """VLAN_ID set → vlans: section; parent has no addresses.""" + context = { + "ETH0_MAC": MACADDR, + "ETH0_IP": PUBLIC_IP, + "ETH0_MASK": "255.255.255.0", + "ETH0_VLAN_ID": "100", + } + for nic in self.system_nics: + m_get_phys_by_mac.return_value = {MACADDR: nic} + net = ds.OpenNebulaNetwork(context, mock.Mock()) + conf = net.gen_conf() + # Parent ethernet: just MAC match, no addresses + assert nic in conf["ethernets"] + assert "addresses" not in conf["ethernets"][nic] + # VLAN child carries the IP config + vlan_name = "%s.100" % nic + assert "vlans" in conf + assert vlan_name in conf["vlans"] + vlan = conf["vlans"][vlan_name] + assert vlan["id"] == 100 + assert vlan["link"] == nic + assert PUBLIC_IP + "/24" in vlan["addresses"] + + @mock.patch(DS_PATH + ".get_physical_nics_by_mac") + def test_gen_conf_no_vlan_no_vlans_key(self, m_get_phys_by_mac): + """Without VLAN_ID the output has no 'vlans' key.""" + context = {"ETH0_MAC": MACADDR} + for nic in self.system_nics: + m_get_phys_by_mac.return_value = {MACADDR: nic} + net = ds.OpenNebulaNetwork(context, mock.Mock()) + conf = net.gen_conf() + assert "vlans" not in conf + + @mock.patch(DS_PATH + ".get_physical_nics_by_mac") + def test_gen_conf_vlan_with_gateway(self, m_get_phys_by_mac): + """Gateway on a VLAN interface ends up in the vlans entry.""" + context = { + "ETH0_MAC": MACADDR, + "ETH0_IP": PUBLIC_IP, + "ETH0_GATEWAY": "10.0.0.1", + "ETH0_VLAN_ID": "200", + } + m_get_phys_by_mac.return_value = {MACADDR: "eth0"} + net = ds.OpenNebulaNetwork(context, mock.Mock()) + conf = net.gen_conf() + vlan = conf["vlans"]["eth0.200"] + assert vlan.get("gateway4") == "10.0.0.1" + assert "gateway4" not in conf["ethernets"]["eth0"] + + @mock.patch(DS_PATH + ".get_physical_nics_by_mac") + def test_gen_conf_vlan_mixed_nics(self, m_get_phys_by_mac): + """One NIC with VLAN, one without: only one vlans entry.""" + MAC_1 = "02:00:0a:12:01:01" + MAC_2 = "02:00:0a:12:01:02" + context = { + "ETH0_MAC": MAC_1, + "ETH0_IP": "10.0.0.1", + "ETH0_VLAN_ID": "10", + "ETH1_MAC": MAC_2, + "ETH1_IP": "10.0.1.1", + } + net = ds.OpenNebulaNetwork( + context, + mock.Mock(), + system_nics_by_mac={MAC_1: "eth0", MAC_2: "eth1"}, + ) + conf = net.gen_conf() + assert "vlans" in conf + assert "eth0.10" in conf["vlans"] + assert "eth1" in conf["ethernets"] + assert "addresses" in conf["ethernets"]["eth1"] + assert "addresses" not in conf["ethernets"]["eth0"] + # ------------------------------------------------------------------ # # ETHx_ROUTES # # ------------------------------------------------------------------ #