The catalog in server/containers had drifted from the VPS. Eleven compose
files and twelve units no longer matched what runs, and two running
services, Gbrown_Agent and BillTracker, were missing. server/vps is now a
copy of the live files, and panama-server reads from it.
server/home holds the home server's services in the same shape. They still
run under Docker, so the podman units there are drafts for a later move, and
nothing installs from that tree. PostHog, Sentry and Plausible are checkouts
of upstream repositories. Only their local files are kept, next to an
UPSTREAM file naming the repository and the exact commit.
Every secret in both trees is a ${VAR} reference named in .env.example, or
CHANGE_ME in files that are not compose files. The live servers still hold
some of those values inline, so a live file can differ from its copy on
those lines alone. Existing .env.example files keep their comments, with new
keys appended at the end.
The ignore rules and the secrets contract now cover both trees, plus the
volume/ and volumes/ directories the home server uses.
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/vps/<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/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 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 "$@"
|