From dd166e5a4d4349241aa3408c8d7f7340cb0a90fb Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 14 Nov 2026 00:33:20 +0000 Subject: tests: populate's tests of its own, krops' using them populate's tests are in pkgs/populate/tests, independent of krops. They run the program, with the sources as JSON from populate's Nix library, and target strings for the remote targets, leaving the user and whether the target is local to populate, so they cover its command line too. Phases expecting populate to refuse its arguments, e.g. a source name or a target path that can't be used, expect the program's refusal, with refused, the message, since nothing checks them during evaluation there. builder.nix is what builds the scenarios, which krops' tests use as well, with a function running the phases, and whether that checks the arguments during evaluation. vm.nix takes a module for both machines, for what else the scenarios run, and fixtures.nix and smoke.nix are what both use of the fixtures and the smoke test. krops' tests are deploy, writeDeploy's arguments to nixos-rebuild, with the stand-in for nixos-rebuild in the module, and populate, populate's scenarios that expect refusals, which pkgs.populate makes evaluation fail with, and those of populate's smoke test, all through pkgs.populate with target attribute sets as mkTarget returns them. -A only selects from all of populate's scenarios and krops' own. --- pkgs/populate/tests/vm.nix | 203 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 pkgs/populate/tests/vm.nix (limited to 'pkgs/populate/tests/vm.nix') diff --git a/pkgs/populate/tests/vm.nix b/pkgs/populate/tests/vm.nix new file mode 100644 index 0000000..dc599dd --- /dev/null +++ b/pkgs/populate/tests/vm.nix @@ -0,0 +1,203 @@ +# Runs scenarios in a NixOS VM test named name, with two machines: deployer, +# which runs populate, and target, which it delivers to via ssh. Local +# scenarios deliver to deployer itself. module is more configuration for +# both, e.g. stand-ins for programs the scenarios run. +{ pkgs, name, scenarios, gitHead, rsync ? pkgs.rsync, module ? {} }: + +let + inherit (pkgs) lib; + + sshKeys = import (pkgs.path + "/nixos/tests/ssh-keys.nix") pkgs; + + script = name: text: if text == null then null else pkgs.writeText name text; + + # The scenarios as JSON, with scripts as files. + plan = pkgs.writeText "${name}.json" (builtins.toJSON (map + (scenario: scenario // { + prepare = pkgs.writeText "prepare" scenario.prepare; + canarySetup = pkgs.writeText "canary-setup" scenario.canarySetup; + canaryCheck = pkgs.writeText "canary-check" scenario.canaryCheck; + prepareDeployer = script "prepare-deployer" scenario.prepareDeployer; + phases = map + (phase: phase // { + check = pkgs.writeText "check" phase.check; + mutate = script "mutate" phase.mutate; + checkDeployer = script "check-deployer" phase.checkDeployer; + mutateDeployer = script "mutate-deployer" phase.mutateDeployer; + }) + scenario.phases; + }) + scenarios)); + + common = { pkgs, ... }: { + imports = [ module ]; + # The plan refers to all scripts the scenarios run. Being part of the + # system, they survive the scenarios collecting garbage. + system.extraDependencies = [ plan "${./fixtures.sh}" ]; + nix.settings.substituters = lib.mkForce []; + environment.systemPackages = [ + pkgs.git + rsync + ]; + # Only warnings and errors on the console, which ends up in the log. + services.journald.extraConfig = lib.mkAfter "MaxLevelConsole=warning"; + boot.initrd.systemd.contents."/etc/systemd/journald.conf".text = + lib.mkAfter "MaxLevelConsole=warning"; + boot.consoleLogLevel = lib.mkForce 4; + # Neither probing BIOS disk information, nor iPXE option ROMs, which + # network boot would need, nor systemd-oomd, which warns about missing + # swap. + boot.kernelParams = lib.optional pkgs.stdenv.hostPlatform.isx86 "edd=off"; + virtualisation.qemu.options = [ "-global virtio-net-pci.romfile=" ]; + systemd.oomd.enable = false; + virtualisation.memorySize = 2048; + virtualisation.diskSize = 4096; + }; + +in pkgs.testers.runNixOSTest { + inherit name; + + globalTimeout = 4 * 60 * 60; + + nodes.deployer = { pkgs, ... }: { + imports = [ common ]; + environment.systemPackages = [ + pkgs.age pkgs.gnupg pkgs.pass pkgs.passage + ]; + networking.firewall.allowedTCPPorts = [ 8000 9418 ]; + # Resolving indirect references fails if the global registry can't be + # downloaded, which it can't in the VM. Disabling it needs flakes, and a + # deploying machine using indirect references has them anyway. The + # target doesn't, so its checks for flake support still mean something. + nix.settings.experimental-features = [ "nix-command" "flakes" ]; + nix.settings.flake-registry = ""; + programs.ssh.extraConfig = '' + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR + ''; + systemd.services.fixtures-http = { + wantedBy = [ "multi-user.target" ]; + preStart = "mkdir -p /fixtures/www"; + serviceConfig.ExecStart = + "${pkgs.python3}/bin/python3 -m http.server 8000 --directory /fixtures/www"; + }; + systemd.services.fixtures-git = { + wantedBy = [ "multi-user.target" ]; + preStart = "mkdir -p /fixtures/git"; + serviceConfig.ExecStart = + "${pkgs.git}/bin/git daemon --reuseaddr --export-all --base-path=/fixtures/git"; + }; + }; + + nodes.target = { ... }: { + imports = [ common ]; + services.openssh.enable = true; + users.users.root.openssh.authorizedKeys.keys = [ sshKeys.snakeOilPublicKey ]; + users.users.deploy = { + isNormalUser = true; + extraGroups = [ "wheel" ]; + openssh.authorizedKeys.keys = [ sshKeys.snakeOilPublicKey ]; + }; + security.sudo.wheelNeedsPassword = false; + }; + + testScript = /* python */ '' + import json + + plan = json.load(open("${plan}")) + nodes = {"deployer": deployer, "target": target} + + + # Runs a command, logging its output only if it fails. + def run(node, command): + status, out = node.execute(f"{command} 2>&1") + if status != 0: + raise Exception(f"{command} fails:\n{out}") + + + start_all() + deployer.wait_for_unit("fixtures-http.service") + deployer.wait_for_unit("fixtures-git.service") + deployer.wait_for_open_port(8000) + deployer.wait_for_open_port(9418) + target.wait_for_unit("sshd.service") + target.wait_for_open_port(22) + + with subtest("setup"): + run(deployer, "install -Dm600 ${sshKeys.snakeOilPrivateKey} /root/.ssh/id_ecdsa") + run(deployer, "ssh root@target true") + run(deployer, "ssh deploy@target sudo true") + run(deployer, "HOME=/root sh ${./fixtures.sh} http://deployer:8000 ${gitHead}") + + failed = [] + # The canaries get created at the start, after isolating, which removes + # some, and after a failure, which may have changed some. + canaries = False + for scenario in plan: + node = nodes[scenario["node"]] + try: + with subtest(scenario["name"]): + if scenario["isolate"]: + for machine in nodes.values(): + run(machine, "rm -fR /var/krops && nix-store --gc >/dev/null") + if scenario["isolate"] or not canaries: + for machine in nodes.values(): + run(machine, f"sh {scenario['canarySetup']}") + canaries = True + if scenario["prepareDeployer"] is not None: + run(deployer, f"sh {scenario['prepareDeployer']}") + run(node, f"sh {scenario['prepare']}") + for i, phase in enumerate(scenario["phases"]): + # A hang fails the scenario, not the whole test. The + # input is the console the test driver talks through, + # a terminal, which ssh -t would take over. + limit = phase["interrupt"] or 600 + status, out = deployer.execute( + f"env HOME=/root {phase['env']} timeout {limit} {phase['populate']} &1", + timeout=900, + ) + for unexpected in phase["unexpected"]: + if unexpected in out: + raise Exception( + f"populate {i} prints {unexpected!r}:\n{out}" + ) + if phase["interrupt"] is not None: + if status != 124: + raise Exception(f"populate {i} ends on its own:\n{out}") + elif phase["expect"] is None: + if status != 0: + raise Exception(f"populate {i} fails:\n{out}") + for expected in phase["output"]: + if expected not in out: + raise Exception( + f"populate {i} doesn't print {expected!r}:\n{out}" + ) + elif status == 0 or phase["expect"] not in out: + raise Exception( + f"populate {i} doesn't fail with {phase['expect']!r}:\n{out}" + ) + elif phase["status"] is not None and status != phase["status"]: + raise Exception( + f"populate {i} exits with {status} instead of {phase['status']}:\n{out}" + ) + run(node, f"KROPS_TEST_GC=1 sh {phase['check']}") + if phase["checkDeployer"] is not None: + run(deployer, f"sh {phase['checkDeployer']}") + if phase["mutate"] is not None: + run(node, f"sh {phase['mutate']}") + if phase["mutateDeployer"] is not None: + run(deployer, f"sh {phase['mutateDeployer']}") + # Each phase's check covers the canaries of its machine. + for machine in nodes.values(): + if machine is not node: + run(machine, f"sh {scenario['canaryCheck']}") + except Exception as e: + print(f"scenario {scenario['name']} fails: {e}") + failed.append(scenario["name"]) + canaries = False + + if failed: + raise Exception(f"{len(failed)} scenarios fail: {' '.join(failed)}") + ''; +} -- cgit v1.3.1