package inventory import ( "bytes" "context" "errors" "io" "log/slog" "os" "path/filepath" "strings" "testing" "time" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/system" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/shcizo/package-updater/internal/protocol" ) type fakeDocker struct { containers []container.Summary images []image.Summary services []swarm.Service info system.Info containerErr error serviceListCalls int inspect map[string]container.InspectResponse inspectErr error } func (f *fakeDocker) ContainerList(context.Context, container.ListOptions) ([]container.Summary, error) { return f.containers, f.containerErr } func (f *fakeDocker) ContainerInspect(_ context.Context, id string) (container.InspectResponse, error) { return f.inspect[id], f.inspectErr } func (f *fakeDocker) ImageList(context.Context, image.ListOptions) ([]image.Summary, error) { return f.images, nil } func (f *fakeDocker) ServiceList(context.Context, swarm.ServiceListOptions) ([]swarm.Service, error) { f.serviceListCalls++ return f.services, nil } func (f *fakeDocker) Info(context.Context) (system.Info, error) { return f.info, nil } func composeContainer(id, project, service, img, imageID, state string) container.Summary { return container.Summary{ ID: id, Image: img, ImageID: imageID, State: state, Names: []string{"/" + project + "-" + service + "-1"}, Labels: map[string]string{ labelProject: project, labelService: service, labelWorkingDir: "/home/shcizo/self-hosted/" + project, }, } } func managerInfo() system.Info { var info system.Info info.Name = "swarm-mgr" info.Swarm.ControlAvailable = true info.Swarm.Cluster = &swarm.ClusterInfo{ID: "cluster-1"} return info } func TestSnapshot_ComposeContainerWithRepoDigest(t *testing.T) { cli := &fakeDocker{ containers: []container.Summary{composeContainer("abc123", "slusk", "web", "gitea.shcizo.se/shcizo/slusk:1.4.2", "sha256:img1", "running")}, images: []image.Summary{{ID: "sha256:img1", RepoDigests: []string{"gitea.shcizo.se/shcizo/slusk@sha256:d1"}}}, } got, err := New(cli, "").Snapshot(context.Background()) require.NoError(t, err) assert.Equal(t, []protocol.Service{{ Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", WorkingDir: "/home/shcizo/self-hosted/slusk", Image: "gitea.shcizo.se/shcizo/slusk:1.4.2", Digest: "sha256:d1", Status: "running", }}, got) } func TestSnapshot_MovedTagUsesConfiguredReference(t *testing.T) { ct := composeContainer("abc123", "slusk", "web", "sha256:img1", "sha256:img1", "running") cli := &fakeDocker{ containers: []container.Summary{ct}, images: []image.Summary{{ID: "sha256:img1", RepoDigests: []string{"gitea.shcizo.se/shcizo/slusk@sha256:d1"}}}, inspect: map[string]container.InspectResponse{ "abc123": {Config: &container.Config{Image: "gitea.shcizo.se/shcizo/slusk:1.4.2"}}, }, } got, err := New(cli, "").Snapshot(context.Background()) require.NoError(t, err) require.Len(t, got, 1) assert.Equal(t, "gitea.shcizo.se/shcizo/slusk:1.4.2", got[0].Image) assert.Equal(t, "sha256:d1", got[0].Digest) } func TestSnapshot_InspectErrorPropagates(t *testing.T) { cli := &fakeDocker{ containers: []container.Summary{composeContainer("abc123", "slusk", "web", "sha256:img1", "sha256:img1", "running")}, inspectErr: errors.New("gone"), } _, err := New(cli, "").Snapshot(context.Background()) require.Error(t, err) assert.Contains(t, err.Error(), "docker container inspect") } func TestSnapshot_ReplicasMergeAndRunningWins(t *testing.T) { cli := &fakeDocker{containers: []container.Summary{ composeContainer("a1", "slusk", "worker", "slusk:1", "sha256:i", "exited"), composeContainer("a2", "slusk", "worker", "slusk:1", "sha256:i", "running"), }} got, err := New(cli, "").Snapshot(context.Background()) require.NoError(t, err) require.Len(t, got, 1) assert.Equal(t, "running", got[0].Status) } func TestSnapshot_PlainContainerAndSwarmTaskSkipped(t *testing.T) { cli := &fakeDocker{containers: []container.Summary{ {ID: "p1", Names: []string{"/adhoc"}, Image: "redis:alpine", State: "exited"}, {ID: "t1", Names: []string{"/gitea_gitea.1.x"}, Image: "gitea/gitea:1.27.3", State: "running", Labels: map[string]string{labelSwarmTask: "svc1"}}, }} got, err := New(cli, "").Snapshot(context.Background()) require.NoError(t, err) assert.Equal(t, []protocol.Service{{ Kind: protocol.KindContainer, ContainerName: "adhoc", Image: "redis:alpine", Status: "stopped", }}, got) } func TestSnapshot_SelfUpdateHelperSkipped(t *testing.T) { cli := &fakeDocker{containers: []container.Summary{ {ID: "h1", Names: []string{"/quirky_helper"}, Image: "sha256:agentimage", State: "running", Labels: map[string]string{protocol.LabelSelfUpdateJob: "7"}}, {ID: "p1", Names: []string{"/adhoc"}, Image: "redis:alpine", State: "exited"}, }} got, err := New(cli, "").Snapshot(context.Background()) require.NoError(t, err) assert.Equal(t, []protocol.Service{{ Kind: protocol.KindContainer, ContainerName: "adhoc", Image: "redis:alpine", Status: "stopped", }}, got) } func TestSnapshot_SelfFlag(t *testing.T) { cli := &fakeDocker{containers: []container.Summary{ composeContainer("0123456789abcdef", "updater-agent", "agent", "pu:1", "sha256:i", "running"), }} got, err := New(cli, "0123456789ab").Snapshot(context.Background()) require.NoError(t, err) require.Len(t, got, 1) assert.True(t, got[0].Self) } func TestSnapshot_SwarmServicesOnlyOnManager(t *testing.T) { svc := swarm.Service{ Spec: swarm.ServiceSpec{ Annotations: swarm.Annotations{Name: "gitea_gitea"}, TaskTemplate: swarm.TaskSpec{ContainerSpec: &swarm.ContainerSpec{Image: "gitea/gitea:1.27.3@sha256:87a6"}}, }, ServiceStatus: &swarm.ServiceStatus{RunningTasks: 1, DesiredTasks: 1}, } worker := &fakeDocker{services: []swarm.Service{svc}} got, err := New(worker, "").Snapshot(context.Background()) require.NoError(t, err) assert.Empty(t, got) assert.Equal(t, 0, worker.serviceListCalls, "workers must not call the manager-only API") manager := &fakeDocker{services: []swarm.Service{svc}, info: managerInfo()} got, err = New(manager, "").Snapshot(context.Background()) require.NoError(t, err) assert.Equal(t, []protocol.Service{{ Kind: protocol.KindSwarm, SwarmService: "gitea_gitea", Image: "gitea/gitea:1.27.3@sha256:87a6", Digest: "sha256:87a6", Status: "running", }}, got) } func TestSwarmStatus(t *testing.T) { cases := []struct { name string in *swarm.ServiceStatus want string }{ {"no status", nil, "unknown"}, {"scaled to zero", &swarm.ServiceStatus{DesiredTasks: 0}, "stopped"}, {"all running", &swarm.ServiceStatus{RunningTasks: 2, DesiredTasks: 2}, "running"}, {"some running", &swarm.ServiceStatus{RunningTasks: 1, DesiredTasks: 2}, "degraded"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { assert.Equal(t, c.want, swarmStatus(c.in)) }) } } func TestSnapshot_SortedByKey(t *testing.T) { cli := &fakeDocker{containers: []container.Summary{ {ID: "2", Names: []string{"/zeta"}, Image: "z", State: "running"}, {ID: "1", Names: []string{"/alpha"}, Image: "a", State: "running"}, }} got, err := New(cli, "").Snapshot(context.Background()) require.NoError(t, err) require.Len(t, got, 2) assert.Equal(t, "alpha", got[0].ContainerName) } func TestSnapshot_PropagatesDockerErrors(t *testing.T) { cli := &fakeDocker{containerErr: errors.New("daemon down")} _, err := New(cli, "").Snapshot(context.Background()) assert.ErrorContains(t, err, "daemon down") } func TestHello(t *testing.T) { got, err := New(&fakeDocker{info: managerInfo()}, "").Hello(context.Background()) require.NoError(t, err) assert.Equal(t, protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "cluster-1"}, got) } func TestSnapshot_ComposeConfigFilesIsRawLabel(t *testing.T) { ct := composeContainer("abc123", "slusk", "web", "slusk:1", "sha256:img1", "running") ct.Labels[labelConfigFiles] = "/s/a/compose.yaml,/s/a/compose.override.yaml" got, err := New(&fakeDocker{containers: []container.Summary{ct}}, "").Snapshot(context.Background()) require.NoError(t, err) require.Len(t, got, 1) assert.Equal(t, "/s/a/compose.yaml,/s/a/compose.override.yaml", got[0].ConfigFiles) } type fakeBuildCheck struct { built map[string]bool err error calls int } func (f *fakeBuildCheck) BuiltServices(context.Context, string, string, string, string) (map[string]bool, error) { f.calls++ return f.built, f.err } func buildCollector(t *testing.T, check BuildCheck, files string) (*Collector, *fakeDocker) { t.Helper() ct := composeContainer("abc", "app", "web", "app-web", "sha256:img1", "running") ct.Labels[labelConfigFiles] = files other := composeContainer("def", "app", "db", "pg:16", "sha256:img2", "running") other.Labels[labelConfigFiles] = files cli := &fakeDocker{containers: []container.Summary{ct, other}} return New(cli, "").WithBuildCheck(check, slog.New(slog.NewTextHandler(io.Discard, nil))), cli } func TestSnapshot_BuiltServiceIsMarked(t *testing.T) { check := &fakeBuildCheck{built: map[string]bool{"web": true, "db": false}} c, _ := buildCollector(t, check, "/s/app/compose.yaml") got, err := c.Snapshot(context.Background()) require.NoError(t, err) byName := map[string]protocol.Service{} for _, s := range got { byName[s.ComposeService] = s } assert.True(t, byName["web"].Built) assert.False(t, byName["db"].Built) } func TestSnapshot_BuildCheckIsCachedUntilFilesChange(t *testing.T) { file := filepath.Join(t.TempDir(), "compose.yaml") require.NoError(t, os.WriteFile(file, []byte("services: {}\n"), 0o644)) check := &fakeBuildCheck{built: map[string]bool{"web": true}} c, _ := buildCollector(t, check, file) for range 3 { _, err := c.Snapshot(context.Background()) require.NoError(t, err) } assert.Equal(t, 1, check.calls, "one check per project while files are unchanged") require.NoError(t, os.WriteFile(file, []byte("services:\n web: {}\n"), 0o644)) _, err := c.Snapshot(context.Background()) require.NoError(t, err) assert.Equal(t, 2, check.calls, "a changed file re-runs the check") } func TestSnapshot_BuildCheckFailureIsNotFatalAndRetriesLater(t *testing.T) { check := &fakeBuildCheck{err: errors.New("no mounts")} c, _ := buildCollector(t, check, "/s/app/compose.yaml") now := time.Now() c.now = func() time.Time { return now } var logs bytes.Buffer c.log = slog.New(slog.NewTextHandler(&logs, nil)) got, err := c.Snapshot(context.Background()) require.NoError(t, err) for _, s := range got { assert.False(t, s.Built) } _, _ = c.Snapshot(context.Background()) assert.Equal(t, 1, check.calls, "a failure is not retried on every snapshot") now = now.Add(buildRetry + time.Second) _, _ = c.Snapshot(context.Background()) assert.Equal(t, 2, check.calls) assert.Equal(t, 1, strings.Count(logs.String(), "compose_build_check_failed"), "logged once per project") } func TestSnapshot_ServiceAbsentFromConfigFailsClosed(t *testing.T) { check := &fakeBuildCheck{built: map[string]bool{"db": false}} // "web" is missing c, _ := buildCollector(t, check, "/s/app/compose.yaml") var logs bytes.Buffer c.log = slog.New(slog.NewTextHandler(&logs, nil)) for range 2 { got, err := c.Snapshot(context.Background()) require.NoError(t, err) byName := map[string]protocol.Service{} for _, s := range got { byName[s.ComposeService] = s } assert.True(t, byName["web"].Built, "an unknown service is not offered for update") assert.False(t, byName["db"].Built) } assert.Equal(t, 1, strings.Count(logs.String(), "compose_service_not_in_config"), "logged once per project") } func TestSnapshot_MissingServiceIsLoggedAgainAfterItCameBack(t *testing.T) { file := filepath.Join(t.TempDir(), "compose.yaml") write := func(body string) { require.NoError(t, os.WriteFile(file, []byte(body), 0o644)) } write("v1") check := &fakeBuildCheck{built: map[string]bool{"db": false}} // "web" missing c, _ := buildCollector(t, check, file) var logs bytes.Buffer c.log = slog.New(slog.NewTextHandler(&logs, nil)) snap := func() { _, err := c.Snapshot(context.Background()) require.NoError(t, err) } snap() write("v2 changed") check.built = map[string]bool{"db": false, "web": false} // back snap() assert.Empty(t, c.warned, "warnings are forgotten once the project checks out") write("v3 changed again") check.built = map[string]bool{"db": false} // missing again snap() assert.Equal(t, 2, strings.Count(logs.String(), "compose_service_not_in_config")) }