package updater import ( "context" "errors" "io" "os" "path/filepath" "strings" "testing" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/mount" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/shcizo/package-updater/internal/protocol" ) type fakeCompose struct { fakeSelf containers []container.Summary } func (f fakeCompose) ContainerList(context.Context, container.ListOptions) ([]container.Summary, error) { return f.containers, nil } type ranCmd struct { dir string args []string } type recorder struct { cmds []ranCmd failOn string // second arg after -f list, e.g. "pull" failOutput string onFirst func() } func (r *recorder) run(_ context.Context, dir string, out io.Writer, args ...string) error { if len(r.cmds) == 0 && r.onFirst != nil { r.onFirst() } r.cmds = append(r.cmds, ranCmd{dir, args}) if r.failOn != "" && strings.Contains(strings.Join(args, " "), " "+r.failOn+" ") { io.WriteString(out, r.failOutput) return errors.New("exit status 1") } return nil } func TestComposeExecutor(t *testing.T) { const d = "sha256:d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1" root, _ := filepath.EvalSymlinks(t.TempDir()) stack := filepath.Join(root, "slusk") require.NoError(t, os.MkdirAll(stack, 0o755)) job := protocol.JobStart{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", Ref: "gitea.shcizo.se/shcizo/slusk:1.4"} pinned := job.Ref + "@" + d ct := container.Summary{Labels: map[string]string{ labelWorkingDir: stack, labelConfigFiles: filepath.Join(stack, "compose.yaml"), }} rw := fakeSelf{mounts: []container.MountPoint{{Type: mount.TypeBind, Source: root, Destination: root, RW: true}}} ro := fakeSelf{mounts: []container.MountPoint{{Type: mount.TypeBind, Source: root, Destination: root, RW: false}}} wantFiles := []string{"-f", filepath.Join(stack, "compose.yaml"), "-f", filepath.Join(stack, OverrideName)} cmd := func(rest ...string) []string { return append(append([]string{"compose", "-p", "slusk"}, wantFiles...), rest...) } overridePath := filepath.Join(stack, OverrideName) cleanOverride := func() { os.Remove(overridePath) } setup := func(cli ComposeDocker, dist *fakeDist, rec *recorder) *ComposeExecutor { return NewComposeExecutor(cli, NewResolver(dist, nil), "me", rec.run) } t.Run("happy path", func(t *testing.T) { t.Cleanup(cleanOverride) rec := &recorder{onFirst: func() { _, err := os.Stat(overridePath) assert.NoError(t, err, "override must exist before the first command") }} var out strings.Builder got, err := setup(fakeCompose{rw, []container.Summary{ct}}, &fakeDist{digest: d}, rec).Execute(context.Background(), job, &out) require.NoError(t, err) assert.Equal(t, pinned, got) require.Len(t, rec.cmds, 2) assert.Equal(t, ranCmd{stack, cmd("pull", "web")}, rec.cmds[0]) assert.Equal(t, ranCmd{stack, cmd("up", "-d", "web")}, rec.cmds[1]) _, doc := readOverride(t, stack) assert.Equal(t, pinned, serviceImage(doc, "web")) assert.Contains(t, out.String(), "resolved "+pinned) }) t.Run("no matching container", func(t *testing.T) { rec := &recorder{} _, err := setup(fakeCompose{rw, nil}, &fakeDist{digest: d}, rec).Execute(context.Background(), job, io.Discard) assert.ErrorContains(t, err, "no container for compose service slusk/web on this machine") assert.Empty(t, rec.cmds) }) t.Run("resolve fails", func(t *testing.T) { t.Cleanup(cleanOverride) rec := &recorder{} _, err := setup(fakeCompose{rw, []container.Summary{ct}}, &fakeDist{err: errors.New("denied")}, rec).Execute(context.Background(), job, io.Discard) assert.ErrorContains(t, err, "denied") assert.Empty(t, rec.cmds) assert.NoFileExists(t, overridePath) }) t.Run("pull fails", func(t *testing.T) { t.Cleanup(cleanOverride) rec := &recorder{failOn: "pull", failOutput: "pull access denied\n"} _, err := setup(fakeCompose{rw, []container.Summary{ct}}, &fakeDist{digest: d}, rec).Execute(context.Background(), job, io.Discard) assert.ErrorContains(t, err, "pull access denied") require.Len(t, rec.cmds, 1, "up must not run") }) t.Run("override refused", func(t *testing.T) { other := filepath.Join(stack, "docker-compose.override.yml") require.NoError(t, os.WriteFile(other, []byte("services: {}\n"), 0o644)) t.Cleanup(func() { os.Remove(other) }) rec := &recorder{} _, err := setup(fakeCompose{rw, []container.Summary{ct}}, &fakeDist{digest: d}, rec).Execute(context.Background(), job, io.Discard) assert.ErrorContains(t, err, "refused") assert.Empty(t, rec.cmds) }) t.Run("read-only mount", func(t *testing.T) { rec := &recorder{} _, err := setup(fakeCompose{ro, []container.Summary{ct}}, &fakeDist{digest: d}, rec).Execute(context.Background(), job, io.Discard) assert.ErrorContains(t, err, "no stack directory is mounted") assert.Empty(t, rec.cmds) assert.NoFileExists(t, overridePath) }) }