301 lines
12 KiB
Go
301 lines
12 KiB
Go
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"]
|
|
}
|
|
|
|
// stackFile creates dir/compose.yaml and returns its path.
|
|
func stackFile(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
p := filepath.Join(dir, "compose.yaml")
|
|
require.NoError(t, os.WriteFile(p, []byte("services: {}\n"), 0o644))
|
|
return p
|
|
}
|
|
|
|
func TestPickOwner(t *testing.T) {
|
|
compose := fileOwner{1000, 1000}
|
|
fromCompose := func() (fileOwner, error) { return compose, nil }
|
|
failing := func() (fileOwner, error) { return fileOwner{}, errors.New("boom") }
|
|
tests := []struct {
|
|
name string
|
|
override *fileOwner
|
|
compose func() (fileOwner, error)
|
|
want fileOwner
|
|
wantErr bool
|
|
}{
|
|
{"no override uses the compose file", nil, fromCompose, compose, false},
|
|
{"override owned by a user wins", &fileOwner{1001, 1002}, fromCompose, fileOwner{1001, 1002}, false},
|
|
{"root-owned override uses the compose file", &fileOwner{0, 0}, fromCompose, compose, false},
|
|
{"compose file is not read when the override decides", &fileOwner{1001, 1002}, failing, fileOwner{1001, 1002}, false},
|
|
{"unreadable compose file is an error", nil, failing, fileOwner{}, true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := pickOwner(tt.override, tt.compose)
|
|
assert.Equal(t, tt.wantErr, err != nil)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveOwner(t *testing.T) {
|
|
me := fileOwner{os.Getuid(), os.Getgid()}
|
|
t.Run("no override uses the compose file", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
got, err := resolveOwner(filepath.Join(dir, OverrideName), stackFile(t, dir))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, me, got)
|
|
})
|
|
t.Run("existing override is used without the compose file", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), nil, 0o644))
|
|
got, err := resolveOwner(filepath.Join(dir, OverrideName), filepath.Join(dir, "missing.yaml"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, me, got)
|
|
})
|
|
t.Run("neither file is an error", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
_, err := resolveOwner(filepath.Join(dir, OverrideName), filepath.Join(dir, "missing.yaml"))
|
|
assert.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestWriteOverride_Owner(t *testing.T) {
|
|
t.Run("success sets no ownerErr", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
_, ownerErr, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
|
|
require.NoError(t, err)
|
|
assert.NoError(t, ownerErr)
|
|
})
|
|
t.Run("unreadable owner is reported and the file is still written", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
_, ownerErr, err := WriteOverride(dir, "web", "a:1@sha256:aa", filepath.Join(dir, "missing.yaml"))
|
|
require.NoError(t, err)
|
|
assert.ErrorContains(t, ownerErr, "read owner")
|
|
_, doc := readOverride(t, dir)
|
|
assert.Equal(t, "a:1@sha256:aa", serviceImage(doc, "web"))
|
|
})
|
|
t.Run("chown failure is reported and the file is still written", func(t *testing.T) {
|
|
if os.Getuid() == 0 {
|
|
t.Skip("root can chown to anyone")
|
|
}
|
|
dir := t.TempDir()
|
|
_, ownerErr, err := writeOverride(dir, "web", "a:1@sha256:aa", func() (fileOwner, error) { return fileOwner{0, 0}, nil })
|
|
require.NoError(t, err)
|
|
assert.ErrorContains(t, ownerErr, "set owner 0:0")
|
|
_, doc := readOverride(t, dir)
|
|
assert.Equal(t, "a:1@sha256:aa", serviceImage(doc, "web"))
|
|
})
|
|
}
|
|
|
|
func TestWriteOverride(t *testing.T) {
|
|
t.Run("new file", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path, ownerErr, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
|
|
require.NoError(t, err)
|
|
assert.NoError(t, ownerErr)
|
|
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", stackFile(t, dir))
|
|
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", stackFile(t, dir))
|
|
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", stackFile(t, dir))
|
|
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", stackFile(t, dir))
|
|
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", stackFile(t, dir))
|
|
require.NoError(t, err)
|
|
entries, err := os.ReadDir(dir)
|
|
require.NoError(t, err)
|
|
require.Len(t, entries, 2)
|
|
assert.Equal(t, "compose.override.yaml", entries[0].Name())
|
|
assert.Equal(t, "compose.yaml", entries[1].Name())
|
|
})
|
|
}
|
|
|
|
type fakeSelf struct {
|
|
mounts []container.MountPoint
|
|
err error
|
|
id string // the container's ID, when set
|
|
image string // the container's image ID, when set
|
|
labels map[string]string // the container's labels, when set
|
|
}
|
|
|
|
func (f fakeSelf) ContainerInspect(context.Context, string) (container.InspectResponse, error) {
|
|
insp := container.InspectResponse{Mounts: f.mounts}
|
|
if f.id != "" || f.image != "" {
|
|
insp.ContainerJSONBase = &container.ContainerJSONBase{ID: f.id, Image: f.image}
|
|
}
|
|
if f.labels != nil {
|
|
insp.Config = &container.Config{Labels: f.labels}
|
|
}
|
|
return insp, 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)
|
|
})
|
|
}
|