From 2d668ddc63bdf4cf170d4a6d2d41795157b4665c Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 4 Aug 2026 19:48:16 +0200 Subject: [PATCH] docs: fix multi-server fanout review findings Documentation fixes from the final branch review, plus small curl/jq hardening in the gitea-action script: - tag was documented as cosmetic ("for logging") but is load-bearing in swarm mode: handlers.go folds it into the requested image, compose discovery strips it via NormaliseImage, but SwarmExecutor assigns it directly to ContainerSpec.Image. Omitting it deploys :latest, silently diverging from what CI just built. Fixed in action.yml, gitea-action's README, and added to CLAUDE.md's Gotchas since it's invisible from either mode's code alone. - gitea-action/README.md's opening line and root README.md's intro/trigger flow described compose-only behavior even though both docs' bodies now cover swarm mode too. - README.md's defense-in-depth section described a two-factor gate; compose mode is actually three factors (token, label, STACKS_ROOT prefix), and swarm mode is genuinely two (no local compose file to path-check against). - action.yml: curl now has --connect-timeout 10 --max-time 900 so a host that accepts TCP but never answers can't block the fan-out loop forever; the jq payload build now fails loudly instead of silently sending an empty payload to every endpoint. - CLAUDE.md References section now lists this branch's spec and plan. Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybut --- CLAUDE.md | 6 ++++++ README.md | 13 +++++++++---- gitea-action/README.md | 8 ++++++-- gitea-action/action.yml | 5 +++-- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 586fba1..1d518e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,12 +78,18 @@ curl -sH "Authorization: Bearer $UPDATER_API_KEY" \ with a permission error against a worker-only node. This is an operator/deployment concern (point `DOCKER_HOST` at a manager, or schedule the updater on a manager), not something the code can detect or work around. +- **`tag` is cosmetic in compose mode but load-bearing in swarm mode.** `handlers.go` folds it + into the requested image; compose discovery strips it via `NormaliseImage`, but + `SwarmExecutor` assigns it directly to `ContainerSpec.Image`. A request without a tag updates + a Swarm service to `:latest`. ## References - Design spec: `docs/superpowers/specs/2026-05-22-package-updater-design.md` (489 lines, authoritative) - Implementation plan: `docs/superpowers/plans/2026-05-22-package-updater-implementation.md` - Consumer-side CI integration: `gitea-action/` +- Multi-server topology spec: `docs/superpowers/specs/2026-08-04-multi-server-fanout-design.md` +- Multi-server fan-out plan: `docs/superpowers/plans/2026-08-04-multi-server-fanout.md` ## Conventions diff --git a/README.md b/README.md index 60f0ffe..0298903 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # package-updater -Webhook-driven Docker Compose service updater. Fills the gap between Watchtower (polling, no CI integration) and full GitOps (Argo CD, Flux) for a self-hosted, single-host environment. +Webhook-driven Docker service updater — Compose stacks or Swarm services. Fills the gap between Watchtower (polling, no CI integration) and full GitOps (Argo CD, Flux) for self-hosted environments, one instance per server. **Trigger flow:** 1. Gitea workflow builds and pushes a new image to your registry. 2. Workflow calls `POST /update` on this service with the image name. -3. Service finds the matching Compose-managed container(s) on the host via Docker labels. -4. Runs `docker compose pull` + `up -d` for the relevant service(s). +3. Service finds the matching container(s) or Swarm service(s) via Docker labels. +4. Runs `docker compose pull` + `up -d`, or `docker service update`, depending on `MODE`. See [design spec](docs/superpowers/specs/2026-05-22-package-updater-design.md) and [implementation plan](docs/superpowers/plans/2026-05-22-package-updater-implementation.md) for full design and rationale. @@ -25,7 +25,12 @@ A container is eligible for update only if it has **both**: - An image name matching the request (tag-agnostic), AND - The opt-in label `se.shcizo.auto-update=true`. -Defense in depth: a valid bearer token AND the opt-in label must both be present before any container is touched. +Defense in depth, compose mode: a valid bearer token AND the opt-in label AND a working +directory inside `STACKS_ROOT` must all hold before a container is touched. A stack outside +`STACKS_ROOT` comes back as `refused` rather than being updated. + +Swarm mode's gate is the token and the opt-in label only — a Swarm service has no local +compose file to anchor a path check against. ## Swarm mode diff --git a/gitea-action/README.md b/gitea-action/README.md index e9e75aa..7fb33d4 100644 --- a/gitea-action/README.md +++ b/gitea-action/README.md @@ -1,6 +1,6 @@ # Deploy via package-updater (composite action) -Notifies `package-updater` to `docker compose pull` + `up -d` for the matching service(s) after a CI build. +Notifies one or more `package-updater` instances to update the matching service(s) after a CI build. Each instance does whatever its own `MODE` dictates — `docker compose pull` + `up -d`, or `docker service update` for swarm. ## Usage @@ -34,7 +34,7 @@ input is sent to every endpoint. |---|---|---|---| | `endpoint` | yes | — | Full URL to `/update`. Several may be given, one per line, to update a fleet. | | `image` | yes | — | Image reference without tag | -| `tag` | no | `""` | Tag that was just pushed (logged for audit) | +| `tag` | no | `""` | Tag that was just pushed. Compose mode ignores it (the compose file pins the reference); **swarm mode sets the service image to it**, so omitting it deploys `:latest`. Always pass it. | | `token` | yes | — | Bearer token configured in package-updater | ## Failure modes @@ -46,3 +46,7 @@ which hosts actually succeeded. The step exits non-zero if any endpoint returned 4xx/5xx or was unreachable. The log lists each endpoint with its HTTP status and response body, so a partial deploy is visible at a glance. + +Endpoints are contacted sequentially, so worst-case wall time is the number of endpoints times +how long one update takes. Each request allows 10s to connect and 15 minutes to complete — +`/update` is synchronous and waits for the deploy to finish. diff --git a/gitea-action/action.yml b/gitea-action/action.yml index 0ec1826..fa8cd3a 100644 --- a/gitea-action/action.yml +++ b/gitea-action/action.yml @@ -8,7 +8,7 @@ inputs: description: "Image reference without tag (e.g. registry.example.com/myapp)" required: true tag: - description: "Tag that was just pushed (for logging)" + description: "Tag that was just pushed. Required in practice for swarm instances — it becomes the image the service is set to. Omit it and swarm deploys :latest." required: false default: "" token: @@ -31,7 +31,7 @@ runs: set -uo pipefail payload=$(jq -nc --arg image "$IMAGE" --arg tag "$TAG" \ - '{image: $image, tag: $tag}') + '{image: $image, tag: $tag}') || { echo "jq is required but failed"; exit 1; } attempted=0 failed=0 @@ -46,6 +46,7 @@ runs: echo "--- $endpoint" if ! response=$(curl -sS -w "\n%{http_code}" \ + --connect-timeout 10 --max-time 900 \ -X POST "$endpoint" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \