aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--pkgs/populate/tests/builder.nix249
-rw-r--r--pkgs/populate/tests/default.nix135
-rw-r--r--pkgs/populate/tests/fixtures.nix7
-rwxr-xr-xpkgs/populate/tests/fixtures.sh (renamed from tests/fixtures.sh)0
-rw-r--r--pkgs/populate/tests/lib.sh (renamed from tests/lib.sh)0
-rw-r--r--pkgs/populate/tests/rsync.nix (renamed from tests/rsync.nix)0
-rw-r--r--pkgs/populate/tests/scenarios.nix2174
-rw-r--r--pkgs/populate/tests/smoke.nix21
-rw-r--r--pkgs/populate/tests/vm.nix (renamed from tests/vm.nix)17
-rw-r--r--tests/default.nix182
-rw-r--r--tests/scenarios.nix2407
11 files changed, 2698 insertions, 2494 deletions
diff --git a/pkgs/populate/tests/builder.nix b/pkgs/populate/tests/builder.nix
new file mode 100644
index 0000000..26a360b
--- /dev/null
+++ b/pkgs/populate/tests/builder.nix
@@ -0,0 +1,249 @@
+# The scenario builder of populate's tests, which krops' tests use as well.
+# A scenario prepares the target directory, then runs its phases. Each phase
+# runs populate, or something running it, expecting it to succeed, to fail
+# with a message, or to refuse its arguments, and checks the result on the
+# target. Refusals checked during evaluation get checked here and don't run.
+{
+ # nixpkgs' lib.
+ lib,
+ # evalSource of populate's Nix library.
+ evalSource,
+ # Function from a phase's arguments, { source, backup, force, prune,
+ # deploy, targetKind, targetPath }, to the command running the phase.
+ # source is what evalSource returns; deploy is null, or arguments for
+ # running populate in a deployment.
+ run,
+ # Whether the command checks populate's arguments during evaluation, so
+ # phases expecting a refusal expect evaluation to fail, instead of
+ # populate refusing when it runs.
+ checksDuringEvaluation,
+ # Where the deploying machine serves /fixtures/www via HTTP.
+ url,
+ # URL of the Git repository /fixtures/git/gitsrc.git.
+ gitUrl,
+ # Host name of the remote target.
+ remoteHost,
+ # Absolute path of age on the deploying machine.
+ agePath,
+ # HEAD of /fixtures/gitsrc, and its parent.
+ gitHead,
+ gitFirst,
+ # Version of rsync on the deploying machine.
+ rsyncVersion,
+}:
+
+let
+ inherit (lib)
+ attrNames concatMap concatStringsSep foldl' isBool mapAttrsToList
+ optional optionalAttrs optionals optionalString;
+
+ prelude = ". ${./lib.sh}\n";
+
+ cartesian = attrs: foldl'
+ (acc: name: concatMap
+ (combination: map
+ (value: combination // { ${name} = value; })
+ attrs.${name})
+ acc)
+ [{}]
+ (attrNames attrs);
+
+ flagsName = flags: concatStringsSep "-" (mapAttrsToList
+ (name: value: name + (if isBool value then (if value then "1" else "0") else value))
+ flags);
+
+ alt = { alt.file = "/fixtures/alt"; };
+
+ # A path on a target of kind targetKind, as populate names it in what it
+ # prints.
+ targetName = targetKind: path: {
+ local = "";
+ remote = "${remoteHost}:";
+ sudo = "deploy@${remoteHost}:";
+ }.${targetKind} + path;
+
+ # How a source draws inputs from other sources: not at all, by rewriting
+ # the lock file of its flake, or by a generated flake.
+ inputModes = [ "none" "rewrite" "generated" ];
+
+ # The options of a source drawing inputs in mode.
+ drawInputs = mode: inputs: optionalAttrs (mode != "none") ({
+ inherit inputs;
+ } // optionalAttrs (mode == "generated") {
+ rewriteLock = false;
+ });
+
+ # The flake of source x that has its inputs drawn in mode.
+ flakeOf = mode:
+ if mode == "generated" then ''"$T"/.populate/flakes/x'' else ''"$T"/x'';
+
+ # Builds a scenario from a name, a target kind, and phases with sources.
+ # Isolated scenarios start with no target directories and a collected store
+ # on both machines, so no other scenario's store paths or roots stand in for
+ # missing ones. An exhaustive scenario covers a combination that no code
+ # path tells apart from the ones the other scenarios cover, so it only runs
+ # in the exhaustive tests.
+ scenario = {
+ name, targetKind, node ? null, isolate ? false, exhaustive ? false,
+ prepare ? "", prepareDeployer ? null, phases,
+ }: let
+ path = "/var/krops/${name}";
+ vars = "T=${path}\n";
+ # A phase expecting evaluation to fail, if checksDuringEvaluation, or else
+ # populate to refuse with the message refused, has a control: sources that
+ # 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. 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. output lists what a
+ # succeeding populate is expected to print, and unexpected what populate
+ # is expected not to print, succeeding or not. expect.fail is what a
+ # failing populate is expected to print, and expect.status, if given,
+ # its exit status.
+ phase = {
+ sources, backup ? false, prune ? false, deploy ? null,
+ targetPath ? path, env ? "", interrupt ? null, output ? [],
+ unexpected ? [],
+ expect ? "success", refused ? null, control ? null, controlTargetPath ? null,
+ check ? "", mutate ? null, checkDeployer ? null, mutateDeployer ? null,
+ }: let
+ command = sources: targetPath: run {
+ source = evalSource [ sources ];
+ inherit backup prune deploy targetKind targetPath;
+ force = true;
+ };
+ evaluates = sources: targetPath:
+ (builtins.tryEval
+ (builtins.seq (toString (command sources targetPath)) true)).success;
+ script = text: if text == null then null else prelude + vars + text;
+ # Without checks during evaluation, populate refuses when it runs.
+ expect' =
+ if expect == "eval-error" && !checksDuringEvaluation then
+ if refused == null then
+ throw "scenario ${name}: expecting a refusal needs refused, what populate says"
+ else { fail = refused; }
+ else expect;
+ in
+ if expect' == "eval-error" then
+ if control == null && controlTargetPath == null then
+ throw "scenario ${name}: expecting evaluation to fail needs a control"
+ else if evaluates sources targetPath then
+ throw "scenario ${name}: populate evaluates, but is expected to fail"
+ 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 targetPath);
+ inherit env interrupt output unexpected;
+ expect = if expect' == "success" then null else expect'.fail;
+ status = if expect' == "success" then null else expect'.status or null;
+ # No phase gets around checking the canaries.
+ check = script (check + "\ncanary_check\n");
+ mutate = script mutate;
+ checkDeployer = script checkDeployer;
+ mutateDeployer = script mutateDeployer;
+ };
+ in {
+ inherit name isolate exhaustive;
+ # Whether a phase expects a refusal, which krops' tests check during
+ # evaluation.
+ refusals = lib.any (phase: phase.expect or null == "eval-error") phases;
+ node =
+ if node != null then node
+ else if targetKind == "local" then "deployer"
+ else "target";
+ prepare = prelude + vars + ''
+ rm -fR "$T"
+ '' + 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);
+ };
+
+ # Same sources twice, so the second phase checks redeploying.
+ twice = phase: [ phase phase ];
+
+ targetKinds = [ "local" "remote" "sudo" ];
+
+ # Scenarios for all combinations of options and target kinds. f gets the
+ # options and the scenario's name, target kind, and target directory, and
+ # returns the scenario's arguments, or null for invalid combinations.
+ matrix = type: options: f:
+ concatMap
+ (targetKind: lib.filter (x: x != null) (map
+ (flags: let
+ name = "${type}-${targetKind}" +
+ optionalString (flags != {}) "-${flagsName flags}";
+ args = f flags { inherit name targetKind; path = "/var/krops/${name}"; };
+ in
+ if args == null then null
+ else scenario ({ inherit name targetKind; } // args))
+ (cartesian options)))
+ targetKinds;
+
+ # Checks that the source at $T/x is a link to a store path, or to the
+ # subdirectory sub therein, or a writable directory if materialized, and
+ # evaluates the attribute attr of the flake there, or of its generated
+ # flake, after collecting garbage.
+ checkFlake = {
+ attr, expected, subdir ? false, generated ? false, materialized ? false,
+ }: (if materialized then ''
+ test -d "$T"/x && ! test -L "$T"/x || fail "x isn't a directory"
+ test -w "$T"/x/flake.nix || fail "flake.nix isn't writable"
+ '' else ''
+ link=$(readlink "$T"/x)
+ ${if subdir then ''
+ expect_eq subdirectory "$(basename "$link")" sub
+ link=$(dirname "$link")
+ '' else ""}
+ expect_eq "store directory" "$(dirname "$link")" /nix/store
+ '') + ''
+ test ! -e "$T"/.populate/flake-uploads || fail "upload directory remains"
+ gc
+ expect_eq ${attr} "$(nix_eval ${
+ if generated then ''"$T"/.populate/flakes/x'' else ''"$T"/x''
+ }#${attr})" ${lib.escapeShellArg expected}
+ '';
+
+ copyingWithSudo = flags: ctx:
+ ctx.targetKind == "sudo" && !(flags.fetchOnTarget or false);
+
+ # The sources with fetchOnTarget set for all flake and flake-input sources,
+ # the control for copying flakes to targets using sudo.
+ withFetchOnTarget = lib.mapAttrs (_: lib.mapAttrs (type: options:
+ if lib.elem type [ "flake" "flake-input" ]
+ then options // { fetchOnTarget = true; }
+ else options));
+
+ # A phase copying flakes to a target using sudo, which isn't supported,
+ # and gets refused during evaluation.
+ refuseCopyingWithSudo = {
+ expect = "eval-error";
+ refused = "copying flakes to targets with sudo is not supported";
+ control = withFetchOnTarget;
+ };
+
+in {
+ inherit lib url gitUrl remoteHost agePath gitHead gitFirst rsyncVersion;
+ inherit (lib)
+ attrNames concatMap concatStringsSep foldl' isBool mapAttrsToList
+ optional optionalAttrs optionals optionalString;
+ inherit
+ prelude cartesian flagsName alt targetName
+ inputModes drawInputs flakeOf scenario twice
+ targetKinds matrix checkFlake copyingWithSudo withFetchOnTarget
+ refuseCopyingWithSudo;
+}
diff --git a/pkgs/populate/tests/default.nix b/pkgs/populate/tests/default.nix
new file mode 100644
index 0000000..7b37bb3
--- /dev/null
+++ b/pkgs/populate/tests/default.nix
@@ -0,0 +1,135 @@
+# NixOS VM tests for populate: one test per source type, one for features
+# spanning sources, one for edge cases of flakes, and one for staying inside
+# the target directory. Each runs the program, with the sources as JSON
+# from populate's Nix library, and target strings, which leave the user and
+# whether the target is local to populate to decide. Build all with
+# nix-build in this directory, or one with, e.g., nix-build -A flake. The
+# exhaustive tests add the combinations of options that no code path tells
+# apart from the ones the others cover; build them with nix-build -A
+# exhaustive, or one with, e.g., nix-build -A exhaustive.flake. The smoke
+# test runs a few scenarios covering each source type and each kind of
+# target in a single VM test, for checking changes quickly; build it with
+# nix-build -A smoke. nix-build -A only --argstr only REGEX runs the
+# scenarios whose names match the regular expression REGEX in a single VM
+# test, e.g. --argstr only 'lock-hooks-.*'.
+{ pkgs ? import <nixpkgs> {}, only ? null }:
+
+let
+ inherit (pkgs) lib;
+
+ populate-unwrapped = pkgs.callPackage ../. {};
+ populateLib = import ../lib { inherit lib; };
+
+ inherit (import ./fixtures.nix) gitHead gitFirst;
+
+ remoteHost = "target";
+
+ # rsync versions the scenarios depending on rsync's version run with.
+ # callPackage adds override and overrideDerivation, which aren't versions.
+ rsyncs = lib.filterAttrs
+ (_: lib.isDerivation)
+ (pkgs.callPackage ./rsync.nix {});
+
+ # A script running populate for a phase, with the tools it runs in front
+ # of PATH, and the sources on a file descriptor of their own, so the
+ # commands populate runs keep standard input. Store paths in the sources
+ # stay dependencies of the script.
+ runWith = rsync: {
+ source, backup, force, prune, deploy, targetKind, targetPath,
+ }:
+ if deploy != null then throw "populate's tests don't deploy" else
+ pkgs.writers.writeDash "populate" /* sh */ ''
+ set -efu
+ PATH=${lib.makeBinPath (with pkgs; [
+ coreutils findutils git nix openssh pass passage rsync
+ ])}''${PATH:+:$PATH}
+ export PATH
+ exec ${populate-unwrapped}/bin/populate ${lib.escapeShellArgs (
+ lib.optional backup "--backup" ++
+ lib.optional force "--force" ++
+ lib.optional prune "--prune" ++
+ [ "/dev/fd/3" ] ++
+ {
+ local = [];
+ remote = [ "root@${remoteHost}" ];
+ sudo = [ "deploy@${remoteHost}" "--sudo" ];
+ }.${targetKind} ++
+ [ "--path" targetPath ]
+ )} 3<<'EOF'
+ ${builtins.toJSON source}
+ EOF
+ '';
+
+ # The scenarios, with populate using rsync.
+ scenariosWith = rsync: import ./scenarios.nix {
+ inherit lib;
+ inherit (populateLib) evalSource;
+ run = runWith rsync;
+ checksDuringEvaluation = false;
+ url = "http://deployer:8000";
+ gitUrl = "git://deployer/gitsrc.git";
+ inherit remoteHost;
+ agePath = "${pkgs.age}/bin/age";
+ inherit gitHead gitFirst;
+ rsyncVersion = rsync.version;
+ };
+ scenarios = scenariosWith pkgs.rsync;
+
+ vm = name: scenarios: rsync: import ./vm.nix {
+ inherit pkgs gitHead scenarios rsync;
+ name = "populate-${name}";
+ };
+
+ # VM tests of those of the groups of scenarios that are, or aren't,
+ # exhaustive, named after the groups with prefix in front.
+ testsOf = exhaustive: prefix: lib.mapAttrs
+ (name: scenarios: vm (prefix + name) scenarios pkgs.rsync)
+ (lib.filterAttrs (_: scenarios: scenarios != [])
+ (builtins.mapAttrs
+ (_: builtins.filter (scenario: scenario.exhaustive == exhaustive))
+ scenarios));
+
+ allScenarios = lib.concatLists (builtins.attrValues scenarios);
+
+ # The scenarios named names, failing if one is missing.
+ named = what: names: let
+ selected = builtins.filter (scenario: builtins.elem scenario.name names) allScenarios;
+ missing = lib.subtractLists (map (scenario: scenario.name) selected) names;
+ in
+ if missing != [] then
+ throw "tests: ${what} names missing scenarios: ${toString missing}"
+ else
+ selected;
+
+ # The scenarios whose names match the regular expression only.
+ onlyScenarios = let
+ selected = builtins.filter
+ (scenario: builtins.match only scenario.name != null)
+ allScenarios;
+ in
+ if only == null then
+ throw "tests: -A only needs --argstr only REGEX"
+ else if selected == [] then
+ throw "tests: no scenario's name matches ${builtins.toJSON only}"
+ else
+ selected;
+in
+ testsOf false ""
+ # Not built by nix-build either, which runs these scenarios anyway.
+ // {
+ smoke.smoke = vm "smoke" (named "smoke test" (import ./smoke.nix)) pkgs.rsync;
+ only.only = vm "only" onlyScenarios pkgs.rsync;
+ }
+ # An attribute set without recurseForDerivations, so nix-build doesn't
+ # build them.
+ // { exhaustive = testsOf true "exhaustive-"; }
+ // lib.mapAttrs'
+ (version: rsync: {
+ name = "rsync-${builtins.replaceStrings [ "." ] [ "-" ] version}";
+ value = vm "rsync-${version}"
+ (builtins.filter
+ (scenario: lib.hasInfix "insecureLinks" scenario.name)
+ (scenariosWith rsync).file)
+ rsync;
+ })
+ rsyncs
diff --git a/pkgs/populate/tests/fixtures.nix b/pkgs/populate/tests/fixtures.nix
new file mode 100644
index 0000000..c834898
--- /dev/null
+++ b/pkgs/populate/tests/fixtures.nix
@@ -0,0 +1,7 @@
+# What tests know about the fixtures fixtures.sh creates: HEAD of
+# /fixtures/gitsrc, which fixtures.sh creates deterministically, and its
+# parent. fixtures.sh fails if HEAD differs, naming the actual one.
+{
+ gitHead = "e42d44ef9cdb76ba5a84dff4c89c38222703bf0c";
+ gitFirst = "8d47013fa0888948cf0ffcc4e3c69a7b5f180562";
+}
diff --git a/tests/fixtures.sh b/pkgs/populate/tests/fixtures.sh
index 85701a1..85701a1 100755
--- a/tests/fixtures.sh
+++ b/pkgs/populate/tests/fixtures.sh
diff --git a/tests/lib.sh b/pkgs/populate/tests/lib.sh
index a8f2594..a8f2594 100644
--- a/tests/lib.sh
+++ b/pkgs/populate/tests/lib.sh
diff --git a/tests/rsync.nix b/pkgs/populate/tests/rsync.nix
index 6c14048..6c14048 100644
--- a/tests/rsync.nix
+++ b/pkgs/populate/tests/rsync.nix
diff --git a/pkgs/populate/tests/scenarios.nix b/pkgs/populate/tests/scenarios.nix
new file mode 100644
index 0000000..d00ebcb
--- /dev/null
+++ b/pkgs/populate/tests/scenarios.nix
@@ -0,0 +1,2174 @@
+# Test scenarios for populate: every combination of each source type's
+# options, delivered to the local machine, to a remote target as root, and
+# to a remote target using sudo, plus scenarios for features spanning
+# sources. The arguments are those of builder.nix, which builds them.
+args:
+
+with import ./builder.nix args;
+
+{
+
+ derivation = matrix "derivation" {} (_: _: {
+ phases = twice {
+ sources.x.derivation = ''
+ derivation {
+ name = "krops-test";
+ system = builtins.currentSystem;
+ builder = "/bin/sh";
+ args = [ "-c" "echo built > $out" ];
+ }
+ '';
+ check = ''
+ gc
+ expect_eq x "$(cat "$T"/x)" built
+ '';
+ };
+ });
+
+ file = let
+ f = flags: _: {
+ # Drawing inputs happens after rsync, so it doesn't depend on rsync's
+ # options.
+ exhaustive = flags.inputs != "none" &&
+ (flags.checksum || flags.deleteExcluded || flags.filters);
+ prepare = ''
+ mkdir -p "$T"/x
+ echo old > "$T"/x/skip-ex
+ echo old > "$T"/x/skip-f
+ echo stale > "$T"/x/stale
+ echo BBBB > "$T"/x/same
+ touch -d @1000000000 "$T"/x/same
+ '';
+ phases = twice {
+ sources = {
+ x.file = {
+ path = "/fixtures/filesrc";
+ useChecksum = flags.checksum;
+ inherit (flags) deleteExcluded;
+ exclude = optional (flags.exclude or false) "skip-ex";
+ filters = optional flags.filters { type = "exclude"; pattern = "skip-f"; };
+ } // drawInputs flags.inputs { dep.follows = "alt"; };
+ } // optionalAttrs (flags.inputs != "none") alt;
+ check = let
+ excluded = file: set: ''
+ ${if !set then ''
+ expect_eq ${file} "$(cat "$T"/x/${file})" src
+ '' else if flags.deleteExcluded then ''
+ test ! -e "$T"/x/${file} || fail "${file} not deleted"
+ '' else ''
+ expect_eq ${file} "$(cat "$T"/x/${file})" old
+ ''}
+ '';
+ in ''
+ expect_eq keep "$(cat "$T"/x/keep)" keep
+ ${excluded "skip-ex" (flags.exclude or false)}
+ ${excluded "skip-f" flags.filters}
+ ${if flags.deleteExcluded then ''
+ test ! -e "$T"/x/stale || fail "stale not deleted"
+ '' else ''
+ expect_eq stale "$(cat "$T"/x/stale)" stale
+ ''}
+ expect_eq same "$(cat "$T"/x/same)" ${if flags.checksum then "AAAA" else "BBBB"}
+ '' + optionalString (flags.inputs != "none") ''
+ # Inputs not drawn from sources aren't delivered, so this needs the
+ # network and can't collect garbage before.
+ expect_eq msg "$(nix_eval_online ${flakeOf flags.inputs}#msg)" top:alt,sub
+ '';
+ };
+ };
+ in
+ matrix "file" {
+ checksum = [ false true ];
+ deleteExcluded = [ false true ];
+ filters = [ false true ];
+ inputs = inputModes;
+ } f
+ ++ [
+ # The deprecated exclude option, once, because it warns.
+ (scenario (let
+ args = f {
+ checksum = false;
+ deleteExcluded = true;
+ exclude = true;
+ filters = false;
+ inputs = "none";
+ } null;
+ in args // {
+ name = "file-local-exclude";
+ targetKind = "local";
+ phases = [ (builtins.head args.phases) ];
+ }))
+ ]
+ # A source whose .rsync-filter rsync 3.5.0 and later can't read, because
+ # it's reached through a symlink that neither root nor the user deploying
+ # owns. (Running as root, the scenarios can't use the other common
+ # cause, a directory on the way that the user may enter but not read.)
+ ++ concatMap (targetKind: map (insecureLinks: let
+ affected = lib.versionAtLeast rsyncVersion "3.5.0";
+ excluded = !affected || insecureLinks == true;
+ in scenario {
+ name = "file-${targetKind}-insecureLinks" +
+ (if insecureLinks == null then "Null" else if insecureLinks then "True" else "False");
+ inherit targetKind;
+ prepare = ''
+ mkdir -p "$T"/x
+ echo old > "$T"/x/garbage
+ '';
+ phases = twice ({
+ sources.x.file = { path = "/fixtures/filtered-link"; inherit insecureLinks; };
+ } // (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 ''
+ test ! -e "$T"/x/garbage || fail "garbage not excluded"
+ '' else ''
+ expect_eq garbage "$(cat "$T"/x/garbage)" garbage
+ ''}
+ '';
+ }));
+ }) [ null true false ]) targetKinds
+ ++ concatMap (targetKind: let
+ affected = lib.versionAtLeast rsyncVersion "3.5.0";
+ unreadable = name: { path, file, dir ? "x", env ? "" }: scenario {
+ name = "file-${targetKind}-insecureLinks${name}";
+ inherit targetKind;
+ prepare = ''
+ mkdir -p "$T"/${dir}
+ echo old > "$T"/${dir}/garbage
+ '';
+ phases = twice ({
+ sources.x.file = { inherit path; };
+ inherit env;
+ } // (if affected then {
+ expect.fail = "can't read ${file}, so it would sync ${path}/ without it.";
+ } else {
+ check = ''
+ expect_eq keep "$(cat "$T"/${dir}/keep)" keep
+ test ! -e "$T"/${dir}/garbage || fail "garbage not excluded"
+ '';
+ }));
+ };
+ in [
+ # The filter file is in the directory above what gets synced, where it
+ # applies as well.
+ (unreadable "Parent" {
+ path = "/fixtures/filtered-parent-link/sub";
+ file = "/fixtures/filtered-parent-link/.rsync-filter";
+ })
+ # The filter file is in a directory whose name rsync prints escaped
+ # in the C locale.
+ (unreadable "Name" {
+ path = "/fixtures/filtered-name-link";
+ file = "/fixtures/filtered-name-link/bär/.rsync-filter";
+ dir = "x/bär";
+ env = "LC_ALL=C";
+ })
+ ]) targetKinds;
+
+ flake = matrix "flake" {
+ kind = [ "git" "path" "subdir" "remote" ];
+ fetchOnTarget = [ false true ];
+ inputs = inputModes;
+ materialize = [ false true ];
+ } (flags: ctx: let
+ flake = {
+ git = "/fixtures/top";
+ path = "path:/fixtures/top";
+ subdir = "git+file:/fixtures/mono?dir=sub";
+ remote = "${url}/remote.tar.gz";
+ }.${flags.kind};
+ msg = {
+ git = "top:${if flags.inputs != "none" then "alt" else "dep+inner"},sub";
+ path = msg.git;
+ remote = "remote:${if flags.inputs != "none" then "alt" else "dep+inner"}";
+ };
+ in
+ # The flake in a subdirectory has no inputs to draw from sources.
+ if flags.kind == "subdir" && flags.inputs != "none" then null else {
+ isolate = true;
+ # Materializing copies what got delivered, whichever way, so it depends
+ # on how inputs get drawn and on subdirectories, not on how the flake got
+ # there.
+ exhaustive = flags.materialize && !lib.elem flags.kind [ "git" "subdir" ];
+ phases = twice ({
+ sources = {
+ x.flake = {
+ url = flake;
+ inherit (flags) fetchOnTarget materialize;
+ } // drawInputs flags.inputs { dep.follows = "alt"; };
+ } // optionalAttrs (flags.inputs != "none") alt;
+ } // (if copyingWithSudo flags ctx then refuseCopyingWithSudo else {
+ check = if flags.kind == "subdir" then checkFlake {
+ attr = "who"; expected = "sub"; subdir = true;
+ materialized = flags.materialize;
+ } else checkFlake {
+ attr = "msg"; expected = msg.${flags.kind};
+ generated = flags.inputs == "generated";
+ materialized = flags.materialize;
+ };
+ }));
+ });
+
+ flake-input = concatMap
+ (name: matrix "flake-input-${lib.replaceStrings ["/"] ["."] name}" ({
+ fetchOnTarget = [ false true ];
+ materialize = [ false true ];
+ } // optionalAttrs (name == "dep") {
+ inputs = inputModes;
+ }) (flags: ctx: let
+ inputs = flags.inputs or "none";
+ in {
+ isolate = true;
+ # Materializing copies what got delivered, whichever input it is.
+ exhaustive = flags.materialize && name != "dep";
+ phases = twice ({
+ sources = {
+ x.flake-input = {
+ url = "/fixtures/top";
+ inherit name;
+ inherit (flags) fetchOnTarget materialize;
+ } // drawInputs inputs { inner.follows = "alt"; };
+ } // optionalAttrs (inputs != "none") alt;
+ } // (if copyingWithSudo flags ctx then refuseCopyingWithSudo else {
+ check = {
+ dep = checkFlake {
+ attr = "greeting";
+ expected = if inputs != "none" then "dep+alt" else "dep+inner";
+ generated = inputs == "generated";
+ materialized = flags.materialize;
+ };
+ "dep/inner" = checkFlake {
+ attr = "who"; expected = "inner"; materialized = flags.materialize;
+ };
+ sub = checkFlake {
+ attr = "who"; expected = "sub"; subdir = true;
+ materialized = flags.materialize;
+ };
+ }.${name};
+ }));
+ }))
+ [ "dep" "dep/inner" "sub" ];
+
+ git = matrix "git" {
+ shallow = [ false true ];
+ fetchAlways = [ false true ];
+ cleanExclude = [ false true ];
+ inputs = inputModes;
+ } (flags: _: let
+ check = ''
+ expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitHead}
+ expect_eq commits "$(git -C "$T"/x rev-list --count HEAD)" ${
+ if flags.shallow then "1" else "2"
+ }
+ test -e "$T"/x/two || fail "two missing"
+ '' + optionalString (flags.inputs == "generated") ''
+ grep -q '"git+file:' "$T"/.populate/flakes/x/flake.nix ||
+ fail "the Git work tree isn't referred to as git+file:"
+ '' + optionalString (flags.inputs != "none") ''
+ gc
+ expect_eq msg "$(nix_eval ${flakeOf flags.inputs}#msg)" git:alt
+ '';
+ sources = ref: {
+ x.git = {
+ url = gitUrl;
+ inherit ref;
+ inherit (flags) shallow fetchAlways;
+ clean.exclude = optional flags.cleanExclude "keepme";
+ } // drawInputs flags.inputs { dep.follows = "alt"; };
+ } // optionalAttrs (flags.inputs != "none") alt;
+ in {
+ # Drawing inputs happens after Git, so it doesn't depend on Git's
+ # options.
+ exhaustive = flags.inputs != "none" &&
+ (flags.shallow || flags.fetchAlways || flags.cleanExclude);
+ phases = [
+ # The older commit first, so deploying HEAD changes the work tree, and
+ # fetches if fetchAlways is set or the clone is shallow.
+ {
+ sources = sources gitFirst;
+ check = ''
+ expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitFirst}
+ expect_eq commits "$(git -C "$T"/x rev-list --count HEAD)" 1
+ test ! -e "$T"/x/two || fail "two present"
+ '';
+ }
+ {
+ sources = sources gitHead;
+ inherit check;
+ mutate = ''
+ echo junk > "$T"/x/junk
+ echo keepme > "$T"/x/keepme
+ '';
+ }
+ {
+ sources = sources gitHead;
+ check = check + ''
+ test ! -e "$T"/x/junk || fail "junk not cleaned"
+ ${if flags.cleanExclude then ''
+ test -e "$T"/x/keepme || fail "keepme cleaned"
+ '' else ''
+ test ! -e "$T"/x/keepme || fail "keepme not cleaned"
+ ''}
+ '';
+ }
+ ];
+ })
+ ++ 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
+ # Branches and tags follow the server: a branch that moves between two
+ # deploys, and a tag, with full and shallow clones.
+ ++ concatMap (targetKind: map (shallow: scenario {
+ name = "git-${targetKind}-shallow${if shallow then "1" else "0"}-refs";
+ inherit targetKind;
+ prepareDeployer = ''
+ git -C /fixtures/git/gitsrc.git update-ref refs/heads/moving ${gitFirst}
+ git -C /fixtures/git/gitsrc.git update-ref refs/tags/first ${gitFirst}
+ '';
+ phases = [
+ {
+ sources.x.git = { url = gitUrl; ref = "moving"; inherit shallow; };
+ check = ''
+ expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitFirst}
+ '';
+ mutateDeployer = ''
+ git -C /fixtures/git/gitsrc.git update-ref refs/heads/moving ${gitHead}
+ '';
+ }
+ {
+ sources.x.git = { url = gitUrl; ref = "moving"; inherit shallow; };
+ check = ''
+ expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitHead}
+ '';
+ }
+ {
+ sources.x.git = { url = gitUrl; ref = "first"; inherit shallow; };
+ check = ''
+ expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitFirst}
+ '';
+ }
+ ];
+ }) [ false true ]) targetKinds
+
+ # A git source stops drawing inputs: its lock file, which Nix created and
+ # staged, goes, so the work tree is as the commit has it.
+ ++ map (targetKind: scenario {
+ name = "git-${targetKind}-dropInputs";
+ inherit targetKind;
+ phases = [
+ {
+ sources = alt // {
+ x.git = { url = gitUrl; ref = gitHead; inputs.dep.follows = "alt"; };
+ };
+ check = ''
+ expect_eq msg "$(nix_eval_online git+file:"$T"/x#msg)" git:alt
+ '';
+ }
+ {
+ sources = alt // { x.git = { url = gitUrl; ref = gitHead; }; };
+ check = ''
+ expect_eq status "$(git -C "$T"/x status --porcelain)" ""
+ '';
+ }
+ ];
+ }) targetKinds;
+
+ pass = matrix "pass" {
+ store = [ "pass" "pass-git" ];
+ } (flags: _: {
+ phases = twice {
+ sources.x.pass = { dir = "/fixtures/${flags.store}"; name = "x"; };
+ check = ''
+ expect_eq a "$(cat "$T"/x/a)" secret-a
+ '';
+ };
+ });
+
+ passage = matrix "passage" {
+ age = [ "default" "path" ];
+ dir = [ "store" "subdir" ];
+ } (flags: _: {
+ phases = twice {
+ sources.x.passage = {
+ dir = "/fixtures/passage/store" + optionalString (flags.dir == "subdir") "/x";
+ identities_file = "/fixtures/passage/identities";
+ } // optionalAttrs (flags.age == "path") {
+ age = agePath;
+ };
+ check = ''
+ expect_eq a "$(cat "$T"/x/${optionalString (flags.dir == "store") "x/"}a)" secret-a
+ '';
+ };
+ });
+
+ pipe = matrix "pipe" {} (_: _: {
+ phases = twice {
+ sources.x.pipe = "/fixtures/pipe";
+ check = ''
+ expect_eq x "$(cat "$T"/x)" piped
+ '';
+ };
+ });
+
+ symlink = matrix "symlink" {
+ absolute = [ false true ];
+ inputs = [ false true ];
+ } (flags: ctx: let
+ target = optionalString flags.absolute "${ctx.path}/" + "topcopy";
+ in {
+ phases = twice {
+ sources = {
+ x.symlink = {
+ inherit target;
+ inputs = optionalAttrs flags.inputs { dep.follows = "alt"; };
+ };
+ topcopy.file = "/fixtures/filesrc";
+ } // optionalAttrs flags.inputs alt;
+ check = ''
+ expect_eq link "$(readlink "$T"/x)" ${target}
+ test -e "$T"/x/flake.nix || fail "flake.nix missing"
+ '' + optionalString flags.inputs ''
+ expect_eq msg "$(nix_eval_online "$T"/.populate/flakes/x#msg)" top:alt,sub
+ '';
+ };
+ });
+
+ # Features spanning several sources.
+ features = concatMap (targetKind: let
+ s = name: args: scenario ({
+ name = "${name}-${targetKind}";
+ inherit targetKind;
+ isolate = true;
+ } // args);
+ copying = targetKind != "sudo";
+ fetchOnTarget = !copying;
+ # A scenario for each way of drawing inputs, which f gets as mode, along
+ # with draw, which adds inputs drawn that way to a source's options, and
+ # flake, which gives the flake to evaluate for a source.
+ modes = name: f: map
+ (mode: s "${name}-${mode}" (f {
+ draw = drawInputs mode;
+ flake = n:
+ if mode == "generated" then ''"$T"/.populate/flakes/${n}'' else ''"$T"/${n}'';
+ inherit mode;
+ }))
+ [ "rewrite" "generated" ];
+ in lib.flatten [
+ # A source draws an input from a source that draws inputs itself.
+ (modes "chain" ({ draw, flake, ... }: {
+ phases = twice {
+ sources = alt // {
+ a.flake = { url = "/fixtures/top"; inherit fetchOnTarget; } // draw { dep.follows = "b"; };
+ b.flake-input = {
+ url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
+ } // draw { inner.follows = "alt"; };
+ };
+ check = ''
+ gc
+ expect_eq msg "$(nix_eval ${flake "a"}#msg)" top:dep+alt,sub
+ '';
+ };
+ }))
+ # An input of an input drawn from a source gets drawn from another
+ # source.
+ (modes "nested" ({ draw, flake, ... }: {
+ phases = twice {
+ sources = alt // {
+ x.flake = {
+ url = "/fixtures/top"; inherit fetchOnTarget;
+ } // draw { dep = { follows = "b"; inputs.inner.follows = "alt"; }; };
+ b.flake-input = { url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; };
+ };
+ check = ''
+ gc
+ expect_eq msg "$(nix_eval ${flake "x"}#msg)" top:dep+alt,sub
+ '';
+ };
+ }))
+ # The lock file gets rewritten or generated again when a source inputs
+ # get drawn from changes.
+ (modes "relock" ({ draw, flake, ... }: let
+ x.file = { path = "/fixtures/filesrc"; } // draw { dep.follows = "a"; };
+ in {
+ phases = [
+ {
+ sources = { inherit x; a.file = "/fixtures/alt"; };
+ check = ''
+ expect_eq msg "$(nix_eval_online ${flake "x"}#msg)" top:alt,sub
+ '';
+ }
+ {
+ sources = { inherit x; a.file = "/fixtures/alt2"; };
+ check = ''
+ expect_eq msg "$(nix_eval_online ${flake "x"}#msg)" top:alt2,sub
+ '';
+ }
+ ];
+ }))
+ # What drawing inputs needs gets removed once a source draws no inputs
+ # anymore.
+ (modes "removal" ({ draw, mode, ... }: let
+ state = {
+ rewrite = "derived/x/result";
+ generated = "flakes/x/flake.lock";
+ }.${mode};
+ in {
+ phases = [
+ {
+ sources = alt // {
+ x.flake = { url = "/fixtures/top"; inherit fetchOnTarget; } // draw { dep.follows = "alt"; };
+ };
+ check = ''
+ test -e "$T"/.populate/${state} || fail "${state} missing"
+ '';
+ }
+ {
+ sources = alt // {
+ x.flake = { url = "/fixtures/top"; inherit fetchOnTarget; };
+ };
+ check = ''
+ test ! -e "$T"/.populate/${dirOf state} || fail "${dirOf state} remains"
+ '' + checkFlake { attr = "msg"; expected = "top:dep+inner,sub"; };
+ }
+ ];
+ }))
+ # Patches get applied in order to a copy, which becomes a store path and
+ # gets made again only when the patches change.
+ (s "patches" (let
+ x = patches: {
+ x.flake-input = {
+ url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget patches;
+ };
+ };
+ both = x [ "/fixtures/patches/hello.patch" "/fixtures/patches/hello2.patch" ];
+ in {
+ phases = [
+ {
+ sources = both;
+ check = checkFlake { attr = "greeting"; expected = "hello2+inner"; };
+ # The key changes whenever the copy gets made again, which
+ # the store path doesn't show, being the same.
+ mutate = ''
+ readlink "$T"/x > "$T".first
+ stat -c %y "$T"/.populate/derived/x/key > "$T".key
+ '';
+ }
+ {
+ sources = both;
+ check = checkFlake { attr = "greeting"; expected = "hello2+inner"; } + ''
+ expect_eq "store path" "$(readlink "$T"/x)" "$(cat "$T".first)"
+ expect_eq "copy made at" "$(stat -c %y "$T"/.populate/derived/x/key)" "$(cat "$T".key)"
+ '';
+ }
+ {
+ sources = x [ "/fixtures/patches/hello.patch" ];
+ check = checkFlake { attr = "greeting"; expected = "hello+inner"; } + ''
+ test "$(readlink "$T"/x)" != "$(cat "$T".first)" || fail "store path unchanged"
+ expect_eq patches "$(ls "$T"/.populate/patches/x)" 0001-hello.patch
+ '';
+ }
+ {
+ sources = x [];
+ check = checkFlake { attr = "greeting"; expected = "dep+inner"; } + ''
+ test ! -e "$T"/.populate/patches || fail "patches remain"
+ test ! -e "$T"/.populate/derived || fail "derived remains"
+ '';
+ }
+ ];
+ }))
+ (s "patch-fails" {
+ phases = [{
+ sources.x.flake-input = {
+ url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
+ patches = [ "/fixtures/patches/bad.patch" ];
+ };
+ expect.fail = "error: patch bad.patch does not apply to source x";
+ }];
+ })
+ # Patches apply in the subdirectory the target path shows.
+ (s "patch-subdir" {
+ phases = twice {
+ sources.x.flake-input = {
+ url = "/fixtures/top"; name = "sub"; inherit fetchOnTarget;
+ patches = [ "/fixtures/patches/sub.patch" ];
+ };
+ check = checkFlake { attr = "who"; expected = "patched"; subdir = true; };
+ };
+ })
+ # A copy with a rewritten lock file gets made again only when a source
+ # it draws inputs from changes.
+ (s "rewrite-cache" (let
+ x.flake-input = {
+ url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
+ inputs.inner.follows = "a";
+ };
+ in {
+ phases = [
+ {
+ sources = { inherit x; a.file = "/fixtures/alt"; };
+ check = checkFlake { attr = "greeting"; expected = "dep+alt"; };
+ # The key changes whenever the copy gets made again, which
+ # the store path doesn't show, being the same.
+ mutate = ''
+ readlink "$T"/x > "$T".first
+ stat -c %y "$T"/.populate/derived/x/key > "$T".key
+ '';
+ }
+ {
+ sources = { inherit x; a.file = "/fixtures/alt"; };
+ check = checkFlake { attr = "greeting"; expected = "dep+alt"; } + ''
+ expect_eq "store path" "$(readlink "$T"/x)" "$(cat "$T".first)"
+ expect_eq "copy made at" "$(stat -c %y "$T"/.populate/derived/x/key)" "$(cat "$T".key)"
+ '';
+ }
+ {
+ sources = { inherit x; a.file = "/fixtures/alt2"; };
+ check = checkFlake { attr = "greeting"; expected = "dep+alt2"; } + ''
+ test "$(readlink "$T"/x)" != "$(cat "$T".first)" || fail "store path unchanged"
+ '';
+ }
+ ];
+ }))
+ # A materialized input is a writable copy, patched and with a rewritten
+ # lock file, which gets made again only when what it derives from
+ # changes, and becomes a link again when not materialized anymore.
+ (s "materialize" (let
+ x = patches: alt // {
+ x.flake-input = {
+ url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget patches;
+ materialize = true;
+ inputs.inner.follows = "alt";
+ };
+ };
+ hello = [ "/fixtures/patches/hello.patch" ];
+ in {
+ phases = [
+ {
+ sources = x hello;
+ check = ''
+ test ! -L "$T"/x || fail "x is a link"
+ test -w "$T"/x/flake.nix || fail "flake.nix isn't writable"
+ gc
+ expect_eq greeting "$(nix_eval "$T"/x#greeting)" hello+alt
+ '';
+ mutate = ''
+ echo local > "$T"/x/local
+ '';
+ }
+ {
+ sources = x hello;
+ check = ''
+ expect_eq local "$(cat "$T"/x/local)" local
+ '';
+ }
+ {
+ sources = x (hello ++ [ "/fixtures/patches/hello2.patch" ]);
+ check = ''
+ test ! -e "$T"/x/local || fail "local remains"
+ gc
+ expect_eq greeting "$(nix_eval "$T"/x#greeting)" hello2+alt
+ '';
+ }
+ {
+ sources = alt // {
+ x.flake-input = { url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; };
+ };
+ check = checkFlake { attr = "greeting"; expected = "dep+inner"; } + ''
+ test ! -e "$T"/.populate/derived || fail "derived remains"
+ '';
+ }
+ ];
+ }))
+ # Roots follow the delivered input.
+ (s "retarget" {
+ phases = [
+ {
+ sources.x.flake-input = { url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; };
+ check = ''
+ expect_eq roots "$(ls "$T"/.populate/flake-inputs/x | wc -l)" 1
+ '';
+ }
+ {
+ 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; };
+ }
+ ];
+ })
+ # Pruning removes everything sources don't deliver anymore, including
+ # garbage collector roots, and keeps everything else.
+ (s "prune" (let
+ sources = {
+ a.file = "/fixtures/alt";
+ b.flake = { url = "/fixtures/top"; inherit fetchOnTarget; };
+ c.flake-input = { url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; };
+ d.symlink = "a";
+ };
+ in {
+ prepare = ''
+ mkdir -p "$T"
+ echo other > "$T"/other
+ '';
+ phases = [
+ {
+ inherit sources;
+ check = ''
+ test -e "$T"/.populate/flake-inputs/b || fail "roots of b missing"
+ test -e "$T"/.populate/flake-inputs/c || fail "roots of c missing"
+ '';
+ }
+ {
+ # Without pruning, nothing gets removed.
+ sources = { inherit (sources) a; };
+ check = ''
+ for name in b c d other .populate/flake-inputs/b .populate/flake-inputs/c; do
+ 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 '${targetName targetKind "/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 .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
+ fail "garbage collector roots remain"
+ fi
+ '';
+ }
+ ];
+ }))
+ (s "cycle" {
+ phases = [{
+ sources = {
+ a.file = { path = "/fixtures/alt"; inputs.foo.follows = "b"; };
+ b.file = { path = "/fixtures/alt"; inputs.foo.follows = "a"; };
+ };
+ expect = "eval-error";
+ refused = "krops: sources draw inputs from each other: a b";
+ control = {
+ a.file = { path = "/fixtures/alt"; inputs.foo.follows = "b"; };
+ b.file = "/fixtures/alt";
+ };
+ }];
+ })
+ (s "self" {
+ phases = [{
+ sources.a.file = { path = "/fixtures/alt"; inputs.foo.follows = "a"; };
+ expect = "eval-error";
+ refused = "krops: source a draws inputs from itself";
+ control = alt // {
+ a.file = { path = "/fixtures/alt"; inputs.foo.follows = "alt"; };
+ };
+ }];
+ })
+ (s "missing" {
+ phases = [{
+ sources.a.file = { path = "/fixtures/alt"; inputs.foo.follows = "nope"; };
+ expect = "eval-error";
+ refused = "krops: source a draws inputs from missing source nope";
+ control = {
+ a.file = { path = "/fixtures/alt"; inputs.foo.follows = "nope"; };
+ nope.file = "/fixtures/alt";
+ };
+ }];
+ })
+ (s "noflake" {
+ phases = [{
+ sources = alt // {
+ x.file = { path = "/fixtures/noflake"; inputs.foo.follows = "alt"; };
+ };
+ expect.fail = "error: source x draws flake inputs from other sources, but contains no flake.nix";
+ }];
+ })
+ (s "noinput" {
+ phases = [{
+ sources.x.flake-input = { url = "/fixtures/top"; name = "nope"; inherit fetchOnTarget; };
+ expect.fail = "error: flake /fixtures/top has no input nope";
+ }];
+ })
+ # rsync's log gives sizes in units of 1024, as %'''l asks for.
+ (s "rsync-units" {
+ prepareDeployer = ''
+ rm -fR /tmp/rsync-units
+ mkdir /tmp/rsync-units
+ head -c 2048 /dev/zero > /tmp/rsync-units/f
+ '';
+ phases = [{
+ sources.x.file = "/tmp/rsync-units";
+ output = [ "/2.00K" ];
+ }];
+ })
+ # A pipe command without #!, which a shell runs as a script.
+ (s "pipe-script" {
+ prepareDeployer = ''
+ printf 'echo piped\n' > /tmp/pipe-script
+ chmod +x /tmp/pipe-script
+ '';
+ phases = [{
+ sources.x.pipe = "/tmp/pipe-script";
+ check = ''
+ expect_eq x "$(cat "$T"/x)" piped
+ '';
+ }];
+ })
+ # A pass source gets decrypted again only when the last commit of its
+ # passwords changes, which .pass_info records.
+ (s "pass-changes" (let
+ sources.x.pass = { dir = "/tmp/pass-changes"; name = "x"; };
+ in {
+ prepareDeployer = ''
+ rm -fR /tmp/pass-changes
+ cp -R /fixtures/pass-git /tmp/pass-changes
+ '';
+ phases = [
+ {
+ inherit sources;
+ check = ''
+ expect_eq a "$(cat "$T"/x/a)" secret-a
+ '';
+ mutate = ''
+ echo local-change > "$T"/x/a
+ '';
+ }
+ {
+ inherit sources;
+ check = ''
+ expect_eq a "$(cat "$T"/x/a)" local-change
+ '';
+ mutateDeployer = ''
+ export GIT_AUTHOR_NAME=krops GIT_AUTHOR_EMAIL=spam@krebsco.de
+ export GIT_COMMITTER_NAME=krops GIT_COMMITTER_EMAIL=spam@krebsco.de
+ printf 'secret-b\n' |
+ PASSWORD_STORE_DIR=/tmp/pass-changes pass insert -m -f x/a >/dev/null
+ '';
+ }
+ {
+ inherit sources;
+ check = ''
+ expect_eq a "$(cat "$T"/x/a)" secret-b
+ '';
+ }
+ ];
+ }))
+ # An input locked to a path the target doesn't have.
+ (s "gone" {
+ phases = [{
+ sources.x.flake = { url = "/fixtures/usegone"; fetchOnTarget = true; };
+ expect.fail = "error: flake /fixtures/usegone: input gone refers to /fixtures/gone, which does not exist on the target";
+ }];
+ })
+ ]) targetKinds;
+
+ # Edge cases of the flake source types and of generated flakes: unusual lock
+ # files, references, and changes between deploys.
+ edge-cases = concatMap (targetKind: let
+ s = name: args: scenario ({
+ name = "${name}-${targetKind}";
+ inherit targetKind;
+ isolate = true;
+ } // args);
+ copying = targetKind != "sudo";
+ fetchOnTarget = !copying;
+ roots = n: ''
+ expect_eq roots "$(ls "$T"/.populate/flake-inputs/x 2>/dev/null | wc -l)" ${toString n}
+ '';
+ # A scenario for each way of drawing inputs, which f gets as mode, along
+ # with draw, which adds inputs drawn that way to a source's options, and
+ # flake, which gives the flake to evaluate for a source.
+ modes = name: f: map
+ (mode: s "${name}-${mode}" (f {
+ draw = drawInputs mode;
+ flake = n:
+ if mode == "generated" then ''"$T"/.populate/flakes/${n}'' else ''"$T"/${n}'';
+ inherit mode;
+ }))
+ [ "rewrite" "generated" ];
+ in lib.flatten [
+ # The registry entry kropsremote, which fixtures.sh adds on the deploying
+ # machine, points at ${url}/remote.tar.gz. The deploying machine has no
+ # global registry, which it couldn't download.
+ (s "indirect" {
+ phases = twice {
+ sources.x.flake = { url = "kropsremote"; inherit fetchOnTarget; };
+ check = checkFlake { attr = "msg"; expected = "remote:dep+inner"; };
+ };
+ })
+ (s "remote-subdir" {
+ phases = twice {
+ sources.x.flake = { url = "${url}/mono.tar.gz?dir=sub"; inherit fetchOnTarget; };
+ check = checkFlake { attr = "who"; expected = "sub"; subdir = true; };
+ };
+ })
+ (modes "subdir-inputs" ({ draw, mode, ... }: {
+ phases = twice {
+ sources = alt // {
+ x.flake = {
+ url = "git+file:/fixtures/monoin?dir=sub"; inherit fetchOnTarget;
+ } // draw { dep.follows = "alt"; };
+ };
+ check = checkFlake {
+ attr = "msg"; expected = "monoin:alt"; subdir = true;
+ generated = mode == "generated";
+ };
+ };
+ }))
+ (s "dirty" {
+ phases = twice {
+ sources.x.flake = { url = "/fixtures/dirty"; inherit fetchOnTarget; };
+ check = checkFlake { attr = "msg"; expected = "dirty:dep+inner,sub"; } + ''
+ test ! -e "$T"/x/untracked || fail "untracked file delivered"
+ '';
+ };
+ })
+ (s "unlocked" {
+ phases = [{
+ sources.x.flake = { url = "/fixtures/unlocked"; inherit fetchOnTarget; };
+ expect.fail = "requires lock file changes";
+ }];
+ })
+ (s "notdir" {
+ phases = [{
+ sources = {
+ x.file = { path = "/fixtures/filesrc"; inputs.dep.follows = "p"; };
+ p.pipe = "/fixtures/pipe";
+ };
+ expect.fail = "error: source p is not a directory";
+ # Locking the flake with an empty URL would lock the current
+ # directory, the login directory on a remote target.
+ unexpected = [ "while fetching the input" ];
+ }];
+ })
+ (s "noinput-nested" {
+ phases = [{
+ sources.x.flake-input = { url = "/fixtures/top"; name = "dep/nope"; inherit fetchOnTarget; };
+ expect.fail = "error: flake /fixtures/top has no input dep/nope";
+ }];
+ })
+ (s "shared" {
+ phases = twice {
+ sources = {
+ x.flake = { url = "/fixtures/top"; inherit fetchOnTarget; };
+ dep.flake-input = { url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; };
+ sub.flake-input = { url = "/fixtures/top"; name = "sub"; fetchOnTarget = true; };
+ };
+ check = checkFlake { attr = "msg"; expected = "top:dep+inner,sub"; } + ''
+ expect_eq dep "$(nix_eval "$T"/dep#greeting)" dep+inner
+ expect_eq sub "$(nix_eval "$T"/sub#who)" sub
+ '';
+ };
+ })
+ (s "follows-empty" {
+ phases = twice {
+ sources.x.flake = { url = "/fixtures/nofollow"; fetchOnTarget = true; };
+ check = checkFlake { attr = "msg"; expected = "nofollow"; };
+ };
+ })
+ (s "follows-cycle" {
+ phases = twice {
+ sources.x.flake = { url = "/fixtures/cyc"; fetchOnTarget = true; };
+ check = checkFlake { attr = "msg"; expected = "cyc:cyca,cycb"; };
+ };
+ })
+ # Names resolve through the parent's lock file, so dep/inner is the
+ # parent's inner, which dep's inner follows.
+ (s "follows-name" {
+ phases = twice {
+ sources.x.flake-input = { url = "/fixtures/follow"; name = "dep/inner"; inherit fetchOnTarget; };
+ check = checkFlake { attr = "who"; expected = "inner2"; };
+ };
+ })
+ # The delivered input comes with the inputs its own lock file pins, which
+ # evaluating it needs, not with the parent's inner, which it follows there.
+ (s "follows-closure" {
+ phases = twice {
+ sources.x.flake-input = { url = "/fixtures/follow"; name = "dep"; inherit fetchOnTarget; };
+ check = checkFlake { attr = "greeting"; expected = "dep+inner"; } + roots 1;
+ };
+ })
+ (s "relative-input" {
+ phases = twice {
+ sources.x.flake = { url = "/fixtures/rel"; fetchOnTarget = true; };
+ check = checkFlake { attr = "msg"; expected = "rel:lib"; };
+ };
+ })
+ # The target can't fetch an input of the flake. The deploying machine
+ # fetched it when locking, so its fetcher cache has to go.
+ (s "fetch-failure" {
+ prepareDeployer = ''
+ rm -fR /root/.cache/nix
+ '';
+ phases = [{
+ sources.x.flake = { url = "/fixtures/broken"; fetchOnTarget = true; };
+ expect.fail = "vanishing.tar.gz";
+ }];
+ })
+ # The delivered input itself is locked to a path the target lacks.
+ (s "gone-input" {
+ phases = [{
+ sources.x.flake-input = { url = "/fixtures/usegone"; name = "gone"; fetchOnTarget = true; };
+ expect.fail = "input gone refers to /fixtures/gone, which does not exist on the target";
+ }];
+ })
+ # An input that gets drawn from a source isn't needed on the target, with
+ # all it pins.
+ (modes "overridden-roots" ({ draw, mode, ... }: {
+ # What gets delivered doesn't depend on how inputs get drawn.
+ exhaustive = mode == "generated";
+ phases = twice {
+ sources = alt // {
+ x.flake-input = {
+ url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
+ } // draw { inner.follows = "alt"; };
+ };
+ check = checkFlake {
+ attr = "greeting"; expected = "dep+alt";
+ generated = mode == "generated";
+ } + roots 0;
+ };
+ }))
+ # The input's own lock file pins something unreachable, which gets drawn
+ # from a source anyway.
+ (modes "own-lock-unreachable" ({ draw, mode, ... }: {
+ # What gets delivered doesn't depend on how inputs get drawn.
+ exhaustive = mode == "generated";
+ prepareDeployer = ''
+ rm -fR /root/.cache/nix
+ '';
+ phases = twice {
+ sources = alt // {
+ x.flake-input = {
+ url = "/fixtures/usepinned"; name = "dep"; inherit fetchOnTarget;
+ } // draw { inner.follows = "alt"; };
+ };
+ check = checkFlake {
+ attr = "greeting"; expected = "pinned+alt";
+ generated = mode == "generated";
+ };
+ };
+ }))
+ # The input's own lock file pins something unreachable, or lacks an input
+ # its flake.nix has. That fails, unless the input isn't required to come
+ # with its inputs.
+ (s "own-lock-broken" (let
+ x = url: requireInputs: {
+ x.flake-input = { inherit url; name = "dep"; inherit fetchOnTarget requireInputs; };
+ };
+ delivered = ''
+ test -e "$T"/x/flake.nix || fail "input not delivered"
+ '';
+ in {
+ prepareDeployer = ''
+ rm -fR /root/.cache/nix
+ '';
+ phases = [
+ {
+ sources = x "/fixtures/usepinned" true;
+ expect.fail = "error: source x: the inputs its own lock file pins can't be delivered. Set its requireInputs to false to deliver it anyway.";
+ }
+ {
+ sources = x "/fixtures/usepinned" false;
+ check = delivered + roots 0;
+ }
+ {
+ sources = x "/fixtures/usestale" true;
+ expect.fail = "error: source x: the inputs its own lock file pins can't be delivered. Set its requireInputs to false to deliver it anyway.";
+ }
+ {
+ sources = x "/fixtures/usestale" false;
+ check = delivered + roots 2;
+ }
+ {
+ sources = x "/fixtures/usestale" false;
+ check = delivered + roots 2;
+ }
+ ];
+ }))
+ # Redeploying an unchanged input while where it comes from is
+ # unreachable and the fetcher caches are gone: the store has it.
+ (s "offline-redeploy" (let
+ sources.x.flake-input = { url = "/fixtures/offline"; name = "gap"; inherit fetchOnTarget; };
+ check = checkFlake { attr = "who"; expected = "gap"; };
+ in {
+ prepareDeployer = ''
+ if test -e /fixtures/www/gap.tar.gz.off; then
+ mv /fixtures/www/gap.tar.gz.off /fixtures/www/gap.tar.gz
+ fi
+ '';
+ phases = [
+ {
+ inherit sources check;
+ mutate = ''
+ rm -fR /root/.cache/nix
+ '';
+ mutateDeployer = ''
+ rm -fR /root/.cache/nix
+ mv /fixtures/www/gap.tar.gz /fixtures/www/gap.tar.gz.off
+ '';
+ }
+ {
+ inherit sources check;
+ mutateDeployer = ''
+ mv /fixtures/www/gap.tar.gz.off /fixtures/www/gap.tar.gz
+ '';
+ }
+ ];
+ }))
+ (s "flake-to-file" {
+ phases = [
+ {
+ sources.x.flake = { url = "/fixtures/top"; inherit fetchOnTarget; };
+ check = roots 3;
+ mutate = ''
+ readlink "$T"/x > "$T"/old
+ '';
+ }
+ {
+ sources.x.file = "/fixtures/alt";
+ check = ''
+ test ! -L "$T"/x || fail "x is still a link to $(readlink "$T"/x)"
+ test -e "$T"/x/flake.nix || fail "flake.nix missing"
+ nix-store --verify-path "$(cat "$T"/old)" || fail "store path changed"
+ '' + roots 0;
+ }
+ ];
+ })
+ (s "symlink-to-flake" {
+ phases = [
+ {
+ sources = alt // { x.symlink = "alt"; };
+ check = ''
+ expect_eq link "$(readlink "$T"/x)" alt
+ '';
+ }
+ {
+ sources = alt // { x.flake = { url = "/fixtures/top"; inherit fetchOnTarget; }; };
+ check = checkFlake { attr = "msg"; expected = "top:dep+inner,sub"; } + ''
+ test -e "$T"/alt/flake.nix || fail "alt changed"
+ '';
+ }
+ ];
+ })
+ # The ssh master stops, after failing as well as after succeeding.
+ (s "ssh-master" (let
+ checkDeployer = ''
+ for i in 1 2 3 4 5 6 7 8 9 10; do
+ if ! pgrep -f 'ssh: .*/populate\..*\[mux\]' >/dev/null; then
+ exit 0
+ fi
+ sleep 0.5
+ done
+ fail "ssh master remains"
+ '';
+ in {
+ phases = [
+ {
+ sources.x.flake-input = { url = "/fixtures/top"; name = "nope"; inherit fetchOnTarget; };
+ expect.fail = "error: flake /fixtures/top has no input nope";
+ inherit checkDeployer;
+ }
+ {
+ sources = alt;
+ inherit checkDeployer;
+ }
+ ];
+ }))
+ # Pruning entries whose names a shell's read would strip or split.
+ (s "prune-names" {
+ prepare = ''
+ mkdir -p "$T/ lead" "$T/trail " "$T/$(printf 'new\nline')" "$T/axb" "$T/[b]"
+ '';
+ phases = [{
+ sources = alt;
+ prune = true;
+ check = ''
+ # In the C locale, ls sorts by bytes, so . comes first.
+ expect_eq entries "$(LC_ALL=C ls -A "$T" | tr '\n' ' ')" '.populate alt '
+ '';
+ }];
+ })
+ # Misspelt input names draw nothing, so they fail before anything
+ # changes: of the source's flake, and below an input drawn from a source.
+ (modes "inputs-typo" ({ draw, ... }: {
+ phases = [
+ {
+ sources = alt // {
+ x.flake-input = {
+ url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
+ } // draw { innr.follows = "alt"; };
+ };
+ expect.fail = "error: source x draws input innr from source alt, but its flake has no input innr";
+ }
+ {
+ sources = {
+ x.flake = {
+ url = "/fixtures/top"; inherit fetchOnTarget;
+ } // draw { dep = { follows = "b"; inputs.qq.follows = "c2"; }; };
+ b.file = "/fixtures/two";
+ c2.file = "/fixtures/c2";
+ };
+ expect.fail = "error: source x draws input dep/qq from source c2, but the flake of source b has no input qq";
+ }
+ ];
+ }))
+ # A signal ending populate leaves neither the ssh master nor the
+ # temporary directory.
+ (s "interrupt" {
+ prepareDeployer = ''
+ find "''${TMPDIR:-/tmp}" -maxdepth 1 -name 'populate.*' -exec rm -fR {} +
+ '';
+ phases = [{
+ sources.x.pipe = "/fixtures/pipe-slow";
+ interrupt = 3;
+ checkDeployer = ''
+ for i in 1 2 3 4 5 6 7 8 9 10; do
+ if ! pgrep -f 'ssh: .*/populate\..*\[mux\]' >/dev/null; then
+ break
+ fi
+ sleep 0.5
+ done
+ ! pgrep -f 'ssh: .*/populate\..*\[mux\]' >/dev/null ||
+ fail "ssh master remains"
+ expect_eq "temporary directories" \
+ "$(find "''${TMPDIR:-/tmp}" -maxdepth 1 -name 'populate.*')" ""
+ '';
+ }];
+ })
+ # An input of an input drawn from a source that draws inputs itself
+ # gets drawn from another source.
+ (modes "override" ({ draw, flake, ... }: {
+ # Drawing inputs happens on the target alike for every kind of target.
+ exhaustive = targetKind != "local";
+ phases = twice {
+ sources = {
+ x.flake = {
+ url = "/fixtures/top"; inherit fetchOnTarget;
+ } // draw { dep = { follows = "b"; inputs.q.follows = "c2"; }; };
+ b.file = { path = "/fixtures/two"; } // draw { p.follows = "c1"; };
+ c1.file = "/fixtures/c1";
+ c2.file = "/fixtures/c2";
+ };
+ check = ''
+ expect_eq msg "$(nix_eval_online ${flake "x"}#msg)" top:two:c1+c2,sub
+ '';
+ };
+ }))
+ # The same one level deeper: the input gets drawn from a source below
+ # an input that the source in between draws from a source itself.
+ (modes "override-deep" ({ draw, flake, ... }: {
+ # Drawing inputs happens on the target alike for every kind of target.
+ exhaustive = targetKind != "local";
+ phases = twice {
+ sources = {
+ x.flake = {
+ url = "/fixtures/top"; inherit fetchOnTarget;
+ } // draw { dep = { follows = "b"; inputs.p.inputs.z.follows = "c2"; }; };
+ b.file = { path = "/fixtures/two"; } // draw { p.follows = "c3"; };
+ c2.file = "/fixtures/c2";
+ c3.file = "/fixtures/c3";
+ };
+ check = ''
+ expect_eq msg "$(nix_eval_online ${flake "x"}#msg)" 'top:two:c3(c2)+inner,sub'
+ '';
+ };
+ }))
+ # A source with a rewritten lock file draws an input from a source with
+ # a generated flake, and an input below it from another source.
+ (s "override-mixed" {
+ # Drawing inputs happens on the target alike for every kind of target.
+ exhaustive = targetKind != "local";
+ phases = twice {
+ sources = {
+ x.flake = {
+ url = "/fixtures/top"; inherit fetchOnTarget;
+ inputs.dep = { follows = "b"; inputs.q.follows = "c2"; };
+ };
+ b.file = {
+ path = "/fixtures/two";
+ rewriteLock = false;
+ inputs.p.follows = "c1";
+ };
+ c1.file = "/fixtures/c1";
+ c2.file = "/fixtures/c2";
+ };
+ check = ''
+ expect_eq msg "$(nix_eval_online "$T"/x#msg)" top:two:c1+c2,sub
+ '';
+ };
+ })
+ (modes "nonflake-input" ({ draw, flake, ... }: {
+ # Drawing inputs happens on the target alike for every kind of target.
+ exhaustive = targetKind != "local";
+ phases = twice {
+ sources = {
+ x.file = { path = "/fixtures/usedata"; } // draw { data.follows = "data"; };
+ data.file = "/fixtures/data";
+ };
+ check = ''
+ expect_eq msg "$(nix_eval ${flake "x"}#msg)" data:hello
+ '';
+ };
+ }))
+ # The target directory in a Git work tree.
+ (modes "gitdir" ({ draw, flake, ... }: {
+ prepare = ''
+ mkdir -p "$T"
+ git -C "$T" init -q
+ '';
+ phases = twice {
+ sources = alt // {
+ x.file = { path = "/fixtures/filesrc"; } // draw { dep.follows = "alt"; };
+ };
+ check = ''
+ # Not just the path, which Nix takes for the enclosing work tree.
+ expect_eq msg "$(nix_eval_online path:${flake "x"}#msg)" top:alt,sub
+ '';
+ };
+ }))
+ # Output of the same length, which rsync's quick check doesn't notice.
+ (s "pipe-change" {
+ prepareDeployer = ''
+ echo piped > /fixtures/pipe2-data
+ '';
+ phases = [
+ {
+ sources.x.pipe = "/fixtures/pipe2";
+ check = ''
+ expect_eq x "$(cat "$T"/x)" piped
+ '';
+ mutateDeployer = ''
+ echo PIPED > /fixtures/pipe2-data
+ '';
+ }
+ {
+ sources.x.pipe = "/fixtures/pipe2";
+ check = ''
+ expect_eq x "$(cat "$T"/x)" PIPED
+ '';
+ mutateDeployer = ''
+ echo piped > /fixtures/pipe2-data
+ '';
+ }
+ ];
+ })
+ # Source names that aren't input names, or that Nix has another use
+ # for, both of sources inputs get drawn from and of sources drawing them.
+ (modes "source-name" ({ draw, flake, ... }: let
+ x = from: { path = "/fixtures/filesrc"; } // draw { dep.follows = from; };
+ in {
+ # Drawing inputs happens on the target alike for every kind of target.
+ exhaustive = targetKind != "local";
+ phases = twice {
+ sources = {
+ "alt-1.0".file = "/fixtures/alt";
+ self.file = "/fixtures/alt";
+ x.file = x "alt-1.0";
+ y.file = x "self";
+ "a.b_c-d".file = x "self";
+ };
+ check = ''
+ expect_eq x "$(nix_eval_online ${flake "x"}#msg)" top:alt,sub
+ expect_eq y "$(nix_eval_online ${flake "y"}#msg)" top:alt,sub
+ expect_eq a.b_c-d "$(nix_eval_online ${flake "a.b_c-d"}#msg)" top:alt,sub
+ '';
+ };
+ }))
+ # An upload an interrupted run left behind.
+ (s "upload-leftover" {
+ phases = [
+ {
+ sources.x.flake = { url = "/fixtures/top"; fetchOnTarget = true; };
+ check = checkFlake { attr = "msg"; expected = "top:dep+inner,sub"; };
+ mutate = ''
+ mkdir -p "$T"/.populate/flake-uploads/x/tree
+ echo half > "$T"/.populate/flake-uploads/x/tree/half
+ '';
+ }
+ {
+ sources.x.flake = { url = "/fixtures/top"; fetchOnTarget = true; };
+ check = checkFlake { attr = "msg"; expected = "top:dep+inner,sub"; };
+ }
+ ];
+ })
+ ] ++ lib.optional (targetKind == "local")
+ # The target has the path the input is locked to.
+ (s "uselocal" {
+ phases = twice {
+ sources.x.flake = { url = "/fixtures/uselocal"; fetchOnTarget = true; };
+ check = checkFlake { attr = "msg"; expected = "uselocal:alt"; };
+ };
+ })
+ ) 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, says ? true }: let
+ phase = {
+ inherit sources;
+ check = ''
+ case $(readlink "$T"/x || :) in
+ ${outside}/*) fail "x still links outside" ;;
+ 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 '${targetName targetKind "/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.
+ 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"
+ '';
+ })
+ # 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"
+ # Named like pruning names it.
+ "populating '${targetName targetKind "/var/krops/rsync-log-${targetKind}/x"}'..."
+ ];
+ }];
+ })
+ # 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";
+ says = false;
+ 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. So do
+ # hard links to a read-only file outside, whose mode stays.
+ (s "leftovers" {
+ isolate = true;
+ prepare = ''
+ mkdir -p "$T"/hard
+ ln -s ${outside}/dir "$T"/other
+ ln -s ${outside}/repo "$T"/repo
+ ln ${outside}/dir/sub/file "$T"/hard/link
+ ln ${outside}/dir/sub/file "$T"/hardlink
+ '';
+ 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 hard hardlink .populate/flake-inputs/gone .populate/flake-uploads; do
+ test ! -L "$T"/$name && test ! -e "$T"/$name || fail "$name not pruned"
+ done
+ '';
+ }
+ ];
+ })
+ # A target path with characters that mean something to shells and in
+ # URLs.
+ (s "odd-target" (let
+ targetPath = "/var/krops/odd-target-${targetKind}/a b*c?d#e%f";
+ in {
+ isolate = true;
+ phases = twice {
+ sources = stateful // {
+ g.git = { url = gitUrl; ref = gitHead; };
+ p.pipe = "/fixtures/pipe";
+ };
+ inherit targetPath;
+ prune = true;
+ check = ''
+ D=${lib.escapeShellArg targetPath}
+ test -e "$D"/alt/flake.nix || fail "alt missing"
+ expect_eq HEAD "$(git -C "$D"/g rev-parse HEAD)" ${gitHead}
+ expect_eq p "$(cat "$D"/p)" piped
+ expect_eq entries "$(ls "$T")" 'a b*c?d#e%f'
+ gc
+ expect_eq greeting "$(nix_eval "$(readlink -f "$D"/x)"#greeting)" hello+alt
+ expect_eq msg "$(nix_eval "$(readlink -f "$D"/y)"#msg)" top:dep+inner,sub
+ '';
+ };
+ }))
+ # 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
+ '';
+ };
+ })
+ # Git work trees whose .git directory names places elsewhere, or a
+ # command: a work tree elsewhere, a configuration file elsewhere, another
+ # repository, and a command run when Git checks for changes. Populate
+ # refuses them, telling why, and replaces them by fresh clones with
+ # reclone. A hook doesn't run.
+ (s "git-foreign" (let
+ elsewhere = "/var/krops-elsewhere";
+ git = name: reclone: {
+ ${name}.git = { url = gitUrl; ref = gitHead; inherit reclone; };
+ };
+ untouched = ''
+ cmp -s ${elsewhere}/config ${elsewhere}/config.orig ||
+ fail "what b/.git/config links to changed"
+ test ! -e ${elsewhere}/ran || fail "a command in a .git directory ran"
+ '';
+ refused = name: message: {
+ sources = git name false;
+ expect.fail = "error: populate doesn't use the Git work tree ${targetName targetKind "/var/krops/git-foreign-${targetKind}/${name}"}, because ${message}. Fix that, remove the work tree, or set the source's reclone to true to replace it by a fresh clone.";
+ };
+ in {
+ isolate = true;
+ prepare = ''
+ rm -fR ${elsewhere}
+ mkdir -p ${elsewhere} "$T"
+ for name in a b c d e; do
+ git clone -q ${gitUrl} "$T"/$name
+ git -C "$T"/$name checkout -q ${gitFirst}
+ done
+ git -C "$T"/a config core.worktree ${outside}/dir
+ mv "$T"/b/.git/config ${elsewhere}/config
+ ln -s ${elsewhere}/config "$T"/b/.git/config
+ cp -p ${elsewhere}/config ${elsewhere}/config.orig
+ printf '#! /bin/sh\ntouch ${elsewhere}/ran\n' > "$T"/c/.git/hooks/reference-transaction
+ chmod +x "$T"/c/.git/hooks/reference-transaction
+ echo ${outside}/repo/.git > "$T"/d/.git/commondir
+ git -C "$T"/e config core.fsmonitor "touch ${elsewhere}/ran"
+ '';
+ phases = [
+ (refused "a" "/var/krops/git-foreign-${targetKind}/a/.git/config sets core.worktree to ${outside}/dir")
+ (refused "b" "/var/krops/git-foreign-${targetKind}/b/.git contains symlinks: /var/krops/git-foreign-${targetKind}/b/.git/config")
+ {
+ sources = git "c" false;
+ check = untouched + ''
+ expect_eq "c HEAD" "$(git -C "$T"/c rev-parse HEAD)" ${gitHead}
+ '';
+ }
+ (refused "d" "/var/krops/git-foreign-${targetKind}/d/.git/commondir names another repository")
+ (refused "e" "/var/krops/git-foreign-${targetKind}/e/.git/config sets core.fsmonitor, which neither git clone nor populate does")
+ {
+ sources = git "a" true // git "b" true // git "c" true // git "d" true // git "e" true;
+ output = map
+ (name: "replacing the Git work tree '${targetName targetKind "/var/krops/git-foreign-${targetKind}/${name}"}', because")
+ [ "a" "b" "d" "e" ];
+ check = untouched + ''
+ for name in a b c d e; do
+ expect_eq "$name HEAD" "$(git -C "$T"/$name rev-parse HEAD)" ${gitHead}
+ test -e "$T"/$name/two || fail "$name/two missing"
+ done
+ '';
+ }
+ ];
+ }))
+ # A Git work tree whose submodule's .git, which Git uses as it finds it,
+ # isn't the gitfile git clone made, naming the repository in
+ # .git/modules: a repository of its own, whose configuration names a
+ # command, and a gitfile naming a repository elsewhere.
+ (s "git-submodule" (let
+ elsewhere = "/var/krops-elsewhere";
+ url = dirOf gitUrl + "/withsub.git";
+ git = reclone: { x.git = { inherit url reclone; ref = "master"; }; };
+ untouched = ''
+ test ! -e ${elsewhere}/ran || fail "a command in a .git directory ran"
+ expect_eq "HEAD elsewhere" "$(git -C ${elsewhere}/repo rev-parse HEAD)" ${gitFirst}
+ '';
+ in {
+ isolate = true;
+ prepare = ''
+ rm -fR ${elsewhere}
+ git clone -q ${gitUrl} ${elsewhere}/repo
+ git -C ${elsewhere}/repo checkout -q ${gitFirst}
+ '';
+ phases = [
+ {
+ sources = git false;
+ check = untouched + ''
+ expect_eq "sub HEAD" "$(git -C "$T"/x/sub rev-parse HEAD)" ${gitHead}
+ '';
+ mutate = ''
+ rm "$T"/x/sub/.git
+ cp -R "$T"/x/.git/modules/sub "$T"/x/sub/.git
+ git config --file "$T"/x/sub/.git/config --unset core.worktree
+ git config --file "$T"/x/sub/.git/config \
+ filter.x.smudge "touch ${elsewhere}/ran; cat"
+ echo '* filter=x' > "$T"/x/sub/.git/info/attributes
+ git -C "$T"/x/sub update-ref --no-deref HEAD ${gitFirst}
+ git -C "$T"/x/sub read-tree ${gitFirst}
+ rm "$T"/x/sub/two
+ '';
+ }
+ {
+ sources = git false;
+ expect.fail = "error: populate doesn't use the Git work tree";
+ check = untouched;
+ mutate = ''
+ rm -fR "$T"/x/sub
+ mkdir "$T"/x/sub
+ echo gitdir: ${elsewhere}/repo/.git > "$T"/x/sub/.git
+ '';
+ }
+ {
+ sources = git false;
+ expect.fail = "error: populate doesn't use the Git work tree";
+ check = untouched;
+ }
+ {
+ sources = git true;
+ check = untouched + ''
+ expect_eq "sub HEAD" "$(git -C "$T"/x/sub rev-parse HEAD)" ${gitHead}
+ '';
+ }
+ ];
+ }))
+ # A ref that git fetch would take for an option: --upload-pack runs a
+ # command where the repository is local.
+ (s "git-ref-option" (let
+ elsewhere = "/var/krops-elsewhere";
+ in {
+ isolate = true;
+ prepare = ''
+ rm -fR ${elsewhere}
+ git clone -q --bare ${gitUrl} ${elsewhere}/repo.git
+ '';
+ phases = [
+ {
+ sources.x.git = {
+ url = "${elsewhere}/repo.git";
+ ref = "--upload-pack=touch ${elsewhere}/ran; git-upload-pack";
+ };
+ expect.fail = "invalid refspec";
+ check = ''
+ test ! -e ${elsewhere}/ran || fail "the ref ran a command"
+ '';
+ }
+ ];
+ }))
+ # populate exits with the status of the command that failed, here git
+ # fetch's.
+ (s "git-status" {
+ phases = [{
+ sources.x.git = { url = gitUrl; ref = "nonexistent"; };
+ expect = { fail = "couldn't find remote ref"; status = 128; };
+ }];
+ })
+ # A .git that a materialized flake got, and one that a file source
+ # keeps, which Nix uses for the git+file: URL of the flake there: writing
+ # its lock file, Nix runs Git, with that repository's configuration.
+ (s "lock-foreign-git" (let
+ elsewhere = "/var/krops-elsewhere";
+ sources = alt // {
+ x.flake-input = {
+ url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
+ materialize = true;
+ inputs.inner.follows = "alt";
+ };
+ y.file = {
+ path = "/fixtures/filesrc";
+ deleteExcluded = false;
+ filters = [ { type = "exclude"; pattern = "/.git"; } ];
+ inputs.dep.follows = "alt";
+ };
+ };
+ in {
+ isolate = true;
+ prepare = ''
+ rm -fR ${elsewhere}
+ mkdir -p ${elsewhere}
+ '';
+ phases = [
+ {
+ inherit sources;
+ # Repositories tracking only flake.nix, so the lock file changes.
+ mutate = ''
+ for name in x y; do
+ git -C "$T"/$name init -q
+ git -C "$T"/$name add flake.nix
+ git -C "$T"/$name -c user.name=x -c user.email=x@x commit -q -m x
+ git -C "$T"/$name config core.fsmonitor "touch ${elsewhere}/ran; false"
+ done
+ '';
+ }
+ {
+ inherit sources;
+ check = ''
+ test ! -e ${elsewhere}/ran || fail "a command in a .git directory ran"
+ '';
+ }
+ ];
+ }))
+ # A .git that a file source keeps, with configuration a git source would
+ # use, but a hook, which Nix's Git runs when it adds the lock file it
+ # writes: populate's Git runs none, so git sources keep hooks, but
+ # drawing inputs refers to the flake as path:.
+ (s "lock-hooks" (let
+ elsewhere = "/var/krops-elsewhere";
+ sources = alt // {
+ y.file = {
+ path = "/fixtures/filesrc";
+ deleteExcluded = false;
+ filters = [ { type = "exclude"; pattern = "/.git"; } ];
+ inputs.dep.follows = "alt";
+ };
+ };
+ in {
+ isolate = true;
+ prepare = ''
+ rm -fR ${elsewhere}
+ mkdir -p ${elsewhere}
+ '';
+ phases = [
+ {
+ inherit sources;
+ # A repository tracking only flake.nix, so Nix adds the lock file,
+ # which runs post-index-change.
+ mutate = ''
+ git -C "$T"/y init -q
+ git -C "$T"/y add flake.nix
+ git -C "$T"/y -c user.name=x -c user.email=x@x commit -q -m x
+ printf '#! /bin/sh\ntouch ${elsewhere}/ran\n' > "$T"/y/.git/hooks/post-index-change
+ chmod +x "$T"/y/.git/hooks/post-index-change
+ '';
+ }
+ {
+ inherit sources;
+ check = ''
+ test ! -e ${elsewhere}/ran || fail "a hook in a .git directory ran"
+ test -e "$T"/y/flake.lock || fail "y/flake.lock missing"
+ '';
+ }
+ ];
+ }))
+ # A file in populate's state replaced by a symlink to a file outside.
+ (s "state-key" (let
+ sources.x.flake-input = {
+ url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
+ patches = hello;
+ };
+ in {
+ isolate = true;
+ phases = [
+ {
+ inherit sources;
+ mutate = ''
+ rm "$T"/.populate/derived/x/key
+ ln -s ${outside}/file "$T"/.populate/derived/x/key
+ '';
+ }
+ {
+ inherit sources;
+ check = checkFlake { attr = "greeting"; expected = "hello+inner"; };
+ }
+ ];
+ }))
+ # Stale state that contains a hard link to a read-only file outside,
+ # whose mode stays.
+ (s "state-hard-link" (let
+ sources.x.flake-input = {
+ url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
+ patches = hello;
+ };
+ in {
+ isolate = true;
+ phases = [
+ {
+ inherit sources;
+ mutate = ''
+ mkdir -p "$T"/.populate/derived/gone "$T"/.populate/derived/x/work
+ ln ${outside}/dir/sub/file "$T"/.populate/derived/gone/file
+ ln ${outside}/dir/sub/file "$T"/.populate/derived/x/work/file
+ '';
+ }
+ {
+ inherit sources;
+ check = ''
+ test ! -e "$T"/.populate/derived/gone || fail "gone remains"
+ '';
+ }
+ ];
+ }))
+ # The source contains flake.lock.populate and a file like the temporary
+ # copy of the lock file, symlinks to a file outside.
+ (s "lock-temporary-link" {
+ isolate = true;
+ prepareDeployer = ''
+ rm -fR /var/krops-src/lock-temporary-link
+ mkdir -p /var/krops-src
+ cp -R /fixtures/filesrc /var/krops-src/lock-temporary-link
+ ln -s ${outside}/file /var/krops-src/lock-temporary-link/flake.lock.populate
+ ln -s ${outside}/file /var/krops-src/lock-temporary-link/.flake.lock.XXXXXX
+ '';
+ phases = twice {
+ sources = alt // {
+ x.file = {
+ path = "/var/krops-src/lock-temporary-link";
+ inputs.dep.follows = "alt";
+ };
+ };
+ check = ''
+ expect_eq msg "$(nix_eval_online path:"$T"/x#msg)" top:alt,sub
+ '';
+ };
+ })
+ # A file in the target directory is a hard link to a read-only file
+ # outside with the same contents, which rsync takes for unchanged, and
+ # would fix the mode and time of in place.
+ (s "rsync-hard-link" {
+ isolate = true;
+ prepareDeployer = ''
+ rm -fR /var/krops-src/rsync-hard-link
+ mkdir -p /var/krops-src/rsync-hard-link
+ echo outside > /var/krops-src/rsync-hard-link/file
+ '';
+ prepare = ''
+ mkdir -p "$T"/x
+ ln ${outside}/dir/sub/file "$T"/x/file
+ '';
+ phases = twice {
+ sources.x.file = {
+ path = "/var/krops-src/rsync-hard-link";
+ useChecksum = true;
+ };
+ check = ''
+ expect_eq file "$(cat "$T"/x/file)" outside
+ expect_eq links "$(stat -c %h "$T"/x/file)" 1
+ '';
+ };
+ })
+ (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";
+ refused = "krops: source name";
+ control = { ok.file = "/fixtures/alt"; };
+ })
+ [ "" "." ".." ".populate" "a/b" "../x" "/etc" "a\nb" "a b" "a*b" "a?b" "a+b" "a=b" "-a" ];
+ })
+ # 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";
+ refused =
+ if targetPath == "" then "the target's path is empty"
+ else "krops: target path";
+ controlTargetPath = "/var/krops/paths-local";
+ })
+ [ "" "/" "//" "/." "relative" "/var/krops/.." "/var/krops/../.." "/var/krops/a\nb" ];
+ })
+ ])
+ ]) targetKinds;
+
+}
diff --git a/pkgs/populate/tests/smoke.nix b/pkgs/populate/tests/smoke.nix
new file mode 100644
index 0000000..45510a0
--- /dev/null
+++ b/pkgs/populate/tests/smoke.nix
@@ -0,0 +1,21 @@
+# Names of the scenarios of the smoke test: a few covering each source type
+# and each kind of target. krops' smoke test runs them too.
+[
+ "file-local-checksum0-deleteExcluded0-filters0-inputsnone"
+ "file-sudo-checksum0-deleteExcluded0-filters1-inputsnone"
+ "git-remote-cleanExclude0-fetchAlways0-inputsnone-shallow0"
+ "git-local-shallow1-refs"
+ "symlink-local-absolute0-inputs0"
+ "pass-remote-storepass"
+ "passage-sudo-agedefault-dirstore"
+ "pipe-local"
+ "derivation-remote"
+ "flake-local-fetchOnTarget0-inputsrewrite-kindgit-materialize0"
+ "flake-sudo-fetchOnTarget1-inputsnone-kindgit-materialize0"
+ "flake-input-dep-remote-fetchOnTarget0-inputsgenerated-materialize0"
+ "flake-input-sub-local-fetchOnTarget0-materialize0"
+ "state-sentinel-local"
+ "prune-remote"
+ "names-local"
+ "paths-local"
+]
diff --git a/tests/vm.nix b/pkgs/populate/tests/vm.nix
index 8a08819..dc599dd 100644
--- a/tests/vm.nix
+++ b/pkgs/populate/tests/vm.nix
@@ -1,7 +1,8 @@
-# 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, rsync ? pkgs.rsync }:
+# 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;
@@ -11,7 +12,7 @@ let
script = name: text: if text == null then null else pkgs.writeText name text;
# The scenarios as JSON, with scripts as files.
- plan = pkgs.writeText "krops-test-${name}.json" (builtins.toJSON (map
+ plan = pkgs.writeText "${name}.json" (builtins.toJSON (map
(scenario: scenario // {
prepare = pkgs.writeText "prepare" scenario.prepare;
canarySetup = pkgs.writeText "canary-setup" scenario.canarySetup;
@@ -29,6 +30,7 @@ let
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}" ];
@@ -36,10 +38,7 @@ let
environment.systemPackages = [
pkgs.git
rsync
- (pkgs.writeScriptBin "nixos-rebuild" (builtins.readFile ./nixos-rebuild.sh))
];
- # The stand-in replaces nixos-rebuild.
- system.disableInstallerTools = true;
# 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 =
@@ -56,7 +55,7 @@ let
};
in pkgs.testers.runNixOSTest {
- name = "krops-${name}";
+ inherit name;
globalTimeout = 4 * 60 * 60;
diff --git a/tests/default.nix b/tests/default.nix
index 0e59e26..475ab5d 100644
--- a/tests/default.nix
+++ b/tests/default.nix
@@ -1,88 +1,118 @@
-# NixOS VM tests for populate: one test per source type, one for features
-# 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.
-# The exhaustive tests add the combinations of options that no code path
-# tells apart from the ones the others cover; build them with nix-build tests
-# -A exhaustive, or one with, e.g., nix-build tests -A exhaustive.flake.
-# The smoke test runs a few scenarios covering each source type and each kind
-# of target in a single VM test, for checking changes quickly; build it with
-# nix-build tests -A smoke. nix-build tests -A only --argstr only REGEX runs
-# the scenarios whose names match the regular expression REGEX in a single
-# VM test, e.g. --argstr only 'lock-hooks-.*'.
+# NixOS VM tests for krops, with the VM machinery and the scenarios of
+# populate's tests, in ../pkgs/populate/tests: deploy, for writeDeploy's
+# arguments to nixos-rebuild, and populate, for pkgs.populate, running
+# populate's scenarios that expect populate to refuse its arguments, which
+# pkgs.populate does during evaluation here, and the scenarios of
+# populate's smoke test, with target attribute sets as mkTarget returns
+# them. Build all with nix-build tests, or one with, e.g., nix-build tests
+# -A deploy. The exhaustive tests add the combinations of options that no
+# code path tells apart from the ones the others cover; build them with
+# nix-build tests -A exhaustive. The smoke test runs a few scenarios of
+# each in a single VM test; build it with nix-build tests -A smoke.
+# nix-build tests -A only --argstr only REGEX runs the scenarios whose
+# names match the regular expression REGEX, of all of populate's scenarios
+# and krops' own, in a single VM test, e.g. --argstr only 'lock-hooks-.*'.
+# populate's own tests run the program directly; see
+# ../pkgs/populate/README.md.
{ pkgs ? import ../pkgs {}, only ? null }:
let
- # HEAD of /fixtures/gitsrc, which fixtures.sh creates deterministically,
- # and its parent. fixtures.sh fails if HEAD differs, naming the actual one.
- gitHead = "e42d44ef9cdb76ba5a84dff4c89c38222703bf0c";
- gitFirst = "8d47013fa0888948cf0ffcc4e3c69a7b5f180562";
+ inherit (pkgs) lib;
+ krops = import ../lib;
+ populateTests = ../pkgs/populate/tests;
- # 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 {});
+ inherit (import (populateTests + "/fixtures.nix")) gitHead gitFirst;
- # The scenarios, with populate using rsync.
- scenariosWith = rsync: let
- populate = pkgs.populate.override { inherit rsync; };
- in import ./scenarios.nix {
- inherit (pkgs) lib;
- krops = import ../lib;
- inherit populate;
- inherit (pkgs.krops.override { inherit populate; }) writeDeploy;
+ remoteHost = "target";
+
+ # Targets of each kind for a path, as mkTarget returns them.
+ targets = {
+ local = path: krops.mkTarget "" // { host = "localhost"; inherit path; };
+ remote = path: {
+ user = "root"; host = remoteHost; port = null; inherit path;
+ sudo = false; extraOptions = [];
+ };
+ sudo = path: {
+ user = "deploy"; host = remoteHost; port = null; inherit path;
+ sudo = true; extraOptions = [];
+ };
+ };
+
+ # A phase runs pkgs.populate, or writeDeploy with a stand-in for
+ # nixos-rebuild.
+ run = {
+ source, backup, force, prune, deploy, targetKind, targetPath,
+ }: let
+ args = {
+ inherit source backup force prune;
+ target = targets.${targetKind} targetPath;
+ };
+ in
+ if deploy == null then pkgs.populate args
+ else pkgs.krops.writeDeploy "deploy"
+ (args // { useNixOutputMonitor = false; } // deploy);
+
+ builderArgs = {
+ inherit lib run;
+ inherit (krops) evalSource;
+ checksDuringEvaluation = true;
url = "http://deployer:8000";
gitUrl = "git://deployer/gitsrc.git";
- remoteHost = "target";
+ inherit remoteHost;
agePath = "${pkgs.age}/bin/age";
inherit gitHead gitFirst;
- rsyncVersion = rsync.version;
+ rsyncVersion = pkgs.rsync.version;
+ };
+
+ populateScenarios = lib.concatLists (builtins.attrValues
+ (import (populateTests + "/scenarios.nix") builderArgs));
+ kropsScenarios = import ./scenarios.nix (builderArgs // { inherit targets; });
+
+ # Names of the scenarios of the smoke test, besides populate's.
+ smokeNames = import (populateTests + "/smoke.nix") ++ [
+ "deploy-local-buildTarget0-etcFlake0-flakefile"
+ "deploy-sudo-allocateTTY"
+ ];
+
+ scenarios = {
+ inherit (kropsScenarios) deploy;
+ populate = builtins.filter
+ (scenario: scenario.refusals || builtins.elem scenario.name smokeNames)
+ populateScenarios;
+ };
+ allScenarios = populateScenarios ++ lib.concatLists (builtins.attrValues kropsScenarios);
+
+ # The stand-in for nixos-rebuild replaces it on both machines.
+ module = { pkgs, ... }: {
+ environment.systemPackages = [
+ (pkgs.writeScriptBin "nixos-rebuild" (builtins.readFile ./nixos-rebuild.sh))
+ ];
+ system.disableInstallerTools = true;
};
- scenarios = scenariosWith pkgs.rsync;
+
+ vm = name: scenarios: import (populateTests + "/vm.nix") {
+ inherit pkgs gitHead scenarios module;
+ name = "krops-${name}";
+ };
+
# VM tests of those of the groups of scenarios that are, or aren't,
# exhaustive, named after the groups with prefix in front.
- testsOf = exhaustive: prefix: pkgs.lib.mapAttrs
- (name: scenarios: import ./vm.nix {
- inherit pkgs gitHead scenarios;
- name = prefix + name;
- })
- (pkgs.lib.filterAttrs (_: scenarios: scenarios != [])
+ testsOf = exhaustive: prefix: lib.mapAttrs
+ (name: scenarios: vm (prefix + name) scenarios)
+ (lib.filterAttrs (_: scenarios: scenarios != [])
(builtins.mapAttrs
(_: builtins.filter (scenario: scenario.exhaustive == exhaustive))
scenarios));
- # Names of the scenarios of the smoke test.
- smokeNames = [
- "file-local-checksum0-deleteExcluded0-filters0-inputsnone"
- "file-sudo-checksum0-deleteExcluded0-filters1-inputsnone"
- "git-remote-cleanExclude0-fetchAlways0-inputsnone-shallow0"
- "git-local-shallow1-refs"
- "symlink-local-absolute0-inputs0"
- "pass-remote-storepass"
- "passage-sudo-agedefault-dirstore"
- "pipe-local"
- "derivation-remote"
- "flake-local-fetchOnTarget0-inputsrewrite-kindgit-materialize0"
- "flake-sudo-fetchOnTarget1-inputsnone-kindgit-materialize0"
- "flake-input-dep-remote-fetchOnTarget0-inputsgenerated-materialize0"
- "flake-input-sub-local-fetchOnTarget0-materialize0"
- "state-sentinel-local"
- "prune-remote"
- "names-local"
- "paths-local"
- "deploy-local-buildTarget0-etcFlake0-flakefile"
- "deploy-sudo-allocateTTY"
- ];
- allScenarios = pkgs.lib.concatLists (builtins.attrValues scenarios);
smokeScenarios = let
selected = builtins.filter (scenario: builtins.elem scenario.name smokeNames) allScenarios;
- missing = pkgs.lib.subtractLists (map (scenario: scenario.name) selected) smokeNames;
+ missing = lib.subtractLists (map (scenario: scenario.name) selected) smokeNames;
in
if missing != [] then
throw "tests: smoke test names missing scenarios: ${toString missing}"
else
selected;
+
# The scenarios whose names match the regular expression only.
onlyScenarios = let
selected = builtins.filter
@@ -96,32 +126,16 @@ let
else
selected;
in
+
+# The remote target must not count as local wherever this gets evaluated.
+assert !(krops.isLocalTarget (targets.remote "/"));
+
testsOf false ""
# Not built by nix-build tests either, which runs these scenarios anyway.
// {
- smoke.smoke = import ./vm.nix {
- inherit pkgs gitHead;
- name = "smoke";
- scenarios = smokeScenarios;
- };
- only.only = import ./vm.nix {
- inherit pkgs gitHead;
- name = "only";
- scenarios = onlyScenarios;
- };
+ smoke.smoke = vm "smoke" smokeScenarios;
+ only.only = vm "only" onlyScenarios;
}
# An attribute set without recurseForDerivations, so nix-build tests
# doesn't build them.
// { exhaustive = testsOf true "exhaustive-"; }
- // 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
diff --git a/tests/scenarios.nix b/tests/scenarios.nix
index 92f94fd..ad4617d 100644
--- a/tests/scenarios.nix
+++ b/tests/scenarios.nix
@@ -1,723 +1,13 @@
-# Test scenarios for populate: every combination of each source type's
-# options, deployed to the local machine, to a remote target as root, and to
-# a remote target using sudo, plus scenarios for features spanning sources.
-#
-# A scenario prepares the target directory, then runs its phases. Each phase
-# runs populate, or writeDeploy with a stand-in for nixos-rebuild, expecting
-# it to succeed or to fail with a message, and checks the result on the
-# target. Phases expecting evaluation to fail get checked here and don't run.
-{
- # nixpkgs' lib.
- lib,
- # krops' lib.
- krops,
- # Function from populate's arguments to the command running populate.
- populate,
- # krops' writeDeploy.
- writeDeploy,
- # Where the deploying machine serves /fixtures/www via HTTP.
- url,
- # URL of the Git repository /fixtures/git/gitsrc.git.
- gitUrl,
- # Host name of the remote target.
- remoteHost,
- # Absolute path of age on the deploying machine.
- agePath,
- # HEAD of /fixtures/gitsrc, and its parent.
- gitHead,
- gitFirst,
- # Version of rsync on the deploying machine.
- rsyncVersion,
-}:
-
-let
- inherit (lib)
- attrNames concatMap concatStringsSep foldl' isBool mapAttrsToList
- optional optionalAttrs optionals optionalString;
-
- prelude = ". ${./lib.sh}\n";
-
- cartesian = attrs: foldl'
- (acc: name: concatMap
- (combination: map
- (value: combination // { ${name} = value; })
- attrs.${name})
- acc)
- [{}]
- (attrNames attrs);
-
- flagsName = flags: concatStringsSep "-" (mapAttrsToList
- (name: value: name + (if isBool value then (if value then "1" else "0") else value))
- flags);
-
- targets = {
- local = path: krops.mkTarget "" // { host = "localhost"; inherit path; };
- remote = path: {
- user = "root"; host = remoteHost; port = null; inherit path;
- sudo = false; extraOptions = [];
- };
- sudo = path: {
- user = "deploy"; host = remoteHost; port = null; inherit path;
- sudo = true; extraOptions = [];
- };
- };
-
- alt = { alt.file = "/fixtures/alt"; };
-
- # A path on a target of kind targetKind, as populate names it in what it
- # prints.
- targetName = targetKind: path: {
- local = "";
- remote = "${remoteHost}:";
- sudo = "deploy@${remoteHost}:";
- }.${targetKind} + path;
-
- # How a source draws inputs from other sources: not at all, by rewriting
- # the lock file of its flake, or by a generated flake.
- inputModes = [ "none" "rewrite" "generated" ];
-
- # The options of a source drawing inputs in mode.
- drawInputs = mode: inputs: optionalAttrs (mode != "none") ({
- inherit inputs;
- } // optionalAttrs (mode == "generated") {
- rewriteLock = false;
- });
-
- # The flake of source x that has its inputs drawn in mode.
- flakeOf = mode:
- if mode == "generated" then ''"$T"/.populate/flakes/x'' else ''"$T"/x'';
-
- # Builds a scenario from a name, a target kind, and phases with sources.
- # Isolated scenarios start with no target directories and a collected store
- # on both machines, so no other scenario's store paths or roots stand in for
- # missing ones. An exhaustive scenario covers a combination that no code
- # path tells apart from the ones the other scenarios cover, so it only runs
- # in the exhaustive tests.
- scenario = {
- name, targetKind, node ? null, isolate ? false, exhaustive ? false,
- prepare ? "", prepareDeployer ? null, phases,
- }: let
- path = "/var/krops/${name}";
- target = targets.${targetKind} path;
- vars = "T=${path}\n";
- # A phase expecting evaluation to fail has a control: sources that
- # 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. 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. output lists what a
- # succeeding populate is expected to print, and unexpected what populate
- # is expected not to print, succeeding or not. expect.fail is what a
- # failing populate is expected to print, and expect.status, if given,
- # its exit status.
- phase = {
- sources, backup ? false, prune ? false, deploy ? null,
- targetPath ? path, env ? "", interrupt ? null, output ? [],
- unexpected ? [],
- expect ? "success", control ? null, controlTargetPath ? null,
- check ? "", mutate ? null, checkDeployer ? null, mutateDeployer ? null,
- }: let
- command = sources: targetPath: let
- args = {
- source = krops.evalSource [ sources ];
- 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: 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 && controlTargetPath == null then
- throw "scenario ${name}: expecting evaluation to fail needs a control"
- else if evaluates sources targetPath then
- throw "scenario ${name}: populate evaluates, but is expected to fail"
- 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 targetPath);
- inherit env interrupt output unexpected;
- expect = if expect == "success" then null else expect.fail;
- status = if expect == "success" then null else expect.status or null;
- # No phase gets around checking the canaries.
- check = script (check + "\ncanary_check\n");
- mutate = script mutate;
- checkDeployer = script checkDeployer;
- mutateDeployer = script mutateDeployer;
- };
- in {
- inherit name isolate exhaustive;
- node =
- if node != null then node
- else if targetKind == "local" then "deployer"
- else "target";
- prepare = prelude + vars + ''
- rm -fR "$T"
- '' + 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);
- };
+# Test scenarios for krops, besides populate's, which run through
+# pkgs.populate here: writeDeploy's arguments to nixos-rebuild, which a
+# stand-in records. The arguments are those of populate's builder.nix,
+# and targets, which give the target of each kind for a path.
+{ targets, ... }@args:
- # Same sources twice, so the second phase checks redeploying.
- twice = phase: [ phase phase ];
-
- targetKinds = [ "local" "remote" "sudo" ];
-
- # Scenarios for all combinations of options and target kinds. f gets the
- # options and the scenario's name, target kind, and target directory, and
- # returns the scenario's arguments, or null for invalid combinations.
- matrix = type: options: f:
- concatMap
- (targetKind: lib.filter (x: x != null) (map
- (flags: let
- name = "${type}-${targetKind}" +
- optionalString (flags != {}) "-${flagsName flags}";
- args = f flags { inherit name targetKind; path = "/var/krops/${name}"; };
- in
- if args == null then null
- else scenario ({ inherit name targetKind; } // args))
- (cartesian options)))
- targetKinds;
-
- # Checks that the source at $T/x is a link to a store path, or to the
- # subdirectory sub therein, or a writable directory if materialized, and
- # evaluates the attribute attr of the flake there, or of its generated
- # flake, after collecting garbage.
- checkFlake = {
- attr, expected, subdir ? false, generated ? false, materialized ? false,
- }: (if materialized then ''
- test -d "$T"/x && ! test -L "$T"/x || fail "x isn't a directory"
- test -w "$T"/x/flake.nix || fail "flake.nix isn't writable"
- '' else ''
- link=$(readlink "$T"/x)
- ${if subdir then ''
- expect_eq subdirectory "$(basename "$link")" sub
- link=$(dirname "$link")
- '' else ""}
- expect_eq "store directory" "$(dirname "$link")" /nix/store
- '') + ''
- test ! -e "$T"/.populate/flake-uploads || fail "upload directory remains"
- gc
- expect_eq ${attr} "$(nix_eval ${
- if generated then ''"$T"/.populate/flakes/x'' else ''"$T"/x''
- }#${attr})" ${lib.escapeShellArg expected}
- '';
-
- copyingWithSudo = flags: ctx:
- ctx.targetKind == "sudo" && !(flags.fetchOnTarget or false);
-
- # The sources with fetchOnTarget set for all flake and flake-input sources,
- # the control for copying flakes to targets using sudo.
- withFetchOnTarget = lib.mapAttrs (_: lib.mapAttrs (type: options:
- if lib.elem type [ "flake" "flake-input" ]
- then options // { fetchOnTarget = true; }
- else options));
-
- # A phase copying flakes to a target using sudo, which isn't supported,
- # and gets refused during evaluation.
- refuseCopyingWithSudo = {
- expect = "eval-error";
- control = withFetchOnTarget;
- };
-
-in
-
-# The remote target must not count as local wherever this gets evaluated.
-assert !(krops.isLocalTarget (targets.remote "/"));
+with import ../pkgs/populate/tests/builder.nix (removeAttrs args [ "targets" ]);
{
- derivation = matrix "derivation" {} (_: _: {
- phases = twice {
- sources.x.derivation = ''
- derivation {
- name = "krops-test";
- system = builtins.currentSystem;
- builder = "/bin/sh";
- args = [ "-c" "echo built > $out" ];
- }
- '';
- check = ''
- gc
- expect_eq x "$(cat "$T"/x)" built
- '';
- };
- });
-
- file = let
- f = flags: _: {
- # Drawing inputs happens after rsync, so it doesn't depend on rsync's
- # options.
- exhaustive = flags.inputs != "none" &&
- (flags.checksum || flags.deleteExcluded || flags.filters);
- prepare = ''
- mkdir -p "$T"/x
- echo old > "$T"/x/skip-ex
- echo old > "$T"/x/skip-f
- echo stale > "$T"/x/stale
- echo BBBB > "$T"/x/same
- touch -d @1000000000 "$T"/x/same
- '';
- phases = twice {
- sources = {
- x.file = {
- path = "/fixtures/filesrc";
- useChecksum = flags.checksum;
- inherit (flags) deleteExcluded;
- exclude = optional (flags.exclude or false) "skip-ex";
- filters = optional flags.filters { type = "exclude"; pattern = "skip-f"; };
- } // drawInputs flags.inputs { dep.follows = "alt"; };
- } // optionalAttrs (flags.inputs != "none") alt;
- check = let
- excluded = file: set: ''
- ${if !set then ''
- expect_eq ${file} "$(cat "$T"/x/${file})" src
- '' else if flags.deleteExcluded then ''
- test ! -e "$T"/x/${file} || fail "${file} not deleted"
- '' else ''
- expect_eq ${file} "$(cat "$T"/x/${file})" old
- ''}
- '';
- in ''
- expect_eq keep "$(cat "$T"/x/keep)" keep
- ${excluded "skip-ex" (flags.exclude or false)}
- ${excluded "skip-f" flags.filters}
- ${if flags.deleteExcluded then ''
- test ! -e "$T"/x/stale || fail "stale not deleted"
- '' else ''
- expect_eq stale "$(cat "$T"/x/stale)" stale
- ''}
- expect_eq same "$(cat "$T"/x/same)" ${if flags.checksum then "AAAA" else "BBBB"}
- '' + optionalString (flags.inputs != "none") ''
- # Inputs not drawn from sources aren't delivered, so this needs the
- # network and can't collect garbage before.
- expect_eq msg "$(nix_eval_online ${flakeOf flags.inputs}#msg)" top:alt,sub
- '';
- };
- };
- in
- matrix "file" {
- checksum = [ false true ];
- deleteExcluded = [ false true ];
- filters = [ false true ];
- inputs = inputModes;
- } f
- ++ [
- # The deprecated exclude option, once, because it warns.
- (scenario (let
- args = f {
- checksum = false;
- deleteExcluded = true;
- exclude = true;
- filters = false;
- inputs = "none";
- } null;
- in args // {
- name = "file-local-exclude";
- targetKind = "local";
- phases = [ (builtins.head args.phases) ];
- }))
- ]
- # A source whose .rsync-filter rsync 3.5.0 and later can't read, because
- # it's reached through a symlink that neither root nor the user deploying
- # owns. (Running as root, the scenarios can't use the other common
- # cause, a directory on the way that the user may enter but not read.)
- ++ concatMap (targetKind: map (insecureLinks: let
- affected = lib.versionAtLeast rsyncVersion "3.5.0";
- excluded = !affected || insecureLinks == true;
- in scenario {
- name = "file-${targetKind}-insecureLinks" +
- (if insecureLinks == null then "Null" else if insecureLinks then "True" else "False");
- inherit targetKind;
- prepare = ''
- mkdir -p "$T"/x
- echo old > "$T"/x/garbage
- '';
- phases = twice ({
- sources.x.file = { path = "/fixtures/filtered-link"; inherit insecureLinks; };
- } // (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 ''
- test ! -e "$T"/x/garbage || fail "garbage not excluded"
- '' else ''
- expect_eq garbage "$(cat "$T"/x/garbage)" garbage
- ''}
- '';
- }));
- }) [ null true false ]) targetKinds
- ++ concatMap (targetKind: let
- affected = lib.versionAtLeast rsyncVersion "3.5.0";
- unreadable = name: { path, file, dir ? "x", env ? "" }: scenario {
- name = "file-${targetKind}-insecureLinks${name}";
- inherit targetKind;
- prepare = ''
- mkdir -p "$T"/${dir}
- echo old > "$T"/${dir}/garbage
- '';
- phases = twice ({
- sources.x.file = { inherit path; };
- inherit env;
- } // (if affected then {
- expect.fail = "can't read ${file}, so it would sync ${path}/ without it.";
- } else {
- check = ''
- expect_eq keep "$(cat "$T"/${dir}/keep)" keep
- test ! -e "$T"/${dir}/garbage || fail "garbage not excluded"
- '';
- }));
- };
- in [
- # The filter file is in the directory above what gets synced, where it
- # applies as well.
- (unreadable "Parent" {
- path = "/fixtures/filtered-parent-link/sub";
- file = "/fixtures/filtered-parent-link/.rsync-filter";
- })
- # The filter file is in a directory whose name rsync prints escaped
- # in the C locale.
- (unreadable "Name" {
- path = "/fixtures/filtered-name-link";
- file = "/fixtures/filtered-name-link/bär/.rsync-filter";
- dir = "x/bär";
- env = "LC_ALL=C";
- })
- ]) targetKinds;
-
- flake = matrix "flake" {
- kind = [ "git" "path" "subdir" "remote" ];
- fetchOnTarget = [ false true ];
- inputs = inputModes;
- materialize = [ false true ];
- } (flags: ctx: let
- flake = {
- git = "/fixtures/top";
- path = "path:/fixtures/top";
- subdir = "git+file:/fixtures/mono?dir=sub";
- remote = "${url}/remote.tar.gz";
- }.${flags.kind};
- msg = {
- git = "top:${if flags.inputs != "none" then "alt" else "dep+inner"},sub";
- path = msg.git;
- remote = "remote:${if flags.inputs != "none" then "alt" else "dep+inner"}";
- };
- in
- # The flake in a subdirectory has no inputs to draw from sources.
- if flags.kind == "subdir" && flags.inputs != "none" then null else {
- isolate = true;
- # Materializing copies what got delivered, whichever way, so it depends
- # on how inputs get drawn and on subdirectories, not on how the flake got
- # there.
- exhaustive = flags.materialize && !lib.elem flags.kind [ "git" "subdir" ];
- phases = twice ({
- sources = {
- x.flake = {
- url = flake;
- inherit (flags) fetchOnTarget materialize;
- } // drawInputs flags.inputs { dep.follows = "alt"; };
- } // optionalAttrs (flags.inputs != "none") alt;
- } // (if copyingWithSudo flags ctx then refuseCopyingWithSudo else {
- check = if flags.kind == "subdir" then checkFlake {
- attr = "who"; expected = "sub"; subdir = true;
- materialized = flags.materialize;
- } else checkFlake {
- attr = "msg"; expected = msg.${flags.kind};
- generated = flags.inputs == "generated";
- materialized = flags.materialize;
- };
- }));
- });
-
- flake-input = concatMap
- (name: matrix "flake-input-${lib.replaceStrings ["/"] ["."] name}" ({
- fetchOnTarget = [ false true ];
- materialize = [ false true ];
- } // optionalAttrs (name == "dep") {
- inputs = inputModes;
- }) (flags: ctx: let
- inputs = flags.inputs or "none";
- in {
- isolate = true;
- # Materializing copies what got delivered, whichever input it is.
- exhaustive = flags.materialize && name != "dep";
- phases = twice ({
- sources = {
- x.flake-input = {
- url = "/fixtures/top";
- inherit name;
- inherit (flags) fetchOnTarget materialize;
- } // drawInputs inputs { inner.follows = "alt"; };
- } // optionalAttrs (inputs != "none") alt;
- } // (if copyingWithSudo flags ctx then refuseCopyingWithSudo else {
- check = {
- dep = checkFlake {
- attr = "greeting";
- expected = if inputs != "none" then "dep+alt" else "dep+inner";
- generated = inputs == "generated";
- materialized = flags.materialize;
- };
- "dep/inner" = checkFlake {
- attr = "who"; expected = "inner"; materialized = flags.materialize;
- };
- sub = checkFlake {
- attr = "who"; expected = "sub"; subdir = true;
- materialized = flags.materialize;
- };
- }.${name};
- }));
- }))
- [ "dep" "dep/inner" "sub" ];
-
- git = matrix "git" {
- shallow = [ false true ];
- fetchAlways = [ false true ];
- cleanExclude = [ false true ];
- inputs = inputModes;
- } (flags: _: let
- check = ''
- expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitHead}
- expect_eq commits "$(git -C "$T"/x rev-list --count HEAD)" ${
- if flags.shallow then "1" else "2"
- }
- test -e "$T"/x/two || fail "two missing"
- '' + optionalString (flags.inputs == "generated") ''
- grep -q '"git+file:' "$T"/.populate/flakes/x/flake.nix ||
- fail "the Git work tree isn't referred to as git+file:"
- '' + optionalString (flags.inputs != "none") ''
- gc
- expect_eq msg "$(nix_eval ${flakeOf flags.inputs}#msg)" git:alt
- '';
- sources = ref: {
- x.git = {
- url = gitUrl;
- inherit ref;
- inherit (flags) shallow fetchAlways;
- clean.exclude = optional flags.cleanExclude "keepme";
- } // drawInputs flags.inputs { dep.follows = "alt"; };
- } // optionalAttrs (flags.inputs != "none") alt;
- in {
- # Drawing inputs happens after Git, so it doesn't depend on Git's
- # options.
- exhaustive = flags.inputs != "none" &&
- (flags.shallow || flags.fetchAlways || flags.cleanExclude);
- phases = [
- # The older commit first, so deploying HEAD changes the work tree, and
- # fetches if fetchAlways is set or the clone is shallow.
- {
- sources = sources gitFirst;
- check = ''
- expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitFirst}
- expect_eq commits "$(git -C "$T"/x rev-list --count HEAD)" 1
- test ! -e "$T"/x/two || fail "two present"
- '';
- }
- {
- sources = sources gitHead;
- inherit check;
- mutate = ''
- echo junk > "$T"/x/junk
- echo keepme > "$T"/x/keepme
- '';
- }
- {
- sources = sources gitHead;
- check = check + ''
- test ! -e "$T"/x/junk || fail "junk not cleaned"
- ${if flags.cleanExclude then ''
- test -e "$T"/x/keepme || fail "keepme cleaned"
- '' else ''
- test ! -e "$T"/x/keepme || fail "keepme not cleaned"
- ''}
- '';
- }
- ];
- })
- ++ 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
- # Branches and tags follow the server: a branch that moves between two
- # deploys, and a tag, with full and shallow clones.
- ++ concatMap (targetKind: map (shallow: scenario {
- name = "git-${targetKind}-shallow${if shallow then "1" else "0"}-refs";
- inherit targetKind;
- prepareDeployer = ''
- git -C /fixtures/git/gitsrc.git update-ref refs/heads/moving ${gitFirst}
- git -C /fixtures/git/gitsrc.git update-ref refs/tags/first ${gitFirst}
- '';
- phases = [
- {
- sources.x.git = { url = gitUrl; ref = "moving"; inherit shallow; };
- check = ''
- expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitFirst}
- '';
- mutateDeployer = ''
- git -C /fixtures/git/gitsrc.git update-ref refs/heads/moving ${gitHead}
- '';
- }
- {
- sources.x.git = { url = gitUrl; ref = "moving"; inherit shallow; };
- check = ''
- expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitHead}
- '';
- }
- {
- sources.x.git = { url = gitUrl; ref = "first"; inherit shallow; };
- check = ''
- expect_eq HEAD "$(git -C "$T"/x rev-parse HEAD)" ${gitFirst}
- '';
- }
- ];
- }) [ false true ]) targetKinds
-
- # A git source stops drawing inputs: its lock file, which Nix created and
- # staged, goes, so the work tree is as the commit has it.
- ++ map (targetKind: scenario {
- name = "git-${targetKind}-dropInputs";
- inherit targetKind;
- phases = [
- {
- sources = alt // {
- x.git = { url = gitUrl; ref = gitHead; inputs.dep.follows = "alt"; };
- };
- check = ''
- expect_eq msg "$(nix_eval_online git+file:"$T"/x#msg)" git:alt
- '';
- }
- {
- sources = alt // { x.git = { url = gitUrl; ref = gitHead; }; };
- check = ''
- expect_eq status "$(git -C "$T"/x status --porcelain)" ""
- '';
- }
- ];
- }) targetKinds;
-
- pass = matrix "pass" {
- store = [ "pass" "pass-git" ];
- } (flags: _: {
- phases = twice {
- sources.x.pass = { dir = "/fixtures/${flags.store}"; name = "x"; };
- check = ''
- expect_eq a "$(cat "$T"/x/a)" secret-a
- '';
- };
- });
-
- passage = matrix "passage" {
- age = [ "default" "path" ];
- dir = [ "store" "subdir" ];
- } (flags: _: {
- phases = twice {
- sources.x.passage = {
- dir = "/fixtures/passage/store" + optionalString (flags.dir == "subdir") "/x";
- identities_file = "/fixtures/passage/identities";
- } // optionalAttrs (flags.age == "path") {
- age = agePath;
- };
- check = ''
- expect_eq a "$(cat "$T"/x/${optionalString (flags.dir == "store") "x/"}a)" secret-a
- '';
- };
- });
-
- pipe = matrix "pipe" {} (_: _: {
- phases = twice {
- sources.x.pipe = "/fixtures/pipe";
- check = ''
- expect_eq x "$(cat "$T"/x)" piped
- '';
- };
- });
-
- symlink = matrix "symlink" {
- absolute = [ false true ];
- inputs = [ false true ];
- } (flags: ctx: let
- target = optionalString flags.absolute "${ctx.path}/" + "topcopy";
- in {
- phases = twice {
- sources = {
- x.symlink = {
- inherit target;
- inputs = optionalAttrs flags.inputs { dep.follows = "alt"; };
- };
- topcopy.file = "/fixtures/filesrc";
- } // optionalAttrs flags.inputs alt;
- check = ''
- expect_eq link "$(readlink "$T"/x)" ${target}
- test -e "$T"/x/flake.nix || fail "flake.nix missing"
- '' + optionalString flags.inputs ''
- expect_eq msg "$(nix_eval_online "$T"/.populate/flakes/x#msg)" top:alt,sub
- '';
- };
- });
-
# writeDeploy's arguments to nixos-rebuild, which a stand-in records.
# flake is what gets deployed: nothing, or a flake from a file, git, or
# flake source, or one whose inputs get drawn from a source, by rewriting
@@ -815,1689 +105,4 @@ assert !(krops.isLocalTarget (targets.remote "/"));
}];
}) targetKinds;
- # Features spanning several sources.
- features = concatMap (targetKind: let
- s = name: args: scenario ({
- name = "${name}-${targetKind}";
- inherit targetKind;
- isolate = true;
- } // args);
- copying = targetKind != "sudo";
- fetchOnTarget = !copying;
- # A scenario for each way of drawing inputs, which f gets as mode, along
- # with draw, which adds inputs drawn that way to a source's options, and
- # flake, which gives the flake to evaluate for a source.
- modes = name: f: map
- (mode: s "${name}-${mode}" (f {
- draw = drawInputs mode;
- flake = n:
- if mode == "generated" then ''"$T"/.populate/flakes/${n}'' else ''"$T"/${n}'';
- inherit mode;
- }))
- [ "rewrite" "generated" ];
- in lib.flatten [
- # A source draws an input from a source that draws inputs itself.
- (modes "chain" ({ draw, flake, ... }: {
- phases = twice {
- sources = alt // {
- a.flake = { url = "/fixtures/top"; inherit fetchOnTarget; } // draw { dep.follows = "b"; };
- b.flake-input = {
- url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
- } // draw { inner.follows = "alt"; };
- };
- check = ''
- gc
- expect_eq msg "$(nix_eval ${flake "a"}#msg)" top:dep+alt,sub
- '';
- };
- }))
- # An input of an input drawn from a source gets drawn from another
- # source.
- (modes "nested" ({ draw, flake, ... }: {
- phases = twice {
- sources = alt // {
- x.flake = {
- url = "/fixtures/top"; inherit fetchOnTarget;
- } // draw { dep = { follows = "b"; inputs.inner.follows = "alt"; }; };
- b.flake-input = { url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; };
- };
- check = ''
- gc
- expect_eq msg "$(nix_eval ${flake "x"}#msg)" top:dep+alt,sub
- '';
- };
- }))
- # The lock file gets rewritten or generated again when a source inputs
- # get drawn from changes.
- (modes "relock" ({ draw, flake, ... }: let
- x.file = { path = "/fixtures/filesrc"; } // draw { dep.follows = "a"; };
- in {
- phases = [
- {
- sources = { inherit x; a.file = "/fixtures/alt"; };
- check = ''
- expect_eq msg "$(nix_eval_online ${flake "x"}#msg)" top:alt,sub
- '';
- }
- {
- sources = { inherit x; a.file = "/fixtures/alt2"; };
- check = ''
- expect_eq msg "$(nix_eval_online ${flake "x"}#msg)" top:alt2,sub
- '';
- }
- ];
- }))
- # What drawing inputs needs gets removed once a source draws no inputs
- # anymore.
- (modes "removal" ({ draw, mode, ... }: let
- state = {
- rewrite = "derived/x/result";
- generated = "flakes/x/flake.lock";
- }.${mode};
- in {
- phases = [
- {
- sources = alt // {
- x.flake = { url = "/fixtures/top"; inherit fetchOnTarget; } // draw { dep.follows = "alt"; };
- };
- check = ''
- test -e "$T"/.populate/${state} || fail "${state} missing"
- '';
- }
- {
- sources = alt // {
- x.flake = { url = "/fixtures/top"; inherit fetchOnTarget; };
- };
- check = ''
- test ! -e "$T"/.populate/${dirOf state} || fail "${dirOf state} remains"
- '' + checkFlake { attr = "msg"; expected = "top:dep+inner,sub"; };
- }
- ];
- }))
- # Patches get applied in order to a copy, which becomes a store path and
- # gets made again only when the patches change.
- (s "patches" (let
- x = patches: {
- x.flake-input = {
- url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget patches;
- };
- };
- both = x [ "/fixtures/patches/hello.patch" "/fixtures/patches/hello2.patch" ];
- in {
- phases = [
- {
- sources = both;
- check = checkFlake { attr = "greeting"; expected = "hello2+inner"; };
- # The key changes whenever the copy gets made again, which
- # the store path doesn't show, being the same.
- mutate = ''
- readlink "$T"/x > "$T".first
- stat -c %y "$T"/.populate/derived/x/key > "$T".key
- '';
- }
- {
- sources = both;
- check = checkFlake { attr = "greeting"; expected = "hello2+inner"; } + ''
- expect_eq "store path" "$(readlink "$T"/x)" "$(cat "$T".first)"
- expect_eq "copy made at" "$(stat -c %y "$T"/.populate/derived/x/key)" "$(cat "$T".key)"
- '';
- }
- {
- sources = x [ "/fixtures/patches/hello.patch" ];
- check = checkFlake { attr = "greeting"; expected = "hello+inner"; } + ''
- test "$(readlink "$T"/x)" != "$(cat "$T".first)" || fail "store path unchanged"
- expect_eq patches "$(ls "$T"/.populate/patches/x)" 0001-hello.patch
- '';
- }
- {
- sources = x [];
- check = checkFlake { attr = "greeting"; expected = "dep+inner"; } + ''
- test ! -e "$T"/.populate/patches || fail "patches remain"
- test ! -e "$T"/.populate/derived || fail "derived remains"
- '';
- }
- ];
- }))
- (s "patch-fails" {
- phases = [{
- sources.x.flake-input = {
- url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
- patches = [ "/fixtures/patches/bad.patch" ];
- };
- expect.fail = "error: patch bad.patch does not apply to source x";
- }];
- })
- # Patches apply in the subdirectory the target path shows.
- (s "patch-subdir" {
- phases = twice {
- sources.x.flake-input = {
- url = "/fixtures/top"; name = "sub"; inherit fetchOnTarget;
- patches = [ "/fixtures/patches/sub.patch" ];
- };
- check = checkFlake { attr = "who"; expected = "patched"; subdir = true; };
- };
- })
- # A copy with a rewritten lock file gets made again only when a source
- # it draws inputs from changes.
- (s "rewrite-cache" (let
- x.flake-input = {
- url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
- inputs.inner.follows = "a";
- };
- in {
- phases = [
- {
- sources = { inherit x; a.file = "/fixtures/alt"; };
- check = checkFlake { attr = "greeting"; expected = "dep+alt"; };
- # The key changes whenever the copy gets made again, which
- # the store path doesn't show, being the same.
- mutate = ''
- readlink "$T"/x > "$T".first
- stat -c %y "$T"/.populate/derived/x/key > "$T".key
- '';
- }
- {
- sources = { inherit x; a.file = "/fixtures/alt"; };
- check = checkFlake { attr = "greeting"; expected = "dep+alt"; } + ''
- expect_eq "store path" "$(readlink "$T"/x)" "$(cat "$T".first)"
- expect_eq "copy made at" "$(stat -c %y "$T"/.populate/derived/x/key)" "$(cat "$T".key)"
- '';
- }
- {
- sources = { inherit x; a.file = "/fixtures/alt2"; };
- check = checkFlake { attr = "greeting"; expected = "dep+alt2"; } + ''
- test "$(readlink "$T"/x)" != "$(cat "$T".first)" || fail "store path unchanged"
- '';
- }
- ];
- }))
- # A materialized input is a writable copy, patched and with a rewritten
- # lock file, which gets made again only when what it derives from
- # changes, and becomes a link again when not materialized anymore.
- (s "materialize" (let
- x = patches: alt // {
- x.flake-input = {
- url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget patches;
- materialize = true;
- inputs.inner.follows = "alt";
- };
- };
- hello = [ "/fixtures/patches/hello.patch" ];
- in {
- phases = [
- {
- sources = x hello;
- check = ''
- test ! -L "$T"/x || fail "x is a link"
- test -w "$T"/x/flake.nix || fail "flake.nix isn't writable"
- gc
- expect_eq greeting "$(nix_eval "$T"/x#greeting)" hello+alt
- '';
- mutate = ''
- echo local > "$T"/x/local
- '';
- }
- {
- sources = x hello;
- check = ''
- expect_eq local "$(cat "$T"/x/local)" local
- '';
- }
- {
- sources = x (hello ++ [ "/fixtures/patches/hello2.patch" ]);
- check = ''
- test ! -e "$T"/x/local || fail "local remains"
- gc
- expect_eq greeting "$(nix_eval "$T"/x#greeting)" hello2+alt
- '';
- }
- {
- sources = alt // {
- x.flake-input = { url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; };
- };
- check = checkFlake { attr = "greeting"; expected = "dep+inner"; } + ''
- test ! -e "$T"/.populate/derived || fail "derived remains"
- '';
- }
- ];
- }))
- # Roots follow the delivered input.
- (s "retarget" {
- phases = [
- {
- sources.x.flake-input = { url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; };
- check = ''
- expect_eq roots "$(ls "$T"/.populate/flake-inputs/x | wc -l)" 1
- '';
- }
- {
- 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; };
- }
- ];
- })
- # Pruning removes everything sources don't deliver anymore, including
- # garbage collector roots, and keeps everything else.
- (s "prune" (let
- sources = {
- a.file = "/fixtures/alt";
- b.flake = { url = "/fixtures/top"; inherit fetchOnTarget; };
- c.flake-input = { url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; };
- d.symlink = "a";
- };
- in {
- prepare = ''
- mkdir -p "$T"
- echo other > "$T"/other
- '';
- phases = [
- {
- inherit sources;
- check = ''
- test -e "$T"/.populate/flake-inputs/b || fail "roots of b missing"
- test -e "$T"/.populate/flake-inputs/c || fail "roots of c missing"
- '';
- }
- {
- # Without pruning, nothing gets removed.
- sources = { inherit (sources) a; };
- check = ''
- for name in b c d other .populate/flake-inputs/b .populate/flake-inputs/c; do
- 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 '${targetName targetKind "/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 .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
- fail "garbage collector roots remain"
- fi
- '';
- }
- ];
- }))
- (s "cycle" {
- phases = [{
- sources = {
- a.file = { path = "/fixtures/alt"; inputs.foo.follows = "b"; };
- b.file = { path = "/fixtures/alt"; inputs.foo.follows = "a"; };
- };
- expect = "eval-error";
- control = {
- a.file = { path = "/fixtures/alt"; inputs.foo.follows = "b"; };
- b.file = "/fixtures/alt";
- };
- }];
- })
- (s "self" {
- phases = [{
- sources.a.file = { path = "/fixtures/alt"; inputs.foo.follows = "a"; };
- expect = "eval-error";
- control = alt // {
- a.file = { path = "/fixtures/alt"; inputs.foo.follows = "alt"; };
- };
- }];
- })
- (s "missing" {
- phases = [{
- sources.a.file = { path = "/fixtures/alt"; inputs.foo.follows = "nope"; };
- expect = "eval-error";
- control = {
- a.file = { path = "/fixtures/alt"; inputs.foo.follows = "nope"; };
- nope.file = "/fixtures/alt";
- };
- }];
- })
- (s "noflake" {
- phases = [{
- sources = alt // {
- x.file = { path = "/fixtures/noflake"; inputs.foo.follows = "alt"; };
- };
- expect.fail = "error: source x draws flake inputs from other sources, but contains no flake.nix";
- }];
- })
- (s "noinput" {
- phases = [{
- sources.x.flake-input = { url = "/fixtures/top"; name = "nope"; inherit fetchOnTarget; };
- expect.fail = "error: flake /fixtures/top has no input nope";
- }];
- })
- # rsync's log gives sizes in units of 1024, as %'''l asks for.
- (s "rsync-units" {
- prepareDeployer = ''
- rm -fR /tmp/rsync-units
- mkdir /tmp/rsync-units
- head -c 2048 /dev/zero > /tmp/rsync-units/f
- '';
- phases = [{
- sources.x.file = "/tmp/rsync-units";
- output = [ "/2.00K" ];
- }];
- })
- # A pipe command without #!, which a shell runs as a script.
- (s "pipe-script" {
- prepareDeployer = ''
- printf 'echo piped\n' > /tmp/pipe-script
- chmod +x /tmp/pipe-script
- '';
- phases = [{
- sources.x.pipe = "/tmp/pipe-script";
- check = ''
- expect_eq x "$(cat "$T"/x)" piped
- '';
- }];
- })
- # A pass source gets decrypted again only when the last commit of its
- # passwords changes, which .pass_info records.
- (s "pass-changes" (let
- sources.x.pass = { dir = "/tmp/pass-changes"; name = "x"; };
- in {
- prepareDeployer = ''
- rm -fR /tmp/pass-changes
- cp -R /fixtures/pass-git /tmp/pass-changes
- '';
- phases = [
- {
- inherit sources;
- check = ''
- expect_eq a "$(cat "$T"/x/a)" secret-a
- '';
- mutate = ''
- echo local-change > "$T"/x/a
- '';
- }
- {
- inherit sources;
- check = ''
- expect_eq a "$(cat "$T"/x/a)" local-change
- '';
- mutateDeployer = ''
- export GIT_AUTHOR_NAME=krops GIT_AUTHOR_EMAIL=spam@krebsco.de
- export GIT_COMMITTER_NAME=krops GIT_COMMITTER_EMAIL=spam@krebsco.de
- printf 'secret-b\n' |
- PASSWORD_STORE_DIR=/tmp/pass-changes pass insert -m -f x/a >/dev/null
- '';
- }
- {
- inherit sources;
- check = ''
- expect_eq a "$(cat "$T"/x/a)" secret-b
- '';
- }
- ];
- }))
- # An input locked to a path the target doesn't have.
- (s "gone" {
- phases = [{
- sources.x.flake = { url = "/fixtures/usegone"; fetchOnTarget = true; };
- expect.fail = "error: flake /fixtures/usegone: input gone refers to /fixtures/gone, which does not exist on the target";
- }];
- })
- ]) targetKinds;
-
- # Edge cases of the flake source types and of generated flakes: unusual lock
- # files, references, and changes between deploys.
- edge-cases = concatMap (targetKind: let
- s = name: args: scenario ({
- name = "${name}-${targetKind}";
- inherit targetKind;
- isolate = true;
- } // args);
- copying = targetKind != "sudo";
- fetchOnTarget = !copying;
- roots = n: ''
- expect_eq roots "$(ls "$T"/.populate/flake-inputs/x 2>/dev/null | wc -l)" ${toString n}
- '';
- # A scenario for each way of drawing inputs, which f gets as mode, along
- # with draw, which adds inputs drawn that way to a source's options, and
- # flake, which gives the flake to evaluate for a source.
- modes = name: f: map
- (mode: s "${name}-${mode}" (f {
- draw = drawInputs mode;
- flake = n:
- if mode == "generated" then ''"$T"/.populate/flakes/${n}'' else ''"$T"/${n}'';
- inherit mode;
- }))
- [ "rewrite" "generated" ];
- in lib.flatten [
- # The registry entry kropsremote, which fixtures.sh adds on the deploying
- # machine, points at ${url}/remote.tar.gz. The deploying machine has no
- # global registry, which it couldn't download.
- (s "indirect" {
- phases = twice {
- sources.x.flake = { url = "kropsremote"; inherit fetchOnTarget; };
- check = checkFlake { attr = "msg"; expected = "remote:dep+inner"; };
- };
- })
- (s "remote-subdir" {
- phases = twice {
- sources.x.flake = { url = "${url}/mono.tar.gz?dir=sub"; inherit fetchOnTarget; };
- check = checkFlake { attr = "who"; expected = "sub"; subdir = true; };
- };
- })
- (modes "subdir-inputs" ({ draw, mode, ... }: {
- phases = twice {
- sources = alt // {
- x.flake = {
- url = "git+file:/fixtures/monoin?dir=sub"; inherit fetchOnTarget;
- } // draw { dep.follows = "alt"; };
- };
- check = checkFlake {
- attr = "msg"; expected = "monoin:alt"; subdir = true;
- generated = mode == "generated";
- };
- };
- }))
- (s "dirty" {
- phases = twice {
- sources.x.flake = { url = "/fixtures/dirty"; inherit fetchOnTarget; };
- check = checkFlake { attr = "msg"; expected = "dirty:dep+inner,sub"; } + ''
- test ! -e "$T"/x/untracked || fail "untracked file delivered"
- '';
- };
- })
- (s "unlocked" {
- phases = [{
- sources.x.flake = { url = "/fixtures/unlocked"; inherit fetchOnTarget; };
- expect.fail = "requires lock file changes";
- }];
- })
- (s "notdir" {
- phases = [{
- sources = {
- x.file = { path = "/fixtures/filesrc"; inputs.dep.follows = "p"; };
- p.pipe = "/fixtures/pipe";
- };
- expect.fail = "error: source p is not a directory";
- # Locking the flake with an empty URL would lock the current
- # directory, the login directory on a remote target.
- unexpected = [ "while fetching the input" ];
- }];
- })
- (s "noinput-nested" {
- phases = [{
- sources.x.flake-input = { url = "/fixtures/top"; name = "dep/nope"; inherit fetchOnTarget; };
- expect.fail = "error: flake /fixtures/top has no input dep/nope";
- }];
- })
- (s "shared" {
- phases = twice {
- sources = {
- x.flake = { url = "/fixtures/top"; inherit fetchOnTarget; };
- dep.flake-input = { url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; };
- sub.flake-input = { url = "/fixtures/top"; name = "sub"; fetchOnTarget = true; };
- };
- check = checkFlake { attr = "msg"; expected = "top:dep+inner,sub"; } + ''
- expect_eq dep "$(nix_eval "$T"/dep#greeting)" dep+inner
- expect_eq sub "$(nix_eval "$T"/sub#who)" sub
- '';
- };
- })
- (s "follows-empty" {
- phases = twice {
- sources.x.flake = { url = "/fixtures/nofollow"; fetchOnTarget = true; };
- check = checkFlake { attr = "msg"; expected = "nofollow"; };
- };
- })
- (s "follows-cycle" {
- phases = twice {
- sources.x.flake = { url = "/fixtures/cyc"; fetchOnTarget = true; };
- check = checkFlake { attr = "msg"; expected = "cyc:cyca,cycb"; };
- };
- })
- # Names resolve through the parent's lock file, so dep/inner is the
- # parent's inner, which dep's inner follows.
- (s "follows-name" {
- phases = twice {
- sources.x.flake-input = { url = "/fixtures/follow"; name = "dep/inner"; inherit fetchOnTarget; };
- check = checkFlake { attr = "who"; expected = "inner2"; };
- };
- })
- # The delivered input comes with the inputs its own lock file pins, which
- # evaluating it needs, not with the parent's inner, which it follows there.
- (s "follows-closure" {
- phases = twice {
- sources.x.flake-input = { url = "/fixtures/follow"; name = "dep"; inherit fetchOnTarget; };
- check = checkFlake { attr = "greeting"; expected = "dep+inner"; } + roots 1;
- };
- })
- (s "relative-input" {
- phases = twice {
- sources.x.flake = { url = "/fixtures/rel"; fetchOnTarget = true; };
- check = checkFlake { attr = "msg"; expected = "rel:lib"; };
- };
- })
- # The target can't fetch an input of the flake. The deploying machine
- # fetched it when locking, so its fetcher cache has to go.
- (s "fetch-failure" {
- prepareDeployer = ''
- rm -fR /root/.cache/nix
- '';
- phases = [{
- sources.x.flake = { url = "/fixtures/broken"; fetchOnTarget = true; };
- expect.fail = "vanishing.tar.gz";
- }];
- })
- # The delivered input itself is locked to a path the target lacks.
- (s "gone-input" {
- phases = [{
- sources.x.flake-input = { url = "/fixtures/usegone"; name = "gone"; fetchOnTarget = true; };
- expect.fail = "input gone refers to /fixtures/gone, which does not exist on the target";
- }];
- })
- # An input that gets drawn from a source isn't needed on the target, with
- # all it pins.
- (modes "overridden-roots" ({ draw, mode, ... }: {
- # What gets delivered doesn't depend on how inputs get drawn.
- exhaustive = mode == "generated";
- phases = twice {
- sources = alt // {
- x.flake-input = {
- url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
- } // draw { inner.follows = "alt"; };
- };
- check = checkFlake {
- attr = "greeting"; expected = "dep+alt";
- generated = mode == "generated";
- } + roots 0;
- };
- }))
- # The input's own lock file pins something unreachable, which gets drawn
- # from a source anyway.
- (modes "own-lock-unreachable" ({ draw, mode, ... }: {
- # What gets delivered doesn't depend on how inputs get drawn.
- exhaustive = mode == "generated";
- prepareDeployer = ''
- rm -fR /root/.cache/nix
- '';
- phases = twice {
- sources = alt // {
- x.flake-input = {
- url = "/fixtures/usepinned"; name = "dep"; inherit fetchOnTarget;
- } // draw { inner.follows = "alt"; };
- };
- check = checkFlake {
- attr = "greeting"; expected = "pinned+alt";
- generated = mode == "generated";
- };
- };
- }))
- # The input's own lock file pins something unreachable, or lacks an input
- # its flake.nix has. That fails, unless the input isn't required to come
- # with its inputs.
- (s "own-lock-broken" (let
- x = url: requireInputs: {
- x.flake-input = { inherit url; name = "dep"; inherit fetchOnTarget requireInputs; };
- };
- delivered = ''
- test -e "$T"/x/flake.nix || fail "input not delivered"
- '';
- in {
- prepareDeployer = ''
- rm -fR /root/.cache/nix
- '';
- phases = [
- {
- sources = x "/fixtures/usepinned" true;
- expect.fail = "error: source x: the inputs its own lock file pins can't be delivered. Set its requireInputs to false to deliver it anyway.";
- }
- {
- sources = x "/fixtures/usepinned" false;
- check = delivered + roots 0;
- }
- {
- sources = x "/fixtures/usestale" true;
- expect.fail = "error: source x: the inputs its own lock file pins can't be delivered. Set its requireInputs to false to deliver it anyway.";
- }
- {
- sources = x "/fixtures/usestale" false;
- check = delivered + roots 2;
- }
- {
- sources = x "/fixtures/usestale" false;
- check = delivered + roots 2;
- }
- ];
- }))
- # Redeploying an unchanged input while where it comes from is
- # unreachable and the fetcher caches are gone: the store has it.
- (s "offline-redeploy" (let
- sources.x.flake-input = { url = "/fixtures/offline"; name = "gap"; inherit fetchOnTarget; };
- check = checkFlake { attr = "who"; expected = "gap"; };
- in {
- prepareDeployer = ''
- if test -e /fixtures/www/gap.tar.gz.off; then
- mv /fixtures/www/gap.tar.gz.off /fixtures/www/gap.tar.gz
- fi
- '';
- phases = [
- {
- inherit sources check;
- mutate = ''
- rm -fR /root/.cache/nix
- '';
- mutateDeployer = ''
- rm -fR /root/.cache/nix
- mv /fixtures/www/gap.tar.gz /fixtures/www/gap.tar.gz.off
- '';
- }
- {
- inherit sources check;
- mutateDeployer = ''
- mv /fixtures/www/gap.tar.gz.off /fixtures/www/gap.tar.gz
- '';
- }
- ];
- }))
- (s "flake-to-file" {
- phases = [
- {
- sources.x.flake = { url = "/fixtures/top"; inherit fetchOnTarget; };
- check = roots 3;
- mutate = ''
- readlink "$T"/x > "$T"/old
- '';
- }
- {
- sources.x.file = "/fixtures/alt";
- check = ''
- test ! -L "$T"/x || fail "x is still a link to $(readlink "$T"/x)"
- test -e "$T"/x/flake.nix || fail "flake.nix missing"
- nix-store --verify-path "$(cat "$T"/old)" || fail "store path changed"
- '' + roots 0;
- }
- ];
- })
- (s "symlink-to-flake" {
- phases = [
- {
- sources = alt // { x.symlink = "alt"; };
- check = ''
- expect_eq link "$(readlink "$T"/x)" alt
- '';
- }
- {
- sources = alt // { x.flake = { url = "/fixtures/top"; inherit fetchOnTarget; }; };
- check = checkFlake { attr = "msg"; expected = "top:dep+inner,sub"; } + ''
- test -e "$T"/alt/flake.nix || fail "alt changed"
- '';
- }
- ];
- })
- # The ssh master stops, after failing as well as after succeeding.
- (s "ssh-master" (let
- checkDeployer = ''
- for i in 1 2 3 4 5 6 7 8 9 10; do
- if ! pgrep -f 'ssh: .*/populate\..*\[mux\]' >/dev/null; then
- exit 0
- fi
- sleep 0.5
- done
- fail "ssh master remains"
- '';
- in {
- phases = [
- {
- sources.x.flake-input = { url = "/fixtures/top"; name = "nope"; inherit fetchOnTarget; };
- expect.fail = "error: flake /fixtures/top has no input nope";
- inherit checkDeployer;
- }
- {
- sources = alt;
- inherit checkDeployer;
- }
- ];
- }))
- # Pruning entries whose names a shell's read would strip or split.
- (s "prune-names" {
- prepare = ''
- mkdir -p "$T/ lead" "$T/trail " "$T/$(printf 'new\nline')" "$T/axb" "$T/[b]"
- '';
- phases = [{
- sources = alt;
- prune = true;
- check = ''
- # In the C locale, ls sorts by bytes, so . comes first.
- expect_eq entries "$(LC_ALL=C ls -A "$T" | tr '\n' ' ')" '.populate alt '
- '';
- }];
- })
- # Misspelt input names draw nothing, so they fail before anything
- # changes: of the source's flake, and below an input drawn from a source.
- (modes "inputs-typo" ({ draw, ... }: {
- phases = [
- {
- sources = alt // {
- x.flake-input = {
- url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
- } // draw { innr.follows = "alt"; };
- };
- expect.fail = "error: source x draws input innr from source alt, but its flake has no input innr";
- }
- {
- sources = {
- x.flake = {
- url = "/fixtures/top"; inherit fetchOnTarget;
- } // draw { dep = { follows = "b"; inputs.qq.follows = "c2"; }; };
- b.file = "/fixtures/two";
- c2.file = "/fixtures/c2";
- };
- expect.fail = "error: source x draws input dep/qq from source c2, but the flake of source b has no input qq";
- }
- ];
- }))
- # A signal ending populate leaves neither the ssh master nor the
- # temporary directory.
- (s "interrupt" {
- prepareDeployer = ''
- find "''${TMPDIR:-/tmp}" -maxdepth 1 -name 'populate.*' -exec rm -fR {} +
- '';
- phases = [{
- sources.x.pipe = "/fixtures/pipe-slow";
- interrupt = 3;
- checkDeployer = ''
- for i in 1 2 3 4 5 6 7 8 9 10; do
- if ! pgrep -f 'ssh: .*/populate\..*\[mux\]' >/dev/null; then
- break
- fi
- sleep 0.5
- done
- ! pgrep -f 'ssh: .*/populate\..*\[mux\]' >/dev/null ||
- fail "ssh master remains"
- expect_eq "temporary directories" \
- "$(find "''${TMPDIR:-/tmp}" -maxdepth 1 -name 'populate.*')" ""
- '';
- }];
- })
- # An input of an input drawn from a source that draws inputs itself
- # gets drawn from another source.
- (modes "override" ({ draw, flake, ... }: {
- # Drawing inputs happens on the target alike for every kind of target.
- exhaustive = targetKind != "local";
- phases = twice {
- sources = {
- x.flake = {
- url = "/fixtures/top"; inherit fetchOnTarget;
- } // draw { dep = { follows = "b"; inputs.q.follows = "c2"; }; };
- b.file = { path = "/fixtures/two"; } // draw { p.follows = "c1"; };
- c1.file = "/fixtures/c1";
- c2.file = "/fixtures/c2";
- };
- check = ''
- expect_eq msg "$(nix_eval_online ${flake "x"}#msg)" top:two:c1+c2,sub
- '';
- };
- }))
- # The same one level deeper: the input gets drawn from a source below
- # an input that the source in between draws from a source itself.
- (modes "override-deep" ({ draw, flake, ... }: {
- # Drawing inputs happens on the target alike for every kind of target.
- exhaustive = targetKind != "local";
- phases = twice {
- sources = {
- x.flake = {
- url = "/fixtures/top"; inherit fetchOnTarget;
- } // draw { dep = { follows = "b"; inputs.p.inputs.z.follows = "c2"; }; };
- b.file = { path = "/fixtures/two"; } // draw { p.follows = "c3"; };
- c2.file = "/fixtures/c2";
- c3.file = "/fixtures/c3";
- };
- check = ''
- expect_eq msg "$(nix_eval_online ${flake "x"}#msg)" 'top:two:c3(c2)+inner,sub'
- '';
- };
- }))
- # A source with a rewritten lock file draws an input from a source with
- # a generated flake, and an input below it from another source.
- (s "override-mixed" {
- # Drawing inputs happens on the target alike for every kind of target.
- exhaustive = targetKind != "local";
- phases = twice {
- sources = {
- x.flake = {
- url = "/fixtures/top"; inherit fetchOnTarget;
- inputs.dep = { follows = "b"; inputs.q.follows = "c2"; };
- };
- b.file = {
- path = "/fixtures/two";
- rewriteLock = false;
- inputs.p.follows = "c1";
- };
- c1.file = "/fixtures/c1";
- c2.file = "/fixtures/c2";
- };
- check = ''
- expect_eq msg "$(nix_eval_online "$T"/x#msg)" top:two:c1+c2,sub
- '';
- };
- })
- (modes "nonflake-input" ({ draw, flake, ... }: {
- # Drawing inputs happens on the target alike for every kind of target.
- exhaustive = targetKind != "local";
- phases = twice {
- sources = {
- x.file = { path = "/fixtures/usedata"; } // draw { data.follows = "data"; };
- data.file = "/fixtures/data";
- };
- check = ''
- expect_eq msg "$(nix_eval ${flake "x"}#msg)" data:hello
- '';
- };
- }))
- # The target directory in a Git work tree.
- (modes "gitdir" ({ draw, flake, ... }: {
- prepare = ''
- mkdir -p "$T"
- git -C "$T" init -q
- '';
- phases = twice {
- sources = alt // {
- x.file = { path = "/fixtures/filesrc"; } // draw { dep.follows = "alt"; };
- };
- check = ''
- # Not just the path, which Nix takes for the enclosing work tree.
- expect_eq msg "$(nix_eval_online path:${flake "x"}#msg)" top:alt,sub
- '';
- };
- }))
- # Output of the same length, which rsync's quick check doesn't notice.
- (s "pipe-change" {
- prepareDeployer = ''
- echo piped > /fixtures/pipe2-data
- '';
- phases = [
- {
- sources.x.pipe = "/fixtures/pipe2";
- check = ''
- expect_eq x "$(cat "$T"/x)" piped
- '';
- mutateDeployer = ''
- echo PIPED > /fixtures/pipe2-data
- '';
- }
- {
- sources.x.pipe = "/fixtures/pipe2";
- check = ''
- expect_eq x "$(cat "$T"/x)" PIPED
- '';
- mutateDeployer = ''
- echo piped > /fixtures/pipe2-data
- '';
- }
- ];
- })
- # Source names that aren't input names, or that Nix has another use
- # for, both of sources inputs get drawn from and of sources drawing them.
- (modes "source-name" ({ draw, flake, ... }: let
- x = from: { path = "/fixtures/filesrc"; } // draw { dep.follows = from; };
- in {
- # Drawing inputs happens on the target alike for every kind of target.
- exhaustive = targetKind != "local";
- phases = twice {
- sources = {
- "alt-1.0".file = "/fixtures/alt";
- self.file = "/fixtures/alt";
- x.file = x "alt-1.0";
- y.file = x "self";
- "a.b_c-d".file = x "self";
- };
- check = ''
- expect_eq x "$(nix_eval_online ${flake "x"}#msg)" top:alt,sub
- expect_eq y "$(nix_eval_online ${flake "y"}#msg)" top:alt,sub
- expect_eq a.b_c-d "$(nix_eval_online ${flake "a.b_c-d"}#msg)" top:alt,sub
- '';
- };
- }))
- # An upload an interrupted run left behind.
- (s "upload-leftover" {
- phases = [
- {
- sources.x.flake = { url = "/fixtures/top"; fetchOnTarget = true; };
- check = checkFlake { attr = "msg"; expected = "top:dep+inner,sub"; };
- mutate = ''
- mkdir -p "$T"/.populate/flake-uploads/x/tree
- echo half > "$T"/.populate/flake-uploads/x/tree/half
- '';
- }
- {
- sources.x.flake = { url = "/fixtures/top"; fetchOnTarget = true; };
- check = checkFlake { attr = "msg"; expected = "top:dep+inner,sub"; };
- }
- ];
- })
- ] ++ lib.optional (targetKind == "local")
- # The target has the path the input is locked to.
- (s "uselocal" {
- phases = twice {
- sources.x.flake = { url = "/fixtures/uselocal"; fetchOnTarget = true; };
- check = checkFlake { attr = "msg"; expected = "uselocal:alt"; };
- };
- })
- ) 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, says ? true }: let
- phase = {
- inherit sources;
- check = ''
- case $(readlink "$T"/x || :) in
- ${outside}/*) fail "x still links outside" ;;
- 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 '${targetName targetKind "/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.
- 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"
- '';
- })
- # 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"
- # Named like pruning names it.
- "populating '${targetName targetKind "/var/krops/rsync-log-${targetKind}/x"}'..."
- ];
- }];
- })
- # 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";
- says = false;
- 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. So do
- # hard links to a read-only file outside, whose mode stays.
- (s "leftovers" {
- isolate = true;
- prepare = ''
- mkdir -p "$T"/hard
- ln -s ${outside}/dir "$T"/other
- ln -s ${outside}/repo "$T"/repo
- ln ${outside}/dir/sub/file "$T"/hard/link
- ln ${outside}/dir/sub/file "$T"/hardlink
- '';
- 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 hard hardlink .populate/flake-inputs/gone .populate/flake-uploads; do
- test ! -L "$T"/$name && test ! -e "$T"/$name || fail "$name not pruned"
- done
- '';
- }
- ];
- })
- # A target path with characters that mean something to shells and in
- # URLs.
- (s "odd-target" (let
- targetPath = "/var/krops/odd-target-${targetKind}/a b*c?d#e%f";
- in {
- isolate = true;
- phases = twice {
- sources = stateful // {
- g.git = { url = gitUrl; ref = gitHead; };
- p.pipe = "/fixtures/pipe";
- };
- inherit targetPath;
- prune = true;
- check = ''
- D=${lib.escapeShellArg targetPath}
- test -e "$D"/alt/flake.nix || fail "alt missing"
- expect_eq HEAD "$(git -C "$D"/g rev-parse HEAD)" ${gitHead}
- expect_eq p "$(cat "$D"/p)" piped
- expect_eq entries "$(ls "$T")" 'a b*c?d#e%f'
- gc
- expect_eq greeting "$(nix_eval "$(readlink -f "$D"/x)"#greeting)" hello+alt
- expect_eq msg "$(nix_eval "$(readlink -f "$D"/y)"#msg)" top:dep+inner,sub
- '';
- };
- }))
- # 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
- '';
- };
- })
- # Git work trees whose .git directory names places elsewhere, or a
- # command: a work tree elsewhere, a configuration file elsewhere, another
- # repository, and a command run when Git checks for changes. Populate
- # refuses them, telling why, and replaces them by fresh clones with
- # reclone. A hook doesn't run.
- (s "git-foreign" (let
- elsewhere = "/var/krops-elsewhere";
- git = name: reclone: {
- ${name}.git = { url = gitUrl; ref = gitHead; inherit reclone; };
- };
- untouched = ''
- cmp -s ${elsewhere}/config ${elsewhere}/config.orig ||
- fail "what b/.git/config links to changed"
- test ! -e ${elsewhere}/ran || fail "a command in a .git directory ran"
- '';
- refused = name: message: {
- sources = git name false;
- expect.fail = "error: populate doesn't use the Git work tree ${targetName targetKind "/var/krops/git-foreign-${targetKind}/${name}"}, because ${message}. Fix that, remove the work tree, or set the source's reclone to true to replace it by a fresh clone.";
- };
- in {
- isolate = true;
- prepare = ''
- rm -fR ${elsewhere}
- mkdir -p ${elsewhere} "$T"
- for name in a b c d e; do
- git clone -q ${gitUrl} "$T"/$name
- git -C "$T"/$name checkout -q ${gitFirst}
- done
- git -C "$T"/a config core.worktree ${outside}/dir
- mv "$T"/b/.git/config ${elsewhere}/config
- ln -s ${elsewhere}/config "$T"/b/.git/config
- cp -p ${elsewhere}/config ${elsewhere}/config.orig
- printf '#! /bin/sh\ntouch ${elsewhere}/ran\n' > "$T"/c/.git/hooks/reference-transaction
- chmod +x "$T"/c/.git/hooks/reference-transaction
- echo ${outside}/repo/.git > "$T"/d/.git/commondir
- git -C "$T"/e config core.fsmonitor "touch ${elsewhere}/ran"
- '';
- phases = [
- (refused "a" "/var/krops/git-foreign-${targetKind}/a/.git/config sets core.worktree to ${outside}/dir")
- (refused "b" "/var/krops/git-foreign-${targetKind}/b/.git contains symlinks: /var/krops/git-foreign-${targetKind}/b/.git/config")
- {
- sources = git "c" false;
- check = untouched + ''
- expect_eq "c HEAD" "$(git -C "$T"/c rev-parse HEAD)" ${gitHead}
- '';
- }
- (refused "d" "/var/krops/git-foreign-${targetKind}/d/.git/commondir names another repository")
- (refused "e" "/var/krops/git-foreign-${targetKind}/e/.git/config sets core.fsmonitor, which neither git clone nor populate does")
- {
- sources = git "a" true // git "b" true // git "c" true // git "d" true // git "e" true;
- output = map
- (name: "replacing the Git work tree '${targetName targetKind "/var/krops/git-foreign-${targetKind}/${name}"}', because")
- [ "a" "b" "d" "e" ];
- check = untouched + ''
- for name in a b c d e; do
- expect_eq "$name HEAD" "$(git -C "$T"/$name rev-parse HEAD)" ${gitHead}
- test -e "$T"/$name/two || fail "$name/two missing"
- done
- '';
- }
- ];
- }))
- # A Git work tree whose submodule's .git, which Git uses as it finds it,
- # isn't the gitfile git clone made, naming the repository in
- # .git/modules: a repository of its own, whose configuration names a
- # command, and a gitfile naming a repository elsewhere.
- (s "git-submodule" (let
- elsewhere = "/var/krops-elsewhere";
- url = dirOf gitUrl + "/withsub.git";
- git = reclone: { x.git = { inherit url reclone; ref = "master"; }; };
- untouched = ''
- test ! -e ${elsewhere}/ran || fail "a command in a .git directory ran"
- expect_eq "HEAD elsewhere" "$(git -C ${elsewhere}/repo rev-parse HEAD)" ${gitFirst}
- '';
- in {
- isolate = true;
- prepare = ''
- rm -fR ${elsewhere}
- git clone -q ${gitUrl} ${elsewhere}/repo
- git -C ${elsewhere}/repo checkout -q ${gitFirst}
- '';
- phases = [
- {
- sources = git false;
- check = untouched + ''
- expect_eq "sub HEAD" "$(git -C "$T"/x/sub rev-parse HEAD)" ${gitHead}
- '';
- mutate = ''
- rm "$T"/x/sub/.git
- cp -R "$T"/x/.git/modules/sub "$T"/x/sub/.git
- git config --file "$T"/x/sub/.git/config --unset core.worktree
- git config --file "$T"/x/sub/.git/config \
- filter.x.smudge "touch ${elsewhere}/ran; cat"
- echo '* filter=x' > "$T"/x/sub/.git/info/attributes
- git -C "$T"/x/sub update-ref --no-deref HEAD ${gitFirst}
- git -C "$T"/x/sub read-tree ${gitFirst}
- rm "$T"/x/sub/two
- '';
- }
- {
- sources = git false;
- expect.fail = "error: populate doesn't use the Git work tree";
- check = untouched;
- mutate = ''
- rm -fR "$T"/x/sub
- mkdir "$T"/x/sub
- echo gitdir: ${elsewhere}/repo/.git > "$T"/x/sub/.git
- '';
- }
- {
- sources = git false;
- expect.fail = "error: populate doesn't use the Git work tree";
- check = untouched;
- }
- {
- sources = git true;
- check = untouched + ''
- expect_eq "sub HEAD" "$(git -C "$T"/x/sub rev-parse HEAD)" ${gitHead}
- '';
- }
- ];
- }))
- # A ref that git fetch would take for an option: --upload-pack runs a
- # command where the repository is local.
- (s "git-ref-option" (let
- elsewhere = "/var/krops-elsewhere";
- in {
- isolate = true;
- prepare = ''
- rm -fR ${elsewhere}
- git clone -q --bare ${gitUrl} ${elsewhere}/repo.git
- '';
- phases = [
- {
- sources.x.git = {
- url = "${elsewhere}/repo.git";
- ref = "--upload-pack=touch ${elsewhere}/ran; git-upload-pack";
- };
- expect.fail = "invalid refspec";
- check = ''
- test ! -e ${elsewhere}/ran || fail "the ref ran a command"
- '';
- }
- ];
- }))
- # populate exits with the status of the command that failed, here git
- # fetch's.
- (s "git-status" {
- phases = [{
- sources.x.git = { url = gitUrl; ref = "nonexistent"; };
- expect = { fail = "couldn't find remote ref"; status = 128; };
- }];
- })
- # A .git that a materialized flake got, and one that a file source
- # keeps, which Nix uses for the git+file: URL of the flake there: writing
- # its lock file, Nix runs Git, with that repository's configuration.
- (s "lock-foreign-git" (let
- elsewhere = "/var/krops-elsewhere";
- sources = alt // {
- x.flake-input = {
- url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
- materialize = true;
- inputs.inner.follows = "alt";
- };
- y.file = {
- path = "/fixtures/filesrc";
- deleteExcluded = false;
- filters = [ { type = "exclude"; pattern = "/.git"; } ];
- inputs.dep.follows = "alt";
- };
- };
- in {
- isolate = true;
- prepare = ''
- rm -fR ${elsewhere}
- mkdir -p ${elsewhere}
- '';
- phases = [
- {
- inherit sources;
- # Repositories tracking only flake.nix, so the lock file changes.
- mutate = ''
- for name in x y; do
- git -C "$T"/$name init -q
- git -C "$T"/$name add flake.nix
- git -C "$T"/$name -c user.name=x -c user.email=x@x commit -q -m x
- git -C "$T"/$name config core.fsmonitor "touch ${elsewhere}/ran; false"
- done
- '';
- }
- {
- inherit sources;
- check = ''
- test ! -e ${elsewhere}/ran || fail "a command in a .git directory ran"
- '';
- }
- ];
- }))
- # A .git that a file source keeps, with configuration a git source would
- # use, but a hook, which Nix's Git runs when it adds the lock file it
- # writes: populate's Git runs none, so git sources keep hooks, but
- # drawing inputs refers to the flake as path:.
- (s "lock-hooks" (let
- elsewhere = "/var/krops-elsewhere";
- sources = alt // {
- y.file = {
- path = "/fixtures/filesrc";
- deleteExcluded = false;
- filters = [ { type = "exclude"; pattern = "/.git"; } ];
- inputs.dep.follows = "alt";
- };
- };
- in {
- isolate = true;
- prepare = ''
- rm -fR ${elsewhere}
- mkdir -p ${elsewhere}
- '';
- phases = [
- {
- inherit sources;
- # A repository tracking only flake.nix, so Nix adds the lock file,
- # which runs post-index-change.
- mutate = ''
- git -C "$T"/y init -q
- git -C "$T"/y add flake.nix
- git -C "$T"/y -c user.name=x -c user.email=x@x commit -q -m x
- printf '#! /bin/sh\ntouch ${elsewhere}/ran\n' > "$T"/y/.git/hooks/post-index-change
- chmod +x "$T"/y/.git/hooks/post-index-change
- '';
- }
- {
- inherit sources;
- check = ''
- test ! -e ${elsewhere}/ran || fail "a hook in a .git directory ran"
- test -e "$T"/y/flake.lock || fail "y/flake.lock missing"
- '';
- }
- ];
- }))
- # A file in populate's state replaced by a symlink to a file outside.
- (s "state-key" (let
- sources.x.flake-input = {
- url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
- patches = hello;
- };
- in {
- isolate = true;
- phases = [
- {
- inherit sources;
- mutate = ''
- rm "$T"/.populate/derived/x/key
- ln -s ${outside}/file "$T"/.populate/derived/x/key
- '';
- }
- {
- inherit sources;
- check = checkFlake { attr = "greeting"; expected = "hello+inner"; };
- }
- ];
- }))
- # Stale state that contains a hard link to a read-only file outside,
- # whose mode stays.
- (s "state-hard-link" (let
- sources.x.flake-input = {
- url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget;
- patches = hello;
- };
- in {
- isolate = true;
- phases = [
- {
- inherit sources;
- mutate = ''
- mkdir -p "$T"/.populate/derived/gone "$T"/.populate/derived/x/work
- ln ${outside}/dir/sub/file "$T"/.populate/derived/gone/file
- ln ${outside}/dir/sub/file "$T"/.populate/derived/x/work/file
- '';
- }
- {
- inherit sources;
- check = ''
- test ! -e "$T"/.populate/derived/gone || fail "gone remains"
- '';
- }
- ];
- }))
- # The source contains flake.lock.populate and a file like the temporary
- # copy of the lock file, symlinks to a file outside.
- (s "lock-temporary-link" {
- isolate = true;
- prepareDeployer = ''
- rm -fR /var/krops-src/lock-temporary-link
- mkdir -p /var/krops-src
- cp -R /fixtures/filesrc /var/krops-src/lock-temporary-link
- ln -s ${outside}/file /var/krops-src/lock-temporary-link/flake.lock.populate
- ln -s ${outside}/file /var/krops-src/lock-temporary-link/.flake.lock.XXXXXX
- '';
- phases = twice {
- sources = alt // {
- x.file = {
- path = "/var/krops-src/lock-temporary-link";
- inputs.dep.follows = "alt";
- };
- };
- check = ''
- expect_eq msg "$(nix_eval_online path:"$T"/x#msg)" top:alt,sub
- '';
- };
- })
- # A file in the target directory is a hard link to a read-only file
- # outside with the same contents, which rsync takes for unchanged, and
- # would fix the mode and time of in place.
- (s "rsync-hard-link" {
- isolate = true;
- prepareDeployer = ''
- rm -fR /var/krops-src/rsync-hard-link
- mkdir -p /var/krops-src/rsync-hard-link
- echo outside > /var/krops-src/rsync-hard-link/file
- '';
- prepare = ''
- mkdir -p "$T"/x
- ln ${outside}/dir/sub/file "$T"/x/file
- '';
- phases = twice {
- sources.x.file = {
- path = "/var/krops-src/rsync-hard-link";
- useChecksum = true;
- };
- check = ''
- expect_eq file "$(cat "$T"/x/file)" outside
- expect_eq links "$(stat -c %h "$T"/x/file)" 1
- '';
- };
- })
- (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" "a b" "a*b" "a?b" "a+b" "a=b" "-a" ];
- })
- # 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;
-
}