Files
Panama/bin/panama-server
T

374 lines
13 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/vps/<Name>/ holds a
# compose.yml, a podman-<name>.service user unit, and an .env.example naming
# what the service needs told. A service may also carry helper units (any
# other *.service or *.timer, e.g. a cert refresh) and helper files (scripts,
# a README) beside them. 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/vps}"
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/vps/
${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 compose unit 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"/podman-*.service; do
[[ -e "$unit" ]] || continue
printf '%s' "$unit"
return 0
done
return 1
}
# Every other unit in the directory: helper services and the timers that fire
# them. They are linked beside the compose unit, and each timer is enabled.
helper_units_for() {
local name="$1" unit
for unit in "$SERVER_DIR/$name"/*.service "$SERVER_DIR/$name"/*.timer; do
[[ -e "$unit" ]] || continue
[[ "$(basename "$unit")" == podman-*.service ]] && continue
printf '%s\n' "$unit"
done
}
# The files linked into ~/Server/<Name>/: every visible top-level file that is
# not a unit. That is compose.yml plus any helper script or README; dotfiles
# are skipped so .env.example (and a stray .env) never become links.
live_files_for() {
local name="$1" file
for file in "$SERVER_DIR/$name"/*; do
[[ -f "$file" ]] || continue
case "$file" in *.service|*.timer) continue ;; esac
printf '%s\n' "$file"
done
}
link_service_files() {
local name="$1" file unit
mkdir -p "$TARGET_DIR/$name" "$UNIT_DIR"
while IFS= read -r file; do
link_file "$file" "$TARGET_DIR/$name/$(basename "$file")"
done < <(live_files_for "$name")
while IFS= read -r unit; do
link_file "$unit" "$UNIT_DIR/$(basename "$unit")"
done < <(helper_units_for "$name")
}
helper_timers_for() {
helper_units_for "$1" | grep '\.timer$' | xargs -r -n1 basename
}
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" ]]
}
# Everything this machine links for the service, so a change to a helper
# script or timer is reported by relink just like a compose change.
definition_hash() {
local name="$1"
{ live_files_for "$name"; unit_path_for "$name" && echo; helper_units_for "$name"; } \
| sort | xargs -r -d '\n' cat 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_service_files "$name"
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
local timer
for timer in $(helper_timers_for "$name"); do
systemctl --user enable --now "$timer" && ok "Enabled $timer"
done
}
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; }
local timer unit
for timer in $(helper_timers_for "$name"); do
systemctl --user disable --now "$timer" 2>/dev/null || true
done
systemctl --user disable --now "$unit_name" 2>/dev/null || true
for unit in "$UNIT_DIR/$unit_name" $(helper_units_for "$name" | xargs -r -n1 basename | sed "s|^|$UNIT_DIR/|"); do
[[ -L "$unit" ]] && rm "$unit"
done
systemctl --user daemon-reload
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"
link_service_files "$name"
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 "$@"