Files
package-updater/README.md
T
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

135 lines
6.5 KiB
Markdown

# package-updater
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 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.
## How it finds the right stack
The service queries the Docker socket and reads the labels Compose itself attaches to every container:
- `com.docker.compose.project`
- `com.docker.compose.service`
- `com.docker.compose.project.working_dir`
- `com.docker.compose.project.config_files`
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, 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
Set `MODE=swarm` to update Docker Swarm services instead of Compose stacks. The
service runs `docker service update --image` via the Docker API instead of
shelling out to `docker compose`.
Two things to get right when running in Swarm mode:
- **The opt-in label goes on the service, not the container/task.** Swarm mode
reads `Service.Spec.Labels`, so add it with
`docker service update --label-add se.shcizo.auto-update=true <service>` or
set it under `deploy.labels` in the stack file — a plain `labels:` entry on
the service (container-level) is not visible to Swarm mode's discovery.
- **The updater must talk to a Swarm manager.** `docker service update`
requires manager API access, so either point `DOCKER_HOST` at a manager node
or schedule the updater container itself on a manager with the socket
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).
2. Copy `docker-compose.example.yml` to `/home/shcizo/self-hosted/package-updater/docker-compose.yml`.
3. Create `.env` next to it: `UPDATER_API_KEY=$(openssl rand -hex 32)`.
4. Point your reverse proxy (NPM/Traefik/Caddy) at `package-updater:8080`. NPM should handle TLS.
5. `docker compose up -d`.
6. Add the opt-in label `se.shcizo.auto-update: "true"` to each service you want auto-updated.
7. Use the [Gitea composite action](gitea-action/README.md) in your repos to call `/update` after a build.
## Configuration
All via environment variables.
| Variable | Required | Default | Purpose |
|---|---|---|---|
| `UPDATER_API_KEY` | **yes** | — | Bearer token. Service refuses to start without it. |
| `STACKS_ROOT` | no | `/home/shcizo/self-hosted` | Required parent for any stack eligible to update. |
| `PORT` | no | `8080` | HTTP listen port. |
| `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`. Which paradigm *this instance's* Docker daemon runs. See [Deploying across multiple servers](#deploying-across-multiple-servers). |
## Endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
| `POST /update` | Bearer token | Trigger pull + restart for matching services |
| `GET /healthz` | none | Liveness + Docker socket reachability |
| `GET /version` | none | Build info |
| `GET /metrics` | none | Prometheus exposition |
`/healthz`, `/version`, and `/metrics` are intentionally unauthenticated — they're internal-network only behind the reverse proxy.
## Observability
- **Logs**: JSON to stdout, picked up by Promtail/Alloy → Loki.
- **Metrics**: Prometheus exposition on `/metrics`. Notable: `package_updater_update_jobs_total{project,service,status}`, `package_updater_last_update_timestamp{project,service}`, `package_updater_docker_ping_up`.
## Development
```bash
go test ./...
go build ./cmd/server
docker build -t package-updater:dev .
```
## Known v1 gaps
These are tracked in the spec's section 2 and section 15 as deliberate out-of-scope:
- **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.
- **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.