6 Commits

Author SHA1 Message Date
shcizo 2d668ddc63 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
2026-08-04 19:48:16 +02:00
shcizo 07b95f5feb feat(action): post /update to every endpoint in a fleet, continuing past failures 2026-08-04 19:36:20 +02:00
shcizo bca2e252fb 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
2026-08-04 19:31:55 +02:00
shcizo 81bd01c5d6 docs: describe one-instance-per-server topology and why MODE is exclusive 2026-08-04 19:30:58 +02:00
shcizo 2421c74df1 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
2026-08-04 19:28:39 +02:00
shcizo fcd5908992 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 19:28:39 +02:00
6 changed files with 750 additions and 30 deletions
+14 -3
View File
@@ -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
@@ -73,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
+36 -8
View File
@@ -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
@@ -46,6 +51,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 +95,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 +127,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.
@@ -0,0 +1,511 @@
# 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
# 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"
```
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**
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
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"
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.
@@ -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.
+21 -5
View File
@@ -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
@@ -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,28 @@ 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) |
| `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
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 failsotherwise 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.
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.
+57 -14
View File
@@ -1,14 +1,14 @@
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)"
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:
@@ -20,18 +20,61 @@ 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}') || { echo "jq is required but failed"; exit 1; }
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}" \
--connect-timeout 10 --max-time 900 \
-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"