From fcd590899205ebe5bd21e51bfbf86911ae10c911 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 4 Aug 2026 18:47:00 +0200 Subject: [PATCH 1/6] docs: design for multi-server deployment and CI fan-out One instance per server, each owning its local Docker daemon; Gitea CI posts /update to every instance. Records why MODE exclusivity is correct rather than a limitation, and the alternatives rejected. Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybut --- .../2026-08-04-multi-server-fanout-design.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-multi-server-fanout-design.md diff --git a/docs/superpowers/specs/2026-08-04-multi-server-fanout-design.md b/docs/superpowers/specs/2026-08-04-multi-server-fanout-design.md new file mode 100644 index 0000000..6e8ce41 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-multi-server-fanout-design.md @@ -0,0 +1,111 @@ +# Multi-Server Deployment & CI Fan-Out — Design + +**Date:** 2026-08-04 +**Status:** Approved, ready for implementation planning + +## Problem + +package-updater was built as a single instance talking to a single local Docker +socket. Two things surfaced at once: + +1. Swarm support (branch `worktree-docker-swarm-support`) added `MODE=compose|swarm`, + selecting one paradigm per deployment. This looked like an artificial limitation: + "why can't one instance do both?" +2. The real constraint is narrower and harder: an instance can only reach the Docker + daemon it is configured against. Compose stacks live on other, separate servers. + No amount of generalising the application makes one instance reach them. + +The second point dissolves the first. The limitation was never in the application — +it was in the deployment topology (one instance for a fleet of servers). + +## Decision + +**One package-updater instance per server. CI fans out to all of them.** + +- Each server runs its own instance against its own local Docker socket. +- The Swarm manager node runs an instance with `MODE=swarm`. +- Each standalone Compose host runs an instance with `MODE=compose`. +- The Gitea CI action posts `/update` to every instance. + +`MODE` exclusivity is therefore correct, not a defect: each instance owns exactly one +machine running exactly one paradigm. No composite Finder or routing Executor is built. + +### Rejected alternatives + +| Alternative | Why rejected | +|---|---| +| Composite Finder + routing Executor (both modes in one instance) | Solves nothing — a single instance still cannot reach other servers' daemons. Only justified if a Swarm manager node *also* ran standalone Compose stacks locally, which it does not. | +| One instance holding N Docker clients (`ssh://` / TCP+TLS to each host) | Technically viable (`client.FromEnv` already supports it) but requires `Job` to carry host identity, discovery to iterate clients, and creates a single point of failure with broad network credentials. Buys nothing over per-server instances given CI can reach every server. | +| Controller + agents (pull model) | Only needed if servers cannot accept inbound traffic. They can. Would be a genuinely different application. | + +### Assumptions + +These held at design time; revisit the decision if any changes: + +- Every server is reachable from Gitea CI over HTTP. +- Per-server outcomes are sufficient; no aggregated cross-server report is needed. +- The Swarm manager node runs Swarm services only, no local Compose stacks. +- All instances share one bearer token (org-level Gitea secret `UPDATER_TOKEN`). + +## Scope + +### Part 1 — Merge Swarm support to main + +Branch `worktree-docker-swarm-support` (10 commits) is complete, builds clean, and +its tests pass. No code changes required. Documentation is reframed so `MODE` +describes *what paradigm this machine runs*, not a product-level restriction, and the +one-instance-per-server deployment model is written down — it is the expensive insight +of this design and is not derivable from the code. + +Documentation changes: + +- `README.md` — describe `MODE` per-instance; add a deployment-topology section + covering one instance per server and CI fan-out. +- `CLAUDE.md` — reword the "Compose mode and Swarm mode are selected once per + deployment" design-intent entry to state the reason (each instance owns one daemon), + so a future reader does not re-litigate "make it handle both". + +### Part 2 — CI fan-out in `gitea-action/` + +`gitea-action/action.yml`'s `endpoint` input accepts a **newline-separated list** of +`/update` URLs instead of a single URL. + +Behaviour: + +- Post to each endpoint in list order. +- **Continue through the whole list even when one fails.** With `set -e` and a naive + loop, an unreachable server B leaves server C never updated — a half-deployed fleet + where the failure also hides which hosts succeeded. +- Print HTTP status and response body per endpoint, so the CI log shows the fleet + state at a glance. +- Exit non-zero at the end if any endpoint returned 4xx/5xx or was unreachable. + +A single URL remains a one-element list and keeps working unchanged. This is a +property of the format, not backward-compatibility work. + +Newline rather than comma as separator: URLs may legally contain commas but never +line breaks, so the separator cannot collide with the data. Choosing an impossible +delimiter is cheaper than building escaping. This also matches the Actions convention +for list inputs (`paths`, `files`). + +Blank lines and surrounding whitespace are ignored, so YAML block scalars with +trailing newlines and indentation behave as expected. + +`README.md` in `gitea-action/` documents the list form with a worked multi-server +example. + +## Out of scope + +- Composite/routing discovery within one instance — not needed under this topology. +- Aggregated cross-server reporting — per-endpoint CI output is sufficient. +- Per-endpoint distinct tokens — all instances share one token. +- Wiring `internal/selfupdate` into the live queue — a pre-existing v1 gap, unrelated. + +## Testing + +- Part 1: existing suite (`go test ./...`) must stay green after merge. No new tests — + no new code. +- Part 2: the action is shell in composite YAML with no test harness in this repo. + Verification is manual: run the loop logic against a mix of reachable and + unreachable endpoints and confirm (a) every endpoint is attempted, (b) the step + exits non-zero, (c) each endpoint's status appears in the output. -- 2.52.0 From 2421c74df1d6f4436c60494df437da153d296303 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 4 Aug 2026 19:19:49 +0200 Subject: [PATCH 2/6] docs: implementation plan for multi-server fan-out Three tasks: merge swarm branch to main, reframe deployment docs, and make the Gitea action post to every endpoint in a fleet. Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybut --- .../plans/2026-08-04-multi-server-fanout.md | 507 ++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-multi-server-fanout.md diff --git a/docs/superpowers/plans/2026-08-04-multi-server-fanout.md b/docs/superpowers/plans/2026-08-04-multi-server-fanout.md new file mode 100644 index 0000000..ab4c04f --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-multi-server-fanout.md @@ -0,0 +1,507 @@ +# Multi-Server Deployment & CI Fan-Out Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Land the finished Swarm support on `main`, document the one-instance-per-server deployment model, and make the Gitea composite action post `/update` to every server in a fleet instead of a single endpoint. + +**Architecture:** No Go code changes. Part 1 is a merge plus documentation reframing — `MODE=compose|swarm` stays exclusive per instance, because each instance owns exactly one Docker daemon. Part 2 changes `gitea-action/action.yml` so its `endpoint` input accepts a newline-separated list, looping over every URL and continuing past failures so one dead host cannot stop the rest of the fleet from updating. + +**Tech Stack:** Go 1.26.3 (unchanged, no code edits), Bash inside a Gitea composite action, `curl`, `jq`. + +## Global Constraints + +- **No Go source changes in this plan.** If a task seems to need one, stop and re-read the spec — the design explicitly rejects composite Finder/Executor work. +- `MODE` stays `compose` or `swarm` only. Do not add a `both` value. +- Do not weaken Compose mode's three-factor gate (token AND opt-in label AND `STACKS_ROOT` prefix). +- Existing single-URL usages of the action must keep working unchanged — a single URL is a one-element list. Do not add a separate input for the list form. +- Separator for the endpoint list is **newline**, never comma. URLs may legally contain commas. +- Conventional Commits (`feat:`, `fix:`, `docs:`, `ci:`, `build:`, `chore:`). +- Full test suite (`go test ./...`) must be green at the end of every task that touches the repo. + +--- + +### Task 1: Merge Swarm support to `main` + +**Files:** +- No files edited. This task merges branch `worktree-docker-swarm-support` (10 commits) into `main`. + +**Interfaces:** +- Consumes: nothing. +- Produces: `main` gains `internal/discovery/swarm.go`, `internal/updater/swarm_executor.go`, `config.Config.Mode`, `discovery.Job.Image`, `discovery.Job.ServiceID`, and `api.ResultDTO.ServiceID`. Task 2 edits documentation files that this merge brings in (`README.md` "Swarm mode" section, `CLAUDE.md` swarm entries). + +- [ ] **Step 1: Confirm the branch is where you think it is** + +Run: +```bash +cd /Users/samuelenocsson/dev/package-updater +git log --oneline main..worktree-docker-swarm-support +``` + +Expected: exactly 10 commits, oldest `8f999c6 docs: add docker swarm support implementation plan`, newest `db84a2f fix(api): plumb request tag into discovery so swarm mode deploys the requested version`. + +If the list differs, stop and report — someone has moved the branch since this plan was written. + +- [ ] **Step 2: Verify the branch is green before merging** + +Run: +```bash +cd /Users/samuelenocsson/dev/package-updater/.claude/worktrees/docker-swarm-support +go build ./... && go test ./... +``` + +Expected: build succeeds, all packages PASS. Do not proceed on a red branch — fix or report first. + +- [ ] **Step 3: Merge into `main`** + +```bash +cd /Users/samuelenocsson/dev/package-updater +git checkout main +git merge --no-ff worktree-docker-swarm-support -m "$(cat <<'EOF' +Merge branch 'worktree-docker-swarm-support' + +Adds MODE=compose|swarm. Each instance owns one Docker daemon and runs +one paradigm; see docs/superpowers/specs/2026-08-04-multi-server-fanout-design.md +for why this exclusivity is the intended design. +EOF +)" +``` + +`--no-ff` is deliberate: it keeps the ten swarm commits grouped under one merge commit, matching how PRs #1 and #2 already appear in this repo's history. + +Note: `docs/superpowers/plans/2026-07-04-docker-swarm-support.md` currently exists as an *untracked* file on `main` and is *committed* on the swarm branch. The merge brings in the tracked copy. If git refuses the merge with "untracked working tree file would be overwritten", delete the untracked copy first (`rm docs/superpowers/plans/2026-07-04-docker-swarm-support.md`) — the branch's committed version is identical and authoritative. + +- [ ] **Step 4: Verify `main` is green after the merge** + +Run: +```bash +go build ./... && go test ./... +``` + +Expected: build succeeds, all packages PASS. This is the first time the swarm code and the post-swarm `main` commits (`CLAUDE.md`, CI workflow) are compiled together on `main`. + +- [ ] **Step 5: Push** + +```bash +git push origin main +``` + +--- + +### Task 2: Document the deployment topology + +**Files:** +- Modify: `README.md` (the version merged in Task 1 — it already contains a "Swarm mode" section at lines ~30-47, a `MODE` row in the config table at line ~71, and a "Known v1 gaps" bullet about single-host at lines ~103-105) +- Modify: `CLAUDE.md` (the "Design intent" section, which after Task 1 contains a "Compose mode and Swarm mode are selected once per deployment via `MODE`" entry) + +**Interfaces:** +- Consumes: the merged documentation from Task 1. +- Produces: nothing consumed by later tasks. Task 3 documents the action separately in `gitea-action/README.md`. + +There is no test for documentation. Verification is reading it back and confirming the claims match the code in `cmd/server/main.go` and `internal/config/config.go`. + +- [ ] **Step 1: Rebase the working branch onto the updated `main`** + +```bash +cd /Users/samuelenocsson/dev/package-updater +git checkout feat/multi-server-fanout +git rebase main +``` + +Expected: clean rebase. The branch currently holds one commit (`docs: design for multi-server deployment and CI fan-out`) touching only a new spec file, so there is nothing to conflict with. + +- [ ] **Step 2: Add a deployment-topology section to `README.md`** + +Insert this section immediately **after** the "Swarm mode" section and **before** "## Quick start": + +```markdown +## Deploying across multiple servers + +An instance can only reach the Docker daemon it is configured against. It cannot +update stacks on other machines. The deployment model follows from that: + +**One instance per server.** Each server runs its own package-updater against its +own local Docker socket, with `MODE` set to whatever that machine runs: + +| Server | `MODE` | Updates | +|---|---|---| +| Swarm manager node | `swarm` | All Swarm services in the cluster | +| Standalone Compose host | `compose` | Compose stacks on that host | + +**CI fans out.** The [Gitea composite action](gitea-action/README.md) takes a list of +endpoints and posts `/update` to every instance, so one workflow run reaches the +whole fleet. Each instance answers for its own machine; there is no aggregated +cross-server response and no instance coordinates any other. + +This is why `MODE` is exclusive rather than a mode that handles both at once: an +instance that could do both would still only reach one daemon, so the extra +generality buys nothing. +``` + +- [ ] **Step 3: Reword the `MODE` row in the configuration table** + +In `README.md`'s configuration table, replace this row: + +```markdown +| `MODE` | no | `compose` | `compose` or `swarm`. Selects the update mechanism for the whole deployment; not mixed per-request. | +``` + +with: + +```markdown +| `MODE` | no | `compose` | `compose` or `swarm`. Which paradigm *this instance's* Docker daemon runs. See [Deploying across multiple servers](#deploying-across-multiple-servers). | +``` + +- [ ] **Step 4: Replace the "single host only" gap bullet** + +In `README.md`'s "Known v1 gaps" section, replace this bullet: + +```markdown +- **Single host only in Compose mode**. Swarm mode (`MODE=swarm`) is the + multi-node path, but only from a manager node's point of view — the updater + itself still needs manager API access (see "Swarm mode" above). +``` + +with: + +```markdown +- **One instance reaches one daemon.** An instance never updates another server; + fleets run one instance per server with CI fanning out to all of them (see + "Deploying across multiple servers"). Swarm mode is the exception in that a + single manager-node instance covers the whole cluster. +``` + +- [ ] **Step 5: Reword the design-intent entry in `CLAUDE.md`** + +In `CLAUDE.md`'s "Design intent (do not break without discussion)" section, replace this entry: + +```markdown +- **Compose mode and Swarm mode are selected once per deployment via `MODE`**, never + mixed at request time. Swarm mode's security gate is opt-in label only — there is + no STACKS_ROOT-equivalent path check, since Swarm services have no local compose + file. Don't add one; don't weaken Compose mode's three-factor gate to match. +``` + +with: + +```markdown +- **`MODE` is exclusive because each instance owns exactly one Docker daemon.** + A composite "handle both at once" mode has been considered and rejected: it would + still only reach one daemon, so it buys nothing. Fleets run one instance per + server and CI fans out. Reopen this only if a Swarm manager node starts running + standalone Compose stacks locally. Rationale and rejected alternatives: + `docs/superpowers/specs/2026-08-04-multi-server-fanout-design.md`. +- **Swarm mode's security gate is opt-in label only** — there is no + STACKS_ROOT-equivalent path check, since Swarm services have no local compose + file. Don't add one; don't weaken Compose mode's three-factor gate to match. +``` + +Splitting the original entry in two is deliberate: the mode-exclusivity rationale and the Swarm security-gate rule are separate rules that were sharing a bullet, and only the first one is changing. + +- [ ] **Step 6: Verify the anchor link resolves** + +The `MODE` table row links to `#deploying-across-multiple-servers`. Confirm the heading added in Step 2 is exactly `## Deploying across multiple servers` — GitHub/Gitea derive the anchor by lowercasing and replacing spaces with hyphens, so any wording drift silently breaks the link. + +Run: `grep -n "^## Deploying across multiple servers" README.md` +Expected: one match. + +- [ ] **Step 7: Commit** + +```bash +git add README.md CLAUDE.md +git commit -m "docs: describe one-instance-per-server topology and why MODE is exclusive" +``` + +--- + +### Task 3: Fan out to multiple endpoints in the Gitea action + +**Files:** +- Modify: `gitea-action/action.yml` (whole `runs.steps` block, lines 17-37, and the `endpoint` input description at lines 5-6) +- Modify: `gitea-action/README.md` (usage example, inputs table, failure-modes section) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: the action's `endpoint` input accepts one URL (unchanged behaviour) or several separated by newlines. No other input changes name, type, or meaning. + +- [ ] **Step 1: Rewrite `gitea-action/action.yml`** + +Replace the entire file with: + +```yaml +name: "Deploy via package-updater" +description: "Notifies one or more package-updater instances to pull & restart a service" +inputs: + endpoint: + description: "Full URL to /update. Give several, one per line, to update a fleet." + required: true + image: + description: "Image reference without tag (e.g. registry.example.com/myapp)" + required: true + tag: + description: "Tag that was just pushed (for logging)" + required: false + default: "" + token: + description: "Bearer token for package-updater" + required: true +runs: + using: "composite" + steps: + - name: Trigger update + shell: bash + env: + ENDPOINTS: ${{ inputs.endpoint }} + IMAGE: ${{ inputs.image }} + TAG: ${{ inputs.tag }} + TOKEN: ${{ inputs.token }} + run: | + # No `set -e`: a failing endpoint must not abort the loop, or one dead + # server leaves the rest of the fleet un-updated and hides which hosts + # actually succeeded. + set -uo pipefail + + payload=$(jq -nc --arg image "$IMAGE" --arg tag "$TAG" \ + '{image: $image, tag: $tag}') + + attempted=0 + failed=0 + + while IFS= read -r endpoint; do + # URLs never contain whitespace, so stripping all of it safely + # handles indentation, blank lines and CRLF line endings. + endpoint=$(printf '%s' "$endpoint" | tr -d '[:space:]') + [ -z "$endpoint" ] && continue + + attempted=$((attempted + 1)) + echo "--- $endpoint" + + if ! response=$(curl -sS -w "\n%{http_code}" \ + -X POST "$endpoint" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$payload"); then + echo "unreachable" + failed=$((failed + 1)) + continue + fi + + body=$(printf '%s' "$response" | head -n -1) + code=$(printf '%s' "$response" | tail -n 1) + echo "HTTP $code" + printf '%s' "$body" | jq . || printf '%s\n' "$body" + + if [ "$code" -ge 400 ]; then + failed=$((failed + 1)) + fi + done <<< "$ENDPOINTS" + + if [ "$attempted" -eq 0 ]; then + echo "no endpoints given" + exit 1 + fi + if [ "$failed" -gt 0 ]; then + echo "$failed of $attempted endpoint(s) failed" + exit 1 + fi + echo "all $attempted endpoint(s) updated" +``` + +Three changes beyond the loop itself, each load-bearing: + +1. **All inputs moved into `env:`.** A multi-line `${{ inputs.endpoint }}` interpolated directly into the script body would break it syntactically. Moving the others too keeps one consistent style in a short script, and stops a value containing shell metacharacters from being executed. +2. **`jq -nc` builds the payload** instead of hand-interpolating into a JSON string. A tag containing a quote previously produced malformed JSON and a confusing 400. +3. **`set -e` dropped**, `-uo pipefail` kept. This is the whole point of the task — see the comment in the script. + +- [ ] **Step 2: Verify the loop continues past a failure** + +This is the behaviour that justifies the task, so test it directly rather than trusting the code by inspection. + +Start a server that answers POST with 200: + +```bash +python3 -c " +import http.server +class H(http.server.BaseHTTPRequestHandler): + def do_POST(self): + self.send_response(200) + self.send_header('Content-Type','application/json') + self.end_headers() + self.wfile.write(b'{\"matched\":1,\"results\":[]}') + def log_message(self, *a): pass +http.server.HTTPServer(('127.0.0.1',8099), H).serve_forever() +" & +echo $! > /tmp/fanout-test-server.pid +``` + +Write the script body to a runnable file. This must be the **exact** text of the +`run:` block from Step 1, dedented — copy it, do not retype it, or you are testing +something other than what ships: + +```bash +cat > /tmp/fanout-test.sh <<'SCRIPT' +set -uo pipefail + +payload=$(jq -nc --arg image "$IMAGE" --arg tag "$TAG" \ + '{image: $image, tag: $tag}') + +attempted=0 +failed=0 + +while IFS= read -r endpoint; do + endpoint=$(printf '%s' "$endpoint" | tr -d '[:space:]') + [ -z "$endpoint" ] && continue + + attempted=$((attempted + 1)) + echo "--- $endpoint" + + if ! response=$(curl -sS -w "\n%{http_code}" \ + -X POST "$endpoint" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$payload"); then + echo "unreachable" + failed=$((failed + 1)) + continue + fi + + body=$(printf '%s' "$response" | head -n -1) + code=$(printf '%s' "$response" | tail -n 1) + echo "HTTP $code" + printf '%s' "$body" | jq . || printf '%s\n' "$body" + + if [ "$code" -ge 400 ]; then + failed=$((failed + 1)) + fi +done <<< "$ENDPOINTS" + +if [ "$attempted" -eq 0 ]; then + echo "no endpoints given" + exit 1 +fi +if [ "$failed" -gt 0 ]; then + echo "$failed of $attempted endpoint(s) failed" + exit 1 +fi +echo "all $attempted endpoint(s) updated" +SCRIPT +``` + +Now execute it with a list where the middle endpoint is dead (port 1 always refuses connections): + +```bash +ENDPOINTS="http://127.0.0.1:8099/update +http://127.0.0.1:1/update +http://127.0.0.1:8099/update" \ +IMAGE="registry.example.com/myapp" \ +TAG="abc123" \ +TOKEN="dummy" \ +bash /tmp/fanout-test.sh +echo "exit code: $?" +``` + +Expected output shape: +``` +--- http://127.0.0.1:8099/update +HTTP 200 +{ "matched": 1, "results": [] } +--- http://127.0.0.1:1/update +unreachable +--- http://127.0.0.1:8099/update +HTTP 200 +{ "matched": 1, "results": [] } +1 of 3 endpoint(s) failed +exit code: 1 +``` + +Three things must all hold: the **third** endpoint was attempted (proves the loop continued), the exit code is **1**, and each endpoint's outcome is visible. If the third `---` line is missing, `set -e` crept back in. + +- [ ] **Step 3: Verify a single endpoint still works unchanged** + +```bash +ENDPOINTS="http://127.0.0.1:8099/update" \ +IMAGE="registry.example.com/myapp" TAG="abc123" TOKEN="dummy" \ +bash /tmp/fanout-test.sh +echo "exit code: $?" +``` + +Expected: one `--- ` line, `HTTP 200`, `all 1 endpoint(s) updated`, exit code 0. This is the existing-usage regression check. + +- [ ] **Step 4: Verify blank lines and indentation are tolerated** + +YAML block scalars routinely produce leading indentation and a trailing newline, so this is the realistic input shape, not an edge case. + +```bash +ENDPOINTS=" http://127.0.0.1:8099/update + + http://127.0.0.1:8099/update +" \ +IMAGE="registry.example.com/myapp" TAG="abc123" TOKEN="dummy" \ +bash /tmp/fanout-test.sh +echo "exit code: $?" +``` + +Expected: exactly two `--- http://127.0.0.1:8099/update` lines with no stray whitespace in the URL, `all 2 endpoint(s) updated`, exit code 0. + +- [ ] **Step 5: Clean up the test server** + +```bash +kill "$(cat /tmp/fanout-test-server.pid)" && rm -f /tmp/fanout-test-server.pid /tmp/fanout-test.sh +``` + +- [ ] **Step 6: Update `gitea-action/README.md`** + +Replace the usage example's `with:` block so it shows the fleet form: + +```yaml + - uses: gitea.example.com/shcizo/package-updater/gitea-action@v1 + with: + endpoint: | + https://updater-swarm.example.com/update + https://updater-web01.example.com/update + https://updater-web02.example.com/update + image: registry.example.com/${{ gitea.repository }} + tag: ${{ gitea.sha }} + token: ${{ secrets.UPDATER_TOKEN }} +``` + +Change the `endpoint` row in the inputs table to: + +```markdown +| `endpoint` | yes | — | Full URL to `/update`. Several may be given, one per line, to update a fleet. | +``` + +Replace the "Failure modes" section with: + +```markdown +## Failure modes + +Every endpoint is attempted, even when an earlier one fails — otherwise one dead +server would leave the rest of the fleet un-updated, and the CI log would not show +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. +``` + +Add this note after the `UPDATER_TOKEN` sentence: + +```markdown +All instances in the fleet must share the same bearer token, since one `token` +input is sent to every endpoint. +``` + +- [ ] **Step 7: Commit** + +```bash +git add gitea-action/action.yml gitea-action/README.md +git commit -m "feat(action): post /update to every endpoint in a fleet, continuing past failures" +``` + +- [ ] **Step 8: Push and open a PR** + +```bash +git push -u origin feat/multi-server-fanout +``` + +Then open a PR against `main` (see `issue-tracker-cli` skill for whether this repo uses `gh` or `tea`). The PR covers the spec, the documentation reframing, and the action fan-out; the Swarm merge landed separately in Task 1. -- 2.52.0 From 81bd01c5d6834d4abe66f6ac169a37dbc9b18c66 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 4 Aug 2026 19:30:58 +0200 Subject: [PATCH 3/6] docs: describe one-instance-per-server topology and why MODE is exclusive --- CLAUDE.md | 11 ++++++++--- README.md | 31 +++++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b0153b..586fba1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,9 +47,14 @@ curl -sH "Authorization: Bearer $UPDATER_API_KEY" \ Tests rely on this — don't drop the nil-check. - **Stateless**: no DB, no config file, no on-disk audit log. Docker daemon is the source of truth. -- **Compose mode and Swarm mode are selected once per deployment via `MODE`**, never - mixed at request time. Swarm mode's security gate is opt-in label only — there is - no STACKS_ROOT-equivalent path check, since Swarm services have no local compose +- **`MODE` is exclusive because each instance owns exactly one Docker daemon.** + A composite "handle both at once" mode has been considered and rejected: it would + still only reach one daemon, so it buys nothing. Fleets run one instance per + server and CI fans out. Reopen this only if a Swarm manager node starts running + standalone Compose stacks locally. Rationale and rejected alternatives: + `docs/superpowers/specs/2026-08-04-multi-server-fanout-design.md`. +- **Swarm mode's security gate is opt-in label only** — there is no + STACKS_ROOT-equivalent path check, since Swarm services have no local compose file. Don't add one; don't weaken Compose mode's three-factor gate to match. ## Gotchas diff --git a/README.md b/README.md index 8a41057..60f0ffe 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,28 @@ Two things to get right when running in Swarm mode: mounted. This is a deployment concern the service cannot detect or work around. +## Deploying across multiple servers + +An instance can only reach the Docker daemon it is configured against. It cannot +update stacks on other machines. The deployment model follows from that: + +**One instance per server.** Each server runs its own package-updater against its +own local Docker socket, with `MODE` set to whatever that machine runs: + +| Server | `MODE` | Updates | +|---|---|---| +| Swarm manager node | `swarm` | All Swarm services in the cluster | +| Standalone Compose host | `compose` | Compose stacks on that host | + +**CI fans out.** The [Gitea composite action](gitea-action/README.md) takes a list of +endpoints and posts `/update` to every instance, so one workflow run reaches the +whole fleet. Each instance answers for its own machine; there is no aggregated +cross-server response and no instance coordinates any other. + +This is why `MODE` is exclusive rather than a mode that handles both at once: an +instance that could do both would still only reach one daemon, so the extra +generality buys nothing. + ## Quick start 1. Build and push the image (e.g. via your own CI). @@ -68,7 +90,7 @@ All via environment variables. | `LOG_LEVEL` | no | `info` | `debug` / `info` / `warn` / `error`. | | `UPDATE_TIMEOUT` | no | `5m` | Per-job timeout (Go duration). | | `OPT_IN_LABEL` | no | `se.shcizo.auto-update` | Label name to check; value must equal `"true"`. | -| `MODE` | no | `compose` | `compose` or `swarm`. Selects the update mechanism for the whole deployment; not mixed per-request. | +| `MODE` | no | `compose` | `compose` or `swarm`. Which paradigm *this instance's* Docker daemon runs. See [Deploying across multiple servers](#deploying-across-multiple-servers). | ## Endpoints @@ -100,7 +122,8 @@ These are tracked in the spec's section 2 and section 15 as deliberate out-of-sc - **Self-update wiring**: `internal/selfupdate.Wrapped` exists and is unit-tested but is not wired into the live queue. The HTTP response flush ordering for self-replacement is a future enhancement; for now, expect to manually rerun `docker compose up -d` on the host if pushing a new image of `package-updater` itself causes a mid-response interruption. - **No rollback**: Compose's "keep old container if new fails to start" is the only safety net. -- **Single host only in Compose mode**. Swarm mode (`MODE=swarm`) is the - multi-node path, but only from a manager node's point of view — the updater - itself still needs manager API access (see "Swarm mode" above). +- **One instance reaches one daemon.** An instance never updates another server; + fleets run one instance per server with CI fanning out to all of them (see + "Deploying across multiple servers"). Swarm mode is the exception in that a + single manager-node instance covers the whole cluster. - **No per-repo API keys**: a single shared bearer token is used. -- 2.52.0 From bca2e252fbe46caa709e42758b581e4dbcba1d53 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 4 Aug 2026 19:31:55 +0200 Subject: [PATCH 4/6] docs: replace GNU-only head -n -1 in fan-out plan with bash expansion head -n -1 fails on BSD/macOS, making the plan's local verification steps unrunnable. Bash parameter expansion is portable and drops two subprocesses per endpoint. Claude-Session: https://claude.ai/code/session_01S3aqJ4tvaPezQhsGNCybut --- .../plans/2026-08-04-multi-server-fanout.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-08-04-multi-server-fanout.md b/docs/superpowers/plans/2026-08-04-multi-server-fanout.md index ab4c04f..a47ab57 100644 --- a/docs/superpowers/plans/2026-08-04-multi-server-fanout.md +++ b/docs/superpowers/plans/2026-08-04-multi-server-fanout.md @@ -285,8 +285,11 @@ runs: continue fi - body=$(printf '%s' "$response" | head -n -1) - code=$(printf '%s' "$response" | tail -n 1) + # Split on the last newline: curl's -w appended the status code + # there. Pure bash — `head -n -1` is GNU-only and fails on + # BSD/macOS, so the fan-out could not be tested locally. + code="${response##*$'\n'}" + body="${response%$'\n'*}" echo "HTTP $code" printf '%s' "$body" | jq . || printf '%s\n' "$body" @@ -306,11 +309,12 @@ runs: echo "all $attempted endpoint(s) updated" ``` -Three changes beyond the loop itself, each load-bearing: +Four changes beyond the loop itself, each load-bearing: 1. **All inputs moved into `env:`.** A multi-line `${{ inputs.endpoint }}` interpolated directly into the script body would break it syntactically. Moving the others too keeps one consistent style in a short script, and stops a value containing shell metacharacters from being executed. 2. **`jq -nc` builds the payload** instead of hand-interpolating into a JSON string. A tag containing a quote previously produced malformed JSON and a confusing 400. 3. **`set -e` dropped**, `-uo pipefail` kept. This is the whole point of the task — see the comment in the script. +4. **`head -n -1` replaced by bash parameter expansion.** The existing action used `head -n -1`, which is GNU-only — it fails on BSD/macOS with `illegal line count`. That made the fan-out logic impossible to test on a developer machine, so the portability fix is what makes Steps 2-4 runnable at all. It also drops two subprocesses per endpoint. - [ ] **Step 2: Verify the loop continues past a failure** @@ -364,8 +368,8 @@ while IFS= read -r endpoint; do continue fi - body=$(printf '%s' "$response" | head -n -1) - code=$(printf '%s' "$response" | tail -n 1) + code="${response##*$'\n'}" + body="${response%$'\n'*}" echo "HTTP $code" printf '%s' "$body" | jq . || printf '%s\n' "$body" -- 2.52.0 From 07b95f5feb20a353aa5a4deddd9a3c8accf8b53e Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 4 Aug 2026 19:36:20 +0200 Subject: [PATCH 5/6] feat(action): post /update to every endpoint in a fleet, continuing past failures --- gitea-action/README.md | 18 +++++++++-- gitea-action/action.yml | 68 +++++++++++++++++++++++++++++++++-------- 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/gitea-action/README.md b/gitea-action/README.md index bc0816a..e9e75aa 100644 --- a/gitea-action/README.md +++ b/gitea-action/README.md @@ -14,7 +14,10 @@ jobs: steps: - uses: gitea.example.com/shcizo/package-updater/gitea-action@v1 with: - endpoint: https://updater.example.com/update + endpoint: | + https://updater-swarm.example.com/update + https://updater-web01.example.com/update + https://updater-web02.example.com/update image: registry.example.com/${{ gitea.repository }} tag: ${{ gitea.sha }} token: ${{ secrets.UPDATER_TOKEN }} @@ -22,15 +25,24 @@ jobs: `UPDATER_TOKEN` should be set as an organisation-level secret in Gitea so all repos share it. +All instances in the fleet must share the same bearer token, since one `token` +input is sent to every endpoint. + ## Inputs | Name | Required | Default | Description | |---|---|---|---| -| `endpoint` | yes | — | Full URL to `/update` | +| `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) | | `token` | yes | — | Bearer token configured in package-updater | ## Failure modes -The step exits non-zero if `package-updater` returns HTTP 4xx or 5xx. This is intentional — the workflow surfaces the deploy failure to whoever pushed. +Every endpoint is attempted, even when an earlier one fails — otherwise one dead +server would leave the rest of the fleet un-updated, and the CI log would not show +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. diff --git a/gitea-action/action.yml b/gitea-action/action.yml index 19148e8..0ec1826 100644 --- a/gitea-action/action.yml +++ b/gitea-action/action.yml @@ -1,8 +1,8 @@ name: "Deploy via package-updater" -description: "Notifies package-updater to pull & restart a Docker Compose service" +description: "Notifies one or more package-updater instances to pull & restart a service" inputs: endpoint: - description: "Full URL to /update (e.g. https://updater.example.com/update)" + description: "Full URL to /update. Give several, one per line, to update a fleet." required: true image: description: "Image reference without tag (e.g. registry.example.com/myapp)" @@ -20,18 +20,60 @@ runs: - name: Trigger update shell: bash env: + ENDPOINTS: ${{ inputs.endpoint }} + IMAGE: ${{ inputs.image }} + TAG: ${{ inputs.tag }} TOKEN: ${{ inputs.token }} run: | - set -euo pipefail - response=$(curl -sS -w "\n%{http_code}" \ - -X POST "${{ inputs.endpoint }}" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d "{\"image\":\"${{ inputs.image }}\",\"tag\":\"${{ inputs.tag }}\"}") - body=$(echo "$response" | head -n -1) - code=$(echo "$response" | tail -n 1) - echo "HTTP $code" - echo "$body" | jq . - if [ "$code" -ge 400 ]; then + # No `set -e`: a failing endpoint must not abort the loop, or one dead + # server leaves the rest of the fleet un-updated and hides which hosts + # actually succeeded. + set -uo pipefail + + payload=$(jq -nc --arg image "$IMAGE" --arg tag "$TAG" \ + '{image: $image, tag: $tag}') + + attempted=0 + failed=0 + + while IFS= read -r endpoint; do + # URLs never contain whitespace, so stripping all of it safely + # handles indentation, blank lines and CRLF line endings. + endpoint=$(printf '%s' "$endpoint" | tr -d '[:space:]') + [ -z "$endpoint" ] && continue + + attempted=$((attempted + 1)) + echo "--- $endpoint" + + if ! response=$(curl -sS -w "\n%{http_code}" \ + -X POST "$endpoint" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$payload"); then + echo "unreachable" + failed=$((failed + 1)) + continue + fi + + # Split on the last newline: curl's -w appended the status code + # there. Pure bash — `head -n -1` is GNU-only and fails on + # BSD/macOS, so the fan-out could not be tested locally. + code="${response##*$'\n'}" + body="${response%$'\n'*}" + echo "HTTP $code" + printf '%s' "$body" | jq . || printf '%s\n' "$body" + + if [ "$code" -ge 400 ]; then + failed=$((failed + 1)) + fi + done <<< "$ENDPOINTS" + + if [ "$attempted" -eq 0 ]; then + echo "no endpoints given" exit 1 fi + if [ "$failed" -gt 0 ]; then + echo "$failed of $attempted endpoint(s) failed" + exit 1 + fi + echo "all $attempted endpoint(s) updated" -- 2.52.0 From 2d668ddc63bdf4cf170d4a6d2d41795157b4665c Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 4 Aug 2026 19:48:16 +0200 Subject: [PATCH 6/6] 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" \ -- 2.52.0