diff options
| author | tv <tv@krebsco.de> | 2026-11-12 20:48:11 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-13 03:24:36 +0100 |
| commit | 8c78f328bf7a1a4b97f26e5f5bb25f2d8b462f24 (patch) | |
| tree | 3f7dfb0c2fecdb09efd87bc1293d25d0bc4b8579 | |
| parent | bde412b675588ec14bf069f7e244b6e34eb5fdff (diff) | |
tests: check output, test rsync versions, close gaps in what's asserted
Several checks would pass with the feature they cover broken, or cover
it only by chance:
- Phases can state what a succeeding populate prints. That covers the
warnings of insecureLinks, the lines pruning prints, the one about a
replaced symlink, and what rsync logs about each item.
- The insecureLinks scenarios take their expectations from the version of
rsync, so with an rsync older than 3.5.0 they pass without the check
ever running. New tests, rsync-3-4-1 and rsync-3-5-0, run them with
these versions, which tests/rsync.nix builds from their release
tarballs, with ACLs and extended attributes, which backups use.
- Pruning has a leftover upload to remove, and a source that stops
having inputs to root leaves no empty directory of roots.
- A full clone has every commit from the start, so the fetch for a commit
it lacks never runs. A git scenario deploys a commit that appears on
the server after cloning.
- The jq test also runs the Nix function that finds a flake's
subdirectory on the target, over both shapes of nix flake metadata's
output.
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | tests/default.nix | 36 | ||||
| -rwxr-xr-x | tests/jq.sh | 29 | ||||
| -rw-r--r-- | tests/rsync.nix | 50 | ||||
| -rw-r--r-- | tests/scenarios.nix | 96 | ||||
| -rw-r--r-- | tests/vm.nix | 9 |
6 files changed, 207 insertions, 24 deletions
@@ -701,14 +701,17 @@ outside the target directory changed. The tests deploy every combination of a source type's options, except for patches, which the features test covers, to the deploying machine itself, to a remote target as `root`, and to a remote target using `sudo`, and check the result after -deploying and after deploying again. +deploying and after deploying again. The scenarios whose results depend on +the version of rsync also run with rsync 3.4.1 and 3.5.0, which +`tests/rsync.nix` builds, whatever version nixpkgs has. The tests require KVM and nixpkgs with `pkgs.testers.runNixOSTest` (23.05 or later), and use `<nixpkgs>` by default. ```sh -nix-build tests # all tests -nix-build tests -A flake # the test for one source type -nix-build tests -A jq # populate's jq programs for flakes, without VMs +nix-build tests # all tests +nix-build tests -A flake # the test for one source type +nix-build tests -A rsync-3-5-0 # the scenarios depending on rsync's version +nix-build tests -A jq # populate's programs for flakes, without VMs ``` diff --git a/tests/default.nix b/tests/default.nix index 7b82bce..3dabee9 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -11,29 +11,55 @@ let gitHead = "e42d44ef9cdb76ba5a84dff4c89c38222703bf0c"; gitFirst = "8d47013fa0888948cf0ffcc4e3c69a7b5f180562"; - scenarios = import ./scenarios.nix { + # rsync versions the scenarios depending on rsync's version run with. + # callPackage adds override and overrideDerivation, which aren't versions. + rsyncs = pkgs.lib.filterAttrs + (_: pkgs.lib.isDerivation) + (pkgs.callPackage ./rsync.nix {}); + + # The scenarios, with populate using rsync. + scenariosWith = rsync: import ./scenarios.nix { inherit (pkgs) lib; krops = import ../lib; - inherit (pkgs) populate; + populate = pkgs.populate.override { inherit rsync; }; inherit (pkgs.krops) writeDeploy; url = "http://deployer:8000"; gitUrl = "git://deployer/gitsrc.git"; remoteHost = "target"; agePath = "${pkgs.age}/bin/age"; inherit gitHead gitFirst; - rsyncVersion = pkgs.rsync.version; + rsyncVersion = rsync.version; }; + scenarios = scenariosWith pkgs.rsync; flakeJq = import ../pkgs/populate/flake-jq.nix; in builtins.mapAttrs (name: scenarios: import ./vm.nix { inherit pkgs name scenarios gitHead; }) scenarios + // pkgs.lib.mapAttrs' + (version: rsync: { + name = "rsync-${builtins.replaceStrings [ "." ] [ "-" ] version}"; + value = import ./vm.nix { + inherit pkgs gitHead rsync; + name = "rsync-${version}"; + scenarios = builtins.filter + (scenario: pkgs.lib.hasInfix "insecureLinks" scenario.name) + (scenariosWith rsync).file; + }; + }) + rsyncs // { # Lock files in the shapes different versions of Nix write. - jq = pkgs.runCommand "krops-jq" { nativeBuildInputs = [ pkgs.jq ]; } '' + jq = pkgs.runCommand "krops-jq" { + nativeBuildInputs = [ pkgs.jq pkgs.nix ]; + } '' sh ${./jq.sh} ${pkgs.linkFarm "krops-flake-jq" (map (name: { name = "${name}.jq"; path = pkgs.writeText "${name}.jq" flakeJq.${name}; }) - [ "selected" "selfIsRemote" "selfLocked" ])} + [ "selected" "selfIsRemote" "selfLocked" ] + ++ [{ + name = "metadataSubdir.nix"; + path = pkgs.writeText "metadataSubdir.nix" flakeJq.metadataSubdir; + }])} touch $out ''; } diff --git a/tests/jq.sh b/tests/jq.sh index a62d6cb..3794ce2 100755 --- a/tests/jq.sh +++ b/tests/jq.sh @@ -5,7 +5,7 @@ # usage: jq.sh DIR # # DIR contains the programs as selected.jq, selfIsRemote.jq, and -# selfLocked.jq. +# selfLocked.jq, and the Nix function for the target as metadataSubdir.nix. set -efu programs=$1 @@ -119,4 +119,31 @@ expect "locked reference of the flake itself" "$( --from-file "$programs"/selfLocked.jq )" "$mono" +# The Nix function finding the subdirectory on the target, which may lack +# jq. +# usage: metadata_subdir METADATA +metadata_subdir() { + METADATA=$1 nix-instantiate --eval --strict --json --store dummy:// --expr " + (import $programs/metadataSubdir.nix) + (builtins.fromJSON (builtins.getEnv \"METADATA\")) + " 2>&1 || : +} +export HOME=${TMPDIR:-/tmp} +export NIX_STATE_DIR=$HOME/nix-state +expect "subdirectory as attribute, on the target" "$(metadata_subdir '{ + "locked": { "type": "tarball", "url": "http://x/mono.tar.gz", "dir": "sub" }, + "original": { "type": "tarball", "url": "http://x/mono.tar.gz", "dir": "sub" } +}')" '"sub"' +expect "subdirectory in the URL, on the target" "$(metadata_subdir '{ + "locked": { "type": "tarball", "url": "http://x/mono.tar.gz?dir=sub&narHash=sha256-mono" }, + "original": { "type": "tarball", "url": "http://x/mono.tar.gz?dir=sub" } +}')" '"sub"' +expect "subdirectory in the original reference only, on the target" "$(metadata_subdir '{ + "locked": { "type": "tarball", "url": "http://x/mono.tar.gz" }, + "original": { "type": "tarball", "url": "http://x/mono.tar.gz?rev=1&dir=sub" } +}')" '"sub"' +expect "no subdirectory, on the target" "$(metadata_subdir '{ + "locked": { "type": "path", "path": "/src/top" } +}')" '""' + exit "$failed" diff --git a/tests/rsync.nix b/tests/rsync.nix new file mode 100644 index 0000000..6c14048 --- /dev/null +++ b/tests/rsync.nix @@ -0,0 +1,50 @@ +# rsync versions whose behaviour around .rsync-filter files differs: 3.4.1 +# reads them like the files it transfers, and 3.5.0 opens them more +# strictly. The tests run the scenarios depending on that with each, +# whatever version nixpkgs has. rsync's own test suite doesn't run: these +# builds only need to behave like the releases. +{ lib, stdenv, fetchurl, fetchpatch, perl, updateAutotoolsGnuConfigScriptsHook +, acl, libiconv, popt, zlib +}: + +let + rsync = { version, hash, patches ? [] }: stdenv.mkDerivation { + pname = "rsync"; + inherit version patches; + src = fetchurl { + # signed with key 9FEF 112D CE19 A0DC 7E88 2CB8 1BB2 4997 A853 5F6F + url = "mirror://samba/rsync/src/rsync-${version}.tar.gz"; + inherit hash; + }; + nativeBuildInputs = [ perl updateAutotoolsGnuConfigScriptsHook ]; + # With ACLs and extended attributes, which backups use (rsync -aAX). + buildInputs = [ acl libiconv popt zlib ]; + configureFlags = [ + "--disable-lz4" + "--disable-openssl" + "--disable-xxhash" + "--disable-zstd" + "--enable-ipv6" + "--with-included-zlib=no" + "--with-nobody-group=nogroup" + ]; + enableParallelBuilding = true; + meta.mainProgram = "rsync"; + }; +in { + "3.4.1" = rsync { + version = "3.4.1"; + hash = "sha256-KSS8s6Hti1UfwQH3QLnw/gogKxFQJ2R89phQ1l/YjFI="; + patches = [ + # Compilation with GCC 15. + (fetchpatch { + url = "https://github.com/RsyncProject/rsync/commit/a4b926dcdce96b0f2cc0dc7744e95747b233500a.patch"; + hash = "sha256-UiEQJ+p2gtIDYNJqnxx4qKgItKIZzCpkHnvsgoxBmSE="; + }) + ]; + }; + "3.5.0" = rsync { + version = "3.5.0"; + hash = "sha256-x//R72U+mVQPZh5HywC3+crR7muXI5mxb5PWcmVuDTM="; + }; +} diff --git a/tests/scenarios.nix b/tests/scenarios.nix index d4df931..2aecc53 100644 --- a/tests/scenarios.nix +++ b/tests/scenarios.nix @@ -98,10 +98,11 @@ let # directory, and controlTargetPath is the control for a path that makes # evaluation fail. env sets environment variables for populate, as # arguments of env. interrupt ends populate after that many seconds, - # which it's expected not to finish before. + # which it's expected not to finish before. output lists what a + # succeeding populate is expected to print. phase = { sources, backup ? false, prune ? false, deploy ? null, - targetPath ? path, env ? "", interrupt ? null, + targetPath ? path, env ? "", interrupt ? null, output ? [], expect ? "success", control ? null, controlTargetPath ? null, check ? "", mutate ? null, checkDeployer ? null, mutateDeployer ? null, }: let @@ -136,7 +137,7 @@ let null else { populate = toString (command sources targetPath); - inherit env interrupt; + inherit env interrupt output; expect = if expect == "success" then null else expect.fail; # No phase gets around checking the canaries. check = script (check + "\ncanary_check\n"); @@ -331,6 +332,11 @@ assert !(krops.isLocalTarget (targets.remote "/")); } // (if affected && insecureLinks == null then { expect.fail = "can't read /fixtures/filtered-link/.rsync-filter, so it would sync /fixtures/filtered-link/ without it."; } else { + output = optional affected ( + if insecureLinks + then "can't read /fixtures/filtered-link/.rsync-filter; it reads it with --insecure-links anyway, because insecureLinks is true." + else "can't read /fixtures/filtered-link/.rsync-filter, so it syncs /fixtures/filtered-link/ without it, because insecureLinks is false." + ); check = '' expect_eq keep "$(cat "$T"/x/keep)" keep ${if excluded then '' @@ -522,7 +528,49 @@ assert !(krops.isLocalTarget (targets.remote "/")); ''; } ]; - }); + }) + ++ map (targetKind: scenario (let + # The commit the first phase adds on top of gitHead, deterministic like + # the ones fixtures.sh creates. + gitThird = "c2e0f9d727337617a548284552909abe0f196d05"; + in { + # A full clone fetches a commit that appears on the server afterwards, + # also without fetchAlways. + name = "git-${targetKind}-fetch"; + inherit targetKind; + prepareDeployer = '' + git -C /fixtures/git/gitsrc.git update-ref -d refs/heads/extra + ''; + phases = [ + { + sources.x.git = { url = gitUrl; ref = gitHead; }; + check = '' + expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitHead} + ''; + mutateDeployer = '' + export GIT_AUTHOR_NAME=krops GIT_AUTHOR_EMAIL=spam@krebsco.de + export GIT_COMMITTER_NAME=krops GIT_COMMITTER_EMAIL=spam@krebsco.de + export GIT_AUTHOR_DATE='1000000000 +0000' GIT_COMMITTER_DATE='1000000000 +0000' + export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 + cd /fixtures/gitsrc + git checkout -q -B extra ${gitHead} + echo three > three + git add -A + git commit -q -m three + git push -q -f /fixtures/git/gitsrc.git extra + git checkout -q master + expect_eq "third commit" "$(git rev-parse extra)" ${gitThird} + ''; + } + { + sources.x.git = { url = gitUrl; ref = gitThird; }; + check = '' + expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitThird} + test -e "$T"/x/three || fail "three missing" + ''; + } + ]; + })) targetKinds; pass = matrix "pass" { store = [ "pass" "pass-git" ]; @@ -917,6 +965,7 @@ assert !(krops.isLocalTarget (targets.remote "/")); sources.x.flake-input = { url = "/fixtures/top"; name = "sub"; inherit fetchOnTarget; }; check = '' test ! -e "$T"/.populate/flake-inputs/x || fail "roots remain" + test ! -e "$T"/.populate/flake-inputs || fail "empty roots directory remains" '' + checkFlake { attr = "who"; expected = "sub"; subdir = true; }; } ]; @@ -951,16 +1000,23 @@ assert !(krops.isLocalTarget (targets.remote "/")); test -e "$T"/$name || fail "$name removed" done ''; + # An upload an interrupted run left behind. + mutate = '' + mkdir -p "$T"/.populate/flake-uploads/c/tree + ''; } { # b changes its type, so its roots go, while b stays. sources = { inherit (sources) a; b.symlink = "a"; }; prune = true; + output = map + (name: "pruning /var/krops/prune-${targetKind}/${name}") + [ "c" "d" "other" ]; check = '' test -e "$T"/a/flake.nix || fail "a removed" expect_eq b "$(readlink "$T"/b)" a test -d "$T"/.populate || fail ".populate removed" - for name in c d other .populate/flake-inputs; do + for name in c d other .populate/flake-inputs .populate/flake-uploads; do test ! -e "$T"/$name || fail "$name not pruned" done if nix-store --gc --print-roots 2>/dev/null | grep -F "$T/"; then @@ -1558,13 +1614,8 @@ assert !(krops.isLocalTarget (targets.remote "/")); }; # 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 { + link = type: { sources, to, check, says ? true }: let + phase = { inherit sources; check = '' case $(readlink "$T"/x || :) in @@ -1572,6 +1623,19 @@ assert !(krops.isLocalTarget (targets.remote "/")); esac '' + check; }; + in s "link-${type}" { + isolate = true; + prepare = '' + mkdir -p "$T" + ln -s ${outside}/${to} "$T"/x + ''; + phases = [ + (phase // { + output = optional says + "replacing symlink /var/krops/link-${type}-${targetKind}/x, which points to ${outside}/${to}"; + }) + phase + ]; }; # Something of populate's state replaced by a symlink to outside between # two deploys. The second one refuses to use it. @@ -1605,6 +1669,13 @@ assert !(krops.isLocalTarget (targets.remote "/")); test -e "$T"/x/flake.nix || fail "flake.nix missing" ''; }) + # What rsync logs about each item: what changed, and its size. + (s "rsync-log" { + phases = [{ + sources.x.file = "/fixtures/alt"; + output = [ "f+++++++++ flake.nix " "/53" ]; + }]; + }) # The link points at a Git work tree, which resetting and cleaning would # change. (link "git" { @@ -1655,6 +1726,7 @@ assert !(krops.isLocalTarget (targets.remote "/")); (link "symlink" { sources = alt // { x.symlink = "alt"; }; to = "dir"; + says = false; check = '' expect_eq link "$(readlink "$T"/x)" alt ''; diff --git a/tests/vm.nix b/tests/vm.nix index a203962..906bb09 100644 --- a/tests/vm.nix +++ b/tests/vm.nix @@ -1,7 +1,7 @@ # Runs scenarios in a NixOS VM test with two machines: deployer, which runs # populate, and target, which it deploys to via ssh. Local scenarios deploy # to deployer itself. -{ pkgs, name, scenarios, gitHead }: +{ pkgs, name, scenarios, gitHead, rsync ? pkgs.rsync }: let inherit (pkgs) lib; @@ -35,7 +35,7 @@ let nix.settings.substituters = lib.mkForce []; environment.systemPackages = [ pkgs.git - pkgs.rsync + rsync (pkgs.writeScriptBin "nixos-rebuild" (builtins.readFile ./nixos-rebuild.sh)) ]; # The stand-in replaces nixos-rebuild. @@ -162,6 +162,11 @@ in pkgs.testers.runNixOSTest { 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}" |
