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
135 lines
6.5 KiB
Markdown
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.
|