Initial commit: PFI fleet inventory, stacks, tooling, and backup pipeline
Captures the full workspace state built up to this point:
- CLAUDE.md + README.md describing conventions and the four-host fleet
(ana-ml2, ana-docker, nh3-docker, esh-docker-vm).
- Per-host notes under servers/<host>/ with ssh-target fallback files
and latest system-details snapshots (two in-compose credential leaks
scrubbed; the upstream compose files still need to move those to .env).
- scripts/: server_inspect.sh (read-only remote diagnostic),
refresh-server-info.sh (dir-driven discovery + snapshot capture with
validation warnings), add-host.sh, sync-stacks.sh (pull
compose/conf trees), deploy-stack.sh (push with per-file diff + prompt).
- stacks/: canonical compose for backrest, beszel, dozzle, llama-swap,
rest-server-ana, rest-server-nh3, vllm-qwen3, plus the retired
infinity reference. All use the .env-driven + traefik-net + homepage
label pattern.
- configs/restic/ana-docker/: first resticprofile config + pre-backup
hook (Synapse pg_dump, Seafile mysqldump, Vaultwarden SQLite); templates
for the other three hosts to come.
- docs/pfi/: general infrastructure reference carried over.
- .gitignore excludes .env, stacks-mirror/, and assorted secret/state
filenames to prevent re-leaks on later commits.
This commit is contained in:
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
# sync-stacks.sh — pull /opt/docker/{compose,conf}/<stack>/ from every
|
||||
# server into version-controlled `stacks-mirror/<host>/<stack>/`.
|
||||
#
|
||||
# Layout (flat per stack):
|
||||
# stacks-mirror/<host>/<stack>/ <- mirrors /opt/docker/compose/<stack>/
|
||||
# stacks-mirror/<host>/<stack>/conf/ <- mirrors /opt/docker/conf/<stack>/
|
||||
#
|
||||
# Opt-out (per-stack, per-kind):
|
||||
# stacks-mirror/<host>/<stack>/.no-sync → skip stack entirely
|
||||
# stacks-mirror/<host>/<stack>/conf/.no-sync → skip conf only
|
||||
# The marker file is preserved; only the rsync is suppressed. Create the
|
||||
# marker manually for any stack you don't want mirrored.
|
||||
#
|
||||
# Secrets and runtime state are always excluded regardless of opt-out:
|
||||
# .env, .env.*, acme.json, client_secrets.json,
|
||||
# *.pem, *.key, *.crt, *.pfx,
|
||||
# *.sqlite, *.sqlite3, *.db, *.log, *.log.*, *.pid,
|
||||
# hub/, logs/
|
||||
#
|
||||
# Usage:
|
||||
# scripts/sync-stacks.sh # pull from every discovered host
|
||||
# scripts/sync-stacks.sh ana-docker nh3-docker
|
||||
# scripts/sync-stacks.sh --dry-run # show what would change, no writes
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v rsync >/dev/null 2>&1; then
|
||||
echo "error: rsync is not installed on this workstation" >&2
|
||||
echo " install it (e.g. 'sudo apt install rsync') and ensure every remote host has it too" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
SERVERS_DIR="$REPO_ROOT/servers"
|
||||
MIRROR_DIR="$REPO_ROOT/stacks-mirror"
|
||||
|
||||
EXCLUDES=(
|
||||
# Include .env.example / *.env.example templates before the broader
|
||||
# .env* exclude — rsync processes these in order, first match wins.
|
||||
--include='.env.example'
|
||||
--include='*.env.example'
|
||||
--exclude=.env
|
||||
--exclude='.env.*'
|
||||
--exclude=acme.json
|
||||
--exclude=client_secrets.json
|
||||
--exclude='*.pem'
|
||||
--exclude='*.key'
|
||||
--exclude='*.crt'
|
||||
--exclude='*.pfx'
|
||||
--exclude='*.sqlite'
|
||||
--exclude='*.sqlite3'
|
||||
--exclude='*.db'
|
||||
--exclude='*.log'
|
||||
--exclude='*.log.*'
|
||||
--exclude='*.pid'
|
||||
--exclude='hub/'
|
||||
--exclude='logs/'
|
||||
)
|
||||
|
||||
DRY_RUN=0
|
||||
REQUESTED=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
-h|--help) sed -n '2,24p' "$0"; exit 0 ;;
|
||||
-*) echo "error: unknown flag $arg" >&2; exit 2 ;;
|
||||
*) REQUESTED+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
resolve_target() {
|
||||
local host="$1"
|
||||
local effective
|
||||
effective=$(ssh -G "$host" 2>/dev/null | awk '/^hostname /{print $2; exit}')
|
||||
if [ -n "$effective" ] && getent hosts "$effective" >/dev/null 2>&1; then
|
||||
echo "$host"; return
|
||||
fi
|
||||
local fb="$SERVERS_DIR/$host/ssh-target"
|
||||
if [ -f "$fb" ]; then awk 'NF{print $1; exit}' "$fb"; return; fi
|
||||
echo "$host"
|
||||
}
|
||||
|
||||
list_remote_subdirs() {
|
||||
# $1 = ssh target, $2 = remote parent path
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 "$1" \
|
||||
"find '$2' -maxdepth 1 -mindepth 1 -type d -printf '%f\n' 2>/dev/null | sort" \
|
||||
2>/dev/null || true
|
||||
}
|
||||
|
||||
sync_one() {
|
||||
# $1 = host, $2 = ssh target, $3 = stack,
|
||||
# $4 = 'compose'|'conf' (kind),
|
||||
# $5 = local dest dir
|
||||
local host="$1" target="$2" stack="$3" kind="$4" dest="$5"
|
||||
local remote_src="/opt/docker/$kind/$stack/"
|
||||
local skip="$dest/.no-sync"
|
||||
local label
|
||||
if [ "$kind" = conf ]; then label='conf '; else label='compose'; fi
|
||||
|
||||
mkdir -p "$dest"
|
||||
|
||||
if [ -f "$skip" ]; then
|
||||
printf ' %s skip (.no-sync)\n' "$label"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local extra=()
|
||||
# Don't recurse into conf/ from the compose side — it's its own mirror target.
|
||||
[ "$kind" = compose ] && extra+=(--exclude='conf/')
|
||||
[ "$DRY_RUN" -eq 1 ] && extra+=(--dry-run)
|
||||
|
||||
local err rc=0
|
||||
err=$(
|
||||
rsync -az --delete --info=stats0,flist0 \
|
||||
"${EXCLUDES[@]}" "${extra[@]}" \
|
||||
"$target:$remote_src" "$dest/" 2>&1
|
||||
) || rc=$?
|
||||
|
||||
if [ $rc -ne 0 ]; then
|
||||
printf ' %s FAIL (rc=%d) %s\n' "$label" "$rc" "$(echo "$err" | head -n 1)"
|
||||
return 1
|
||||
fi
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
printf ' %s dry-run ok\n' "$label"
|
||||
else
|
||||
printf ' %s ok\n' "$label"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ "${#REQUESTED[@]}" -eq 0 ]; then
|
||||
mapfile -t HOSTS < <(find "$SERVERS_DIR" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort)
|
||||
else
|
||||
HOSTS=("${REQUESTED[@]}")
|
||||
fi
|
||||
|
||||
[ "${#HOSTS[@]}" -eq 0 ] && { echo "error: no hosts found" >&2; exit 2; }
|
||||
[ "$DRY_RUN" -eq 1 ] && echo "(dry-run)"
|
||||
|
||||
mkdir -p "$MIRROR_DIR"
|
||||
total_fail=0
|
||||
|
||||
for host in "${HOSTS[@]}"; do
|
||||
target=$(resolve_target "$host")
|
||||
printf '%s (%s):\n' "$host" "$target"
|
||||
|
||||
mapfile -t compose_stacks < <(list_remote_subdirs "$target" /opt/docker/compose)
|
||||
mapfile -t conf_stacks < <(list_remote_subdirs "$target" /opt/docker/conf)
|
||||
|
||||
if [ "${#compose_stacks[@]}" -eq 0 ] && [ "${#conf_stacks[@]}" -eq 0 ]; then
|
||||
printf ' (no stacks discovered — check ssh + remote /opt/docker layout)\n'
|
||||
continue
|
||||
fi
|
||||
|
||||
# Union of stack names.
|
||||
mapfile -t all_stacks < <(printf '%s\n' "${compose_stacks[@]}" "${conf_stacks[@]}" | sort -u | grep .)
|
||||
|
||||
# Warn about local stacks that no longer exist on the remote.
|
||||
if [ -d "$MIRROR_DIR/$host" ]; then
|
||||
for local_stack in "$MIRROR_DIR/$host"/*/; do
|
||||
[ -d "$local_stack" ] || continue
|
||||
name=$(basename "$local_stack")
|
||||
if ! printf '%s\n' "${all_stacks[@]}" | grep -qxF "$name"; then
|
||||
printf ' ! %s exists locally but not on remote (stale — remove manually if intentional)\n' "$name"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
for stack in "${all_stacks[@]}"; do
|
||||
printf ' %s\n' "$stack"
|
||||
stack_root="$MIRROR_DIR/$host/$stack"
|
||||
if [ -f "$stack_root/.no-sync" ]; then
|
||||
printf ' skip (.no-sync at stack root)\n'
|
||||
continue
|
||||
fi
|
||||
if printf '%s\n' "${compose_stacks[@]}" | grep -qxF "$stack"; then
|
||||
sync_one "$host" "$target" "$stack" compose "$stack_root" || total_fail=$((total_fail+1))
|
||||
fi
|
||||
if printf '%s\n' "${conf_stacks[@]}" | grep -qxF "$stack"; then
|
||||
sync_one "$host" "$target" "$stack" conf "$stack_root/conf" || total_fail=$((total_fail+1))
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$total_fail" -gt 0 ]; then
|
||||
printf '\n%d sync operation(s) failed\n' "$total_fail" >&2
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user