Files
package-updater/internal/agent/updater/override_test.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)
})
}