package updater import ( "context" "io" "log/slog" "sync" "testing" "time" "github.com/docker/docker/api/types/container" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/shcizo/package-updater/internal/protocol" ) func TestResumeSelfUpdates(t *testing.T) { const pinned = "gitea.shcizo.se/shcizo/package-updater:1.4@sha256:aa" started := time.Date(2026, 10, 13, 9, 0, 0, 0, time.UTC) self := fakeSelf{id: "selfid", labels: map[string]string{labelComposeProject: "updater", labelComposeService: "agent"}} helper := func(id, project, service string, extra map[string]string) container.Summary { labels := map[string]string{ labelSelfUpdateJob: "j-" + id, labelSelfUpdateRef: pinned, labelSelfUpdateStarted: started.Format(time.RFC3339Nano), labelSelfUpdateProject: project, labelSelfUpdateService: service, labelSelfUpdateOutput: "resolved " + pinned + "\n$ docker compose pull agent\n", } for k, v := range extra { if v == "" { delete(labels, k) } else { labels[k] = v } } return container.Summary{ID: id, Labels: labels} } separator := "--- agent restarted; output of the update helper ---\n" cases := []struct { name string helper container.Summary exit int64 state container.ContainerState noStarted bool // the old agent's start time is lost with the label wantStatus string wantErr string wantOutput string }{ {name: "helper succeeded", helper: helper("h1", "updater", "agent", nil), wantStatus: protocol.ResultSucceeded, wantOutput: "resolved " + pinned + "\n$ docker compose pull agent\n" + separator + "Recreated\n"}, {name: "helper failed", helper: helper("h1", "updater", "agent", nil), exit: 1, wantStatus: protocol.ResultFailed, wantErr: "docker compose up: exit status 1 (output: Recreated)"}, {name: "no ref label", helper: helper("h1", "updater", "agent", map[string]string{labelSelfUpdateRef: ""}), wantStatus: protocol.ResultFailed, wantErr: "update helper has no ref label"}, {name: "no started label", helper: helper("h1", "updater", "agent", map[string]string{labelSelfUpdateStarted: ""}), noStarted: true, wantStatus: protocol.ResultFailed, wantErr: `update helper has no usable started label ""`}, {name: "helper never started", helper: helper("h1", "updater", "agent", nil), state: container.StateCreated, wantStatus: protocol.ResultFailed, wantErr: "update helper was never started"}, {name: "no output label", helper: helper("h1", "updater", "agent", map[string]string{labelSelfUpdateOutput: ""}), wantStatus: protocol.ResultSucceeded, wantOutput: separator + "Recreated\n"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { h := &fakeHelper{exit: c.exit, state: c.state, logs: "Recreated\n"} unrelated := []container.Summary{ helper("other-project", "other", "agent", nil), helper("other-service", "updater", "web", nil), } cli := fakeHelperCompose{fakeCompose{self, append(unrelated, c.helper)}, h} r := NewRunner(execFunc(func(context.Context, Job, io.Writer) (string, error) { t.Error("a resumed job must not reach the executor") return "", nil }), time.Minute) require.NoError(t, ResumeSelfUpdates(context.Background(), cli, "me", r, slog.New(slog.NewTextHandler(io.Discard, nil)))) assert.Equal(t, []string{"j-h1"}, r.Known(), "known before the runner starts, so the first hello lists it") ctx, cancel := context.WithCancel(context.Background()) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done(); r.Run(ctx) }() t.Cleanup(func() { cancel(); wg.Wait() }) got := waitPending(t, r, 1) require.Len(t, got, 1) assert.Equal(t, "j-h1", got[0].ID) res := got[0].Result assert.Equal(t, c.wantStatus, res.Status) if c.state != container.StateCreated { assert.Equal(t, container.WaitConditionNotRunning, h.waitCond) } assert.Equal(t, []string{"h1"}, h.removed, "only the matching helper is touched") if c.wantStatus == protocol.ResultSucceeded { assert.Equal(t, pinned, res.Ref) assert.Equal(t, c.wantOutput, res.Output) } else { assert.Equal(t, c.wantErr, res.Error) } if !c.noStarted { assert.True(t, started.Equal(res.StartedAt), "the old agent's start time is kept") } }) } t.Run("an agent outside Compose resumes nothing", func(t *testing.T) { cli := fakeHelperCompose{fakeCompose{fakeSelf{id: "selfid"}, []container.Summary{helper("h1", "updater", "agent", nil)}}, &fakeHelper{}} r := NewRunner(nil, time.Minute) require.NoError(t, ResumeSelfUpdates(context.Background(), cli, "me", r, slog.New(slog.NewTextHandler(io.Discard, nil)))) assert.Empty(t, r.Known()) }) }