Files
Panama/install
T
Gabriel Brown fa8b14e05e Fix: Adopt a Terra the machine already trusts
The repository audit made any Terra that is not Panama's own pinned form a
trust-root failure, and status 78 then stopped every stage before it ran. A
machine that installed Terra the way Terra documents it -- terra-release's own
repo file, a metalink, the key at its stock path -- was classified hostile and
had no way back, because install_terra_repository refused to touch a machine
terra-release had already reached. A gate with no door.

The trust root is the signing key, and that key is byte-for-byte the
fingerprint this repository reviewed and pinned, with every signature check
already on. So verify the fingerprint and adopt the configuration into the
pinned form instead of refusing it. Adoption needs no network and no DNF, it
runs before any other transaction in the stage, and it is repeatable, which it
has to be: terra-release owns that file and restores it on update.

Adoption stays narrow. The pinned fingerprint must match both the reviewed key
and the key the machine actually verifies against, the gpgkey must be a local
file under the system trust directory, and the endpoint must be one Terra
itself serves -- so the reviewed baseurl or the reviewed metalink host, now
pinned as TERRA_METALINK_BASEURL. An unknown key, a redirected baseurl, a
second enabled Terra, or a disabled signature check is still a hard refusal.

A refusal also stops less than it did. It suppresses the stages that open DNF
and the migrations, which may run a transaction of their own, and the run still
exits 78. It no longer stops link-dotfiles, link-skills or link-user, which
read no repository and install no package. Exiting before them is what left
this laptop with a stale ~/.claude/skills and no shipped skill reachable.

Also stub ensure_flathub_remote in the extras contract, which has been failing
since that call was added to install_extra_category without one.

Claude-Session: https://claude.ai/code/session_01PeTrG9dGY89UWuhGm4Pr1s
2026-08-28 14:53:48 -04:00

515 lines
24 KiB
Bash
Executable File

#!/usr/bin/env bash
# Panama's installer. Safe to re-run: every stage is idempotent, and this is
# also the upgrade path.
#
# ./install A machine being built. Asks the interview, runs
# every stage, enrolls hardware.
# ./install --upgrade A machine that already exists. Asks nothing.
#
# Two entry points, one stage list, deliberately in one file. `panama update`
# passes --upgrade; if the upgrade path owned a second copy of STAGES the two
# would drift the first time somebody added a stage to one of them, and the
# symptom would be a stage that silently never runs. Keeping the lists together
# means the decision about which path owns a new stage is made in view of the
# other one.
#
# What --upgrade changes, and nothing else:
#
# * The interview is skipped, so every PANAMA_* answer is unset and each
# stage takes its documented empty-answer path. Six of the eight need no
# answer at all; link-user falls back to the decision it recorded.
# * setup-identity and install-hardware are dropped. They exist only to
# consume interview answers -- git identity, NVIDIA, Secure Boot, firmware
# -- and every one of those is a first-run decision.
# * install-packages runs only when its tracked installation inputs changed.
# * Migrations always run rather than baseline. See the migrations block.
#
# Everything else is shared on purpose: the sudo keepalive, the per-stage
# failure collection, migrations, the health summary and the post-upgrade hook.
set -uo pipefail
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
UPGRADE=0
FORCE_PACKAGES=0
ROLE_PRESET=""
for arg in "$@"; do
case "$arg" in
--upgrade) UPGRADE=1 ;;
--packages) FORCE_PACKAGES=1 ;;
--server) ROLE_PRESET=server ;;
-h|--help)
cat <<'USAGE'
usage: install [--upgrade] [--packages] [--server]
(no arguments) Build this machine. Asks the interview, runs every stage.
--upgrade Update a machine that already exists. Asks nothing, and
skips setup-identity and install-hardware.
--packages Run install-packages even when the lists are unchanged.
Only meaningful with --upgrade; a full install always runs it.
--server Answer the interview's role question with 'server' without
being asked -- the curl-onto-a-fresh-VPS path. The rest of
the interview still runs. Meaningless with --upgrade, which
reads the role this machine already recorded.
USAGE
exit 0 ;;
*)
printf 'install: unknown argument: %s\n' "$arg" >&2
printf "Run './install --help' to see what it takes.\n" >&2
exit 2 ;;
esac
done
source "$PANAMA_PATH/bin/ascii"
# ── Have the installation inputs changed? ───────────────────────────────────
#
# install-packages is the slow stage -- a dnf metadata refresh, a Flathub
# round-trip, and a transaction that resolves to "nothing to do" almost every
# time. On an upgrade it is worth running only when its package lists or
# reviewed installer trust inputs changed, so this hashes them and remembers
# the result. The framed, sorted stream includes top-level package files, the
# package-stage adapter, every helper it sources, and regular provenance files;
# both relative paths and bytes are part of the state.
#
# A content hash rather than a git range, because Panama is developed in place:
# a package added to a list and not yet committed must still install. A range
# check would see nothing, and the package would arrive whenever the commit
# happened to be pulled somewhere else.
#
# -maxdepth 1 excludes setup/packages/extras/. An answer-free run has an empty
# PANAMA_EXTRAS and installs no optional category, so hashing those files would
# flip the hash, run the stage, install nothing, and record the new hash as
# though it had. Optional categories cannot be re-applied by an upgrade at all
# -- which ones this machine chose is nowhere on disk, because the interview's
# answers are deliberately transient -- and `panama apps` is the tool for that.
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
PACKAGES_HASH="$STATE_DIR/packages-hash"
hash_packages() {
local file relative size fixed_input digest
for fixed_input in \
"$PANAMA_PATH/setup/scripts/install-packages" \
"$PANAMA_PATH/setup/lib/artifact-provenance" \
"$PANAMA_PATH/setup/lib/chatgpt-package" \
"$PANAMA_PATH/setup/lib/extras-catalog" \
"$PANAMA_PATH/setup/lib/machine-role"; do
[[ -f "$fixed_input" && ! -L "$fixed_input" && -r "$fixed_input" ]] || return 1
done
digest="$(
{
find "$PANAMA_PATH/setup/packages" -maxdepth 1 -type f -print0 || exit 1
printf '%s\0' \
"$PANAMA_PATH/setup/scripts/install-packages" \
"$PANAMA_PATH/setup/lib/artifact-provenance" \
"$PANAMA_PATH/setup/lib/chatgpt-package" \
"$PANAMA_PATH/setup/lib/extras-catalog" \
"$PANAMA_PATH/setup/lib/machine-role" || exit 1
find "$PANAMA_PATH/setup/provenance" -type f -print0 || exit 1
} | LC_ALL=C sort -z | while IFS= read -r -d '' file; do
relative="${file#"$PANAMA_PATH"/}"
size="$(wc -c <"$file")" || exit 1
printf '%s\0%s\0' "$relative" "$size" || exit 1
cat -- "$file" || exit 1
printf '\0' || exit 1
done | sha256sum | cut -d' ' -f1
)" || return 1
printf '%s\n' "$digest"
}
packages_needed() {
local current_hash="$1" recorded_hash
(( FORCE_PACKAGES )) && return 0
(( UPGRADE )) || return 0
[[ -r "$PACKAGES_HASH" ]] || return 0
recorded_hash="$(cat "$PACKAGES_HASH")" || return 2
[[ "$current_hash" != "$recorded_hash" ]]
}
# Written only after the stage succeeds, mirroring the rule panama-migrate
# documents for its markers: a step that did not complete has not happened, and
# recording it as done hides it forever.
record_packages_hash() {
local starting_hash="$1" current_hash temporary_hash
current_hash="$(hash_packages)" || return 1
[[ "$current_hash" == "$starting_hash" ]] || return 1
mkdir -p "$STATE_DIR"
temporary_hash="$(mktemp "$STATE_DIR/.packages-hash.XXXXXX")" || return 1
if printf '%s\n' "$starting_hash" >"$temporary_hash"; then
mv -f -- "$temporary_hash" "$PACKAGES_HASH"
else
rm -f -- "$temporary_hash"
return 1
fi
}
# Repository trust is checked before the installer can reach its bootstrap DNF.
# Status 78 is reserved for a trust-root failure. It suppresses every stage that
# opens DNF -- install-hardware included, which would otherwise pull drivers
# through the very repository in doubt -- and the run still exits 78 at the end.
#
# It suppresses nothing else. Linking dotfiles, skills and user content reads no
# repository and installs no package, and a machine whose Terra is in question
# still wants its configuration. Refusing the safe work because the unsafe work
# is unavailable does not make the machine safer, it just leaves the machine
# unconfigured with no way to fix itself. Exiting here instead meant link-skills
# never ran on a machine whose Terra was merely unadopted, so ~/.claude/skills
# stayed the whole-directory symlink it had been before skills were linked one
# by one, and not one shipped skill was reachable.
TERRA_TRUST_FAILURE_STATUS=78
DNF_STAGES=(install-packages change-settings install-hardware)
package_trust_refused=0
stage_opens_dnf() {
local candidate="$1" dnf_stage
for dnf_stage in "${DNF_STAGES[@]}"; do
[[ "$candidate" == "$dnf_stage" ]] && return 0
done
return 1
}
trust_preflight="$PANAMA_PATH/setup/scripts/install-packages"
if [[ ! -x "$trust_preflight" ]]; then
printf 'install: package repository trust preflight is unavailable\n' >&2
package_trust_refused=1
elif ! "$trust_preflight" --trust-preflight; then
printf 'install: package repository trust preflight failed\n' >&2
package_trust_refused=1
fi
# ── The interview ────────────────────────────────────────────────────────────
#
# Everything Panama needs to be told is asked here, before a single package is
# installed, and nothing asks again afterwards. That is the whole bargain: the
# rest of the run takes twenty minutes and needs nobody watching it.
#
# The interview's tools are bootstrapped first because it cannot install them
# itself -- they are declared in the package lists, which install-packages
# installs, which runs after this. gum is the interface; pciutils, mokutil and
# fwupd are the interview's eyes.
# It probes for an NVIDIA card, Secure Boot state and updatable firmware
# BEFORE install-packages runs, and a missing probe tool degrades the answer
# silently to "no" -- which for Secure Boot once meant installing a driver
# that could never load. Workstation ships all four; a minimal base does not.
# Gated exactly like the interview itself: under --upgrade no questions are
# asked, so nothing here is used, and a machine that cannot install gum must
# not have that stop an upgrade that never needed it.
if (( ! UPGRADE && ! package_trust_refused )); then
bootstrap=()
command -v gum >/dev/null 2>&1 || bootstrap+=(gum)
# The probe tools serve only the hardware questions, which a server is never
# asked -- installing lspci on a VPS to not use it would be the interview
# costing packages the machine has no reason to carry. Only gated when the
# role is already known; a plain ./install on a server still bootstraps them,
# harmlessly, because the role is not known until the interview answers.
if [[ "$ROLE_PRESET" != server ]]; then
command -v lspci >/dev/null 2>&1 || bootstrap+=(pciutils)
command -v mokutil >/dev/null 2>&1 || bootstrap+=(mokutil)
command -v fwupdmgr >/dev/null 2>&1 || bootstrap+=(fwupd)
fi
if (( ${#bootstrap[@]} > 0 )); then
echo "Installing what the setup questions are built on: ${bootstrap[*]}"
sudo dnf install -y "${bootstrap[@]}" >/dev/null || {
echo "Could not install ${bootstrap[*]}, so the setup questions cannot be asked." >&2
exit 1
}
fi
fi
# ── Keep the machine awake for the duration ──────────────────────────────────
# Package installation takes long enough to hit an idle lock, and being locked
# out mid-transaction is unpleasant. Restored on every exit path, including
# failure and Ctrl-C, so an interrupted install does not leave the screen
# permanently awake.
cleanup() {
gsettings set org.gnome.desktop.screensaver lock-enabled true 2>/dev/null || true
gsettings set org.gnome.desktop.session idle-delay 300 2>/dev/null || true
# Deleted on every exit path, including Ctrl-C. The answers are transient by
# design, and one of them is an email address.
[[ -n "${PANAMA_ANSWERS:-}" ]] && rm -f "$PANAMA_ANSWERS"
[[ -n "${SUDO_KEEPALIVE:-}" ]] && kill "$SUDO_KEEPALIVE" 2>/dev/null
}
trap cleanup EXIT
# A bare `trap cleanup INT` is not an abort: bash runs the handler and then
# carries on with the script, so Ctrl-C would kill only the current stage and
# the remaining ones -- MOK enrollment, firmware -- would still run. Exit
# explicitly instead; the EXIT trap above does the actual cleanup.
trap 'exit 130' INT
trap 'exit 143' TERM
gsettings set org.gnome.desktop.screensaver lock-enabled false 2>/dev/null || true
gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
# ── Stages ───────────────────────────────────────────────────────────────────
# Each runs in its own process so strict-shell options and helper variables stay
# local to the script that owns them. A failing stage is reported and the rest
# still run: a missing optional package should not stop the dotfiles being
# linked. The summary at the end is what decides whether the install worked,
# because a failure scrolled past twenty minutes ago is a failure nobody saw.
#
# Explicit order, not glob order: change-settings runs `vicinae theme set`,
# which needs both vicinae itself (installed by install-packages) and the
# theme files it selects among (symlinked into place by link-dotfiles);
# link-user runs before setup-identity so tracked personal content wins over
# what the interview would otherwise seed, and after link-skills so a personal
# skill wins a name collision with a shipped one; setup-identity needs the gh and
# git-all that install-packages provides; and
# install-hardware is last because MOK enrollment arms a prompt consumed at the
# next boot and a firmware update may ask for a reboot -- a machine that reboots
# out of the final stage has already been completely configured. New scripts
# must be added here explicitly, or they will not run at all.
# The interview is not in that list, because it is the one stage whose output the
# installer reads back -- and because declining it must stop everything rather
# than be recorded as one failure among several.
#
# The answers live for exactly one run. There is no state file to go stale and
# nothing personal reaches a durable path, which is what keeps this repository
# something somebody else could clone. Created here rather than earlier so the
# trap that deletes it is already armed before the file exists.
#
# Skipped entirely under --upgrade. Nothing is exported, so every answer below
# is unset and each stage takes the empty-answer path it already documents --
# which is why this is a flag rather than a rewrite of seven stage scripts.
if (( ! UPGRADE )); then
PANAMA_ANSWERS="$(mktemp -t panama-answers.XXXXXX)"
export PANAMA_ANSWERS
if ! PANAMA_ROLE_PRESET="$ROLE_PRESET" "$PANAMA_PATH/setup/scripts/interview"; then
exit 1
fi
# shellcheck source=/dev/null
source "$PANAMA_ANSWERS"
export PANAMA_ROLE PANAMA_HOSTNAME PANAMA_GIT_NAME PANAMA_GIT_EMAIL \
PANAMA_GIT_EDITOR PANAMA_GH_LOGIN PANAMA_SSH_KEY PANAMA_NVIDIA \
PANAMA_MOK_HASH PANAMA_DEBLOAT PANAMA_FIRMWARE PANAMA_EXTRAS \
PANAMA_USER_CONTENT
fi
# ── The role ─────────────────────────────────────────────────────────────────
#
# The one interview answer that outlives the run, because every later
# `panama update` runs with no interview and still has to know which machine
# this is. A fresh install records what was just answered; an upgrade reads
# what an earlier install recorded, defaulting to desktop -- which is what
# every machine that predates roles is. Exported so each stage sees the same
# answer through setup/lib/machine-role without re-deriving it.
# shellcheck source=setup/lib/machine-role
source "$PANAMA_PATH/setup/lib/machine-role"
if (( ! UPGRADE )); then
PANAMA_ROLE="${PANAMA_ROLE:-desktop}"
panama_role_record "$PANAMA_ROLE"
else
PANAMA_ROLE="$(panama_role)"
fi
export PANAMA_ROLE
# One password, before anything long runs, and then never again. The stages
# call sudo dozens of times across twenty-plus minutes, and the timestamp
# expires five minutes after whichever call came last -- so a single dnf step
# that outlasts it turned the next stage into a password prompt nobody was
# there to answer. The refresher holds the timestamp open for exactly as long
# as this script lives; cleanup() kills it on every exit path, so nothing
# outlives the install with ambient credentials.
if (( UPGRADE )); then
echo "Panama needs administrator rights to apply system settings and packages."
else
echo "Panama needs administrator rights for the rest of the run."
fi
sudo -v || exit 1
( while kill -0 "$$" 2>/dev/null; do sudo -n true 2>/dev/null || true; sleep 60; done ) &
SUDO_KEEPALIVE=$!
# Applied here rather than in a stage, and applied early: it needs sudo, and
# sudo is warm right now.
if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then
sudo hostnamectl set-hostname "$PANAMA_HOSTNAME"
echo "Hostname set to: $(hostname)"
fi
# One list per role, chosen whole rather than filtered from a superset, so
# what a server runs is readable here rather than derived. A server gets the
# shared stages plus its own two; it never links skills (all three shipped
# skills operate the desktop), never touches gsettings or Vicinae, and has no
# hardware stage -- NVIDIA, Secure Boot and firmware are first-boot desktop
# concerns. setup-server runs after packages (it needs podman and firewalld
# installed) and link-server after that, so the units it links land on a
# machine already able to run them.
if [[ "$PANAMA_ROLE" == server ]]; then
STAGES=(install-packages link-dotfiles link-user setup-server link-server setup-identity)
else
STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware)
fi
# The two an upgrade drops. Both exist only to act on interview answers, and
# both are first-run decisions: who you are and what hardware this is. Filtered
# by name rather than by position so reordering STAGES cannot silently change
# which stages an upgrade runs.
if (( UPGRADE )); then
upgrade_stages=()
for stage in "${STAGES[@]}"; do
case "$stage" in
setup-identity|install-hardware) continue ;;
esac
upgrade_stages+=("$stage")
done
STAGES=("${upgrade_stages[@]}")
fi
failed=()
for stage in "${STAGES[@]}"; do
script="$PANAMA_PATH/setup/scripts/$stage"
[[ -x "$script" ]] || continue
printf '\n=== %s ===\n' "$stage"
if (( package_trust_refused )) && stage_opens_dnf "$stage"; then
echo "Skipped: the package repository trust check refused package work."
continue
fi
if [[ "$stage" == install-packages ]]; then
package_state_status=0
package_start_hash="$(hash_packages)" || package_state_status=2
if (( package_state_status == 0 )); then
packages_needed "$package_start_hash" || package_state_status=$?
fi
if (( package_state_status == 1 )); then
echo "The package lists have not changed since the last run; skipping."
echo "Run with --packages to install them anyway."
continue
elif (( package_state_status != 0 )); then
failed+=("$stage")
printf '!!! %s could not read its tracked installation inputs\n' "$stage" >&2
continue
fi
fi
if "$script"; then
if [[ "$stage" == install-packages ]]; then
if ! record_packages_hash "$package_start_hash"; then
failed+=("$stage")
printf '!!! %s could not record its tracked installation inputs\n' "$stage" >&2
fi
fi
else
stage_status=$?
# A configuration change between the preflight and this stage. Suppress the
# remaining DNF stages, keep the safe ones, and carry the status to the end.
if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then
printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2
package_trust_refused=1
continue
fi
failed+=("$stage")
printf '!!! %s failed\n' "$stage" >&2
fi
done
# ── Migrations ───────────────────────────────────────────────────────────────
#
# Repairs for machines that installed an older Panama: removing a file this
# repository stopped shipping, disabling a unit it stopped wanting. The
# installer itself cannot do any of that, because it only ever adds.
#
# A machine that has never seen migrations before is one of two things, and
# the difference matters. If it has no marker directory at all it was just
# built from THIS checkout, so every repair those migrations describe is
# already true of it -- they are marked applied without running, exactly as
# Migrations.qml stamps a pre-versioning settings file at its baseline rather
# than replaying upgrades it never needed. Otherwise the pending ones run.
#
# That inference is only sound during a real install. Under --upgrade the
# machine demonstrably existed before this run, so an absent marker directory
# means it predates migrations entirely -- exactly the machine the repairs were
# written for -- and baselining would skip every one of them forever. Every
# migration is self-guarding and a no-op where it does not apply, so running
# them is the safe direction.
#
# Held back when package work was refused. A migration is free to run a DNF
# transaction -- the ChatGPT package replacement does exactly that -- so the
# repositories have to be trustworthy before any of them is allowed to run.
# They are not marked applied either, so the next run still has them pending.
migrate="$PANAMA_PATH/bin/panama-migrate"
if (( package_trust_refused )) && [[ -x "$migrate" ]]; then
printf '\n=== migrations ===\n'
echo "Skipped: the package repository trust check refused package work."
elif [[ -x "$migrate" ]]; then
printf '\n=== migrations ===\n'
if (( UPGRADE )) || [[ -d "$STATE_DIR/migrations" ]]; then
"$migrate" run || failed+=(migrations)
else
"$migrate" --baseline || true
fi
fi
# ── Did it actually work? ────────────────────────────────────────────────────
#
# A failed-stage count only reports what exited non-zero. It says nothing about a
# service that did not start or a font that did not land, and those are the
# failures that survive an install unnoticed. Doctor answers the question the
# stage list cannot.
#
# It never changes the exit code. On a fresh machine it legitimately reports
# things as unconfigured -- no Home Assistant token yet, Nextcloud not signed in
# -- and failing an install over those would be crying wolf.
# On a server the quickshell doctor would report a desktop that was never
# installed; what is actually running there is the container services, and
# panama-server status is the check that answers for them.
if [[ "$PANAMA_ROLE" == server ]]; then
server_status="$PANAMA_PATH/bin/panama-server"
if [[ -x "$server_status" ]]; then
printf '\n=== health ===\n'
"$server_status" status || true
fi
else
doctor="$PANAMA_PATH/config/dot/quickshell/scripts/panama-doctor"
if [[ -x "$doctor" ]]; then
printf '\n=== health ===\n'
"$doctor" --summary || true
fi
fi
# Whatever this particular machine wants doing that Panama should not carry for
# everyone. Runs last, after every stage, migrations and the health summary.
hook="$PANAMA_PATH/bin/panama-hook"
[[ -x "$hook" ]] && "$hook" post-upgrade || true
printf '\n'
if (( UPGRADE )); then
retry='panama update'
else
retry='./install'
fi
# Reported last and on its own, because it is not an ordinary stage failure:
# everything safe did run, and what did not run is named rather than buried in a
# list. The exit status stays 78 so a caller can still tell the two apart.
if (( package_trust_refused )); then
printf 'Package work was refused: the Terra repository configuration on this\n' >&2
printf 'machine is not one Panama can verify. Skipped: %s\n' "${DNF_STAGES[*]}" >&2
printf 'Everything that touches no repository was still applied.\n' >&2
printf 'Inspect it with: panama diagnose\n' >&2
exit "$TERRA_TRUST_FAILURE_STATUS"
fi
if (( ${#failed[@]} == 0 )); then
if (( UPGRADE )); then
echo "Panama is up to date."
elif [[ "$PANAMA_ROLE" == server ]]; then
echo "Panama installed. Enable a service with: panama server enable <Name>"
else
echo "Panama installed. Log out and choose the Hyprland session to start it."
fi
else
if (( UPGRADE )); then
printf 'Panama updated with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2
else
printf 'Panama installed with %d failed stage(s): %s\n' "${#failed[@]}" "${failed[*]}" >&2
fi
printf 'Re-running %s is safe and will retry them.\n' "$retry" >&2
printf 'If it fails again, hand it to an agent: panama diagnose\n' >&2
exit 1
fi