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}, {"flagged self", func(s *model.Service) { s.Self = true }, model.ReasonSelf}, {"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, _ := 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}, {"self", func(s *model.Service) { s.Self = true }, model.ReasonSelf}, {"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) }