#!/usr/bin/env bash # # panama-server – the compose services a server machine runs. # # The repository carries the catalog: server/containers// holds a # compose.yml, a podman-.service user unit, and an .env.example naming # what the service needs told. The machine carries the rest: ~/Server// # 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 link it into ~/Server, seed .env, enable the unit # disable stop it and remove the unit; data and .env stay put # status [] 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/ 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 < 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 Stop the unit and unlink it. ~/Server/, its .env and its data are never touched. $PROGRAM status [] 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-.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 "; 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 "; 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 "$@"