diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/default.nix | 4 | ||||
| -rw-r--r-- | tests/lib.sh | 64 | ||||
| -rw-r--r-- | tests/scenarios.nix | 360 | ||||
| -rw-r--r-- | tests/vm.nix | 14 |
4 files changed, 429 insertions, 13 deletions
diff --git a/tests/default.nix b/tests/default.nix index 285ed86..7b82bce 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -1,6 +1,6 @@ # NixOS VM tests for populate: one test per source type, one for features -# spanning sources, one for edge cases of flakes, and one for writeDeploy's -# arguments to nixos-rebuild. Build all with nix-build tests, or one with, +# spanning sources, one for edge cases of flakes, one for staying inside the +# target directory, and one for writeDeploy's arguments to nixos-rebuild. Build all with nix-build tests, or one with, # e.g., nix-build tests -A flake. jq runs populate's jq programs for flakes # without VMs. { pkgs ? import ../pkgs {} }: diff --git a/tests/lib.sh b/tests/lib.sh index 33a0c6e..4f62890 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -48,3 +48,67 @@ gc() { nix-store --gc >/dev/null fi } + +# Canaries are files outside the target directories, which populate must +# never change, whatever the target directories contain. Some scenarios +# point links at them. The manifest lists every entry with its type, mode, +# and link target, and every file with its checksum. +canary_manifest() { + { + find /var/krops/canary /var/krops/sibling /var/krops-outside \ + -printf '%y %m %p -> %l\n' + find /var/krops/canary /var/krops/sibling /var/krops-outside \ + -type f -exec sha256sum {} + + } 2>&1 | sort +} + +# Creates the canaries, unless they are there unchanged: a file beside the +# target directories, a directory beside them, and a directory elsewhere +# with files, a read-only directory, and a Git work tree with a changed and +# an untracked file, which resetting and cleaning would lose. +canary_setup() { + if test -e /var/krops-outside.manifest && + canary_manifest | cmp -s - /var/krops-outside.manifest; then + return + fi + rm -fR /var/krops/canary /var/krops/sibling /var/krops-outside + mkdir -p /var/krops/sibling /var/krops-outside/dir/sub + echo canary > /var/krops/canary + echo sibling > /var/krops/sibling/file + echo outside > /var/krops-outside/file + echo outside > /var/krops-outside/dir/file + echo outside > /var/krops-outside/dir/sub/file + chmod a-w /var/krops-outside/dir/sub/file /var/krops-outside/dir/sub + git init -q /var/krops-outside/repo + ( + cd /var/krops-outside/repo + echo tracked > tracked + git add tracked + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \ + git -c user.name=krops -c user.email=spam@krebsco.de commit -q -m canary + echo changed > tracked + echo untracked > untracked + ) + canary_manifest > /var/krops-outside.manifest +} + +# Fails if a canary changed, or if a store path that an entry of the target +# directory $T links to doesn't match its hash anymore. +canary_check() { + if ! canary_manifest | cmp -s - /var/krops-outside.manifest; then + canary_manifest | diff /var/krops-outside.manifest - >&2 || : + fail "something outside the target directory changed" + fi + if test -n "${T-}" && test -d "$T"; then + ls -A "$T" | while IFS= read -r canary_name; do + canary_link=$(readlink "$T/$canary_name" 2>/dev/null) || continue + case $canary_link in + /nix/store/*) + nix-store --verify-path \ + "$(printf '%s\n' "$canary_link" | cut -d/ -f1-4)" || + fail "$T/$canary_name links to a store path that changed" + ;; + esac + done + fi +} diff --git a/tests/scenarios.nix b/tests/scenarios.nix index 2815c3f..af072b9 100644 --- a/tests/scenarios.nix +++ b/tests/scenarios.nix @@ -94,37 +94,49 @@ let # differ only in what makes evaluation fail, or a function returning them # for the phase's sources, and that must evaluate. So the phase doesn't # pass for failing for an unrelated reason, e.g. a misspelt option. + # targetPath deploys to another path than the scenario's target + # directory, and controlTargetPath is the control for a path that makes + # evaluation fail. phase = { - sources, prune ? false, deploy ? null, - expect ? "success", control ? null, + sources, backup ? false, prune ? false, deploy ? null, + targetPath ? path, + expect ? "success", control ? null, controlTargetPath ? null, check ? "", mutate ? null, checkDeployer ? null, mutateDeployer ? null, }: let - command = sources: let + command = sources: targetPath: let args = { source = krops.evalSource [ sources ]; - inherit prune target; + inherit backup prune; + target = target // { path = targetPath; }; force = true; }; in if deploy == null then populate args else writeDeploy "deploy" (args // { useNixOutputMonitor = false; } // deploy); - evaluates = sources: - (builtins.tryEval (builtins.seq (toString (command sources)) true)).success; + evaluates = sources: targetPath: + (builtins.tryEval + (builtins.seq (toString (command sources targetPath)) true)).success; script = text: if text == null then null else prelude + vars + text; in if expect == "eval-error" then - if control == null then + if control == null && controlTargetPath == null then throw "scenario ${name}: expecting evaluation to fail needs a control" - else if evaluates sources then + else if evaluates sources targetPath then throw "scenario ${name}: populate evaluates, but is expected to fail" - else if !evaluates (if lib.isFunction control then control sources else control) then + else if !( + if controlTargetPath != null then evaluates sources controlTargetPath + else evaluates + (if lib.isFunction control then control sources else control) + targetPath + ) then throw "scenario ${name}: the control doesn't evaluate either" else null else { - populate = toString (command sources); + populate = toString (command sources targetPath); expect = if expect == "success" then null else expect.fail; - check = script check; + # No phase gets around checking the canaries. + check = script (check + "\ncanary_check\n"); mutate = script mutate; checkDeployer = script checkDeployer; mutateDeployer = script mutateDeployer; @@ -140,6 +152,10 @@ let '' + prepare; prepareDeployer = if prepareDeployer == null then null else prelude + vars + prepareDeployer; + # For all machines: before the scenario, and after it for the ones its + # checks don't run on. + canarySetup = prelude + "canary_setup\n"; + canaryCheck = prelude + "canary_check\n"; phases = lib.filter (x: x != null) (map phase phases); }; @@ -1255,4 +1271,326 @@ assert !(krops.isLocalTarget (targets.remote "/")); }) ) targetKinds; + + # Populate replaces and removes things in the target directory. These + # scenarios check that it doesn't anywhere else: names and paths that lead + # out of the target directory don't evaluate, and symlinks in it get + # replaced or refused instead of followed. The canaries, which every phase + # of every scenario checks, tell if something outside changed; these + # scenarios point links at them. + safety = concatMap (targetKind: let + s = name: args: scenario ({ + name = "${name}-${targetKind}"; + inherit targetKind; + } // args); + copying = targetKind != "sudo"; + fetchOnTarget = !copying; + outside = "/var/krops-outside"; + hello = [ "/fixtures/patches/hello.patch" ]; + # Sources that use every kind of state populate keeps: x a copy with + # patches, y garbage collector roots and, where the flake gets uploaded, + # an upload, and z a generated flake. + stateful = alt // { + x.flake-input = { + url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; + patches = hello; + inputs.inner.follows = "alt"; + }; + y.flake = { url = "/fixtures/top"; inherit fetchOnTarget; }; + z.file = { + path = "/fixtures/filesrc"; + rewriteLock = false; + inputs.dep.follows = "alt"; + }; + }; + # A source deployed where a symlink to something outside is. The link + # gets replaced, never followed. + link = type: { sources, to, check }: s "link-${type}" { + isolate = true; + prepare = '' + mkdir -p "$T" + ln -s ${outside}/${to} "$T"/x + ''; + phases = twice { + inherit sources; + check = '' + case $(readlink "$T"/x || :) in + ${outside}/*) fail "x still links outside" ;; + esac + '' + check; + }; + }; + # Something of populate's state replaced by a symlink to outside between + # two deploys. The second one refuses to use it. + state = name: { + entry, backup ? false, + message ? "/${entry} is a symlink, but populate needs a directory of its own there", + }: s "state-${name}" { + isolate = true; + phases = [ + { + sources = stateful; + inherit backup; + mutate = '' + rm -fR "$T"/${entry} + mkdir -p "$(dirname "$T"/${entry})" + ln -s ${outside}/dir "$T"/${entry} + ''; + } + { + sources = stateful; + inherit backup; + expect.fail = message; + } + ]; + }; + in lib.flatten [ + (link "file" { + sources.x.file = "/fixtures/alt"; + to = "dir"; + check = '' + test -e "$T"/x/flake.nix || fail "flake.nix missing" + ''; + }) + # The link points at a Git work tree, which resetting and cleaning would + # change. + (link "git" { + sources.x.git = { url = gitUrl; ref = gitHead; }; + to = "repo"; + check = '' + expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitHead} + ''; + }) + (link "pass" { + sources.x.pass = { dir = "/fixtures/pass"; name = "x"; }; + to = "dir"; + check = '' + expect_eq a "$(cat "$T"/x/a)" secret-a + ''; + }) + (link "passage" { + sources.x.passage = { + dir = "/fixtures/passage/store/x"; + identities_file = "/fixtures/passage/identities"; + }; + to = "dir"; + check = '' + expect_eq a "$(cat "$T"/x/a)" secret-a + ''; + }) + (link "pipe" { + sources.x.pipe = "/fixtures/pipe"; + to = "file"; + check = '' + expect_eq x "$(cat "$T"/x)" piped + ''; + }) + (link "derivation" { + sources.x.derivation = '' + derivation { + name = "krops-test"; + system = builtins.currentSystem; + builder = "/bin/sh"; + args = [ "-c" "echo built > $out" ]; + } + ''; + to = "file"; + check = '' + expect_eq x "$(cat "$T"/x)" built + ''; + }) + (link "symlink" { + sources = alt // { x.symlink = "alt"; }; + to = "dir"; + check = '' + expect_eq link "$(readlink "$T"/x)" alt + ''; + }) + (link "flake" { + sources.x.flake = { url = "/fixtures/top"; inherit fetchOnTarget; }; + to = "dir"; + check = checkFlake { attr = "msg"; expected = "top:dep+inner,sub"; }; + }) + (link "flake-materialized" { + sources.x.flake = { + url = "/fixtures/top"; inherit fetchOnTarget; + materialize = true; + }; + to = "dir"; + check = checkFlake { + attr = "msg"; expected = "top:dep+inner,sub"; materialized = true; + }; + }) + (link "flake-input-patched" { + sources.x.flake-input = { + url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; + patches = hello; + }; + to = "dir"; + check = checkFlake { attr = "greeting"; expected = "hello+inner"; }; + }) + # Symlinks to outside in what a source replaces: rsync and Git remove + # them, and so does replacing a directory by a link. + (s "inner-links" (let + prepare = '' + for name in a b c; do + mkdir -p "$T"/$name + ln -s ${outside}/dir "$T"/$name/keep + ln -s ${outside}/dir "$T"/$name/stale + ln -s ${outside}/repo/.git "$T"/$name/.git + done + # A work tree whose repository is elsewhere. + mkdir -p "$T"/d + echo "gitdir: ${outside}/repo/.git" > "$T"/d/.git + ''; + in { + isolate = true; + inherit prepare; + phases = twice { + sources = { + a.file = "/fixtures/filesrc"; + b.git = { url = gitUrl; ref = gitHead; }; + c.flake = { url = "/fixtures/top"; inherit fetchOnTarget; }; + d.git = { url = gitUrl; ref = gitHead; }; + }; + check = '' + expect_eq keep "$(cat "$T"/a/keep)" keep + test ! -L "$T"/a/stale || fail "a/stale remains" + expect_eq HEAD "$(git -C "$T"/b rev-parse HEAD)" ${gitHead} + test ! -L "$T"/b/.git || fail "b/.git remains a link" + test ! -L "$T"/b/stale || fail "b/stale remains" + expect_eq HEAD "$(git -C "$T"/d rev-parse HEAD)" ${gitHead} + test -d "$T"/d/.git || fail "d/.git isn't a directory" + expect_eq "store directory" "$(dirname "$(readlink "$T"/c)")" /nix/store + ''; + }; + })) + (state "sentinel" { + entry = ".populate"; + message = "error: sentinel file is a symlink"; + }) + (state "backup" { entry = ".populate/backup"; backup = true; }) + (map + (entry: state (lib.replaceStrings ["/"] ["-"] entry) { + entry = ".populate/${entry}"; + }) + [ + "derived" "flake-inputs" "flake-uploads" "flakes" "patches" + "derived/x" "patches/x" "flake-inputs/y" "flake-uploads/y" "flakes/z" + ]) + # Symlinks to outside among what populate removes: leftovers of + # interrupted runs, state of sources that are gone, and, when pruning, + # entries of the target directory. They get removed as links. + (s "leftovers" { + isolate = true; + prepare = '' + mkdir -p "$T" + ln -s ${outside}/dir "$T"/other + ln -s ${outside}/repo "$T"/repo + ''; + phases = [ + { + sources = stateful; + mutate = '' + ln -s ${outside}/dir "$T"/.populate/derived/x/work + ln -s ${outside}/dir "$T"/.populate/derived/gone + ln -s ${outside}/dir "$T"/.populate/flakes/gone + ln -s ${outside}/dir "$T"/.populate/patches/gone + ln -s ${outside}/dir "$T"/.populate/flake-inputs/gone + mkdir -p "$T"/.populate/flake-uploads + ln -s ${outside}/dir "$T"/.populate/flake-uploads/gone + ''; + } + { + sources = stateful; + check = '' + for name in derived/x/work derived/gone flakes/gone patches/gone; do + test ! -L "$T"/.populate/$name || fail "$name remains" + done + for name in other repo .populate/flake-inputs/gone .populate/flake-uploads/gone; do + test -L "$T"/$name || fail "$name removed without pruning" + done + ''; + } + { + sources = stateful; + prune = true; + check = '' + for name in other repo .populate/flake-inputs/gone .populate/flake-uploads; do + test ! -L "$T"/$name && test ! -e "$T"/$name || fail "$name not pruned" + done + ''; + } + ]; + }) + # The target directory itself may be a symlink: what it points to is the + # target directory then. + (s "linked-target" { + isolate = true; + prepare = '' + rm -fR "$T"-real + mkdir "$T"-real + ln -s "$(basename "$T")"-real "$T" + ''; + phases = twice { + sources = stateful; + check = '' + test -L "$T" || fail "the target directory isn't a link anymore" + test -e "$T"-real/alt/flake.nix || fail "alt missing" + gc + expect_eq greeting "$(nix_eval "$(readlink -f "$T"/x)"#greeting)" hello+alt + expect_eq msg "$(nix_eval "$(readlink -f "$T"/y)"#msg)" top:dep+inner,sub + expect_eq msg "$(nix_eval_online "path:$(readlink -f "$T"/.populate/flakes/z)#msg")" top:alt,sub + ''; + }; + }) + (lib.optionals (targetKind == "local") [ + # Rewriting a lock file changes no other hard link to it. The lock + # file is like the source's, with its modification time, so rsync + # leaves the link in place. (The source is on the deploying machine, + # so this only runs there.) + (s "lock-hard-link" { + prepare = '' + mkdir -p "$T" + cp -a /fixtures/filesrc "$T"/x + cp -p "$T"/x/flake.lock "$T".lock + ln -f "$T".lock "$T"/x/flake.lock + ''; + phases = twice { + sources = alt // { + x.file = { path = "/fixtures/filesrc"; inputs.dep.follows = "alt"; }; + }; + check = '' + cmp -s "$T".lock /fixtures/filesrc/flake.lock || + fail "the other hard link changed" + expect_eq msg "$(nix_eval_online "$T"/x#msg)" top:alt,sub + ''; + }; + }) + # Names that lead out of the target directory, to the directory itself, + # or to populate's state. + (s "names" { + phases = map + (name: { + sources.${name}.file = "/fixtures/alt"; + expect = "eval-error"; + control = { ok.file = "/fixtures/alt"; }; + }) + [ "" "." ".." ".populate" "a/b" "../x" "/etc" "a\nb" ]; + }) + # Target paths that aren't absolute, that are the root directory, or + # that resolve to another directory than they name. + (s "paths" { + phases = map + (targetPath: { + sources = alt; + inherit targetPath; + expect = "eval-error"; + controlTargetPath = "/var/krops/paths-local"; + }) + [ "" "/" "//" "/." "relative" "/var/krops/.." "/var/krops/../.." "/var/krops/a\nb" ]; + }) + ]) + ]) targetKinds; + } diff --git a/tests/vm.nix b/tests/vm.nix index d808dd2..5eb4dbb 100644 --- a/tests/vm.nix +++ b/tests/vm.nix @@ -14,6 +14,8 @@ let plan = pkgs.writeText "krops-test-${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 // { @@ -130,6 +132,9 @@ in pkgs.testers.runNixOSTest { 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: @@ -137,6 +142,10 @@ in pkgs.testers.runNixOSTest { 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']}") @@ -160,9 +169,14 @@ in pkgs.testers.runNixOSTest { 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)}") |
