package updater import ( "bytes" "context" "errors" "io" "os" "path/filepath" "strings" "testing" "time" "unicode/utf8" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" "github.com/docker/docker/pkg/stdcopy" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "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 } // fakeCompose is not meant to start helper containers; these fail the test // run if an executor tries. func (fakeCompose) ContainerCreate(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, *ocispec.Platform, string) (container.CreateResponse, error) { panic("unexpected ContainerCreate") } func (fakeCompose) ContainerStart(context.Context, string, container.StartOptions) error { panic("unexpected ContainerStart") } func (fakeCompose) ContainerWait(context.Context, string, container.WaitCondition) (<-chan container.WaitResponse, <-chan error) { panic("unexpected ContainerWait") } func (fakeCompose) ContainerLogs(context.Context, string, container.LogsOptions) (io.ReadCloser, error) { panic("unexpected ContainerLogs") } func (fakeCompose) ContainerRemove(context.Context, string, container.RemoveOptions) error { panic("unexpected ContainerRemove") } // createdHelper is what a fakeHelper was asked to create. type createdHelper struct { cfg *container.Config host *container.HostConfig } // fakeHelper is a helper container the test controls. Its logs are framed // like Docker's multiplexed stream. type fakeHelper struct { createErr error exit int64 hold bool // ContainerWait never reports an exit logs string // what the helper printed onStart func() state container.ContainerState // what an inspect reports; exited when empty created []createdHelper started []string removed []string waitCond container.WaitCondition } func (h *fakeHelper) ContainerCreate(_ context.Context, cfg *container.Config, host *container.HostConfig, _ *network.NetworkingConfig, _ *ocispec.Platform, _ string) (container.CreateResponse, error) { if h.createErr != nil { return container.CreateResponse{}, h.createErr } h.created = append(h.created, createdHelper{cfg, host}) return container.CreateResponse{ID: "helper1"}, nil } func (h *fakeHelper) ContainerInspect(context.Context, string) (container.InspectResponse, error) { state := h.state if state == "" { state = container.StateExited } return container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{State: &container.State{Status: state}}}, nil } func (h *fakeHelper) ContainerStart(_ context.Context, id string, _ container.StartOptions) error { h.started = append(h.started, id) if h.onStart != nil { h.onStart() } return nil } func (h *fakeHelper) ContainerWait(_ context.Context, _ string, cond container.WaitCondition) (<-chan container.WaitResponse, <-chan error) { h.waitCond = cond res, errs := make(chan container.WaitResponse, 1), make(chan error, 1) if !h.hold { res <- container.WaitResponse{StatusCode: h.exit} } return res, errs } func (h *fakeHelper) ContainerLogs(context.Context, string, container.LogsOptions) (io.ReadCloser, error) { var buf bytes.Buffer stdcopy.NewStdWriter(&buf, stdcopy.Stdout).Write([]byte(h.logs)) return io.NopCloser(&buf), nil } func (h *fakeHelper) ContainerRemove(_ context.Context, id string, opts container.RemoveOptions) error { if !opts.Force { return errors.New("remove without force") } h.removed = append(h.removed, id) return nil } // fakeHelperCompose is a fakeCompose whose helper containers are h. type fakeHelperCompose struct { fakeCompose h *fakeHelper } // ContainerInspect answers for the agent's own container ("me") like fakeCompose // and for any other ID like the helper. func (f fakeHelperCompose) ContainerInspect(ctx context.Context, id string) (container.InspectResponse, error) { if id == "me" { return f.fakeCompose.ContainerInspect(ctx, id) } return f.h.ContainerInspect(ctx, id) } func (f fakeHelperCompose) ContainerCreate(ctx context.Context, cfg *container.Config, host *container.HostConfig, n *network.NetworkingConfig, p *ocispec.Platform, name string) (container.CreateResponse, error) { return f.h.ContainerCreate(ctx, cfg, host, n, p, name) } func (f fakeHelperCompose) ContainerStart(ctx context.Context, id string, o container.StartOptions) error { return f.h.ContainerStart(ctx, id, o) } func (f fakeHelperCompose) ContainerWait(ctx context.Context, id string, c container.WaitCondition) (<-chan container.WaitResponse, <-chan error) { return f.h.ContainerWait(ctx, id, c) } func (f fakeHelperCompose) ContainerLogs(ctx context.Context, id string, o container.LogsOptions) (io.ReadCloser, error) { return f.h.ContainerLogs(ctx, id, o) } func (f fakeHelperCompose) ContainerRemove(ctx context.Context, id string, o container.RemoveOptions) error { return f.h.ContainerRemove(ctx, id, o) } // 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)) composeFile := stackFile(t, stack) job := Job{ID: "7", Started: time.Date(2026, 10, 13, 9, 0, 0, 0, time.UTC), JobStart: 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) 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.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") } func TestComposeSelfUpdate(t *testing.T) { const d = "sha256:d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1d1" root, _ := filepath.EvalSymlinks(t.TempDir()) stack := filepath.Join(root, "updater") require.NoError(t, os.MkdirAll(stack, 0o755)) stackFile(t, stack) job := Job{ID: "7", Started: time.Date(2026, 10, 13, 9, 0, 0, 0, time.UTC), JobStart: protocol.JobStart{ Kind: protocol.KindCompose, ComposeProject: "updater", ComposeService: "agent", Ref: "gitea.shcizo.se/shcizo/package-updater:1.4", Self: true}} pinned := job.Ref + "@" + d overridePath := filepath.Join(stack, OverrideName) t.Cleanup(func() { os.Remove(overridePath) }) selfCt := container.Summary{ID: "selfid", Labels: map[string]string{ labelWorkingDir: stack, labelConfigFiles: filepath.Join(stack, "compose.yaml"), }} mounts := []container.MountPoint{ {Type: mount.TypeBind, Source: root, Destination: root, RW: true}, {Type: mount.TypeBind, Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock", RW: true}, {Type: mount.TypeBind, Source: "/root/.docker/config.json", Destination: "/docker-config/config.json", RW: false}, {Type: mount.TypeVolume, Name: "agent-data", Source: "/var/lib/docker/volumes/agent-data/_data", Destination: "/data", RW: true}, {Type: mount.TypeTmpfs, Destination: "/tmp", RW: true}, } self := fakeSelf{id: "selfid", image: "sha256:agentimage", mounts: mounts} wantMounts := []mount.Mount{ {Type: mount.TypeBind, Source: root, Target: root}, {Type: mount.TypeBind, Source: "/var/run/docker.sock", Target: "/var/run/docker.sock"}, {Type: mount.TypeBind, Source: "/root/.docker/config.json", Target: "/docker-config/config.json", ReadOnly: true}, {Type: mount.TypeVolume, Source: "agent-data", Target: "/data"}, } base := []string{"compose", "-p", "updater", "--project-directory", stack, "-f", filepath.Join(stack, "compose.yaml"), "-f", overridePath} withStep := func(step ...string) []string { return append(append([]string(nil), base...), step...) } t.Setenv("PATH", "/usr/bin") t.Setenv("DOCKER_CONFIG", "/docker-config") t.Setenv("AGENT_TOKEN", "secret") cases := []struct { name string helper *fakeHelper cancelled bool // ctx is cancelled when the helper starts wantErr string wantCancel bool wantRemoved []string wantOut []string }{ {name: "helper succeeds", helper: &fakeHelper{logs: "Container updater-agent-1 Recreated\n"}, wantRemoved: []string{"helper1"}, wantOut: []string{"Container updater-agent-1 Recreated"}}, {name: "helper fails", helper: &fakeHelper{exit: 1, logs: "network not found\n"}, wantErr: "docker compose up: exit status 1 (output: network not found)", wantRemoved: []string{"helper1"}, wantOut: []string{"network not found"}}, {name: "ctx ends while waiting", helper: &fakeHelper{hold: true}, cancelled: true, wantCancel: true}, {name: "create fails", helper: &fakeHelper{createErr: errors.New("no such image")}, wantErr: "create update helper: no such image"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { t.Cleanup(func() { os.Remove(overridePath) }) ctx, cancel := context.WithCancel(context.Background()) defer cancel() if c.cancelled { c.helper.onStart = cancel } rec := &recorder{} cli := fakeHelperCompose{fakeCompose{self, []container.Summary{selfCt}}, c.helper} e := NewComposeExecutor(cli, NewResolver(&fakeDist{digest: d}, nil), "me", rec.run, (&outRunner{json: `{"services":{"agent":{"image":"x"}}}`}).run) var out strings.Builder got, err := e.Execute(ctx, job, &out) require.Len(t, rec.cmds, 1, "pull ran, up did not") assert.Equal(t, ranCmd{stack, withStep("pull", "agent")}, rec.cmds[0]) assert.Equal(t, c.wantRemoved, c.helper.removed) for _, s := range c.wantOut { assert.Contains(t, out.String(), s) } switch { case c.wantCancel: assert.ErrorIs(t, err, context.Canceled) case c.wantErr != "": assert.EqualError(t, err, c.wantErr) assert.Empty(t, got) default: require.NoError(t, err) assert.Equal(t, pinned, got) } if len(c.helper.created) == 0 { return } cfg, host := c.helper.created[0].cfg, c.helper.created[0].host assert.Equal(t, "sha256:agentimage", cfg.Image) assert.Equal(t, []string{"docker"}, []string(cfg.Entrypoint)) assert.Equal(t, withStep("up", "-d", "agent"), []string(cfg.Cmd)) assert.Equal(t, stack, cfg.WorkingDir) assert.Equal(t, dockerEnv(), cfg.Env) assert.Equal(t, wantMounts, host.Mounts) assert.False(t, host.AutoRemove) assert.Equal(t, container.RestartPolicy{}, host.RestartPolicy) assert.Equal(t, []string{"helper1"}, c.helper.started) assert.Equal(t, container.WaitConditionNextExit, c.helper.waitCond) labels := cfg.Labels assert.Len(t, labels, 6, "no compose labels") assert.Equal(t, "7", labels[labelSelfUpdateJob]) assert.Equal(t, pinned, labels[labelSelfUpdateRef]) assert.Equal(t, "2026-10-13T09:00:00Z", labels[labelSelfUpdateStarted]) assert.Equal(t, "updater", labels[labelSelfUpdateProject]) assert.Equal(t, "agent", labels[labelSelfUpdateService]) assert.Contains(t, labels[labelSelfUpdateOutput], "resolved "+pinned) assert.Contains(t, labels[labelSelfUpdateOutput], "$ docker "+strings.Join(withStep("pull", "agent"), " ")) }) } t.Run("a service that is not the agent's own never uses a helper", func(t *testing.T) { t.Cleanup(func() { os.Remove(overridePath) }) other := self other.id = "otherid" // fakeCompose panics on ContainerCreate rec := &recorder{} e := NewComposeExecutor(fakeCompose{other, []container.Summary{selfCt}}, NewResolver(&fakeDist{digest: d}, nil), "me", rec.run, (&outRunner{json: `{"services":{"agent":{"image":"x"}}}`}).run) got, err := e.Execute(context.Background(), job, io.Discard) require.NoError(t, err) assert.Equal(t, pinned, got) require.Len(t, rec.cmds, 2) assert.Equal(t, withStep("up", "-d", "agent"), rec.cmds[1].args) }) t.Run("output label keeps the tail and says it was cut", func(t *testing.T) { b := &tailBuffer{max: 4} b.Write([]byte("abcdef")) assert.Equal(t, truncatedMarker+"cdef", outputLabel(b)) b2 := &tailBuffer{max: 3} b2.Write([]byte("aéé")) // the cut falls inside a character assert.True(t, utf8.ValidString(outputLabel(b2))) }) }