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:
@@ -44,6 +44,27 @@ const (
|
||||
ReasonNoTag = "follows no tag"
|
||||
ReasonRemoved = "service no longer exists"
|
||||
ReasonBuilt = "built from source (build:)"
|
||||
|
||||
ReasonDrifted = "drifted: restore or accept the running version first"
|
||||
ReasonNotDrifted = "runs its desired version"
|
||||
)
|
||||
|
||||
// MergeRefusedError says why two services cannot be merged. Reason is shown
|
||||
// in the UI as is.
|
||||
type MergeRefusedError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *MergeRefusedError) Error() string { return "cannot merge: " + e.Reason }
|
||||
|
||||
// Reasons two services cannot be merged. ReasonSelf is reused for the
|
||||
// updater itself.
|
||||
const (
|
||||
ReasonMergeSame = "a service cannot be merged into itself"
|
||||
ReasonMergeLiveOld = "the old service still exists"
|
||||
ReasonMergeGoneNew = "the new service no longer exists"
|
||||
ReasonMergeKind = "only services of the same kind can be merged"
|
||||
ReasonMergeContainer = "plain containers cannot be merged"
|
||||
)
|
||||
|
||||
// JobStatus is the state of an update job.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
@@ -58,6 +59,84 @@ func TestImageVersionRef(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
dA = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
dB = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
)
|
||||
|
||||
func TestRunsRef(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
desired, actualRef, actualDig string
|
||||
want bool
|
||||
}{
|
||||
{"same string", "repo:v1@" + dA, "repo:v1@" + dA, "", true},
|
||||
{"tag and digest, actual reports it separately", "repo:tag@" + dA, "repo:tag", dA, true},
|
||||
{"bare image reports latest digest", "postgres:latest@" + dA, "postgres", dA, true},
|
||||
{"tagless actual with the same digest", "repo:tag@" + dA, "repo@" + dA, "", true},
|
||||
{"same digest under another tag", "repo:v1@" + dA, "repo:v2@" + dA, "", true},
|
||||
{"same digest, actual tag differs, digest reported", "repo:v1@" + dA, "repo:v2", dA, true},
|
||||
{"same tag, actual digest unknown", "repo:tag@" + dA, "repo:tag", "", true},
|
||||
{"desired without digest, same tag", "repo:tag", "repo:tag@" + dA, "", true},
|
||||
{"bare desired, latest actual", "repo", "repo:latest", "", true},
|
||||
{"registry with port", "localhost:5000/foo:v1@" + dA, "localhost:5000/foo:v1", dA, true},
|
||||
{"registry with port, other digest", "localhost:5000/foo:v1@" + dA, "localhost:5000/foo:v1", dB, false},
|
||||
{"same tag, other digest", "repo:tag@" + dA, "repo:tag", dB, false},
|
||||
{"other digest in the ref", "repo:tag@" + dA, "repo:tag@" + dB, "", false},
|
||||
{"other tag, digest unknown", "repo:v1@" + dA, "repo:v2", "", false},
|
||||
{"tagless desired, tagged actual, digest unknown", "repo@" + dA, "repo:v1", "", false},
|
||||
{"other repo", "repo:tag@" + dA, "other:tag", dA, false},
|
||||
{"no docker.io normalisation", "nginx:1@" + dA, "docker.io/library/nginx:1", dA, false},
|
||||
{"case-sensitive repo", "Repo:1@" + dA, "repo:1", dA, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, RunsRef(c.desired, c.actualRef, c.actualDig))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrifted(t *testing.T) {
|
||||
now := time.Now()
|
||||
drifted := Service{DesiredRef: "repo:tag@" + dA, ActualRef: "repo:tag", ActualDigest: dB}
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Service)
|
||||
want bool
|
||||
}{
|
||||
{"runs another digest", func(*Service) {}, true},
|
||||
{"never updated", func(s *Service) { s.DesiredRef = "" }, false},
|
||||
{"removed", func(s *Service) { s.RemovedAt = &now }, false},
|
||||
{"awaiting inventory", func(s *Service) { s.AwaitingInventory = true }, false},
|
||||
{"runs the desired digest", func(s *Service) { s.ActualDigest = dA }, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
svc := drifted
|
||||
c.mutate(&svc)
|
||||
assert.Equal(t, c.want, svc.Drifted())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningRef(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, actualRef, actualDigest, want string
|
||||
}{
|
||||
{"ref with digest is kept", "repo:v1@" + dB, dA, "repo:v1@" + dB},
|
||||
{"tag plus reported digest", "repo:v1", dA, "repo:v1@" + dA},
|
||||
{"bare image becomes latest", "postgres", dA, "postgres:latest@" + dA},
|
||||
{"registry with port", "localhost:5000/foo:v1", dA, "localhost:5000/foo:v1@" + dA},
|
||||
{"no digest known", "repo:v1", "", "repo:v1"},
|
||||
{"malformed digest", "repo:v1", "sha256:abc", "repo:v1"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, RunningRef(c.actualRef, c.actualDigest))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob_Active(t *testing.T) {
|
||||
cases := []struct {
|
||||
status JobStatus
|
||||
|
||||
Reference in New Issue
Block a user