From 6f7bb4885e5caccc20676f59e57256a6de8892f5 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Sun, 26 Apr 2026 22:07:30 -0700 Subject: [PATCH] =?UTF-8?q?stacks/=20is=20canonical;=20stacks-mirror/=20is?= =?UTF-8?q?=20drift=20snapshot=20=E2=80=94=20stop=20confusing=20the=20two?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision recorded in CLAUDE.md ("Stack tree convention") and memory (convention_stacks_vs_mirror.md): stacks// canonical / intent. git-tracked. deploy-stack.sh reads from here. stacks-mirror/// 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. --- CLAUDE.md | 40 +++++++++++++++++------- scripts/deploy-stack.sh | 19 ++++++----- scripts/sync-stacks.sh | 9 +++++- stacks/llama-swap/{ => conf}/config.yaml | 0 4 files changed, 48 insertions(+), 20 deletions(-) rename stacks/llama-swap/{ => conf}/config.yaml (100%) diff --git a/CLAUDE.md b/CLAUDE.md index ca7d6af..bdbec28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,23 +140,36 @@ scripts/refresh-server-info.sh --validate-only all scripts/refresh-server-info.sh --validate-only ``` -## Stack mirror (pull / push) +## Stack tree convention (canonical vs mirror) -Compose and config trees are mirrored into `stacks-mirror///` so they can be diffed and version-controlled. Pull is fleet-wide and safe; push is one stack at a time with a diff + prompt. +Two trees, distinct roles. **They are NOT interchangeable.** + +| tree | role | git | who writes | who reads | +|---|---|---|---|---| +| `stacks//` | **canonical / intent** — source of truth for what we want deployed | tracked | you / Claude | `deploy-stack.sh` | +| `stacks-mirror///` | **snapshot / reality** — what's currently on each host | gitignored | `sync-stacks.sh` | drift inspection | + +**Why two:** keeps "intent" (committed, reviewable, deployed) cleanly separate from "reality on the server right now" (often drifts, useful to compare, not durable). Editing the mirror does NOT affect what gets deployed. ```bash -# Pull compose + conf from every host into stacks-mirror/ -scripts/sync-stacks.sh -scripts/sync-stacks.sh --dry-run # see what would change -scripts/sync-stacks.sh ana-docker # one host +# Edit the canonical, then push it to the host: +# stacks// → /opt/docker/compose// +# stacks//conf/ → /opt/docker/conf// +$EDITOR stacks//compose.yaml +scripts/deploy-stack.sh # diffs vs live, prompts y/N +scripts/deploy-stack.sh --compose # skip conf side +scripts/deploy-stack.sh --conf # skip compose side -# Push a local stack back to the server (diffs each file, prompts y/N) -scripts/deploy-stack.sh -scripts/deploy-stack.sh --compose # skip conf -scripts/deploy-stack.sh --conf # skip compose +# Pull current host state into the gitignored snapshot tree (drift check): +scripts/sync-stacks.sh # every host +scripts/sync-stacks.sh ana-docker # one host +scripts/sync-stacks.sh --dry-run # see what would change + +# Compare canonical (intent) vs mirror (reality) for one stack: +diff -ru stacks// stacks-mirror/// ``` -**Opt-out per stack:** create `stacks-mirror///.no-sync` (skip both sides) or `stacks-mirror///conf/.no-sync` (skip conf only). +**Opt-out per stack** (mirror only — sync-stacks.sh skip): create `stacks-mirror///.no-sync` (skip both sides) or `stacks-mirror///conf/.no-sync` (skip conf only). **Always excluded in both directions** (secrets / runtime state): `.env`, `.env.*`, `acme.json`, `client_secrets.json`, `*.pem`, `*.key`, `*.crt`, `*.pfx`, `*.sqlite`, `*.sqlite3`, `*.db`, `*.log`, `*.log.*`, `*.pid`, `hub/`, `logs/`. @@ -174,11 +187,14 @@ eshpfi-management/ │ └── / │ ├── README.md │ └── system-details.txt # latest server_inspect output -├── stacks/ +├── stacks/ # canonical/intent — git-tracked source of truth │ └── / │ ├── compose.yaml # deployed to /opt/docker/compose// +│ ├── conf/ # deployed to /opt/docker/conf// │ ├── .env.example # template; real .env lives on server │ └── README.md # what this stack does, how to deploy +├── stacks-mirror/ # gitignored snapshot of live host state (drift detection) +│ └── // # populated by sync-stacks.sh, NOT a deploy source └── docs/ └── pfi/ # general PFI infrastructure reference ``` diff --git a/scripts/deploy-stack.sh b/scripts/deploy-stack.sh index 432bf38..bc5200e 100755 --- a/scripts/deploy-stack.sh +++ b/scripts/deploy-stack.sh @@ -1,10 +1,15 @@ #!/usr/bin/env bash -# deploy-stack.sh — push a local stacks-mirror dir to a server, with -# per-file diff and confirmation prompt. +# 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-mirror/// → :/opt/docker/compose// -# stacks-mirror///conf/ → :/opt/docker/conf// +# 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, @@ -32,7 +37,7 @@ 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" +STACKS_DIR="$REPO_ROOT/stacks" EXCLUDES=( # Include .env.example / *.env.example templates before the broader @@ -100,9 +105,9 @@ resolve_target() { } TARGET=$(resolve_target "$HOST") -STACK_DIR="$MIRROR_DIR/$HOST/$STACK" +STACK_DIR="$STACKS_DIR/$STACK" -[ -d "$STACK_DIR" ] || { echo "error: $STACK_DIR not found (pull with sync-stacks.sh first)" >&2; exit 2; } +[ -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: "||" diff --git a/scripts/sync-stacks.sh b/scripts/sync-stacks.sh index 038f54a..b3dea93 100755 --- a/scripts/sync-stacks.sh +++ b/scripts/sync-stacks.sh @@ -1,6 +1,13 @@ #!/usr/bin/env bash # sync-stacks.sh — pull /opt/docker/{compose,conf}// from every -# server into version-controlled `stacks-mirror///`. +# server into the gitignored `stacks-mirror///` 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//` (git-tracked), and `deploy-stack.sh` pushes from +# there. Use the mirror to compare what we have to what's deployed: +# diff -ru stacks// stacks-mirror/// +# See CLAUDE.md "Stack tree convention" for the full split. # # Layout (flat per stack): # stacks-mirror/// <- mirrors /opt/docker/compose// diff --git a/stacks/llama-swap/config.yaml b/stacks/llama-swap/conf/config.yaml similarity index 100% rename from stacks/llama-swap/config.yaml rename to stacks/llama-swap/conf/config.yaml