312 lines
11 KiB
Go
312 lines
11 KiB
Go
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_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")
|
|
}
|