staging-nixos -> staging-next (#501226)

This commit is contained in:
Vladimír Čunát
2026-03-19 06:18:39 +00:00
committed by GitHub
39 changed files with 1960 additions and 1183 deletions
+7
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
@@ -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 = [
@@ -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()))
+119 -30
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
@@ -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()
+5 -4
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
+1
View File
@@ -56,6 +56,7 @@ pkgs.lib.throwIf (args ? specialArgs)
{
machine ? null,
nodes ? { },
containers ? { },
testScript,
enableOCR ? false,
globalTimeout ? (60 * 60),
+35 -13
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}' \
+82 -50
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
];
};
};
}
+3 -2
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)
);
}
)
+156 -39
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
+4
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 = ''
+2 -1
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
+16 -2
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.
'';
}
];
@@ -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
@@ -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 =
@@ -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,
+2 -3
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"
{
@@ -1137,9 +1138,6 @@ in
nixos-rebuild-target-host = runTest {
imports = [ ./nixos-rebuild-target-host.nix ];
};
nixos-rebuild-target-host-interrupted = runTest {
imports = [ ./nixos-rebuild-target-host-interrupted.nix ];
};
nixpkgs = pkgs.callPackage ../modules/misc/nixpkgs/test.nix { inherit evalMinimalConfig; };
nixpkgs-config-allow-unfree =
pkgs.callPackage ../modules/misc/nixpkgs/test-nixpkgs-config-allow-unfree.nix
@@ -1648,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;
@@ -1,232 +0,0 @@
{ hostPkgs, ... }:
# This test recreates a remote deployment scenario where the connection
# between deployer and target is closed during the deployment - in this
# case because the connection goes over a 'reverse ssh' tunnel service
# that has changes that are being deployed.
# This is not seamless (the deployer doesn't get to see the logs after
# the disconnect), but is a lot better than the old behaviour, where
# the switch was aborted and the connection never restored.
{
name = "nixos-rebuild-target-host-interrupted";
# TODO: remove overlay from nixos/modules/profiles/installation-device.nix
# make it a _small package instead, then remove pkgsReadOnly = false;.
node.pkgsReadOnly = false;
nodes = {
deployer =
{
nodes,
lib,
pkgs,
...
}:
let
inherit (import ./ssh-keys.nix pkgs) snakeOilPrivateKey snakeOilPublicKey;
in
{
imports = [
../modules/profiles/installation-device.nix
];
nix.settings = {
substituters = lib.mkForce [ ];
hashed-mirrors = null;
connect-timeout = 1;
};
system.includeBuildDependencies = true;
virtualisation = {
cores = 2;
memorySize = 3072;
};
services.openssh.enable = true;
users.users.root.openssh.authorizedKeys.keys = [ nodes.target.system.build.publicKey ];
system.extraDependencies = [
# so that it doesn't need to be built inside the test
pkgs.nixVersions.latest
];
system.build.privateKey = snakeOilPrivateKey;
system.build.publicKey = snakeOilPublicKey;
system.switch.enable = true;
services.getty.autologinUser = lib.mkForce "root";
};
target =
{
nodes,
lib,
pkgs,
...
}:
let
inherit (import ./ssh-keys.nix pkgs) snakeOilPrivateKey snakeOilPublicKey;
targetConfig = {
documentation.enable = false;
services.openssh.enable = true;
system.build.privateKey = snakeOilPrivateKey;
system.build.publicKey = snakeOilPublicKey;
users.users.root.openssh.authorizedKeys.keys = [ nodes.deployer.system.build.publicKey ];
users.users.alice.openssh.authorizedKeys.keys = [ nodes.deployer.system.build.publicKey ];
users.users.bob.openssh.authorizedKeys.keys = [ nodes.deployer.system.build.publicKey ];
users.users.alice.extraGroups = [ "wheel" ];
users.users.bob.extraGroups = [ "wheel" ];
# Disable sudo for root to ensure sudo isn't called without `--sudo`
security.sudo.extraRules = lib.mkForce [
{
groups = [ "wheel" ];
commands = [ { command = "ALL"; } ];
}
{
users = [ "alice" ];
commands = [
{
command = "ALL";
options = [ "NOPASSWD" ];
}
];
}
];
nix.settings.trusted-users = [ "@wheel" ];
services.autossh-ng.sessions.will-be-interrupted-by-rebuild = {
user = "root";
destination = "deployer";
extraArguments = "-R2222:localhost:22";
hostKeyChecking = false;
};
};
in
{
imports = [ ./common/user-account.nix ];
config = lib.mkMerge [
targetConfig
{
system.build = {
inherit targetConfig;
};
system.switch.enable = true;
networking.hostName = "target";
}
];
};
};
testScript =
{ nodes, ... }:
let
sshConfig = builtins.toFile "ssh.conf" ''
UserKnownHostsFile=/dev/null
StrictHostKeyChecking=no
'';
targetConfigJSON = hostPkgs.writeText "target-configuration.json" (
builtins.toJSON nodes.target.system.build.targetConfig
);
targetNetworkJSON = hostPkgs.writeText "target-network.json" (
builtins.toJSON nodes.target.system.build.networkConfig
);
configFile =
hostname:
hostPkgs.writeText "configuration.nix" # nix
''
{ lib, pkgs, modulesPath, ... }: {
imports = [
(modulesPath + "/virtualisation/qemu-vm.nix")
(modulesPath + "/testing/test-instrumentation.nix")
(modulesPath + "/../tests/common/user-account.nix")
(lib.modules.importJSON ./target-configuration.json)
(lib.modules.importJSON ./target-network.json)
./hardware-configuration.nix
];
boot.loader.grub = {
enable = true;
device = "/dev/vda";
forceInstall = true;
};
# needed to make NIX_SSHOPTS work for nix-copy-closure
# 2.31.3 (current default) break, 2.32.6 and 2.33.3 (current latest) work
# let's use the default here again once the fix has made it there.
nix.package = pkgs.nixVersions.latest;
# We're changing the '-E' parameter to the new hostname here,
# not because we care about the logs, but because we want to
# force the scenario where the connection is broken during the
# deployment (because the autossh-ng service is stopped and
# started):
services.autossh-ng.sessions.will-be-interrupted-by-rebuild.extraArguments = "-R2222:localhost:22 -E ${hostname}";
# this will be asserted to validate the switch happened:
networking.hostName = "${hostname}";
}
'';
in
# python
''
start_all()
target.wait_for_open_port(22)
deployer.wait_until_succeeds("ping -c1 target")
deployer.succeed("install -Dm 600 ${nodes.deployer.system.build.privateKey} ~root/.ssh/id_ecdsa")
deployer.succeed("install ${sshConfig} ~root/.ssh/config")
target.succeed("nixos-generate-config")
target.succeed("install -Dm 600 ${nodes.target.system.build.privateKey} ~root/.ssh/id_ecdsa")
deployer.succeed("scp alice@target:/etc/nixos/hardware-configuration.nix /root/hardware-configuration.nix")
target.wait_for_unit("autossh-ng-will-be-interrupted-by-rebuild.service")
deployer.copy_from_host("${configFile "config-1-deployed"}", "/root/configuration-1.nix")
deployer.copy_from_host("${configFile "config-2-deployed"}", "/root/configuration-2.nix")
deployer.copy_from_host("${targetNetworkJSON}", "/root/target-network.json")
deployer.copy_from_host("${targetConfigJSON}", "/root/target-configuration.json")
with subtest("Deploy to alice@target via reverse ssh"):
deployer.wait_for_unit("multi-user.target")
# Uses TTY/send_chars instead of deployer.succeed to set NIX_SSHOPTS
deployer.send_chars("NIX_SSHOPTS=\"-p 2222\" nixos-rebuild switch -I nixos-config=/root/configuration-1.nix --target-host alice@localhost --sudo\n")
# the connection breaks, but the 'switch' should now continue in the background:
deployer.wait_until_tty_matches("1", "error: while running command with remote sudo")
def deployed(last_try: bool) -> bool:
target_hostname = deployer.succeed("ssh alice@target cat /etc/hostname", timeout=20).rstrip()
if last_try:
print(f"Still seeing hostname {target_hostname}")
return target_hostname == "config-1-deployed"
retry(deployed)
with subtest("Deploy to bob@target via reverse ssh with password-based sudo"):
deployer.wait_for_unit("multi-user.target")
# Uses TTY/send_chars instead of deployer.succeed to set NIX_SSHOPTS and for ask-sudo-password
deployer.send_chars("NIX_SSHOPTS=\"-p 2222\" nixos-rebuild switch -I nixos-config=/root/configuration-2.nix --target-host bob@localhost --ask-sudo-password\n")
deployer.wait_until_tty_matches("1", "password for bob")
deployer.send_chars("${nodes.target.users.users.bob.password}\n")
# the connection breaks, but the 'switch' should now continue in the background:
deployer.wait_until_tty_matches("1", "error: while running command with remote sudo")
def deployed(last_try: bool) -> bool:
target_hostname = deployer.succeed("ssh alice@target cat /etc/hostname", timeout=20).rstrip()
if last_try:
print(f"Still seeing hostname {target_hostname}")
return target_hostname == "config-2-deployed"
retry(deployed)
'';
}
@@ -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")
'';
}
+215
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)
'';
}
@@ -356,10 +356,16 @@ rec {
export ${name}
'') runtimeEnv
)
+ lib.optionalString (runtimeInputs != [ ]) ''
+ ''
export PATH="${lib.makeBinPath runtimeInputs}${lib.optionalString inheritPath ":$PATH"}"
export PATH="${
lib.concatStringsSep ":" (
(lib.optionals (runtimeInputs != [ ]) [ (lib.makeBinPath runtimeInputs) ])
++ (lib.optionals inheritPath [ "$PATH" ])
)
}"
''
+ ''
${text}
@@ -83,6 +83,50 @@ linkFarm "writeShellApplication-tests" {
'';
};
test-no-inherit-path-no-runtimeInputs = checkShellApplication {
name = "test-no-inherit-path-no-runtimeInputs";
inheritPath = false;
runtimeInputs = [ ];
text = ''
if [[ ''${#PATH} -eq 0 ]]; then
echo -n "PATH is empty"
fi
'';
expected = "PATH is empty";
};
test-no-inherit-path-runtimeInputs = checkShellApplication {
name = "test-no-inherit-path-runtimeInputs";
inheritPath = false;
runtimeInputs = [ hello ];
text = ''
extra_colon_pattern='(^:|:$)'
if [[ ''${PATH} =~ $extra_colon_pattern ]]; then
echo "PATH should not start or end with a colon: $PATH"
fi
if [[ ''${#PATH} -gt 0 ]]; then
echo -n "PATH is not empty"
fi
'';
expected = "PATH is not empty";
};
test-inherit-path-no-runtimeInputs = checkShellApplication {
name = "test-inherit-path-no-runtimeInputs";
inheritPath = true;
runtimeInputs = [ ];
text = ''
extra_colon_pattern='(^:|:$)'
if [[ ''${PATH} =~ $extra_colon_pattern ]]; then
echo "PATH should not start or end with a colon: $PATH"
fi
if [[ ''${#PATH} -gt 0 ]]; then
echo -n "PATH is not empty"
fi
'';
expected = "PATH is not empty";
};
test-check-phase = checkShellApplication {
name = "test-check-phase";
text = "";
+4 -4
View File
@@ -5,7 +5,6 @@
zlib,
curl,
expat,
fuse,
fuse3,
openssl,
autoreconfHook,
@@ -30,9 +29,10 @@ stdenv.mkDerivation (finalAttrs: {
expat
openssl
python3
]
++ lib.optionals stdenv.hostPlatform.isLinux [ fuse3 ]
++ lib.optionals stdenv.hostPlatform.isDarwin [ fuse ];
fuse3
];
env.CFLAGS = lib.optionalString stdenv.hostPlatform.isDarwin "-DFUSE_DARWIN_ENABLE_EXTENSIONS=0";
meta = {
homepage = "http://afflib.sourceforge.net/";
@@ -0,0 +1,31 @@
diff --git a/src/btfs.cc b/src/btfs.cc
index eaac245..6bae7c0 100644
--- a/src/btfs.cc
+++ b/src/btfs.cc
@@ -19,6 +19,10 @@ along with BTFS. If not, see <http://www.gnu.org/licenses/>.
#define FUSE_USE_VERSION 31
+#ifdef __APPLE__
+#define FUSE_DARWIN_ENABLE_EXTENSIONS 0
+#endif
+
#include <cstdlib>
#include <iostream>
#include <fstream>
@@ -733,15 +737,9 @@ btfs_listxattr(const char *path, char *data, size_t len) {
return xattrslen;
}
-#ifdef __APPLE__
-static int
-btfs_getxattr(const char *path, const char *key, char *value, size_t len,
- uint32_t position) {
-#else
static int
btfs_getxattr(const char *path, const char *key, char *value, size_t len) {
uint32_t position = 0;
-#endif
char xattr[16];
int xattrlen = 0;
+5
View File
@@ -22,6 +22,11 @@ stdenv.mkDerivation (finalAttrs: {
sha256 = "sha256-JuofC4TpbZ56qiUrHeoK607YHVbwqwLGMIdUpsTm9Ic=";
};
patches = [
# https://github.com/johang/btfs/pull/103
./disable-macfuse-extensions.patch
];
nativeBuildInputs = [
autoreconfHook
pkg-config
+2
View File
@@ -29,6 +29,8 @@ stdenv.mkDerivation (finalAttrs: {
mbedtls
];
env.CFLAGS = lib.optionalString stdenv.hostPlatform.isDarwin "-DFUSE_DARWIN_ENABLE_EXTENSIONS=0";
meta = {
description = "Read BitLocker encrypted partitions in Linux";
homepage = "https://github.com/Aorimn/dislocker";
@@ -25,14 +25,12 @@ from .models import (
Profile,
Remote,
)
from .process import PRESERVE_ENV, SSH_DEFAULT_OPTS, run_wrapper, run_wrapper_bg
from .process import Args as ProcessArgs
from .process import PRESERVE_ENV, SSH_DEFAULT_OPTS, run_wrapper
from .utils import Args, dict_to_flags
FLAKE_FLAGS: Final = ["--extra-experimental-features", "nix-command flakes"]
FLAKE_REPL_TEMPLATE: Final = "repl.nix.template"
SYSTEMD_RUN_UNIT_PREFIX: Final = "nixos-rebuild-switch-to-configuration"
SYSTEMD_RUN_CMD_PREFIX: Final = [
SWITCH_TO_CONFIGURATION_CMD_PREFIX: Final = [
"systemd-run",
"-E",
# Will be set to new value early in switch-to-configuration script,
@@ -43,10 +41,11 @@ SYSTEMD_RUN_CMD_PREFIX: Final = [
"-E",
"NIXOS_NO_CHECK",
"--collect",
"--wait",
"--no-ask-password",
"--pipe",
"--quiet",
"--service-type=exec",
"--unit=nixos-rebuild-switch-to-configuration",
]
logger: Final = logging.getLogger(__name__)
@@ -207,17 +206,20 @@ def copy_closure(
append_local_env=env,
)
def nix_copy(to_host: Remote, from_host: Remote) -> None:
def nix_copy(to_host: Remote | None, from_host: Remote | None) -> None:
host_flags = []
if from_host is not None:
host_flags += ["--from", f"{from_host.store_type}://{from_host.host}"]
if to_host is not None:
host_flags += ["--to", f"{to_host.store_type}://{to_host.host}"]
run_wrapper(
[
"nix",
*FLAKE_FLAGS,
"copy",
*dict_to_flags(copy_flags),
"--from",
f"ssh://{from_host.host}",
"--to",
f"ssh://{to_host.host}",
*host_flags,
closure,
],
append_local_env=env,
@@ -226,9 +228,12 @@ def copy_closure(
match (to_host, from_host):
case (x, y) if x == y:
return
case (Remote(_) as host, None) | (None, Remote(_) as host):
# nix-copy-closure doesn't support store types other than "ssh".
case (Remote(_) as host, None) | (None, Remote(_) as host) if (
host.store_type == "ssh"
):
nix_copy_closure(host, to=bool(to_host))
case (Remote(_), Remote(_)):
case (Remote(_), _) | (_, Remote(_)):
nix_copy(to_host, from_host)
@@ -437,7 +442,10 @@ def get_generations(profile: Profile) -> list[Generation]:
)
return sorted(
[parse_path(p, profile) for p in profile.path.parent.glob("system-*-link")],
[
parse_path(p, profile)
for p in profile.path.parent.glob(f"{profile.name}-*-link")
],
key=lambda d: d.id,
)
@@ -662,80 +670,6 @@ def set_profile(
remote=target_host,
sudo=sudo,
)
def _has_systemd(target_host: Remote | None) -> bool:
r = run_wrapper(
["test", "-d", "/run/systemd/system"],
remote=target_host,
check=False,
)
return r.returncode == 0
def _run_action(
action: Literal[Action.SWITCH, Action.BOOT, Action.TEST, Action.DRY_ACTIVATE],
path_to_config: Path,
install_bootloader: bool,
target_host: Remote | None,
sudo: bool,
prefix: ProcessArgs | None = None,
) -> None:
cmd: ProcessArgs = [path_to_config / "bin/switch-to-configuration", str(action)]
if prefix:
cmd = [*prefix, *cmd]
run_wrapper(
cmd,
env={
"LOCALE_ARCHIVE": PRESERVE_ENV,
"NIXOS_NO_CHECK": PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1" if install_bootloader else "0",
},
remote=target_host,
sudo=sudo,
)
def _run_action_with_systemd(
action: Literal[Action.SWITCH, Action.BOOT, Action.TEST, Action.DRY_ACTIVATE],
path_to_config: Path,
install_bootloader: bool,
target_host: Remote | None,
sudo: bool,
) -> None:
unique_unit_name = SYSTEMD_RUN_UNIT_PREFIX + "-" + uuid.uuid4().hex[:8]
journalctl = run_wrapper_bg(
[
"journalctl",
"-f",
f"--unit={unique_unit_name}",
"--output=cat",
],
remote=target_host,
sudo=sudo,
)
try:
_run_action(
action=action,
path_to_config=path_to_config,
install_bootloader=install_bootloader,
target_host=target_host,
sudo=sudo,
prefix=[*SYSTEMD_RUN_CMD_PREFIX, f"--unit={unique_unit_name}"],
)
except KeyboardInterrupt:
run_wrapper(
["systemctl", "stop", unique_unit_name],
remote=target_host,
sudo=sudo,
)
raise
finally:
journalctl.terminate()
def switch_to_configuration(
path_to_config: Path,
action: Literal[Action.SWITCH, Action.BOOT, Action.TEST, Action.DRY_ACTIVATE],
@@ -759,26 +693,29 @@ def switch_to_configuration(
if not path_to_config.exists():
raise NixOSRebuildError(f"specialisation not found: {specialisation}")
if _has_systemd(target_host):
_run_action_with_systemd(
action=action,
path_to_config=path_to_config,
install_bootloader=install_bootloader,
target_host=target_host,
sudo=sudo,
)
else:
r = run_wrapper(
["test", "-d", "/run/systemd/system"],
remote=target_host,
check=False,
)
cmd = SWITCH_TO_CONFIGURATION_CMD_PREFIX
if r.returncode:
logger.debug(
"skipping systemd-run to switch configuration since systemd is "
"not working in target host"
)
_run_action(
action=action,
path_to_config=path_to_config,
install_bootloader=install_bootloader,
target_host=target_host,
sudo=sudo,
)
cmd = []
run_wrapper(
[*cmd, path_to_config / "bin/switch-to-configuration", str(action)],
env={
"LOCALE_ARCHIVE": PRESERVE_ENV,
"NIXOS_NO_CHECK": PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1" if install_bootloader else "0",
},
remote=target_host,
sudo=sudo,
)
def upgrade_channels(all_channels: bool = False, sudo: bool = False) -> None:
@@ -9,7 +9,7 @@ from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from enum import Enum
from ipaddress import AddressValueError, IPv6Address
from typing import Final, Literal, NamedTuple, Self, TextIO, TypedDict, Unpack, override
from typing import Final, Literal, Self, TextIO, TypedDict, Unpack, override
from . import tmpdir
@@ -46,6 +46,7 @@ class Remote:
host: str
opts: list[str]
sudo_password: str | None
store_type: str
@classmethod
def from_arg(
@@ -57,13 +58,18 @@ class Remote:
if not host:
return None
try:
store_type, host = host.split("://", 1)
except ValueError:
store_type = "ssh"
opts = shlex.split(os.getenv("NIX_SSHOPTS", ""))
if validate_opts:
cls._validate_opts(opts, ask_sudo_password)
sudo_password = None
if ask_sudo_password:
sudo_password = getpass.getpass(f"[sudo] password for {host}: ")
return cls(host, opts, sudo_password)
return cls(host, opts, sudo_password, store_type)
@staticmethod
def _validate_opts(opts: list[str], ask_sudo_password: bool | None) -> None:
@@ -117,18 +123,17 @@ def cleanup_ssh() -> None:
atexit.register(cleanup_ssh)
class _RunParams(NamedTuple):
args: Args
popen_env: dict[str, str] | None
process_input: str | None
def _build_run_params(
def run_wrapper(
args: Args,
env: Mapping[str, EnvValue] | None,
remote: Remote | None,
sudo: bool,
) -> _RunParams:
*,
check: bool = True,
env: Mapping[str, EnvValue] | None = None,
append_local_env: Mapping[str, str] | None = None,
remote: Remote | None = None,
sudo: bool = False,
**kwargs: Unpack[RunKwargs],
) -> subprocess.CompletedProcess[str]:
"Wrapper around `subprocess.run` that supports extra functionality."
process_input = None
run_args: list[Arg] = list(args)
final_args: list[Arg]
@@ -190,64 +195,9 @@ def _build_run_params(
final_args = run_args
popen_env = None if env is None else resolved_env
return _RunParams(final_args, popen_env, process_input)
def run_wrapper_bg(
args: Args,
env: Mapping[str, EnvValue] | None = None,
remote: Remote | None = None,
sudo: bool = False,
) -> subprocess.Popen[str]:
"Wrapper around `subprocess.Popen` that supports extra functionality."
(final_args, popen_env, process_input) = _build_run_params(args, env, remote, sudo)
logger.debug(
"calling Popen with args=%r",
_sanitize_env_run_args(list(final_args)),
)
if process_input:
stdin = subprocess.PIPE
else:
stdin = None
r = subprocess.Popen(
final_args,
env=popen_env,
stdin=stdin,
# Hope nobody is using NixOS with non-UTF8 encodings, but
# "surrogateescape" should still work in those systems.
text=True,
errors="surrogateescape",
)
if r.stdin and process_input:
r.stdin.write(process_input)
r.stdin.write("\n")
r.stdin.close()
return r
def run_wrapper(
args: Args,
*,
check: bool = True,
env: Mapping[str, EnvValue] | None = None,
append_local_env: Mapping[str, str] | None = None,
remote: Remote | None = None,
sudo: bool = False,
**kwargs: Unpack[RunKwargs],
) -> subprocess.CompletedProcess[str]:
"Wrapper around `subprocess.run` that supports extra functionality."
(final_args, popen_env, process_input) = _build_run_params(
list(args), env, remote, sudo
)
logger.debug(
"calling run with args=%r, kwargs=%r, env=%r, append_local_env=%r",
_sanitize_env_run_args(list(final_args)),
_sanitize_env_run_args(remote_run_args if remote else run_args),
kwargs,
env,
append_local_env,
@@ -166,15 +166,8 @@ def test_parse_args() -> None:
{"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"},
clear=True,
)
@patch("uuid.uuid4")
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
def test_execute_nix_boot(
mock_popen: Mock, mock_run: Mock, mock_uuid4: Mock, tmp_path: Path
) -> None:
test_uuid = uuid.UUID("58fe9784-f60a-42bc-aa94-eb8f1a7e5c17")
mock_uuid4.return_value = test_uuid
def test_execute_nix_boot(mock_run: Mock, tmp_path: Path) -> None:
nixpkgs_path = tmp_path / "nixpkgs"
(nixpkgs_path / ".git").mkdir(parents=True)
config_path = tmp_path / "test"
@@ -245,8 +238,7 @@ def test_execute_nix_boot(
),
call(
[
*nr.nix.SYSTEMD_RUN_CMD_PREFIX,
f"--unit={nr.nix.SYSTEMD_RUN_UNIT_PREFIX}-{test_uuid.hex[:8]}",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
config_path / "bin/switch-to-configuration",
"boot",
],
@@ -267,8 +259,7 @@ def test_execute_nix_boot(
# https://github.com/NixOS/nixpkgs/issues/437872
@patch.dict(os.environ, {}, clear=True)
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
def test_execute_nix_build(mock_popen: Mock, mock_run: Mock, tmp_path: Path) -> None:
def test_execute_nix_build(mock_run: Mock, tmp_path: Path) -> None:
config_path = tmp_path / "test"
config_path.touch()
@@ -310,8 +301,7 @@ def test_execute_nix_build(mock_popen: Mock, mock_run: Mock, tmp_path: Path) ->
@patch.dict(os.environ, {}, clear=True)
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
def test_execute_nix_build_vm(mock_popen: Mock, mock_run: Mock, tmp_path: Path) -> None:
def test_execute_nix_build_vm(mock_run: Mock, tmp_path: Path) -> None:
config_path = tmp_path / "test"
config_path.touch()
@@ -360,10 +350,7 @@ def test_execute_nix_build_vm(mock_popen: Mock, mock_run: Mock, tmp_path: Path)
@patch.dict(os.environ, {}, clear=True)
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
def test_execute_nix_build_image_flake(
mock_popen: Mock, mock_run: Mock, tmp_path: Path
) -> None:
def test_execute_nix_build_image_flake(mock_run: Mock, tmp_path: Path) -> None:
config_path = tmp_path / "test"
config_path.touch()
@@ -445,18 +432,11 @@ def test_execute_nix_build_image_flake(
{"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"},
clear=True,
)
@patch("uuid.uuid4")
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
def test_execute_nix_switch_flake(
mock_popen: Mock, mock_run: Mock, mock_uuid4: Mock, tmp_path: Path
) -> None:
def test_execute_nix_switch_flake(mock_run: Mock, tmp_path: Path) -> None:
config_path = tmp_path / "test"
config_path.touch()
test_uuid = uuid.UUID("58fe9784-f60a-42bc-aa94-eb8f1a7e5c17")
mock_uuid4.return_value = test_uuid
def run_side_effect(args: list[str], **kwargs: Any) -> CompletedProcess[str]:
if args[0] == "nix":
return CompletedProcess([], 0, str(config_path))
@@ -526,8 +506,7 @@ def test_execute_nix_switch_flake(
"env",
"-i",
"NIXOS_INSTALL_BOOTLOADER=1",
*nr.nix.SYSTEMD_RUN_CMD_PREFIX,
f"--unit={nr.nix.SYSTEMD_RUN_UNIT_PREFIX}-{test_uuid.hex[:8]}",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
config_path / "bin/switch-to-configuration",
"switch",
],
@@ -544,13 +523,11 @@ def test_execute_nix_switch_flake(
clear=True,
)
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
@patch("uuid.uuid4", autospec=True)
@patch(get_qualified_name(nr.services.cleanup_ssh), autospec=True)
def test_execute_nix_switch_build_target_host(
mock_cleanup_ssh: Mock,
mock_uuid4: Mock,
mock_popen: Mock,
mock_run: Mock,
tmp_path: Path,
) -> None:
@@ -574,8 +551,7 @@ def test_execute_nix_switch_build_target_host(
return CompletedProcess([], 0)
mock_run.side_effect = run_side_effect
test_uuid = uuid.UUID("58fe9784-f60a-42bc-aa94-eb8f1a7e5c17")
mock_uuid4.side_effect = [uuid.UUID(int=0), uuid.UUID(int=1), test_uuid]
mock_uuid4.return_value = uuid.UUID(int=0)
nr.execute(
[
@@ -668,7 +644,7 @@ def test_execute_nix_switch_build_target_host(
"--realise",
str(config_path),
"--add-root",
"/tmp/tmpdir/00000000000000000000000000000001",
"/tmp/tmpdir/00000000000000000000000000000000",
],
check=True,
stdout=PIPE,
@@ -772,8 +748,7 @@ def test_execute_nix_switch_build_target_host(
"-c",
"""'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"sh",
*nr.nix.SYSTEMD_RUN_CMD_PREFIX,
f"--unit={nr.nix.SYSTEMD_RUN_UNIT_PREFIX}-{test_uuid.hex[:8]}",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),
"switch",
],
@@ -789,20 +764,13 @@ def test_execute_nix_switch_build_target_host(
{"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"},
clear=True,
)
@patch("uuid.uuid4")
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
@patch(get_qualified_name(nr.services.cleanup_ssh), autospec=True)
def test_execute_nix_switch_flake_target_host(
mock_cleanup_ssh: Mock,
mock_popen: Mock,
mock_run: Mock,
mock_uuid4: Mock,
tmp_path: Path,
) -> None:
test_uuid = uuid.UUID("58fe9784-f60a-42bc-aa94-eb8f1a7e5c17")
mock_uuid4.return_value = test_uuid
config_path = tmp_path / "test"
config_path.touch()
@@ -897,8 +865,7 @@ def test_execute_nix_switch_flake_target_host(
"-c",
"""'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"sh",
*nr.nix.SYSTEMD_RUN_CMD_PREFIX,
f"--unit={nr.nix.SYSTEMD_RUN_UNIT_PREFIX}-{test_uuid.hex[:8]}",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),
"switch",
],
@@ -914,20 +881,13 @@ def test_execute_nix_switch_flake_target_host(
{"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"},
clear=True,
)
@patch("uuid.uuid4")
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
@patch(get_qualified_name(nr.services.cleanup_ssh), autospec=True)
def test_execute_nix_switch_flake_build_host(
mock_cleanup_ssh: Mock,
mock_popen: Mock,
mock_run: Mock,
mock_uuid4: Mock,
tmp_path: Path,
) -> None:
test_uuid = uuid.UUID("58fe9784-f60a-42bc-aa94-eb8f1a7e5c17")
mock_uuid4.return_value = test_uuid
config_path = tmp_path / "test"
config_path.touch()
@@ -1024,8 +984,7 @@ def test_execute_nix_switch_flake_build_host(
),
call(
[
*nr.nix.SYSTEMD_RUN_CMD_PREFIX,
f"--unit={nr.nix.SYSTEMD_RUN_UNIT_PREFIX}-{test_uuid.hex[:8]}",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
config_path / "bin/switch-to-configuration",
"switch",
],
@@ -1037,10 +996,7 @@ def test_execute_nix_switch_flake_build_host(
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
def test_execute_switch_rollback(
mock_popen: Mock, mock_run: Mock, tmp_path: Path
) -> None:
def test_execute_switch_rollback(mock_run: Mock, tmp_path: Path) -> None:
nixpkgs_path = tmp_path / "nixpkgs"
(nixpkgs_path / ".git").mkdir(parents=True)
@@ -1117,8 +1073,7 @@ def test_execute_switch_rollback(
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
def test_execute_build(mock_popen: Mock, mock_run: Mock, tmp_path: Path) -> None:
def test_execute_build(mock_run: Mock, tmp_path: Path) -> None:
config_path = tmp_path / "test"
config_path.touch()
mock_run.side_effect = [
@@ -1147,9 +1102,8 @@ def test_execute_build(mock_popen: Mock, mock_run: Mock, tmp_path: Path) -> None
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
def test_execute_build_dry_run_build_and_target_remote(
mock_popen: Mock, mock_run: Mock, tmp_path: Path
mock_run: Mock, tmp_path: Path
) -> None:
config_path = tmp_path / "test"
config_path.touch()
@@ -1220,8 +1174,7 @@ def test_execute_build_dry_run_build_and_target_remote(
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
def test_execute_test_flake(mock_popen: Mock, mock_run: Mock, tmp_path: Path) -> None:
def test_execute_test_flake(mock_run: Mock, tmp_path: Path) -> None:
config_path = tmp_path / "test"
config_path.touch()
@@ -1271,13 +1224,11 @@ def test_execute_test_flake(mock_popen: Mock, mock_run: Mock, tmp_path: Path) ->
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
@patch("pathlib.Path.exists", autospec=True, return_value=True)
@patch("pathlib.Path.mkdir", autospec=True)
def test_execute_test_rollback(
mock_path_mkdir: Mock,
mock_path_exists: Mock,
mock_popen: Mock,
mock_run: Mock,
) -> None:
def run_side_effect(args: list[str], **kwargs: Any) -> CompletedProcess[str]:
@@ -1340,15 +1291,8 @@ def test_execute_test_rollback(
{"NIXOS_REBUILD_I_UNDERSTAND_THE_CONSEQUENCES_PLEASE_BREAK_MY_SYSTEM": "1"},
clear=True,
)
@patch("uuid.uuid4", autospec=True)
@patch("subprocess.run", autospec=True)
@patch("subprocess.Popen")
def test_execute_switch_store_path(
mock_popen: Mock, mock_run: Mock, mock_uuid4: Mock, tmp_path: Path
) -> None:
test_uuid = uuid.UUID("58fe9784-f60a-42bc-aa94-eb8f1a7e5c17")
mock_uuid4.return_value = test_uuid
def test_execute_switch_store_path(mock_run: Mock, tmp_path: Path) -> None:
config_path = tmp_path / "test-system"
config_path.mkdir()
@@ -1386,8 +1330,7 @@ def test_execute_switch_store_path(
),
call(
[
*nr.nix.SYSTEMD_RUN_CMD_PREFIX,
f"--unit={nr.nix.SYSTEMD_RUN_UNIT_PREFIX}-{test_uuid.hex[:8]}",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
config_path / "bin/switch-to-configuration",
"switch",
],
@@ -1406,20 +1349,13 @@ def test_execute_switch_store_path(
@patch.dict(os.environ, {}, clear=True)
@patch("uuid.uuid4")
@patch("subprocess.run", autospec=True)
@patch(get_qualified_name(nr.services.cleanup_ssh), autospec=True)
@patch("subprocess.Popen")
def test_execute_switch_store_path_target_host(
mock_popen: Mock,
mock_cleanup_ssh: Mock,
mock_run: Mock,
mock_uuid4: Mock,
tmp_path: Path,
) -> None:
test_uuid = uuid.UUID("58fe9784-f60a-42bc-aa94-eb8f1a7e5c17")
mock_uuid4.return_value = test_uuid
config_path = tmp_path / "test-system"
config_path.mkdir()
@@ -1512,8 +1448,7 @@ def test_execute_switch_store_path_target_host(
"-c",
"""'exec env -i PATH="${PATH-}" LOCALE_ARCHIVE="${LOCALE_ARCHIVE-}" NIXOS_NO_CHECK="${NIXOS_NO_CHECK-}" NIXOS_INSTALL_BOOTLOADER=0 "$@"'""",
"sh",
*nr.nix.SYSTEMD_RUN_CMD_PREFIX,
f"--unit={nr.nix.SYSTEMD_RUN_UNIT_PREFIX}-{test_uuid.hex[:8]}",
*nr.nix.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
str(config_path / "bin/switch-to-configuration"),
"switch",
],
@@ -43,7 +43,7 @@ def test_flake_parse(mock_node: Mock, tmpdir: Path, monkeypatch: MonkeyPatch) ->
autospec=True,
return_value=subprocess.CompletedProcess([], 0, stdout="remote\n"),
):
target_host = m.Remote("target@remote", [], None)
target_host = m.Remote("target@remote", [], None, "ssh")
assert m.Flake.parse("/path/to/flake", target_host) == m.Flake(
"/path/to/flake", 'nixosConfigurations."remote"'
)
@@ -162,9 +162,9 @@ def test_flake_from_arg(
return_value=subprocess.CompletedProcess([], 0, "remote-hostname\n"),
),
):
assert m.Flake.from_arg("/path/to", m.Remote("user@host", [], None)) == m.Flake(
"/path/to", 'nixosConfigurations."remote-hostname"'
)
assert m.Flake.from_arg(
"/path/to", m.Remote("user@host", [], None, "ssh")
) == m.Flake("/path/to", 'nixosConfigurations."remote-hostname"')
@patch("pathlib.Path.mkdir", autospec=True)
@@ -2,7 +2,7 @@ import sys
import textwrap
import uuid
from pathlib import Path
from subprocess import PIPE, CompletedProcess, Popen
from subprocess import PIPE, CompletedProcess
from typing import Any
from unittest.mock import ANY, Mock, call, patch
@@ -83,7 +83,7 @@ def test_build_flake(mock_run: Mock, monkeypatch: MonkeyPatch, tmpdir: Path) ->
def test_build_remote(
mock_uuid4: Mock, mock_run: Mock, monkeypatch: MonkeyPatch
) -> None:
build_host = m.Remote("user@host", [], None)
build_host = m.Remote("user@host", [], None, "ssh")
monkeypatch.setenv("NIX_SSHOPTS", "--ssh opts")
def run_wrapper_side_effect(
@@ -177,7 +177,7 @@ def test_build_remote_flake(
) -> None:
monkeypatch.chdir(tmpdir)
flake = m.Flake.parse("/flake.nix#hostname")
build_host = m.Remote("user@host", [], None)
build_host = m.Remote("user@host", [], None, "ssh")
monkeypatch.setenv("NIX_SSHOPTS", "--ssh opts")
assert n.build_remote_flake(
@@ -237,8 +237,9 @@ def test_copy_closure(monkeypatch: MonkeyPatch) -> None:
n.copy_closure(closure, None)
mock_run.assert_not_called()
target_host = m.Remote("user@target.host", [], None)
build_host = m.Remote("user@build.host", [], None)
target_host = m.Remote("user@target.host", [], None, "ssh")
build_host = m.Remote("user@build.host", [], None, "ssh")
target_host_ng = m.Remote("user@target.host", [], None, "ssh-ng")
with patch(get_qualified_name(n.run_wrapper, n), autospec=True) as mock_run:
n.copy_closure(closure, target_host)
mock_run.assert_called_with(
@@ -246,6 +247,21 @@ def test_copy_closure(monkeypatch: MonkeyPatch) -> None:
append_local_env={"NIX_SSHOPTS": " ".join(p.SSH_DEFAULT_OPTS)},
)
with patch(get_qualified_name(n.run_wrapper, n), autospec=True) as mock_run:
n.copy_closure(closure, target_host_ng)
mock_run.assert_called_with(
[
"nix",
"--extra-experimental-features",
"nix-command flakes",
"copy",
"--to",
"ssh-ng://user@target.host",
closure,
],
append_local_env={"NIX_SSHOPTS": " ".join(p.SSH_DEFAULT_OPTS)},
)
monkeypatch.setenv("NIX_SSHOPTS", "--ssh build-opt")
with patch(get_qualified_name(n.run_wrapper, n), autospec=True) as mock_run:
n.copy_closure(closure, None, build_host, {"copy_flag": True})
@@ -458,6 +474,10 @@ def test_get_generations(tmp_path: Path) -> None:
(tmp_path / "system-3-link").symlink_to(nixos_path)
(tmp_path / "system-2-link").symlink_to(nixos_path)
# An alternate profile; this shouldn't appear.
(tmp_path / "custom").symlink_to(tmp_path / "custom-1-link")
(tmp_path / "custom-1-link").symlink_to(nixos_path)
assert n.get_generations(m.Profile("system", tmp_path / "system")) == [
m.Generation(id=1, current=False, timestamp=ANY),
m.Generation(id=2, current=True, timestamp=ANY),
@@ -465,6 +485,27 @@ def test_get_generations(tmp_path: Path) -> None:
]
def test_get_generations_with_profile(tmp_path: Path) -> None:
nixos_path = tmp_path / "nixos-system"
nixos_path.mkdir()
(tmp_path / "custom").symlink_to(tmp_path / "custom-2-link")
# In the "wrong" order on purpose to make sure we are sorting the results
(tmp_path / "custom-1-link").symlink_to(nixos_path)
(tmp_path / "custom-3-link").symlink_to(nixos_path)
(tmp_path / "custom-2-link").symlink_to(nixos_path)
# An alternate profile; none of these should appear.
(tmp_path / "system").symlink_to(tmp_path / "system-1-link")
(tmp_path / "system-1-link").symlink_to(nixos_path)
assert n.get_generations(m.Profile("custom", tmp_path / "custom")) == [
m.Generation(id=1, current=False, timestamp=ANY),
m.Generation(id=2, current=True, timestamp=ANY),
m.Generation(id=3, current=False, timestamp=ANY),
]
def test_get_generations_from_nix_env(tmp_path: Path) -> None:
path = tmp_path / "test"
path.touch()
@@ -493,7 +534,7 @@ def test_get_generations_from_nix_env(tmp_path: Path) -> None:
sudo=False,
)
remote = m.Remote("user@host", [], "password")
remote = m.Remote("user@host", [], "password", "ssh")
with patch(
get_qualified_name(n.run_wrapper, n), autospec=True, return_value=return_value
) as mock_run:
@@ -555,7 +596,6 @@ def test_list_generations(mock_get_generations: Mock, tmp_path: Path) -> None:
@patch(get_qualified_name(n.run_wrapper, n), autospec=True)
def test_diff_closures(mock_run: Mock) -> None:
n.diff_closures(
Path("/run/current-system"), Path("/nix/var/nix/profiles/system"), None
)
@@ -607,7 +647,7 @@ def test_rollback(mock_run: Mock, tmp_path: Path) -> None:
sudo=False,
)
target_host = m.Remote("user@localhost", [], None)
target_host = m.Remote("user@localhost", [], None, "ssh")
assert n.rollback(profile, target_host, True) == profile.path
mock_run.assert_called_with(
["nix-env", "--rollback", "-p", path],
@@ -647,7 +687,7 @@ def test_rollback_temporary_profile(tmp_path: Path) -> None:
sudo=False,
)
target_host = m.Remote("user@localhost", [], None)
target_host = m.Remote("user@localhost", [], None, "ssh")
assert (
n.rollback_temporary_profile(m.Profile("foo", path), target_host, True)
== path.parent / "foo-2083-link"
@@ -703,159 +743,139 @@ def test_set_profile(mock_run: Mock) -> None:
@patch(get_qualified_name(n.run_wrapper, n), autospec=True)
@patch(get_qualified_name(n.run_wrapper_bg, n), autospec=True)
def test_switch_to_configuration_without_systemd_run(
mock_run_bg: Mock, mock_run: Mock, monkeypatch: MonkeyPatch
mock_run: Any, monkeypatch: MonkeyPatch
) -> None:
profile_path = Path("/path/to/profile")
config_path = Path("/path/to/config")
mock_run.return_value = CompletedProcess([], 1)
proc = Popen(["echo"])
try:
mock_run_bg.return_value = p
mock_run.return_value = CompletedProcess([], 1)
with monkeypatch.context() as mp:
mp.setenv("LOCALE_ARCHIVE", "")
with monkeypatch.context() as mp:
mp.setenv("LOCALE_ARCHIVE", "")
n.switch_to_configuration(
profile_path,
m.Action.SWITCH,
sudo=False,
target_host=None,
specialisation=None,
install_bootloader=False,
)
mock_run.assert_called_with(
[profile_path / "bin/switch-to-configuration", "switch"],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "0",
},
n.switch_to_configuration(
profile_path,
m.Action.SWITCH,
sudo=False,
remote=None,
target_host=None,
specialisation=None,
install_bootloader=False,
)
mock_run.assert_called_with(
[profile_path / "bin/switch-to-configuration", "switch"],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "0",
},
sudo=False,
remote=None,
)
with pytest.raises(m.NixOSRebuildError) as e:
n.switch_to_configuration(
config_path,
m.Action.BOOT,
sudo=False,
target_host=None,
specialisation="special",
)
assert (
str(e.value)
== "error: '--specialisation' can only be used with 'switch' and 'test'"
with pytest.raises(m.NixOSRebuildError) as e:
n.switch_to_configuration(
config_path,
m.Action.BOOT,
sudo=False,
target_host=None,
specialisation="special",
)
assert (
str(e.value)
== "error: '--specialisation' can only be used with 'switch' and 'test'"
)
target_host = m.Remote("user@localhost", [], None)
with monkeypatch.context() as mp:
mp.setenv("LOCALE_ARCHIVE", "/path/to/locale")
mp.setenv("PATH", "/path/to/bin")
mp.setattr(Path, Path.exists.__name__, lambda self: True)
target_host = m.Remote("user@localhost", [], None, "ssh")
with monkeypatch.context() as mp:
mp.setenv("LOCALE_ARCHIVE", "/path/to/locale")
mp.setenv("PATH", "/path/to/bin")
mp.setattr(Path, Path.exists.__name__, lambda self: True)
n.switch_to_configuration(
Path("/path/to/config"),
m.Action.TEST,
sudo=True,
target_host=target_host,
install_bootloader=True,
specialisation="special",
)
mock_run.assert_called_with(
[
config_path / "specialisation/special/bin/switch-to-configuration",
"test",
],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1",
},
n.switch_to_configuration(
Path("/path/to/config"),
m.Action.TEST,
sudo=True,
remote=target_host,
target_host=target_host,
install_bootloader=True,
specialisation="special",
)
finally:
proc.communicate(timeout=1)
mock_run.assert_called_with(
[
config_path / "specialisation/special/bin/switch-to-configuration",
"test",
],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1",
},
sudo=True,
remote=target_host,
)
@patch("uuid.uuid4")
@patch(get_qualified_name(n.run_wrapper, n), autospec=True)
@patch(get_qualified_name(n.run_wrapper_bg, n), autospec=True)
def test_switch_to_configuration_with_systemd_run(
mock_run_bg: Mock, mock_run: Mock, mock_uuid4: Mock, monkeypatch: MonkeyPatch
mock_run: Mock, monkeypatch: MonkeyPatch
) -> None:
test_uuid = uuid.UUID("58fe9784-f60a-42bc-aa94-eb8f1a7e5c17")
mock_uuid4.return_value = test_uuid
profile_path = Path("/path/to/profile")
config_path = Path("/path/to/config")
mock_run.return_value = CompletedProcess([], 0)
proc = Popen(["echo"])
try:
mock_run_bg.return_value = proc
mock_run.return_value = CompletedProcess([], 0)
with monkeypatch.context() as mp:
mp.setenv("LOCALE_ARCHIVE", "")
with monkeypatch.context() as mp:
mp.setenv("LOCALE_ARCHIVE", "")
n.switch_to_configuration(
profile_path,
m.Action.SWITCH,
sudo=False,
target_host=None,
specialisation=None,
install_bootloader=False,
)
mock_run.assert_called_with(
[
*n.SYSTEMD_RUN_CMD_PREFIX,
f"--unit={n.SYSTEMD_RUN_UNIT_PREFIX}-{test_uuid.hex[:8]}",
profile_path / "bin/switch-to-configuration",
"switch",
],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "0",
},
n.switch_to_configuration(
profile_path,
m.Action.SWITCH,
sudo=False,
remote=None,
target_host=None,
specialisation=None,
install_bootloader=False,
)
mock_run.assert_called_with(
[
*n.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
profile_path / "bin/switch-to-configuration",
"switch",
],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "0",
},
sudo=False,
remote=None,
)
target_host = m.Remote("user@localhost", [], None)
with monkeypatch.context() as mp:
mp.setenv("LOCALE_ARCHIVE", "/path/to/locale")
mp.setenv("PATH", "/path/to/bin")
mp.setattr(Path, Path.exists.__name__, lambda self: True)
target_host = m.Remote("user@localhost", [], None, "ssh")
with monkeypatch.context() as mp:
mp.setenv("LOCALE_ARCHIVE", "/path/to/locale")
mp.setenv("PATH", "/path/to/bin")
mp.setattr(Path, Path.exists.__name__, lambda self: True)
n.switch_to_configuration(
Path("/path/to/config"),
m.Action.TEST,
sudo=True,
target_host=target_host,
install_bootloader=True,
specialisation="special",
)
mock_run.assert_called_with(
[
*n.SYSTEMD_RUN_CMD_PREFIX,
f"--unit={n.SYSTEMD_RUN_UNIT_PREFIX}-{test_uuid.hex[:8]}",
config_path / "specialisation/special/bin/switch-to-configuration",
"test",
],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1",
},
n.switch_to_configuration(
Path("/path/to/config"),
m.Action.TEST,
sudo=True,
remote=target_host,
target_host=target_host,
install_bootloader=True,
specialisation="special",
)
finally:
proc.communicate(timeout=1)
mock_run.assert_called_with(
[
*n.SWITCH_TO_CONFIGURATION_CMD_PREFIX,
config_path / "specialisation/special/bin/switch-to-configuration",
"test",
],
env={
"LOCALE_ARCHIVE": p.PRESERVE_ENV,
"NIXOS_NO_CHECK": p.PRESERVE_ENV,
"NIXOS_INSTALL_BOOTLOADER": "1",
},
sudo=True,
remote=target_host,
)
@patch(
@@ -112,7 +112,7 @@ def test_run_wrapper(mock_run: Any) -> None:
p.run_wrapper(
["test", "--with", "some flags"],
check=True,
remote=m.Remote("user@localhost", ["--ssh", "opt"], "password"),
remote=m.Remote("user@localhost", ["--ssh", "opt"], "password", "ssh"),
)
mock_run.assert_called_with(
[
@@ -142,7 +142,7 @@ def test_run_wrapper(mock_run: Any) -> None:
check=True,
sudo=True,
env={"FOO": "bar"},
remote=m.Remote("user@localhost", ["--ssh", "opt"], "password"),
remote=m.Remote("user@localhost", ["--ssh", "opt"], "password", "ssh"),
)
mock_run.assert_called_with(
[
@@ -181,7 +181,7 @@ def test__kill_long_running_ssh_process(mock_run: Any) -> None:
"build",
"/nix/store/la0c8nmpr9xfclla0n4f3qq9iwgdrq4g-nixos-system-sankyuu-nixos-25.05.20250424.f771eb4.drv^*",
],
m.Remote("user@localhost", opts=[], sudo_password=None),
m.Remote("user@localhost", opts=[], sudo_password=None, store_type="ssh"),
)
mock_run.assert_called_with(
[
@@ -208,6 +208,7 @@ def test_remote_from_name(monkeypatch: MonkeyPatch) -> None:
"user@localhost",
opts=[],
sudo_password=None,
store_type="ssh",
)
with patch("getpass.getpass", autospec=True, return_value="password"):
@@ -216,11 +217,12 @@ def test_remote_from_name(monkeypatch: MonkeyPatch) -> None:
"user@localhost",
opts=["-f", "foo", "-b", "bar", "-t"],
sudo_password="password",
store_type="ssh",
)
def test_ssh_host() -> None:
remotes = {
ssh_remotes = {
"user@[fe80::1%25eth0]": "user@fe80::1%eth0",
"[fe80::c98b%25enp4s0]": "fe80::c98b%enp4s0",
"user@[2001::5fce:a:198]": "user@2001::5fce:a:198",
@@ -231,12 +233,23 @@ def test_ssh_host() -> None:
"localhost": "localhost",
"user@example.org": "user@example.org",
"example.org": "example.org",
"ssh://explicit-store@localhost": "explicit-store@localhost",
}
ssh_ng_remotes = {
"ssh-ng://example.org": "example.org",
}
for host_input, expected in remotes.items():
for host_input, expected in ssh_remotes.items():
remote = m.Remote.from_arg(host_input, None, False)
assert remote is not None
assert remote.ssh_host() == expected
assert remote.store_type == "ssh"
for host_input, expected in ssh_ng_remotes.items():
remote = m.Remote.from_arg(host_input, None, False)
assert remote is not None
assert remote.ssh_host() == expected
assert remote.store_type == "ssh-ng"
@patch("subprocess.run", autospec=True)
@@ -275,7 +288,7 @@ def test_custom_sudo_args(mock_run: Any) -> None:
["test"],
check=False,
sudo=True,
remote=m.Remote("user@localhost", [], None),
remote=m.Remote("user@localhost", [], None, "ssh"),
)
mock_run.assert_called_with(
[
+3 -3
View File
@@ -16,18 +16,18 @@
rustPlatform.buildRustPackage (finalAttrs: {
pname = "ruff";
version = "0.15.5";
version = "0.15.6";
src = fetchFromGitHub {
owner = "astral-sh";
repo = "ruff";
tag = finalAttrs.version;
hash = "sha256-bemgVXV/Bkp0aXmWX+R6Aas2/naOx0XEGp0ofh+vyyM=";
hash = "sha256-a8A9FdfrGCyg6TdunsZcXqAzeXb9pCWO/02f9Nl5juU=";
};
cargoBuildFlags = [ "--package=ruff" ];
cargoHash = "sha256-NaWWX6EAVkEg/KQ+Up0t2fh/24fnTo6i5dDZoOWErjg=";
cargoHash = "sha256-TD5FLdi4YJwDzJpCctNKYxUNj/VgMnB/OBp3exk3cZw=";
nativeBuildInputs = [ installShellFiles ];
+2
View File
@@ -36,6 +36,8 @@ stdenv.mkDerivation (finalAttrs: {
'AC_CHECK_DECLS(fdatasync)' ""
'';
env.CFLAGS = lib.optionalString stdenv.hostPlatform.isDarwin "-DFUSE_DARWIN_ENABLE_EXTENSIONS=0";
meta = {
homepage = "https://github.com/archiecobbs/s3backer";
description = "FUSE-based single file backing store via Amazon S3";
+2 -5
View File
@@ -22,9 +22,6 @@
openssh,
}:
let
fuse = if stdenv.hostPlatform.isDarwin then macfuse-stubs.override { isFuse3 = true; } else fuse3;
in
stdenv.mkDerivation (finalAttrs: {
pname = "sshfs-fuse";
inherit version;
@@ -46,7 +43,7 @@ stdenv.mkDerivation (finalAttrs: {
makeWrapper
];
buildInputs = [
fuse
fuse3
glib
];
nativeCheckInputs = [
@@ -75,7 +72,7 @@ stdenv.mkDerivation (finalAttrs: {
checkPhase = lib.optionalString (!stdenv.hostPlatform.isDarwin) ''
# The tests need fusermount:
mkdir bin
cp ${fuse}/bin/fusermount3 bin/fusermount
cp ${fuse3}/bin/fusermount3 bin/fusermount
export PATH=bin:$PATH
# Can't access /dev/fuse within the sandbox: "FUSE kernel module does not seem to be loaded"
substituteInPlace test/util.py --replace "/dev/fuse" "/dev/null"
@@ -62,5 +62,6 @@ buildPythonPackage rec {
dotlambda
];
changelog = "https://github.com/libfuse/pyfuse3/blob/${src.tag}/Changes.rst";
platforms = lib.platforms.linux;
};
}
+6 -1
View File
@@ -8555,7 +8555,12 @@ with pkgs;
);
fuse = fuse2;
fuse2 = lowPrio (if stdenv.hostPlatform.isDarwin then macfuse-stubs else fusePackages.fuse_2);
fuse3 = fusePackages.fuse_3;
fuse3 = lowPrio (
if stdenv.hostPlatform.isDarwin then
macfuse-stubs.override { isFuse3 = true; }
else
fusePackages.fuse_3
);
gpm-ncurses = gpm.override { withNcurses = true; };