From f35bbefda0c80071f742e9db8db99d9780e6f32e Mon Sep 17 00:00:00 2001 From: Sam Hartman Date: Thu, 25 Jun 2026 12:53:20 -0600 Subject: [PATCH 1/8] Add macvlan network device support Enable containers to connect directly to physical network interfaces for workload hosting scenarios requiring real network device passthrough. * Add --nic CLI argument for macvlan network attachment * Implement ensure_macvlan_network() to create podman macvlan networks * Implement get_default_network() to query podman's default network * Update build_runtime_options() to configure macvlan networks --- patch_whs/cli.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/patch_whs/cli.py b/patch_whs/cli.py index ed241e4..18ebf6b 100644 --- a/patch_whs/cli.py +++ b/patch_whs/cli.py @@ -43,6 +43,12 @@ def build_parser() -> argparse.ArgumentParser: metavar="DIR", help="Bind mount DIR to /run/libvirt inside the container", ) + start_parser.add_argument( + "--nic", + metavar="IFNAME", + action="append", + help="Attach real device(s) via macvlan on specified interface(s)", + ) start_parser.set_defaults(handler=handle_start) return parser @@ -89,11 +95,33 @@ def parse_command(command: str) -> list[str]: return shlex.split(command, posix=True) +def ensure_macvlan_network(nic: str) -> None: + """Ensure the macvlan network for the given NIC exists.""" + net_name = f"whs-{nic}" + run_podman_command([ + "podman", "network", "create", + "--driver", "macvlan", + "-o", f"parent={nic}", + "-o", "mode=passthru", + "--ipam-driver", "none", + "--ignore", + net_name + ]) + + def build_runtime_options(args: argparse.Namespace) -> list[str]: options: list[str] = [] if args.expose_libvirt: libvirt_dir = Path(args.expose_libvirt) options.extend(["-v", f"{libvirt_dir}:/run/libvirt"]) + + nics = args.nic or [] + if nics: + # Add default network first + options.extend(["--network", get_default_network()]) + for nic in nics: + ensure_macvlan_network(nic) + options.extend(["--network", f"whs-{nic}:interface_name=xi-{nic}"]) return options @@ -188,9 +216,20 @@ def ensure_podman_ready() -> None: return +def get_default_network() -> str: + """Return the default podman network name.""" + result = run_podman_command(["podman", "info", "--format", "json"], capture_output=True) + try: + data = json.loads(result.stdout) + return data["host"]["networkBackendInfo"]["defaultNetwork"] + except (json.JSONDecodeError, KeyError): + return "podman" + + def handle_start(args: argparse.Namespace) -> int: ensure_podman_ready() ensure_windows_nested_virtualization() + run_podman_command(["podman", "pull", args.image]) labels = inspect_image_labels(args.image) label_name = DEVELOP_LABEL if args.develop else RUN_LABEL @@ -206,6 +245,7 @@ def handle_start(args: argparse.Namespace) -> int: "NAME": args.name, }, ) + argv = strip_podman_remote_command_args(parse_command(expanded_command)) run_podman_command(argv) return 0 From 29573e11b5a55845face3b5693518617b10b94a4 Mon Sep 17 00:00:00 2001 From: Sam Hartman Date: Thu, 25 Jun 2026 14:33:48 -0600 Subject: [PATCH 2/8] Simplify V4Config handling --- layout/python/layout.py | 88 ++++++++++++++++++++++++----------------- 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/layout/python/layout.py b/layout/python/layout.py index 9623a51..0ac7373 100644 --- a/layout/python/layout.py +++ b/layout/python/layout.py @@ -4,19 +4,67 @@ from carthage.modeling import * from carthage.podman import * from carthage.oci import * -from carthage.network import V4Config +from carthage.network import V4Config, persistent_random_mac, NetworkConfig from carthage.systemd import SystemdNetworkModelMixin +from carthage.modeling import NetworkConfigModel, injector_access +from carthage.dependency_injection import inject, InjectionKey from carthage_base import * from .images import WhsRouter from .web_backend import web_server_key from .models import ModelStore, VmImage from pathlib import Path +from typing import Optional root_path = Path(__file__).parent.parent assignments_path = root_path/"assignments.yml" +@inject(device_model=InjectionKey('device_model')) +def build_v4_config(device_model) -> Optional[V4Config]: + '''Build V4Config from device model, only setting non-falsy values. + + This function only sets v4_config attributes if the device model properties + are non-falsy. Gateway and DNS servers are typically not set on devices + (we don't want to override the V4Config in this case). + + dhcp is deprecated in the device model and is ignored here. + ''' + if not device_model: + return None + + kwargs = {} + + # Only set address if non-falsy + if device_model.ipv4_manual: + kwargs['address'] = str(device_model.ipv4_manual) + + # Only set gateway if non-falsy (typically not set on devices) + if device_model.gateway: + kwargs['gateway'] = str(device_model.gateway) + + # Only set dns_servers if non-empty + if device_model.dns_servers: + kwargs['dns_servers'] = tuple(str(s) for s in device_model.dns_servers) + + # Only create V4Config if we have static configuration + if kwargs.get('address') or kwargs.get('gateway') or kwargs.get('dns_servers'): + return V4Config(**kwargs) + return None + + +@inject(device_model=InjectionKey('device_model')) +def build_mac(device_model) -> str: + '''Build MAC address from device model.''' + if device_model.mac_address: + return device_model.mac_address + return persistent_random_mac + + +class DeviceNetworkConfig(NetworkConfigModel): + add('eth0', mac=build_mac, v4_config=build_v4_config, net=injector_access('bridge_net')) + + @inject(model_store=ModelStore, ainjector=AsyncInjector) async def build_layout(model_store, ainjector) -> CarthageLayout: injector = ainjector.injector @@ -32,6 +80,7 @@ class layout(CarthageLayout): add_provider(podman_container_host, LocalPodmanContainerHost) add_provider(persistent_seed_path, assignments_path) add_provider(MachineDependency(f'router.{domain}')) + add_provider(InjectionKey(NetworkConfig), DeviceNetworkConfig, allow_multiple=True) @provides('bridge_net') class net(NetworkModel): @@ -83,27 +132,10 @@ def build_container(device): device_image = model_store.get_device_container_image(device) class whs_container(MachineModel): + device_model = dveice add_provider(machine_implementation_key, dependency_quote(PodmanContainer)) add_provider(oci_container_image, device_image.name) name = device_name - class net_config(NetworkConfigModel): - if not device_dhcp: - add( - 'eth0', - mac=device_mac, - net=injector_access('bridge_net'), - v4_config=V4Config(dhcp=False, - address=device_ipv4, - gateway=device_gateway, - dns_servers=device_dns_servers) - ) - else: - add( - 'eth0', - mac=device_mac, - net=injector_access('bridge_net'), - v4_config=V4Config(dhcp=device_dhcp), - ) return whs_container @@ -139,24 +171,6 @@ class whs_vm(MachineModel): add_provider(machine_implementation_key, dependency_quote(carthage.libvirt.Vm)) add_provider(carthage.libvirt.vm_image_key, vm_image) - class net_config(NetworkConfigModel): - if not device_dhcp: - add( - 'eth0', - mac=device_mac, - net=injector_access('bridge_net'), - v4_config=V4Config(dhcp=False, - address=device_ipv4, - gateway=device_gateway, - dns_servers=device_dns_servers) - ) - else: - add( - 'eth0', - mac=device_mac, - net=injector_access('bridge_net'), - v4_config=V4Config(dhcp=device_dhcp), - ) return whs_vm for id, device in model_store.devices.items(): From 01b1aa03d8171891dd438ba9bf442e0ba23fa5d1 Mon Sep 17 00:00:00 2001 From: Sam Hartman Date: Thu, 25 Jun 2026 14:45:38 -0600 Subject: [PATCH 3/8] Add bare metal device support and fix container typo * Fix typo: dveice -> device in build_container * Add build_bare_metal function to support bareMetal device type * Add @dynamic_name wrapper to whs_container class * Add bare metal handling in device type loop --- layout/python/layout.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/layout/python/layout.py b/layout/python/layout.py index 0ac7373..e449f76 100644 --- a/layout/python/layout.py +++ b/layout/python/layout.py @@ -131,14 +131,24 @@ def build_container(device): device_dns_servers = device.dns_servers device_image = model_store.get_device_container_image(device) + @dynamic_name(device.name) class whs_container(MachineModel): - device_model = dveice + device_model = device add_provider(machine_implementation_key, dependency_quote(PodmanContainer)) add_provider(oci_container_image, device_image.name) name = device_name return whs_container - + + def build_bare_metal(device): + @dynamic_name(device.name) + class whs_bare_metal(MachineModel): + device_model = device + name = device.name + add_provider(machine_implementation_key, dependency_quote(BareMetalMachine)) + + return whs_bare_metal + def build_vm(device): device_dhcp = device.dhcp @@ -178,5 +188,7 @@ class whs_vm(MachineModel): new_vm = build_vm(device) elif device.type == 'container': new_container = build_container(device) + elif device.type == 'bareMetal': + new_bare_metal = build_bare_metal(device) return await ainjector(layout) From 9d0b740d860f9415f9ca72106a56c8dc777c679e Mon Sep 17 00:00:00 2001 From: Sam Hartman Date: Thu, 25 Jun 2026 15:06:37 -0600 Subject: [PATCH 4/8] Add xi-* macvlan interfaces to whs-bridge for real device support --- container/network/macvlan.network | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 container/network/macvlan.network diff --git a/container/network/macvlan.network b/container/network/macvlan.network new file mode 100644 index 0000000..43b2bd2 --- /dev/null +++ b/container/network/macvlan.network @@ -0,0 +1,5 @@ +[Match] +Name=xi-* +Kind=macvlan +[Network] +Bridge=whs-lab \ No newline at end of file From 011ea343584bf4b6250f19e4c3a845807ad86806 Mon Sep 17 00:00:00 2001 From: Sam Hartman Date: Thu, 25 Jun 2026 16:17:50 -0600 Subject: [PATCH 5/8] Force BareMetalMachines to be running so we don't ssh_online them --- layout/python/layout.py | 1 + 1 file changed, 1 insertion(+) diff --git a/layout/python/layout.py b/layout/python/layout.py index e449f76..616271d 100644 --- a/layout/python/layout.py +++ b/layout/python/layout.py @@ -146,6 +146,7 @@ class whs_bare_metal(MachineModel): device_model = device name = device.name add_provider(machine_implementation_key, dependency_quote(BareMetalMachine)) + running = True return whs_bare_metal From 43c71f97caa30d5a18f027edbfee023f5c511426 Mon Sep 17 00:00:00 2001 From: Sam Hartman Date: Thu, 25 Jun 2026 16:18:23 -0600 Subject: [PATCH 6/8] Fix permissions on macvlan.network --- container/network/macvlan.network | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 container/network/macvlan.network diff --git a/container/network/macvlan.network b/container/network/macvlan.network old mode 100644 new mode 100755 From 5e60a5b4d94d8f24d8784c17c99d173d6c76671b Mon Sep 17 00:00:00 2001 From: Sam Hartman Date: Thu, 25 Jun 2026 16:55:56 -0600 Subject: [PATCH 7/8] Make sure whs-lab network is defined everywhere so if it is up podman does not complain. --- Containerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Containerfile b/Containerfile index 5eece72..442163f 100644 --- a/Containerfile +++ b/Containerfile @@ -24,6 +24,7 @@ RUN podman network create net \ -o mode=unmanaged && rm -rf /run/containers /run/libpod COPY container/podman.json /etc/containers/networks/podman.json COPY container/podman.json /srv/whs/containers/networks/podman.json +RUN cp /srv/whs/containers/networks/net.json /etc/containers/networks LABEL run_whs 'podman run -d -ti --privileged -p 8080:8080 --group-add=keep-groups -v$NAME:/srv/whs --name $NAME $IMAGE' LABEL develop_whs 'podman run -d -ti --privileged -p 8080:8080 --group-add=keep-groups -v${PWD}:/app -v$NAME:/srv/whs --name $NAME $IMAGE' VOLUME /srv/whs From 9176094b59bf63938aef1c4fa0c059a75bc628d8 Mon Sep 17 00:00:00 2001 From: Sam Hartman Date: Mon, 29 Jun 2026 12:55:48 -0600 Subject: [PATCH 8/8] Set running in the Machine not the MachineModel for BareMetalMachine --- layout/python/layout.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/layout/python/layout.py b/layout/python/layout.py index 616271d..2dfcd40 100644 --- a/layout/python/layout.py +++ b/layout/python/layout.py @@ -19,6 +19,11 @@ root_path = Path(__file__).parent.parent assignments_path = root_path/"assignments.yml" +class AlreadyRunningMachine(Machine): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.running = True + @inject(device_model=InjectionKey('device_model')) def build_v4_config(device_model) -> Optional[V4Config]: @@ -146,7 +151,7 @@ class whs_bare_metal(MachineModel): device_model = device name = device.name add_provider(machine_implementation_key, dependency_quote(BareMetalMachine)) - running = True + machine_mixins = (AlreadyRunningMachine,) return whs_bare_metal