308 lines
13 KiB
Go
308 lines
13 KiB
Go
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("a pinned ref is written verbatim and not resolved", func(t *testing.T) {
|
|
t.Cleanup(cleanOverride)
|
|
for _, ref := range []string{"gitea.shcizo.se/shcizo/slusk:1.3@" + d, "gitea.shcizo.se/shcizo/slusk@" + d} {
|
|
rec := &recorder{}
|
|
dist := &fakeDist{digest: "sha256:other"}
|
|
var out strings.Builder
|
|
pinnedJob := job
|
|
pinnedJob.Ref = ref
|
|
got, err := setup(fakeCompose{rw, []container.Summary{ct}}, dist, rec).Execute(context.Background(), pinnedJob, &out)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ref, got)
|
|
assert.Empty(t, dist.refs, "no registry lookup")
|
|
_, doc := readOverride(t, stack)
|
|
assert.Equal(t, ref, serviceImage(doc, "web"))
|
|
assert.Contains(t, out.String(), "pinned "+ref)
|
|
assert.NotContains(t, out.String(), "resolved")
|
|
}
|
|
})
|
|
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"), "--profile", "*", "config", "--format", "json"}}, outr.cmds[0])
|
|
})
|
|
t.Run("service missing from the config is refused", func(t *testing.T) {
|
|
t.Cleanup(cleanOverride)
|
|
rec := &recorder{}
|
|
e := setup(fakeCompose{rw, []container.Summary{ct}}, &fakeDist{digest: d}, rec)
|
|
outr.json = `{"services":{"db":{"image":"y"}}}`
|
|
dist := &fakeDist{digest: d}
|
|
e.resolve = NewResolver(dist, nil)
|
|
_, err := e.Execute(context.Background(), job, io.Discard)
|
|
assert.EqualError(t, err, "service web not found in compose config")
|
|
assert.Empty(t, rec.cmds)
|
|
assert.Empty(t, dist.refs, "nothing resolved")
|
|
assert.NoFileExists(t, overridePath)
|
|
})
|
|
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))
|
|
t.Cleanup(func() { os.Remove(envFile); os.Remove(filepath.Join(stack, ".env.local")) })
|
|
envFile2 := filepath.Join(stack, ".env.local")
|
|
require.NoError(t, os.WriteFile(envFile2, []byte("B=2\n"), 0o644))
|
|
withEnv := container.Summary{Labels: map[string]string{
|
|
labelWorkingDir: stack, labelConfigFiles: filepath.Join(stack, "compose.yaml"), labelEnvFile: envFile + "," + envFile2,
|
|
}}
|
|
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, "--env-file", envFile2}
|
|
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")
|
|
}
|