Panama learns what a server is: from a root login to running containers

A machine's role is now the interview's first question and the one answer
Panama records. Servers get the same shell minus the screen: core packages,
nvm, Bun, Claude Code and Codex (desktops get Codex too), linger, rootless
ports from 80, firewalld, the nginx-bridge network, and a nightly image
updater that replaced watchtower for cause.

server/containers/ carries junior's 23 compose services -- secrets moved to
per-machine .env files that never enter this public repo, every transformed
compose proven to render byte-identical to what is live. 'panama server'
enables, disables and relinks them; nothing here restarts a running service.
'boot --server' walks a fresh VPS from its root login to a normal install.

Five new contracts pin the secrets rule, the catalog's shape, panama-server's
behavior, the role plumbing, and the dotfile classification.

Claude-Session: https://claude.ai/code/session_01NU5JGiN3JfzqrLQB6wmJ1E
This commit is contained in:
Gabriel Brown
2026-08-25 23:11:49 -04:00
parent 9b338608ef
commit f33da41cc6
93 changed files with 4735 additions and 247 deletions
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# The machine role: the one interview answer Panama keeps.
#
# What is pinned, and why each half matters:
#
# * The interview asks the role first and a server is never asked the
# desktop's questions -- hardware and extras feed stages the server path
# does not run, and a question nothing consumes is a control that lies.
# * `--server` presets the answer without a prompt, because the fresh-VPS
# path runs from a curl with nobody to interview twice.
# * setup/lib/machine-role reads env over file over default, and records
# only values it can read back -- an unreadable role file must degrade to
# desktop, never to an error, because every pre-role machine has none.
# * install runs different stages per role, and the server list must never
# silently reacquire a desktop stage (or the reverse).
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
interview="$repo_dir/setup/scripts/interview"
install_script="$repo_dir/install"
machine_role="$repo_dir/setup/lib/machine-role"
boot="$repo_dir/boot"
findings=()
note() { findings+=("$1"); }
# ── The helper, sandboxed ────────────────────────────────────────────────────
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
role_of() {
env -u PANAMA_ROLE XDG_STATE_HOME="$work/state" ${1:+PANAMA_ROLE="$1"} \
bash -c "source '$machine_role'; panama_role"
}
[[ "$(role_of "")" == desktop ]] \
|| note 'with no file and no env, the role is not desktop'
env XDG_STATE_HOME="$work/state" \
bash -c "source '$machine_role'; panama_role_record server"
[[ "$(role_of "")" == server ]] \
|| note 'a recorded server role does not read back'
[[ "$(role_of desktop)" == desktop ]] \
|| note 'an exported PANAMA_ROLE does not win over the recorded file'
printf 'gibberish\n' >"$work/state/panama/role"
[[ "$(role_of "")" == desktop ]] \
|| note 'a role file with an unknown value does not degrade to desktop'
env XDG_STATE_HOME="$work/state" \
bash -c "source '$machine_role'; panama_role_record nonsense"
[[ "$(role_of "")" == desktop ]] \
|| note 'recording an unknown value does not degrade to desktop'
# ── The interview, per role ──────────────────────────────────────────────────
#
# The same stub the interview contract stands up, plus `choose` so the role
# question is answerable. GUM_STUB_CHOOSE also feeds the extras checklist on
# a desktop run, which is why the desktop case leaves it empty.
stub_dir="$work/bin"
mkdir -p "$stub_dir"
cat >"$stub_dir/gum" <<'STUB'
#!/usr/bin/env bash
case "$1" in
input) printf '%s\n' "$GUM_STUB_INPUT" ;;
confirm) [[ "$GUM_STUB_CONFIRM" == yes ]] ;;
choose) printf '%s\n' "$GUM_STUB_CHOOSE" ;;
style) shift; printf '%s\n' "${@: -1}" ;;
*) exit 0 ;;
esac
STUB
chmod +x "$stub_dir/gum"
answers="$work/answers"
ask_interview() {
: >"$answers"
GUM_STUB_INPUT="x" GUM_STUB_CONFIRM=yes GUM_STUB_CHOOSE="${2:-}" \
PANAMA_ANSWERS="$answers" PANAMA_ROLE_PRESET="${1:-}" \
PATH="$stub_dir:$PATH" bash "$interview" >/dev/null 2>&1
}
answered() {
( # shellcheck source=/dev/null
source "$answers"; printf '%s' "${!1:-}" )
}
ask_interview "" server \
|| note 'the interview fails when the role question answers server'
[[ "$(answered PANAMA_ROLE)" == server ]] \
|| note 'choosing server does not record PANAMA_ROLE=server'
[[ "$(answered PANAMA_EXTRAS)" == "" ]] \
|| note 'a server was asked the extras question'
[[ "$(answered PANAMA_NVIDIA)" == no ]] \
|| note 'a server run does not record the hardware defaults'
ask_interview "" "" \
|| note 'the interview fails when the role question is escaped'
[[ "$(answered PANAMA_ROLE)" == desktop ]] \
|| note 'an escaped role question does not default to desktop'
ask_interview server desktop \
|| note 'the interview fails under a --server preset'
[[ "$(answered PANAMA_ROLE)" == server ]] \
|| note 'PANAMA_ROLE_PRESET=server does not win: the prompt was asked anyway'
# ── install runs the right stages ────────────────────────────────────────────
#
# Read from the source rather than run: the stage lists are data, and what
# matters is which names each role's list carries.
# Only the two literal lists: the upgrade filter reassigns STAGES from a
# variable, which is not a role's stage list.
server_stages="$(sed -n 's/^ STAGES=(\(.*\))$/\1/p' "$install_script" | grep '^install-packages' | head -1)"
desktop_stages="$(sed -n 's/^ STAGES=(\(.*\))$/\1/p' "$install_script" | grep '^install-packages' | tail -1)"
for stage in install-packages link-dotfiles link-user setup-server link-server setup-identity; do
grep -qw "$stage" <<<"$server_stages" \
|| note "the server stage list is missing $stage"
done
for stage in install-hardware change-settings link-skills link-vicinae-scripts; do
grep -qw "$stage" <<<"$server_stages" \
&& note "the server stage list acquired the desktop stage $stage"
done
for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware; do
grep -qw "$stage" <<<"$desktop_stages" \
|| note "the desktop stage list lost $stage"
done
grep -q -- '--server)' "$install_script" \
|| note 'install does not accept --server'
grep -q 'panama_role_record' "$install_script" \
|| note 'install never records the role, so panama update cannot know it'
# ── boot's front door ────────────────────────────────────────────────────────
grep -q -- '--server) SERVER=1' "$boot" \
|| note 'boot does not accept --server'
grep -q 'not root' "$boot" \
|| note 'boot no longer refuses a root run without --server'
grep -q 'runuser' "$boot" \
|| note 'the root bootstrap does not hand off to the created user'
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'role contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'role contract: PASS\n'