feat: master/agent phase 5 — drift and merge, backend (#10) (#30)
Docker Build / docker (push) Successful in 42s

## Summary
- Drift detection: `model.Service.Drifted()` compares desired and running version by digest (tag when the digest is unknown). `services.awaiting_inventory` hides drift between a job result and the next inventory write.
- Poll and webhook jobs are refused for a drifted service; `POST /services/{id}/restore` (manual job to `desired_ref`) and `POST /services/{id}/accept` (running version becomes desired, guarded by `actual` = `Service.RunningRef`).
- Move suggestions (`store.MoveCandidates`, Compose only) and `POST /services/{id}/merge`: the old row keeps its id, mode, `desired_ref` and history and takes the new location; the duplicate is deleted.
- Backend only. The buttons and badges come with #28, on top of the Ledger UI (#18).

Closes #10

## Test plan
- [x] `gofmt -l .`, `go vet ./...`, `go test ./...`
- [x] `go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/`
- [x] `go test -tags integration ./internal/master/store/` against postgres:17-alpine
- [ ] On the server: migration 0005 applies; no drift shown after a normal update
- [ ] On the server: manual `docker compose up` with another tag gives drift; webhook for an auto service is refused
- [ ] On the server: restore and accept via POST; merge after moving a stack

Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
2026-10-13 09:04:36 +00:00
parent 307e46e4c7
commit 919ad91710
21 changed files with 2139 additions and 39 deletions
+25
View File
@@ -64,6 +64,11 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
list in `known_jobs`, and only then accepts jobs for that connection.
- **The webhook never creates services; new services always start `manual`.** The upsert
does not touch `update_mode`.
- **Drift is never corrected automatically; poll/webhook jobs are refused for a drifted
service** (`jobs.Start`, `ReasonDrifted`). Only a manual job, `RestoreDesired` or an
accept gets it out.
- **Merging is never automatic; merge is the only path that deletes a `services` row**
(`store.MergeService`). The old row keeps its id and takes the duplicate's location.
- **Auth**: agents by token over pinned TLS; people by OIDC; CI by the `UPDATER_API_KEY`
bearer token on `POST /update` only. Every state-changing UI request is a POST with a
CSRF token.
@@ -138,6 +143,25 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
newer suggestion, and a digest-pinned pick does not clear a digest-less (webhook) one.
- **Job output is capped at `protocol.MaxJobOutput` (128 KiB, tail).** NUL bytes are
stripped by the hub because Postgres text rejects them.
- **Drift compares by digest, not by string** (`model.RunsRef`): `repo:tag@D` is run by
`repo:tag` reporting digest D. Same digest under another tag is not drift. Different
repo strings (`nginx` vs `docker.io/library/nginx`) are.
- **`services.awaiting_inventory`** is true from a successful `FinishJob` until the next
inventory write for the row; `Service.Drifted()` is false meanwhile, since `desired_ref`
is ahead of `actual_ref` for a debounce interval after every job.
- **Accept never touches `last_tag_digest`, `available_*` or `update_mode`**, and the
`actual` form field (`Service.RunningRef()`, ref plus digest) is only a guard against a
changed service; the value written comes from the row. `RestoreDesired` sets
`last_tag_digest` to the drifted digest when it is another digest of the desired tag and
`last_tag_digest` is empty or equals the desired digest (otherwise it holds back a newer
digest and stays).
- **`awaiting_inventory` can hide real drift** until the next inventory change when a job
result arrives after the snapshot that shows the drift (an agent reconnect resends its
outbox after the snapshot), or when Swarm coalesces an update and a rollback into one
debounce interval. This is accepted.
- **Merge lock order is agent, then service rows by id** (as in inventory writes), and the
duplicate row is deleted before the old row is updated, because `location_key` is unique
and not deferrable. A removal racing a merge can be lost; the next snapshot fixes it.
## References
@@ -146,6 +170,7 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
- Phase 2 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-2-jobs.md`
- Phase 3 plan: `docs/superpowers/plans/2026-10-12-master-agent-phase-3-webhook.md`
- Phase 4 plan: `docs/superpowers/plans/2026-10-13-master-agent-phase-4-polling.md`
- Phase 5 plan: `docs/superpowers/plans/2026-10-13-master-agent-phase-5-drift-merge.md`
- Original single-instance design (historical):
`docs/superpowers/specs/2026-05-22-package-updater-design.md`
- Consumer-side CI integration: `gitea-action/` (calls the master's `POST /update`)