## 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
11 KiB
Master/Agent Phase 5 — Drift and Merge Implementation Plan
This plan documents what was built for issue #10 (backend only) and where it deviates from the spec. Code in "Frozen contracts" is final; tasks give the contract and the test cases.
Goal: The master knows when a service runs something other than the version the last job deployed (drift), never corrects it by itself, and offers two ways out: restore the desired version or accept the running one. When a Compose service disappears from one agent and appears on another, the master suggests that it moved, and a merge moves the old row (history, mode, desired ref) to the new location. Merging can also be done by hand.
Architecture:
- Model:
Service.Drifted()plusRunsRefandRunningRefdecide drift and what accept writes.Movecarries a suggestion. - Store: migration 0005 adds
services.awaiting_inventory;FinishJobsets it, every inventory upsert clears it. NewAcceptRunning,MoveCandidates,MergeSources,MergeService(the only code that deletes aservicesrow). - Jobs:
Startrefuses poll and webhook jobs for a drifted service.RestorableandRestoreDesireddeploy the recorded ref again as a manual job. - Web: three POST routes (
/services/{id}/restore,/accept,/merge) and newpageDatafields. No template or CSS changes: issue #18 (the Ledger rewrite of the Services and Agents pages) is a separate UI change; the restore, accept and merge buttons arrive with the UI follow-up, issue #28.
Tech Stack: no new dependencies.
Spec: docs/superpowers/specs/2026-10-12-master-agent-design.md, sections "Drift" and "Service identity and moves". Phase 4 plan conventions still apply.
Global Constraints
go test ./...passes with no Docker and no Postgres. Postgres tests carry//go:build integration.- No changes to
internal/master/web/templates/*,static/*,layout.htmlorgroupServices. - Interfaces declared at the consumer (
jobs.Store,web.Store,web.Jobs). Hand-written fakes and testify, table-driven where there is more than one case. - GoDoc on every exported identifier. Conventional Commits, each ending with
(#10). - Every state-changing request is a POST with a CSRF token. Refusals render the service page with the reason (409), not a bare error.
- The invariants of earlier phases hold: one active job per service (
update_jobs_one_active), the webhook and poller never create services, the updater's own services are never updated or merged.
Deliberate deviations from the spec in this phase
- Drift compares what runs, not reference strings. The spec says a service drifts when
actual_refdiffers fromdesired_ref.desired_refis alwaysrepo:tag@digestwhileactual_refis the raw image string, so a string comparison would flag every healthy service.RunsRefcompares the repo exactly (nodocker.ionormalisation), then the digest when both sides know one, else the followed tag. The same digest under a different tag is therefore not drift. awaiting_inventory. The agent's upsert arrives a debounce interval afterjob.result; until thendesired_refis newer thanactual_ref. The flag, set byFinishJoband cleared by the next upsert of the row, keeps that window from counting as drift.- Poll and webhook jobs are refused for a drifted service (
ReasonDrifted). Manual jobs are allowed, so a person can still update or roll back. - Move suggestion is stricter than "exactly one appears". Besides one removed and one live row on each side, the live row must be newer, have no desired ref and no jobs, and the removed row must have something worth moving (desired ref, auto mode or jobs). Otherwise every restarted stack would produce a suggestion.
- Manual merge is allowed for Compose and Swarm of the same kind, names may differ, same agent allowed. Plain containers and the updater itself are refused.
- Accept does not clear
available_*, and the form'sactualfield (Service.RunningRef()) is only a guard; the value written comes from the row. - The overview does not show moves or drift yet.
MoveCandidatesis only read on the service page.Service.Drifted()is a model method, so the UI follow-up needs no further wiring. CreateJobmaps the foreign key violation onupdate_jobs_service_id_fkeytoErrNotFound, so a job for a service that a merge just deleted fails cleanly.
Review Focus
| Failure case | Test that covers it |
|---|---|
| Drift by digest, not string: tag+digest vs tag and reported digest, bare image, tagless, port colon, other repo, case. | model TestRunsRef, TestDrifted |
Accept writes repo:tag@digest from the running version. |
model TestRunningRef |
| A fresh job result is not drift until the inventory arrives. | store TestFinishJob_SetsAwaitingInventoryUntilTheNextUpsert; e2e TestNoDriftAfterASuccessfulJob |
Accept is guarded by the running version, refuses removed/never-updated, leaves last_tag_digest, available_*, mode alone. |
store TestAcceptRunning_* |
| Poll and webhook start no job for a drifted service; manual does. | jobs TestStart_RefusesPollAndWebhookWhileDrifted; e2e TestDriftedAutoServiceIsNotUpdatedByPollOrWebhook |
| Restore deploys the recorded ref and remembers the drifted digest of the same tag. | jobs TestRestoreDesired_*; e2e TestRestoreDeploysThePinnedRefOnTheAgent |
| Move suggestion only for a unique, new, history-less target and a removed source with history. | store TestMoveCandidates |
| Merge keeps id, mode, desired ref, first seen; takes location and inventory; moves jobs; deletes the duplicate. | store TestMergeService_MovesHistoryModeAndDesiredToTheNewLocation |
| Merge refusals change nothing (active job, kinds, container, self, live old, removed new, revoked agent, unknown). | store TestMergeService_Refusals |
| Inventory after a merge updates the survivor; the old agent returning creates a new row. | store TestMergeService_InventoryAfterTheMerge |
A job for a deleted service is ErrNotFound. |
store TestCreateJob_UnknownServiceIsNotFound |
| Handlers: redirects, 400/404/409, signals published, survivor id in the redirect. | web TestRestoreDesired, TestAcceptRunning_*, TestMergeService_*, TestServiceDetail_DriftAndMoveData |
Frozen contracts
C1. Migration 0005_drift_merge.sql
services.awaiting_inventory boolean NOT NULL DEFAULT false; index services_by_compose_name on (compose_project, compose_service) WHERE kind = 'compose'.
C2. internal/master/model
Service.AwaitingInventory; Service.Drifted(); RunsRef(desired, actualRef, actualDigest); RunningRef(actualRef, actualDigest); Move{FromID, ToID, FromAgent, ToAgent} (FromID is the removed row). ReasonDrifted, ReasonNotDrifted; MergeRefusedError{Reason} with the ReasonMerge* constants (ReasonSelf is reused).
C3. Store
upsertServicesetsawaiting_inventory = false;FinishJobsuccess sets it to true.AcceptRunning(ctx, id, actualRef, actualDigest, desired): updates only a live, previously updated row that still reports that actual ref and digest.ErrNotFoundfor unknown or removed,ErrConflictotherwise.MoveCandidates(ctx) ([]model.Move, error): Compose only, not self, other agent, unique on both sides before the extra filters (live row newer, no desired ref, no jobs; removed row has a desired ref, auto mode or jobs). Agent names byLEFT JOIN, so a revoked agent keeps its name.MergeSources(ctx, toID): removed rows of the same kind (not container, not self), same name first, thenremoved_at DESC, at most 50.MergeService(ctx, fromID, toID): one transaction. Lock order: the live row's agent (FOR SHARE,ErrRevoked), then both rowsORDER BY id FOR UPDATE. Validates (MergeRefusedError), refuses an active job on either row (ErrJobActive), moves the duplicate's jobs, reads its location and inventory, deletes it, then updates the old row (keeps id, kind,first_seen_at,update_mode,desired_ref; takes agent, location, inventory columns, available suggestion and poll error;last_tag_digestonly if the old row has none;removed_at = NULL,awaiting_inventory = false). The delete comes first becauselocation_keyis unique and not deferrable.CreateJob: foreign key violationupdate_jobs_service_id_fkeybecomesErrNotFound.
C4. Jobs
Start returns NotUpdatableError{ReasonDrifted} for a non-manual trigger on a drifted service. Restorable(svc): drifted, then updatable(svc, true), and the desired ref's repo is not the agent image repo. RestoreDesired(ctx, id, by): Start with Ref: svc.DesiredRef, manual. After a created job, SetLastTagDigest to the drifted digest when the actual tag equals the desired tag, the digest is well formed, differs from the desired digest and from last_tag_digest, and last_tag_digest is empty or equals the desired digest (otherwise it holds back a newer digest and stays).
C5. Web
Routes POST /services/{id}/restore, /accept (form actual required, the Service.RunningRef() the user saw, compared with the row's; optional return=overview), /merge (form from = removed row, {id} = live row; redirects to the surviving id). pageData gains RestoreBlocked, MoveFrom, MoveTo, MergeSources. Log events drift_accepted and service_merged.
C6. Concurrency
An upsert for the live row's location waits on its lock, then updates the survivor. A snapshot after the merge lists the survivor's key, so it is not marked removed. A removal racing a merge can be lost; the next snapshot fixes it. CreateJob on the deleted row gets ErrNotFound; the poller and webhook re-read and log it.
Tasks
Task 1: Store and model (C1, C2, C3)
Migration, imageref.IsDigest (replaces the local pattern in the store), model types and functions, store methods. Tests: model tables; integration tests listed in Review Focus.
Task 2: Jobs (C4)
Store.SetLastTagDigest on the consumer interface, Start refusal, Restorable, RestoreDesired. Tests: refusal table, manual job to the desired ref with protocol 3, active job, old agent, last-tag-digest table.
Task 3: Web (C5)
Handlers, interfaces, pageData. Tests with fakes.
Task 4: End-to-end
memStore.FinishJob sets AwaitingInventory. internal/e2e/drift_test.go.
Task 5: Documentation
README section "Drift and moved services", webhook note, CLAUDE.md design intent and gotchas, this plan.
Final verification
gofmt -l . ; go vet ./...
go build ./cmd/master ./cmd/agent && rm -f master agent
go test ./...
go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/
DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/