636 lines
26 KiB
Bash
Executable File
636 lines
26 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# `panama update`: the routine command, and the promises that make it routine.
|
|
#
|
|
# An update that asks questions is an update nobody runs, and a machine nobody
|
|
# updates drifts until the next reinstall. So the properties below are the
|
|
# whole point of the command rather than details of it:
|
|
#
|
|
# 1. --upgrade NEVER runs the interview. This is the regression that started
|
|
# the redesign: ./install asked the full first-install questionnaire every
|
|
# time, including on a machine whose answers could not have changed.
|
|
# 2. --upgrade runs exactly the stages that need no answer, and never
|
|
# setup-identity or install-hardware. Both act only on interview answers.
|
|
# 3. install-packages is gated on a content hash of the package lists, and
|
|
# the hash is NOT recorded when the stage fails -- the same rule
|
|
# panama-migrate applies to its markers, for the same reason.
|
|
# 4. A conflicted `git stash pop` leaves no conflict markers in the tree.
|
|
# Every dotfile here is a symlink into the checkout, so a half-merged file
|
|
# is not something to fix later: it is live in ~/.config immediately, and
|
|
# a broken .qml costs the running shell.
|
|
#
|
|
# Driven against fixture stages in a throwaway PANAMA_PATH, with sudo and
|
|
# gsettings shimmed, so this never touches the machine running it.
|
|
|
|
set -uo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
installer="$repo_dir/install"
|
|
panama="$repo_dir/bin/panama"
|
|
|
|
findings=()
|
|
note() { findings+=("$1"); }
|
|
|
|
[[ -x "$installer" ]] || { printf 'update command contract: no installer at %s\n' "$installer" >&2; exit 1; }
|
|
|
|
tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
|
|
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
|
|
link-vicinae-scripts setup-server link-server setup-identity
|
|
install-hardware)
|
|
|
|
copy_hash_inputs() {
|
|
local root="$1" source relative
|
|
while IFS= read -r -d '' source; do
|
|
relative="${source#"$repo_dir"/}"
|
|
mkdir -p "$(dirname "$root/$relative")"
|
|
cp -- "$source" "$root/$relative"
|
|
done < <(
|
|
find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0
|
|
find "$repo_dir/setup/provenance" -type f -print0
|
|
)
|
|
mkdir -p "$root/setup/lib"
|
|
cp -- "$repo_dir/setup/lib/artifact-provenance" "$root/setup/lib/artifact-provenance"
|
|
}
|
|
|
|
# A PANAMA_PATH that looks enough like the real one for install to run, and
|
|
# records what it was asked to do instead of doing it.
|
|
build_fixture() {
|
|
local root="$1" packages_rc="${2:-0}" trust_rc="${3:-0}"
|
|
rm -rf "$root"
|
|
mkdir -p "$root/bin" "$root/setup/scripts" "$root/setup/packages" \
|
|
"$root/setup/lib" "$root/setup/provenance/keys" \
|
|
"$root/config/dot/quickshell/scripts"
|
|
|
|
cp "$installer" "$root/install"
|
|
: >"$root/bin/ascii"
|
|
copy_hash_inputs "$root"
|
|
|
|
local stage
|
|
for stage in "${STAGE_NAMES[@]}"; do
|
|
cat >"$root/setup/scripts/$stage" <<EOF
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "$stage" >>"\$PANAMA_RAN"
|
|
EOF
|
|
chmod +x "$root/setup/scripts/$stage"
|
|
done
|
|
# The one stage whose exit code the caller wants to control.
|
|
cat >"$root/setup/scripts/install-packages" <<EOF
|
|
#!/usr/bin/env bash
|
|
if [[ "\${1:-}" == --trust-preflight ]]; then
|
|
printf 'trust-preflight\n' >>"\$PANAMA_RAN"
|
|
exit $trust_rc
|
|
fi
|
|
printf 'install-packages\n' >>"\$PANAMA_RAN"
|
|
exit $packages_rc
|
|
EOF
|
|
chmod +x "$root/setup/scripts/install-packages"
|
|
|
|
cat >"$root/setup/scripts/interview" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'interview\n' >>"$PANAMA_RAN"
|
|
: >"$PANAMA_ANSWERS"
|
|
EOF
|
|
chmod +x "$root/setup/scripts/interview"
|
|
|
|
cat >"$root/bin/panama-migrate" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'migrate %s\n' "${1:-run}" >>"$PANAMA_RAN"
|
|
EOF
|
|
chmod +x "$root/bin/panama-migrate"
|
|
|
|
cat >"$root/config/dot/quickshell/scripts/panama-doctor" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'doctor\n' >>"$PANAMA_RAN"
|
|
EOF
|
|
chmod +x "$root/config/dot/quickshell/scripts/panama-doctor"
|
|
|
|
# Nothing that reaches the real machine. sudo would prompt in a test run,
|
|
# and gsettings would genuinely change the tester's screensaver.
|
|
mkdir -p "$root/shim"
|
|
cat >"$root/shim/sudo" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
[[ "${1:-}" == -v || "${1:-}" == -n ]] && exit 0
|
|
exit 0
|
|
EOF
|
|
cat >"$root/shim/gsettings" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
exit 0
|
|
EOF
|
|
cat >"$root/shim/hostnamectl" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
exit 0
|
|
EOF
|
|
cat >"$root/shim/dnf" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'dnf-transaction\n' >>"$PANAMA_RAN"
|
|
exit 0
|
|
EOF
|
|
for prerequisite in gum lspci mokutil fwupdmgr; do
|
|
ln -s gsettings "$root/shim/$prerequisite"
|
|
done
|
|
chmod +x "$root/shim"/*
|
|
}
|
|
|
|
# Run the fixture installer and echo what ran, one stage per line.
|
|
run_install() {
|
|
local root="$1"; shift
|
|
local status=0
|
|
: >"$root/ran"
|
|
PATH="$root/shim:$PATH" PANAMA_PATH="$root" PANAMA_RAN="$root/ran" \
|
|
XDG_STATE_HOME="$root/state" bash "$root/install" "$@" \
|
|
>"$root/out" 2>&1 || status=$?
|
|
cat "$root/ran"
|
|
return "$status"
|
|
}
|
|
|
|
run_hash() {
|
|
local root="$1"
|
|
sed -n '/^hash_packages() {/,/^}$/p' "$root/install" >"$root/hash-only"
|
|
printf 'set -uo pipefail\nhash_packages\n' >>"$root/hash-only"
|
|
PANAMA_PATH="$root" bash "$root/hash-only" 2>"$root/hash-only.err"
|
|
}
|
|
|
|
assert_hash_failure() {
|
|
local root="$1" description="$2" status=0 digest
|
|
digest="$(run_hash "$root")" || status=$?
|
|
[[ "$status" -ne 0 && -z "$digest" ]] \
|
|
|| note "$description produced a digest instead of failing closed"
|
|
}
|
|
|
|
# ── 1. The interview never runs on an upgrade ────────────────────────────────
|
|
|
|
build_fixture "$tmp/a"
|
|
install_status=0
|
|
ran="$(run_install "$tmp/a" --upgrade)" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "initial install --upgrade failed with status $install_status"
|
|
|
|
if grep -qx 'interview' <<<"$ran"; then
|
|
note 'install --upgrade ran the interview, which is the whole regression this prevents'
|
|
fi
|
|
|
|
# And the control: a real install must still ask.
|
|
build_fixture "$tmp/b"
|
|
install_status=0
|
|
ran_install="$(run_install "$tmp/b")" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "plain fixture install failed with status $install_status"
|
|
if ! grep -qx 'interview' <<<"$ran_install"; then
|
|
note 'a plain ./install no longer asks the interview, so a new machine is never configured'
|
|
fi
|
|
|
|
# ── 2. Exactly the answer-free stages ────────────────────────────────────────
|
|
|
|
for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts; do
|
|
grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage"
|
|
done
|
|
for stage in setup-identity install-hardware; do
|
|
grep -qx "$stage" <<<"$ran" \
|
|
&& note "install --upgrade ran $stage, which exists only to act on interview answers"
|
|
done
|
|
|
|
# A stage added to STAGES without a decision about which path owns it shows up
|
|
# here, because this list is written down twice on purpose.
|
|
declared=()
|
|
if declared_output="$(python3 - "$installer" <<'PY'
|
|
import re
|
|
import sys
|
|
|
|
found = False
|
|
for line in open(sys.argv[1], encoding="utf-8"):
|
|
match = re.match(r'^\s*STAGES=\((.*)\)\s*$', line)
|
|
if match and "$" not in match.group(1):
|
|
found = True
|
|
print("\n".join(re.findall(r"[\w-]+", match.group(1))))
|
|
if not found:
|
|
print("install has no STAGES assignment", file=sys.stderr)
|
|
sys.exit(1)
|
|
PY
|
|
)"; then
|
|
while IFS= read -r stage; do
|
|
[[ -n "$stage" ]] && declared+=("$stage")
|
|
done <<<"$declared_output"
|
|
else
|
|
note 'could not read the install STAGES assignment'
|
|
fi
|
|
for stage in "${declared[@]}"; do
|
|
printf '%s\n' "${STAGE_NAMES[@]}" | grep -qx "$stage" \
|
|
|| note "install declares a stage this contract has never heard of: $stage"
|
|
done
|
|
|
|
# ── 3. The packages hash gates the stage, and a failure does not record it ───
|
|
|
|
package_inputs=()
|
|
while IFS= read -r -d '' input; do
|
|
package_inputs+=("${input#"$repo_dir"/}")
|
|
done < <(find "$repo_dir/setup/packages" -maxdepth 1 -type f -print0)
|
|
provenance_inputs=()
|
|
while IFS= read -r -d '' input; do
|
|
provenance_inputs+=("${input#"$repo_dir"/}")
|
|
done < <(find "$repo_dir/setup/provenance" -type f -print0)
|
|
(( ${#package_inputs[@]} > 0 )) \
|
|
|| note 'the current repository has no top-level package input to exercise'
|
|
(( ${#provenance_inputs[@]} > 0 )) \
|
|
|| note 'the current repository has no provenance input to exercise'
|
|
|
|
# Second run, nothing changed: the stage must be skipped.
|
|
install_status=0
|
|
ran_again="$(run_install "$tmp/a" --upgrade)" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "repeat install --upgrade failed with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_again" \
|
|
&& note 'install-packages ran again with the package lists unchanged'
|
|
|
|
# --packages overrides the hash.
|
|
install_status=0
|
|
ran_forced="$(run_install "$tmp/a" --upgrade --packages)" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "install --upgrade --packages failed with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_forced" \
|
|
|| note '--packages did not force install-packages to run'
|
|
|
|
# Every current package and provenance member is part of the state definition.
|
|
# Dynamically discovering them makes this fail when a new reviewed input is
|
|
# added but omitted from hash_packages.
|
|
for relative in "${package_inputs[@]}" "${provenance_inputs[@]}" \
|
|
'setup/scripts/install-packages' 'setup/lib/artifact-provenance'; do
|
|
printf 'changed %s\n' "$relative" >>"$tmp/a/$relative"
|
|
install_status=0
|
|
ran_input_changed="$(run_install "$tmp/a" --upgrade)" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "install --upgrade failed after changing $relative with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_input_changed" \
|
|
|| note "a changed $relative did not bring install-packages back"
|
|
done
|
|
|
|
# A path-only change must invalidate state even when the file bytes are exact.
|
|
for relative in "${package_inputs[0]:-}" "${provenance_inputs[0]:-}"; do
|
|
[[ -n "$relative" ]] || continue
|
|
build_fixture "$tmp/path-rename"
|
|
run_install "$tmp/path-rename" --upgrade >/dev/null
|
|
mv -- "$tmp/path-rename/$relative" "$tmp/path-rename/$relative.renamed"
|
|
install_status=0
|
|
ran_renamed="$(run_install "$tmp/path-rename" --upgrade)" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "install --upgrade failed after renaming $relative with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_renamed" \
|
|
|| note "renaming $relative without changing bytes did not bring install-packages back"
|
|
done
|
|
|
|
# Fixed hash inputs must not silently disappear or degrade into a directory or
|
|
# link. An unreadable package input also proves a failed content read cannot be
|
|
# hidden by the final digest command.
|
|
for fixed_input in setup/scripts/install-packages setup/lib/artifact-provenance; do
|
|
for case_name in missing directory symlink unreadable; do
|
|
case_root="$tmp/hash-${fixed_input//\//-}-$case_name"
|
|
build_fixture "$case_root"
|
|
fixed_path="$case_root/$fixed_input"
|
|
case "$case_name" in
|
|
missing) rm -- "$fixed_path" ;;
|
|
directory) rm -- "$fixed_path"; mkdir -- "$fixed_path" ;;
|
|
symlink)
|
|
printf 'untrusted target\n' >"$case_root/untrusted-target"
|
|
rm -- "$fixed_path"
|
|
ln -s "$case_root/untrusted-target" "$fixed_path"
|
|
;;
|
|
unreadable) chmod 000 "$fixed_path" ;;
|
|
esac
|
|
assert_hash_failure "$case_root" "$fixed_input $case_name"
|
|
done
|
|
done
|
|
read_failure_root="$tmp/hash-package-read-failure"
|
|
build_fixture "$read_failure_root"
|
|
chmod 000 "$read_failure_root/${package_inputs[0]}"
|
|
assert_hash_failure "$read_failure_root" "${package_inputs[0]} unreadable"
|
|
|
|
# A hash failure is an installer failure, not a reason to skip the package
|
|
# stage and retain a stale stamp.
|
|
build_fixture "$tmp/hash-failure"
|
|
run_install "$tmp/hash-failure" --upgrade >/dev/null
|
|
cp -- "$tmp/hash-failure/state/panama/packages-hash" "$tmp/hash-failure/stamp-before"
|
|
rm -- "$tmp/hash-failure/setup/lib/artifact-provenance"
|
|
install_status=0
|
|
ran_hash_failure="$(run_install "$tmp/hash-failure" --upgrade)" || install_status=$?
|
|
[[ "$install_status" -ne 0 ]] \
|
|
|| note 'a failed package-state hash returned success'
|
|
grep -qx 'install-packages' <<<"$ran_hash_failure" \
|
|
&& note 'a failed package-state hash still ran install-packages'
|
|
cmp -s -- "$tmp/hash-failure/stamp-before" "$tmp/hash-failure/state/panama/packages-hash" \
|
|
|| note 'a failed package-state hash wrote a new packages-hash stamp'
|
|
|
|
# A failing stage must not record the hash, or the failure is hidden forever.
|
|
build_fixture "$tmp/c" 1
|
|
install_status=0
|
|
run_install "$tmp/c" --upgrade >/dev/null || install_status=$?
|
|
[[ "$install_status" -eq 1 ]] \
|
|
|| note "install --upgrade returned $install_status instead of the failing stage status"
|
|
if [[ -r "$tmp/c/state/panama/packages-hash" ]]; then
|
|
note 'install-packages failed but its hash was recorded, so it will never be retried'
|
|
fi
|
|
grep -qx 'link-dotfiles' "$tmp/c/ran" \
|
|
|| note 'an ordinary package-stage failure no longer allows later safe stages'
|
|
|
|
# An invalid enabled Terra root is not an ordinary package failure. It must
|
|
# stop before the installer's bootstrap DNF and before every stage.
|
|
build_fixture "$tmp/terra-preflight-hard" 0 78
|
|
install_status=0
|
|
run_install "$tmp/terra-preflight-hard" >/dev/null || install_status=$?
|
|
[[ "$install_status" -eq 78 ]] \
|
|
|| note "initial Terra trust failure returned $install_status instead of 78"
|
|
asserted_preflight="$(<"$tmp/terra-preflight-hard/ran")"
|
|
[[ "$asserted_preflight" == trust-preflight ]] \
|
|
|| note "initial Terra trust failure allowed later work: ${asserted_preflight//$'\n'/,}"
|
|
|
|
# The trust verifier is itself mandatory. Losing its executable adapter must
|
|
# fail closed before interview, bootstrap, or stage work.
|
|
build_fixture "$tmp/terra-preflight-missing"
|
|
rm "$tmp/terra-preflight-missing/setup/scripts/install-packages"
|
|
install_status=0
|
|
run_install "$tmp/terra-preflight-missing" >/dev/null || install_status=$?
|
|
[[ "$install_status" -eq 78 ]] \
|
|
|| note "missing Terra trust verifier returned $install_status instead of 78"
|
|
[[ ! -s "$tmp/terra-preflight-missing/ran" ]] \
|
|
|| note 'missing Terra trust verifier allowed later work'
|
|
|
|
# The package stage repeats the preflight to close a configuration-change race.
|
|
# Its hard status must also stop link stages and install-hardware immediately.
|
|
build_fixture "$tmp/terra-stage-hard" 78 0
|
|
install_status=0
|
|
run_install "$tmp/terra-stage-hard" >/dev/null || install_status=$?
|
|
[[ "$install_status" -eq 78 ]] \
|
|
|| note "stage-time Terra trust failure returned $install_status instead of 78"
|
|
grep -qx 'install-packages' "$tmp/terra-stage-hard/ran" \
|
|
|| note 'stage-time Terra trust fixture never reached install-packages'
|
|
for suppressed in link-dotfiles link-skills link-user change-settings install-hardware dnf-transaction; do
|
|
grep -qx "$suppressed" "$tmp/terra-stage-hard/ran" \
|
|
&& note "stage-time Terra trust failure still ran $suppressed"
|
|
done
|
|
|
|
# A full install always runs the stage, whatever any recorded hash says.
|
|
build_fixture "$tmp/d"
|
|
install_status=0
|
|
run_install "$tmp/d" --upgrade >/dev/null || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "hash-seeding install --upgrade failed with status $install_status"
|
|
install_status=0
|
|
ran_full="$(run_install "$tmp/d")" || install_status=$?
|
|
[[ "$install_status" -eq 0 ]] \
|
|
|| note "full fixture reinstall failed with status $install_status"
|
|
grep -qx 'install-packages' <<<"$ran_full" \
|
|
|| note 'a full ./install skipped install-packages because of a recorded hash'
|
|
|
|
# ── 4. A conflicted pop never leaves markers in a live config ────────────────
|
|
#
|
|
# The fixture also covers a clean fast-forward, installer status propagation,
|
|
# and the boundary between update and sync before forcing the conflict below.
|
|
|
|
# Make every ambient configuration source hostile before constructing the Git
|
|
# fixtures. A hermetic fixture overrides these values with its own empty state;
|
|
# consuming any of them either leaves a sentinel or prevents a commit.
|
|
hostile="$tmp/hostile-environment"
|
|
mkdir -p "$hostile/home" "$hostile/xdg-config" "$hostile/xdg-state" \
|
|
"$hostile/xdg-cache" "$hostile/xdg-data" "$hostile/hooks" \
|
|
"$hostile/template/hooks"
|
|
for profile in .bash_profile .bashrc .profile; do
|
|
cat >"$hostile/home/$profile" <<'EOF'
|
|
printf 'profile\n' >>"${PANAMA_HOSTILE_PROFILE_SENTINEL:?}"
|
|
EOF
|
|
done
|
|
cat >"$hostile/bash-env" <<'EOF'
|
|
printf 'BASH_ENV\n' >>"${PANAMA_HOSTILE_BASH_ENV_SENTINEL:?}"
|
|
EOF
|
|
cat >"$hostile/hooks/pre-commit" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
printf 'global hook\n' >>"${PANAMA_HOSTILE_GIT_SENTINEL:?}"
|
|
exit 97
|
|
EOF
|
|
cat >"$hostile/template/hooks/pre-commit" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
# PANAMA_HOSTILE_TEMPLATE_HOOK
|
|
printf 'template hook\n' >>"${PANAMA_HOSTILE_TEMPLATE_SENTINEL:?}"
|
|
exit 98
|
|
EOF
|
|
chmod +x "$hostile/hooks/pre-commit" "$hostile/template/hooks/pre-commit"
|
|
cat >"$hostile/global.gitconfig" <<EOF
|
|
[core]
|
|
hooksPath = $hostile/hooks
|
|
[commit]
|
|
gpgSign = true
|
|
[init]
|
|
templateDir = $hostile/template
|
|
EOF
|
|
cp "$hostile/global.gitconfig" "$hostile/system.gitconfig"
|
|
|
|
export HOME="$hostile/home"
|
|
export XDG_CONFIG_HOME="$hostile/xdg-config"
|
|
export XDG_STATE_HOME="$hostile/xdg-state"
|
|
export XDG_CACHE_HOME="$hostile/xdg-cache"
|
|
export XDG_DATA_HOME="$hostile/xdg-data"
|
|
export BASH_ENV="$hostile/bash-env"
|
|
export PANAMA_HOSTILE_PROFILE_SENTINEL="$hostile/profile-sourced"
|
|
export PANAMA_HOSTILE_BASH_ENV_SENTINEL="$hostile/bash-env-sourced"
|
|
export PANAMA_HOSTILE_GIT_SENTINEL="$hostile/global-config-sourced"
|
|
export PANAMA_HOSTILE_TEMPLATE_SENTINEL="$hostile/template-hook-sourced"
|
|
export GIT_CONFIG_NOSYSTEM=0
|
|
export GIT_CONFIG_SYSTEM="$hostile/system.gitconfig"
|
|
export GIT_CONFIG_GLOBAL="$hostile/global.gitconfig"
|
|
export GIT_CONFIG_COUNT=1
|
|
export GIT_CONFIG_KEY_0=core.hooksPath
|
|
export GIT_CONFIG_VALUE_0="$hostile/hooks"
|
|
export GIT_TEMPLATE_DIR="$hostile/template"
|
|
|
|
prepare_cli_fixture_environment() {
|
|
local root="$1"
|
|
mkdir -p "$root/home" "$root/xdg-config" "$root/xdg-state" \
|
|
"$root/xdg-cache" "$root/xdg-data" "$root/xdg-runtime" \
|
|
"$root/empty-templates" "$root/empty-hooks"
|
|
chmod 700 "$root/xdg-runtime"
|
|
}
|
|
|
|
run_cli_fixture_environment() {
|
|
local root="$1"
|
|
shift
|
|
env -u BASH_ENV -u ENV -u GIT_CONFIG_PARAMETERS \
|
|
-u GIT_CONFIG_KEY_0 -u GIT_CONFIG_VALUE_0 \
|
|
HOME="$root/home" \
|
|
XDG_CONFIG_HOME="$root/xdg-config" \
|
|
XDG_STATE_HOME="$root/xdg-state" \
|
|
XDG_CACHE_HOME="$root/xdg-cache" \
|
|
XDG_DATA_HOME="$root/xdg-data" \
|
|
XDG_RUNTIME_DIR="$root/xdg-runtime" \
|
|
GIT_CONFIG_NOSYSTEM=1 \
|
|
GIT_CONFIG_SYSTEM=/dev/null \
|
|
GIT_CONFIG_GLOBAL=/dev/null \
|
|
GIT_CONFIG_COUNT=0 \
|
|
GIT_TEMPLATE_DIR="$root/empty-templates" \
|
|
"$@"
|
|
}
|
|
|
|
fixture_git() {
|
|
local root="$1"
|
|
shift
|
|
run_cli_fixture_environment "$root" \
|
|
git -c commit.gpgSign=false -c tag.gpgSign=false \
|
|
-c core.hooksPath="$root/empty-hooks" "$@"
|
|
}
|
|
|
|
configure_fixture_repo() {
|
|
local root="$1" repository="$2"
|
|
fixture_git "$root" -C "$repository" config user.email contract@panama || return 1
|
|
fixture_git "$root" -C "$repository" config user.name contract || return 1
|
|
fixture_git "$root" -C "$repository" config commit.gpgSign false || return 1
|
|
fixture_git "$root" -C "$repository" config tag.gpgSign false || return 1
|
|
fixture_git "$root" -C "$repository" config core.hooksPath "$root/empty-hooks" || return 1
|
|
}
|
|
|
|
# Each fixture has the same three repositories as a real update: a bare remote,
|
|
# a clone that publishes upstream changes, and the machine clone being updated.
|
|
build_cli_fixture() (
|
|
local root="$1"
|
|
rm -rf "$root" || return 1
|
|
mkdir -p "$root" || return 1
|
|
prepare_cli_fixture_environment "$root" || return 1
|
|
fixture_git "$root" init -q --bare "$root/origin.git" || return 1
|
|
fixture_git "$root" -C "$root/origin.git" config core.hooksPath "$root/empty-hooks" || return 1
|
|
fixture_git "$root" clone -q "$root/origin.git" "$root/upstream" 2>/dev/null || return 1
|
|
configure_fixture_repo "$root" "$root/upstream" || return 1
|
|
|
|
mkdir -p "$root/upstream/bin" || return 1
|
|
cp "$panama" "$root/upstream/bin/panama" || return 1
|
|
cat >"$root/upstream/install" <<'EOF' || return 1
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "$*" >>"${PANAMA_UPDATE_FIXTURE_LOG:?}"
|
|
exit "${PANAMA_UPDATE_INSTALL_RC:-0}"
|
|
EOF
|
|
chmod +x "$root/upstream/bin/panama" "$root/upstream/install" || return 1
|
|
printf 'one\n' >"$root/upstream/f" || return 1
|
|
fixture_git "$root" -C "$root/upstream" add -A || return 1
|
|
fixture_git "$root" -C "$root/upstream" commit -qm initial || return 1
|
|
fixture_git "$root" -C "$root/upstream" push -qu origin HEAD || return 1
|
|
|
|
fixture_git "$root" clone -q "$root/origin.git" "$root/machine" || return 1
|
|
configure_fixture_repo "$root" "$root/machine" || return 1
|
|
)
|
|
|
|
advance_upstream() (
|
|
local root="$1" file="$2" contents="$3"
|
|
printf '%s\n' "$contents" >"$root/upstream/$file" || return 1
|
|
fixture_git "$root" -C "$root/upstream" add "$file" || return 1
|
|
fixture_git "$root" -C "$root/upstream" commit -qm "update $file" || return 1
|
|
fixture_git "$root" -C "$root/upstream" push -q || return 1
|
|
)
|
|
|
|
# This write fails before the later Git commands. The helper must return that
|
|
# failure rather than let a final successful push hide it.
|
|
helper_probe="$tmp/helper-failure"
|
|
if build_cli_fixture "$helper_probe"; then
|
|
if advance_upstream "$helper_probe" missing/child probe \
|
|
2>"$helper_probe/intermediate-failure.err"; then
|
|
note 'advance_upstream hid an intermediate fixture setup failure'
|
|
fi
|
|
else
|
|
note 'the fixture helper failure probe could not be built'
|
|
fi
|
|
|
|
clean="$tmp/clean-update"
|
|
if build_cli_fixture "$clean" && advance_upstream "$clean" release new; then
|
|
machine_before="$(fixture_git "$clean" -C "$clean/machine" rev-parse HEAD)"
|
|
upstream_after="$(fixture_git "$clean" -C "$clean/upstream" rev-parse HEAD)"
|
|
[[ "$machine_before" != "$upstream_after" ]] \
|
|
|| note 'the clean update fixture started current, so it cannot prove a fast-forward'
|
|
|
|
: >"$clean/install.log"
|
|
update_status=0
|
|
run_cli_fixture_environment "$clean" \
|
|
PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" \
|
|
"$clean/machine/bin/panama" update >"$clean/update.out" 2>&1 \
|
|
|| update_status=$?
|
|
[[ "$update_status" -eq 0 ]] \
|
|
|| note "panama update failed on a clean clone with status $update_status"
|
|
[[ "$(fixture_git "$clean" -C "$clean/machine" rev-parse HEAD)" == "$upstream_after" ]] \
|
|
|| note 'panama update did not fast-forward the clean machine clone'
|
|
grep -qx -- '--upgrade' "$clean/install.log" \
|
|
|| note 'panama update did not invoke the installer with --upgrade'
|
|
|
|
: >"$clean/install.log"
|
|
update_status=0
|
|
run_cli_fixture_environment "$clean" \
|
|
PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" PANAMA_UPDATE_INSTALL_RC=23 \
|
|
"$clean/machine/bin/panama" update >"$clean/failing-update.out" 2>&1 \
|
|
|| update_status=$?
|
|
[[ "$update_status" -eq 23 ]] \
|
|
|| note "panama update returned $update_status instead of installer status 23"
|
|
grep -qx -- '--upgrade' "$clean/install.log" \
|
|
|| note 'the failing update did not reach the fixture installer'
|
|
|
|
: >"$clean/install.log"
|
|
printf 'local sync\n' >"$clean/machine/synced"
|
|
sync_status=0
|
|
printf 'y\ncontract sync\n' \
|
|
| run_cli_fixture_environment "$clean" \
|
|
PANAMA_UPDATE_FIXTURE_LOG="$clean/install.log" \
|
|
"$clean/machine/bin/panama" sync >"$clean/sync.out" 2>&1 \
|
|
|| sync_status=$?
|
|
[[ "$sync_status" -eq 0 ]] \
|
|
|| note "panama sync failed in the local fixture with status $sync_status"
|
|
[[ ! -s "$clean/install.log" ]] \
|
|
|| note 'panama sync invoked the installer; sync and update are separate jobs'
|
|
else
|
|
note 'the clean update fixture could not be built'
|
|
fi
|
|
|
|
# A conflicted stash pop must not leave markers in the checkout. The failed pop
|
|
# keeps the stash, so the local version remains recoverable after the reset.
|
|
conflict="$tmp/conflict-update"
|
|
if build_cli_fixture "$conflict"; then
|
|
printf 'local\n' >"$conflict/machine/f"
|
|
if advance_upstream "$conflict" f upstream; then
|
|
: >"$conflict/install.log"
|
|
conflict_status=0
|
|
run_cli_fixture_environment "$conflict" \
|
|
PANAMA_UPDATE_FIXTURE_LOG="$conflict/install.log" \
|
|
"$conflict/machine/bin/panama" update >"$conflict/update.out" 2>&1 \
|
|
|| conflict_status=$?
|
|
[[ "$conflict_status" -eq 0 ]] \
|
|
|| note "panama update failed while recovering a stash conflict with status $conflict_status"
|
|
[[ "$(<"$conflict/machine/f")" == upstream ]] \
|
|
|| note 'panama update did not reset the conflicted file to the upstream version'
|
|
if fixture_git "$conflict" -C "$conflict/machine" \
|
|
grep -qE '^(<<<<<<<|=======|>>>>>>>)' -- .; then
|
|
note 'panama update left conflict markers in the machine checkout'
|
|
fi
|
|
[[ -n "$(fixture_git "$conflict" -C "$conflict/machine" stash list)" ]] \
|
|
|| note 'panama update dropped the stash after its conflicted pop'
|
|
recovered="$(fixture_git "$conflict" -C "$conflict/machine" \
|
|
show 'stash@{0}:f' 2>/dev/null)"
|
|
[[ "$recovered" == local ]] \
|
|
|| note 'the stash left by panama update does not contain the local version'
|
|
else
|
|
note 'the conflict fixture could not publish its upstream edit'
|
|
fi
|
|
else
|
|
note 'the conflict update fixture could not be built'
|
|
fi
|
|
|
|
for sentinel in profile-sourced bash-env-sourced global-config-sourced template-hook-sourced; do
|
|
[[ ! -e "$hostile/$sentinel" ]] \
|
|
|| note "the update Git fixture consumed hostile state: $sentinel"
|
|
done
|
|
template_copy="$(
|
|
find "$tmp" -path "$hostile" -prune -o \
|
|
-type f -path '*/hooks/pre-commit' \
|
|
-exec grep -lF 'PANAMA_HOSTILE_TEMPLATE_HOOK' {} + 2>/dev/null
|
|
)"
|
|
[[ -z "$template_copy" ]] \
|
|
|| note "the update Git fixture copied a hostile template hook: $template_copy"
|
|
|
|
if (( ${#findings[@]} > 0 )); then
|
|
printf 'update command contract: %d finding(s)\n' "${#findings[@]}" >&2
|
|
printf ' - %s\n' "${findings[@]}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf 'update command contract: PASS\n'
|