From 4bd5482aa60b025be8d0fc56764bc1672fd1274e Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman Date: Fri, 5 Dec 2025 17:04:01 -0800 Subject: [PATCH 1/8] nixos/nspawn-container: init a new nspawn-container profile This shares a lot in common with the infrastructure, but is designed to behave like our `qemu-vm.nix` profile (provides a lot of the same `virtualisation.*` options, produces a simple script you can run). This lays the groundwork to be able to rework the nixos test infrastructure to allow for containers as well as qemu nodes. That work isn't quite done yet, but if you want more context, you can see the followup work in . Credit due to the [Clan.lol](https://clan.lol/) team for first implementing this. I'm just cleaning it up and making it play nicely with upstream. To try it out, create a `demo.nix`: ```nix let pkgs = import ./. { }; mkContainer = { nodeNumber, vlans, }: pkgs.nixos ( { config, modulesPath, pkgs, lib, ... }: let interfaces = lib.attrValues config.virtualisation.allInterfaces; # Automatically assign IP addresses to requested interfaces. assignIPs = lib.filter (i: i.assignIP) interfaces; ipInterfaces = lib.forEach assignIPs ( i: lib.nameValuePair i.name { ipv4.addresses = [ { address = "192.168.${toString i.vlan}.${toString nodeNumber}"; prefixLength = 24; } ]; } ); in { imports = [ "${modulesPath}/virtualisation/nspawn-container" ]; users.users.root.password = ""; networking.hostName = "c${toString nodeNumber}"; virtualisation.vlans = vlans; networking.interfaces = lib.listToAttrs ipInterfaces; environment.systemPackages = [ pkgs.neovim ]; system.stateVersion = lib.trivial.release; } ); in { container1 = mkContainer { nodeNumber = 1; vlans = [ 1 ]; }; container2 = mkContainer { nodeNumber = 2; vlans = [ 2 ]; }; container12 = mkContainer { nodeNumber = 12; vlans = [ 1 2 ]; }; } ``` Build and run the machines in separate terminals (unfortunately, `systemd-nspawn` requires `sudo`): ```console $ sudo $(nix-build ./demo.nix -A container1.config.system.build.nspawn)/bin/run-c1-nspawn $ sudo $(nix-build ./demo.nix -A container2.config.system.build.nspawn)/bin/run-c2-nspawn $ sudo $(nix-build ./demo.nix -A container12.config.system.build.nspawn)/bin/run-c12-nspawn ``` You can log into this machines as `root`, and verify they can ping each other: `c1` can ping `c12`: ``` [root@c1:~]# ping 192.168.1.12 -c 1 PING 192.168.1.12 (192.168.1.12) 56(84) bytes of data. 64 bytes from 192.168.1.12: icmp_seq=1 ttl=64 time=0.164 ms ... ``` So can `c2`: ``` [root@c2:~]# ping 192.168.2.12 PING 192.168.2.12 (192.168.2.12) 56(84) bytes of data. 64 bytes from 192.168.2.12: icmp_seq=1 ttl=64 time=0.127 ms ``` --- nixos/lib/testing/network.nix | 16 +- .../nspawn-container/default.nix | 253 ++++++++++++++++++ .../nspawn-container/run-nspawn/default.nix | 6 + .../nspawn-container/run-nspawn/package.nix | 50 ++++ .../nspawn-container/run-nspawn/shell.nix | 4 + .../run-nspawn/src/pyproject.toml | 10 + .../run-nspawn/src/run_nspawn/__init__.py | 206 ++++++++++++++ nixos/modules/virtualisation/qemu-vm.nix | 104 +++++-- 8 files changed, 617 insertions(+), 32 deletions(-) create mode 100644 nixos/modules/virtualisation/nspawn-container/default.nix create mode 100644 nixos/modules/virtualisation/nspawn-container/run-nspawn/default.nix create mode 100644 nixos/modules/virtualisation/nspawn-container/run-nspawn/package.nix create mode 100644 nixos/modules/virtualisation/nspawn-container/run-nspawn/shell.nix create mode 100644 nixos/modules/virtualisation/nspawn-container/run-nspawn/src/pyproject.toml create mode 100644 nixos/modules/virtualisation/nspawn-container/run-nspawn/src/run_nspawn/__init__.py diff --git a/nixos/lib/testing/network.nix b/nixos/lib/testing/network.nix index e65767e7fb99..9a5facfc2433 100644 --- a/nixos/lib/testing/network.nix +++ b/nixos/lib/testing/network.nix @@ -32,14 +32,8 @@ let let qemu-common = import ../qemu-common.nix { inherit (pkgs) lib stdenv; }; - # Convert legacy VLANs to named interfaces and merge with explicit interfaces. - vlansNumbered = forEach (zipLists config.virtualisation.vlans (range 1 255)) (v: { - name = "eth${toString v.snd}"; - vlan = v.fst; - assignIP = true; - }); - explicitInterfaces = lib.mapAttrsToList (n: v: v // { name = n; }) config.virtualisation.interfaces; - interfaces = vlansNumbered ++ explicitInterfaces; + interfaces = lib.attrValues config.virtualisation.allInterfaces; + interfacesNumbered = zipLists interfaces (range 1 255); # Automatically assign IP addresses to requested interfaces. @@ -67,10 +61,10 @@ let { fst, snd }: qemu-common.qemuNICFlags snd fst.vlan config.virtualisation.test.nodeNumber ) ); - udevRules = forEach interfacesNumbered ( - { fst, snd }: + udevRules = forEach interfaces ( + interface: # MAC Addresses for QEMU network devices are lowercase, and udev string comparison is case-sensitive. - ''SUBSYSTEM=="net",ACTION=="add",ATTR{address}=="${toLower (qemu-common.qemuNicMac fst.vlan config.virtualisation.test.nodeNumber)}",NAME="${fst.name}"'' + ''SUBSYSTEM=="net",ACTION=="add",ATTR{address}=="${toLower (qemu-common.qemuNicMac interface.vlan config.virtualisation.test.nodeNumber)}",NAME="${interface.name}"'' ); networkConfig = { diff --git a/nixos/modules/virtualisation/nspawn-container/default.nix b/nixos/modules/virtualisation/nspawn-container/default.nix new file mode 100644 index 000000000000..cdcc68f4b98c --- /dev/null +++ b/nixos/modules/virtualisation/nspawn-container/default.nix @@ -0,0 +1,253 @@ +# This module creates a lightweight "container" from the NixOS configuration. +# Building the `config.system.build.nspawn` attribute gives you a command +# that starts a systemd-nspawn container running the NixOS configuration +# defined in `config`. By default, the Nix store is shared read-only with the +# host, which makes (re)building very efficient. +# This shares a lot in common with +# `nixos/modules/virtualisation/nixos-containers.nix`, but doesn't use systemd +# units. +# The networking options here match the options in +# `nixos/modules/virtualisation/nixos-containers.nix` which allows using these +# lightweight containers for nixos integration tests. + +{ + config, + pkgs, + lib, + ... +}: +let + inherit (lib) types; + cfg = config.virtualisation; + + interfaceType = types.submodule ( + { name, ... }: + { + options = { + name = lib.mkOption { + type = types.str; + default = name; + description = '' + Interface name + ''; + }; + + vlan = lib.mkOption { + type = types.ints.unsigned; + description = '' + VLAN to which the network interface is connected. + ''; + }; + + assignIP = lib.mkOption { + type = types.bool; + default = false; + description = '' + Automatically assign an IP address to the network interface using the same scheme as + virtualisation.vlans. + ''; + }; + }; + } + ); + + # Convert legacy VLANs to named interfaces. + vlansNumbered = lib.listToAttrs ( + lib.forEach (lib.zipLists cfg.vlans (lib.range 1 255)) ( + v: + let + name = "eth${toString v.snd}"; + in + lib.nameValuePair name { + inherit name; + vlan = v.fst; + assignIP = true; + } + ) + ); +in +{ + options = { + networking.primaryIPAddress = lib.mkOption { + type = types.str; + default = ""; + internal = true; + description = "Primary IP address used in /etc/hosts."; + }; + + networking.primaryIPv6Address = lib.mkOption { + type = types.str; + default = ""; + internal = true; + description = "Primary IPv6 address used in /etc/hosts."; + }; + + virtualisation.vlans = lib.mkOption { + type = types.listOf types.ints.unsigned; + default = if cfg.interfaces == { } then [ 1 ] else [ ]; + defaultText = lib.literalExpression ''if cfg.interfaces == {} then [ 1 ] else [ ]''; + example = [ + 1 + 2 + ]; + description = '' + Virtual networks to which the container is connected. Each number «N» in + this list causes the container to have a virtual Ethernet interface + attached to a separate virtual network on which it will be assigned IP + address `192.168.«N».«M»`, where «M» is the index of this container in + the list of containers. + ''; + }; + + virtualisation.interfaces = lib.mkOption { + default = { }; + example = { + enp1s0.vlan = 1; + }; + description = '' + Network interfaces to add to the container. + ''; + type = types.attrsOf interfaceType; + }; + + virtualisation.allInterfaces = lib.mkOption { + type = types.attrsOf interfaceType; + readOnly = true; + description = '' + All network interfaces for the VM. Combines + {option}`virtualisation.vlans` and {option}`virtualisation.interfaces`. + ''; + default = vlansNumbered // cfg.interfaces; + }; + + virtualisation.initArgs = lib.mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ + "systemd.unit=rescue.target" + "systemd.log_level=debug" + "systemd.log_target=console" + ]; + description = '' + Command line arguments to pass to the init process (likely systemd). + Useful for debugging. + ''; + }; + + virtualisation.rootDir = lib.mkOption { + type = types.str; + default = "./${config.system.name}-root"; + defaultText = lib.literalExpression ''"./''${config.system.name}-root"''; + description = '' + Path to a directory for the root filesystem for the container. + The directory will be created on startup if it does not + exist. + ''; + }; + + virtualisation.systemd-nspawn = { + package = lib.mkPackageOption pkgs "systemd" { }; + options = lib.mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "--bind=/home:/home" ]; + description = '' + Options passed to systemd-nspawn. + See [systemd-nspawn docs](https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html) for a complete list. + ''; + }; + }; + }; + + config = { + assertions = [ + ( + let + conflictingKeys = lib.intersectAttrs vlansNumbered cfg.interfaces; + in + { + assertion = conflictingKeys == { }; + message = '' + `virtualisation.vlans` and `virtualisation.interfaces` have conflicting keys: ${lib.concatStringsSep "," (lib.attrNames conflictingKeys)} + ''; + } + ) + ( + let + allInterfaceNames = + (lib.mapAttrsToList (k: i: i.name) vlansNumbered) + ++ (lib.mapAttrsToList (k: i: i.name) cfg.interfaces); + in + { + assertion = lib.allUnique allInterfaceNames; + message = '' + `virtualisation.vlans` and `virtualisation.interfaces` have conflicting interface names: ${lib.concatStringsSep "," allInterfaceNames} + ''; + } + ) + ]; + + boot.isNspawnContainer = true; + + # Needed since nixpkgs 7fb2f407c01b017737eafc26b065d7f56434a992 removed the getty unit by default. + console.enable = true; + + virtualisation.systemd-nspawn.options = [ + "--private-network" + "--machine=${config.system.name}" + "--bind-ro=/nix/store:/nix/store" + + # systemd-nspawn does some cleverness to mount a procfs and sysfs in an + # unprivileged container, see + # . + # Unfortunately, this doesn't work in the Nix build sandbox as we do not + # have permission to mount filesystems of type `sysfs` nor `procfs`. + # Fortunately, the build sandbox does provide a `/proc` and `/sys` that + # we can just forward onto the container. + "--private-users=no" + "--bind=/proc:/run/host/proc" + "--bind=/sys:/run/host/sys" + + # From `man systemd-nspawn`: + # > Use --keep-unit and --register=no in combination to disable any + # > kind of unit allocation or registration with systemd-machined. + "--keep-unit" + "--register=no" + ]; + + system.build.nspawn = + let + toPythonStr = + s: + assert builtins.typeOf s == "string"; + # Hack: JSON strings are (AFAIK) valid Python expressions that + # represent the exact same string. + builtins.toJSON s; + + toPythonExpression = data: /* python */ "json.loads(${toPythonStr (builtins.toJSON data)})"; + in + pkgs.writers.writePython3Bin "run-${config.system.name}-nspawn" + { + libraries = ps: [ + (pkgs.callPackage ./run-nspawn { python3Packages = ps; }) + ]; + } + '' + import json + import os + import sys + from pathlib import Path + + import run_nspawn + + run_nspawn.run( + container_name=${toPythonStr config.system.name}, # noqa + root_dir_str=Path(os.environ.get("RUN_NSPAWN_ROOT_DIR", ${toPythonStr cfg.rootDir})), # noqa + interfaces=${toPythonExpression (lib.attrValues cfg.allInterfaces)}, # noqa + nspawn_options=${toPythonExpression config.virtualisation.systemd-nspawn.options} + sys.argv[1:], # noqa + init=${toPythonStr "${config.system.build.toplevel}/init"}, # noqa + init_args=${toPythonExpression cfg.initArgs}, # noqa + ) + ''; + }; +} diff --git a/nixos/modules/virtualisation/nspawn-container/run-nspawn/default.nix b/nixos/modules/virtualisation/nspawn-container/run-nspawn/default.nix new file mode 100644 index 000000000000..477f8179d34e --- /dev/null +++ b/nixos/modules/virtualisation/nspawn-container/run-nspawn/default.nix @@ -0,0 +1,6 @@ +{ python3Packages, systemd }: + +python3Packages.callPackage ./package.nix { + # We want `pkgs.systemd`, *not* `python3Packages.system`. + inherit systemd; +} diff --git a/nixos/modules/virtualisation/nspawn-container/run-nspawn/package.nix b/nixos/modules/virtualisation/nspawn-container/run-nspawn/package.nix new file mode 100644 index 000000000000..08b07c6b8cfa --- /dev/null +++ b/nixos/modules/virtualisation/nspawn-container/run-nspawn/package.nix @@ -0,0 +1,50 @@ +{ + buildPythonPackage, + e2fsprogs, + iproute2, + lib, + mypy, + ruff, + setuptools, + systemd, +}: + +buildPythonPackage { + pname = "run-nspawn"; + version = "1.0"; + pyproject = true; + + src = ./src; + + postPatch = '' + substituteInPlace run_nspawn/__init__.py \ + --replace-fail "@ip@" "${lib.getExe' iproute2 "ip"}" \ + --replace-fail "@systemd-nspawn@" "${lib.getExe' systemd "systemd-nspawn"}" \ + --replace-fail "@chattr@" "${lib.getExe' e2fsprogs "chattr"}" + ''; + + build-system = [ + setuptools + ]; + + propagatedBuildInputs = [ + systemd + iproute2 + ]; + + doCheck = true; + + nativeCheckInputs = [ + mypy + ruff + ]; + + checkPhase = '' + echo -e "\x1b[32m## run mypy\x1b[0m" + mypy run_nspawn + echo -e "\x1b[32m## run ruff check\x1b[0m" + ruff check . + echo -e "\x1b[32m## run ruff format\x1b[0m" + ruff format --check --diff . + ''; +} diff --git a/nixos/modules/virtualisation/nspawn-container/run-nspawn/shell.nix b/nixos/modules/virtualisation/nspawn-container/run-nspawn/shell.nix new file mode 100644 index 000000000000..ee8c77543238 --- /dev/null +++ b/nixos/modules/virtualisation/nspawn-container/run-nspawn/shell.nix @@ -0,0 +1,4 @@ +{ + pkgs ? import ../../../../.. { }, +}: +pkgs.callPackage ./default.nix { } diff --git a/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/pyproject.toml b/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/pyproject.toml new file mode 100644 index 000000000000..e67bb76c3c24 --- /dev/null +++ b/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/pyproject.toml @@ -0,0 +1,10 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "run-nspawn" +version = "0.0.0" + +[tool.setuptools.packages] +find = {} diff --git a/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/run_nspawn/__init__.py b/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/run_nspawn/__init__.py new file mode 100644 index 000000000000..76b1d414f732 --- /dev/null +++ b/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/run_nspawn/__init__.py @@ -0,0 +1,206 @@ +import contextlib +import dataclasses +import fcntl +import logging +import os +import signal +import subprocess +import sys +import typing +from pathlib import Path + +logger = logging.getLogger() + + +# Install a SIGTERM handler so all our context managers get a chance to +# clean up. +signal.signal(signal.SIGTERM, lambda _signum, _frame: sys.exit(0)) + + +@dataclasses.dataclass +class Netns: + name: str + path: Path + + +def run_ip(*args: str) -> None: + subprocess.run( + ["@ip@", *args], + check=True, + ) + + +@contextlib.contextmanager +def mk_netns(name: str) -> typing.Generator[Netns, None, None]: + logger.info("creating netns %s", name) + run_ip("netns", "add", name) + try: + yield Netns(name=name, path=Path("/run/netns") / name) + finally: + logger.info("deleting netns %s", name) + run_ip("netns", "delete", name) + + +@contextlib.contextmanager +def vlan_lock(vlan: int) -> typing.Generator[None, None, None]: + lockfile = Path(f"/run/nixos-nspawn/vlan-{vlan}.lock") + lockfile.parent.mkdir(parents=True, exist_ok=True) + with lockfile.open("w") as f: + # Grab an exclusive lock. + fcntl.flock(f, fcntl.LOCK_EX) + try: + yield + finally: + # Release the exclusive lock. + fcntl.flock(f, fcntl.LOCK_UN) + + +@contextlib.contextmanager +def ensure_vlan_bridge(vlan: int) -> typing.Generator[str, None, None]: + """ + Ensure a bridge for the given vlan exists, and create one if it does not. + + Note that this bridge may get used by other containers, so we're careful + to not delete it unless we're sure nobody else is using it. + """ + # These IP addresses correspond to the static IP assignment logic in + # . + ipv4_addr = f"192.168.{vlan}.254/24" + ipv6_addr = f"2001:db8:{vlan}::fe/64" + + bridge_name = f"br{vlan}" + bridge_path = Path("/sys/class/net") / bridge_name + try: + # To avoid racing against other nspawn containers that also + # need this vlan, grab an exclusive lock. + with vlan_lock(vlan): + if not bridge_path.exists(): + logger.info("creating bridge %s", bridge_name) + run_ip("link", "add", bridge_name, "type", "bridge") + run_ip("link", "set", bridge_name, "up") + run_ip("addr", "add", ipv4_addr, "dev", bridge_name) + run_ip("addr", "add", ipv6_addr, "dev", bridge_name) + + yield bridge_name + finally: + # To avoid racing against other nspawn containers that also + # releasing this vlan, grab an exclusive lock. + with vlan_lock(vlan): + if bridge_path.exists(): + child_intf_count = len(list((bridge_path / "brif").iterdir())) + if child_intf_count == 0: + logger.info("deleting bridge %s", bridge_name) + run_ip("link", "delete", bridge_name) + + +@contextlib.contextmanager +def mk_veth( + container_name: str, + netns: Netns, + container_intf_name: str, + vlan: int, +) -> typing.Generator[None, None, None]: + host_intf_name = f"{container_name}-{container_intf_name}" + with ensure_vlan_bridge(vlan) as bridge_name: + logger.info("creating interface %s", host_intf_name) + run_ip( + "link", + "add", + host_intf_name, + "type", + "veth", + "peer", + "name", + container_intf_name, + "netns", + netns.name, + ) + try: + run_ip("link", "set", host_intf_name, "master", bridge_name) + run_ip("link", "set", host_intf_name, "up") + yield + finally: + logger.info("deleting interface %s", host_intf_name) + run_ip("link", "delete", host_intf_name) + + +def run( + container_name: str, + root_dir_str: str, + interfaces: dict, + nspawn_options: list[str], + init: str, + init_args: list[str], +) -> None: + logging.basicConfig( + format=f"nixos-nspawn({container_name}): %(message)s", + level=logging.WARNING, + ) + + assert os.geteuid() == 0, ( + f"systemd-nspawn requires root to work. You are {os.geteuid()}" + ) + + root_dir = Path(root_dir_str) + + root_dir.mkdir(parents=True, exist_ok=True) + root_dir.chmod(0o755) + + with ( + mk_netns(f"nixos-nspawn-{container_name}") as netns, + contextlib.ExitStack() as stack, + ): + for interface in interfaces: + stack.enter_context( + mk_veth( + container_name=container_name, + netns=netns, + container_intf_name=interface["name"], + vlan=interface["vlan"], + ) + ) + + def print_pid() -> None: + print( + f"systemd-nspawn's PID is {os.getpid()}", + # Need to flush stdout before systemd-nspawn gets exec-ed. + flush=True, + ) + + cp = subprocess.Popen( + [ + "@systemd-nspawn@", + *nspawn_options, + f"--directory={root_dir}", + f"--network-namespace-path={netns.path}", + init, + *init_args, + ], + preexec_fn=print_pid, + ) + + try: + exit_code = cp.wait() + finally: + # If we get interrupted for any reason (most likely a SIGTERM), + # be sure to kill our child process. + cp.terminate() + + # NixOS creates `/var/empty` with the immutable filesystem attribute [0]. + # This makes it difficult to clean up the machine's root directory + # (which the test driver does to ensure tests start fresh). + # We unset that bit here so others are less likely to run into this issue. + # Note: this may cause issues on filesystems that don't support + # "Linux file attributes". Please improve this if you run into this! + # + # [0]: https://github.com/NixOS/nixpkgs/blob/d1eff395720e1fe15838263642a2bc1dca1eea32/nixos/modules/system/activation/activation-script.nix#L281 + subprocess.run( + [ + "@chattr@", + "-i", + root_dir / "var/empty", + ], + check=True, + ) + + sys.exit(exit_code) diff --git a/nixos/modules/virtualisation/qemu-vm.nix b/nixos/modules/virtualisation/qemu-vm.nix index 3be38c02085d..a83f825de444 100644 --- a/nixos/modules/virtualisation/qemu-vm.nix +++ b/nixos/modules/virtualisation/qemu-vm.nix @@ -360,6 +360,53 @@ let copyChannel = false; OVMF = cfg.efi.OVMF; }; + + interfaceType = types.submodule ( + { name, ... }: + { + options = { + name = mkOption { + type = types.str; + default = name; + description = '' + Interface name + ''; + }; + + vlan = mkOption { + type = types.ints.unsigned; + description = '' + VLAN to which the network interface is connected. + ''; + }; + + assignIP = mkOption { + type = types.bool; + default = false; + description = '' + Automatically assign an IP address to the network interface using the same scheme as + virtualisation.vlans. + ''; + }; + }; + } + ); + + # Convert legacy VLANs to named interfaces. + vlansNumbered = lib.listToAttrs ( + lib.forEach (lib.zipLists cfg.vlans (lib.range 1 255)) ( + v: + let + name = "eth${toString v.snd}"; + in + lib.nameValuePair name { + inherit name; + vlan = v.fst; + assignIP = true; + } + ) + ); + in { imports = [ @@ -705,29 +752,20 @@ in enp1s0.vlan = 1; }; description = '' - Network interfaces to add to the VM. + Extra network interfaces to add to the VM in addition to the ones + created by {option}`virtualisation.vlans`. ''; - type = - with types; - attrsOf (submodule { - options = { - vlan = mkOption { - type = types.ints.unsigned; - description = '' - VLAN to which the network interface is connected. - ''; - }; + type = types.attrsOf interfaceType; + }; - assignIP = mkOption { - type = types.bool; - default = false; - description = '' - Automatically assign an IP address to the network interface using the same scheme as - virtualisation.vlans. - ''; - }; - }; - }); + virtualisation.allInterfaces = mkOption { + type = types.attrsOf interfaceType; + readOnly = true; + description = '' + All network interfaces for the VM. Combines + {option}`virtualisation.vlans` and {option}`virtualisation.interfaces`. + ''; + default = vlansNumbered // cfg.interfaces; }; virtualisation.writableStore = mkOption { @@ -1248,6 +1286,30 @@ in ) ) ++ [ + ( + let + conflictingKeys = lib.intersectAttrs vlansNumbered cfg.interfaces; + in + { + assertion = conflictingKeys == { }; + message = '' + `virtualisation.vlans` and `virtualisation.interfaces` have conflicting keys: ${lib.concatStringsSep "," (lib.attrNames conflictingKeys)} + ''; + } + ) + ( + let + allInterfaceNames = + (lib.mapAttrsToList (k: i: i.name) vlansNumbered) + ++ (lib.mapAttrsToList (k: i: i.name) cfg.interfaces); + in + { + assertion = lib.allUnique allInterfaceNames; + message = '' + `virtualisation.vlans` and `virtualisation.interfaces` have conflicting interface names: ${lib.concatStringsSep "," allInterfaceNames} + ''; + } + ) { assertion = pkgs.stdenv.hostPlatform.is32bit -> cfg.memorySize < 2047; message = '' From 40f2d0b242fd107904a79c28ac473070e556fe18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Mon, 5 Jan 2026 08:15:39 +0100 Subject: [PATCH 2/8] nixos/nspawn-container: fix typo in comment --- .../virtualisation/nspawn-container/run-nspawn/default.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nixos/modules/virtualisation/nspawn-container/run-nspawn/default.nix b/nixos/modules/virtualisation/nspawn-container/run-nspawn/default.nix index 477f8179d34e..3301e9a4d512 100644 --- a/nixos/modules/virtualisation/nspawn-container/run-nspawn/default.nix +++ b/nixos/modules/virtualisation/nspawn-container/run-nspawn/default.nix @@ -1,6 +1,6 @@ { python3Packages, systemd }: python3Packages.callPackage ./package.nix { - # We want `pkgs.systemd`, *not* `python3Packages.system`. + # We want `pkgs.systemd`, *not* `python3Packages.systemd`. inherit systemd; } From 46303ebf0931fb035ba905334af7d6ec8459b68d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Mon, 5 Jan 2026 08:24:21 +0100 Subject: [PATCH 3/8] nixos/nspawn-container: fix typo in virtualisation.allInterfaces --- nixos/modules/virtualisation/nspawn-container/default.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nixos/modules/virtualisation/nspawn-container/default.nix b/nixos/modules/virtualisation/nspawn-container/default.nix index cdcc68f4b98c..a16351856c40 100644 --- a/nixos/modules/virtualisation/nspawn-container/default.nix +++ b/nixos/modules/virtualisation/nspawn-container/default.nix @@ -114,7 +114,7 @@ in type = types.attrsOf interfaceType; readOnly = true; description = '' - All network interfaces for the VM. Combines + All network interfaces for the container. Combines {option}`virtualisation.vlans` and {option}`virtualisation.interfaces`. ''; default = vlansNumbered // cfg.interfaces; From 835e38cf1ab166ff729b5a66932eac34cb9f3c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Mon, 5 Jan 2026 08:25:29 +0100 Subject: [PATCH 4/8] nixos/nspawn-container: clarify format of virtualisation.systemd-nspawn option tree --- nixos/modules/virtualisation/nspawn-container/default.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nixos/modules/virtualisation/nspawn-container/default.nix b/nixos/modules/virtualisation/nspawn-container/default.nix index a16351856c40..a04120d277dd 100644 --- a/nixos/modules/virtualisation/nspawn-container/default.nix +++ b/nixos/modules/virtualisation/nspawn-container/default.nix @@ -146,7 +146,9 @@ in }; virtualisation.systemd-nspawn = { + package = lib.mkPackageOption pkgs "systemd" { }; + options = lib.mkOption { type = types.listOf types.str; default = [ ]; @@ -156,6 +158,7 @@ in See [systemd-nspawn docs](https://www.freedesktop.org/software/systemd/man/latest/systemd-nspawn.html) for a complete list. ''; }; + }; }; From 7ba279d21ece354fe6385eaf77e86268bf8c4c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Mon, 5 Jan 2026 11:45:49 +0100 Subject: [PATCH 5/8] nixos/nspawn-container: rename initArgs to cmdline --- nixos/modules/virtualisation/nspawn-container/default.nix | 4 ++-- .../nspawn-container/run-nspawn/src/run_nspawn/__init__.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nixos/modules/virtualisation/nspawn-container/default.nix b/nixos/modules/virtualisation/nspawn-container/default.nix index a04120d277dd..f7f7016289e0 100644 --- a/nixos/modules/virtualisation/nspawn-container/default.nix +++ b/nixos/modules/virtualisation/nspawn-container/default.nix @@ -120,7 +120,7 @@ in default = vlansNumbered // cfg.interfaces; }; - virtualisation.initArgs = lib.mkOption { + virtualisation.cmdline = lib.mkOption { type = types.listOf types.str; default = [ ]; example = [ @@ -249,7 +249,7 @@ in interfaces=${toPythonExpression (lib.attrValues cfg.allInterfaces)}, # noqa nspawn_options=${toPythonExpression config.virtualisation.systemd-nspawn.options} + sys.argv[1:], # noqa init=${toPythonStr "${config.system.build.toplevel}/init"}, # noqa - init_args=${toPythonExpression cfg.initArgs}, # noqa + cmdline=${toPythonExpression cfg.cmdline}, # noqa ) ''; }; diff --git a/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/run_nspawn/__init__.py b/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/run_nspawn/__init__.py index 76b1d414f732..333bd5634366 100644 --- a/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/run_nspawn/__init__.py +++ b/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/run_nspawn/__init__.py @@ -130,7 +130,7 @@ def run( interfaces: dict, nspawn_options: list[str], init: str, - init_args: list[str], + cmdline: list[str], ) -> None: logging.basicConfig( format=f"nixos-nspawn({container_name}): %(message)s", @@ -174,7 +174,7 @@ def run( f"--directory={root_dir}", f"--network-namespace-path={netns.path}", init, - *init_args, + *cmdline, ], preexec_fn=print_pid, ) From 1e3c78f75d1cba608d2a57c95859f8f5924f8c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Mon, 5 Jan 2026 14:07:22 +0100 Subject: [PATCH 6/8] nixos/nspawn-container: run-nspawn as cli application --- .../nspawn-container/default.nix | 42 +++++------------ .../nspawn-container/run-nspawn/package.nix | 8 +++- .../run-nspawn/src/pyproject.toml | 5 +- .../run-nspawn/src/run_nspawn/__init__.py | 46 ++++++++++++++++++- 4 files changed, 66 insertions(+), 35 deletions(-) diff --git a/nixos/modules/virtualisation/nspawn-container/default.nix b/nixos/modules/virtualisation/nspawn-container/default.nix index f7f7016289e0..c3dc37f1f269 100644 --- a/nixos/modules/virtualisation/nspawn-container/default.nix +++ b/nixos/modules/virtualisation/nspawn-container/default.nix @@ -220,37 +220,17 @@ in system.build.nspawn = let - toPythonStr = - s: - assert builtins.typeOf s == "string"; - # Hack: JSON strings are (AFAIK) valid Python expressions that - # represent the exact same string. - builtins.toJSON s; - - toPythonExpression = data: /* python */ "json.loads(${toPythonStr (builtins.toJSON data)})"; + run-nspawn = pkgs.callPackage ./run-nspawn { }; + commandLineOptions = lib.cli.toCommandLineShellGNU { } { + container-name = config.system.name; + root-dir = cfg.rootDir; + interfaces-json = builtins.toJSON (lib.attrValues cfg.allInterfaces); + init = "${config.system.build.toplevel}/init"; + cmdline-json = builtins.toJSON cfg.cmdline; + }; in - pkgs.writers.writePython3Bin "run-${config.system.name}-nspawn" - { - libraries = ps: [ - (pkgs.callPackage ./run-nspawn { python3Packages = ps; }) - ]; - } - '' - import json - import os - import sys - from pathlib import Path - - import run_nspawn - - run_nspawn.run( - container_name=${toPythonStr config.system.name}, # noqa - root_dir_str=Path(os.environ.get("RUN_NSPAWN_ROOT_DIR", ${toPythonStr cfg.rootDir})), # noqa - interfaces=${toPythonExpression (lib.attrValues cfg.allInterfaces)}, # noqa - nspawn_options=${toPythonExpression config.virtualisation.systemd-nspawn.options} + sys.argv[1:], # noqa - init=${toPythonStr "${config.system.build.toplevel}/init"}, # noqa - cmdline=${toPythonExpression cfg.cmdline}, # noqa - ) - ''; + pkgs.writers.writeDashBin "run-${config.system.name}-nspawn" '' + exec ${lib.getExe run-nspawn} ${commandLineOptions} ${lib.escapeShellArgs config.virtualisation.systemd-nspawn.options} "$@" + ''; }; } diff --git a/nixos/modules/virtualisation/nspawn-container/run-nspawn/package.nix b/nixos/modules/virtualisation/nspawn-container/run-nspawn/package.nix index 08b07c6b8cfa..4f7a0e68748b 100644 --- a/nixos/modules/virtualisation/nspawn-container/run-nspawn/package.nix +++ b/nixos/modules/virtualisation/nspawn-container/run-nspawn/package.nix @@ -1,5 +1,5 @@ { - buildPythonPackage, + buildPythonApplication, e2fsprogs, iproute2, lib, @@ -9,7 +9,7 @@ systemd, }: -buildPythonPackage { +buildPythonApplication { pname = "run-nspawn"; version = "1.0"; pyproject = true; @@ -47,4 +47,8 @@ buildPythonPackage { echo -e "\x1b[32m## run ruff format\x1b[0m" ruff format --check --diff . ''; + + meta = { + mainProgram = "run-nspawn"; + }; } diff --git a/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/pyproject.toml b/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/pyproject.toml index e67bb76c3c24..6fb4fe4336dc 100644 --- a/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/pyproject.toml +++ b/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/pyproject.toml @@ -4,7 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "run-nspawn" -version = "0.0.0" +version = "1.0" + +[project.scripts] +run-nspawn = "run_nspawn:main" [tool.setuptools.packages] find = {} diff --git a/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/run_nspawn/__init__.py b/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/run_nspawn/__init__.py index 333bd5634366..99f50038fd7c 100644 --- a/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/run_nspawn/__init__.py +++ b/nixos/modules/virtualisation/nspawn-container/run-nspawn/src/run_nspawn/__init__.py @@ -11,7 +11,6 @@ from pathlib import Path logger = logging.getLogger() - # Install a SIGTERM handler so all our context managers get a chance to # clean up. signal.signal(signal.SIGTERM, lambda _signum, _frame: sys.exit(0)) @@ -204,3 +203,48 @@ def run( ) sys.exit(exit_code) + + +def main(): + import argparse + import json + + arg_parser = argparse.ArgumentParser() + arg_parser.add_argument( + "--container-name", required=True, help="Name of the container" + ) + arg_parser.add_argument( + "--root-dir", + required=True, + help="Path to container root directory (overridable with RUN_NSPAWN_ROOT_DIR)", + ) + arg_parser.add_argument( + "--interfaces-json", + dest="interfaces", + type=json.loads, + required=True, + help="JSON-encoded list of interfaces, each with 'name' and 'vlan' fields", + ) + arg_parser.add_argument("--init", required=True, help="Path to init binary") + arg_parser.add_argument( + "--cmdline-json", + dest="cmdline", + type=json.loads, + default=[], + help="JSON-encoded list of command line arguments to pass to init", + ) + # Parse only known args to allow for extra args to be passed to systemd-nspawn. + args, nspawn_options = arg_parser.parse_known_args() + + run( + container_name=args.container_name, + root_dir_str=os.getenv("RUN_NSPAWN_ROOT_DIR", default=args.root_dir), + interfaces=args.interfaces, + nspawn_options=nspawn_options, + init=args.init, + cmdline=args.cmdline, + ) + + +if __name__ == "__main__": + main() From f4baee30bba470d7834e1ba29840f23263d2287d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Mon, 5 Jan 2026 17:25:25 +0100 Subject: [PATCH 7/8] nixos/virtualisation: factor out common networking options between qemu-vm and nspawn-container --- .../guest-networking-options.nix | 138 ++++++++++++++++++ .../nspawn-container/default.nix | 126 +--------------- nixos/modules/virtualisation/qemu-vm.nix | 128 +--------------- 3 files changed, 141 insertions(+), 251 deletions(-) create mode 100644 nixos/modules/virtualisation/guest-networking-options.nix diff --git a/nixos/modules/virtualisation/guest-networking-options.nix b/nixos/modules/virtualisation/guest-networking-options.nix new file mode 100644 index 000000000000..fb450cfa09ba --- /dev/null +++ b/nixos/modules/virtualisation/guest-networking-options.nix @@ -0,0 +1,138 @@ +# This module defines networking options for virtual machines and containers. +# It is intended to be used with systemd-nspawn containers and QEMU virtual machines. +{ config, lib, ... }: +let + inherit (lib) types; + + interfaceType = types.submodule ( + { name, ... }: + { + options = { + name = lib.mkOption { + type = types.str; + default = name; + description = '' + Interface name + ''; + }; + + vlan = lib.mkOption { + type = types.ints.unsigned; + description = '' + VLAN to which the network interface is connected. + ''; + }; + + assignIP = lib.mkOption { + type = types.bool; + default = false; + description = '' + Automatically assign an IP address to the network interface using the same scheme as + virtualisation.vlans. + ''; + }; + }; + } + ); + + cfg = config.virtualisation; + + # Convert legacy VLANs to named interfaces. + vlansNumbered = lib.listToAttrs ( + lib.forEach (lib.zipLists cfg.vlans (lib.range 1 255)) ( + v: + let + name = "eth${toString v.snd}"; + in + lib.nameValuePair name { + inherit name; + vlan = v.fst; + assignIP = true; + } + ) + ); +in +{ + options = { + networking.primaryIPAddress = lib.mkOption { + type = types.str; + default = ""; + internal = true; + description = "Primary IP address used in /etc/hosts."; + }; + + networking.primaryIPv6Address = lib.mkOption { + type = types.str; + default = ""; + internal = true; + description = "Primary IPv6 address used in /etc/hosts."; + }; + + virtualisation.vlans = lib.mkOption { + type = types.listOf types.ints.unsigned; + default = if cfg.interfaces == { } then [ 1 ] else [ ]; + defaultText = lib.literalExpression ''if cfg.interfaces == {} then [ 1 ] else [ ]''; + example = [ + 1 + 2 + ]; + description = '' + Virtual networks to which the container or VM is connected. Each number «N» in + this list causes the container to have a virtual Ethernet interface + attached to a separate virtual network on which it will be assigned IP + address `192.168.«N».«M»`, where «M» is the index of this container in + the list of containers. + ''; + }; + + virtualisation.interfaces = lib.mkOption { + default = { }; + example = { + enp1s0.vlan = 1; + }; + description = '' + Extra network interfaces to add to the container or VM in addition to the ones + created by {option}`virtualisation.vlans`. + ''; + type = types.attrsOf interfaceType; + }; + + virtualisation.allInterfaces = lib.mkOption { + type = types.attrsOf interfaceType; + readOnly = true; + description = '' + All network interfaces for the container or VM. Combines + {option}`virtualisation.vlans` and {option}`virtualisation.interfaces`. + ''; + default = vlansNumbered // cfg.interfaces; + }; + }; + config = { + assertions = [ + ( + let + conflictingKeys = lib.intersectAttrs vlansNumbered cfg.interfaces; + in + { + assertion = conflictingKeys == { }; + message = '' + `virtualisation.vlans` and `virtualisation.interfaces` have conflicting keys: ${lib.concatStringsSep "," (lib.attrNames conflictingKeys)} + ''; + } + ) + ( + let + allInterfaceNames = + (lib.mapAttrsToList (k: i: i.name) vlansNumbered) + ++ (lib.mapAttrsToList (k: i: i.name) cfg.interfaces); + in + { + assertion = lib.allUnique allInterfaceNames; + message = '' + `virtualisation.vlans` and `virtualisation.interfaces` have conflicting interface names: ${lib.concatStringsSep "," allInterfaceNames} + ''; + } + ) + ]; + }; +} diff --git a/nixos/modules/virtualisation/nspawn-container/default.nix b/nixos/modules/virtualisation/nspawn-container/default.nix index c3dc37f1f269..c3ab6f1e4873 100644 --- a/nixos/modules/virtualisation/nspawn-container/default.nix +++ b/nixos/modules/virtualisation/nspawn-container/default.nix @@ -19,106 +19,11 @@ let inherit (lib) types; cfg = config.virtualisation; - - interfaceType = types.submodule ( - { name, ... }: - { - options = { - name = lib.mkOption { - type = types.str; - default = name; - description = '' - Interface name - ''; - }; - - vlan = lib.mkOption { - type = types.ints.unsigned; - description = '' - VLAN to which the network interface is connected. - ''; - }; - - assignIP = lib.mkOption { - type = types.bool; - default = false; - description = '' - Automatically assign an IP address to the network interface using the same scheme as - virtualisation.vlans. - ''; - }; - }; - } - ); - - # Convert legacy VLANs to named interfaces. - vlansNumbered = lib.listToAttrs ( - lib.forEach (lib.zipLists cfg.vlans (lib.range 1 255)) ( - v: - let - name = "eth${toString v.snd}"; - in - lib.nameValuePair name { - inherit name; - vlan = v.fst; - assignIP = true; - } - ) - ); in { + imports = [ ../guest-networking-options.nix ]; + options = { - networking.primaryIPAddress = lib.mkOption { - type = types.str; - default = ""; - internal = true; - description = "Primary IP address used in /etc/hosts."; - }; - - networking.primaryIPv6Address = lib.mkOption { - type = types.str; - default = ""; - internal = true; - description = "Primary IPv6 address used in /etc/hosts."; - }; - - virtualisation.vlans = lib.mkOption { - type = types.listOf types.ints.unsigned; - default = if cfg.interfaces == { } then [ 1 ] else [ ]; - defaultText = lib.literalExpression ''if cfg.interfaces == {} then [ 1 ] else [ ]''; - example = [ - 1 - 2 - ]; - description = '' - Virtual networks to which the container is connected. Each number «N» in - this list causes the container to have a virtual Ethernet interface - attached to a separate virtual network on which it will be assigned IP - address `192.168.«N».«M»`, where «M» is the index of this container in - the list of containers. - ''; - }; - - virtualisation.interfaces = lib.mkOption { - default = { }; - example = { - enp1s0.vlan = 1; - }; - description = '' - Network interfaces to add to the container. - ''; - type = types.attrsOf interfaceType; - }; - - virtualisation.allInterfaces = lib.mkOption { - type = types.attrsOf interfaceType; - readOnly = true; - description = '' - All network interfaces for the container. Combines - {option}`virtualisation.vlans` and {option}`virtualisation.interfaces`. - ''; - default = vlansNumbered // cfg.interfaces; - }; virtualisation.cmdline = lib.mkOption { type = types.listOf types.str; @@ -163,33 +68,6 @@ in }; config = { - assertions = [ - ( - let - conflictingKeys = lib.intersectAttrs vlansNumbered cfg.interfaces; - in - { - assertion = conflictingKeys == { }; - message = '' - `virtualisation.vlans` and `virtualisation.interfaces` have conflicting keys: ${lib.concatStringsSep "," (lib.attrNames conflictingKeys)} - ''; - } - ) - ( - let - allInterfaceNames = - (lib.mapAttrsToList (k: i: i.name) vlansNumbered) - ++ (lib.mapAttrsToList (k: i: i.name) cfg.interfaces); - in - { - assertion = lib.allUnique allInterfaceNames; - message = '' - `virtualisation.vlans` and `virtualisation.interfaces` have conflicting interface names: ${lib.concatStringsSep "," allInterfaceNames} - ''; - } - ) - ]; - boot.isNspawnContainer = true; # Needed since nixpkgs 7fb2f407c01b017737eafc26b065d7f56434a992 removed the getty unit by default. diff --git a/nixos/modules/virtualisation/qemu-vm.nix b/nixos/modules/virtualisation/qemu-vm.nix index a83f825de444..762349a39753 100644 --- a/nixos/modules/virtualisation/qemu-vm.nix +++ b/nixos/modules/virtualisation/qemu-vm.nix @@ -360,58 +360,12 @@ let copyChannel = false; OVMF = cfg.efi.OVMF; }; - - interfaceType = types.submodule ( - { name, ... }: - { - options = { - name = mkOption { - type = types.str; - default = name; - description = '' - Interface name - ''; - }; - - vlan = mkOption { - type = types.ints.unsigned; - description = '' - VLAN to which the network interface is connected. - ''; - }; - - assignIP = mkOption { - type = types.bool; - default = false; - description = '' - Automatically assign an IP address to the network interface using the same scheme as - virtualisation.vlans. - ''; - }; - }; - } - ); - - # Convert legacy VLANs to named interfaces. - vlansNumbered = lib.listToAttrs ( - lib.forEach (lib.zipLists cfg.vlans (lib.range 1 255)) ( - v: - let - name = "eth${toString v.snd}"; - in - lib.nameValuePair name { - inherit name; - vlan = v.fst; - assignIP = true; - } - ) - ); - in { imports = [ ../profiles/qemu-guest.nix ./disk-size-option.nix + ./guest-networking-options.nix (mkRenamedOptionModule [ "virtualisation" @@ -726,48 +680,6 @@ in ''; }; - virtualisation.vlans = mkOption { - type = types.listOf types.ints.unsigned; - default = if config.virtualisation.interfaces == { } then [ 1 ] else [ ]; - defaultText = lib.literalExpression ''if config.virtualisation.interfaces == {} then [ 1 ] else [ ]''; - example = [ - 1 - 2 - ]; - description = '' - Virtual networks to which the VM is connected. Each - number «N» in this list causes - the VM to have a virtual Ethernet interface attached to a - separate virtual network on which it will be assigned IP - address - `192.168.«N».«M»`, - where «M» is the index of this VM - in the list of VMs. - ''; - }; - - virtualisation.interfaces = mkOption { - default = { }; - example = { - enp1s0.vlan = 1; - }; - description = '' - Extra network interfaces to add to the VM in addition to the ones - created by {option}`virtualisation.vlans`. - ''; - type = types.attrsOf interfaceType; - }; - - virtualisation.allInterfaces = mkOption { - type = types.attrsOf interfaceType; - readOnly = true; - description = '' - All network interfaces for the VM. Combines - {option}`virtualisation.vlans` and {option}`virtualisation.interfaces`. - ''; - default = vlansNumbered // cfg.interfaces; - }; - virtualisation.writableStore = mkOption { type = types.bool; default = cfg.mountHostNixStore; @@ -790,20 +702,6 @@ in ''; }; - networking.primaryIPAddress = mkOption { - type = types.str; - default = ""; - internal = true; - description = "Primary IP address used in /etc/hosts."; - }; - - networking.primaryIPv6Address = mkOption { - type = types.str; - default = ""; - internal = true; - description = "Primary IPv6 address used in /etc/hosts."; - }; - virtualisation.host.pkgs = mkOption { type = options.nixpkgs.pkgs.type; default = pkgs; @@ -1286,30 +1184,6 @@ in ) ) ++ [ - ( - let - conflictingKeys = lib.intersectAttrs vlansNumbered cfg.interfaces; - in - { - assertion = conflictingKeys == { }; - message = '' - `virtualisation.vlans` and `virtualisation.interfaces` have conflicting keys: ${lib.concatStringsSep "," (lib.attrNames conflictingKeys)} - ''; - } - ) - ( - let - allInterfaceNames = - (lib.mapAttrsToList (k: i: i.name) vlansNumbered) - ++ (lib.mapAttrsToList (k: i: i.name) cfg.interfaces); - in - { - assertion = lib.allUnique allInterfaceNames; - message = '' - `virtualisation.vlans` and `virtualisation.interfaces` have conflicting interface names: ${lib.concatStringsSep "," allInterfaceNames} - ''; - } - ) { assertion = pkgs.stdenv.hostPlatform.is32bit -> cfg.memorySize < 2047; message = '' From be846f5e47b56f33d1e83a1c3a68d920938aa5b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kier=C3=A1n=20Meinhardt?= Date: Mon, 19 Jan 2026 08:38:52 +0100 Subject: [PATCH 8/8] nixos/nspawn-container: assign getty TODO to @arianvp Co-Authored-By: Arian van Putten --- nixos/modules/virtualisation/nspawn-container/default.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nixos/modules/virtualisation/nspawn-container/default.nix b/nixos/modules/virtualisation/nspawn-container/default.nix index c3ab6f1e4873..6f303eec16df 100644 --- a/nixos/modules/virtualisation/nspawn-container/default.nix +++ b/nixos/modules/virtualisation/nspawn-container/default.nix @@ -70,7 +70,7 @@ in config = { boot.isNspawnContainer = true; - # Needed since nixpkgs 7fb2f407c01b017737eafc26b065d7f56434a992 removed the getty unit by default. + # TODO(arianvp): Remove after https://github.com/NixOS/nixpkgs/pull/480686 is merged console.enable = true; virtualisation.systemd-nspawn.options = [