239 lines
8.4 KiB
Go
239 lines
8.4 KiB
Go
package jobs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/shcizo/package-updater/internal/master/model"
|
|
"github.com/shcizo/package-updater/internal/protocol"
|
|
)
|
|
|
|
const (
|
|
driftRepo = "gitea.shcizo.se/shcizo/slusk"
|
|
driftD1 = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
|
driftD2 = "sha256:2222222222222222222222222222222222222222222222222222222222222222"
|
|
driftD3 = "sha256:3333333333333333333333333333333333333333333333333333333333333333"
|
|
)
|
|
|
|
// driftedSvc was updated to 1.4.2 at D1 but now runs 1.4.2 at D2.
|
|
func driftedSvc() model.Service {
|
|
svc := composeSvc
|
|
svc.ImageRepo = driftRepo
|
|
svc.DesiredRef = driftRepo + ":1.4.2@" + driftD1
|
|
svc.ActualRef = driftRepo + ":1.4.2"
|
|
svc.ActualDigest = driftD2
|
|
return svc
|
|
}
|
|
|
|
func notUpdatableReason(t *testing.T, err error) string {
|
|
t.Helper()
|
|
var nu *model.NotUpdatableError
|
|
require.True(t, errors.As(err, &nu), "got %v", err)
|
|
return nu.Reason
|
|
}
|
|
|
|
func TestRestorable(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*model.Service)
|
|
reason string // "" means restorable
|
|
}{
|
|
{"drifted", func(*model.Service) {}, ""},
|
|
{"drifted service that follows no tag", func(s *model.Service) {
|
|
s.ActualRef = driftRepo + "@" + driftD2
|
|
}, ""},
|
|
{"not drifted", func(s *model.Service) { s.ActualDigest = driftD1 }, model.ReasonNotDrifted},
|
|
{"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},
|
|
{"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 = "gitea.shcizo.se/shcizo/package-updater:latest@" + driftD1
|
|
}, ""},
|
|
}
|
|
s, _ := newService(&fakeStore{}, &fakeHub{})
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
svc := driftedSvc()
|
|
tt.mutate(&svc)
|
|
err := s.Restorable(svc)
|
|
if tt.reason == "" {
|
|
assert.NoError(t, err)
|
|
return
|
|
}
|
|
assert.Equal(t, tt.reason, notUpdatableReason(t, err))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRestoreDesired_StartsAManualJobToTheDesiredRef(t *testing.T) {
|
|
svc := driftedSvc()
|
|
st := &fakeStore{services: map[int64]model.Service{1: svc}, jobs: map[int64]model.Job{}}
|
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
|
s, _ := newService(st, h)
|
|
|
|
job, err := s.RestoreDesired(context.Background(), 1, "sub-1")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.JobQueued, job.Status)
|
|
require.Len(t, st.created, 1)
|
|
assert.Equal(t, svc.DesiredRef, st.created[0].ToRef)
|
|
assert.Equal(t, svc.ActualRef, st.created[0].FromRef)
|
|
assert.Equal(t, "sub-1", st.created[0].RequestedBy)
|
|
assert.Equal(t, model.TriggerManual, st.created[0].Trigger)
|
|
assert.Equal(t, []int{protocol.MinPinnedRefVersion}, h.mins)
|
|
require.Len(t, h.starts, 1)
|
|
assert.Equal(t, svc.DesiredRef, h.starts[0].start.Ref)
|
|
}
|
|
|
|
func TestRestoreDesired_Refusals(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*model.Service)
|
|
reason string
|
|
}{
|
|
{"not drifted", func(s *model.Service) { s.ActualDigest = driftD1 }, model.ReasonNotDrifted},
|
|
{"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},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
svc := driftedSvc()
|
|
tt.mutate(&svc)
|
|
st := &fakeStore{services: map[int64]model.Service{1: svc}, jobs: map[int64]model.Job{}}
|
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
|
s, pub := newService(st, h)
|
|
|
|
_, err := s.RestoreDesired(context.Background(), 1, "sub-1")
|
|
|
|
assert.Equal(t, tt.reason, notUpdatableReason(t, err))
|
|
assert.Empty(t, st.created)
|
|
assert.Empty(t, h.starts)
|
|
assert.Empty(t, pub.topics)
|
|
assert.Empty(t, st.lastTag, "last_tag_digest untouched")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRestoreDesired_UnknownService(t *testing.T) {
|
|
s, _ := newService(&fakeStore{services: map[int64]model.Service{}}, &fakeHub{})
|
|
|
|
_, err := s.RestoreDesired(context.Background(), 9, "sub-1")
|
|
|
|
assert.ErrorIs(t, err, model.ErrNotFound)
|
|
}
|
|
|
|
func TestRestoreDesired_ActiveJobReturnsItAndMarksNothing(t *testing.T) {
|
|
activeJob := model.Job{ID: 99, Status: model.JobRunning}
|
|
st := &fakeStore{services: map[int64]model.Service{1: driftedSvc()}, jobs: map[int64]model.Job{99: activeJob}, active: &activeJob}
|
|
s, _ := newService(st, &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}})
|
|
|
|
job, err := s.RestoreDesired(context.Background(), 1, "sub-1")
|
|
|
|
assert.ErrorIs(t, err, model.ErrJobActive)
|
|
assert.Equal(t, int64(99), job.ID)
|
|
assert.Empty(t, st.lastTag)
|
|
}
|
|
|
|
func TestRestoreDesired_OldAgentGivesAFailedJob(t *testing.T) {
|
|
st := &fakeStore{services: map[int64]model.Service{1: driftedSvc()}, jobs: map[int64]model.Job{}}
|
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}, agentProtocol: 2}
|
|
s, _ := newService(st, h)
|
|
|
|
job, err := s.RestoreDesired(context.Background(), 1, "sub-1")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, model.JobFailed, job.Status)
|
|
assert.Equal(t, model.ErrAgentTooOldForPinned.Error(), job.Error)
|
|
assert.Empty(t, h.starts)
|
|
}
|
|
|
|
func TestRestoreDesired_LastTagDigest(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*model.Service)
|
|
want string // "" means SetLastTagDigest is not called
|
|
}{
|
|
{"drifted to another digest of the same tag", func(*model.Service) {}, driftD2},
|
|
{"digest in the actual ref", func(s *model.Service) {
|
|
s.ActualRef, s.ActualDigest = driftRepo+":1.4.2@"+driftD3, ""
|
|
}, driftD3},
|
|
{"last tag digest holds back a newer one", func(s *model.Service) {
|
|
s.DesiredRef = driftRepo + ":1.4.2@" + driftD1
|
|
s.LastTagDigest = driftD3
|
|
}, ""},
|
|
{"last tag digest equals the desired digest", func(s *model.Service) { s.LastTagDigest = driftD1 }, driftD2},
|
|
{"already remembered", func(s *model.Service) { s.LastTagDigest = driftD2 }, ""},
|
|
{"different tag", func(s *model.Service) { s.ActualRef = driftRepo + ":1.5.0" }, ""},
|
|
{"actual digest malformed", func(s *model.Service) { s.ActualDigest = "sha256:abc" }, ""},
|
|
{"desired follows no tag", func(s *model.Service) { s.DesiredRef = driftRepo + "@" + driftD1 }, ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
svc := driftedSvc()
|
|
tt.mutate(&svc)
|
|
st := &fakeStore{services: map[int64]model.Service{1: svc}, jobs: map[int64]model.Job{}}
|
|
s, _ := newService(st, &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}})
|
|
|
|
_, err := s.RestoreDesired(context.Background(), 1, "sub-1")
|
|
|
|
require.NoError(t, err)
|
|
if tt.want == "" {
|
|
assert.Empty(t, st.lastTag)
|
|
return
|
|
}
|
|
assert.Equal(t, map[int64]string{1: tt.want}, st.lastTag)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStart_RefusesPollAndWebhookWhileDrifted(t *testing.T) {
|
|
tests := []struct {
|
|
trigger model.JobTrigger
|
|
refused bool
|
|
}{
|
|
{model.TriggerPoll, true},
|
|
{model.TriggerWebhook, true},
|
|
{model.TriggerManual, false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(string(tt.trigger), func(t *testing.T) {
|
|
svc := driftedSvc()
|
|
st := &fakeStore{services: map[int64]model.Service{1: svc}, jobs: map[int64]model.Job{}}
|
|
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
|
s, _ := newService(st, h)
|
|
|
|
_, err := s.Start(context.Background(), svc, StartRequest{Trigger: tt.trigger, Ref: driftRepo + ":2.0.0"})
|
|
|
|
if tt.refused {
|
|
assert.Equal(t, model.ReasonDrifted, notUpdatableReason(t, err))
|
|
assert.Empty(t, st.created)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
assert.Len(t, st.created, 1)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStart_PollAllowedWhenNotDrifted(t *testing.T) {
|
|
svc := driftedSvc()
|
|
svc.AwaitingInventory = true
|
|
st := &fakeStore{services: map[int64]model.Service{1: svc}, jobs: map[int64]model.Job{}}
|
|
s, _ := newService(st, &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}})
|
|
|
|
_, err := s.Start(context.Background(), svc, StartRequest{Trigger: model.TriggerPoll})
|
|
|
|
require.NoError(t, err)
|
|
assert.Len(t, st.created, 1)
|
|
}
|