Files
Panama/bin/panama-server
T
Gabriel Brown f33da41cc6 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
2026-08-25 23:11:49 -04:00

324 lines
12 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# panama-server the compose services a server machine runs.
#
# The repository carries the catalog: server/containers/<Name>/ holds a
# compose.yml, a podman-<name>.service user unit, and an .env.example naming
# what the service needs told. The machine carries the rest: ~/Server/<Name>/
# is a real directory owning the .env (secrets never sit inside the checkout,
# gitignored or not) and the bind-mounted data. Only the tracked files are
# symlinked, so a `git clean` in the repo can never reach a database.
#
# list every service in the catalog, with its state here
# enable <Name> link it into ~/Server, seed .env, enable the unit
# disable <Name> stop it and remove the unit; data and .env stay put
# status [<Name>] what is actually running
# relink refresh the symlinks for everything enabled here
#
# Verbs act on the unit and the links, never on data: there is deliberately no
# verb here that deletes ~/Server/<Name> or anything in it.
set -uo pipefail
PROGRAM="panama server"
SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)
# Overridable so the contract can point this at a fixture catalog and a
# throwaway HOME rather than the machine's real services.
SERVER_DIR="${PANAMA_SERVER_DIR:-$PANAMA_DIR/server/containers}"
TARGET_DIR="${PANAMA_SERVER_TARGET:-$HOME/Server}"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
STATE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/panama/server-definitions"
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
BOLD=$(tput bold); RESET=$(tput sgr0)
RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); BLUE=$(tput setaf 4)
else
BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""
fi
info() { printf '%s==>%s %s\n' "${BLUE}${BOLD}" "$RESET" "$*"; }
ok() { printf '%s✓%s %s\n' "${GREEN}${BOLD}" "$RESET" "$*"; }
warn() { printf '%s!%s %s\n' "${YELLOW}${BOLD}" "$RESET" "$*"; }
err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; }
usage() {
cat <<EOF
${BOLD}$PROGRAM${RESET} manage the compose services in server/containers/
${BOLD}Usage:${RESET}
$PROGRAM list Every service in the catalog, and its state here
$PROGRAM enable <Name> Link a service into ~/Server, seed its .env from
.env.example, and enable its unit. Stops short of
starting when the .env still holds CHANGE_ME
placeholders, and says which.
$PROGRAM disable <Name> Stop the unit and unlink it. ~/Server/<Name>,
its .env and its data are never touched.
$PROGRAM status [<Name>] What is actually running, per service
$PROGRAM relink Refresh every enabled service's symlinks after a
pull, and name the ones whose definitions changed
(nothing is restarted for you)
EOF
}
# ── The catalog ──────────────────────────────────────────────────────────────
services() {
local dir
for dir in "$SERVER_DIR"/*/; do
[[ -d "$dir" ]] || continue
basename "$dir"
done
}
require_service() {
local name="$1"
if [[ ! -d "$SERVER_DIR/$name" ]]; then
err "No such service: '$name'"
printf 'The catalog has:\n' >&2
services | sed 's/^/ /' >&2
exit 1
fi
}
# The one unit file a service directory carries. Its filename is its identity
# -- podman-<name>.service, exactly what lands in the systemd user directory --
# so nothing here invents a name that could drift from the file's.
unit_path_for() {
local name="$1" unit
for unit in "$SERVER_DIR/$name"/*.service; do
[[ -e "$unit" ]] || continue
printf '%s' "$unit"
return 0
done
return 1
}
unit_name_for() {
local unit
unit="$(unit_path_for "$1")" || return 1
basename "$unit"
}
# Installed means our symlink is in the systemd user directory. A regular file
# of the same name is a pre-Panama install done by hand -- reported, never
# silently replaced.
unit_installed() {
local unit_name
unit_name="$(unit_name_for "$1")" || return 1
[[ -L "$UNIT_DIR/$unit_name" ]]
}
definition_hash() {
local name="$1" unit
unit="$(unit_path_for "$name")" || unit=/dev/null
cat "$SERVER_DIR/$name/compose.yml" "$unit" 2>/dev/null | sha256sum | cut -d' ' -f1
}
recorded_hash() {
[[ -r "$STATE_FILE" ]] || return 0
awk -v name="$1" '$1 == name {print $2}' "$STATE_FILE"
}
record_hash() {
local name="$1" hash="$2"
mkdir -p "$(dirname "$STATE_FILE")"
{ [[ -r "$STATE_FILE" ]] && awk -v name="$name" '$1 != name' "$STATE_FILE"; \
printf '%s %s\n' "$name" "$hash"; } >"$STATE_FILE.tmp"
mv "$STATE_FILE.tmp" "$STATE_FILE"
}
# Replace path with a symlink to src. An existing regular file is moved aside
# with its name intact plus a suffix, because the only regular file that can be
# here is a pre-Panama one somebody wrote by hand -- worth keeping to diff.
link_file() {
local src="$1" path="$2"
if [[ -L "$path" ]]; then
[[ "$(readlink -f "$path")" == "$(readlink -f "$src")" ]] && return 0
rm "$path"
elif [[ -e "$path" ]]; then
mv "$path" "$path.pre-panama"
warn "Kept the existing $(basename "$path") as $(basename "$path").pre-panama"
fi
ln -s "$src" "$path"
}
# ── Verbs ────────────────────────────────────────────────────────────────────
cmd_list() {
local name unit_name state enabled
for name in $(services); do
unit_name="$(unit_name_for "$name")" || { printf '%-24s %s\n' "$name" "no unit file"; continue; }
if unit_installed "$name"; then
state="$(systemctl --user is-active "$unit_name" 2>/dev/null || true)"
enabled="enabled"
[[ "$state" == active ]] && state="${GREEN}active${RESET}" || state="${YELLOW}${state:-unknown}${RESET}"
printf '%-24s %s, %b\n' "$name" "$enabled" "$state"
elif [[ -e "$UNIT_DIR/$unit_name" ]]; then
printf '%-24s %s\n' "$name" "installed by hand (not Panama's symlink)"
else
printf '%-24s %s\n' "$name" "-"
fi
done
}
cmd_enable() {
local name="${1:-}"
[[ -n "$name" ]] || { err "Which service? Usage: $PROGRAM enable <Name>"; exit 1; }
require_service "$name"
local repo_dir="$SERVER_DIR/$name" live_dir="$TARGET_DIR/$name"
[[ -f "$repo_dir/compose.yml" ]] || { err "$name has no compose.yml in the catalog"; exit 1; }
local unit_path unit_name
unit_path="$(unit_path_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
unit_name="$(basename "$unit_path")"
mkdir -p "$live_dir"
link_file "$repo_dir/compose.yml" "$live_dir/compose.yml"
ok "Linked compose.yml → $live_dir/compose.yml"
# The .env lives with the machine, not the checkout. Seeded once from the
# example and never overwritten -- it is where the person's secrets go.
if [[ -f "$repo_dir/.env.example" && ! -e "$live_dir/.env" ]]; then
cp "$repo_dir/.env.example" "$live_dir/.env"
chmod 600 "$live_dir/.env"
ok "Seeded $live_dir/.env from .env.example"
fi
# Refusing to start on placeholders, and saying which: a service brought up
# with CHANGE_ME as its database password does not fail loudly, it runs --
# and what it runs is a service whose password is CHANGE_ME.
if [[ -e "$live_dir/.env" ]] && grep -q 'CHANGE_ME' "$live_dir/.env"; then
warn "$live_dir/.env still has placeholder values:"
grep -n 'CHANGE_ME' "$live_dir/.env" | sed 's/^/ /'
printf 'Fill them in, then run: %s enable %s\n' "$PROGRAM" "$name"
exit 1
fi
mkdir -p "$UNIT_DIR"
link_file "$unit_path" "$UNIT_DIR/$unit_name"
systemctl --user daemon-reload
if systemctl --user enable --now "$unit_name"; then
record_hash "$name" "$(definition_hash "$name")"
ok "$name enabled and started ($unit_name)"
else
err "$unit_name did not start; see: systemctl --user status $unit_name"
exit 1
fi
}
cmd_disable() {
local name="${1:-}"
[[ -n "$name" ]] || { err "Which service? Usage: $PROGRAM disable <Name>"; exit 1; }
require_service "$name"
local unit_name
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
systemctl --user disable --now "$unit_name" 2>/dev/null || true
if [[ -L "$UNIT_DIR/$unit_name" ]]; then
rm "$UNIT_DIR/$unit_name"
systemctl --user daemon-reload
fi
ok "$name disabled. ~/Server/$name, its .env and its data were not touched."
}
cmd_status() {
local name="${1:-}"
if [[ -n "$name" ]]; then
require_service "$name"
local unit_name
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file"; exit 1; }
systemctl --user status --no-pager "$unit_name" || true
if [[ -d "$TARGET_DIR/$name" ]]; then
( cd "$TARGET_DIR/$name" && podman compose ps 2>/dev/null ) || true
fi
return 0
fi
# The role's health summary: what is enabled here, and is it actually up.
local any=0 unit_name state
for name in $(services); do
unit_installed "$name" || continue
any=1
unit_name="$(unit_name_for "$name")"
state="$(systemctl --user is-active "$unit_name" 2>/dev/null || true)"
if [[ "$state" == active ]]; then
ok "$name"
else
err "$name is ${state:-unknown}"
fi
done
(( any )) || info "No services enabled on this machine yet. See: $PROGRAM list"
return 0
}
cmd_relink() {
local name unit_path unit_name changed=() unmanaged=() refreshed=0
for name in $(services); do
unit_path="$(unit_path_for "$name")" || continue
unit_name="$(basename "$unit_path")"
if [[ -e "$UNIT_DIR/$unit_name" && ! -L "$UNIT_DIR/$unit_name" ]]; then
unmanaged+=("$name")
continue
fi
unit_installed "$name" || continue
link_file "$unit_path" "$UNIT_DIR/$unit_name"
mkdir -p "$TARGET_DIR/$name"
link_file "$SERVER_DIR/$name/compose.yml" "$TARGET_DIR/$name/compose.yml"
refreshed=$((refreshed + 1))
# Changed since the last time this machine looked, which is what makes a
# pull actionable: the restart is deliberately yours to run, so the least
# this can do is say which services are running a definition that is no
# longer what the repository says.
local now
now="$(definition_hash "$name")"
if [[ "$(recorded_hash "$name")" != "$now" ]]; then
changed+=("$name")
record_hash "$name" "$now"
fi
done
systemctl --user daemon-reload 2>/dev/null || true
info "Relinked $refreshed enabled service(s)"
if (( ${#unmanaged[@]} > 0 )); then
warn "Installed by hand, left alone: ${unmanaged[*]}"
fi
if (( ${#changed[@]} > 0 )); then
warn "Definitions changed; restart each when ready:"
for name in "${changed[@]}"; do
printf ' %s: systemctl --user restart %s\n' "$name" "$(unit_name_for "$name")"
done
fi
}
# ── Dispatcher ───────────────────────────────────────────────────────────────
main() {
if [[ ! -d "$SERVER_DIR" ]]; then
err "No service catalog at $SERVER_DIR"
exit 1
fi
local cmd="${1:-}"
case "$cmd" in
list) shift; cmd_list "$@" ;;
enable) shift; cmd_enable "$@" ;;
disable) shift; cmd_disable "$@" ;;
status) shift; cmd_status "$@" ;;
relink) shift; cmd_relink "$@" ;;
help|-h|--help|"") usage ;;
*)
err "Unknown command: '$cmd'"
echo
usage
exit 1
;;
esac
}
main "$@"