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
324 lines
12 KiB
Bash
Executable File
324 lines
12 KiB
Bash
Executable File
#!/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 "$@"
|