#!/usr/bin/env bash # deploy-stack.sh — push a canonical stack from `stacks/` to a server, # with per-file diff and confirmation prompt. # # Source of truth is `stacks//` (the canonical, git-tracked tree). # `stacks-mirror/` is a separate, gitignored snapshot of what's currently # on each host (pulled by sync-stacks.sh) — it's used for drift detection, # NOT as the deploy source. See CLAUDE.md "Stack tree convention". # # Layout assumed: # stacks//:/opt/docker/compose// # stacks//conf/:/opt/docker/conf// # # Secrets / runtime state are never pushed (same exclude list as # sync-stacks.sh): .env*, acme.json, *.key/crt/pem/pfx, *.sqlite*, *.db, # *.log*, *.pid, hub/, logs/, client_secrets.json. # # The script: # 1. Runs rsync --dry-run to find which files would change. # 2. Prints a unified diff for each changed/added file (deletions noted). # 3. Prompts [y/N]; applies the rsync only on 'y'. # # Usage: # scripts/deploy-stack.sh # scripts/deploy-stack.sh --yes # skip prompt (use sparingly) # scripts/deploy-stack.sh --compose # push only compose side # scripts/deploy-stack.sh --conf # push only conf side 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 the target 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" STACKS_DIR="$REPO_ROOT/stacks" 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/' ) HOST= STACK= ASSUME_YES=0 DO_COMPOSE=1 DO_CONF=1 for a in "$@"; do case "$a" in --yes|-y) ASSUME_YES=1 ;; --compose) DO_CONF=0 ;; --conf) DO_COMPOSE=0 ;; -h|--help) sed -n '2,22p' "$0"; exit 0 ;; -*) echo "error: unknown flag $a" >&2; exit 2 ;; *) if [ -z "$HOST" ]; then HOST="$a" elif [ -z "$STACK" ]; then STACK="$a" else echo "error: unexpected positional '$a'" >&2; exit 2 fi ;; esac done [ -n "$HOST" ] || { echo "usage: $(basename "$0") " >&2; exit 2; } [ -n "$STACK" ] || { echo "usage: $(basename "$0") " >&2; exit 2; } 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" } TARGET=$(resolve_target "$HOST") STACK_DIR="$STACKS_DIR/$STACK" [ -d "$STACK_DIR" ] || { echo "error: $STACK_DIR not found — author the canonical stack first (see stacks// for examples)" >&2; exit 2; } # Collect the two src/dest pairs we need to consider. PAIRS=() # each entry: "||" if [ "$DO_COMPOSE" -eq 1 ]; then PAIRS+=("compose|$STACK_DIR/|$TARGET:/opt/docker/compose/$STACK/") fi if [ "$DO_CONF" -eq 1 ] && [ -d "$STACK_DIR/conf" ]; then PAIRS+=("conf|$STACK_DIR/conf/|$TARGET:/opt/docker/conf/$STACK/") fi [ "${#PAIRS[@]}" -gt 0 ] || { echo "nothing to deploy"; exit 0; } # --------- Dry-run summary: which files would change, per kind. -------- declare -A CHANGED_FILES_BY_KIND=() # kind → newline-separated list declare -A DELETED_FILES_BY_KIND=() declare -A RAW_RSYNC_OUT_BY_KIND=() # kind → raw rsync itemize output any_change=0 for entry in "${PAIRS[@]}"; do IFS='|' read -r kind src dest <<<"$entry" extra=() [ "$kind" = compose ] && extra+=(--exclude='conf/') # Pre-create the remote dir. Without this, rsync against a nonexistent # destination can fail in ways the dry-run doesn't surface cleanly. remote_path="/opt/docker/$kind/$STACK/" if ! ssh -n -o BatchMode=yes -o ConnectTimeout=10 "$TARGET" \ "mkdir -p '$remote_path'" 2>/dev/null; then echo "error: could not create $remote_path on $TARGET (check perms / ssh)" >&2 exit 2 fi tmp_out=$(mktemp) tmp_err=$(mktemp) rc=0 rsync -az --delete --dry-run \ --out-format='%i %n' \ "${EXCLUDES[@]}" "${extra[@]}" \ "$src" "$dest" >"$tmp_out" 2>"$tmp_err" || rc=$? if [ "$rc" -ne 0 ]; then echo "error: rsync dry-run failed (exit $rc) for $src → $dest" >&2 sed 's/^/ /' "$tmp_err" >&2 rm -f "$tmp_out" "$tmp_err" exit 2 fi mapfile -t lines < "$tmp_out" RAW_RSYNC_OUT_BY_KIND[$kind]=$(cat "$tmp_out") rm -f "$tmp_out" "$tmp_err" changed="" deleted="" for ln in "${lines[@]}"; do # Itemized codes (rsync uses '<' for push, '>' for pull): # f+++++++++ newfile (new file, pull) # f'*) changed+="$name"$'\n' ;; *) : ;; # dir entries, metadata-only, unknown esac done CHANGED_FILES_BY_KIND[$kind]="$changed" DELETED_FILES_BY_KIND[$kind]="$deleted" if [ -n "$changed$deleted" ]; then any_change=1; fi done if [ "$any_change" -eq 0 ]; then echo "up to date: $HOST/$STACK is already in sync with server." # Diagnostic: if the remote dir actually looks empty, we may have been # fooled by an rsync quirk — dump what rsync saw so the user can tell. for entry in "${PAIRS[@]}"; do IFS='|' read -r kind _ _ <<<"$entry" raw=${RAW_RSYNC_OUT_BY_KIND[$kind]:-} remote_path="/opt/docker/$kind/$STACK/" remote_count=$(ssh -n -o BatchMode=yes "$TARGET" \ "find '$remote_path' -mindepth 1 -maxdepth 1 2>/dev/null | wc -l" \ 2>/dev/null || echo "?") printf ' %s: remote has %s entries, rsync itemize output:\n' "$kind" "$remote_count" if [ -z "$raw" ]; then printf ' (empty — rsync reported no work)\n' else sed 's/^/ /' <<<"$raw" fi done exit 0 fi # --------- Print diffs. ----------------------------------------------- divider() { printf '\n%s\n' "------------------------------------------------------------"; } for entry in "${PAIRS[@]}"; do IFS='|' read -r kind src dest <<<"$entry" remote_base="/opt/docker/$kind/$STACK" changed=${CHANGED_FILES_BY_KIND[$kind]:-} deleted=${DELETED_FILES_BY_KIND[$kind]:-} [ -z "$changed$deleted" ] && continue printf '\n=== %s → %s ===\n' "$src" "$dest" # ssh below runs with -n: without it, ssh slurps the loop's heredoc # stdin ($changed / $deleted) and eats the remaining iterations, # silently truncating the diff output to the first file only. while IFS= read -r rel; do [ -z "$rel" ] && continue local_file="$src$rel" remote_file="$remote_base/$rel" divider if ssh -n -o BatchMode=yes "$TARGET" "[ -f '$remote_file' ]" 2>/dev/null; then printf 'MODIFY %s\n' "$rel" diff -u --label "a/$rel (remote)" --label "b/$rel (local)" \ <(ssh -n -o BatchMode=yes "$TARGET" "cat '$remote_file'" 2>/dev/null) \ "$local_file" || true else printf 'ADD %s\n' "$rel" diff -u --label /dev/null --label "b/$rel (local)" \ /dev/null "$local_file" || true fi done <<<"$changed" while IFS= read -r rel; do [ -z "$rel" ] && continue remote_file="$remote_base/$rel" divider printf 'DELETE %s\n' "$rel" diff -u --label "a/$rel (remote)" --label /dev/null \ <(ssh -n -o BatchMode=yes "$TARGET" "cat '$remote_file'" 2>/dev/null) \ /dev/null || true done <<<"$deleted" done divider if [ "$ASSUME_YES" -ne 1 ]; then read -r -p "Apply these changes to $TARGET? [y/N] " ans case "$ans" in y|Y|yes|YES) ;; *) echo "aborted."; exit 1 ;; esac fi # --------- Apply. ----------------------------------------------------- for entry in "${PAIRS[@]}"; do IFS='|' read -r kind src dest <<<"$entry" extra=() [ "$kind" = compose ] && extra+=(--exclude='conf/') printf 'pushing %s → %s\n' "$src" "$dest" rsync -az --delete \ "${EXCLUDES[@]}" "${extra[@]}" \ "$src" "$dest" done echo "done."