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:
@@ -103,6 +103,8 @@ ${BOLD}Commands:${RESET}
|
||||
install.
|
||||
${GREEN}app${RESET} Build and install an application that neither dnf nor
|
||||
Flathub carries. With no name, lists what is available.
|
||||
${GREEN}server${RESET} The compose services a server machine runs: list, enable,
|
||||
disable, status, relink. See 'panama server help'.
|
||||
${GREEN}help${RESET} Show this help (also -h, --help).
|
||||
|
||||
${BOLD}Options:${RESET}
|
||||
@@ -892,6 +894,7 @@ main() {
|
||||
migrate) shift; cmd_migrate "$@" ;;
|
||||
app) shift; cmd_app "$@" ;;
|
||||
apps) shift; cmd_apps "$@" ;;
|
||||
server) shift; exec "$PANAMA_DIR/bin/panama-server" "$@" ;;
|
||||
help|-h|--help|"") usage ;;
|
||||
--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;;
|
||||
*)
|
||||
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
#!/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 "$@"
|
||||
Reference in New Issue
Block a user