2d668ddc63
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
5.8 KiB
5.8 KiB
package-updater — Claude context
Webhook-driven Docker Compose updater. Go service, single binary, deployed as
a container with /var/run/docker.sock mounted.
See README.md for user-facing docs. This file is for working ON the code.
Commands
go test ./... # all tests, fast (no docker required)
go test -run TestFoo ./internal/api # single test
go build ./cmd/server # produces ./server
docker build -t package-updater:dev . # multi-stage, builds golang:1.26-alpine
# Local smoke test (builds from Dockerfile, runs against /var/run/docker.sock):
cp env.sample .env && $EDITOR .env # fill UPDATER_API_KEY
docker compose -f docker-compose.local.yml up --build
curl -sH "Authorization: Bearer $UPDATER_API_KEY" \
-d '{"image":"foo"}' http://localhost:8080/update | jq
Architecture (one-line per package)
cmd/server— wiring only: config → docker client → discovery → queue → httpinternal/api— HTTP handlers, bearer-token auth, request-id + access-log middlewareinternal/config— env-var loading; fails fast ifUPDATER_API_KEYmissinginternal/discovery— given an image, return ComposeJobs to run (label parsing, path check, dedup)internal/discovery/swarm.go— SwarmDiscovery: same FindJobs signature, listsdocker service ls, gates on service-level opt-in label instead of STACKS_ROOT path-checkinternal/updater— FIFO queue + single worker +docker composesubprocess executorinternal/updater/swarm_executor.go— SwarmExecutor:docker service update --imagevia the Docker API instead of adocker composesubprocessinternal/selfupdate— flush-then-exec wrapper for updating ourselves (NOT wired in live, see gotcha)internal/metrics— Prometheus collectorsinternal/logging— slog JSON, request-id context propagation
Design intent (do not break without discussion)
- Single FIFO worker by design (
internal/updater/queue.go). Spec §5.7. Never parallelise the queue — twodocker composecalls against the same stack race. - Defense in depth: token AND opt-in label AND STACKS_ROOT prefix must all hold before a container is touched. Weakening any of these breaks the security model — discuss before changing.
- Auth on
/updateonly./healthz,/metrics,/versionare intentionally unauthenticated (internal network, scraper/healthcheck need them). Seecmd/server/main.gorouteAuth. metrics *Metricsparameters may be nil; constructors and call sites check. 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.
MODEis 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
internal/selfupdate.Wrappedis implemented and unit-tested but NOT wired into the live queue. Spec §15 calls this out as a v1 gap. If you "fix" this, read the package doc — flush-before-exec ordering is subtle.- Image matching is case-sensitive and tag/digest-agnostic. The tricky case
is
localhost:5000/foo:v1where the first colon is a port, not a tag. Seeinternal/discovery/matching.goNormaliseImage. - Path safety uses
filepath.Rel+ ".." prefix check, NOTstrings.HasPrefix. Prevents the/foo-evilvs/fooconfusion. Seeinternal/discovery/pathcheck.go. STACKS_ROOTdefaults to/home/shcizo/self-hostedin prod butenv.samplesuggests/tmpfor smoke tests. The default inconfig.gois the prod value — set explicitly in test/dev envs.- Go 1.26.3 (
go.mod). Dockerfile pinsgolang:1.26-alpine. Bumping one without the other has caused a fix commit already. <summary>C# convention from global CLAUDE.md does not apply here — this is Go. Use idiomatic GoDoc (// FuncName does X.).- Swarm mode requires manager-node API access.
docker service updatefails with a permission error against a worker-only node. This is an operator/deployment concern (pointDOCKER_HOSTat a manager, or schedule the updater on a manager), not something the code can detect or work around. tagis cosmetic in compose mode but load-bearing in swarm mode.handlers.gofolds it into the requested image; compose discovery strips it viaNormaliseImage, butSwarmExecutorassigns it directly toContainerSpec.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
- Conventional Commits (
feat:,fix:,docs:,ci:,build:,chore:). - One package = one responsibility; interfaces defined at consumer site
(
api.Finder,api.Submitter,api.Pinger) for testability. - Table-driven tests with
stretchr/testify. No mocks beyond hand-written fakes.