feat: self-update of master and agents (#15) (#32)

This commit was merged in pull request #32.
This commit is contained in:
2026-10-13 10:17:52 +00:00
parent ec28d1e027
commit 850a670446
39 changed files with 1421 additions and 130 deletions
+6 -2
View File
@@ -16,8 +16,9 @@ type candidate struct {
// Route picks the connection a job for svc goes to, among agents that speak
// at least protocol version minProtocol. It returns model.ErrAgentOffline or
// model.ErrAgentTooOld (model.ErrAgentTooOldForPinned when minProtocol asks
// for a pinned ref) when there is none.
// model.ErrAgentTooOld (model.ErrAgentTooOldForSelfUpdate when minProtocol
// asks for self-update, model.ErrAgentTooOldForPinned when it asks for a
// pinned ref) when there is none.
func (h *Hub) Route(svc model.Service, minProtocol int) (model.Route, error) {
h.mu.Lock()
conns := make([]candidate, 0, len(h.conns))
@@ -65,6 +66,9 @@ func route(svc model.Service, conns []candidate, minProtocol int) (model.Route,
}
}
if tooOld {
if minProtocol >= protocol.MinSelfUpdateVersion {
return model.Route{}, model.ErrAgentTooOldForSelfUpdate
}
if minProtocol >= protocol.MinPinnedRefVersion {
return model.Route{}, model.ErrAgentTooOldForPinned
}
+12 -1
View File
@@ -14,9 +14,10 @@ func TestRoute(t *testing.T) {
swarmSvc := model.Service{Kind: protocol.KindSwarm, AgentID: ptr(int64(1)), SwarmClusterID: "c1"}
v2 := protocol.Hello{ProtocolVersion: 2}
v3 := protocol.Hello{ProtocolVersion: 3}
v4 := protocol.Hello{ProtocolVersion: 4}
mgrV2 := protocol.Hello{ProtocolVersion: 2, SwarmManager: true, SwarmClusterID: "c1"}
mgrV3 := protocol.Hello{ProtocolVersion: 3, SwarmManager: true, SwarmClusterID: "c1"}
jobs, pinned := protocol.MinJobVersion, protocol.MinPinnedRefVersion
jobs, pinned, selfUpdate := protocol.MinJobVersion, protocol.MinPinnedRefVersion, protocol.MinSelfUpdateVersion
tests := []struct {
name string
@@ -47,6 +48,8 @@ func TestRoute(t *testing.T) {
[]candidate{{1, 10, protocol.Hello{ProtocolVersion: 2, SwarmManager: true}}}, jobs, model.Route{}, model.ErrAgentOffline},
{"swarm, pinned ref falls back to a v3 manager when the reporting one is v2", swarmSvc,
[]candidate{{1, 10, mgrV2}, {3, 30, mgrV3}}, pinned, model.Route{AgentID: 3, ConnID: 30}, nil},
{"compose, own agent is v3 and the job updates the agent itself", composeSvc, []candidate{{2, 20, v3}}, selfUpdate, model.Route{}, model.ErrAgentTooOldForSelfUpdate},
{"compose, v4 agent and a self-update", composeSvc, []candidate{{2, 20, v4}}, selfUpdate, model.Route{AgentID: 2, ConnID: 20}, nil},
{"swarm, pinned ref and only v2 managers", swarmSvc, []candidate{{1, 10, mgrV2}}, pinned, model.Route{}, model.ErrAgentTooOldForPinned},
}
for _, tt := range tests {
@@ -57,3 +60,11 @@ func TestRoute(t *testing.T) {
})
}
}
func TestRoute_TooOldForSelfUpdateIsAlsoTooOld(t *testing.T) {
svc := model.Service{Kind: protocol.KindCompose, AgentID: ptr(int64(2))}
_, err := route(svc, []candidate{{2, 20, protocol.Hello{ProtocolVersion: 3}}}, protocol.MinSelfUpdateVersion)
assert.ErrorIs(t, err, model.ErrAgentTooOldForSelfUpdate)
assert.ErrorIs(t, err, model.ErrAgentTooOld)
assert.EqualError(t, err, "agent too old to update itself")
}
+5 -4
View File
@@ -51,12 +51,13 @@ func TestRestorable(t *testing.T) {
{"never updated", func(s *model.Service) { s.DesiredRef = "" }, model.ReasonNotDrifted},
{"awaiting inventory", func(s *model.Service) { s.AwaitingInventory = true }, model.ReasonNotDrifted},
{"removed", func(s *model.Service) { s.RemovedAt = ptr(time.Now()) }, model.ReasonRemoved},
{"flagged self", func(s *model.Service) { s.Self = true }, model.ReasonSelf},
{"agent's own Compose service", func(s *model.Service) { s.Self = true }, ""},
{"agent's own Swarm service", func(s *model.Service) { s.Self = true; s.Kind = protocol.KindSwarm }, model.ReasonSelfSwarm},
{"built", func(s *model.Service) { s.Built = true }, model.ReasonBuilt},
{"container", func(s *model.Service) { s.Kind = protocol.KindContainer }, model.ReasonContainer},
{"desired ref is the agent image", func(s *model.Service) {
s.DesiredRef = selfImage + "@" + driftD1
}, model.ReasonSelf},
s.DesiredRef = "gitea.shcizo.se/shcizo/package-updater:latest@" + driftD1
}, ""},
}
s, _ := newService(&fakeStore{}, &fakeHub{})
for _, tt := range tests {
@@ -100,7 +101,7 @@ func TestRestoreDesired_Refusals(t *testing.T) {
reason string
}{
{"not drifted", func(s *model.Service) { s.ActualDigest = driftD1 }, model.ReasonNotDrifted},
{"self", func(s *model.Service) { s.Self = true }, model.ReasonSelf},
{"own Swarm service", func(s *model.Service) { s.Self = true; s.Kind = protocol.KindSwarm }, model.ReasonSelfSwarm},
{"built", func(s *model.Service) { s.Built = true }, model.ReasonBuilt},
{"container", func(s *model.Service) { s.Kind = protocol.KindContainer }, model.ReasonContainer},
}
+15 -14
View File
@@ -48,8 +48,11 @@ type Service struct {
now func() time.Time
}
// New returns a Service. agentImage is the master's AGENT_IMAGE; services
// whose image repo equals its repo are the updater itself and are refused.
// New returns a Service. agentImage is the master's AGENT_IMAGE. A service
// whose image repo equals its repo may be an agent that reports itself as
// another service (an agent older than protocol version 4 does not tell), so
// such a job is only sent to an agent that speaks protocol version
// protocol.MinSelfUpdateVersion.
func New(store Store, hub Hub, pub Publisher, agentImage string, log *slog.Logger) *Service {
return &Service{
store: store, hub: hub, pub: pub, log: log, now: time.Now,
@@ -102,8 +105,8 @@ func (s *Service) updatable(svc model.Service, explicitRef bool) error {
switch {
case svc.RemovedAt != nil:
return &model.NotUpdatableError{Reason: model.ReasonRemoved}
case svc.Self || imageref.Parse(svc.ActualRef).Repo == s.selfRepo:
return &model.NotUpdatableError{Reason: model.ReasonSelf}
case svc.Self && svc.Kind == protocol.KindSwarm:
return &model.NotUpdatableError{Reason: model.ReasonSelfSwarm}
case svc.Built:
return &model.NotUpdatableError{Reason: model.ReasonBuilt}
case svc.Kind == protocol.KindContainer:
@@ -126,13 +129,7 @@ func (s *Service) Restorable(svc model.Service) error {
if !svc.Drifted() {
return &model.NotUpdatableError{Reason: model.ReasonNotDrifted}
}
if err := s.updatable(svc, true); err != nil {
return err
}
if imageref.Parse(svc.DesiredRef).Repo == s.selfRepo {
return &model.NotUpdatableError{Reason: model.ReasonSelf}
}
return nil
return s.updatable(svc, true)
}
// RestoreDesired starts a manual job that deploys the drifted service's
@@ -232,9 +229,10 @@ func (s *Service) TriggerVersion(ctx context.Context, serviceID, versionID int64
}
// Start starts an update of svc. It returns the job, which may already be
// failed ("agent offline", "agent too old for updates", or "agent too old to
// failed ("agent offline", "agent too old for updates", "agent too old to
// deploy a pinned version" when the ref has a digest or no tag and the agent
// speaks protocol version 2); a
// speaks protocol version 2, or "agent too old to update itself" for an
// agent's own service before protocol version 4); a
// *model.NotUpdatableError when the service cannot be updated (no job
// created); the active job and model.ErrJobActive when one is queued or
// running. Poll and webhook jobs are refused for a drifted service; only a
@@ -255,6 +253,9 @@ func (s *Service) Start(ctx context.Context, svc model.Service, req StartRequest
if p := imageref.Parse(ref); p.Digest != "" || p.Tag == "" {
minProtocol = protocol.MinPinnedRefVersion
}
if svc.Self || imageref.Parse(svc.ActualRef).Repo == s.selfRepo {
minProtocol = protocol.MinSelfUpdateVersion
}
r, routeErr := s.hub.Route(svc, minProtocol)
nj := model.NewJob{
ServiceID: svc.ID, FromRef: svc.ActualRef, ToRef: ref,
@@ -276,7 +277,7 @@ func (s *Service) Start(ctx context.Context, svc model.Service, req StartRequest
failure := routeErr
if failure == nil {
start := protocol.JobStart{Kind: svc.Kind, Ref: ref}
start := protocol.JobStart{Kind: svc.Kind, Ref: ref, Self: svc.Self}
if svc.Kind == protocol.KindSwarm {
start.SwarmClusterID, start.SwarmService = svc.SwarmClusterID, svc.SwarmService
} else {
+68 -7
View File
@@ -16,6 +16,8 @@ import (
"github.com/shcizo/package-updater/internal/protocol"
)
const selfImage = "gitea.shcizo.se/shcizo/package-updater:latest"
type fakeStore struct {
services map[int64]model.Service
jobs map[int64]model.Job
@@ -92,6 +94,9 @@ type fakeHub struct {
func (h *fakeHub) Route(_ model.Service, minProtocol int) (model.Route, error) {
h.mins = append(h.mins, minProtocol)
if h.agentProtocol != 0 && minProtocol > h.agentProtocol {
if minProtocol >= protocol.MinSelfUpdateVersion {
return model.Route{}, model.ErrAgentTooOldForSelfUpdate
}
if minProtocol >= protocol.MinPinnedRefVersion {
return model.Route{}, model.ErrAgentTooOldForPinned
}
@@ -109,8 +114,6 @@ type fakePub struct{ topics []events.Topic }
func (p *fakePub) Publish(t events.Topic) { p.topics = append(p.topics, t) }
const selfImage = "gitea.shcizo.se/shcizo/package-updater:latest"
var (
composeSvc = model.Service{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web",
AgentID: ptr(int64(7)), ActualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2@sha256:d1"}
@@ -134,9 +137,10 @@ func TestUpdatable(t *testing.T) {
reason string
}{
{"removed", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", RemovedAt: ptr(time.Now())}, model.ReasonRemoved},
{"flagged self", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Self: true}, model.ReasonSelf},
{"agent's own Compose service", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Self: true}, ""},
{"agent's own Swarm service", model.Service{Kind: protocol.KindSwarm, ActualRef: "a:1", Self: true}, model.ReasonSelfSwarm},
{"same repo as the agent image", model.Service{Kind: protocol.KindCompose,
ActualRef: "gitea.shcizo.se/shcizo/package-updater:1.2.0@sha256:abc"}, model.ReasonSelf},
ActualRef: "gitea.shcizo.se/shcizo/package-updater:1.2.0@sha256:abc"}, ""},
{"built from source", model.Service{Kind: protocol.KindCompose, ActualRef: "slusk:1", Built: true}, model.ReasonBuilt},
{"container", model.Service{Kind: protocol.KindContainer, ActualRef: "redis:7"}, model.ReasonContainer},
{"digest without tag", model.Service{Kind: protocol.KindCompose, ActualRef: "redis@sha256:abc"}, model.ReasonNoTag},
@@ -298,7 +302,8 @@ func TestStart_ExplicitRefBypassesOnlyTheNoTagRefusal(t *testing.T) {
}{
{"digest only", model.Service{Kind: protocol.KindCompose, ActualRef: "redis@sha256:abc"}, ""},
{"removed", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", RemovedAt: ptr(time.Now())}, model.ReasonRemoved},
{"self", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Self: true}, model.ReasonSelf},
{"own Swarm service", model.Service{Kind: protocol.KindSwarm, ActualRef: "a:1", Self: true}, model.ReasonSelfSwarm},
{"own Compose service", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Self: true}, ""},
{"built", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Built: true}, model.ReasonBuilt},
{"container", model.Service{Kind: protocol.KindContainer, ActualRef: "redis:7"}, model.ReasonContainer},
}
@@ -342,8 +347,9 @@ func TestAutoAllowed(t *testing.T) {
reason string
}{
{"removed", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", RemovedAt: ptr(time.Now())}, model.ReasonRemoved},
{"self", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Self: true}, model.ReasonSelf},
{"agent image repo", model.Service{Kind: protocol.KindCompose, ActualRef: selfImage}, model.ReasonSelf},
{"own Swarm service", model.Service{Kind: protocol.KindSwarm, ActualRef: "a:1", Self: true}, model.ReasonSelfSwarm},
{"own Compose service", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Self: true}, ""},
{"agent image repo", model.Service{Kind: protocol.KindCompose, ActualRef: "gitea.shcizo.se/shcizo/package-updater:latest"}, ""},
{"built", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Built: true}, model.ReasonBuilt},
{"container", model.Service{Kind: protocol.KindContainer, ActualRef: "redis:7"}, model.ReasonContainer},
{"digest without tag is allowed", model.Service{Kind: protocol.KindCompose, ActualRef: "redis@sha256:abc"}, ""},
@@ -396,6 +402,61 @@ func TestStart_RoutesWithTheMinimumProtocolOfTheRef(t *testing.T) {
}
}
func TestStart_SelfServiceNeedsASelfUpdatingAgent(t *testing.T) {
self := composeSvc
self.Self = true
ok := model.Route{AgentID: 7, ConnID: 70}
t.Run("routes with the self-update protocol and marks the job", func(t *testing.T) {
st := &fakeStore{services: map[int64]model.Service{1: self}, jobs: map[int64]model.Job{}}
h := &fakeHub{route: ok}
s, _ := newService(st, h)
_, err := s.Start(context.Background(), self, StartRequest{Trigger: model.TriggerManual, Ref: "x/y:2@sha256:a"})
require.NoError(t, err)
assert.Equal(t, []int{protocol.MinSelfUpdateVersion}, h.mins, "also above the pinned-ref minimum")
require.Len(t, h.starts, 1)
assert.True(t, h.starts[0].start.Self)
})
t.Run("a service with the agent image repo needs a self-updating agent but is not marked", func(t *testing.T) {
svc := composeSvc
svc.ActualRef = selfImage
st := &fakeStore{services: map[int64]model.Service{1: svc}, jobs: map[int64]model.Job{}}
h := &fakeHub{route: ok}
s, _ := newService(st, h)
_, err := s.Start(context.Background(), svc, StartRequest{Trigger: model.TriggerManual})
require.NoError(t, err)
assert.Equal(t, []int{protocol.MinSelfUpdateVersion}, h.mins)
require.Len(t, h.starts, 1)
assert.False(t, h.starts[0].start.Self)
})
t.Run("a service that is not the agent's own is not marked", func(t *testing.T) {
st := &fakeStore{services: map[int64]model.Service{1: composeSvc}, jobs: map[int64]model.Job{}}
h := &fakeHub{route: ok}
s, _ := newService(st, h)
_, err := s.Start(context.Background(), composeSvc, StartRequest{Trigger: model.TriggerManual})
require.NoError(t, err)
assert.False(t, h.starts[0].start.Self)
})
t.Run("an older agent fails the job with a clear error", func(t *testing.T) {
st := &fakeStore{services: map[int64]model.Service{1: self}, jobs: map[int64]model.Job{}}
h := &fakeHub{route: ok, agentProtocol: 3}
s, _ := newService(st, h)
job, err := s.Start(context.Background(), self, StartRequest{Trigger: model.TriggerManual})
require.NoError(t, err)
assert.Equal(t, model.JobFailed, job.Status)
assert.Equal(t, "agent too old to update itself", job.Error)
assert.Empty(t, h.starts)
})
}
func TestTriggerVersion_Ref(t *testing.T) {
const repo = "gitea.shcizo.se/shcizo/slusk"
noTag := model.Service{ID: 4, Kind: protocol.KindCompose, ComposeProject: "n", ComposeService: "n",
+15 -3
View File
@@ -19,6 +19,10 @@ var (
// speaks protocol version 2, which cannot deploy a digest-pinned or
// tagless ref. errors.Is(err, ErrAgentTooOld) is true for it as well.
ErrAgentTooOldForPinned error = agentTooOldForPinned{}
// ErrAgentTooOldForSelfUpdate means the agent that runs the service speaks
// a protocol version before 4 and cannot update itself.
// errors.Is(err, ErrAgentTooOld) is true for it as well.
ErrAgentTooOldForSelfUpdate error = agentTooOldForSelfUpdate{}
)
type agentTooOldForPinned struct{}
@@ -29,6 +33,12 @@ func (agentTooOldForPinned) Error() string {
func (agentTooOldForPinned) Is(target error) bool { return target == ErrAgentTooOld }
type agentTooOldForSelfUpdate struct{}
func (agentTooOldForSelfUpdate) Error() string { return "agent too old to update itself" }
func (agentTooOldForSelfUpdate) Is(target error) bool { return target == ErrAgentTooOld }
// NotUpdatableError says why a service cannot be updated. Reason is shown
// in the UI as is.
type NotUpdatableError struct {
@@ -39,11 +49,13 @@ func (e *NotUpdatableError) Error() string { return "not updatable: " + e.Reason
// Reasons a service cannot be updated.
const (
ReasonSelf = "updated manually outside the system"
ReasonContainer = "plain containers cannot be updated"
ReasonNoTag = "follows no tag"
ReasonRemoved = "service no longer exists"
ReasonBuilt = "built from source (build:)"
// ReasonSelfSwarm is for an agent's own Swarm service, which cannot be
// updated by the agent that runs in it.
ReasonSelfSwarm = "an agent's own Swarm service cannot update itself"
ReasonDrifted = "drifted: restore or accept the running version first"
ReasonNotDrifted = "runs its desired version"
@@ -57,14 +69,14 @@ type MergeRefusedError struct {
func (e *MergeRefusedError) Error() string { return "cannot merge: " + e.Reason }
// Reasons two services cannot be merged. ReasonSelf is reused for the
// updater itself.
// Reasons two services cannot be merged.
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"
ReasonMergeSelf = "an agent's own service cannot be merged"
)
// JobStatus is the state of an update job.
+17 -1
View File
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"log/slog"
"slices"
"strings"
"sync"
"time"
@@ -209,14 +210,29 @@ func (p *Poller) record(ctx context.Context, l lookup, svcs []model.Service, sta
}
stats.ok++
p.setPollError(ctx, svcs, "", stats, func(s model.Service) bool { return s.LastPollError != "" })
var auto []model.Service
for _, svc := range svcs {
if !p.suggest(ctx, svc, l, stats) {
continue
}
if autoWanted(svc, l.ref, l.digest) {
p.startAuto(ctx, svc.ID, l.ref, l.digest, stats)
auto = append(auto, svc)
}
}
// An agent's own service goes last: jobs that reach an agent while it
// replaces itself are lost, and messages to one agent arrive in order.
slices.SortStableFunc(auto, func(a, b model.Service) int { return selfRank(a) - selfRank(b) })
for _, svc := range auto {
p.startAuto(ctx, svc.ID, l.ref, l.digest, stats)
}
}
// selfRank sorts an agent's own service after all others.
func selfRank(svc model.Service) int {
if svc.Self {
return 1
}
return 0
}
// suggest marks l.digest as the available version of svc unless the service
+19 -1
View File
@@ -297,6 +297,24 @@ func TestNewDigest(t *testing.T) {
}
}
func TestAgentsOwnServiceIsStartedLast(t *testing.T) {
ref := repoA + ":1.4"
own := svc(1, ref+"@sha256:old")
own.UpdateMode, own.Self = model.ModeAuto, true
other := svc(2, ref+"@sha256:old")
other.UpdateMode = model.ModeAuto
st := &fakeStore{services: []model.Service{own, other}}
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
j := &fakeJobs{}
p, _ := newPoller(st, j, reg, Config{})
p.Cycle(context.Background())
require.Len(t, j.starts, 2)
assert.Equal(t, int64(2), j.starts[0].svcID)
assert.Equal(t, int64(1), j.starts[1].svcID)
}
func TestSameSuggestionIsIdempotent(t *testing.T) {
ref := repoA + ":1.4"
running := svc(1, ref+"@sha256:old")
@@ -332,7 +350,7 @@ func TestStartErrorsDoNotAbortCycle(t *testing.T) {
reg := &fakeRegistry{digests: map[string]string{ref: "sha256:new"}}
j := &fakeJobs{errFor: map[int64]error{
1: model.ErrJobActive,
2: &model.NotUpdatableError{Reason: model.ReasonSelf},
2: &model.NotUpdatableError{Reason: model.ReasonContainer},
3: errors.New("database down"),
}}
p, _ := newPoller(st, j, reg, Config{})
@@ -418,11 +418,11 @@ func TestMergeService_Refusals(t *testing.T) {
{"the updater itself (new)", func(t *testing.T, s *Store, f moveFixture) error {
mustExec(t, s, `UPDATE services SET self = true WHERE id = $1`, f.to)
return s.MergeService(context.Background(), f.from, f.to)
}, refused(model.ReasonSelf)},
}, refused(model.ReasonMergeSelf)},
{"the updater itself (old)", func(t *testing.T, s *Store, f moveFixture) error {
mustExec(t, s, `UPDATE services SET self = true WHERE id = $1`, f.from)
return s.MergeService(context.Background(), f.from, f.to)
}, refused(model.ReasonSelf)},
}, refused(model.ReasonMergeSelf)},
{"active job on the old service", func(t *testing.T, s *Store, f moveFixture) error {
_, err := s.CreateJob(context.Background(), newJob(f.from, f.a))
require.NoError(t, err)
+1 -1
View File
@@ -243,7 +243,7 @@ func mergeRefusal(from, to mergeRow) string {
case from.kind == string(protocol.KindContainer):
return model.ReasonMergeContainer
case from.self || to.self:
return model.ReasonSelf
return model.ReasonMergeSelf
}
return ""
}
+2 -2
View File
@@ -273,8 +273,8 @@ func TestServiceDetail_DriftAndMoveData(t *testing.T) {
{name: "swarm service can merge, no suggestion", id: 3, wantSources: sources},
{name: "container cannot merge", id: 4},
{name: "self cannot merge", id: 5},
{name: "restore refusal is passed on", id: 1, restorable: &model.NotUpdatableError{Reason: model.ReasonSelf},
wantBlocked: model.ReasonSelf, wantFrom: &move, wantSources: sources},
{name: "restore refusal is passed on", id: 1, restorable: &model.NotUpdatableError{Reason: model.ReasonContainer},
wantBlocked: model.ReasonContainer, wantFrom: &move, wantSources: sources},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+1
View File
@@ -883,6 +883,7 @@ func agentSnippet(cfg Config, token string) string {
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# - /path/to/stacks:/path/to/stacks # mount each stacks directory at the same path, read-write
# (also this agent's own stack directory, or it cannot update itself)
# - /root/.docker/config.json:/docker-config/config.json:ro
`, cfg.AgentImage, cfg.AgentURL, cfg.MasterKey, token)
}
+6 -3
View File
@@ -303,7 +303,8 @@ func TestOverview_GroupsServicesPerMachineAndSwarm(t *testing.T) {
assert.Contains(t, body, `<span class="stk">slusk</span>`)
assert.Contains(t, body, "jellyfin_jellyfin", "Swarm service names are shown as they are")
assert.Contains(t, body, "sha256:0f42497a69fa…", "digests are shortened")
assert.Contains(t, body, "Managed outside")
assert.Contains(t, body, `<span class="tag f-out">This agent</span>`)
assert.NotContains(t, body, "Managed outside")
assert.Less(t, strings.Index(body, ">Swarm</h2>"), strings.Index(body, ">node-2</h2>"), "a cluster is followed by its managers' machines")
assert.Contains(t, body, `class="sub" data-machine="agent-2"`, "the machine filter lists the manager under the cluster")
}
@@ -513,6 +514,7 @@ func TestAgentSnippet(t *testing.T) {
assert.Contains(t, snippet, "UPDATE_TIMEOUT: 5m")
assert.Contains(t, snippet, " # DOCKER_CONFIG: /docker-config\n")
assert.Contains(t, snippet, " # - /path/to/stacks:/path/to/stacks # mount each stacks directory at the same path, read-write\n")
assert.Contains(t, snippet, "also this agent's own stack directory")
assert.Contains(t, snippet, "AGENT_TOKEN: tok")
}
@@ -537,7 +539,7 @@ func TestServicePage_ShowsUpdateButton(t *testing.T) {
}
func TestServicePage_DisabledReason(t *testing.T) {
for _, reason := range []string{model.ReasonSelf, model.ReasonContainer, model.ReasonNoTag, model.ReasonRemoved, model.ReasonBuilt} {
for _, reason := range []string{model.ReasonContainer, model.ReasonNoTag, model.ReasonRemoved, model.ReasonBuilt} {
t.Run(reason, func(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.updatable = &model.NotUpdatableError{Reason: reason}
@@ -762,7 +764,8 @@ func TestOverview_ShowsModeToggleAndAvailableBadge(t *testing.T) {
assert.Contains(t, body, `<input type="hidden" name="mode" value="auto">`, "manual services toggle to auto: "+path)
assert.Contains(t, body, `aria-checked="false" disabled title="`+model.ReasonBuilt+`">`, "blocked manual services get a disabled switch: "+path)
assert.Contains(t, body, `<span class="na">`+model.ReasonBuilt+`</span>`, "and say why: "+path)
assert.Contains(t, body, `<span class="na">n/a</span>`, "services managed outside have no switch: "+path)
assert.Contains(t, body, `action="/services/3/mode"`, "the agent's own service has a switch like any other: "+path)
assert.NotContains(t, body, `<span class="na">n/a</span>`, path)
}
}
+3 -4
View File
@@ -79,11 +79,10 @@
{{if .AvailableRef}}<span class="tag f-up" title="{{.AvailableRef}}">Update available<em>{{.AvailableTag}}</em></span>{{end}}
{{if followsNoTag .Service}}<span class="tag f-notag">Follows no tag</span>{{end}}
{{if .LastPollError}}<span class="tag f-err" title="{{.LastPollError}}">Poll failed</span>{{end}}
{{if .Self}}<span class="tag f-out">Managed outside</span>{{end}}
{{if .Self}}<span class="tag f-out">This agent</span>{{end}}
</div>
<div class="mode">
{{if .Self}}<span class="na">n/a</span>
{{else}}<form method="post" action="/services/{{.ID}}/mode">
<form method="post" action="/services/{{.ID}}/mode">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<input type="hidden" name="return" value="overview">
{{if eq .UpdateMode "auto"}}<input type="hidden" name="mode" value="manual">
@@ -91,7 +90,7 @@
{{else}}<input type="hidden" name="mode" value="auto">
<button type="submit" class="sw" role="switch" aria-checked="false"{{if .AutoBlocked}} disabled title="{{.AutoBlocked}}"{{end}}><i></i>Manual</button>
{{if .AutoBlocked}}<span class="na">{{.AutoBlocked}}</span>{{end}}{{end}}
</form>{{end}}
</form>
</div>
</div>
{{end}}
+1 -1
View File
@@ -68,7 +68,7 @@ func TestServicePage_VersionPickerWarnsForAutoServices(t *testing.T) {
}
func TestServicePage_VersionPickerHiddenWhenServiceCannotBeUpdated(t *testing.T) {
for _, reason := range []string{model.ReasonSelf, model.ReasonContainer, model.ReasonRemoved, model.ReasonBuilt} {
for _, reason := range []string{model.ReasonContainer, model.ReasonRemoved, model.ReasonBuilt} {
t.Run(reason, func(t *testing.T) {
srv, store, _, _, jobs := newTestEnv(t)
store.services[0].ImageRepo = sluskRepo
+12
View File
@@ -15,6 +15,7 @@ import (
"log/slog"
"net/http"
"regexp"
"slices"
"strconv"
"strings"
"time"
@@ -181,6 +182,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
resp := response{RequestID: requestID, Image: req.Image, Tag: req.Tag, Matched: len(services)}
pending := map[int]int64{} // index into resp.Results -> job ID
// An agent's own service goes last: jobs that reach an agent while it
// replaces itself are lost, and messages to one agent arrive in order.
slices.SortStableFunc(services, func(a, b model.Service) int { return selfRank(a) - selfRank(b) })
for _, svc := range services {
res, job := h.handle(work, svc, req.Tag, started, log)
resp.Results = append(resp.Results, res)
@@ -252,6 +256,14 @@ func (h *Handler) handle(ctx context.Context, svc model.Service, tag string, sta
return res, nil
}
// selfRank sorts an agent's own service after all others.
func selfRank(svc model.Service) int {
if svc.Self {
return 1
}
return 0
}
func (h *Handler) setJob(res *result, job model.Job) {
res.JobID = job.ID
res.JobURL = h.cfg.PublicURL + "/jobs/" + strconv.FormatInt(job.ID, 10)
+17 -2
View File
@@ -470,14 +470,14 @@ func TestBusyIsNotAFailure(t *testing.T) {
func TestRefusedIsNotAFailure(t *testing.T) {
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
e.jobs.errFor[1] = &model.NotUpdatableError{Reason: model.ReasonSelf}
e.jobs.errFor[1] = &model.NotUpdatableError{Reason: model.ReasonContainer}
rec := e.post(`{"image":"` + slusk + `","tag":"2"}`)
assert.Equal(t, 200, rec.Code)
r := decode(t, rec).Results[0]
assert.Equal(t, statusRefused, r.Status)
assert.Equal(t, model.ReasonSelf, r.Error)
assert.Equal(t, model.ReasonContainer, r.Error)
assert.Zero(t, r.JobID)
assert.NotEmpty(t, e.store.avail)
}
@@ -500,6 +500,21 @@ func TestMixedAutoAndManual(t *testing.T) {
assert.Len(t, e.store.avail, 3)
}
func TestAgentsOwnServiceIsStartedLast(t *testing.T) {
own := compose(1, model.ModeAuto, slusk+":1")
own.Self = true
e := newEnv(t, own, compose(2, model.ModeAuto, slusk+":1"))
e.jobs.jobFor[1] = model.Job{Status: model.JobSucceeded}
e.jobs.jobFor[2] = model.Job{Status: model.JobSucceeded}
e.post(`{"image":"` + slusk + `","tag":"2"}`)
calls := e.jobs.startCalls()
require.Len(t, calls, 2)
assert.Equal(t, int64(2), calls[0].svc.ID)
assert.Equal(t, int64(1), calls[1].svc.ID)
}
func TestClientCancelStopsWaitingButNotStarting(t *testing.T) {
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
e.h.cfg.Timeout = time.Minute