Files
esh-pfi-infrastructure/scripts/deploy-stack.sh
T
vh 3132a16ca0 fv-ml1: finish the renumber the cutover missed -- 16 dead dashboard links
Every fv-ml1 link on the Homepage dashboard was broken. Measured against the
live dashboard API before the fix: 16 entries pointing at the dead 10.250.50.54
and zero at the live 10.251.50.54, covering gen, M.O.G.-SEC, Scriberr, Embed,
Rerank, Reward, Coder, Dockge and six dormant seats.

The miss was structural, not careless. fv-ml1-rename-sweep.sh works from an
allowlist assembled from files that mention the HOST, and a homepage.href label
mentions only an IP -- so every stack whose sole stale reference was a label
fell outside it. The allowlist now covers those 24 files, and records how to
derive the list next time (grep the old address, subtract history) rather than
enumerating from memory.

History is still untouched, and the exclusions are now written down with the
reason each one keeps the old address: recorded benchmark results, whose
base_url is part of a measurement's provenance; the one LiteLLM comment
preserving a retired hand-test endpoint; and the cutover runbooks, where the old
address is the subject matter.

Two bugs found while applying it, both fixed here:

  - deploy-stack.sh rejected any stack name containing a dot, so qwen3.5-122b,
    qwopus3.5-122b and mistral-medium-3.5 could not be deployed by the script at
    all. The check exists to stop path traversal, which means rejecting ".." and
    "/" -- not every dot. Traversal is now rejected explicitly and tested.
  - stacks/scriberr/.env.example allowed CORS only from the dead IP and from
    scriberr.ana.internal, which no longer resolves; the box is at the fv site
    and DNS already carries scriberr.fv.internal. The live .env had both stale
    origins, i.e. an allowlist with nothing reachable in it.

Host side, applied separately: canonical pushed for the 16 stacks whose only
difference from the host was this renumber, and an in-place address-only fix for
the nine whose host copy has genuinely drifted or has no canonical copy, so that
drift survives for a deliberate reconciliation instead of being clobbered. Every
compose.yaml on fv-ml1 now reads 10.251.50.54. The labels themselves only take
effect at container creation, so the running containers still need recreating.
2026-09-12 23:05:29 -07:00

295 lines
10 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
# Optional environment:
# DEPLOY_DEST_STACK=<name> retain a legacy remote stack directory/project
# DEPLOY_SUDO=1 use passwordless sudo for remote files and rsync
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
DEST_STACK=${DEPLOY_DEST_STACK:-}
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; }
DEST_STACK=${DEST_STACK:-$STACK}
# Dots ARE legal — several stacks carry a version in the name (qwen3.5-122b,
# qwopus3.5-122b, mistral-medium-3.5). The previous pattern excluded them, so
# those stacks could not be deployed by this script AT ALL; it surfaced on
# 2026-09-13 as "invalid DEPLOY_DEST_STACK" during the fv-ml1 label repoint.
# The check exists to stop path traversal and shell metacharacters, which means
# it has to reject `..` and `/` — not every dot.
case "$DEST_STACK" in
*..*|*/*) echo "invalid stack name (path traversal): $DEST_STACK" >&2; exit 2 ;;
esac
[[ "$DEST_STACK" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] || { echo "invalid stack name: $DEST_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")
REMOTE_PREFIX=
RSYNC_REMOTE=()
if [ "${DEPLOY_SUDO:-0}" = 1 ]; then
REMOTE_PREFIX='sudo -n '
RSYNC_REMOTE=(--rsync-path='sudo -n rsync')
fi
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/$DEST_STACK/")
fi
if [ "$DO_CONF" -eq 1 ] && [ -d "$STACK_DIR/conf" ]; then
PAIRS+=("conf|$STACK_DIR/conf/|$TARGET:/opt/docker/conf/$DEST_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/$DEST_STACK/"
if ! ssh -n -o BatchMode=yes -o ConnectTimeout=10 "$TARGET" \
"${REMOTE_PREFIX}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 \
"${RSYNC_REMOTE[@]}" \
--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/$DEST_STACK/"
remote_count=$(ssh -n -o BatchMode=yes "$TARGET" \
"${REMOTE_PREFIX}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/$DEST_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" "${REMOTE_PREFIX}test -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" "${REMOTE_PREFIX}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" "${REMOTE_PREFIX}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 \
"${RSYNC_REMOTE[@]}" \
"${EXCLUDES[@]}" "${extra[@]}" \
"$src" "$dest"
done
echo "done."