diff options
| author | tv <tv@krebsco.de> | 2026-11-11 16:14:54 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-11 16:14:54 +0000 |
| commit | 84888f4e20de185e3bd3aaaa63e180dace349b92 (patch) | |
| tree | 3d45686568c29e5bbaad25c9fbd6dbeeb17a2afb | |
| parent | 28430f1b07e85e0ea553d5486be568a2bf372e88 (diff) | |
populate: add flake source type
Deliver a flake, or one of its locked inputs, to the target: archive the
flake locally, copy the selected input together with its own inputs into the
target's store, and link it at the target path, registered as garbage
collector root. The result can be used in NIX_PATH as well as by flakes
evaluated on the target, without network access.
nix copy runs with a separate ssh wrapper without the control socket, because
Nix waits for its LocalCommand, which may not run over a multiplexed
connection.
| -rw-r--r-- | README.md | 47 | ||||
| -rw-r--r-- | lib/types/populate.nix | 19 | ||||
| -rw-r--r-- | pkgs/populate/default.nix | 98 |
3 files changed, 163 insertions, 1 deletions
@@ -265,6 +265,53 @@ Supported attributes: of rsync. Defaults to `true`. +### `flake` + +The flake source type delivers a flake, or one of its locked inputs, to the +target. The flake gets archived locally using `nix flake archive`, the +selected input and its own inputs get copied into the target's Nix store using +`nix copy`, and the target path becomes a symlink to the input's store path, +registered as garbage collector root. The input's own inputs get garbage +collector roots in `.populate/flake-inputs/<name>`, which turns the sentinel +file into a directory. + +Because the target path points at the store path named by the flake's lock +file, it can be used in `NIX_PATH` as well as by flakes evaluated on the +target, without network access. + +Supported attributes: + +* `flake` - + flake reference, e.g. the absolute path of a directory containing a + `flake.nix`. Its lock file is used as is; populating fails if the lock + file needs to be updated. + +* `input` (optional) - + name of the input to deliver. Inputs of inputs are separated by `/`, + e.g. `stockholm/nixpkgs`. If omitted, the flake itself gets delivered, + which includes only files tracked by Git. Use a `file` source for work + trees with untracked files. + +Instead of an attribute set, the flake reference can be given as a string. + +The flake commands run using krops' own Nix, so flakes don't need to be +enabled on the deploying machine. The target only needs `nix-store`. +Because the copied store paths aren't signed, the target user must be allowed +to import them, i.e. be `root` or a trusted user. Targets using `sudo` aren't +supported. + +Example: + +```nix +{ + nixpkgs.flake = { + flake = toString ./.; + input = "nixpkgs"; + }; +} +``` + + ### `git` Git sources that will be fetched on the target machine. diff --git a/lib/types/populate.nix b/lib/types/populate.nix index 6264f99..a194831 100644 --- a/lib/types/populate.nix +++ b/lib/types/populate.nix @@ -31,6 +31,14 @@ source-types.file ]); }; + flake = lib.mkOption { + apply = x: + if lib.types.str.check x + then { flake = x; } + else x; + default = null; + type = lib.types.nullOr (lib.types.either lib.types.str source-types.flake); + }; git = lib.mkOption { default = null; type = lib.types.nullOr source-types.git; @@ -137,6 +145,17 @@ }; }; }; + flake = lib.types.submodule { + options = { + flake = lib.mkOption { + type = lib.types.str; # TODO lib.types.flake-ref + }; + input = lib.mkOption { + default = null; + type = lib.types.nullOr lib.types.str; + }; + }; + }; git = lib.types.submodule { options = { clean = { diff --git a/pkgs/populate/default.nix b/pkgs/populate/default.nix index 221ba6e..fc577cf 100644 --- a/pkgs/populate/default.nix +++ b/pkgs/populate/default.nix @@ -1,7 +1,7 @@ with import ../../lib; with shell; -{ coreutils, dash, findutils, git, jq, openssh, pass, passage, rsync, writers }: +{ coreutils, dash, findutils, git, jq, nix, openssh, pass, passage, rsync, writers }: let check = { force, target }: let @@ -61,6 +61,93 @@ let in rsync' target config sourcePath; + pop.flake = target: source: let + nix' = "${nix}/bin/nix --extra-experimental-features ${quote "nix-command flakes"}"; + input = source.input or null; + # jq filter selecting the flake or input in nix flake archive's output. + node = + if input == null then "." + else concatMapStrings (name: ".inputs[${toJSON name}]") (splitString "/" input); + sentinel = "${dirOf target.path}/.populate"; + # Directory of garbage collector roots for the input's own inputs. + inputsRoots = "${sentinel}/flake-inputs/${baseNameOf target.path}"; + # Reads store paths from stdin, links the first one at the target path, and + # registers it and the remaining ones as garbage collector roots. Roots + # get added before obsolete ones get removed, so the inputs stay rooted. + link = /* sh */ '' + set -efu + read -r path + if test -e ${quote target.path} && ! test -L ${quote target.path}; then + rm -fR ${quote target.path} + fi + nix-store --realise "$path" --add-root ${quote target.path} --indirect >/dev/null + + # The sentinel only needs to exist, so it can become a directory. + if test -f ${quote sentinel}; then + rm ${quote sentinel} + mkdir ${quote sentinel} + fi + mkdir -p ${quote inputsRoots} + roots= + while read -r path; do + root=$(basename "$path") + if test "$(readlink ${quote inputsRoots}/"$root")" != "$path"; then + nix-store --realise "$path" \ + --add-root ${quote inputsRoots}/"$root" --indirect \ + >/dev/null + fi + roots="$roots $root" + done + ls -A ${quote inputsRoots} | while read -r root; do + case " $roots " in + *" $root "*) ;; + *) rm ${quote inputsRoots}/"$root" ;; + esac + done + # An input without own inputs leaves the directory empty. + if test -z "$roots"; then + rmdir ${quote inputsRoots} + fi + ''; + in + if target.sudo then + throw "krops: flake source ${target.path}: targets with sudo are not supported" + else /* sh */ '' + set -efu + + # Archive each flake only once, even if several sources use it. + archive=$TMPDIR/flake-archive.$( + printf %s ${quote source.flake} | + ${coreutils}/bin/sha256sum | + ${coreutils}/bin/cut -c-32 + ).json + if ! test -e "$archive"; then + ${nix'} flake archive --json --no-update-lock-file ${quote source.flake} \ + > "$archive" + fi + + if ! ${jq}/bin/jq -e ${quote "${node} | .path"} "$archive" >/dev/null; then + >&2 printf 'error: flake %s has no input %s\n' \ + ${quote source.flake} ${quote (toString input)} + exit 1 + fi + + # The input followed by its own inputs, which flakes evaluated on the + # target need as well. + paths=$(${jq}/bin/jq -r ${quote "${node} | .. | .path? | strings"} "$archive") + + ${optionalString (!isLocalTarget target) /* sh */ '' + mkdir -p "$PWD"/bin + ln -fns ${sshNix' target} "$PWD"/bin/ssh + PATH=$PWD/bin:$PATH \ + ${nix'} copy --to ${quote "ssh://${target.host}"} $paths >&2 + ''} + + printf '%s\n' "$paths" | { + ${runShell target link} + } + ''; + pop.git = target: source: runShell target /* sh */ '' set -efu # Remove target path if it doesn't look like a git worktree. @@ -268,6 +355,15 @@ let ${ssh' target} ${quote target.host} ${quote command} ''; + # ssh for Nix's ssh:// store. Without the control socket, because Nix waits + # for its LocalCommand, which may not run over a multiplexed connection. + sshNix' = target: writers.writeDash "ssh-nix-wrapper" '' + exec ${openssh}/bin/ssh \ + ${concatMapStringsSep " " quote (mkUserPortSSHOpts target)} \ + ${concatMapStringsSep " " quote target.extraOptions} \ + "$@" + ''; + ssh' = target: writers.writeDash "ssh-wrapper" '' exec ${openssh}/bin/ssh \ ${concatMapStringsSep " " quote (mkUserPortSSHOpts target)} \ |
