Merge origin/main (phase 5, drift and merge) into feat/ledger-ui-services-agents-18

Conflicts:
- CLAUDE.md: keep both sets of gotchas (drift/merge from main, SSE ping and
  swarm_cluster_id from this branch).
- internal/imageref/imageref_test.go: keep both TestIsDigest (main) and
  TestIsRegistryHost (this branch).
- internal/master/web/server.go: take main's renderServiceError and make it
  render through s.page, so service error pages get the sidebar nav data.

Also renumber this branch's migration 0005_agent_cluster.sql to
0006_agent_cluster.sql, after main's 0005_drift_merge.sql.

Claude-Session: https://claude.ai/code/session_01GAZsp3P7Qto6JnhipxWUJQ
This commit is contained in:
2026-10-13 11:18:50 +02:00
22 changed files with 2139 additions and 39 deletions
+71 -3
View File
@@ -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")
)
@@ -71,9 +73,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.
@@ -116,6 +122,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 {