aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/populate/src/sh.rs
diff options
context:
space:
mode:
Diffstat (limited to 'pkgs/populate/src/sh.rs')
-rw-r--r--pkgs/populate/src/sh.rs485
1 files changed, 485 insertions, 0 deletions
diff --git a/pkgs/populate/src/sh.rs b/pkgs/populate/src/sh.rs
new file mode 100644
index 0000000..5d6bf3e
--- /dev/null
+++ b/pkgs/populate/src/sh.rs
@@ -0,0 +1,485 @@
+//! Shell code that runs on the target, where populate can't rely on more
+//! than a POSIX shell and the usual tools. Values come as positional
+//! parameters, never quoted into the code.
+
+/// The directories below the sentinel, once that is a directory, which have
+/// a directory for each source that needs one. Besides, there is backup,
+/// which holds copies of the sources.
+pub const STATE_KINDS: &str = "derived flake-inputs flake-uploads flakes patches";
+
+/// Defines use_state SENTINEL NAME..., which prepares keeping state below
+/// the sentinel for the sources named NAME. The sentinel only needs to
+/// exist, so it may be a file, which becomes a directory. Populate changes
+/// and removes what's in its state directories, and a symlink in place of
+/// one would make it do that to whatever the link points to, so it refuses
+/// to use those.
+pub fn use_state() -> String {
+ format!(
+ r#"
+refuse_link() {{
+ if test -L "$1"; then
+ >&2 printf 'error: %s is a symlink, but populate needs a directory of its own there\n' "$1"
+ exit 1
+ fi
+}}
+use_state() {{
+ sentinel=$1
+ shift
+ refuse_link "$sentinel"
+ refuse_link "$sentinel"/backup
+ for state_kind in {STATE_KINDS}; do
+ refuse_link "$sentinel"/"$state_kind"
+ for state_name; do
+ refuse_link "$sentinel"/"$state_kind"/"$state_name"
+ done
+ done
+ if test -f "$sentinel"; then
+ rm "$sentinel"
+ mkdir "$sentinel"
+ fi
+}}
+"#
+ )
+}
+
+/// Defines remove_tree PATH, which removes what's at PATH, with everything
+/// below it, also what isn't writable. A symlink gets removed itself,
+/// instead of followed. Only directories get made writable, which is what
+/// removing what's in them needs: a file may be a hard link to one
+/// elsewhere, which shares its mode.
+pub const REMOVE_TREE: &str = r#"
+remove_tree() {
+ if test -L "$1"; then
+ rm "$1"
+ elif test -e "$1"; then
+ find "$1" -type d -exec chmod u+w {} +
+ rm -fR "$1"
+ fi
+}
+"#;
+
+/// Defines remove_link PATH NAME, which removes a symlink at PATH, saying
+/// so, with NAME for PATH, because the link may be there on purpose, but
+/// populate never follows it: what a source delivers would change or remove
+/// what the link points to. remove_foreign_link PATH NAME does so unless
+/// the link points into the store, like the links populate puts there
+/// itself, and make_room_for_link PATH NAME makes room at PATH for a link
+/// into the store, which Nix only puts in place of another link into the
+/// store.
+pub const LINKS: &str = r#"
+remove_link() {
+ if test -L "$1"; then
+ >&2 printf "replacing symlink '%s', which points to '%s'...\n" \
+ "$2" "$(readlink "$1")"
+ rm "$1"
+ fi
+}
+remove_foreign_link() {
+ if test -L "$1"; then
+ # Resolved, so a link like /nix/store/../.. doesn't count.
+ case $(readlink -f "$1") in
+ /nix/store/*) ;;
+ *) remove_link "$1" "$2" ;;
+ esac
+ fi
+}
+make_room_for_link() {
+ remove_foreign_link "$1" "$2"
+ if ! test -L "$1" && test -e "$1"; then
+ rm -fR "$1"
+ fi
+}
+"#;
+
+/// Defines remove_others DIR LOG NAME..., which removes the entries of DIR
+/// that aren't named NAME. find, unlike a loop reading names, copes with
+/// every name, e.g. ones that end in blanks or contain newlines. With LOG
+/// empty, entries get removed like remove_tree does; for entries of the
+/// target directory, LOG is the directory's name in the line printed for
+/// each entry, and nothing gets made writable first. The names are
+/// portable file names, which mean themselves as patterns of find.
+pub const REMOVE_OTHERS: &str = r#"
+remove_others() {
+ dir=$1
+ log=$2
+ shift 2
+ n=$#
+ while test "$n" -gt 0; do
+ set -- "$@" ! -name "$1"
+ shift
+ n=$((n - 1))
+ done
+ find "$dir" -mindepth 1 -maxdepth 1 "$@" -exec sh -c '
+ log=$1
+ shift
+ for path; do
+ if test -n "$log"; then
+ >&2 printf "pruning '\''%s/%s'\''...\n" "$log" "${path##*/}"
+ rm -fR "$path"
+ elif test -L "$path"; then
+ rm "$path"
+ else
+ find "$path" -type d -exec chmod u+w {\} +
+ rm -fR "$path"
+ fi
+ done
+ ' sh "$log" {} +
+}
+"#;
+
+/// check SENTINEL NAME DIR FORCE: fails unless the sentinel exists, creating
+/// it and the target directory DIR first if FORCE isn't empty. NAME is the
+/// sentinel's name in messages.
+pub const CHECK: &str = r#"
+sentinel=$1 name=$2 dir=$3 force=$4
+# Before anything touches it, which would touch what it points to.
+if test -L "$sentinel"; then
+ >&2 printf 'error: sentinel file is a symlink: %s\n' "$name"
+ exit 1
+fi
+if test -n "$force"; then
+ mkdir -vp "$dir" >&2
+ if ! test -e "$sentinel"; then
+ touch "$sentinel"
+ fi
+fi
+if ! test -e "$sentinel"; then
+ >&2 printf 'error: missing sentinel file: %s\n' "$name"
+ exit 1
+fi
+"#;
+
+/// backup DIR NAME: copies the target directory DIR to the sentinel's
+/// backup directory. NAME is the sentinel's name in messages.
+pub fn backup() -> String {
+ use_state()
+ + r#"
+dir=$1 name=$2
+use_state "$dir"/.populate
+if ! test -d "$dir"/.populate; then
+ >&2 printf 'error: sentinel file is not a directory: %s\n' "$name"
+ exit 1
+fi
+rsync >&2 \
+ -aAXF \
+ --delete \
+ --exclude /.populate \
+ --link-dest="$dir" \
+ "$dir"/ \
+ "$dir"/.populate/backup/
+"#
+}
+
+/// prune DIR LOG NAME...: removes the entries of the target directory DIR
+/// that aren't sources named NAME, logging them with LOG for DIR, and
+/// populate's state that only sources that are gone needed. The names
+/// after -- are the ones of flake and flake-input sources, whose roots for
+/// the garbage collector stay.
+pub fn prune() -> String {
+ use_state()
+ + REMOVE_TREE
+ + REMOVE_OTHERS
+ + r#"
+dir=$1 log=$2
+shift 2
+names=
+while test "$1" != --; do
+ names="$names $1"
+ shift
+done
+shift
+use_state "$dir"/.populate
+cd "$dir"
+# The names are portable file names, so splitting them at blanks is fine.
+# shellcheck disable=SC2086
+remove_others . "$log" .populate $names
+roots=.populate/flake-inputs
+if test -d "$roots"; then
+ remove_others "$roots" "" "$@"
+ if test -z "$(ls -A "$roots")"; then
+ rmdir "$roots"
+ fi
+fi
+remove_tree .populate/flake-uploads
+"#
+}
+
+/// break_hard_links PATH NAME: prepares PATH for rsync. rsync writes
+/// through a symlink at the target path, e.g. one a symlink or flake source
+/// left there, into what it points to. And rsync fixes the mode and time of
+/// a file it takes for unchanged in place, which changes every hard link to
+/// it, e.g. one elsewhere, or a backup's. So each file with other hard
+/// links gets replaced by a copy of its own first.
+pub fn break_hard_links() -> String {
+ LINKS.to_string()
+ + r#"
+remove_link "$1" "$2"
+if test -d "$1"; then
+ find "$1" -type f -links +1 -exec sh -c '
+ for file; do
+ copy=$(mktemp "$file.XXXXXX")
+ cp -p "$file" "$copy"
+ mv -f "$copy" "$file"
+ done
+ ' sh {} +
+fi
+"#
+}
+
+/// derivation PATH NAME TEXT: builds the Nix expression TEXT on the target,
+/// with a link to the result at PATH.
+pub fn derivation() -> String {
+ LINKS.to_string()
+ + r#"
+make_room_for_link "$1" "$2"
+nix-build -E "$3" -o "$1" >&2
+"#
+}
+
+/// symlink PATH TARGET: makes PATH a symlink to TARGET.
+pub const SYMLINK: &str = r#"
+ln -fnsT "$2" "$1"
+"#;
+
+/// Defines git_problems PATH, which prints why populate can't use the .git
+/// directory of the Git work tree at PATH, or nothing. A .git directory
+/// that git clone and populate made has no symlinks, no commondir file,
+/// which names another repository, no linked work trees, and only the
+/// configuration they write: the repository's format, the remote, branches,
+/// and submodules, whose repositories have their work tree in the work
+/// tree.
+pub const GIT_PROBLEMS: &str = r#"
+git_problems() (
+ path=$1
+ git_dir=$1/.git
+ links=$(find "$git_dir" -type l | head -n 3 | paste -s -d ' ' -)
+ if test -n "$links"; then
+ printf '%s contains symlinks: %s' "$git_dir" "$links"
+ exit
+ fi
+ common=$(find "$git_dir" -name commondir | head -n 1)
+ if test -n "$common"; then
+ printf '%s names another repository' "$common"
+ exit
+ fi
+ if test -e "$git_dir"/worktrees; then
+ printf '%s has linked work trees' "$git_dir"
+ exit
+ fi
+ find "$git_dir" -name config -type f | while IFS= read -r config; do
+ command git config --file "$config" --list --name-only |
+ while IFS= read -r key; do
+ case $key in
+ core.repositoryformatversion|core.filemode|core.bare|\
+ core.logallrefupdates|core.symlinks|core.ignorecase|\
+ core.precomposeunicode|\
+ extensions.objectformat|extensions.refstorage|\
+ remote.origin.url|remote.origin.fetch|\
+ branch.*.remote|branch.*.merge|\
+ submodule.active|submodule.*.url|submodule.*.active) ;;
+ core.worktree)
+ # Submodules' repositories have their work tree in the work tree.
+ worktree=$(command git config --file "$config" core.worktree)
+ dir=$(dirname "$config")
+ case $dir/$worktree in
+ "$git_dir"/modules/*)
+ resolved=$(cd "$dir" && cd "$worktree" 2>/dev/null && pwd -P) || :
+ case $resolved in
+ "$(cd "$path" && pwd -P)"/*) continue ;;
+ esac
+ ;;
+ esac
+ printf '%s sets core.worktree to %s' "$config" "$worktree"
+ exit 1
+ ;;
+ *)
+ printf '%s sets %s, which neither git clone nor populate does' "$config" "$key"
+ exit 1
+ ;;
+ esac
+ done || exit 1
+ done || :
+)
+"#;
+
+/// git PATH NAME URL REF SHALLOW FETCH_ALWAYS RECLONE EXCLUDE...: makes
+/// PATH a Git work tree of the repository at URL, with REF checked out,
+/// and everything removed that Git doesn't track, except what matches an
+/// EXCLUDE pattern. SHALLOW, FETCH_ALWAYS, and RECLONE are flags, empty
+/// for false. NAME is PATH's name in messages.
+pub fn git() -> String {
+ LINKS.to_string()
+ + GIT_PROBLEMS
+ + r#"
+path=$1 name=$2 url=$3 ref=$4 shallow=$5 fetch_always=$6 reclone=$7
+shift 7
+# Hooks and commands a repository's configuration names run with Git's
+# commands, so populate's Git doesn't run any. The configuration gets
+# checked as well, but Git also runs a hook it has itself.
+git() {
+ command git -c core.hooksPath=/dev/null -c core.fsmonitor=false "$@"
+}
+# A target path that doesn't look like a Git work tree gets removed, e.g.
+# when it had another type before. A work tree that a symlink points to,
+# or whose .git is a symlink or a file naming a repository elsewhere, isn't
+# populate's own: fetching, resetting, and cleaning would change what's
+# elsewhere.
+remove_link "$path" "$name"
+if test -L "$path"/.git || ! test -d "$path"/.git; then
+ rm -fR "$path"
+fi
+# A .git directory can name places elsewhere too, which Git's commands
+# change, or commands, which they run. Populate only uses a work tree
+# whose .git directory has nothing like that, and else replaces it if
+# reclone is set, or fails, telling why.
+if test -e "$path"; then
+ if problems=$(git_problems "$path"); then :; else
+ exit 1
+ fi
+ if test -n "$problems"; then
+ if test -n "$reclone"; then
+ >&2 printf "replacing the Git work tree '%s', because %s...\n" \
+ "$name" "$problems"
+ rm -fR "$path"
+ else
+ >&2 printf 'error: populate doesn'"'"'t use the Git work tree %s, because %s. Fix that, remove the work tree, or set the source'"'"'s reclone to true to replace it by a fresh clone.\n' \
+ "$name" "$problems"
+ exit 1
+ fi
+ fi
+fi
+if ! test -e "$path"; then
+ if test -n "$shallow"; then
+ git init "$path"
+ else
+ git clone --recurse-submodules "$url" "$path"
+ fi
+fi
+cd "$path"
+if ! origin=$(git config remote.origin.url); then
+ git remote add origin "$url"
+elif test "$origin" != "$url"; then
+ git remote set-url origin "$url"
+fi
+# A commit, given by its hash or a prefix thereof, gets fetched only if the
+# work tree lacks it, unless fetchAlways is set. A branch or tag gets
+# fetched on every run, so the work tree follows it: resetting to its name
+# would reset to a local branch, which fetching doesn't move.
+fetch() {
+ if test -n "$shallow"; then
+ git fetch --depth=1 origin "$1"
+ else
+ git fetch origin "$1"
+ fi
+}
+fetch_commit() {
+ if test -n "$shallow"; then
+ fetch "$hash"
+ else
+ git fetch origin
+ fi
+}
+if printf %s "$ref" | grep -Eqx '[0-9a-f]{4,40}'; then
+ hash=$ref
+ if ! test "$(git log --format=%H -1)" = "$hash"; then
+ if test -n "$fetch_always"; then
+ fetch_commit
+ elif ! git log -1 "$hash" >/dev/null 2>&1; then
+ fetch_commit
+ fi
+ fi
+else
+ fetch "$ref"
+ hash=$(git rev-parse --verify 'FETCH_HEAD^{commit}')
+fi
+# Also if HEAD is there already: rewriting a lock file in place, e.g.,
+# changes the work tree, and stages the lock file if Nix created it.
+git reset --hard "$hash" >&2
+git submodule update --init --recursive
+n=$#
+while test "$n" -gt 0; do
+ set -- "$@" -e "$1"
+ shift
+ n=$((n - 1))
+done
+git clean -dfx "$@"
+"#
+}
+
+/// Shell code running on the deploying machine, in the source's working
+/// directory.
+pub mod local {
+ /// pass_info DIR NAME: prints what tells whether the pass source of the
+ /// passwords below NAME in the store DIR changed: the last commits of
+ /// NAME, and of what symlinks therein point to, whose changes the
+ /// commits of NAME miss. Prints nothing if the store isn't a Git
+ /// repository.
+ pub const PASS_INFO: &str = r#"
+dir=$1 name=$2
+if test -e "$dir"/.git; then
+ printf '%s %s\n' "$name" "$(
+ git -C "$dir" log -1 --format=%H "$name"
+ find "$dir"/"$name" -type l -exec realpath {} + |
+ sort |
+ xargs -r -n 1 git -C "$dir" log -1 --format=%H
+ )"
+fi
+"#;
+
+ /// pass DIR NAME INFO: decrypts the passwords below NAME in the store
+ /// DIR into the working directory, with the times of their last
+ /// commits, and INFO, unless empty, in .pass_info.
+ pub const PASS: &str = r#"
+dir=$1 name=$2 info=$3
+umask 0077
+chmod 0700 "$PWD"
+prefix=$dir/$name
+find "$prefix" -type f -follow ! -name .gpg-id |
+while read -r gpg_path; do
+ rel_name=${gpg_path#"$prefix"}
+ rel_name=${rel_name%.gpg}
+ pass_date=$(
+ if test -e "$dir"/.git; then
+ git -C "$dir" log -1 --format=%aI "$gpg_path"
+ fi
+ )
+ pass_name=$name/$rel_name
+ pass_out=$PWD/$rel_name
+ mkdir -p "$(dirname "$pass_out")"
+ PASSWORD_STORE_DIR=$dir pass show "$pass_name" > "$pass_out"
+ if [ -n "$pass_date" ]; then
+ touch -d "$pass_date" "$pass_out"
+ fi
+done
+if test -n "$info"; then
+ printf '%s\n' "$info" > "$PWD"/.pass_info
+fi
+"#;
+
+ /// passage HOST: decrypts the secrets in $PASSAGE_DIR into the working
+ /// directory, with the times of their files. HOST is the first label
+ /// of the target's host, for the command a failure prints.
+ pub const PASSAGE: &str = r#"
+host=$1
+umask 0077
+chmod 0700 "$PWD"
+find "$PASSAGE_DIR" -type f -name \*.age -follow |
+while read -r age_path; do
+ pass_name=${age_path#"$PASSAGE_DIR"/}
+ pass_name=${pass_name%.age}
+ pass_out=$PWD/$pass_name
+ mkdir -p "$(dirname "$pass_out")"
+ if ! passage show "$pass_name" > "$pass_out" 2>&1; then
+ echo ++ secrets "$host" show "$pass_name" >&2
+ jq -Rr 'select(test("^[^[]"))' "$pass_out" >&2
+ exit 1
+ fi
+ touch -r "$age_path" "$pass_out"
+done
+"#;
+}
+
+/// pass_info PATH: prints the .pass_info the pass source at PATH got last.
+pub const PASS_INFO: &str = r#"
+cat "$1"/.pass_info || :
+"#;