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
+40
View File
@@ -10,6 +10,7 @@ Design: [master/agent spec](docs/superpowers/specs/2026-10-12-master-agent-desig
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).
Phase 5 plan: [drift and merge](docs/superpowers/plans/2026-10-13-master-agent-phase-5-drift-merge.md).
## What an update does
@@ -105,6 +106,9 @@ Response (`200` or `500`):
| `500` | At least one result is `failed` or `timeout`. |
| `503` | `UPDATER_API_KEY` is not set on the master. |
A service that has **drifted** (see [Drift and moved services](#drift-and-moved-services))
is refused with its reason, in auto mode too, until someone restores or accepts it.
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:`
@@ -191,6 +195,42 @@ that differs from the newest one the master deployed. The newer version it was r
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.
## Drift and moved services
A service has **drifted** when it runs something other than the version the last update
job deployed. A service the updater never touched has nothing to drift from. Drift is
decided by what runs, not by how the reference is written: the digest decides when both
sides know one, so `repo:tag@sha256:…` is run by a service reporting `repo:tag` with that
digest, and a different tag with the same digest is not drift. (The design spec compares
the two reference strings; this is deliberately looser.) Between a successful job and the
agent's next inventory report the service is not counted as drifted, because the report
has not arrived yet.
The master never corrects drift on its own, because a manual `docker stack deploy` of a
newer tag may be intentional. Polling and the webhook start no job for a drifted
service, in auto mode too; a person can still start one by hand. There are two ways out:
- **Restore desired** deploys the recorded version again (a manual job to the exact
`repo:tag@digest`; an agent speaking protocol 3 is needed). If the service had drifted
to another digest of the same tag, that digest is remembered, so polling does not
deploy it again until the registry shows a newer one (unless `last_tag_digest` already
holds back a newer digest, which then stays).
- **Accept running** records what runs now as the desired version. It starts no job and
leaves the available suggestion and the update mode alone.
When a Compose service disappears from one agent and exactly one service with the same
project and service name appears on another, and the removed one has history (a desired
version, auto mode or jobs) while the new one has none, the service page suggests that it
moved. **Merge** moves the old row, with its history, mode and desired version, to the new
location and deletes the duplicate. Merging is never automatic. It can also be done by
hand for any removed service of the same kind (Compose or Swarm, names may differ), but
not for plain containers, the updater itself, or a service with a queued or running job.
A merge keeps the old row's mode and desired version, so a manually merged target with its
own history may show drift right after the merge and can be accepted.
The restore, accept and merge buttons arrive with the UI follow-up (issue #28); the endpoints
(`POST /services/{id}/restore`, `/accept`, `/merge`) and the data for them are in place.
## Adding agents
Open the master's **Agents** page and add one. It shows a ready-to-run compose file with