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 } // outRunner is a fake OutputRunner for `docker compose config`. type outRunner struct { json string err error cmds []ranCmd } func (o *outRunner) run(_ context.Context, dir string, args ...string) ([]byte, error) { o.cmds = append(o.cmds, ranCmd{dir, args}) return []byte(o.json), o.err } const plainConfig = `{"services":{"web":{"image":"x"},"db":{"image":"y"}}}` 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", "--project-directory", stack}, wantFiles...), rest...) } overridePath := filepath.Join(stack, OverrideName) cleanOverride := func() { os.Remove(overridePath) } outr := &outRunner{json: plainConfig} setup := func(cli ComposeDocker, dist *fakeDist, rec *recorder) *ComposeExecutor { *outr = outRunner{json: plainConfig} return NewComposeExecutor(cli, NewResolver(dist, nil), "me", rec.run, outr.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("built from source is refused before anything is written", func(t *testing.T) { t.Cleanup(cleanOverride) rec := &recorder{} e := setup(fakeCompose{rw, []container.Summary{ct}}, &fakeDist{digest: d}, rec) outr.json = `{"services":{"web":{"image":"nginx","build":{"context":"."}}}}` _, err := e.Execute(context.Background(), job, io.Discard) assert.EqualError(t, err, "service web is built from source (build:), not updated") assert.Empty(t, rec.cmds, "no pull/up") assert.NoFileExists(t, overridePath) }) t.Run("build check uses the job's files without the override", func(t *testing.T) { t.Cleanup(cleanOverride) rec := &recorder{} _, err := setup(fakeCompose{rw, []container.Summary{ct}}, &fakeDist{digest: d}, rec).Execute(context.Background(), job, io.Discard) require.NoError(t, err) require.Len(t, outr.cmds, 1) assert.Equal(t, ranCmd{stack, []string{"compose", "-p", "slusk", "--project-directory", stack, "-f", filepath.Join(stack, "compose.yaml"), "config", "--format", "json"}}, outr.cmds[0]) }) t.Run("build check failure stops the job", func(t *testing.T) { t.Cleanup(cleanOverride) rec := &recorder{} e := setup(fakeCompose{rw, []container.Summary{ct}}, &fakeDist{digest: d}, rec) outr.err = errors.New("invalid compose file") _, err := e.Execute(context.Background(), job, io.Discard) assert.ErrorContains(t, err, "invalid compose file") assert.Empty(t, rec.cmds) assert.NoFileExists(t, overridePath) }) t.Run("env files are passed to every compose call", func(t *testing.T) { t.Cleanup(cleanOverride) envFile := filepath.Join(stack, ".env.prod") require.NoError(t, os.WriteFile(envFile, []byte("A=1\n"), 0o644)) withEnv := container.Summary{Labels: map[string]string{ labelWorkingDir: stack, labelConfigFiles: filepath.Join(stack, "compose.yaml"), labelEnvFile: envFile, }} rec := &recorder{} _, err := setup(fakeCompose{rw, []container.Summary{withEnv}}, &fakeDist{digest: d}, rec).Execute(context.Background(), job, io.Discard) require.NoError(t, err) prefix := []string{"compose", "-p", "slusk", "--project-directory", stack, "--env-file", envFile} require.Len(t, outr.cmds, 1) assert.Equal(t, prefix, outr.cmds[0].args[:len(prefix)]) require.Len(t, rec.cmds, 2) assert.Equal(t, prefix, rec.cmds[0].args[:len(prefix)]) assert.Equal(t, prefix, rec.cmds[1].args[:len(prefix)]) }) t.Run("env file outside the roots is refused", func(t *testing.T) { t.Cleanup(cleanOverride) outside := filepath.Join(t.TempDir(), "secrets.env") require.NoError(t, os.WriteFile(outside, []byte("A=1\n"), 0o644)) bad := container.Summary{Labels: map[string]string{ labelWorkingDir: stack, labelConfigFiles: filepath.Join(stack, "compose.yaml"), labelEnvFile: outside, }} rec := &recorder{} _, err := setup(fakeCompose{rw, []container.Summary{bad}}, &fakeDist{digest: d}, rec).Execute(context.Background(), job, io.Discard) assert.ErrorContains(t, err, "env file") assert.Empty(t, rec.cmds) assert.Empty(t, outr.cmds) assert.NoFileExists(t, overridePath) }) t.Run("env file symlinked outside the roots is refused", func(t *testing.T) { t.Cleanup(cleanOverride) outside := filepath.Join(t.TempDir(), "secrets.env") require.NoError(t, os.WriteFile(outside, []byte("A=1\n"), 0o644)) link := filepath.Join(stack, ".env.link") require.NoError(t, os.Symlink(outside, link)) t.Cleanup(func() { os.Remove(link) }) bad := container.Summary{Labels: map[string]string{ labelWorkingDir: stack, labelConfigFiles: filepath.Join(stack, "compose.yaml"), labelEnvFile: link, }} rec := &recorder{} _, err := setup(fakeCompose{rw, []container.Summary{bad}}, &fakeDist{digest: d}, rec).Execute(context.Background(), job, io.Discard) assert.ErrorContains(t, err, "env file") assert.Empty(t, rec.cmds) }) 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("working dir outside the roots", func(t *testing.T) { outside := filepath.Join(t.TempDir(), "elsewhere") bad := container.Summary{Labels: map[string]string{ labelWorkingDir: outside, labelConfigFiles: filepath.Join(stack, "compose.yaml"), }} rec := &recorder{} _, err := setup(fakeCompose{rw, []container.Summary{bad}}, &fakeDist{digest: d}, rec).Execute(context.Background(), job, io.Discard) assert.ErrorContains(t, err, "not mounted") assert.Empty(t, rec.cmds) assert.NoFileExists(t, overridePath) }) 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) }) } func TestDockerEnv(t *testing.T) { t.Setenv("AGENT_TOKEN", "secret") t.Setenv("HOSTNAME", "abc") t.Setenv("MASTER_URL", "wss://x") t.Setenv("PATH", "/usr/bin") t.Setenv("DOCKER_CONFIG", "/docker-config") t.Setenv("DOCKER_HOST", "") os.Unsetenv("DOCKER_CERT_PATH") env := dockerEnv() assert.Contains(t, env, "PATH=/usr/bin") assert.Contains(t, env, "DOCKER_CONFIG=/docker-config") assert.NotContains(t, env, "DOCKER_HOST=", "empty variables are not passed") for _, e := range env { assert.NotContains(t, e, "AGENT_TOKEN") assert.NotContains(t, e, "HOSTNAME") assert.NotContains(t, e, "MASTER_URL") assert.False(t, strings.HasPrefix(e, "DOCKER_CERT_PATH="), "unset variables are not passed") } } func TestExecDocker_UsesMinimalEnv(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "docker"), []byte("#!/bin/sh\nenv\n"), 0o755)) t.Setenv("PATH", dir+":/usr/bin:/bin") t.Setenv("AGENT_TOKEN", "secret") t.Setenv("DOCKER_CONFIG", "/docker-config") var out strings.Builder require.NoError(t, ExecDocker(context.Background(), dir, &out, "compose", "ps")) assert.NotContains(t, out.String(), "AGENT_TOKEN") assert.Contains(t, out.String(), "DOCKER_CONFIG=/docker-config") raw, err := ExecDockerOutput(context.Background(), dir, "compose", "config") require.NoError(t, err) assert.NotContains(t, string(raw), "AGENT_TOKEN") assert.Contains(t, string(raw), "DOCKER_CONFIG=/docker-config") }