nixos/test-driver: add support for nspawn containers (#478109)

This commit is contained in:
Maximilian Bosch
2026-03-18 23:14:04 +00:00
committed by GitHub
21 changed files with 1598 additions and 534 deletions

View File

@@ -19,9 +19,12 @@
qemu_test,
setuptools,
socat,
systemd,
tesseract4,
util-linux,
vde2,
enableNspawn ? false,
enableOCR ? false,
extraPythonPackages ? (_: [ ]),
}:
@@ -51,8 +54,12 @@ buildPythonApplication {
netpbm
qemu_pkg
socat
util-linux
vde2
]
++ lib.optionals enableNspawn [
systemd
]
++ lib.optionals enableOCR [
imagemagick_light
tesseract4

View File

@@ -51,7 +51,7 @@ def main() -> None:
class_definitions = (node for node in module.body if isinstance(node, ast.ClassDef))
machine_class = next(filter(lambda x: x.name == "Machine", class_definitions))
machine_class = next(filter(lambda x: x.name == "BaseMachine", class_definitions))
assert machine_class is not None
function_definitions = [

View File

@@ -1,6 +1,8 @@
import argparse
import os
import sys
import time
import warnings
from pathlib import Path
import ptpython.ipython
@@ -16,7 +18,7 @@ from test_driver.logger import (
class EnvDefault(argparse.Action):
"""An argpars Action that takes values from the specified
"""An argparse Action that takes values from the specified
environment variable as the flags default value.
"""
@@ -55,9 +57,15 @@ def writeable_dir(arg: str) -> Path:
def main() -> None:
arg_parser = argparse.ArgumentParser(prog="nixos-test-driver")
arg_parser.add_argument(
"-K",
"--keep-vm-state",
help="re-use a VM state coming from a previous run",
help=argparse.SUPPRESS,
dest="keep_machine_state",
action="store_true",
)
arg_parser.add_argument(
"-K",
"--keep-machine-state",
help="re-use a machine state coming from a previous run",
action="store_true",
)
arg_parser.add_argument(
@@ -71,13 +79,37 @@ def main() -> None:
help="Enable interactive debugging breakpoints for sandboxed runs",
)
arg_parser.add_argument(
"--start-scripts",
metavar="START-SCRIPT",
"--vm-names",
metavar="VM-NAME",
action=EnvDefault,
envvar="startScripts",
envvar="vmNames",
nargs="*",
help="names of participating virtual machines",
)
arg_parser.add_argument(
"--vm-start-scripts",
metavar="VM-START-SCRIPT",
action=EnvDefault,
envvar="vmStartScripts",
nargs="*",
help="start scripts for participating virtual machines",
)
arg_parser.add_argument(
"--container-names",
metavar="CONTAINER-NAME",
action=EnvDefault,
envvar="containerNames",
nargs="*",
help="names of participating containers",
)
arg_parser.add_argument(
"--container-start-scripts",
metavar="CONTAINER-START-SCRIPT",
action=EnvDefault,
envvar="containerStartScripts",
nargs="*",
help="start scripts for participating containers",
)
arg_parser.add_argument(
"--vlans",
metavar="VLAN",
@@ -97,8 +129,8 @@ def main() -> None:
arg_parser.add_argument(
"-o",
"--output_directory",
help="""The path to the directory where outputs copied from the VM will be placed.
By e.g. Machine.copy_from_vm or Machine.screenshot""",
help="""The path to the directory where outputs copied from the machine will be placed.
By e.g. NspawnMachine.copy_from_machine or QemuMachine.screenshot""",
default=Path.cwd(),
type=writeable_dir,
)
@@ -122,6 +154,12 @@ def main() -> None:
args = arg_parser.parse_args()
if "--keep-vm-state" in sys.argv:
warnings.warn(
"The flag '--keep-vm-state' is deprecated. Use '--keep-machine-state' instead.",
DeprecationWarning,
)
output_directory = args.output_directory.resolve()
logger = CompositeLogger([TerminalLogger()])
@@ -131,21 +169,33 @@ def main() -> None:
if args.junit_xml:
logger.add_logger(JunitXMLLogger(output_directory / args.junit_xml))
if not args.keep_vm_state:
logger.info("Machine state will be reset. To keep it, pass --keep-vm-state")
if not args.keep_machine_state:
logger.info(
"Machine state will be reset. To keep it, pass --keep-machine-state"
)
debugger: DebugAbstract = DebugNop()
if args.debug_hook_attach is not None:
debugger = Debug(logger, args.debug_hook_attach)
assert len(args.vm_names) == len(args.vm_start_scripts), (
f"the number of vm names and vm start scripts must be the same: {args.vm_names} vs. {args.vm_start_scripts}"
)
assert len(args.container_names) == len(args.container_start_scripts), (
f"the number of container names and container start scripts must be the same: {args.container_names} vs. {args.container_start_scripts}"
)
with Driver(
args.start_scripts,
args.vlans,
args.testscript.read_text(),
output_directory,
logger,
args.keep_vm_state,
args.global_timeout,
vm_names=args.vm_names,
vm_start_scripts=args.vm_start_scripts,
container_names=args.container_names,
container_start_scripts=args.container_start_scripts,
vlans=args.vlans,
tests=args.testscript.read_text(),
out_dir=output_directory,
logger=logger,
keep_machine_state=args.keep_machine_state,
global_timeout=args.global_timeout,
debug=debugger,
) as driver:
if offset := args.dump_vsocks:
@@ -170,7 +220,16 @@ def generate_driver_symbols() -> None:
in user's test scripts. That list is then used by pyflakes to lint those
scripts.
"""
d = Driver([], [], "", Path(), CompositeLogger([]))
d = Driver(
vm_names=[],
vm_start_scripts=[],
container_names=[],
container_start_scripts=[],
vlans=[],
tests="",
out_dir=Path(),
logger=CompositeLogger([]),
)
test_symbols = d.test_symbols()
with open("driver-symbols", "w") as fp:
fp.write(",".join(test_symbols.keys()))

View File

@@ -1,6 +1,7 @@
import os
import re
import signal
import subprocess
import sys
import tempfile
import threading
@@ -16,7 +17,12 @@ from colorama import Style
from test_driver.debug import DebugAbstract, DebugNop
from test_driver.errors import MachineError, RequestedAssertionFailed
from test_driver.logger import AbstractLogger
from test_driver.machine import Machine, NixStartScript, retry
from test_driver.machine import (
BaseMachine,
NspawnMachine,
QemuMachine,
retry,
)
from test_driver.polling_condition import PollingCondition
from test_driver.vlan import VLan
@@ -63,7 +69,8 @@ class Driver:
tests: str
vlans: list[VLan]
machines: list[Machine]
machines_qemu: list[QemuMachine]
machines_nspawn: list[NspawnMachine]
polling_conditions: list[PollingCondition]
global_timeout: int
race_timer: threading.Timer
@@ -72,12 +79,15 @@ class Driver:
def __init__(
self,
start_scripts: list[str],
vm_names: list[str],
vm_start_scripts: list[str],
container_names: list[str],
container_start_scripts: list[str],
vlans: list[int],
tests: str,
out_dir: Path,
logger: AbstractLogger,
keep_vm_state: bool = False,
keep_machine_state: bool = False,
global_timeout: int = 24 * 60 * 60 * 7,
debug: DebugAbstract = DebugNop(),
):
@@ -94,25 +104,95 @@ class Driver:
vlans = list(set(vlans))
self.vlans = [VLan(nr, tmp_dir, self.logger) for nr in vlans]
def cmd(scripts: list[str]) -> Iterator[NixStartScript]:
for s in scripts:
yield NixStartScript(s)
self.polling_conditions = []
self.machines = [
Machine(
start_command=cmd,
keep_vm_state=keep_vm_state,
name=cmd.machine_name,
self.machines_qemu = [
QemuMachine(
name=name,
start_command=vm_start_script,
keep_machine_state=keep_machine_state,
tmp_dir=tmp_dir,
callbacks=[self.check_polling_conditions],
out_dir=self.out_dir,
logger=self.logger,
)
for cmd in cmd(start_scripts)
for name, vm_start_script in zip(vm_names, vm_start_scripts)
]
if len(container_start_scripts) > 0:
self._init_nspawn_environment()
self.machines_nspawn = [
NspawnMachine(
name=name,
start_command=container_start_script,
tmp_dir=tmp_dir,
logger=self.logger,
keep_machine_state=keep_machine_state,
callbacks=[self.check_polling_conditions],
out_dir=self.out_dir,
)
for name, container_start_script in zip(
container_names,
container_start_scripts,
)
]
def _init_nspawn_environment(self) -> None:
assert os.geteuid() == 0, (
f"systemd-nspawn requires root to work. You are {os.geteuid()}"
)
# set up prerequisites for systemd-nspawn containers.
# these are not guaranteed to be set up in the Nix sandbox.
# if running interactively as root, these will already be set up.
# check if /run is writable by root
if not os.access("/run", os.W_OK):
Path("/run").mkdir(parents=True, exist_ok=True)
subprocess.run(["mount", "-t", "tmpfs", "none", "/run"], check=True)
Path("/run/netns").mkdir(parents=True, exist_ok=True)
# check if /var/run is a symlink to /run
if not (os.path.exists("/var/run") and os.path.samefile("/var/run", "/run")):
Path("/var").mkdir(parents=True, exist_ok=True)
subprocess.run(["ln", "-s", "/run", "/var/run"], check=True)
# check if /sys/fs/cgroup is mounted as cgroup2
with open("/proc/mounts", encoding="utf-8") as mounts:
for line in mounts:
parts = line.split()
if len(parts) >= 3 and parts[1] == "/sys/fs/cgroup":
if parts[2] == "cgroup2":
break
else:
Path("/sys/fs/cgroup").mkdir(parents=True, exist_ok=True)
subprocess.run(
["mount", "-t", "cgroup2", "none", "/sys/fs/cgroup"], check=True
)
# systemd-nspawn requires that /etc/os-release exists
# It supports SYSTEMD_NSPAWN_CHECK_OS_RELEASE=0, but that
# would try to "fix" it by bind mounting, which is worse.
if not os.path.isfile("/etc/os-release"):
subprocess.run(["touch", "/etc/os-release"], check=True)
# ensure /etc/machine-id exists and is non-empty
if (
not os.path.isfile("/etc/machine-id")
or os.path.getsize("/etc/machine-id") == 0
):
subprocess.run(
["systemd-machine-id-setup"], check=True
) # set up /etc/machine-id
@property
def machines(self) -> list[QemuMachine | NspawnMachine]:
machines = self.machines_qemu + self.machines_nspawn
# Sort the machines by name for consistency with `nodesAndContainers` in <nixos/lib/testing/network.nix>.
machines.sort(key=lambda machine: machine.name)
return machines
def __enter__(self) -> "Driver":
return self
@@ -148,7 +228,8 @@ class Driver:
general_symbols = dict(
start_all=self.start_all,
test_script=self.test_script,
machines=self.machines,
machines_qemu=self.machines_qemu,
machines_nspawn=self.machines_nspawn,
vlans=self.vlans,
driver=self,
log=self.logger,
@@ -161,7 +242,7 @@ class Driver:
serial_stdout_off=self.serial_stdout_off,
serial_stdout_on=self.serial_stdout_on,
polling_condition=self.polling_condition,
Machine=Machine, # for typing
BaseMachine=BaseMachine, # for typing
t=AssertionTester(),
debug=self.debug,
)
@@ -186,14 +267,14 @@ class Driver:
def dump_machine_ssh(self, offset: int) -> None:
print("SSH backdoor enabled, the machines can be accessed like this:")
print(
f"{Style.BRIGHT}Note:{Style.RESET_ALL} this requires {Style.BRIGHT}systemd-ssh-proxy(1){Style.RESET_ALL} to be enabled (default on NixOS 25.05 and newer)."
f"{Style.BRIGHT}Note:{Style.RESET_ALL} vsocks require {Style.BRIGHT}systemd-ssh-proxy(1){Style.RESET_ALL} to be enabled (default on NixOS 25.05 and newer)."
)
names = [machine.name for machine in self.machines]
longest_name = len(max(names, key=len))
for num, name in enumerate(names, start=offset + 1):
longest_name = len(max((machine.name for machine in self.machines), key=len))
for index, machine in enumerate(self.machines, start=offset + 1):
name = machine.name
spaces = " " * (longest_name - len(name) + 2)
print(
f" {name}:{spaces}{Style.BRIGHT}ssh -o User=root vsock/{num}{Style.RESET_ALL}"
f" {name}:{spaces}{Style.BRIGHT}{machine.ssh_backdoor_command(index)}{Style.RESET_ALL}"
)
def test_script(self) -> None:
@@ -252,8 +333,16 @@ class Driver:
def start_all(self) -> None:
"""Start all machines"""
with self.logger.nested("start all VMs"):
threads = []
for machine in self.machines:
machine.start()
# Create a thread for each machine's start method
t = threading.Thread(target=machine.start, name=f"start-{machine.name}")
threads.append(t)
t.start()
# Wait for all startup threads to complete before proceeding
for t in threads:
t.join()
def join_all(self) -> None:
"""Wait for all machines to shut down"""
@@ -279,19 +368,19 @@ class Driver:
start_command: str,
*,
name: str | None = None,
keep_vm_state: bool = False,
) -> Machine:
keep_machine_state: bool = False,
) -> BaseMachine:
"""
Create a `QemuMachine`. This currently only supports qemu "nodes", not containers.
"""
tmp_dir = get_tmp_dir()
cmd = NixStartScript(start_command)
name = name or cmd.machine_name
return Machine(
return QemuMachine(
tmp_dir=tmp_dir,
out_dir=self.out_dir,
start_command=cmd,
start_command=start_command,
name=name,
keep_vm_state=keep_vm_state,
keep_machine_state=keep_machine_state,
logger=self.logger,
)

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,8 @@ class VLan:
pid: int
fd: io.TextIOBase
plug_process: subprocess.Popen
logger: AbstractLogger
def __repr__(self) -> str:
@@ -58,6 +60,7 @@ class VLan:
def __init__(self, nr: int, tmp_dir: Path, logger: AbstractLogger):
self.nr = nr
self.socket_dir = tmp_dir / f"vde{self.nr}.ctl"
self.tap_name = f"vde-tap{self.nr}"
self.logger = logger
# TODO: don't side-effect environment here
@@ -114,6 +117,13 @@ class VLan:
if "1000 Success" in line:
break
# This is needed to allow systemd-nspawn containers to communicate
# with VMs connected to the VLAN.
self.logger.info(f"creating tap interface {self.tap_name}")
self.plug_process = subprocess.Popen(
["vde_plug2tap", "-s", self.socket_dir, self.tap_name],
)
assert (self.socket_dir / "ctl").exists(), "cannot start vde_switch"
self.logger.info(f"running vlan (pid {self.pid}; ctl {self.socket_dir})")
@@ -122,4 +132,7 @@ class VLan:
self.logger.info(f"kill vlan (pid {self.pid})")
assert self.process.stdin is not None
self.process.stdin.close()
if self.plug_process:
self.plug_process.terminate()
self.plug_process.wait()
self.process.terminate()

View File

@@ -4,7 +4,7 @@
from test_driver.debug import DebugAbstract
from test_driver.driver import Driver
from test_driver.vlan import VLan
from test_driver.machine import Machine
from test_driver.machine import BaseMachine, NspawnMachine, QemuMachine
from test_driver.logger import AbstractLogger
from typing import Callable, Iterator, ContextManager, Optional, List, Dict, Any, Union
from typing_extensions import Protocol
@@ -34,8 +34,9 @@ class CreateMachineProtocol(Protocol):
start_command: str | dict,
*,
name: Optional[str] = None,
keep_vm_state: bool = False,
) -> Machine:
keep_machine_state: bool = False,
**kwargs: Any, # to allow usage of deprecated keep_vm_state
) -> BaseMachine:
raise Exception("This is just type information for the Nix test driver")
@@ -43,7 +44,7 @@ start_all: Callable[[], None]
subtest: Callable[[str], ContextManager[None]]
retry: RetryProtocol
test_script: Callable[[], None]
machines: List[Machine]
machines: List[BaseMachine]
vlans: List[VLan]
driver: Driver
log: AbstractLogger

View File

@@ -56,6 +56,7 @@ pkgs.lib.throwIf (args ? specialArgs)
{
machine ? null,
nodes ? { },
containers ? { },
testScript,
enableOCR ? false,
globalTimeout ? (60 * 60),

View File

@@ -14,18 +14,17 @@ let
qemu_pkg = config.qemu.package;
imagemagick_light = hostPkgs.imagemagick_light.override { inherit (hostPkgs) libtiff; };
tesseract4 = hostPkgs.tesseract4.override { enableLanguages = [ "eng" ]; };
enableNspawn = config.containers != { };
# We want `pkgs.systemd`, *not* `python3Packages.system`.
systemd = hostPkgs.systemd;
};
vlans = map (
m: (m.virtualisation.vlans ++ (lib.mapAttrsToList (_: v: v.vlan) m.virtualisation.interfaces))
) (lib.attrValues config.nodes);
) ((lib.attrValues config.nodes) ++ (lib.attrValues config.containers));
vms = map (m: m.system.build.vm) (lib.attrValues config.nodes);
nodeHostNames =
let
nodesList = map (c: c.system.name) (lib.attrValues config.nodes);
in
nodesList ++ lib.optional (lib.length nodesList == 1 && !lib.elem "machine" nodesList) "machine";
containers = map (m: m.system.build.nspawn) (lib.attrValues config.containers);
pythonizeName =
name:
@@ -38,8 +37,22 @@ let
uniqueVlans = lib.unique (builtins.concatLists vlans);
vlanNames = map (i: "vlan${toString i}: VLan;") uniqueVlans;
pythonizedNames = map pythonizeName nodeHostNames;
machineNames = map (name: "${name}: Machine;") pythonizedNames;
vmMachineNames = map (c: c.system.name) (lib.attrValues config.nodes);
containerMachineNames = map (c: c.system.name) (lib.attrValues config.containers);
theOnlyMachine =
let
exactlyOneMachine = lib.length (lib.attrValues config.nodes) == 1;
allMachineNames = map (c: c.system.name) (lib.attrValues config.allMachines);
in
lib.optional (exactlyOneMachine && !lib.elem "machine" allMachineNames) "machine";
pythonizedVmNames = map pythonizeName (vmMachineNames ++ theOnlyMachine);
vmMachineTypeHints = map (name: "${name}: QemuMachine;") pythonizedVmNames;
pythonizedContainerNames = map pythonizeName containerMachineNames;
containerMachineTypeHints = map (name: "${name}: NspawnMachine;") pythonizedContainerNames;
withChecks = lib.warnIf config.skipLint "Linting is disabled";
@@ -62,12 +75,16 @@ let
''
mkdir -p $out/bin
vmStartScripts=($(for i in ${toString vms}; do echo $i/bin/run-*-vm; done))
vmNames=(${lib.escapeShellArgs vmMachineNames})
vmStartScripts=(${lib.escapeShellArgs (map lib.getExe vms)})
containerNames=(${lib.escapeShellArgs containerMachineNames})
containerStartScripts=(${lib.escapeShellArgs (map lib.getExe containers)})
${lib.optionalString (!config.skipTypeCheck) ''
# prepend type hints so the test script can be type checked with mypy
cat "${../test-script-prepend.py}" >> testScriptWithTypes
echo "${toString machineNames}" >> testScriptWithTypes
echo "${toString vmMachineTypeHints}" >> testScriptWithTypes
echo "${toString containerMachineTypeHints}" >> testScriptWithTypes
echo "${toString vlanNames}" >> testScriptWithTypes
echo -n "$testScript" >> testScriptWithTypes
@@ -90,7 +107,9 @@ let
echo "See https://nixos.org/manual/nixos/stable/#test-opt-skipLint"
PYFLAKES_BUILTINS="$(
echo -n ${lib.escapeShellArg (lib.concatStringsSep "," pythonizedNames)},
echo -n ${
lib.escapeShellArg (lib.concatStringsSep "," (pythonizedVmNames ++ pythonizedContainerNames))
},
cat ${lib.escapeShellArg "driver-symbols"}
)" ${hostPkgs.python3Packages.pyflakes}/bin/pyflakes $out/test-script
''}
@@ -98,7 +117,10 @@ let
# set defaults through environment
# see: ./test-driver/test-driver.py argparse implementation
wrapProgram $out/bin/nixos-test-driver \
--set startScripts "''${vmStartScripts[*]}" \
--set vmStartScripts "''${vmStartScripts[*]}" \
--set vmNames "''${vmNames[*]}" \
--set containerStartScripts "''${containerStartScripts[*]}" \
--set containerNames "''${containerNames[*]}" \
--set testScript "$out/test-script" \
--set globalTimeout "${toString config.globalTimeout}" \
--set vlans '${toString vlans}' \

View File

@@ -1,11 +1,13 @@
{ lib, nodes, ... }:
testModuleArgs@{
lib,
...
}:
let
inherit (lib)
attrNames
concatMap
concatMapAttrsStringSep
concatMapStrings
flip
forEach
head
listToAttrs
@@ -20,22 +22,15 @@ let
zipLists
;
nodeNumbers = listToAttrs (zipListsWith nameValuePair (attrNames nodes) (range 1 254));
nodeNumbers = listToAttrs (
zipListsWith nameValuePair (attrNames testModuleArgs.config.allMachines) (range 1 254)
);
networkModule =
{
config,
nodes,
pkgs,
...
}:
{ config, ... }:
let
qemu-common = import ../qemu-common.nix { inherit (pkgs) lib stdenv; };
interfaces = lib.attrValues config.virtualisation.allInterfaces;
interfacesNumbered = zipLists interfaces (range 1 255);
# Automatically assign IP addresses to requested interfaces.
assignIPs = lib.filter (i: i.assignIP) interfaces;
ipInterfaces = forEach assignIPs (
@@ -56,17 +51,6 @@ let
}
);
qemuOptions = lib.flatten (
forEach interfacesNumbered (
{ fst, snd }: qemu-common.qemuNICFlags snd fst.vlan config.virtualisation.test.nodeNumber
)
);
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 interface.vlan config.virtualisation.test.nodeNumber)}",NAME="${interface.name}"''
);
networkConfig = {
networking.hostName = mkDefault config.virtualisation.test.nodeName;
@@ -80,33 +64,51 @@ let
optionalString (ipInterfaces != [ ])
(head (head ipInterfaces).value.ipv6.addresses).address;
# Put the IP addresses of all VMs in this machine's
# /etc/hosts file. If a machine has multiple
# interfaces, use the IP address corresponding to
# the first interface (i.e. the first network in its
# virtualisation.vlans option).
networking.extraHosts = flip concatMapStrings (attrNames nodes) (
m':
# Generate /etc/hosts including every remote's primary IP addresses
# (whichever VLAN they may belong to) as well as all IP addresses from
# VLANs that both the local machine and the remote machine share.
networking.extraHosts =
let
config = nodes.${m'};
hostnames =
optionalString (
config.networking.domain != null
) "${config.networking.hostName}.${config.networking.domain} "
+ "${config.networking.hostName}\n";
localVlans = config.virtualisation.vlans;
in
optionalString (
config.networking.primaryIPAddress != ""
) "${config.networking.primaryIPAddress} ${hostnames}"
+ optionalString (
config.networking.primaryIPv6Address != ""
) "${config.networking.primaryIPv6Address} ${hostnames}"
);
concatMapAttrsStringSep "" (
mName: remoteConfig:
let
remoteInterfaces = remoteConfig.networking.interfaces;
sharedIps = lib.flatten (
lib.mapAttrsToList (
ifaceName: ifaceCfg:
let
remoteIfaceMeta = remoteConfig.virtualisation.allInterfaces."${ifaceName}" or { };
vlanId = remoteIfaceMeta.vlan or null;
in
if vlanId != null && builtins.elem vlanId localVlans then
builtins.map (addr: addr.address) ifaceCfg.ipv4.addresses
++ builtins.map (addr: addr.address) ifaceCfg.ipv6.addresses
else
[ ]
) remoteInterfaces
);
virtualisation.qemu.options = qemuOptions;
boot.initrd.services.udev.rules = concatMapStrings (x: x + "\n") udevRules;
# We also want to test router protocols that enable connections
# between nodes even if they don't share a VLAN, so we include
# the primary IPs of all machines in the hosts file.
primaryIPs = [
remoteConfig.networking.primaryIPAddress
remoteConfig.networking.primaryIPv6Address
];
allReachableIps = lib.lists.uniqueStrings (sharedIps ++ primaryIPs);
hostnames =
optionalString (
remoteConfig.networking.domain != null
) "${remoteConfig.networking.hostName}.${remoteConfig.networking.domain} "
+ "${remoteConfig.networking.hostName}\n";
in
builtins.concatStringsSep "" (map (ip: "${ip} ${hostnames}") allReachableIps)
) testModuleArgs.config.allMachines;
};
in
{
key = "network-interfaces";
@@ -117,6 +119,31 @@ let
};
};
qemuNetworkModule =
{ config, pkgs, ... }:
let
qemu-common = import ../qemu-common.nix { inherit (pkgs) lib stdenv; };
interfaces = lib.attrValues config.virtualisation.allInterfaces;
interfacesNumbered = zipLists interfaces (range 1 255);
qemuOptions = lib.flatten (
forEach interfacesNumbered (
{ fst, snd }: qemu-common.qemuNICFlags snd fst.vlan config.virtualisation.test.nodeNumber
)
);
udevRules = map (
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 interface.vlan config.virtualisation.test.nodeNumber)}",NAME="${interface.name}"''
) interfaces;
in
{
virtualisation.qemu.options = qemuOptions;
boot.initrd.services.udev.rules = concatMapStrings (x: x + "\n") udevRules;
};
nodeNumberModule = (
regular@{ config, name, ... }:
{
@@ -127,7 +154,7 @@ let
# We need to force this in specialisations, otherwise it'd be
# readOnly = true;
description = ''
The `name` in `nodes.<name>`; stable across `specialisations`.
The `name` in `nodes.<name>` and `containers.<name>`; stable across `specialisations`.
'';
};
virtualisation.test.nodeNumber = mkOption {
@@ -136,7 +163,7 @@ let
readOnly = true;
default = nodeNumbers.${config.virtualisation.test.nodeName};
description = ''
A unique number assigned for each node in `nodes`.
A unique number assigned for each machine in `nodes` and `containers`.
'';
};
@@ -172,5 +199,10 @@ in
nodeNumberModule
];
};
extraBaseNodeModules = {
imports = [
qemuNetworkModule
];
};
};
}

View File

@@ -7,7 +7,6 @@ let
in
{
imports = [
../../modules/virtualisation/qemu-vm.nix
../../modules/testing/test-instrumentation.nix # !!! should only get added for automated test runs
{
key = "no-manual";
@@ -32,7 +31,9 @@ in
# This is mostly a Hydra optimization, so we don't rebuild all the tests every time switch-to-configuration-ng changes.
key = "no-switch-to-configuration";
system.switch.enable = mkDefault (
config.isSpecialisation || config.specialisation != { } || config.virtualisation.installBootLoader
config.isSpecialisation
|| config.specialisation != { }
|| (!config.boot.isContainer && config.virtualisation.installBootLoader)
);
}
)

View File

@@ -2,7 +2,6 @@ testModuleArgs@{
config,
lib,
hostPkgs,
nodes,
options,
...
}:
@@ -12,12 +11,9 @@ let
literalExpression
literalMD
mapAttrs
mkDefault
mkIf
mkMerge
mkOption
mkForce
optional
optionalAttrs
types
;
@@ -49,15 +45,11 @@ let
./nixos-test-base.nix
{
key = "nodes";
_module.args.nodes = config.nodesCompat;
_module.args = {
inherit (config) containers;
nodes = config.nodesCompat;
};
}
(
{ config, ... }:
{
virtualisation.qemu.package = testModuleArgs.config.qemu.package;
virtualisation.host.pkgs = hostPkgs;
}
)
(
{ options, ... }:
{
@@ -73,6 +65,62 @@ let
testModuleArgs.config.extraBaseModules
];
};
baseQemuOS = baseOS.extendModules {
modules = [
../../modules/virtualisation/qemu-vm.nix
config.nodeDefaults
{
key = "base-qemu";
virtualisation.qemu.package = testModuleArgs.config.qemu.package;
virtualisation.host.pkgs = hostPkgs;
}
testModuleArgs.config.extraBaseNodeModules
];
};
baseNspawnOS = baseOS.extendModules {
modules = [
../../modules/virtualisation/nspawn-container
config.containerDefaults
(
{ pkgs, ... }:
{
key = "base-nspawn";
# PAM requires setuid and doesn't work in the build sandbox.
# https://github.com/NixOS/nix/blob/959c244a1265f4048390f3ad21679219d7b27a99/src/libstore/unix/build/linux-derivation-builder.cc#L63
services.openssh.settings.UsePAM = false;
# Networking for tests is statically configured by default.
# dhcpcd times out after blocking for a long time, which slows down tests.
# See https://github.com/NixOS/nixpkgs/pull/478109#discussion_r2867570799
networking.useDHCP = lib.mkDefault false;
# Disable Info manual directory generation to prevent build failures.
#
# Context: 'install-info' (from texinfo) is triggered during system-path
# generation to index manuals, but it requires 'gzip' in the $PATH to
# decompress them.
# When 'networking.useDHCP' is set to false, transitive dependencies
# (like dhcpcd or other network tools) that normally pull 'gzip' into
# the system environment are removed. This leaves 'install-info'
# stranded without 'gzip', causing the 'system-path' derivation to fail.
# Since nspawn containers are typically minimal, disabling 'info'
# is a cleaner fix than explicitly adding 'gzip' to systemPackages.
documentation.info.enable = lib.mkDefault false;
# Gross, insecure hack to make login work. See above.
security.pam.services.login = {
text = ''
auth sufficient ${pkgs.linux-pam}/lib/security/pam_permit.so
account sufficient ${pkgs.linux-pam}/lib/security/pam_permit.so
password sufficient ${pkgs.linux-pam}/lib/security/pam_permit.so
session sufficient ${pkgs.linux-pam}/lib/security/pam_permit.so
'';
};
}
)
];
};
# TODO (lib): Dedup with run.nix, add to lib/options.nix
mkOneUp = opt: f: lib.mkOverride (opt.highestPrio - 1) (f opt.value);
@@ -109,15 +157,16 @@ in
node.type = mkOption {
type = types.raw;
default = baseOS.type;
default = baseQemuOS.type;
internal = true;
};
nodes = mkOption {
type = types.lazyAttrsOf config.node.type;
default = { };
visible = "shallow";
description = ''
An attribute set of NixOS configuration modules.
An attribute set of NixOS configuration modules representing QEMU vms that can be started during a test.
The configurations are augmented by the [`defaults`](#test-opt-defaults) option.
@@ -127,7 +176,55 @@ in
'';
};
container.type = mkOption {
type = types.raw;
default = baseNspawnOS.type;
internal = true;
};
containers = mkOption {
type = types.lazyAttrsOf config.container.type;
default = { };
visible = "shallow";
description = ''
An attribute set of NixOS configuration modules representing systemd-nspawn containers that can be started during a test.
The configurations are augmented by the [`defaults`](#test-opt-defaults) option.
They are assigned network addresses according to the `nixos/lib/testing/network.nix` module.
A few special options are available, that aren't in a plain NixOS configuration. See [Configuring the nodes](#sec-nixos-test-nodes)
'';
};
allMachines = mkOption {
readOnly = true;
internal = true;
description = ''
Basically a merge of [{option}`nodes`](#test-opt-nodes) and [{option}`containers`](#test-opt-containers).
This ensures that there are no name collisions between nodes and containers.
'';
default =
let
overlappingNames = lib.intersectLists (lib.attrNames config.nodes) (
lib.attrNames config.containers
);
in
lib.throwIfNot (overlappingNames == [ ])
"The following names are used in both `nodes` and `containers`: ${lib.concatStringsSep ", " overlappingNames}"
(config.nodes // config.containers);
};
defaults = mkOption {
description = ''
NixOS configuration that is applied to all [{option}`nodes`](#test-opt-nodes) and [{option}`containers`](#test-opt-containers).
'';
type = types.deferredModule;
default = { };
};
nodeDefaults = mkOption {
description = ''
NixOS configuration that is applied to all [{option}`nodes`](#test-opt-nodes).
'';
@@ -135,7 +232,23 @@ in
default = { };
};
containerDefaults = mkOption {
description = ''
NixOS configuration that is applied to all [{option}`containers`](#test-opt-containers).
'';
type = types.deferredModule;
default = { };
};
extraBaseModules = mkOption {
description = ''
NixOS configuration that, like [{option}`defaults`](#test-opt-defaults), is applied to all [{option}`nodes`](#test-opt-nodes) and [{option}`containers`](#test-opt-containers) and can not be undone with [`specialisation.<name>.inheritParentConfig`](https://search.nixos.org/options?show=specialisation.%3Cname%3E.inheritParentConfig&from=0&size=50&sort=relevance&type=packages&query=specialisation).
'';
type = types.deferredModule;
default = { };
};
extraBaseNodeModules = mkOption {
description = ''
NixOS configuration that, like [{option}`defaults`](#test-opt-defaults), is applied to all [{option}`nodes`](#test-opt-nodes) and can not be undone with [`specialisation.<name>.inheritParentConfig`](https://search.nixos.org/options?show=specialisation.%3Cname%3E.inheritParentConfig&from=0&size=50&sort=relevance&type=packages&query=specialisation).
'';
@@ -145,7 +258,7 @@ in
node.pkgs = mkOption {
description = ''
The Nixpkgs to use for the nodes.
The Nixpkgs to use for the nodes and containers.
Setting this will make the `nixpkgs.*` options read-only, to avoid mistakenly testing with a Nixpkgs configuration that diverges from regular use.
'';
@@ -160,7 +273,7 @@ in
description = ''
Whether to make the `nixpkgs.*` options read-only. This is only relevant when [`node.pkgs`](#test-opt-node.pkgs) is set.
Set this to `false` when any of the [`nodes`](#test-opt-nodes) needs to configure any of the `nixpkgs.*` options. This will slow down evaluation of your test a bit.
Set this to `false` when any of the [`nodes`](#test-opt-nodes) or [{option}`containers`](#test-opt-containers) need to configure any of the `nixpkgs.*` options. This will slow down evaluation of your test a bit.
'';
type = types.bool;
default = config.node.pkgs != null;
@@ -188,6 +301,7 @@ in
};
config = {
_module.args.containers = config.containers;
_module.args.nodes = config.nodesCompat;
nodesCompat = mapAttrs (
name: config:
@@ -201,6 +315,7 @@ in
) config.nodes;
passthru.nodes = config.nodesCompat;
passthru.containers = config.containers;
extraDriverArgs = mkIf config.sshBackdoor.enable [
"--dump-vsocks=${toString config.sshBackdoor.vsockOffset}"
@@ -211,33 +326,35 @@ in
nixpkgs.pkgs = config.node.pkgs;
imports = [ ../../modules/misc/nixpkgs/read-only.nix ];
})
(mkIf config.sshBackdoor.enable (
let
inherit (config.sshBackdoor) vsockOffset;
in
{ config, ... }:
{
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "yes";
PermitEmptyPasswords = "yes";
};
(mkIf config.sshBackdoor.enable {
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "yes";
PermitEmptyPasswords = "yes";
};
};
security.pam.services.sshd = {
allowNullPassword = true;
};
virtualisation.qemu.options = [
"-device vhost-vsock-pci,guest-cid=${
toString (config.virtualisation.test.nodeNumber + vsockOffset)
}"
];
}
))
security.pam.services.sshd = {
allowNullPassword = true;
};
})
];
nodeDefaults = mkIf config.sshBackdoor.enable (
let
inherit (config.sshBackdoor) vsockOffset;
in
{ config, ... }:
{
virtualisation.qemu.options = [
"-device vhost-vsock-pci,guest-cid=${
toString (config.virtualisation.test.nodeNumber + vsockOffset)
}"
];
}
);
# Docs: nixos/doc/manual/development/writing-nixos-tests.section.md
/**
See https://nixos.org/manual/nixos/unstable#sec-override-nixos-test

View File

@@ -2,6 +2,7 @@
config,
hostPkgs,
lib,
containers,
options,
...
}:
@@ -96,12 +97,15 @@ in
requiredSystemFeatures = [
"nixos-test"
]
# Containers use systemd-nspawn, which requires pid 0 inside of the sandbox.
++ lib.optional (builtins.length (lib.attrNames containers) > 0) "uid-range"
++ lib.optional isLinux "kvm"
++ lib.optional isDarwin "apple-virt";
nativeBuildInputs = lib.optionals config.enableDebugHook [
hostPkgs.openssh
hostPkgs.inetutils
hostPkgs.socat # to allow SSH backdoor connections for systemd-nspawn containers
];
buildCommand = ''

View File

@@ -56,11 +56,12 @@ in
# reuse memoized config
v
) config.nodesCompat;
containers = config.containers;
}
else
config.testScript;
defaults =
nodeDefaults =
{ config, name, ... }:
{
# Make sure all derivations referenced by the test

View File

@@ -86,7 +86,8 @@ in
options.testing = {
backdoor = lib.mkEnableOption "backdoor service in stage 2" // {
default = true;
# See assertion below for why the backdoor doesn't work with containers.
default = !config.boot.isContainer;
};
initrdBackdoor = lib.mkEnableOption ''
@@ -105,7 +106,20 @@ in
{
assertion = cfg.initrdBackdoor -> config.boot.initrd.systemd.enable;
message = ''
testing.initrdBackdoor requires boot.initrd.systemd.enable to be enabled.
`testing.initrdBackdoor` requires `boot.initrd.systemd.enable` to be enabled.
'';
}
{
assertion = config.boot.isContainer -> !cfg.backdoor;
message = ''
`testing.backdoor` uses virtio console, which does not work with
containers (we use `nsenter` instead).
'';
}
{
assertion = config.boot.isContainer -> !cfg.initrdBackdoor;
message = ''
`testing.initrdBackdoor` does not work with containers as there is no initrd.
'';
}
];

View File

@@ -71,7 +71,7 @@ in
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 [ ]";
defaultText = lib.literalExpression "if config.virtualisation.interfaces == {} then [ 1 ] else [ ]";
example = [
1
2

View File

@@ -70,6 +70,45 @@ in
config = {
boot.isNspawnContainer = true;
assertions = [
{
assertion = config.specialisation == { };
message = ''
Setting 'specialisation' is disallowed for systemd-nspawn container configurations.
Activating a specialisation requires creating SUID wrappers (e.g., for 'sudo'),
which is prohibited within the Nix build sandbox where the test is run.
'';
}
{
# Check every interface defined in allInterfaces.
# Containers try to create a bridge "${config.system.name}-${interfaceName}"
assertion = lib.all (
iface:
let
hostName = "${config.system.name}-${iface.name}";
in
lib.stringLength hostName <= 15
) (lib.attrValues cfg.allInterfaces);
message =
let
offendingInterfaces = lib.filter (
iface: lib.stringLength "${config.system.name}-${iface.name}" > 15
) (lib.attrValues cfg.allInterfaces);
offenderList = map (
i:
"${config.system.name}-${i.name} (${toString (lib.stringLength "${config.system.name}-${i.name}")} chars)"
) offendingInterfaces;
in
''
The following generated host interface names exceed the Linux 15-character limit:
${lib.concatStringsSep "\n " offenderList}
Please shorten 'config.system.name' or the interface names in 'virtualisation.interfaces'.
'';
}
];
# TODO(arianvp): Remove after https://github.com/NixOS/nixpkgs/pull/480686 is merged
console.enable = true;
@@ -94,6 +133,9 @@ in
# > kind of unit allocation or registration with systemd-machined.
"--keep-unit"
"--register=no"
# Send a READY=1 notification to a socket when the container is fully booted.
"--notify-ready=yes"
];
system.build.nspawn =

View File

@@ -68,7 +68,9 @@ def ensure_vlan_bridge(vlan: int) -> typing.Generator[str, None, None]:
ipv6_addr = f"2001:db8:{vlan}::fe/64"
bridge_name = f"br{vlan}"
tap_name = f"vde-tap{vlan}"
bridge_path = Path("/sys/class/net") / bridge_name
tap_path = Path("/sys/class/net") / tap_name
try:
# To avoid racing against other nspawn containers that also
# need this vlan, grab an exclusive lock.
@@ -80,6 +82,19 @@ def ensure_vlan_bridge(vlan: int) -> typing.Generator[str, None, None]:
run_ip("addr", "add", ipv4_addr, "dev", bridge_name)
run_ip("addr", "add", ipv6_addr, "dev", bridge_name)
if tap_path.exists():
logger.info(f"attaching {tap_name} to {bridge_name}")
run_ip("link", "set", tap_name, "master", bridge_name)
run_ip("link", "set", tap_name, "up")
else:
logger.warning(
f"TAP {tap_name} not found; container will be isolated from VDE"
)
if not Path("/dev/net").exists():
logger.warning(
"A common reason for this is that /dev/net is not available in the Nix sandbox. Try adding /dev/net to extra-sandbox-paths."
)
yield bridge_name
finally:
# To avoid racing against other nspawn containers that also
@@ -126,6 +141,7 @@ def mk_veth(
def run(
container_name: str,
root_dir_str: str,
shared_dir_str: typing.Optional[str],
interfaces: dict,
nspawn_options: list[str],
init: str,
@@ -166,12 +182,19 @@ def run(
flush=True,
)
shared_dir = Path(shared_dir_str) if shared_dir_str else None
cp = subprocess.Popen(
[
"@systemd-nspawn@",
*nspawn_options,
f"--directory={root_dir}",
f"--network-namespace-path={netns.path}",
*(
[f"--bind={shared_dir}:/tmp/shared"]
if shared_dir is not None
else []
),
init,
*cmdline,
],
@@ -218,6 +241,11 @@ def main():
required=True,
help="Path to container root directory (overridable with RUN_NSPAWN_ROOT_DIR)",
)
arg_parser.add_argument(
"--shared-dir",
required=False,
help="Path to a shared directory to bind-mount into the container at /tmp/shared (overridable with RUN_NSPAWN_SHARED_DIR)",
)
arg_parser.add_argument(
"--interfaces-json",
dest="interfaces",
@@ -239,6 +267,7 @@ def main():
run(
container_name=args.container_name,
root_dir_str=os.getenv("RUN_NSPAWN_ROOT_DIR", default=args.root_dir),
shared_dir_str=os.getenv("RUN_NSPAWN_SHARED_DIR", default=args.shared_dir),
interfaces=args.interfaces,
nspawn_options=nspawn_options,
init=args.init,

View File

@@ -168,6 +168,7 @@ in
node-name = runTest ./nixos-test-driver/node-name.nix;
busybox = runTest ./nixos-test-driver/busybox.nix;
console-log = runTest ./nixos-test-driver/console-log.nix;
containers = runTest ./nixos-test-driver/containers.nix;
driver-timeout =
pkgs.runCommand "ensure-timeout-induced-failure"
{
@@ -1645,6 +1646,7 @@ in
teleports = runTest ./teleports.nix;
temporal = runTest ./temporal.nix;
terminal-emulators = handleTest ./terminal-emulators.nix { };
test-containers-bittorrent = runTest ./test-containers-bittorrent.nix;
thanos = runTest ./thanos.nix;
thelounge = handleTest ./thelounge.nix { };
tiddlywiki = runTest ./tiddlywiki.nix;

View File

@@ -0,0 +1,77 @@
{ pkgs, ... }:
{
name = "containers";
meta.maintainers = with pkgs.lib.maintainers; [ jfly ];
nodes = {
n1 = {
virtualisation.vlans = [ 1 ];
};
n2 = {
virtualisation.vlans = [
2
];
};
};
containers = {
c1 = {
virtualisation.vlans = [ 1 ];
};
c2 = {
virtualisation.vlans = [ 2 ];
};
c12 = {
virtualisation.vlans = [
1
2
];
};
};
testScript = /* python */ ''
c1.start()
c2.start()
c12.start()
c1.succeed("echo hello > /hello.txt")
c1.copy_from_machine("/hello.txt")
c1.systemctl("start network-online.target")
c2.systemctl("start network-online.target")
c12.systemctl("start network-online.target")
c1.wait_for_unit("network-online.target")
c2.wait_for_unit("network-online.target")
c12.wait_for_unit("network-online.target")
# Confirm containers in vlan 1 can talk to each other.
c1.succeed("ping -c 1 c12")
c12.succeed("ping -c 1 c1")
# Confirm containers in vlan 2 can talk to each other.
c2.succeed("ping -c 1 c12")
c12.succeed("ping -c 1 c2")
# Confirm containers in separate vlans cannot talk to each other.
c1.fail("ping -c 1 -W 1 c2")
n1.start()
n2.start()
n1.systemctl("start network-online.target")
n2.systemctl("start network-online.target")
n1.wait_for_unit("network-online.target")
n2.wait_for_unit("network-online.target")
# Confirm containers and nodes in the same vlan can talk to each other.
c1.succeed("ping -c 1 n1")
n1.succeed("ping -c 1 c1")
c2.succeed("ping -c 1 n2")
n2.succeed("ping -c 1 c2")
# Confirm containers and nodes in different vlans cannot talk to each other.
c1.fail("ping -c 1 -W 1 n2")
n1.fail("ping -c 1 -W 1 c2")
c2.fail("ping -c 1 -W 1 n1")
n2.fail("ping -c 1 -W 1 c1")
'';
}

View File

@@ -0,0 +1,215 @@
# This test runs a Bittorrent tracker on one machine, and verifies
# that two client machines can download the torrent using
# `aria2c'. The first client (behind a NAT router) downloads
# from the initial seeder running on the tracker. Then we kill the
# initial seeder. The second client downloads from the first client,
# which only works if the first client successfully uses the UPnP-IGD
# protocol to poke a hole in the NAT.
# We use aria2 as the initial seeder because transmission
# fails in the sandbox because of systemd hardening settings,
# namely MountAPIVFS=yes, so we get the following error:
# $ journalctl --unit transmission.service
# (n-daemon)[417]: transmission.service: Failed to create destination mount point node '/run/transmission/run/host/.os-release-stage/', ignoring: Read-only file system
# (n-daemon)[417]: transmission.service: Failed to mount /run/systemd/propagate/.os-release-stage to /run/transmission/run/host/.os-release-stage/: No such file or directory
# (n-daemon)[417]: transmission.service: Failed to set up mount namespacing: /run/host/.os-release-stage/: No such file or directory
# (n-daemon)[417]: transmission.service: Failed at step NAMESPACE spawning /nix/store/zfksw9bllp95pl45d1nxmpd2lks42bkj-transmission-4.0.6/bin/transmission-daemon: No such file or directory
# systemd[1]: transmission.service: Main process exited, code=exited, status=226/NAMESPACE
{ lib, hostPkgs, ... }:
let
# Some random file to serve.
file = hostPkgs.hello.src;
internalRouterAddress = "192.168.3.1";
internalClient1Address = "192.168.3.2";
# cannot use documentation networks (198.51.100.0/24 or 192.0.2.0/24) here
# because miniupnpd recognizes them as such and refuses to work with them
# https://github.com/miniupnp/miniupnp/blob/2a74cb2f27cacf06d2b50c187e8f90aa1f5c2528/miniupnpd/miniupnpd.c#L998
externalRouterAddress = "80.100.100.1";
externalClient2Address = "80.100.100.2";
externalTrackerAddress = "80.100.100.3";
download-dir = "/tmp/aria2-downloads";
peerConfig =
{ pkgs, ... }:
{
environment.systemPackages = [
pkgs.aria2
pkgs.transmission_4 # only needed for transmission-create
];
};
in
{
name = "bittorrent";
meta = {
maintainers = [
lib.maintainers.kmein
];
};
containers = {
tracker =
{ pkgs, ... }:
{
imports = [ peerConfig ];
virtualisation.vlans = [ 1 ];
networking.firewall.enable = false;
networking.interfaces.eth1.ipv4.addresses = [
{
address = externalTrackerAddress;
prefixLength = 24;
}
];
# We need Apache on the tracker to serve the torrents.
services.httpd = {
enable = true;
virtualHosts = {
"torrentserver.org" = {
adminAddr = "foo@example.org";
documentRoot = "/tmp";
};
};
};
services.opentracker.enable = true;
};
router =
{ pkgs, containers, ... }:
{
virtualisation.vlans = [
1
2
];
networking.nat.enable = true;
networking.nat.internalInterfaces = [ "eth2" ];
networking.nat.externalInterface = "eth1";
networking.firewall.enable = true;
networking.firewall.trustedInterfaces = [ "eth2" ];
networking.interfaces.eth0.ipv4.addresses = [ ];
networking.interfaces.eth1.ipv4.addresses = [
{
address = externalRouterAddress;
prefixLength = 24;
}
];
networking.interfaces.eth2.ipv4.addresses = [
{
address = internalRouterAddress;
prefixLength = 24;
}
];
networking.nftables.enable = true;
services.miniupnpd = {
enable = true;
externalInterface = "eth1";
internalIPs = [ "eth2" ];
appendConfig = ''
ext_ip=${externalRouterAddress}
'';
};
};
client1 =
{ pkgs, containers, ... }:
{
imports = [ peerConfig ];
environment.systemPackages = [ pkgs.miniupnpc ];
virtualisation.vlans = [ 2 ];
networking.interfaces.eth0.ipv4.addresses = [ ];
networking.interfaces.eth1.ipv4.addresses = [
{
address = internalClient1Address;
prefixLength = 24;
}
];
networking.defaultGateway = internalRouterAddress;
networking.firewall.enable = false;
};
client2 =
{ pkgs, ... }:
{
imports = [ peerConfig ];
virtualisation.vlans = [ 1 ];
networking.interfaces.eth0.ipv4.addresses = [ ];
networking.interfaces.eth1.ipv4.addresses = [
{
address = externalClient2Address;
prefixLength = 24;
}
];
networking.firewall.enable = false;
};
};
testScript =
{ containers, ... }:
''
start_all()
# Wait for network and miniupnpd.
router.systemctl("start network-online.target")
router.wait_for_unit("network-online.target")
router.wait_for_unit("miniupnpd")
# Create the torrent.
tracker.succeed("mkdir -p ${download-dir}")
tracker.succeed(
"cp ${file} ${download-dir}/test.tar.bz2"
)
tracker.succeed(
"transmission-create ${download-dir}/test.tar.bz2 --private --tracker http://${externalTrackerAddress}:6969/announce --outfile /tmp/test.torrent"
)
tracker.succeed("chmod 644 /tmp/test.torrent")
# Start the tracker
tracker.systemctl("start network-online.target")
tracker.wait_for_unit("network-online.target")
tracker.wait_for_unit("opentracker.service")
tracker.wait_for_open_port(6969)
# --- Start the initial seeder using aria2 ---
# https://stackoverflow.com/a/44528978
tracker.execute(
"aria2c --enable-dht=false --seed-time=999 --dir=${download-dir} "
"-V --seed-ratio=0.0 "
"/tmp/test.torrent >/dev/null &"
)
# --- Wait until the tracker shows we are seeding ---
tracker.wait_until_succeeds("curl -s http://localhost:6969/stats | grep -q 'serving 1 torrents'")
# Now we should be able to download from the client behind the NAT.
tracker.wait_for_unit("httpd")
def connect_from(machine):
machine.systemctl("start network-online.target")
machine.wait_for_unit("network-online.target")
machine.execute(
"aria2c --enable-dht=false --seed-time=999 --dir=${download-dir} "
"http://${externalTrackerAddress}/test.torrent >/dev/null &"
)
machine.wait_until_succeeds(
"cmp ${download-dir}/test.tar.bz2 ${file}"
) # Wait for download to finish and verify
connect_from(client1)
# --- Bring down the initial seeder ---
tracker.succeed("pkill aria2c")
# Now download from the second client. This can only succeed if
# the first client created a NAT hole in the router.
connect_from(client2)
'';
}