9e9bdf9810
Without -n, ssh inherits the surrounding loop's stdin and consumes the heredoc that feeds $changed / $deleted, silently truncating the diff output to the first file only.
272 lines
9.1 KiB
Bash
Executable File
272 lines
9.1 KiB
Bash
Executable File
#!/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/<stack>/` (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/<stack>/<file> → <host>:/opt/docker/compose/<stack>/<file>
|
|
# stacks/<stack>/conf/<file> → <host>:/opt/docker/conf/<stack>/<file>
|
|
#
|
|
# 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 <host> <stack>
|
|
# scripts/deploy-stack.sh <host> <stack> --yes # skip prompt (use sparingly)
|
|
# scripts/deploy-stack.sh <host> <stack> --compose # push only compose side
|
|
# scripts/deploy-stack.sh <host> <stack> --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") <host> <stack>" >&2; exit 2; }
|
|
[ -n "$STACK" ] || { echo "usage: $(basename "$0") <host> <stack>" >&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/<other>/ for examples)" >&2; exit 2; }
|
|
|
|
# Collect the two src/dest pairs we need to consider.
|
|
PAIRS=() # each entry: "<kind>|<src>|<dest>"
|
|
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, push)
|
|
# >f+++++++++ newfile (new file, pull)
|
|
# <f..t...... file.yaml (content update, push)
|
|
# *deleting oldfile (deletion, either direction)
|
|
# .d..t...... ./ (metadata on a dir — skip)
|
|
# cd+++++++++ somedir/ (new dir — skip, we diff files only)
|
|
[ -z "$ln" ] && continue
|
|
code=$(awk '{print $1}' <<<"$ln")
|
|
name=$(awk '{ $1=""; sub(/^ /,""); print }' <<<"$ln")
|
|
[ -z "$name" ] && continue
|
|
[ "${name: -1}" = "/" ] && continue # directory entry
|
|
|
|
case "$code" in
|
|
'*deleting') deleted+="$name"$'\n' ;;
|
|
'<f'*|'>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."
|