8c15ec2d5f
pfi-pve (Anaheim), nh3-pve (NH), esh-pve + esh-pve-nas (ESH home lab) now have servers/<host>/ entries with ssh-target files, README stubs, and first snapshots. Register all four via add-host.sh + ssh-copy-id; SSH auth is root@<name> with key-based access. Fix a resolver bug in the three workspace scripts: previously, when a hostname resolved via /etc/hosts, resolve_target returned the bare name and ssh dropped the user prefix stored in ssh-target (connecting as the wrong user). Now ssh-target wins whenever it's present, since it can carry user@ or port info that /etc/hosts + ssh_config cannot. Falls back to the dir name only when no ssh-target file exists. CLAUDE.md and README.md split the fleet table into Docker hosts vs hypervisors with an explicit placement rule: don't deploy Docker stacks on PVE hosts; capture host-level detail with server_inspect.sh, use Proxmox-native tooling (qm list, pvesh, zpool) for VM/storage audits.
197 lines
6.1 KiB
Bash
Executable File
197 lines
6.1 KiB
Bash
Executable File
#!/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() {
|
|
# ssh-target file wins when present (may carry user@ or non-default port);
|
|
# /etc/hosts + ssh_config is the fallback.
|
|
local host="$1"
|
|
local fb="$SERVERS_DIR/$host/ssh-target"
|
|
if [ -f "$fb" ]; then
|
|
local t
|
|
t=$(awk 'NF{print $1; exit}' "$fb")
|
|
if [ -n "$t" ]; then echo "$t"; return; fi
|
|
fi
|
|
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
|
|
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
|