Configure a router from settings.json and rebuild it on the device
nixosModules.settings builds a router from a JSON file: the router options as they are, plus host name, time zone, SSH keys and stateVersion; the module system validates it. lib.mkRouterSystem builds such a system, lib.mkConfigDir the configuration directory installed to /var/lib/router/config (a flake pinned to this router source, the settings and an optional local.nix). router-apply builds it and switches with commit-confirm. The device must be able to rebuild without internet, so the packages the rebuilt derivations need are part of the system; they were found by diffing the derivation graphs of typical changes against the installed system. checks.apply installs such a router, boots it offline, checks that the unchanged settings rebuild to the installed system, applies and confirms a change, and rolls back an unconfirmed one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+140
@@ -0,0 +1,140 @@
|
||||
# A router configured by settings.json, changed and rebuilt on the device:
|
||||
# install it (its configuration directory travels with the secrets), boot it
|
||||
# without network, then
|
||||
# - rebuilding the unchanged settings gives exactly the installed system,
|
||||
# - a changed setting is built offline, activated and confirmed,
|
||||
# - an unconfirmed change is rolled back.
|
||||
{ self, pkgs }:
|
||||
let
|
||||
inherit (pkgs) lib;
|
||||
common = import ./common.nix;
|
||||
|
||||
settings = pkgs.writeText "settings.json" (
|
||||
builtins.toJSON {
|
||||
system = {
|
||||
hostName = "router";
|
||||
stateVersion = "26.11";
|
||||
sshKeys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeKeyForTestsOnlyXXXXXXXXXXXXXXXXXXXXXXXXXX test"
|
||||
];
|
||||
};
|
||||
router = {
|
||||
wan.interface = "eth1";
|
||||
lans.main = {
|
||||
interface = "eth2";
|
||||
internet = true;
|
||||
ipv4.address = "192.168.10.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.10.100";
|
||||
end = "192.168.10.199";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
# Only so the test driver can talk to the installed system.
|
||||
local = pkgs.writeText "local.nix" ''
|
||||
{ modulesPath, ... }:
|
||||
{
|
||||
imports = [ (modulesPath + "/testing/test-instrumentation.nix") ];
|
||||
}
|
||||
'';
|
||||
|
||||
configDir = self.lib.mkConfigDir { inherit pkgs settings local; };
|
||||
|
||||
# What the device evaluates from configDir.
|
||||
toplevel =
|
||||
(self.lib.mkRouterSystem {
|
||||
settings = "${configDir}/settings.json";
|
||||
modules = [ "${configDir}/local.nix" ];
|
||||
}).config.system.build.toplevel;
|
||||
|
||||
sharedDisk = {
|
||||
virtualisation.diskImage = "./target.qcow2";
|
||||
virtualisation.diskSize = 8192;
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "router-apply";
|
||||
|
||||
nodes = {
|
||||
installer =
|
||||
{ lib, ... }:
|
||||
{
|
||||
imports = [
|
||||
sharedDisk
|
||||
self.nixosModules.installer
|
||||
common.tools
|
||||
];
|
||||
router.installer.system = toplevel;
|
||||
virtualisation.memorySize = 2048;
|
||||
virtualisation.fileSystems."/".autoFormat = true;
|
||||
virtualisation.emptyDiskImages = [ 1024 ];
|
||||
virtualisation.rootDevice = "/dev/vdb";
|
||||
nix.settings.substituters = lib.mkForce [ ];
|
||||
};
|
||||
|
||||
target = {
|
||||
imports = [ sharedDisk ];
|
||||
# Evaluating a NixOS system takes memory.
|
||||
virtualisation.memorySize = 3072;
|
||||
virtualisation.vlans = [
|
||||
1
|
||||
2
|
||||
];
|
||||
virtualisation.useBootLoader = true;
|
||||
virtualisation.useEFIBoot = true;
|
||||
virtualisation.useDefaultFilesystems = false;
|
||||
virtualisation.efi.keepVariables = false;
|
||||
virtualisation.fileSystems."/" = {
|
||||
device = "/dev/disk/by-label/this-is-not-real-and-will-never-be-used";
|
||||
fsType = "ext4";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
installer.start()
|
||||
installer.wait_for_unit("multi-user.target")
|
||||
installer.succeed("mkdir -p /root/secrets && cp -r ${configDir} /root/secrets/config")
|
||||
installer.succeed("router-install --yes --secrets /root/secrets /dev/vda >&2")
|
||||
installer.shutdown()
|
||||
|
||||
target.state_dir = installer.state_dir
|
||||
target.start()
|
||||
target.wait_for_unit("multi-user.target")
|
||||
|
||||
def current():
|
||||
return target.succeed("readlink -f /run/current-system").strip()
|
||||
|
||||
def pool_end():
|
||||
return target.succeed("grep -o '192.168.10.100 - 192.168.10.[0-9]*' /etc/kea/dhcp4-server.conf").split()[-1]
|
||||
|
||||
with subtest("the installed system is the one built from the settings"):
|
||||
assert current() == "${toplevel}"
|
||||
target.succeed("test -f /var/lib/router/config/settings.json")
|
||||
|
||||
with subtest("rebuilding unchanged settings on the device changes nothing"):
|
||||
out = target.succeed("router-apply 2>&1")
|
||||
assert "no changes" in out, out
|
||||
|
||||
with subtest("a changed setting is built offline, activated and confirmed"):
|
||||
target.succeed("sed -i 's/192.168.10.199/192.168.10.150/' /var/lib/router/config/settings.json")
|
||||
target.succeed("router-apply 5min >&2")
|
||||
assert current() != "${toplevel}"
|
||||
assert pool_end() == "192.168.10.150"
|
||||
target.succeed("systemctl is-active router-rollback.timer")
|
||||
target.succeed("router-confirm >&2")
|
||||
target.fail("systemctl is-active router-rollback.timer")
|
||||
confirmed = current()
|
||||
assert target.succeed("readlink -f /nix/var/nix/profiles/system").strip() == confirmed
|
||||
|
||||
with subtest("an unconfirmed change is rolled back"):
|
||||
target.succeed("sed -i 's/192.168.10.150/192.168.10.120/' /var/lib/router/config/settings.json")
|
||||
target.succeed("router-apply 20s >&2")
|
||||
assert pool_end() == "192.168.10.120"
|
||||
target.wait_until_succeeds(f"test $(readlink -f /run/current-system) = {confirmed}", timeout=120)
|
||||
assert pool_end() == "192.168.10.150"
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user