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:
@@ -15,6 +15,8 @@ import (
|
||||
// Sentinel errors returned by storage.
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
// ErrConflict means the row already exists, or that it changed meanwhile
|
||||
// (AcceptRunning, MergeService).
|
||||
ErrConflict = errors.New("already exists")
|
||||
ErrRevoked = errors.New("agent revoked")
|
||||
)
|
||||
@@ -68,9 +70,13 @@ type Service struct {
|
||||
// not start another job for the same digest, so a rollback sticks until the
|
||||
// registry shows a different one.
|
||||
LastTagDigest string
|
||||
FirstSeenAt time.Time
|
||||
UpdatedAt time.Time
|
||||
RemovedAt *time.Time
|
||||
// AwaitingInventory is true from a successful job until the next inventory
|
||||
// write for the row. Until then DesiredRef is newer than ActualRef without
|
||||
// the service having drifted.
|
||||
AwaitingInventory bool
|
||||
FirstSeenAt time.Time
|
||||
UpdatedAt time.Time
|
||||
RemovedAt *time.Time
|
||||
}
|
||||
|
||||
// Update modes of a service.
|
||||
@@ -113,6 +119,68 @@ func (v ImageVersion) Ref() string {
|
||||
return imageref.Ref{Repo: v.Repo, Tag: v.Tag, Digest: v.Digest}.String()
|
||||
}
|
||||
|
||||
// Drifted reports whether the service runs something other than the version
|
||||
// the last job deployed. A service the updater never touched, a removed one
|
||||
// and one whose inventory has not caught up with the last job do not drift.
|
||||
func (s Service) Drifted() bool {
|
||||
return s.DesiredRef != "" && s.RemovedAt == nil && !s.AwaitingInventory &&
|
||||
!RunsRef(s.DesiredRef, s.ActualRef, s.ActualDigest)
|
||||
}
|
||||
|
||||
// RunsRef reports whether a service reporting actualRef and actualDigest runs
|
||||
// the desired ref. The digest decides when both sides know one, so
|
||||
// "repo:tag@D" is run by "repo:tag" reporting digest D, and by "repo@D".
|
||||
// Otherwise the repo (compared exactly, without registry normalisation) and
|
||||
// the followed tag must be equal.
|
||||
func RunsRef(desired, actualRef, actualDigest string) bool {
|
||||
d, a := imageref.Parse(desired), imageref.Parse(actualRef)
|
||||
if d.Repo != a.Repo {
|
||||
return false
|
||||
}
|
||||
ad := a.Digest
|
||||
if ad == "" {
|
||||
ad = actualDigest
|
||||
}
|
||||
if d.Digest != "" && ad != "" {
|
||||
return d.Digest == ad
|
||||
}
|
||||
return followedTag(d) == followedTag(a)
|
||||
}
|
||||
|
||||
// followedTag is the tag a ref follows: a bare image follows "latest" and a
|
||||
// ref with a digest but no tag follows nothing.
|
||||
func followedTag(r imageref.Ref) string {
|
||||
if r.Tag == "" && r.Digest == "" {
|
||||
return "latest"
|
||||
}
|
||||
return r.Tag
|
||||
}
|
||||
|
||||
// RunningRef is the ref to record as the desired one when the running version
|
||||
// is accepted: actualRef itself if it carries a digest, "repo:tag@digest" if
|
||||
// only actualDigest is known (a bare image is "latest"), else actualRef.
|
||||
func RunningRef(actualRef, actualDigest string) string {
|
||||
r := imageref.Parse(actualRef)
|
||||
if r.Digest != "" || !imageref.IsDigest(actualDigest) {
|
||||
return actualRef
|
||||
}
|
||||
r.Tag, r.Digest = followedTag(r), actualDigest
|
||||
return r.String()
|
||||
}
|
||||
|
||||
// RunningRef returns the ref accepting the running version records as the
|
||||
// desired one; see the function RunningRef. Templates post it as the guard of
|
||||
// the accept form.
|
||||
func (s Service) RunningRef() string { return RunningRef(s.ActualRef, s.ActualDigest) }
|
||||
|
||||
// Move says that the removed service FromID has probably moved to the live
|
||||
// service ToID on another agent. The names are the agents' own; a revoked
|
||||
// agent keeps its name.
|
||||
type Move struct {
|
||||
FromID, ToID int64
|
||||
FromAgent, ToAgent string
|
||||
}
|
||||
|
||||
// DisplayName is the human-facing name of the service.
|
||||
func (s Service) DisplayName() string {
|
||||
switch s.Kind {
|
||||
|
||||
Reference in New Issue
Block a user