diff options
| author | Daniil Baturin <daniil@vyos.io> | 2025-09-15 11:19:01 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-09-15 11:19:01 +0100 |
| commit | 48b9f84e5ce681946271e159a19013d23e13d43e (patch) | |
| tree | de933d4fe1783a4afb10b53bfeabb08238a0e61a | |
| parent | 238a9006cc78022ba5e95258c1033ccf8abfb1af (diff) | |
| parent | 048ff4fab605a1c6ebf5124fe2adedb81788066e (diff) | |
| download | vyos-1x-48b9f84e5ce681946271e159a19013d23e13d43e.tar.gz vyos-1x-48b9f84e5ce681946271e159a19013d23e13d43e.zip | |
Merge pull request #4686 from l0crian1/container-macvlan
container: T7186: Add macvlan network type for containers
| -rw-r--r-- | interface-definitions/container.xml.in | 74 | ||||
| -rw-r--r-- | python/vyos/utils/network.py | 45 | ||||
| -rwxr-xr-x | smoketest/scripts/cli/test_container.py | 58 | ||||
| -rwxr-xr-x | src/conf_mode/container.py | 119 |
4 files changed, 274 insertions, 22 deletions
diff --git a/interface-definitions/container.xml.in b/interface-definitions/container.xml.in index f20fd7690..d4efc08be 100644 --- a/interface-definitions/container.xml.in +++ b/interface-definitions/container.xml.in @@ -566,6 +566,24 @@ <children> #include <include/generic-description.xml.i> #include <include/interface/mtu-68-16000.xml.i> + <leafNode name="gateway"> + <properties> + <help>Gateway address to use for this network</help> + <valueHelp> + <format>ipv4</format> + <description>IPv4 gateway address</description> + </valueHelp> + <valueHelp> + <format>ipv6</format> + <description>IPv6 gateway address</description> + </valueHelp> + <constraint> + <validator name="ip-address"/> + <validator name="ipv6-address"/> + </constraint> + <multi/> + </properties> + </leafNode> <leafNode name="prefix"> <properties> <help>Prefix which allocated to that network</help> @@ -590,6 +608,62 @@ <valueless/> </properties> </leafNode> + <node name="type"> + <properties> + <help>Network type (default: bridge)</help> + </properties> + <children> + <leafNode name="bridge"> + <properties> + <help>Bridge network</help> + <valueless/> + </properties> + </leafNode> + <node name="macvlan"> + <properties> + <help>MACVLAN network</help> + </properties> + <children> + <leafNode name="mode"> + <properties> + <help>MACVLAN mode</help> + <completionHelp> + <list>bridge private vepa</list> + </completionHelp> + <valueHelp> + <format>bridge</format> + <description>Containers act as separate hosts on the parent network</description> + </valueHelp> + <valueHelp> + <format>private</format> + <description>Containers are isolated from the host and each other</description> + </valueHelp> + <valueHelp> + <format>vepa</format> + <description>Containers send all traffic through the parent switch for forwarding</description> + </valueHelp> + <constraint> + <regex>bridge|private|vepa</regex> + </constraint> + <constraintErrorMessage>Invalid mode</constraintErrorMessage> + </properties> + </leafNode> + <leafNode name="parent"> + <properties> + <help>Parent network interface</help> + <completionHelp> + <script>${vyos_completion_dir}/list_interfaces --type ethernet --type bonding --type bridge</script> + </completionHelp> + <constraint> + <regex>((bond|br|eth)[0-9]+(\.[0-9]+)?)</regex> + </constraint> + <constraintErrorMessage>Invalid parent interface</constraintErrorMessage> + </properties> + </leafNode> + </children> + </node> + </children> + </node> #include <include/interface/vrf.xml.i> </children> </tagNode> diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 93f780d8b..e6b838cdc 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -13,8 +13,10 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. If not, see <http://www.gnu.org/licenses/>. +import hashlib from socket import AF_INET from socket import AF_INET6 +from vyos.utils.process import cmd def _are_same_ip(one, two): from socket import inet_pton @@ -48,6 +50,49 @@ def is_netns_interface(interface, netns): return True return False +def get_host_identity() -> str: + """ + Build a stable host identity string for deterministic MAC generation. + + Combines: + • The system's HardwareUUID (from /sys/class/dmi/id/product_uuid) + • The system hostname + + Both are normalized (lowercase, dashes removed in UUID) and joined with a colon. + + Returns: + str: A string "<uuid>:<hostname>", used as part of the host-specific seed when + generating deterministic MAC addresses. + """ + uuid = cmd(f"cat /sys/class/dmi/id/product_uuid").strip().replace("-", "").lower() + host = cmd("hostname").strip().lower() + return f"{uuid}:{host}" + +def gen_mac(name: str, addr: str, ident: str) -> str: + """ + Generate a deterministic locally-administered MAC address. + + The MAC is derived from: + • Host identity (UUID + hostname) + • Container name + • Concatenated address string (IPv4 and/or IPv6 addresses) + + A SHA-256 digest is computed from the combined string. The first 5 bytes + of the digest are used, prefixed with 0x02 to mark the address as + locally-administered and unicast. + + Args: + name (str): Container name to differentiate MACs. + addr (str): Concatenated list of container addresses (IPv4/IPv6). + + Returns: + str: Deterministic MAC address in standard "xx:xx:xx:xx:xx:xx" format. + """ + h = hashlib.sha256(f"{ident}:{name}:{addr}".encode()).hexdigest() + # 0x02 = locally-administered, unicast + b = [0x02] + [int(h[i:i+2], 16) for i in range(0, 10, 2)] # 5 bytes = 40 bits + return ":".join(f"{x:02x}" for x in b) + def get_netns_all() -> list: from json import loads from vyos.utils.process import cmd diff --git a/smoketest/scripts/cli/test_container.py b/smoketest/scripts/cli/test_container.py index 7590ed1cd..864207308 100755 --- a/smoketest/scripts/cli/test_container.py +++ b/smoketest/scripts/cli/test_container.py @@ -165,6 +165,64 @@ class TestContainer(VyOSUnitTestSHIM.TestCase): # Check for running process self.assertEqual(process_named_running(PROCESS_NAME), pid) + def test_network_types(self): + self.cli_set(['interfaces', 'ethernet', 'eth0', 'vif', '100']) + self.cli_set(['interfaces', 'ethernet', 'eth0', 'vif', '101']) + + # MACVLAN Networks + self.cli_set(base_path + ['network', 'macvlan1', 'prefix', '10.0.0.0/24']) + self.cli_set(base_path + ['network', 'macvlan1', 'type', 'macvlan', 'parent', 'eth0']) + self.cli_set(base_path + ['network', 'macvlan1', 'type', 'macvlan', 'mode', 'bridge']) + self.cli_set(base_path + ['network', 'macvlan2', 'prefix', '10.0.100.0/24']) + self.cli_set(base_path + ['network', 'macvlan2', 'gateway', '10.0.100.5']) + self.cli_set(base_path + ['network', 'macvlan2', 'type', 'macvlan', 'parent', 'eth0.100']) + self.cli_set(base_path + ['network', 'macvlan2', 'type', 'macvlan', 'mode', 'private']) + self.cli_set(base_path + ['network', 'macvlan3', 'prefix', '2001::/64']) + self.cli_set(base_path + ['network', 'macvlan3', 'type', 'macvlan', 'parent', 'eth0.101']) + self.cli_set(base_path + ['network', 'macvlan3', 'type', 'macvlan', 'mode', 'vepa']) + + # Bridge Network + self.cli_set(base_path + ['network', 'bridge1', 'prefix', '10.0.1.0/24']) + self.cli_set(base_path + ['network', 'bridge1', 'type', 'bridge']) + + # Bridge Network before T7186; default network type is bridge + self.cli_set(base_path + ['network', 'bridge2', 'prefix', '10.0.2.0/24']) + + self.cli_commit() + + n = cmd_to_json(f'sudo podman network inspect macvlan1') + self.assertEqual(n['driver'], 'macvlan') + self.assertEqual(n['network_interface'], 'eth0') + self.assertEqual(n['options']['mode'], 'bridge') + self.assertEqual(n['subnets'][0]['subnet'], '10.0.0.0/24') + self.assertEqual(n['subnets'][0]['gateway'], '10.0.0.1') + + n = cmd_to_json(f'sudo podman network inspect macvlan2') + self.assertEqual(n['driver'], 'macvlan') + self.assertEqual(n['network_interface'], 'eth0.100') + self.assertEqual(n['options']['mode'], 'private') + self.assertEqual(n['subnets'][0]['subnet'], '10.0.100.0/24') + self.assertEqual(n['subnets'][0]['gateway'], '10.0.100.5') + + n = cmd_to_json(f'sudo podman network inspect macvlan3') + self.assertEqual(n['driver'], 'macvlan') + self.assertEqual(n['network_interface'], 'eth0.101') + self.assertEqual(n['options']['mode'], 'vepa') + self.assertEqual(n['subnets'][0]['subnet'], '2001::/64') + self.assertEqual(n['subnets'][0]['gateway'], '2001::1') + + n = cmd_to_json(f'sudo podman network inspect bridge1') + self.assertEqual(n['driver'], 'bridge') + self.assertEqual(n['network_interface'], 'pod-bridge1') + self.assertEqual(n['subnets'][0]['subnet'], '10.0.1.0/24') + self.assertEqual(n['subnets'][0]['gateway'], '10.0.1.1') + + n = cmd_to_json(f'sudo podman network inspect bridge2') + self.assertEqual(n['driver'], 'bridge') + self.assertEqual(n['network_interface'], 'pod-bridge2') + self.assertEqual(n['subnets'][0]['subnet'], '10.0.2.0/24') + self.assertEqual(n['subnets'][0]['gateway'], '10.0.2.1') + def test_ipv4_network(self): prefix = '192.0.2.0/24' base_name = 'ipv4' diff --git a/src/conf_mode/container.py b/src/conf_mode/container.py index 4ec9b8849..8950f857e 100755 --- a/src/conf_mode/container.py +++ b/src/conf_mode/container.py @@ -32,9 +32,12 @@ from vyos.configverify import verify_vrf from vyos.ifconfig import Interface from vyos.utils.cpu import get_core_count from vyos.utils.file import write_file +from vyos.utils.dict import dict_search from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import run +from vyos.utils.network import gen_mac +from vyos.utils.network import get_host_identity from vyos.utils.network import interface_exists from vyos.template import bracketize_ipv6 from vyos.template import inc_ip @@ -260,22 +263,59 @@ def verify(container): # Add new network if 'network' in container: for network, network_config in container['network'].items(): - v4_prefix = 0 - v6_prefix = 0 + net_dict = {'ipv4_pfx_len': 0, 'ipv6_pfx_len': 0, 'ipv4_gateway_len': 0, 'ipv6_gateway_len': 0} + # If ipv4-prefix not defined for user-defined network if 'prefix' not in network_config: raise ConfigError(f'prefix for network "{network}" must be defined!') for prefix in network_config['prefix']: if is_ipv4(prefix): - v4_prefix += 1 + net_dict['ipv4_pfx_len'] += 1 + net_dict['ipv4_prefix'] = prefix elif is_ipv6(prefix): - v6_prefix += 1 - - if v4_prefix > 1: + net_dict['ipv6_pfx_len'] += 1 + net_dict['ipv6_prefix'] = prefix + + for gateway in network_config.get('gateway', []): + if is_ipv4(gateway): + net_dict['ipv4_gateway_len'] += 1 + net_dict['ipv4_gateway'] = gateway + elif is_ipv6(gateway): + net_dict['ipv6_gateway_len'] += 1 + net_dict['ipv6_gateway'] = gateway + + if net_dict['ipv4_pfx_len'] > 1: raise ConfigError(f'Only one IPv4 prefix can be defined for network "{network}"!') - if v6_prefix > 1: + if net_dict['ipv6_pfx_len'] > 1: raise ConfigError(f'Only one IPv6 prefix can be defined for network "{network}"!') + if net_dict['ipv4_gateway_len'] > 1: + raise ConfigError(f'Only one IPv4 gateway can be defined for network "{network}"!') + if net_dict['ipv6_gateway_len'] > 1: + raise ConfigError(f'Only one IPv6 gateway can be defined for network "{network}"!') + + if net_dict.get('ipv4_prefix') and net_dict.get('ipv4_gateway'): + if ip_address(net_dict['ipv4_gateway']) not in ip_network(net_dict['ipv4_prefix']): + raise ConfigError(f'IPv4 gateway "{net_dict["ipv4_gateway"]}" is not in the IPv4 prefix "{net_dict["ipv4_prefix"]}"!') + if net_dict.get('ipv6_prefix') and net_dict.get('ipv6_gateway'): + if ip_address(net_dict['ipv6_gateway']) not in ip_network(net_dict['ipv6_prefix']): + raise ConfigError(f'IPv6 gateway "{net_dict["ipv6_gateway"]}" is not in the IPv6 prefix "{net_dict["ipv6_prefix"]}"!') + if net_dict.get('ipv4_gateway') and not net_dict.get('ipv4_prefix'): + raise ConfigError(f'IPv4 gateway configured but no IPv4 prefix defined for network "{network}"!') + if net_dict.get('ipv6_gateway') and not net_dict.get('ipv6_prefix'): + raise ConfigError(f'IPv6 gateway configured but no IPv6 prefix defined for network "{network}"!') + + type_config = dict_search('type', network_config) + if dict_search('macvlan', type_config): + parent = dict_search('macvlan.parent', type_config) + if not parent: + raise ConfigError(f'MACVLAN networks must have a parent interface!') + if not interface_exists(parent): + raise ConfigError(f'MACVLAN parent interface "{parent}" does not exist!') + if not dict_search('macvlan.mode', type_config): + raise ConfigError(f'MACVLAN networks must have a mode configured!') + if dict_search('vrf', network_config): + raise ConfigError(f'MACVLAN networks do not support direct VRF assignment!') # Verify VRF exists verify_vrf(network_config) @@ -304,7 +344,7 @@ def verify(container): return None -def generate_run_arguments(name, container_config): +def generate_run_arguments(name, container_config, host_ident): image = container_config['image'] cpu_quota = container_config['cpu_quota'] memory = container_config['memory'] @@ -430,6 +470,7 @@ def generate_run_arguments(name, container_config): return f'{container_base_cmd} --net host {entrypoint} {image} {command} {command_arguments}'.strip() ip_param = '' + addr_info = '' networks = ",".join(container_config['network']) for network in container_config['network']: if 'address' not in container_config['network'][network]: @@ -440,7 +481,11 @@ def generate_run_arguments(name, container_config): else: ip_param += f' --ip {address}' - return f'{container_base_cmd} --no-healthcheck --net {networks} {ip_param} {entrypoint} {image} {command} {command_arguments}'.strip() + addr_info = ''.join(container_config['network'][network]['address']) + + mac_address = f'--mac-address {gen_mac(name, addr_info, host_ident)}' + + return f'{container_base_cmd} --no-healthcheck --net {networks} {ip_param} {mac_address} {entrypoint} {image} {command} {command_arguments}'.strip() def generate(container): @@ -453,11 +498,22 @@ def generate(container): if 'network' in container: for network, network_config in container['network'].items(): + type_config = dict_search('type', network_config) + if dict_search('macvlan', type_config): + net_interface = dict_search('macvlan.parent', type_config) + driver = 'macvlan' + mode = dict_search('macvlan.mode', type_config) + elif dict_search('bridge', type_config) is not None: + net_interface = f'pod-{network}' + driver = 'bridge' + else: + net_interface = f'pod-{network}' + driver = 'bridge' tmp = { 'name': network, 'id': sha256(f'{network}'.encode()).hexdigest(), - 'driver': 'bridge', - 'network_interface': f'pod-{network}', + 'driver': driver, + 'network_interface': net_interface, 'subnets': [], 'ipv6_enabled': False, 'internal': False, @@ -466,6 +522,7 @@ def generate(container): 'driver': 'host-local' }, 'options': { + **({'mode': mode} if driver == 'macvlan' else {}), 'mtu': '1500' } } @@ -477,11 +534,26 @@ def generate(container): tmp['options']['mtu'] = network_config['mtu'] for prefix in network_config['prefix']: - net = {'subnet': prefix, 'gateway': inc_ip(prefix, 1)} - tmp['subnets'].append(net) + gateway4, gateway6 = None, None + if dict_search('gateway', network_config): + for gw in network_config['gateway']: + if is_ipv6(gw): + gateway6 = gw + else: + gateway4 = gw + + if is_ipv6(prefix) and not gateway6: + gateway6 = inc_ip(prefix, 1) + elif not gateway4: + gateway4 = inc_ip(prefix, 1) if is_ipv6(prefix): tmp['ipv6_enabled'] = True + net = {'subnet': prefix, 'gateway': gateway6} + else: + net = {'subnet': prefix, 'gateway': gateway4} + + tmp['subnets'].append(net) write_file(f'/etc/containers/networks/{network}.json', json_write(tmp, indent=2)) @@ -490,12 +562,13 @@ def generate(container): render(config_storage, 'container/storage.conf.j2', container) if 'name' in container: + host_ident = get_host_identity() for name, container_config in container['name'].items(): if 'disable' in container_config: continue file_path = os.path.join(systemd_unit_path, f'vyos-container-{name}.service') - run_args = generate_run_arguments(name, container_config) + run_args = generate_run_arguments(name, container_config, host_ident) render(file_path, 'container/systemd-unit.j2', {'name': name, 'run_args': run_args, }, formater=lambda _: _.replace(""", '"').replace("'", "'")) @@ -553,14 +626,16 @@ def apply(container): # the network interface in advance if 'network' in container: for network, network_config in container['network'].items(): - network_name = f'pod-{network}' - # T5147: Networks are started only as soon as there is a consumer. - # If only a network is created in the first place, no need to assign - # it to a VRF as there's no consumer, yet. - if interface_exists(network_name): - tmp = Interface(network_name) - tmp.set_vrf(network_config.get('vrf', '')) - tmp.add_ipv6_eui64_address('fe80::/64') + type_config = dict_search('type', network_config) + if not dict_search('macvlan', type_config): + network_name = f'pod-{network}' + # T5147: Networks are started only as soon as there is a consumer. + # If only a network is created in the first place, no need to assign + # it to a VRF as there's no consumer, yet. + if interface_exists(network_name): + tmp = Interface(network_name) + tmp.set_vrf(network_config.get('vrf', '')) + tmp.add_ipv6_eui64_address('fe80::/64') return None |
