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
+54 -9
View File
@@ -34,16 +34,61 @@ hyprland_at="$(line_of '^HYPR_FILE=')"
# ── Nothing fragile above the desktop ───────────────────────────────────────
#
# Named individually rather than by pattern: each is a command whose failure is
# survivable, and each one above the Hyprland block is a machine that boots to
# nothing.
# survivable, and each one that EXECUTES above the Hyprland block is a machine
# that boots to nothing.
#
# Executes, not appears: the fragile steps live in functions defined near the
# top (the server role calls them without ever reaching a desktop section),
# and a definition runs nothing. So function bodies are excluded from the
# position scan, and the desktop path's calls to those functions are required
# to sit below the desktop instead.
for fragile in 'dnf swap' 'groupupdate' 'group upgrade' 'gstreamer1-plugins' \
'flatpak install' 'nvm install' 'curl -fsSL'; do
while read -r at; do
(( at < hyprland_at )) \
&& note "'$fragile' runs at line $at, above the desktop at line $hyprland_at"
done < <(grep -n -- "$fragile" "$installer" | grep -v '^\s*#' | cut -d: -f1)
done
while IFS= read -r finding; do
[[ -n "$finding" ]] && note "$finding"
done < <(python3 - "$installer" "$hyprland_at" <<'PY'
import re, sys
path, hypr = sys.argv[1], int(sys.argv[2])
lines = open(path, encoding="utf-8").read().splitlines()
in_body = False
body = set()
for i, line in enumerate(lines, 1):
if not in_body and re.match(r'^[a-z_]+\(\)\s*\{', line):
in_body = True
body.add(i)
continue
if in_body:
body.add(i)
if line == '}':
in_body = False
fragile = ['dnf swap', 'groupupdate', 'group upgrade', 'gstreamer1-plugins',
'flatpak install', 'nvm install', 'curl -fsSL']
for i, line in enumerate(lines, 1):
if i >= hypr or i in body or line.strip().startswith('#'):
continue
for needle in fragile:
if needle in line:
print(f"'{needle}' runs at line {i}, above the desktop at line {hypr}")
# The desktop path still has to run the fragile helpers -- below the desktop.
# (The server path calls them above, inside a branch that exits before the
# desktop section; the exit is asserted back in bash.)
for call in ('setup_node', 'install_bun', 'install_claude_code', 'install_codex'):
calls = [i for i, line in enumerate(lines, 1)
if re.match(r'^\s*' + call + r'\s*$', line) and i not in body]
if not calls:
print(f"{call} is never called, so the desktop path skips it")
elif not any(i > hypr for i in calls):
print(f"{call} is only called above the desktop")
PY
)
# The server branch is what excuses fragile calls above the desktop, and only
# because it never falls through into the desktop section.
sed -n '/^if \[\[ "\$ROLE" == server \]\]; then/,/^fi/p' "$installer" | grep -q '^\s*exit 0' \
|| note 'the server branch does not exit before the desktop section'
# ── Everything fragile is actually tolerated ────────────────────────────────
#
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Every directory under config/dot is claimed by exactly one of
# link-dotfiles' three lists -- universal (both roles), desktop, or handled
# (linked or consumed some other way in that script). The failure this
# prevents is quiet: a new dot directory added to the repo but classified
# nowhere links on no server, or on every server, depending on which list the
# author forgot -- and nothing says so until a machine is missing its config.
#
# The lists are lifted from the script itself rather than restated here, so
# renaming them fails loudly instead of freezing a stale copy.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
link_dotfiles="$repo_dir/setup/scripts/link-dotfiles"
dot_dir="$repo_dir/config/dot"
findings=()
note() { findings+=("$1"); }
lists="$(sed -n '/^universal_dirs=(/,/^handled_dirs=(.*)$/p' "$link_dotfiles")"
if [[ -z "$lists" ]]; then
printf 'dotfile classification contract: link-dotfiles no longer defines the three lists\n' >&2
exit 1
fi
eval "$lists"
claimed=("${universal_dirs[@]}" "${desktop_dirs[@]}" "${handled_dirs[@]}")
for dir in "$dot_dir"/*/; do
name="$(basename "$dir")"
hits=0
for entry in "${claimed[@]}"; do
[[ "$entry" == "$name" ]] && hits=$((hits + 1))
done
if (( hits == 0 )); then
note "config/dot/$name is claimed by no list, so servers silently skip or acquire it"
elif (( hits > 1 )); then
note "config/dot/$name is claimed by $hits lists"
fi
done
for entry in "${claimed[@]}"; do
[[ -d "$dot_dir/$entry" ]] \
|| note "link-dotfiles classifies '$entry', which config/dot does not contain"
done
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'dotfile classification contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'dotfile classification contract: PASS (%d directories classified)\n' "${#claimed[@]}"
+4 -2
View File
@@ -35,7 +35,9 @@ consumed="$(grep -rhoE '\$\{PANAMA_[A-Z_]+' "$repo_dir"/setup/scripts/* "$instal
# which is the akmods certificate path with an override on it. Nothing sets that
# override outside the hardware contract, which needs a certificate it is allowed
# to create -- enrolling the real one to find out what happens is not a test.
INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR|PANAMA_MOK_CERT)$'
# PANAMA_ROLE_PRESET is the --server flag on its way INTO the interview, not an
# answer out of it -- the answer it produces is PANAMA_ROLE, which is checked.
INFRASTRUCTURE='^(PANAMA_PATH|PANAMA_ANSWERS|PANAMA_BASH|PANAMA_DOT|PANAMA_OLD|PANAMA_APPLICATION_DIR|PANAMA_ICON_DIR|PANAMA_UNIT_DIR|PANAMA_CURSOR_DIR|PANAMA_WALLPAPER_DIR|PANAMA_MOK_CERT|PANAMA_ROLE_PRESET)$'
while read -r key; do
[[ -n "$key" ]] || continue
@@ -67,7 +69,7 @@ grep -qE 'mktemp' "$install_script" \
|| note 'install does not create the answers file with mktemp'
# Declining must stop the run rather than count as one failed stage among five.
grep -qE 'if ! "\$PANAMA_PATH/setup/scripts/interview"; then' "$install_script" \
grep -qE 'if ! [A-Z_="$ ]*"\$PANAMA_PATH/setup/scripts/interview"; then' "$install_script" \
|| note 'install does not treat a declined interview as fatal'
# ── 4. A real run, with gum stubbed ──────────────────────────────────────────
+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'
+14 -5
View File
@@ -257,14 +257,23 @@ fi
# ── The stage runs, in the one order that gives personal skills precedence ──
# Checked against every literal per-role stage list. The server list carries
# no link-skills at all (its three skills operate the desktop), so the demand
# is: at least one list runs it, and any list that runs it runs it between
# link-dotfiles and link-user.
python3 - "$installer" <<'PY' || note 'link-skills is not in STAGES between link-dotfiles and link-user'
import re, sys
line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES="))
stages = re.findall(r"[\w-]+", line)
for name in ("link-dotfiles", "link-skills", "link-user"):
if name not in stages:
lines = [l.strip() for l in open(sys.argv[1], encoding="utf-8")
if l.strip().startswith("STAGES=(") and "upgrade_stages" not in l]
anywhere = False
for line in lines:
stages = re.findall(r"[\w-]+", line)
if "link-skills" not in stages:
continue
anywhere = True
if not stages.index("link-dotfiles") < stages.index("link-skills") < stages.index("link-user"):
raise SystemExit(1)
if not stages.index("link-dotfiles") < stages.index("link-skills") < stages.index("link-user"):
if not anywhere:
raise SystemExit(1)
PY
+11 -4
View File
@@ -166,13 +166,20 @@ grep -q 'PANAMA_USER_CONTENT' "$interview" \
|| note 'the interview never asks about personal content'
# Order matters: link-user must land the tracked espanso identity before
# setup-identity would seed one from the interview answers.
# setup-identity would seed one from the interview answers. Checked in every
# role's literal stage list (the upgrade filter reassigns from a variable and
# is not a list).
python3 - "$installer" <<'PY' || note 'link-user does not run before setup-identity'
import re, sys
line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES="))
stages = re.findall(r"[\w-]+", line)
if stages.index("link-user") > stages.index("setup-identity"):
lines = [l.strip() for l in open(sys.argv[1], encoding="utf-8")
if l.strip().startswith("STAGES=(") and "upgrade_stages" not in l]
if not lines:
raise SystemExit(1)
for line in lines:
stages = re.findall(r"[\w-]+", line)
if "link-user" in stages and "setup-identity" in stages:
if stages.index("link-user") > stages.index("setup-identity"):
raise SystemExit(1)
PY
if (( ${#findings[@]} > 0 )); then