feat: master/agent phase 2 — jobs and manual update (#7) #20

Merged
shcizo merged 29 commits from feat/master-agent-jobs-7 into main 2026-10-12 17:58:54 +00:00
3 changed files with 160 additions and 164 deletions
Showing only changes of commit b7e25bb1fb - Show all commits
+75 -68
View File
@@ -1,104 +1,111 @@
# package-updater — Claude context # package-updater — Claude context
Webhook-driven Docker Compose updater. Go service, single binary, deployed as Master/agent fleet updater. A master (UI behind OIDC, Postgres) shows every Compose
a container with `/var/run/docker.sock` mounted. stack, Swarm service and container on every machine and starts updates; one agent per
machine (`/var/run/docker.sock` mounted) reports its inventory and runs the jobs.
Both are in one image: `updater-master` and `updater-agent`.
See `README.md` for user-facing docs. This file is for working ON the code. See `README.md` for user-facing docs. This file is for working ON the code.
## Commands ## Commands
```bash ```bash
go test ./... # all tests, fast (no docker required) go test ./... # all tests, fast (no docker, no postgres)
go test -run TestFoo ./internal/api # single test go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/
go build ./cmd/server # produces ./server go test -run TestFoo ./internal/master/hub # single test
go build ./cmd/master ./cmd/agent # new master/agent binaries (phase 1: inventory) go build ./cmd/master ./cmd/agent # produce ./master and ./agent
DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/ # Postgres tests
docker build -t package-updater:dev . # multi-stage, builds golang:1.26-alpine 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): # Postgres tests (store), against any throwaway database:
cp env.sample .env && $EDITOR .env # fill UPDATER_API_KEY DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
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) ## Architecture (one-line per package)
- `cmd/server` — wiring only: config → docker client → discovery → queue → http - `cmd/master` — wiring only: config → store → hub → jobs → web, two listeners (UI, agents over pinned TLS)
- `internal/api` — HTTP handlers, bearer-token auth, request-id + access-log middleware - `cmd/agent` — wiring only: config → docker client → inventory → job runner → master connection
- `internal/config` — env-var loading; fails fast if `UPDATER_API_KEY` missing - `internal/protocol` — the WebSocket messages both sides speak (`Version`, `JobStart`, `JobResult`, ...)
- `internal/discovery`— given an image, return Compose `Job`s to run (label parsing, path check, dedup) - `internal/pin` — master key pair, certificate and fingerprint pinning
- `internal/discovery/swarm.go` — SwarmDiscovery: same FindJobs signature, lists `docker service ls`, gates on service-level opt-in label instead of STACKS_ROOT path-check - `internal/agenttoken` — agent token generation and hashing
- `internal/updater` — FIFO queue + single worker + `docker compose` subprocess executor - `internal/imageref` — image reference parsing (repo, tag, digest)
- `internal/updater/swarm_executor.go` — SwarmExecutor: `docker service update --image` via the Docker API instead of a `docker compose` subprocess
- `internal/selfupdate` — flush-then-exec wrapper for updating ourselves (NOT wired in live, see gotcha)
- `internal/metrics` — Prometheus collectors
- `internal/logging` — slog JSON, request-id context propagation - `internal/logging` — slog JSON, request-id context propagation
- `cmd/master`, `cmd/agent` — master/agent architecture (spec `2026-10-12-master-agent-design.md`), phase 1 = inventory only. `cmd/server` keeps doing updates until phase 3. - `internal/agent/config` — env-var loading; fails fast on missing `MASTER_URL`/`MASTER_KEY`/`AGENT_TOKEN`
- `internal/protocol` — agent↔master WebSocket messages - `internal/agent/inventory` — reads Compose, Swarm and plain containers from the Docker daemon
- `internal/pin` — master key pair, self-signed cert, fingerprint-pinned TLS client - `internal/agent/conn` — dial, hello, snapshot, job messages, reconnect with backoff; one writer goroutine per session
- `internal/agenttoken`— per-agent tokens (`pua_` prefix, only the SHA-256 hash is stored) - `internal/agent/updater` — job `Runner` (single FIFO worker, outbox until acked), Compose and Swarm executors, override file, mount-root check, registry auth
- `internal/imageref` — split `repo:tag@digest` - `internal/master/config` — env-var loading
- `internal/agent/...` — inventory collection, Docker event debounce, connection loop - `internal/master/store` — Postgres access and migrations
- `internal/master/...`— Postgres store, agent hub, change broker, OIDC auth, htmx UI - `internal/master/hub` — agent connections: auth, per-connection writer, routing, job reconcile on hello
- `internal/master/jobs` — decides whether a service may be updated and starts a job on the right agent
- `internal/master/web`, `webauth` — HTMX UI behind OIDC login, SSE signals
- `internal/master/events` — in-process publish/subscribe for UI refresh signals
- `internal/master/model` — domain types shared by the above
## Design intent (do not break without discussion) ## Design intent (do not break without discussion)
- **Single FIFO worker by design** (`internal/updater/queue.go`). Spec §5.7. Never - **Single FIFO worker per agent by design** (`internal/agent/updater/runner.go`).
parallelise the queue — two `docker compose` calls against the same stack race. Two `docker compose` calls against the same stack race.
- **Defense in depth: token AND opt-in label AND STACKS_ROOT prefix** must all - **One queued or running job per service**, enforced by the unique index
hold before a container is touched. Weakening any of these breaks the security `update_jobs_one_active` (not by application code).
model — discuss before changing. - **The agent writes exactly one file**, `<stack dir>/compose.override.yaml`, and only
- **Auth on `/update` only**. `/healthz`, `/metrics`, `/version` are intentionally if it is absent or starts with the header line. It refuses the job when any other
unauthenticated (internal network, scraper/healthcheck need them). See auto-loaded override name exists.
`cmd/server/main.go` `routeAuth`. - **Every config file and the override path must be inside a read-write bind mount of
- **`metrics *Metrics` parameters may be nil**; constructors and call sites check. the agent's own container whose source equals its destination.** Mounts are read per
Tests rely on this — don't drop the nil-check. job (`ContainerInspect(SELF_ID)`) and checked after symlink resolution. This replaces
- **Stateless**: no DB, no config file, no on-disk audit log. Docker daemon is the old `STACKS_ROOT`.
the source of truth. - **Self-protection on the master**: a service is refused when its image repo equals the
- **Compose mode and Swarm mode are selected once per deployment via `MODE`**, never repo of `AGENT_IMAGE` or the row has the `self` flag. The agent does not re-check.
mixed at request time. Swarm mode's security gate is opt-in label only — there is - **Unknown message types are logged and ignored; malformed messages close the connection.**
no STACKS_ROOT-equivalent path check, since Swarm services have no local compose - **Reconcile before routable**: the hub fails a reconnecting agent's jobs it does not
file. Don't add one; don't weaken Compose mode's three-factor gate to match. list in `known_jobs`, and only then accepts jobs for that connection.
- **Auth**: agents by token over pinned TLS; people by OIDC. Every state-changing UI
request is a POST with a CSRF token.
## Gotchas ## Gotchas
- **`internal/selfupdate.Wrapped` is implemented and unit-tested but NOT wired - **Image matching is case-sensitive.** The tricky case is `localhost:5000/foo:v1` where
into the live queue.** Spec §15 calls this out as a v1 gap. If you "fix" this, the first colon is a port, not a tag. See `internal/imageref`.
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:v1` where the first colon is a port, not a tag. See
`internal/discovery/matching.go` `NormaliseImage`.
- **Path safety uses `filepath.Rel` + ".." prefix check**, NOT `strings.HasPrefix`. - **Path safety uses `filepath.Rel` + ".." prefix check**, NOT `strings.HasPrefix`.
Prevents the `/foo-evil` vs `/foo` confusion. See `internal/discovery/pathcheck.go`. Prevents the `/foo-evil` vs `/foo` confusion. See `internal/agent/updater/pathcheck.go`.
- **`STACKS_ROOT` defaults to `/home/shcizo/self-hosted`** in prod but `env.sample`
suggests `/tmp` for smoke tests. The default in `config.go` is the prod value
— set explicitly in test/dev envs.
- **Go 1.26.3** (`go.mod`). Dockerfile pins `golang:1.26-alpine`. Bumping one - **Go 1.26.3** (`go.mod`). Dockerfile pins `golang:1.26-alpine`. Bumping one
without the other has caused a fix commit already. without the other has caused a fix commit already.
- **`<summary>` C# convention from global CLAUDE.md does not apply here** — this - **`<summary>` C# convention from global CLAUDE.md does not apply here** — this
is Go. Use idiomatic GoDoc (`// FuncName does X.`). is Go. Use idiomatic GoDoc (`// FuncName does X.`).
- **Swarm mode requires manager-node API access.** `docker service update` fails - **Compose loads only one override file.** Of `compose.override.yml`,
with a permission error against a worker-only node. This is an operator/deployment `compose.override.yaml`, `docker-compose.override.yml`, `docker-compose.override.yaml`
concern (point `DOCKER_HOST` at a manager, or schedule the updater on a manager), only the first that exists is read. Hence the refusal described above.
not something the code can detect or work around. - **Stacks started with explicit `-f` do not load the override on a manual `up`.** The
- **WebSocket read limit is raised to 1 MiB on both sides.** coder/websocket defaults to 32 KiB, which a full snapshot from a busy machine exceeds — symptom is an agent stuck in a reconnect loop. agent's own jobs always pass it explicitly.
- **The master's key lives in Postgres (`master_identity`).** Wiping the database means every agent needs a new `MASTER_KEY`. - **Override entries are never pruned.** A service deleted from the stack file keeps its
- **SSE responses must flush through every wrapper.** `web.events` uses `http.ResponseController`; a middleware that wraps `ResponseWriter` without `Unwrap()` silently breaks live updates. Nginx needs `X-Accel-Buffering: no` (set by the handler). `image:` line in `compose.override.yaml`.
- **The "stateless" and "label gate" design intents above describe `cmd/server` only.** The master is stateful by design and gates updates per service in the UI (spec 2026-10-12). - **There is no `STACKS_ROOT`.** Stack directories must be mounted read-write at the host
- **The master does OIDC discovery once at startup and exits if the issuer is unreachable.** It does not retry; Swarm restarts the container, so a short Authentik outage during deploy resolves itself. path (`/path:/path`), and `SELF_ID` must resolve to the agent's own container. On Docker
Desktop/OrbStack bind sources are translated, so Compose jobs fail there.
- **`job.start` carries no paths.** The agent reads working dir and config files from its
own Docker daemon at execution time.
- **`protocol.Service.ConfigFiles` is the raw comma-joined label string**, so `Service`
stays comparable with `==` (`inventory.Diff` depends on it).
- **Swarm "succeeded" means the daemon accepted the new spec**, not that tasks converged.
- **Swarm updates need manager-node API access** for the agent doing the update.
- **The image's default entrypoint is `updater-agent`.** The master overrides it with
`updater-master`. There is no `POST /update` until phase 3.
- **Job output is capped at `protocol.MaxJobOutput` (128 KiB, tail).** NUL bytes are
stripped by the hub because Postgres text rejects them.
## References ## References
- Design spec: `docs/superpowers/specs/2026-05-22-package-updater-design.md` (489 lines, authoritative) - Master/agent spec: `docs/superpowers/specs/2026-10-12-master-agent-design.md` (authoritative)
- Implementation plan: `docs/superpowers/plans/2026-05-22-package-updater-implementation.md`
- Consumer-side CI integration: `gitea-action/`
- Master/agent spec: `docs/superpowers/specs/2026-10-12-master-agent-design.md`
- Phase 1 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-1-inventory.md` - Phase 1 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-1-inventory.md`
- Phase 2 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-2-jobs.md`
- Original single-instance design (historical, its webhook is gone until phase 3):
`docs/superpowers/specs/2026-05-22-package-updater-design.md`
- Consumer-side CI integration: `gitea-action/` (calls `POST /update`, which no longer
exists until phase 3)
## Conventions ## Conventions
- Conventional Commits (`feat:`, `fix:`, `docs:`, `ci:`, `build:`, `chore:`). - Conventional Commits (`feat:`, `fix:`, `docs:`, `ci:`, `build:`, `chore:`).
- One package = one responsibility; interfaces defined at consumer site - One package = one responsibility; interfaces defined at consumer site
(`api.Finder`, `api.Submitter`, `api.Pinger`) for testability. (`jobs.Store`, `jobs.Hub`, `conn.Jobs`, `hub.Store`) for testability.
- Table-driven tests with `stretchr/testify`. No mocks beyond hand-written fakes. - Table-driven tests with `stretchr/testify`. No mocks beyond hand-written fakes.
+84 -95
View File
@@ -1,121 +1,110 @@
# package-updater # package-updater
Webhook-driven Docker Compose service updater. Fills the gap between Watchtower (polling, no CI integration) and full GitOps (Argo CD, Flux) for a self-hosted, single-host environment. 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).
**Trigger flow:** 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).
1. Gitea workflow builds and pushes a new image to your registry. > **Webhook:** the old `POST /update` endpoint is gone. It returns in phase 3 on the
2. Workflow calls `POST /update` on this service with the image name. > master; until then the Gitea action in `gitea-action/` has nothing to call.
3. Service finds the matching Compose-managed container(s) on the host via Docker labels.
4. Runs `docker compose pull` + `up -d` for the relevant service(s).
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. ## What an update does
## How it finds the right stack 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. One update at a
time per service, and one job at a time per agent.
The service queries the Docker socket and reads the labels Compose itself attaches to every container: ## Deploying the master
- `com.docker.compose.project` Use [`deploy/master.stack.yml`](deploy/master.stack.yml) as a starting point (Swarm stack;
- `com.docker.compose.service` needs Postgres and an OIDC provider). The master listens for the UI on `PORT` (default
- `com.docker.compose.project.working_dir` `8080`, put it behind your reverse proxy) and for agents on `AGENT_PORT` (default `8090`,
- `com.docker.compose.project.config_files` pinned TLS, reached directly on the internal network).
A container is eligible for update only if it has **both**: ## Adding agents
- An image name matching the request (tag-agnostic), AND Open the master's **Agents** page and add one. It shows a ready-to-run compose file with
- The opt-in label `se.shcizo.auto-update=true`. 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).
Defense in depth: a valid bearer token AND the opt-in label must both be present before any container is touched.
## 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.
## Master/agent (in progress)
A master with one agent per machine is replacing the single-instance webhook
model; see the [design spec](docs/superpowers/specs/2026-10-12-master-agent-design.md).
Phase 1 delivers a live inventory: every Compose stack, Swarm service and
container on every machine, behind Authentik login. Updates still go through
the existing `/update` webhook until phase 3.
- Master: `updater-master` in the same image, example stack in `deploy/master.stack.yml`.
At startup it does OIDC discovery against `OIDC_ISSUER` and exits if the issuer
is unreachable; Swarm then restarts it.
- Agents: add one per machine on the master's **Agents** page; it shows a
ready-to-run compose file with the agent's token and the master's key
fingerprint.
## 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 | | Variable | Required | Default | Purpose |
|---|---|---|---| |---|---|---|---|
| `UPDATER_API_KEY` | **yes** | — | Bearer token. Service refuses to start without it. | | `MASTER_URL` | yes | — | `wss://host:port/agent` |
| `STACKS_ROOT` | no | `/home/shcizo/self-hosted` | Required parent for any stack eligible to update. | | `MASTER_KEY` | yes | — | Master key fingerprint (`sha256:...`) shown in the UI. |
| `PORT` | no | `8080` | HTTP listen port. | | `AGENT_TOKEN` | yes | — | Token shown once when the agent was added. |
| `LOG_LEVEL` | no | `info` | `debug` / `info` / `warn` / `error`. | | `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). | | `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"`. | | `DOCKER_CONFIG` | no | — | Directory with a `config.json` holding registry credentials. Only inline `auths` entries work; `credsStore`/`credHelpers` are not available in the container. |
| `MODE` | no | `compose` | `compose` or `swarm`. Selects the update mechanism for the whole deployment; not mixed per-request. | | `LOG_LEVEL` | no | `info` | `debug` / `info` / `warn` / `error`. |
## Endpoints ### Mounting stack directories
| Endpoint | Auth | Purpose | 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**
| `POST /update` | Bearer token | Trigger pull + restart for matching services | (Compose labels hold host paths). Add one line per directory:
| `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. ```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /home/shcizo/self-hosted:/home/shcizo/self-hosted
```
## Observability 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.
- **Logs**: JSON to stdout, picked up by Promtail/Alloy → Loki. ## How Compose updates work, and their limits
- **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`.
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. Pin it to the last pre-phase-2 tag first.
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 ## Development
```bash ```bash
go test ./... go test ./...
go build ./cmd/server go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/
go build ./cmd/master ./cmd/agent
docker build -t package-updater:dev . docker build -t package-updater:dev .
DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
``` ```
## 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.
- **Single host only in Compose mode**. Swarm mode (`MODE=swarm`) is the
multi-node path, but only from a manager node's point of view — the updater
itself still needs manager API access (see "Swarm mode" above).
- **No per-repo API keys**: a single shared bearer token is used.
+1 -1
View File
@@ -1,4 +1,4 @@
# Example Swarm stack for the package-updater master (phase 1: inventory only). # Example Swarm stack for the package-updater master (phase 2: inventory and manual updates).
# Deploy on the manager: docker stack deploy -c master.stack.yml package-updater-master # Deploy on the manager: docker stack deploy -c master.stack.yml package-updater-master
# #
# Before deploying: # Before deploying: