package updater import ( "context" "errors" "os" "path/filepath" "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" "gopkg.in/yaml.v3" ) func readOverride(t *testing.T, dir string) (string, map[string]any) { t.Helper() raw, err := os.ReadFile(filepath.Join(dir, OverrideName)) require.NoError(t, err) var doc map[string]any require.NoError(t, yaml.Unmarshal(raw, &doc)) return string(raw), doc } func serviceImage(doc map[string]any, name string) any { svcs, _ := doc["services"].(map[string]any) entry, _ := svcs[name].(map[string]any) return entry["image"] } func TestWriteOverride(t *testing.T) { t.Run("new file", func(t *testing.T) { dir := t.TempDir() path, err := WriteOverride(dir, "web", "a:1@sha256:aa") require.NoError(t, err) assert.Equal(t, filepath.Join(dir, OverrideName), path) raw, doc := readOverride(t, dir) assert.True(t, len(raw) > len(overrideHeader) && raw[:len(overrideHeader)] == overrideHeader) assert.Equal(t, "a:1@sha256:aa", serviceImage(doc, "web")) }) t.Run("keeps other services", func(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(overrideHeader+"services:\n db:\n image: db:1\n"), 0o644)) _, err := WriteOverride(dir, "web", "a:1@sha256:aa") require.NoError(t, err) _, doc := readOverride(t, dir) assert.Equal(t, "db:1", serviceImage(doc, "db")) assert.Equal(t, "a:1@sha256:aa", serviceImage(doc, "web")) }) t.Run("replaces own entry", func(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(overrideHeader+"services:\n web:\n image: old\n"), 0o644)) _, err := WriteOverride(dir, "web", "a:2@sha256:bb") require.NoError(t, err) _, doc := readOverride(t, dir) assert.Equal(t, "a:2@sha256:bb", serviceImage(doc, "web")) }) t.Run("operator file without header is never touched", func(t *testing.T) { dir := t.TempDir() const body = "services:\n web:\n environment: [A=1]\n" require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(body), 0o644)) _, err := WriteOverride(dir, "web", "a:1@sha256:aa") assert.ErrorContains(t, err, "refused") got, _ := os.ReadFile(filepath.Join(dir, OverrideName)) assert.Equal(t, body, string(got)) }) for _, other := range []string{"compose.override.yml", "docker-compose.override.yml", "docker-compose.override.yaml"} { t.Run("refuses next to "+other, func(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, other), []byte("services: {}\n"), 0o644)) _, err := WriteOverride(dir, "web", "a:1@sha256:aa") assert.ErrorContains(t, err, other) _, statErr := os.Stat(filepath.Join(dir, OverrideName)) assert.True(t, errors.Is(statErr, os.ErrNotExist)) }) } t.Run("leaves no temp files", func(t *testing.T) { dir := t.TempDir() _, err := WriteOverride(dir, "web", "a:1@sha256:aa") require.NoError(t, err) entries, err := os.ReadDir(dir) require.NoError(t, err) require.Len(t, entries, 1) assert.Equal(t, OverrideName, entries[0].Name()) }) } type fakeSelf struct { mounts []container.MountPoint err error } func (f fakeSelf) ContainerInspect(context.Context, string) (container.InspectResponse, error) { return container.InspectResponse{Mounts: f.mounts}, f.err } func TestMountRoots(t *testing.T) { bind := func(src, dst string, rw bool) container.MountPoint { return container.MountPoint{Type: mount.TypeBind, Source: src, Destination: dst, RW: rw} } cases := []struct { name string self fakeSelf selfID string want []string wantErr string }{ {"rw same path", fakeSelf{mounts: []container.MountPoint{bind("/srv/stacks", "/srv/stacks", true)}}, "me", []string{"/srv/stacks"}, ""}, {"read-only", fakeSelf{mounts: []container.MountPoint{bind("/srv/stacks", "/srv/stacks", false)}}, "me", nil, ""}, {"path translated", fakeSelf{mounts: []container.MountPoint{bind("/home/x/stacks", "/stacks", true)}}, "me", nil, ""}, {"named volume", fakeSelf{mounts: []container.MountPoint{{Type: mount.TypeVolume, Source: "/v", Destination: "/v", RW: true}}}, "me", nil, ""}, {"inspect error", fakeSelf{err: errors.New("no such container")}, "me", nil, "inspect own container"}, {"no self id", fakeSelf{}, "", nil, "no stack directory is mounted"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, err := MountRoots(context.Background(), c.self, c.selfID) if c.wantErr != "" { assert.ErrorContains(t, err, c.wantErr) return } require.NoError(t, err) assert.Equal(t, c.want, got) }) } } func TestComposeFiles(t *testing.T) { root := t.TempDir() root, err := filepath.EvalSymlinks(root) // macOS temp dirs are symlinked require.NoError(t, err) mk := func(p ...string) string { dir := filepath.Join(append([]string{root}, p...)...) require.NoError(t, os.MkdirAll(dir, 0o755)) return dir } a := mk("a") nested := mk("apps", "x", "y") second := t.TempDir() second, _ = filepath.EvalSymlinks(second) secondDir := filepath.Join(second, "s") require.NoError(t, os.MkdirAll(secondDir, 0o755)) outside, _ := filepath.EvalSymlinks(t.TempDir()) require.NoError(t, os.Symlink(outside, filepath.Join(root, "evil"))) sock := filepath.Join(root, "docker.sock") require.NoError(t, os.WriteFile(sock, nil, 0o644)) t.Run("adds override last", func(t *testing.T) { files, dir, err := composeFiles([]string{root}, filepath.Join(a, "compose.yaml")) require.NoError(t, err) assert.Equal(t, []string{filepath.Join(a, "compose.yaml"), filepath.Join(a, OverrideName)}, files) assert.Equal(t, a, dir) }) t.Run("override in label listed once, last", func(t *testing.T) { label := filepath.Join(a, OverrideName) + "," + filepath.Join(a, "compose.yaml") + "," + filepath.Join(a, "extra.yaml") files, _, err := composeFiles([]string{root}, label) require.NoError(t, err) assert.Equal(t, []string{filepath.Join(a, "compose.yaml"), filepath.Join(a, "extra.yaml"), filepath.Join(a, OverrideName)}, files) }) t.Run("nested", func(t *testing.T) { _, dir, err := composeFiles([]string{root}, filepath.Join(nested, "compose.yaml")) require.NoError(t, err) assert.Equal(t, nested, dir) }) t.Run("second root", func(t *testing.T) { _, _, err := composeFiles([]string{root, second}, filepath.Join(secondDir, "compose.yaml")) require.NoError(t, err) }) t.Run("empty label", func(t *testing.T) { _, _, err := composeFiles([]string{root}, "") assert.ErrorContains(t, err, "config_files") }) t.Run("no roots", func(t *testing.T) { _, _, err := composeFiles(nil, filepath.Join(a, "compose.yaml")) assert.ErrorContains(t, err, "no stack directory is mounted") }) t.Run("outside every root", func(t *testing.T) { _, _, err := composeFiles([]string{root}, filepath.Join(second, "s", "compose.yaml")) assert.EqualError(t, err, "stack directory "+filepath.Join(second, "s")+" is not mounted into the agent at the same path (read-write)") }) t.Run("symlink escaping the root", func(t *testing.T) { _, _, err := composeFiles([]string{root}, filepath.Join(root, "evil", "compose.yaml")) assert.EqualError(t, err, "stack directory "+filepath.Join(root, "evil")+" is not mounted into the agent at the same path (read-write)") }) t.Run("second file under a symlink escaping the root", func(t *testing.T) { label := filepath.Join(a, "compose.yaml") + "," + filepath.Join(root, "evil", "extra.yaml") _, _, err := composeFiles([]string{root}, label) assert.ErrorContains(t, err, "not mounted") }) t.Run("prefix trick", func(t *testing.T) { evil := root + "-evil" require.NoError(t, os.MkdirAll(evil, 0o755)) t.Cleanup(func() { os.RemoveAll(evil) }) _, _, err := composeFiles([]string{root}, filepath.Join(evil, "compose.yaml")) assert.ErrorContains(t, err, "not mounted") }) t.Run("a file root allows nothing", func(t *testing.T) { _, _, err := composeFiles([]string{sock}, filepath.Join(sock, "compose.yaml")) assert.Error(t, err) }) }