Files
package-updater/internal/agent/updater/compose_test.go
T

564 lines
24 KiB
Go

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()
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) 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
}
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[protocol.LabelSelfUpdateJob])
assert.Equal(t, pinned, labels[protocol.LabelSelfUpdateRef])
assert.Equal(t, "2026-10-13T09:00:00Z", labels[protocol.LabelSelfUpdateStarted])
assert.Equal(t, "updater", labels[protocol.LabelSelfUpdateProject])
assert.Equal(t, "agent", labels[protocol.LabelSelfUpdateService])
assert.Contains(t, labels[protocol.LabelSelfUpdateOutput], "resolved "+pinned)
assert.Contains(t, labels[protocol.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)))
})
}