# package-updater Updates self-hosted Docker Compose and Docker Swarm services from one place. A **master** (web UI behind OIDC login, state in Postgres) shows every Compose stack, Swarm service and container on every machine, and starts updates when someone clicks *Update*. One **agent** per machine reports its inventory and runs the jobs. Both are in the same image: `updater-master` and `updater-agent` (the default entrypoint). Design: [master/agent spec](docs/superpowers/specs/2026-10-12-master-agent-design.md). Phase 2 plan: [jobs and manual update](docs/superpowers/plans/2026-10-12-master-agent-phase-2-jobs.md). Phase 3 plan: [webhook and auto mode](docs/superpowers/plans/2026-10-12-master-agent-phase-3-webhook.md). ## What an update does Clicking *Update* on a service re-pulls the service's **current tag** (`repo:tag`). The agent asks its Docker daemon for the tag's digest and deploys `repo:tag@digest`, so what runs is exactly what was pulled. The log streams to the browser while it runs; job history is kept in Postgres. A service without a tag (`repo@digest`), a plain container, and the updater's own services cannot be updated, and the UI says why. A bare image (`image: postgres`, no tag and no digest) counts as `postgres:latest`; only a reference with a digest and no tag (`repo@sha256:...`) "follows no tag". Compose services with a `build:` section are shown but never updated ("built from source (build:)"). One update at a time per service, and one job at a time per agent. ## Deploying the master Use [`deploy/master.stack.yml`](deploy/master.stack.yml) as a starting point (Swarm stack; needs Postgres and an OIDC provider). The master listens for the UI on `PORT` (default `8080`, put it behind your reverse proxy) and for agents on `AGENT_PORT` (default `8090`, pinned TLS, reached directly on the internal network). | Variable | Required | Default | Purpose | |---|---|---|---| | `UPDATER_API_KEY` | no | — | Bearer token for `POST /update`. Without it the webhook answers `503` and the master logs `webhook_disabled`. | | `UPDATE_TIMEOUT` | no | `5m` | How long one `POST /update` waits for its auto jobs (Go duration). | The master's other settings (`DATABASE_URL`, `PUBLIC_URL`, `AGENT_URL`, `OIDC_*`, `ALLOWED_GROUP`, `AGENT_IMAGE`, `PORT`, `AGENT_PORT`, `LOG_LEVEL`) are shown in [`deploy/master.stack.yml`](deploy/master.stack.yml). ## Webhook (CI) CI calls the master after pushing an image. The Gitea action in [`gitea-action/`](gitea-action/README.md) does this; any HTTP client works. ``` POST /update Authorization: Bearer Content-Type: application/json {"image": "registry.example.com/myapp", "tag": "1.5.0"} ``` `image` is matched against the image repo of every service on every agent (tag and digest in the request are ignored for matching; matching is case-sensitive). `tag` is optional. For every match the master records the version as *available* (the overview shows an "update available" badge). Services in **manual** mode stop there. Services in **auto** mode also get an update job, and the request waits for those jobs to finish, at most `UPDATE_TIMEOUT`. **`tag` semantics.** The target of an auto job is `:`, built from the service's own repo and never from the request's `image` string. The tag must be one that has been pushed. Compose services get pinned to it through `compose.override.yaml`, as in any update. Omit `tag` to re-pull the tag the service already follows (a bare image counts as `:latest`); a service that follows no tag (`repo@digest`) is then not touched, and an auto service is reported as `refused`. With a `tag`, the "follows no tag" refusal does not apply; the other refusals (the updater's own services, `build:` services, plain containers) still do. Response (`200` or `500`): ```json {"request_id": "…", "image": "registry.example.com/myapp", "tag": "1.5.0", "matched": 2, "results": [ {"service_id": 12, "service": "myapp/web", "kind": "compose", "mode": "auto", "status": "succeeded", "job_id": 42, "job_url": "https://updater.example.com/jobs/42", "to_ref": "registry.example.com/myapp:1.5.0", "duration_ms": 8210}, {"service_id": 15, "service": "myapp/worker", "kind": "compose", "mode": "manual", "status": "marked", "to_ref": "registry.example.com/myapp:1.5.0", "duration_ms": 3}]} ``` | `status` | Meaning | Counts as failure | |---|---|---| | `succeeded` | Auto job finished successfully. | no | | `failed` | Auto job failed (for example agent offline), or the version could not be recorded. `error` says why. | yes | | `timeout` | Job still running when `UPDATE_TIMEOUT` passed. The master leaves it running. | yes | | `marked` | Manual service; the version was recorded only. | no | | `busy` | The service already has a queued or running job; `job_id` is that job, which is not waited for. | no | | `refused` | The service may not be updated (`error` has the reason). | no | | Code | When | |---|---| | `200` | No result is `failed` or `timeout`. | | `400` | Body is not JSON, `image` is missing, or `tag` is not a valid Docker tag. | | `401` | Missing or wrong bearer token. | | `404` | No service uses the image (catches typos in CI configuration). | | `500` | At least one result is `failed` or `timeout`. | | `503` | `UPDATER_API_KEY` is not set on the master. | The webhook never creates services: the first deployment of a stack is done by hand. A new service always starts in **manual** mode; switch it with the *Make auto* button on the overview or the service page. Auto is refused for the updater's own services, `build:` services and plain containers. > **Warning:** auto mode deploys whatever CI pushes, without anyone looking at it. Using > it on critical services is your own risk. The request is open for as long as the jobs run, so the reverse proxy in front of the master must allow a read timeout longer than `UPDATE_TIMEOUT`. If the client disconnects, marking and starting jobs still complete; only the waiting stops. ## Adding agents Open the master's **Agents** page and add one. It shows a ready-to-run compose file with the agent's token (shown once) and the master's key fingerprint; the same layout is in [`deploy/agent.compose.yml`](deploy/agent.compose.yml). | Variable | Required | Default | Purpose | |---|---|---|---| | `MASTER_URL` | yes | — | `wss://host:port/agent` | | `MASTER_KEY` | yes | — | Master key fingerprint (`sha256:...`) shown in the UI. | | `AGENT_TOKEN` | yes | — | Token shown once when the agent was added. | | `SELF_ID` | no | hostname | Container ID or name of the agent itself; used to read its mounts. | | `UPDATE_TIMEOUT` | no | `5m` | Per-job timeout (Go duration). | | `DOCKER_CONFIG` | no | — | Directory with a `config.json` holding registry credentials. Only inline `auths` entries work; `credsStore`/`credHelpers` are not available in the container. | | `LOG_LEVEL` | no | `info` | `debug` / `info` / `warn` / `error`. | `docker compose` runs inside the agent with only `PATH`, `HOME` and the `DOCKER_*` variables. Variables a stack uses for interpolation must come from the stack's own `.env` file or an `--env-file` (which the agent honours), never from the agent container's environment. ### Registry credentials With `DOCKER_CONFIG` set, Compose jobs pull with the same credentials as `docker compose`. For Swarm jobs the agent passes the matching registry's credentials with the service update (same as `docker service update --with-registry-auth`), so Swarm stores them in the cluster and hands them to the nodes that pull the image. Only the entry for the image's registry host is ever sent. ### Mounting stack directories There is no `STACKS_ROOT`. The agent may only touch Compose stacks in directories that are bind-mounted into its own container **read-write at the same path as on the host** (Compose labels hold host paths). Add one line per directory: ```yaml volumes: - /var/run/docker.sock:/var/run/docker.sock - /home/shcizo/self-hosted:/home/shcizo/self-hosted ``` Read-only mounts and mounts whose source differs from the destination are refused with a clear message. On Docker Desktop and OrbStack bind sources are translated host paths, so Compose jobs fail there; that is a development-only limitation. ## How Compose updates work, and their limits The agent never edits your files. It writes one file next to the stack's base file, `compose.override.yaml`, with a header line and an `image:` entry per updated service (`repo:tag@digest`), then runs `docker compose pull` and `up -d` for that service with the override passed explicitly. - The agent only writes `compose.override.yaml` if it is absent or starts with its header line, and **refuses the job** if any other auto-loaded override exists (`compose.override.yml`, `docker-compose.override.yml`, `docker-compose.override.yaml`): Compose loads only one of them, so ours would silently disable yours. - Stacks you start with explicit `-f` files do not load the override on a manual `docker compose up`; the agent's own jobs always pass it. - **The override is never pruned.** If you delete a service from the stack file, its `image:` entry stays in `compose.override.yaml`, and a manual `docker compose up -d` would recreate it. Remove the entry by hand. **Check before enabling.** List the config files of every running stack to spot existing override files and stacks started with explicit `-f`: ```bash docker ps --format '{{.Label "com.docker.compose.project.config_files"}}' | sort -u ``` ## Swarm updates The agent updates the service with `ServiceUpdate` and the pinned image. "Succeeded" means the daemon accepted the new spec, not that the tasks converged; the inventory shows `degraded` until they do. The agent doing the update must talk to a manager node (run it on a manager, or point `DOCKER_HOST` at one). ## Upgrading from the single-instance updater This release removes `cmd/server` and changes the image's default entrypoint to `updater-agent`. A running old-style updater (default entrypoint, `UPDATER_API_KEY`) will crash-loop the next time its image is pulled. Anything that pulls `:latest` for the old instance triggers this: its own `/update` webhook, Watchtower or Shepherd. Pinning means two things: pin the old instance to the last pre-phase-2 tag **and** remove its `se.shcizo.auto-update` label, so nothing updates it to the new image. The master serves `POST /update` again (see [Webhook](#webhook-ci)); point the Gitea action's `endpoint` there and set `UPDATER_API_KEY` on the master. Master and agents must be on this release together: a phase-1 agent connects but every job sent to it fails "agent too old for updates". ## Development ```bash go test ./... go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/ go build ./cmd/master ./cmd/agent docker build -t package-updater:dev . DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/ ```