package e2e import ( "context" "io" "log/slog" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/shcizo/package-updater/internal/master/jobs" "github.com/shcizo/package-updater/internal/master/model" "github.com/shcizo/package-updater/internal/master/poller" "github.com/shcizo/package-updater/internal/protocol" ) // jobCount is the number of jobs created so far. func (m *memStore) jobCount() int { m.mu.Lock() defer m.mu.Unlock() return len(m.jobs) } // fixedRegistry answers every lookup from a fixed table. type fixedRegistry struct { digests map[string]string calls int } func (f *fixedRegistry) Digest(_ context.Context, ref string) (string, error) { f.calls++ return f.digests[ref], nil } // Tags lists no tags, so no newer tag is ever suggested. func (f *fixedRegistry) Tags(context.Context, string) ([]string, error) { return nil, nil } func TestPickPinnedVersion(t *testing.T) { env := newJobEnv(t) env.store.seedVersion(model.ImageVersion{ID: 5, Repo: slusk, Tag: "1.3.0", Digest: "sha256:old130", Source: model.SourceRunning}) job, err := env.jobs.TriggerVersion(context.Background(), 1, 5, "alice") require.NoError(t, err) assert.Equal(t, slusk+":1.3.0@sha256:old130", job.ToRef) select { case ref := <-env.exec.started: assert.Equal(t, slusk+":1.3.0@sha256:old130", ref, "the agent receives exactly the picked version") case <-time.After(waitFor): t.Fatal("the agent never received the job") } close(env.exec.release) done := env.waitStatus(t, job.ID, model.JobSucceeded) assert.Equal(t, model.TriggerManual, done.Trigger) assert.Equal(t, "alice", done.RequestedBy) } func TestPickVersionOfAnotherRepoIsRefused(t *testing.T) { env := newJobEnv(t) env.store.seedVersion(model.ImageVersion{ID: 6, Repo: "evil/other", Tag: "1", Digest: "sha256:x", Source: model.SourcePoll}) _, err := env.jobs.TriggerVersion(context.Background(), 1, 6, "alice") assert.ErrorIs(t, err, model.ErrNotFound) assert.Zero(t, env.store.jobCount(), "no job was created") } func TestPollStartsAutoJob(t *testing.T) { env := newJobEnv(t) env.store.seedService(model.Service{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: ptr(agentID), ImageRepo: slusk, ActualRef: slusk + ":1.4.2@sha256:old", ActualDigest: "sha256:old", UpdateMode: model.ModeAuto}) reg := &fixedRegistry{digests: map[string]string{slusk + ":1.4.2": "sha256:new"}} p := poller.New(env.store, env.jobs, reg, env.m.broker, poller.Config{Workers: 1}, slog.New(slog.NewTextHandler(io.Discard, nil))) p.Cycle(context.Background()) select { case ref := <-env.exec.started: assert.Equal(t, slusk+":1.4.2", ref, "the agent gets repo:tag and resolves the digest itself") case <-time.After(waitFor): t.Fatal("the agent never received the job") } require.Equal(t, 1, env.store.jobCount()) job := env.job(t, 1) assert.Equal(t, model.TriggerPoll, job.Trigger) assert.Equal(t, slusk+":1.4.2", job.ToRef) svc, err := env.store.GetService(context.Background(), 1) require.NoError(t, err) assert.Equal(t, slusk+":1.4.2", svc.AvailableRef) assert.Equal(t, "sha256:new", svc.AvailableDigest) assert.Equal(t, model.SourcePoll, svc.AvailableSource) assert.Equal(t, "sha256:new", svc.LastTagDigest) p.Cycle(context.Background()) assert.Equal(t, 2, reg.calls, "the registry is asked again") assert.Equal(t, 1, env.store.jobCount(), "the same digest does not start another job") close(env.exec.release) env.waitStatus(t, job.ID, model.JobSucceeded) } // An update started by the webhook, then a rollback by hand: polling must not // redo the update while the registry still shows the digest that was deployed. func TestPollKeepsARollbackAfterAWebhookUpdate(t *testing.T) { env := newJobEnv(t) ctx := context.Background() close(env.exec.release) env.store.seedService(model.Service{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: ptr(agentID), ImageRepo: slusk, ActualRef: slusk + ":main@sha256:old", ActualDigest: "sha256:old", UpdateMode: model.ModeAuto}) env.store.seedVersion(model.ImageVersion{ID: 5, Repo: slusk, Tag: "main", Digest: "sha256:old", Source: model.SourceRunning}) svc, err := env.store.GetService(ctx, 1) require.NoError(t, err) hook, err := env.jobs.Start(ctx, svc, jobs.StartRequest{Trigger: model.TriggerWebhook, Ref: slusk + ":main"}) require.NoError(t, err) env.waitStatus(t, hook.ID, model.JobSucceeded) svc, err = env.store.GetService(ctx, 1) require.NoError(t, err) assert.Equal(t, e2eDigest, svc.LastTagDigest, "the deployed digest of the tag is remembered") pick, err := env.jobs.TriggerVersion(ctx, 1, 5, "alice") require.NoError(t, err) env.waitStatus(t, pick.ID, model.JobSucceeded) jobsBefore := env.store.jobCount() reg := &fixedRegistry{digests: map[string]string{slusk + ":main": e2eDigest}} p := poller.New(env.store, env.jobs, reg, env.m.broker, poller.Config{Workers: 1}, slog.New(slog.NewTextHandler(io.Discard, nil))) p.Cycle(ctx) assert.Equal(t, 1, reg.calls) assert.Equal(t, jobsBefore, env.store.jobCount(), "the rollback stays") }