472 lines
21 KiB
Bash
Executable File
472 lines
21 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 and is propagated unchanged so
|
|
# no later stage, especially install-hardware, can invoke DNF with that repo.
|
|
TERRA_TRUST_FAILURE_STATUS=78
|
|
trust_preflight="$PANAMA_PATH/setup/scripts/install-packages"
|
|
if [[ ! -x "$trust_preflight" ]]; then
|
|
printf 'install: package repository trust preflight is unavailable\n' >&2
|
|
exit "$TERRA_TRUST_FAILURE_STATUS"
|
|
fi
|
|
if "$trust_preflight" --trust-preflight; then
|
|
:
|
|
else
|
|
trust_status=$?
|
|
printf 'install: package repository trust preflight failed\n' >&2
|
|
exit "$trust_status"
|
|
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 )); 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 [[ "$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=$?
|
|
if [[ "$stage" == install-packages && "$stage_status" -eq "$TERRA_TRUST_FAILURE_STATUS" ]]; then
|
|
printf '!!! %s stopped on an untrusted package repository\n' "$stage" >&2
|
|
exit "$stage_status"
|
|
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.
|
|
migrate="$PANAMA_PATH/bin/panama-migrate"
|
|
if [[ -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
|
|
|
|
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
|