aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-12 20:48:11 +0000
committertv <tv@krebsco.de>2026-11-13 03:24:36 +0100
commit8c78f328bf7a1a4b97f26e5f5bb25f2d8b462f24 (patch)
tree3f7dfb0c2fecdb09efd87bc1293d25d0bc4b8579 /tests
parentbde412b675588ec14bf069f7e244b6e34eb5fdff (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.
Diffstat (limited to 'tests')
-rw-r--r--tests/default.nix36
-rwxr-xr-xtests/jq.sh29
-rw-r--r--tests/rsync.nix50
-rw-r--r--tests/scenarios.nix96
-rw-r--r--tests/vm.nix9
5 files changed, 200 insertions, 20 deletions
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}"