feat: master/agent phase 5 — drift and merge, backend (#10) (#30)
Docker Build / docker (push) Successful in 42s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user