From 114f234cd32240dc9c335a57a315c95d1dc68a45 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Sun, 12 Jul 2026 20:50:15 +0200 Subject: [PATCH] Revise announce+agent proposal against the real deploy script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original draft speced a hypothetical per-project agent.sh on a per-project cron. Reality turned out different: there's already a production bulk deploy script on the host (fixed project array, docker compose up -d per project, no explicit pull — freshness comes entirely from pull_policy: always) — so this patches that existing loop in place instead of introducing a parallel per-project script that would duplicate its array/failure-tracking logic. Also folds in a real prerequisite gap found while reconciling the proposal with the actual script: pull_policy: always isn't universal across the fleet yet, independent of keep entirely — the bulk script can't redeploy anything missing that line regardless of keep adoption. One instance of this was already fixed this session (a sibling project's docker-compose.yml missed the line when copying another project's compose pattern). Reframes §5's announce/watch benefit accordingly: since the real script is one all-or-nothing sweep, not N independent watchers, the natural fit is waking the whole sweep early (watch-any), not per-project selective redeploy — still deliberately left unbuilt until there's a real signal it's worth it over the already-scheduled sweep. Project names anonymized per policy — generic proj1..proj12 placeholders instead of the real fleet. Co-Authored-By: Claude Sonnet 5 --- PROPOSAL-announce-and-agent.md | 221 +++++++++++++++++++-------------- 1 file changed, 129 insertions(+), 92 deletions(-) diff --git a/PROPOSAL-announce-and-agent.md b/PROPOSAL-announce-and-agent.md index 4d07a88..f903f76 100644 --- a/PROPOSAL-announce-and-agent.md +++ b/PROPOSAL-announce-and-agent.md @@ -23,13 +23,50 @@ code. The relevant existing pieces for this proposal: - `src/cli/` — `identity.ts`, `recipient.ts`, `vault.ts`, `signedFetch.ts` (note: `signedFetch`'s `path` param must never include a query string — see the comment there for why). -- `scripts/deploy.sh` — pulls `/` from `keep` into - `$DEPLOY_ROOT//.env`, then `docker compose up -d`. Assumes - the vault already exists and this machine already has a grant on it. -- `.gitea/workflows/docker.yml` (present in `keep`, `wisp`, - `npm-statuspage`) — builds and pushes an image to the Gitea container +- `scripts/deploy.sh` (this repo) — pulls `/` from `keep` + into `$DEPLOY_ROOT//.env`, then `docker compose up -d` for + one project. Assumes the vault already exists and this machine + already has a grant on it. **Superseded by the real deploy script + below as the thing this proposal actually patches** — kept in this + repo as a single-project example/fallback, not the production path. +- **The real, already-running deploy script — on the host, not in any + repo.** A fixed-array bulk script, already in production use, looping + over roughly a dozen project directories: + + ```bash + ROOT="$(cd "$(dirname "$0")" && pwd)" + PROJECTS=(proj1 proj2 proj3 proj4 proj5 proj6 proj7 proj8 proj9 proj10 proj11 proj12) + for project in "${PROJECTS[@]}"; do + dir="$ROOT/$project" + [ -f "$dir/docker-compose.yml" ] || { echo "skip: $project"; continue; } + (cd "$dir" && docker compose up -d) || FAILED+=("$project") + done + docker image prune -f + ``` + + Two things about it that matter for this proposal: **it never runs + `docker compose pull`** — freshness comes entirely from each + project's own `pull_policy: always` in its `docker-compose.yml` + (`up -d` re-pulls implicitly). And **it loops over a fixed, explicit + array**, not directory auto-discovery — adding a project means + editing this array by hand. This is the actual thing to patch, not a + new per-project script; see Design §4. +- **Prerequisite gap, found while writing this proposal, not yet fixed + everywhere**: `pull_policy: always` isn't universal across the + fleet — confirmed present in roughly half the projects checked, + confirmed missing in several others (one of which was fixed in this + session — a sibling project's `docker-compose.yml` didn't have it, a + regression from copying another project's compose file without + carrying this line over), and unchecked for the rest (no local + checkout available at proposal time). **This is independent of + `keep` entirely** — the bulk script can't redeploy any project + missing this line regardless of whether that project ever adopts + `keep`, and it should be swept fleet-wide before or alongside this + work, not as part of it. +- `.gitea/workflows/docker.yml` (present in `keep` and other sibling + projects) — builds and pushes an image to the Gitea container registry on every push to `main`. Does **not** currently deploy - anything — that's a manual step today. + anything — that's the manual/scheduled bulk script above today. ## Motivation @@ -38,7 +75,7 @@ is still a manual "SSH in and run the deploy script" step. The obvious fix — CI SSHes into the VPS and runs the deploy — was considered and rejected: it means a standing SSH credential with production-deploy rights sits in Gitea's CI secrets, reachable by whatever's executing on -the shared runner across all ~17 projects it builds. A compromised build +the shared runner across every project it builds. A compromised build step in any one project could then reach every other project's deploy target. That's a real regression from where `keep` already left things (see IMPLEMENTATION.md's resolved "read no longer implies write" — the @@ -55,12 +92,11 @@ relay, same posture as everything else it does. **Not every project will have been migrated to `keep` yet.** Bootstrapping a project (register a recipient, grant it, push the first vault) is a real, deliberate step per project, and there'll be a period — maybe a -long one — where some of the ~17 projects have it and some don't. The -self-deploying agent has to work identically well for both, defaulting to -exactly what happens today (a plain `docker compose pull && up -d` -against whatever's already on disk) for anything not yet bootstrapped, -rather than either blocking deploys entirely or hard-failing on missing -vaults. +long one — where some projects have it and some don't. The redeploy path +has to work identically well for both, defaulting to exactly what +happens today (a plain `docker compose up -d`, relying on that project's +own `pull_policy: always`) for anything not yet bootstrapped, rather than +either blocking deploys entirely or hard-failing on missing vaults. ## Design @@ -113,91 +149,93 @@ keep watch [--interval 60] -- polls GET .../announce every N seconds, ``` `keep watch` is deliberately dumb — plain polling, not a websocket or -long-poll. Matches `npm-statuspage`'s existing polling pattern, and a -60-second detection lag for a deploy is fine. Don't build push-based -notification for this; it's more machinery for a problem polling already -solves adequately. +long-poll. Matches the polling pattern already used elsewhere in this +project family, and a 60-second detection lag for a deploy is fine. +Don't build push-based notification for this; it's more machinery for a +problem polling already solves adequately. -### 4. The agent script and its fallback +### 4. Patch the existing loop — don't build a parallel script -This is the part the fallback requirement is actually about. One script -per VPS, running on a timer (systemd timer or cron, not a long-lived -daemon — matches this project's bias toward simple, restartable, -stateless-between-runs tooling), one invocation per project it's -responsible for: +This is the part the fallback requirement is actually about. The real +deploy script already does the one thing that matters (a loop over a +fixed project list, `docker compose up -d` per project, relying on +`pull_policy: always` for freshness) — the change is inserting one +`keep`-with-fallback step per iteration, in place, not writing a new +per-project `agent.sh` that duplicates the loop/array/failure-tracking +logic the real script already has: ```bash -#!/usr/bin/env bash -# agent.sh [env] — self-deploy check for one project. Meant to -# run on a schedule (cron/systemd timer), not as a daemon. -set -euo pipefail +for project in "${PROJECTS[@]}"; do + dir="$ROOT/$project" + [ -f "$dir/docker-compose.yml" ] || { echo "skip: $project"; continue; } -project="${1:?usage: agent.sh [env]}" -env="${2:-production}" -root="${DEPLOY_ROOT:-/opt/docker}" -dir="$root/$project" -vault="$project/$env" + echo "" + echo "▶ $project" -if [ ! -d "$dir" ]; then - echo "error: $dir does not exist" >&2 - exit 1 -fi + # Best-effort: does this machine have a working keep grant for + # "$project/production"? If not — not bootstrapped onto keep yet, or + # never will be (a static site with no secrets) — fall through to + # exactly what this script already did before keep existed. Every + # keep call here is allowed to fail; failure means "keep doesn't + # apply to this project," never "stop the deploy." + if keep pull "$project/production" --out "$dir/.env.new" 2>/dev/null; then + mv "$dir/.env.new" "$dir/.env" + else + rm -f "$dir/.env.new" + fi -cd "$dir" - -# Best-effort: does this machine have a working keep grant on this -# vault at all? If not — project not yet bootstrapped onto keep, or -# this box was never granted access — fall through to the plain path. -# Every keep call below is allowed to fail; failure just means "keep -# isn't available for this project," not "the deploy is broken." -if keep pull "$vault" --out .env.new 2>/dev/null; then - mv .env.new .env - docker compose pull -q - docker compose up -d -else - rm -f .env.new - # No keep vault (or no grant) for this project — deploy exactly as it - # would have worked before keep existed: whatever .env is already - # sitting here (possibly none, if the compose file doesn't need one), - # plain pull + up. This path must never hard-fail just because keep - # doesn't know about this project. - docker compose pull -q - docker compose up -d -fi + if (cd "$dir" && docker compose up -d 2>&1); then + echo " ✓ $project up" + else + echo " ✗ $project failed" + FAILED+=("$project") + fi +done ``` +Vault key convention: `/production`, using the exact directory +name already in `PROJECTS` — no separate mapping table to keep in sync. + Notice `announce`/`watch` aren't actually load-bearing in this version — -the agent above just runs on a timer and lets `docker compose pull` -figure out for itself whether there's anything new (a no-op if the -digest hasn't changed, idempotent either way). That's deliberate: **a -dumb periodic `compose pull && up -d` is already a complete, correct -baseline deploy strategy on its own**, keep-bootstrapped or not. `announce` -sits on top as an optimization for bootstrapped projects, not a -requirement for the agent to function — see the next section. +whatever already triggers this script (cron, systemd timer, or still +run by hand) keeps triggering it exactly as before, and `keep pull` +either updates `.env` or silently doesn't. That's deliberate: **the +existing script, patched this way, is already a complete, correct +baseline** — keep-bootstrapped projects get fresh secrets on every run, +everything else behaves identically to today. `announce` sits on top as +a latency optimization *for however this script gets triggered*, not a +requirement for this patch to be complete — see the next section. ### 5. Where `announce`/`watch` actually earn their keep -For a project that *has* been bootstrapped, there are two independent -benefits, worth keeping conceptually separate: +Two independent benefits, worth keeping conceptually separate — and the +second one looks different than it would for a one-agent-per-project +design, because the real script is one bulk all-or-nothing sweep, not N +independent watchers: - **Secrets sourcing** — `keep pull` instead of a hand-placed `.env`. - Available the moment a vault exists and this machine has a grant. - Already true of `deploy.sh` today; unchanged by this proposal. -- **Fast, precise redeploy** — instead of every VPS blindly polling - `docker compose pull` on a fixed timer for every project it hosts - (harmless, but imprecise and up to a full interval late), a - bootstrapped project's agent can `keep watch` and react within - seconds of the CI `announce` step, and only for the project that - actually changed rather than sweeping all of them. + Available per-project the moment its vault exists and this machine + has a grant on it. This is what §4's patch already delivers, on its + own, with zero dependency on `announce`. +- **Waking the sweep early** — however the bulk script currently gets + triggered (cron, systemd timer, or still by hand), `announce` could + let it wake immediately on a push instead of waiting for the next + scheduled run. The natural fit given the script's actual shape: `keep + watch-any ...` blocking until *any* watched vault's + tag changes, used as the trigger for a full sweep — not a per-project + selective redeploy. Selective ("only redeploy the one project that + actually changed") would require reshaping the script away from its + current fixed-array-sweep model into something that takes a target + project as an argument, which is a real behavior change to something + already in production use, not a small addition — out of scope here. -A fancier version of the agent script could branch on `keep watch` -succeeding vs. not to decide between "wait for a watch event" and "just -poll on the dumb timer" — deliberately **not** speccing that split here. -Ship the dumb-timer-plus-opportunistic-`keep-pull` version first (above); -it already satisfies the fallback requirement completely, and whether -the watch-driven fast path is worth the added complexity should be -judged after living with the dumb version for a while, not designed in -advance of any real signal that polling latency is actually a problem. +Deliberately **not** building the wake-the-sweep-early mechanism in this +pass. Ship §4's patch first — it already satisfies the fallback +requirement completely and delivers the secrets-sourcing benefit on its +own. Whether waiting up to one scheduled interval for a redeploy is +actually a problem worth solving with `announce`/`watch-any` should be +judged after living with the patched bulk script for a while, not +designed in advance of any real signal that the wait matters. ### 6. CI workflow addition @@ -249,16 +287,15 @@ not-yet-bootstrapped project, not a red X on the workflow. ## Open questions -- **Per-project agent scheduling** — one cron line per project, or one - script that loops over every directory under `$DEPLOY_ROOT`? Leaning - toward one-line-per-project (explicit, greppable in crontab, no - surprise when a new project directory silently starts getting picked - up) over auto-discovery. -- **Does `watch` ever get used**, or does the dumb-timer version turn - out to be good enough forever? Deliberately left unbuilt (see "Where - announce/watch actually earn their keep") until there's a real signal - it's worth the added complexity. +- **Fleet-wide `pull_policy: always` sweep** — a prerequisite this + proposal depends on but doesn't itself fix (see Context). Should + happen before or alongside this work, as its own small pass, not + bundled into this one. +- **Does `watch`/`watch-any` ever get used**, or does the + already-scheduled bulk sweep turn out to be good enough forever? + Deliberately left unbuilt (see §5) until there's a real signal the + wait between scheduled runs is worth solving. - **Multi-VPS fan-out for one project** — if a project ever runs on more than one box, `announce` already supports that for free (any number of - agents can poll the same vault's announce endpoint), but this hasn't + watchers can poll the same vault's announce endpoint), but this hasn't been exercised and might reveal something not accounted for here.