This commit was merged in pull request #32.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -8,9 +9,14 @@ import (
|
||||
"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"
|
||||
|
||||
@@ -26,6 +32,123 @@ func (f fakeCompose) ContainerList(context.Context, container.ListOptions) ([]co
|
||||
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
|
||||
@@ -70,7 +193,7 @@ func TestComposeExecutor(t *testing.T) {
|
||||
stack := filepath.Join(root, "slusk")
|
||||
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 := 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{
|
||||
@@ -319,3 +442,139 @@ func TestExecDocker_UsesMinimalEnv(t *testing.T) {
|
||||
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)))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user