Skip to content
18 changes: 9 additions & 9 deletions cloudinit/net/network_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,21 +743,21 @@ def handle_ethernets(self, command):
)
phy_cmd["mac_address"] = mac_address

# Determine the name of the interface by using one of the
# following in the order they are listed:
# * set-name
# * interface name looked up by mac
# * value of "eth" key from this loop
name = eth
# Determine and bind interface name here
# (must be resolved before handle_physical)
resolved_name = None
set_name = cfg.get("set-name")
if set_name:
name = set_name
resolved_name = set_name

elif mac_address and ifaces_by_mac:
lcase_mac_address = mac_address.lower()
mac = find_interface_name_from_mac(lcase_mac_address)
if mac:
name = mac
phy_cmd["name"] = name
resolved_name = mac

# Always fall back to config key
phy_cmd["name"] = resolved_name or eth

driver = match.get("driver", None)
if driver:
Expand Down
9 changes: 9 additions & 0 deletions cloudinit/sources/DataSourceEc2.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,15 @@ def network_config(self):
# SRU_BLOCKER: xenial, bionic and eoan should default
# apply_full_imds_network_config to False to retain original
# behavior on those releases.
primary_mac = ec2.get_primary_mac_from_metadata(self.metadata)
if primary_mac:
LOG.debug(
"Identified primary NIC via EC2 metadata: %s",
primary_mac,
)
else:
LOG.debug("No primary NIC identified via EC2 metadata")

result = convert_ec2_metadata_network_config(
net_md,
self.distro,
Expand Down
75 changes: 75 additions & 0 deletions cloudinit/sources/helpers/ec2.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import functools
import json
import logging
from typing import Any, Dict, Optional

from cloudinit import url_helper, util

Expand Down Expand Up @@ -231,6 +232,80 @@ def mcaller(url):
return {}


def get_primary_mac_from_metadata(
metadata: Optional[Dict[str, Any]],
) -> Optional[str]:
"""
Determine the primary NIC MAC address from EC2 metadata.

The primary NIC is defined as the interface with:
- network-card == 0
- device-number == 0

Metadata is expected to be materialized EC2 metadata as returned by
get_instance_metadata().

Returns:
str: MAC address of the primary NIC if found
None: if no primary NIC can be determined
"""
if not isinstance(metadata, dict):
LOG.debug(
"EC2 metadata missing or malformed; cannot determine primary MAC"
)
return None

network = metadata.get("network")
if not isinstance(network, dict):
LOG.debug(
"EC2 metadata missing or malformed; cannot determine primary MAC"
)
return None

interfaces = network.get("interfaces")
if not isinstance(interfaces, dict):
LOG.debug(
"EC2 metadata missing or malformed; cannot determine primary MAC"
)
return None

macs_metadata = interfaces.get("macs")
if not isinstance(macs_metadata, dict) or not macs_metadata:
LOG.debug("No NIC metadata found in EC2 metadata")
return None

primary_candidates: list[str] = []

for mac, nic_md in macs_metadata.items():
if not isinstance(nic_md, dict):
continue

try:
network_card = int(nic_md.get("network-card", -1))
device_number = int(nic_md.get("device-number", -1))
except (TypeError, ValueError):
continue

if network_card == 0 and device_number == 0:
primary_candidates.append(mac)

if len(primary_candidates) == 1:
return primary_candidates[0]

if len(primary_candidates) > 1:
# Deterministic fallback: lowest MAC lexicographically
chosen = sorted(primary_candidates)[0]
LOG.debug(
"Multiple primary NIC candidates found %s; selected %s",
primary_candidates,
chosen,
)
return chosen

LOG.debug("No primary NIC identified via EC2 metadata")
return None


def get_instance_metadata(
api_version="latest",
metadata_address="http://169.254.169.254",
Expand Down
62 changes: 62 additions & 0 deletions tests/unittests/net/test_network_state_interface_resolution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from cloudinit.net.network_state import NetworkStateInterpreter


def test_interface_with_default_route_is_preferred():
config = {
"version": 2,
"ethernets": {
"eth0": {
"match": {"macaddress": "aa:bb:cc:dd:ee:01"},
"dhcp4": True,
},
"eth1": {
"match": {"macaddress": "aa:bb:cc:dd:ee:02"},
"addresses": ["10.0.0.2/24"],
"routes": [{"to": "0.0.0.0/0", "via": "10.0.0.1"}],
},
},
}

nsi = NetworkStateInterpreter(version=2, config=config)
nsi.parse_config()
state = nsi.network_state

assert "eth1" in state._network_state["interfaces"]
iface = state._network_state["interfaces"]["eth1"]

# eth1 must be selected because it has default route
assert iface["name"] == "eth1"


def test_single_interface_resolution_unchanged():
config = {
"version": 2,
"ethernets": {
"eth0": {
"dhcp4": True,
}
},
}

nsi = NetworkStateInterpreter(version=2, config=config)
nsi.parse_config()
state = nsi.network_state

assert "eth0" in state._network_state["interfaces"]


def test_deterministic_fallback_without_routes():
config = {
"version": 2,
"ethernets": {
"eth9": {"dhcp4": True},
"eth1": {"dhcp4": True},
},
}

nsi = NetworkStateInterpreter(version=2, config=config)
nsi.parse_config()
state = nsi.network_state

# eth1 should win deterministically
assert "eth1" in state._network_state["interfaces"]
90 changes: 90 additions & 0 deletions tests/unittests/sources/helpers/test_ec2.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,96 @@

from cloudinit import url_helper as uh
from cloudinit.sources.helpers import ec2
from cloudinit.sources.helpers.ec2 import get_primary_mac_from_metadata


class TestGetPrimaryMacFromMetadata:
def test_no_metadata(self):
assert get_primary_mac_from_metadata(None) is None

def test_empty_metadata(self):
assert get_primary_mac_from_metadata({}) is None

def test_no_network_section(self):
md = {"foo": "bar"}
assert get_primary_mac_from_metadata(md) is None

def test_single_primary_nic(self):
md = {
"network": {
"interfaces": {
"macs": {
"aa:bb:cc:dd:ee:ff": {
"network-card": "0",
"device-number": "0",
},
"11:22:33:44:55:66": {
"network-card": "1",
"device-number": "0",
},
}
}
}
}

assert get_primary_mac_from_metadata(md) == "aa:bb:cc:dd:ee:ff"

def test_primary_not_first_in_dict(self):
md = {
"network": {
"interfaces": {
"macs": {
"11:22:33:44:55:66": {
"network-card": "1",
"device-number": "0",
},
"aa:bb:cc:dd:ee:ff": {
"network-card": "0",
"device-number": "0",
},
}
}
}
}

assert get_primary_mac_from_metadata(md) == "aa:bb:cc:dd:ee:ff"

def test_multiple_primary_candidates(self):
md = {
"network": {
"interfaces": {
"macs": {
"bb:bb:bb:bb:bb:bb": {
"network-card": "0",
"device-number": "0",
},
"aa:aa:aa:aa:aa:aa": {
"network-card": "0",
"device-number": "0",
},
}
}
}
}

# Deterministic: lowest lexicographically
assert get_primary_mac_from_metadata(md) == "aa:aa:aa:aa:aa:aa"

def test_invalid_values_are_ignored(self):
md = {
"network": {
"interfaces": {
"macs": {
"aa:bb": {
"network-card": "foo",
"device-number": "bar",
}
}
}
}
}

assert get_primary_mac_from_metadata(md) is None


class TestEc2Util:
Expand Down