Files
vh 6f7bb4885e stacks/ is canonical; stacks-mirror/ is drift snapshot — stop confusing the two
Decision recorded in CLAUDE.md ("Stack tree convention") and memory
(convention_stacks_vs_mirror.md):

  stacks/<stack>/                    canonical / intent. git-tracked.
                                     deploy-stack.sh reads from here.
  stacks-mirror/<host>/<stack>/      snapshot / reality. gitignored.
                                     sync-stacks.sh writes here. Used
                                     for drift inspection only — never
                                     a deploy source.

Bug this fixes: deploy-stack.sh was reading from the mirror, so edits
to stacks/llama-swap/config.yaml never reached ana-ml2. Today's
two new model entries (qwen3.6-35-a3b-heretic + qwen3.6-27b) lived
in the canonical for hours but the deploy reported "in sync" because
the script only diffed mirror vs server.

Changes:
* deploy-stack.sh: source switched from MIRROR_DIR/$HOST/$STACK to
  STACKS_DIR/$STACK. Header comment + error message updated.
* sync-stacks.sh: header explicitly identifies its role as drift
  detection; documents the diff command for comparing canonical vs
  mirror.
* stacks/llama-swap/{config.yaml → conf/config.yaml}: matches the
  deploy mapping (conf/ in canonical → /opt/docker/conf/ on host).
* CLAUDE.md: "Stack mirror (pull / push)" section rewritten as
  "Stack tree convention (canonical vs mirror)" with the role table
  + workflow rules + diff recipe. Layout diagram updated.
2026-04-26 22:07:30 -07:00

204 lines
6.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# sync-stacks.sh — pull /opt/docker/{compose,conf}/<stack>/ from every
# server into the gitignored `stacks-mirror/<host>/<stack>/` snapshot tree.
#
# Role: drift detection. The mirror reflects what's actually running on
# each host RIGHT NOW. The canonical source of truth lives at
# `stacks/<stack>/` (git-tracked), and `deploy-stack.sh` pushes from
# there. Use the mirror to compare what we have to what's deployed:
# diff -ru stacks/<stack>/ stacks-mirror/<host>/<stack>/
# See CLAUDE.md "Stack tree convention" for the full split.
#
# 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