feat: master/agent phase 4 — registry polling and version history (#9) (#23)
Docker Build / docker (push) Successful in 47s
Docker Build / docker (push) Successful in 47s
This commit was merged in pull request #23.
This commit is contained in:
@@ -9,6 +9,7 @@ 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).
|
||||
Phase 4 plan: [registry polling and version history](docs/superpowers/plans/2026-10-13-master-agent-phase-4-polling.md).
|
||||
|
||||
## What an update does
|
||||
|
||||
@@ -33,6 +34,8 @@ pinned TLS, reached directly on the internal network).
|
||||
|---|---|---|---|
|
||||
| `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). |
|
||||
| `POLL_INTERVAL` | no | `6h` | Time between registry polling cycles (Go duration, must be positive). The first cycle runs one minute after start. |
|
||||
| `DOCKER_CONFIG` | no | `~/.docker` | Directory with a `config.json` holding registry credentials for polling. Only inline `auths` entries work; see [Registry polling](#registry-polling). |
|
||||
|
||||
The master's other settings (`DATABASE_URL`, `PUBLIC_URL`, `AGENT_URL`, `OIDC_*`,
|
||||
`ALLOWED_GROUP`, `AGENT_IMAGE`, `PORT`, `AGENT_PORT`, `LOG_LEVEL`) are shown in
|
||||
@@ -112,6 +115,77 @@ The request is open for as long as the jobs run, so the reverse proxy in front o
|
||||
master must allow a read timeout longer than `UPDATE_TIMEOUT`. If the client disconnects,
|
||||
marking and starting jobs still complete; only the waiting stops.
|
||||
|
||||
## Registry polling
|
||||
|
||||
Besides the webhook, the master asks each registry whether the tag a service follows has
|
||||
a new digest. Every `POLL_INTERVAL` it sends one `HEAD /v2/<repo>/manifests/<tag>` per
|
||||
distinct followed `repo:tag` (a bare image counts as `:latest`; `repo:tag@digest` is polled
|
||||
as `repo:tag`). It never downloads a manifest: Docker Hub counts a `GET` as a pull against
|
||||
the rate limit and a `HEAD` as nothing
|
||||
([source](https://docs.docker.com/docker-hub/usage/pulls/)), and Docker Hub also has a
|
||||
separate abuse limit for many pulls from one IP address, so the poller stays HEAD-only. For a
|
||||
multi-arch image the digest is the index digest, the same one `docker inspect` shows in
|
||||
`RepoDigests`.
|
||||
|
||||
- **What counts as new.** A digest different from the one the service runs becomes the
|
||||
service's *available* version (the overview badge, source `poll`). A digest the master
|
||||
already suggested is not suggested again. A service in **auto** mode also gets an update
|
||||
job to `repo:tag` (the agent resolves the digest itself, so agents on protocol version 2
|
||||
keep working). The master remembers the newest digest it started or deployed for the tag
|
||||
in `services.last_tag_digest` (set when polling starts a job, when polling sees the
|
||||
service already running that digest, and when any plain tag job succeeds, whoever started
|
||||
it) and starts no job for that digest again, whether the job
|
||||
failed or the user rolled back afterwards. If the service already had a job when the
|
||||
digest was found, the next cycle tries again.
|
||||
- **What is not polled.** Services that follow no tag (`repo@digest`), `build:` services, and
|
||||
services without a registry digest (locally built images). The service page says which.
|
||||
The updater's own services and plain containers are polled, so their badge works, but
|
||||
jobs for them are still refused.
|
||||
- **Errors.** A failed lookup is stored for the services that follow that tag and shown as a
|
||||
"poll failed" badge on the overview and in the "Polling" row of the service page; other
|
||||
tags continue. A later success clears it.
|
||||
- **Credentials.** The master reads `$DOCKER_CONFIG/config.json` (a file written by
|
||||
`docker login`), mounted read-only into the container; without it registries are accessed
|
||||
anonymously. Only inline `auths` entries are supported. A `credsStore` or `credHelpers`
|
||||
entry makes every lookup fail with `error getting credentials ... executable file not found
|
||||
in $PATH`, because the container has no credential helper binaries. The master logs
|
||||
`registry_credentials` at startup with the directory and whether `config.json` exists,
|
||||
never its contents.
|
||||
- **Requests go wherever the image name points.** The master sends the lookup to the
|
||||
registry host in the image name, including a `GET /v2/` ping that follows redirects.
|
||||
Anyone who can start containers on a managed host can therefore make the master send
|
||||
blind requests to internal hosts. The registry's answer is not shown: an error keeps only
|
||||
the HTTP status and the registry's error codes; any other failure shows only "timeout",
|
||||
"cannot reach registry" or "lookup failed". The full error goes to the master's log
|
||||
(`registry_lookup_failed`).
|
||||
|
||||
### Version history and rollback
|
||||
|
||||
The master records every version it sees (webhook, polling, and what agents report as
|
||||
running) in `image_versions`. The service page lists the 10 most recently first seen
|
||||
versions of the service's image repo, marks the one that is running, and has a *Deploy*
|
||||
button on each row. A webhook announces a tag but not its digest, so such a row has no
|
||||
digest until polling or an agent reports one; it is hidden when a row with the same tag
|
||||
and a digest was first seen at or after it. A bare image (`postgres`) is recorded as tag
|
||||
`latest`.
|
||||
|
||||
Picking a version starts a manual job to exactly that reference. Picking an older one is a
|
||||
rollback, including to a digest-pinned reference. A version without a tag deploys
|
||||
`repo@digest` and the service then follows no tag, so it is no longer polled or updatable
|
||||
with the plain *Update* button until it is moved to a tagged version again. Picks of
|
||||
digest-pinned or tagless versions need an agent speaking protocol version 3 (this
|
||||
release); on an older agent the job fails with "agent too old to deploy a pinned version
|
||||
(needs protocol 3)".
|
||||
|
||||
A rollback made before the first poll cycle after the upgrade has seen the service is not
|
||||
protected, because the master has not yet recorded the digest it was running.
|
||||
|
||||
A service in **auto** mode stays auto after a rollback. Polling does not undo it: the
|
||||
service stays on the older version until the registry shows a digest of the followed tag
|
||||
that differs from the newest one the master deployed. The newer version it was rolled back
|
||||
from stays marked as available. A webhook that names a tag still starts a job for an auto
|
||||
service every time, so a CI push moves it forward again.
|
||||
|
||||
## Adding agents
|
||||
|
||||
Open the master's **Agents** page and add one. It shows a ready-to-run compose file with
|
||||
|
||||
Reference in New Issue
Block a user