Compare commits

..
9 changed files with 280 additions and 38 deletions
+8
View File
@@ -105,6 +105,12 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
agent's own jobs always pass it explicitly. agent's own jobs always pass it explicitly.
- **Override entries are never pruned.** A service deleted from the stack file keeps its - **Override entries are never pruned.** A service deleted from the stack file keeps its
`image:` line in `compose.override.yaml`. `image:` line in `compose.override.yaml`.
- **The override file gets the owner of the existing override, else of the stack's first
compose file.** The exception is a root-owned existing override (written before this
fix): then the compose file's owner is used, so it stops being root-owned. The agent runs
as root, so `WriteOverride` chowns the open temp file before the rename (never the path
afterwards: a symlink could be swapped in). A failure is a `warning:` line in the job
output, never a failed job.
- **There is no `STACKS_ROOT`.** Stack directories must be mounted read-write at the host - **There is no `STACKS_ROOT`.** Stack directories must be mounted read-write at the host
path (`/path:/path`), and `SELF_ID` must resolve to the agent's own container. On Docker path (`/path:/path`), and `SELF_ID` must resolve to the agent's own container. On Docker
Desktop/OrbStack bind sources are translated, so Compose jobs fail there. Desktop/OrbStack bind sources are translated, so Compose jobs fail there.
@@ -116,6 +122,8 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/
- **Swarm updates need manager-node API access** for the agent doing the update. - **Swarm updates need manager-node API access** for the agent doing the update.
- **The image's default entrypoint is `updater-agent`.** The master overrides it with - **The image's default entrypoint is `updater-agent`.** The master overrides it with
`updater-master`. `updater-master`.
- **The example stack runs the master as nobody (`user: "65534:65534"`).** Never add `USER`
to the Dockerfile: the agent uses the same image and needs root for the Docker socket.
- **The webhook waits via a broker signal plus a store re-read, with a fallback poll** - **The webhook waits via a broker signal plus a store re-read, with a fallback poll**
(the broker drops signals for slow subscribers). Marking and starting jobs runs on (the broker drops signals for slow subscribers). Marking and starting jobs runs on
`context.WithoutCancel`, so a client that disconnects cannot leave a queued job no agent `context.WithoutCancel`, so a client that disconnects cannot leave a queued job no agent
+10 -5
View File
@@ -28,14 +28,16 @@ a time per service, and one job at a time per agent.
Use [`deploy/master.stack.yml`](deploy/master.stack.yml) as a starting point (Swarm stack; Use [`deploy/master.stack.yml`](deploy/master.stack.yml) as a starting point (Swarm stack;
needs Postgres and an OIDC provider). The master listens for the UI on `PORT` (default needs Postgres and an OIDC provider). The master listens for the UI on `PORT` (default
`8080`, put it behind your reverse proxy) and for agents on `AGENT_PORT` (default `8090`, `8080`, put it behind your reverse proxy) and for agents on `AGENT_PORT` (default `8090`,
pinned TLS, reached directly on the internal network). pinned TLS, reached directly on the internal network). The example runs the master as
`nobody` (`65534`); it needs no root. The agent runs as root because access to the Docker
socket is root on the host anyway.
| Variable | Required | Default | Purpose | | Variable | Required | Default | Purpose |
|---|---|---|---| |---|---|---|---|
| `UPDATER_API_KEY` | no | — | Bearer token for `POST /update`. Without it the webhook answers `503` and the master logs `webhook_disabled`. | | `UPDATER_API_KEY` | no | — | Bearer token for `POST /update`. Without it the webhook answers `503` and the master logs `webhook_disabled`. |
| `UPDATE_TIMEOUT` | no | `5m` | How long one `POST /update` waits for its auto jobs (Go duration). | | `UPDATE_TIMEOUT` | no | `5m` | How long one `POST /update` waits for its auto jobs (Go duration). |
| `POLL_INTERVAL` | no | `6h` | Time between registry polling cycles (Go duration, must be positive). The first cycle runs one minute after start. | | `POLL_INTERVAL` | no | `6h` | Time between registry polling cycles (Go duration, must be positive). The first cycle runs one minute after start. |
| `DOCKER_CONFIG` | no | `~/.docker` | Directory with a `config.json` holding registry credentials for polling. Only inline `auths` entries work; see [Registry polling](#registry-polling). | | `DOCKER_CONFIG` | no | `~/.docker` | Directory with a `config.json` holding registry credentials for polling, readable by the master's user. Only inline `auths` entries work; see [Registry polling](#registry-polling). |
The master's other settings (`DATABASE_URL`, `PUBLIC_URL`, `AGENT_URL`, `OIDC_*`, The master's other settings (`DATABASE_URL`, `PUBLIC_URL`, `AGENT_URL`, `OIDC_*`,
`ALLOWED_GROUP`, `AGENT_IMAGE`, `PORT`, `AGENT_PORT`, `LOG_LEVEL`) are shown in `ALLOWED_GROUP`, `AGENT_IMAGE`, `PORT`, `AGENT_PORT`, `LOG_LEVEL`) are shown in
@@ -146,11 +148,14 @@ multi-arch image the digest is the index digest, the same one `docker inspect` s
tags continue. A later success clears it. tags continue. A later success clears it.
- **Credentials.** The master reads `$DOCKER_CONFIG/config.json` (a file written by - **Credentials.** The master reads `$DOCKER_CONFIG/config.json` (a file written by
`docker login`), mounted read-only into the container; without it registries are accessed `docker login`), mounted read-only into the container; without it registries are accessed
anonymously. Only inline `auths` entries are supported. A `credsStore` or `credHelpers` anonymously. The directory and file must be readable by the master's user (`65534` in the
example stack) without being world-readable: `chown -R 65534:65534` the directory and keep
`0700`/`0600`. Only inline `auths` entries are supported. A `credsStore` or `credHelpers`
entry makes every lookup fail with `error getting credentials ... executable file not found entry makes every lookup fail with `error getting credentials ... executable file not found
in $PATH`, because the container has no credential helper binaries. The master logs in $PATH`, because the container has no credential helper binaries. The master logs
`registry_credentials` at startup with the directory and whether `config.json` exists, `registry_credentials` at startup with the directory, `config_json_found` and
never its contents. `config_json_readable`, never the contents. If the file exists but cannot be opened it also
logs a `registry_credentials_unreadable` warning with the error.
- **Requests go wherever the image name points.** The master sends the lookup to the - **Requests go wherever the image name points.** The master sends the lookup to the
registry host in the image name, including a `GET /v2/` ping that follows redirects. registry host in the image name, including a `GET /v2/` ping that follows redirects.
Anyone who can start containers on a managed host can therefore make the master send Anyone who can start containers on a managed host can therefore make the master send
+22 -8
View File
@@ -48,19 +48,29 @@ const pollFirstDelay = time.Minute
const pollWorkers = 4 const pollWorkers = 4
// registryCredentialsDir returns the directory the registry client reads // registryCredentialsDir returns the directory the registry client reads
// credentials from (DOCKER_CONFIG, else ~/.docker) and whether a config.json // credentials from (DOCKER_CONFIG, else ~/.docker), whether a config.json is
// is in it. Only the path is logged, never the file. // in it, and the error from opening it when it exists but cannot be read (for
func registryCredentialsDir() (dir string, found bool) { // example because the master runs as another user than the file owner). The
// file is opened and closed, never read; only the path is logged.
func registryCredentialsDir() (dir string, found bool, readErr error) {
dir = os.Getenv("DOCKER_CONFIG") dir = os.Getenv("DOCKER_CONFIG")
if dir == "" { if dir == "" {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", false return "", false, nil
} }
dir = filepath.Join(home, ".docker") dir = filepath.Join(home, ".docker")
} }
_, err := os.Stat(filepath.Join(dir, "config.json")) path := filepath.Join(dir, "config.json")
return dir, err == nil if _, err := os.Stat(path); err != nil {
return dir, false, nil
}
f, err := os.Open(path)
if err != nil {
return dir, true, err
}
_ = f.Close()
return dir, true, nil
} }
func run() error { func run() error {
@@ -154,8 +164,12 @@ func run() error {
BaseContext: baseContext, BaseContext: baseContext,
} }
credDir, credFound := registryCredentialsDir() credDir, credFound, credReadErr := registryCredentialsDir()
logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound) logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound,
"config_json_readable", credFound && credReadErr == nil)
if credReadErr != nil {
logger.Warn("registry_credentials_unreadable", "docker_config", credDir, "error", credReadErr)
}
pollCtx, stopPolling := context.WithCancel(ctx) pollCtx, stopPolling := context.WithCancel(ctx)
defer stopPolling() defer stopPolling()
registryPoller := poller.New(st, jobService, registry.New("package-updater-master/"+version, logger), broker, poller.Config{ registryPoller := poller.New(st, jobService, registry.New("package-updater-master/"+version, logger), broker, poller.Config{
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRegistryCredentialsDir(t *testing.T) {
t.Run("missing file", func(t *testing.T) {
dir := t.TempDir()
t.Setenv("DOCKER_CONFIG", dir)
got, found, readErr := registryCredentialsDir()
assert.Equal(t, dir, got)
assert.False(t, found)
assert.NoError(t, readErr)
})
t.Run("readable file", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o600))
t.Setenv("DOCKER_CONFIG", dir)
_, found, readErr := registryCredentialsDir()
assert.True(t, found)
assert.NoError(t, readErr)
})
t.Run("unreadable file", func(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root can read any file")
}
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o000))
t.Setenv("DOCKER_CONFIG", dir)
_, found, readErr := registryCredentialsDir()
assert.True(t, found)
assert.Error(t, readErr)
})
}
+8 -3
View File
@@ -12,12 +12,17 @@
# The reverse proxy must allow a read timeout longer than UPDATE_TIMEOUT (default 5m). # The reverse proxy must allow a read timeout longer than UPDATE_TIMEOUT (default 5m).
# 5. Optional, for registry polling of private images: put a config.json written by # 5. Optional, for registry polling of private images: put a config.json written by
# `docker login` (inline "auths" only; no credsStore) in /path/to/docker-config and # `docker login` (inline "auths" only; no credsStore) in /path/to/docker-config and
# uncomment DOCKER_CONFIG and the volumes entry below. The directory must exist on every # uncomment DOCKER_CONFIG and the volumes entry below. The master runs as 65534:65534, so
# manager node (the master is placed on a manager), or Swarm rejects the task. Without # the directory and config.json must be readable by that user while staying private:
# it registries are accessed anonymously. # `chown -R 65534:65534 /path/to/docker-config` (keep 0700/0600), or set `user:` below
# to the owner's uid:gid. Do not make config.json world-readable; it holds registry
# passwords. The directory must exist on every manager node (the master is placed on a
# manager), or Swarm rejects the task. Without it registries are accessed anonymously.
services: services:
master: master:
image: gitea.shcizo.se/shcizo/package-updater:latest image: gitea.shcizo.se/shcizo/package-updater:latest
# Runs as nobody: the master needs no root (the agent does, for the Docker socket).
user: "65534:65534"
entrypoint: ["/bin/sh", "-c", "DATABASE_URL=$$(cat /run/secrets/pu_database_url) OIDC_CLIENT_SECRET=$$(cat /run/secrets/pu_oidc_client_secret) UPDATER_API_KEY=$$(cat /run/secrets/pu_updater_api_key) exec /usr/local/bin/updater-master"] entrypoint: ["/bin/sh", "-c", "DATABASE_URL=$$(cat /run/secrets/pu_database_url) OIDC_CLIENT_SECRET=$$(cat /run/secrets/pu_oidc_client_secret) UPDATER_API_KEY=$$(cat /run/secrets/pu_updater_api_key) exec /usr/local/bin/updater-master"]
environment: environment:
PUBLIC_URL: https://updater.example.com PUBLIC_URL: https://updater.example.com
+7 -1
View File
@@ -152,9 +152,15 @@ func (e *ComposeExecutor) Execute(ctx context.Context, job protocol.JobStart, ou
} }
fmt.Fprintln(out, resolveNote(job.Ref, pinned)) fmt.Fprintln(out, resolveNote(job.Ref, pinned))
if _, err := WriteOverride(dir, service, pinned); err != nil { // Giving the override the stack's owner is best effort: a failure is only
// a warning, the update itself is fine.
_, ownerErr, err := WriteOverride(dir, service, pinned, files[0])
if err != nil {
return "", err return "", err
} }
if ownerErr != nil {
fmt.Fprintf(out, "warning: could not give the override the stack's owner: %v\n", ownerErr)
}
base := composeBase(project, workingDir, files, envs) base := composeBase(project, workingDir, files, envs)
for _, step := range [][]string{{"pull", service}, {"up", "-d", service}} { for _, step := range [][]string{{"pull", service}, {"up", "-d", service}} {
+14
View File
@@ -69,6 +69,7 @@ func TestComposeExecutor(t *testing.T) {
root, _ := filepath.EvalSymlinks(t.TempDir()) root, _ := filepath.EvalSymlinks(t.TempDir())
stack := filepath.Join(root, "slusk") stack := filepath.Join(root, "slusk")
require.NoError(t, os.MkdirAll(stack, 0o755)) require.NoError(t, os.MkdirAll(stack, 0o755))
composeFile := stackFile(t, stack)
job := protocol.JobStart{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", Ref: "gitea.shcizo.se/shcizo/slusk:1.4"} job := protocol.JobStart{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", Ref: "gitea.shcizo.se/shcizo/slusk:1.4"}
pinned := job.Ref + "@" + d pinned := job.Ref + "@" + d
@@ -107,6 +108,19 @@ func TestComposeExecutor(t *testing.T) {
_, doc := readOverride(t, stack) _, doc := readOverride(t, stack)
assert.Equal(t, pinned, serviceImage(doc, "web")) assert.Equal(t, pinned, serviceImage(doc, "web"))
assert.Contains(t, out.String(), "resolved "+pinned) assert.Contains(t, out.String(), "resolved "+pinned)
assert.NotContains(t, out.String(), "warning")
})
t.Run("an unreadable owner is a warning and the job still succeeds", func(t *testing.T) {
t.Cleanup(cleanOverride)
require.NoError(t, os.Rename(composeFile, composeFile+".bak"))
t.Cleanup(func() { os.Rename(composeFile+".bak", composeFile) })
var out strings.Builder
got, err := setup(fakeCompose{rw, []container.Summary{ct}}, &fakeDist{digest: d}, &recorder{}).Execute(context.Background(), job, &out)
require.NoError(t, err)
assert.Equal(t, pinned, got)
assert.Contains(t, out.String(), "warning: could not give the override the stack's owner: read owner: ")
_, doc := readOverride(t, stack)
assert.Equal(t, pinned, serviceImage(doc, "web"))
}) })
t.Run("a pinned ref is written verbatim and not resolved", func(t *testing.T) { t.Run("a pinned ref is written verbatim and not resolved", func(t *testing.T) {
t.Cleanup(cleanOverride) t.Cleanup(cleanOverride)
+75 -13
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/mount"
@@ -34,15 +35,68 @@ var overrideNames = []string{
// keeping every other entry in the file, and returns the file's path. It // keeping every other entry in the file, and returns the file's path. It
// refuses when the file exists without the header, or when another // refuses when the file exists without the header, or when another
// auto-loaded override file exists in dir. The write is atomic. // auto-loaded override file exists in dir. The write is atomic.
func WriteOverride(dir, service, ref string) (string, error) { //
// composeFile is the stack's first compose file. The new file gets the owner
// of the existing override, or of composeFile when there is none or the
// existing one is owned by root (see pickOwner). The agent runs as root and the
// rename creates a new file, so without this the stack's owner would lose
// control of the file. ownerErr reports that the owner could not be read or
// set; the override is still written. err means it was not written.
func WriteOverride(dir, service, ref, composeFile string) (path string, ownerErr error, err error) {
overridePath := filepath.Join(dir, OverrideName)
return writeOverride(dir, service, ref, func() (fileOwner, error) {
return resolveOwner(overridePath, composeFile)
})
}
// fileOwner is the uid and gid of a file.
type fileOwner struct{ uid, gid int }
// readOwner returns the owner of the file at path.
func readOwner(path string) (fileOwner, error) {
fi, err := os.Stat(path)
if err != nil {
return fileOwner{}, fmt.Errorf("read owner: %w", err)
}
st, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return fileOwner{}, fmt.Errorf("read owner of %s: not available on this platform", path)
}
return fileOwner{int(st.Uid), int(st.Gid)}, nil
}
// pickOwner chooses the owner of a new override: the existing override's
// (nil when there is none), unless that is root, then the compose file's.
// Overrides written before the agent set owners are root-owned and would
// otherwise stay so. compose is only called when needed.
func pickOwner(override *fileOwner, compose func() (fileOwner, error)) (fileOwner, error) {
if override != nil && override.uid != 0 {
return *override, nil
}
return compose()
}
// resolveOwner applies pickOwner to the files on disk.
func resolveOwner(overridePath, composeFile string) (fileOwner, error) {
var existing *fileOwner
if o, err := readOwner(overridePath); err == nil {
existing = &o
} else if !errors.Is(err, os.ErrNotExist) {
return fileOwner{}, err
}
return pickOwner(existing, func() (fileOwner, error) { return readOwner(composeFile) })
}
// writeOverride is WriteOverride with the owner lookup injected, for tests.
func writeOverride(dir, service, ref string, owner func() (fileOwner, error)) (string, error, error) {
for _, name := range overrideNames { for _, name := range overrideNames {
if name == OverrideName { if name == OverrideName {
continue continue
} }
if _, err := os.Lstat(filepath.Join(dir, name)); err == nil { if _, err := os.Lstat(filepath.Join(dir, name)); err == nil {
return "", fmt.Errorf("refused: %s exists; the agent only manages %s and never edits operator files", name, OverrideName) return "", nil, fmt.Errorf("refused: %s exists; the agent only manages %s and never edits operator files", name, OverrideName)
} else if !errors.Is(err, os.ErrNotExist) { } else if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("check %s: %w", name, err) return "", nil, fmt.Errorf("check %s: %w", name, err)
} }
} }
path := filepath.Join(dir, OverrideName) path := filepath.Join(dir, OverrideName)
@@ -51,12 +105,12 @@ func WriteOverride(dir, service, ref string) (string, error) {
switch { switch {
case errors.Is(err, os.ErrNotExist): case errors.Is(err, os.ErrNotExist):
case err != nil: case err != nil:
return "", fmt.Errorf("read override: %w", err) return "", nil, fmt.Errorf("read override: %w", err)
case !bytes.HasPrefix(raw, []byte(overrideHeader)): case !bytes.HasPrefix(raw, []byte(overrideHeader)):
return "", fmt.Errorf("refused: %s exists and was not written by package-updater", path) return "", nil, fmt.Errorf("refused: %s exists and was not written by package-updater", path)
default: default:
if err := yaml.Unmarshal(raw, &doc); err != nil { if err := yaml.Unmarshal(raw, &doc); err != nil {
return "", fmt.Errorf("parse override: %w", err) return "", nil, fmt.Errorf("parse override: %w", err)
} }
if doc == nil { if doc == nil {
doc = map[string]any{} doc = map[string]any{}
@@ -75,30 +129,38 @@ func WriteOverride(dir, service, ref string) (string, error) {
doc["services"] = services doc["services"] = services
body, err := yaml.Marshal(doc) body, err := yaml.Marshal(doc)
if err != nil { if err != nil {
return "", fmt.Errorf("encode override: %w", err) return "", nil, fmt.Errorf("encode override: %w", err)
} }
// Temp file in the same directory + rename: a crash never leaves a // Temp file in the same directory + rename: a crash never leaves a
// half-written override that compose would then load. // half-written override that compose would then load.
tmp, err := os.CreateTemp(dir, "."+OverrideName+".*") tmp, err := os.CreateTemp(dir, "."+OverrideName+".*")
if err != nil { if err != nil {
return "", fmt.Errorf("write override: %w", err) return "", nil, fmt.Errorf("write override: %w", err)
} }
defer os.Remove(tmp.Name()) // no-op after a successful rename defer os.Remove(tmp.Name()) // no-op after a successful rename
if _, err := tmp.WriteString(overrideHeader + string(body)); err != nil { if _, err := tmp.WriteString(overrideHeader + string(body)); err != nil {
tmp.Close() tmp.Close()
return "", fmt.Errorf("write override: %w", err) return "", nil, fmt.Errorf("write override: %w", err)
} }
if err := tmp.Chmod(0o644); err != nil { if err := tmp.Chmod(0o644); err != nil {
tmp.Close() tmp.Close()
return "", fmt.Errorf("write override: %w", err) return "", nil, fmt.Errorf("write override: %w", err)
}
// Chown the open file, not the path after the rename: a path could have
// been swapped for a symlink by someone with write access to the stack.
var ownerErr error
if o, err := owner(); err != nil {
ownerErr = err
} else if err := tmp.Chown(o.uid, o.gid); err != nil {
ownerErr = fmt.Errorf("set owner %d:%d: %w", o.uid, o.gid, err)
} }
if err := tmp.Close(); err != nil { if err := tmp.Close(); err != nil {
return "", fmt.Errorf("write override: %w", err) return "", nil, fmt.Errorf("write override: %w", err)
} }
if err := os.Rename(tmp.Name(), path); err != nil { if err := os.Rename(tmp.Name(), path); err != nil {
return "", fmt.Errorf("write override: %w", err) return "", nil, fmt.Errorf("write override: %w", err)
} }
return path, nil return path, ownerErr, nil
} }
// SelfInspector is the subset of the Docker SDK MountRoots needs. // SelfInspector is the subset of the Docker SDK MountRoots needs.
+94 -8
View File
@@ -29,11 +29,96 @@ func serviceImage(doc map[string]any, name string) any {
return entry["image"] 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) { func TestWriteOverride(t *testing.T) {
t.Run("new file", func(t *testing.T) { t.Run("new file", func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path, err := WriteOverride(dir, "web", "a:1@sha256:aa") path, ownerErr, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
require.NoError(t, err) require.NoError(t, err)
assert.NoError(t, ownerErr)
assert.Equal(t, filepath.Join(dir, OverrideName), path) assert.Equal(t, filepath.Join(dir, OverrideName), path)
raw, doc := readOverride(t, dir) raw, doc := readOverride(t, dir)
assert.True(t, len(raw) > len(overrideHeader) && raw[:len(overrideHeader)] == overrideHeader) assert.True(t, len(raw) > len(overrideHeader) && raw[:len(overrideHeader)] == overrideHeader)
@@ -42,7 +127,7 @@ func TestWriteOverride(t *testing.T) {
t.Run("keeps other services", func(t *testing.T) { t.Run("keeps other services", func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(overrideHeader+"services:\n db:\n image: db:1\n"), 0o644)) 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") _, _, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
require.NoError(t, err) require.NoError(t, err)
_, doc := readOverride(t, dir) _, doc := readOverride(t, dir)
assert.Equal(t, "db:1", serviceImage(doc, "db")) assert.Equal(t, "db:1", serviceImage(doc, "db"))
@@ -51,7 +136,7 @@ func TestWriteOverride(t *testing.T) {
t.Run("replaces own entry", func(t *testing.T) { t.Run("replaces own entry", func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(overrideHeader+"services:\n web:\n image: old\n"), 0o644)) 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") _, _, err := WriteOverride(dir, "web", "a:2@sha256:bb", stackFile(t, dir))
require.NoError(t, err) require.NoError(t, err)
_, doc := readOverride(t, dir) _, doc := readOverride(t, dir)
assert.Equal(t, "a:2@sha256:bb", serviceImage(doc, "web")) assert.Equal(t, "a:2@sha256:bb", serviceImage(doc, "web"))
@@ -60,7 +145,7 @@ func TestWriteOverride(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
const body = "services:\n web:\n environment: [A=1]\n" const body = "services:\n web:\n environment: [A=1]\n"
require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(body), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, OverrideName), []byte(body), 0o644))
_, err := WriteOverride(dir, "web", "a:1@sha256:aa") _, _, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
assert.ErrorContains(t, err, "refused") assert.ErrorContains(t, err, "refused")
got, _ := os.ReadFile(filepath.Join(dir, OverrideName)) got, _ := os.ReadFile(filepath.Join(dir, OverrideName))
assert.Equal(t, body, string(got)) assert.Equal(t, body, string(got))
@@ -69,7 +154,7 @@ func TestWriteOverride(t *testing.T) {
t.Run("refuses next to "+other, func(t *testing.T) { t.Run("refuses next to "+other, func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, other), []byte("services: {}\n"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, other), []byte("services: {}\n"), 0o644))
_, err := WriteOverride(dir, "web", "a:1@sha256:aa") _, _, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
assert.ErrorContains(t, err, other) assert.ErrorContains(t, err, other)
_, statErr := os.Stat(filepath.Join(dir, OverrideName)) _, statErr := os.Stat(filepath.Join(dir, OverrideName))
assert.True(t, errors.Is(statErr, os.ErrNotExist)) assert.True(t, errors.Is(statErr, os.ErrNotExist))
@@ -77,12 +162,13 @@ func TestWriteOverride(t *testing.T) {
} }
t.Run("leaves no temp files", func(t *testing.T) { t.Run("leaves no temp files", func(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
_, err := WriteOverride(dir, "web", "a:1@sha256:aa") _, _, err := WriteOverride(dir, "web", "a:1@sha256:aa", stackFile(t, dir))
require.NoError(t, err) require.NoError(t, err)
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, entries, 1) require.Len(t, entries, 2)
assert.Equal(t, OverrideName, entries[0].Name()) assert.Equal(t, "compose.override.yaml", entries[0].Name())
assert.Equal(t, "compose.yaml", entries[1].Name())
}) })
} }