diff --git a/AGENTS.md b/AGENTS.md index a0f73f6825..e91c95f52a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,15 @@ - Test single: `go test ./pkg/compose/ -run TestFunctionName` - E2E tests: `go test -tags e2e ./pkg/e2e/ -run TestName` +## E2E tests + +- **New e2e tests use the declarative `Scenario` DSL** (`NewScenario` in + `pkg/e2e/scenario.go`): intent, inline compose model, steps as + `(command → expected observables)`. Read `pkg/e2e/SCENARIO.md` before + writing or debugging one — it codifies the rules (state-based checks first, + `OutputContains` as last resort, new checks go in `pkg/e2e/checks.go`) and + how to exploit failure artifacts and `E2E_KEEP_FAILED=1`. + ## Lint - Linter: golangci-lint v2 (config in `.golangci.yml`) diff --git a/go.mod b/go.mod index bd1f292cd7..a4c579195f 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( go.yaml.in/yaml/v4 v4.0.0-rc.6 golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 + golang.org/x/tools v0.47.0 google.golang.org/grpc v1.83.0 gotest.tools/v3 v3.5.2 tags.cncf.io/container-device-interface v1.1.0 diff --git a/pkg/e2e/SCENARIO.md b/pkg/e2e/SCENARIO.md new file mode 100644 index 0000000000..3b5ac24651 --- /dev/null +++ b/pkg/e2e/SCENARIO.md @@ -0,0 +1,107 @@ +# E2E scenarios: the contract + +With coding agents writing most of the production code, e2e tests are the +document humans actually read and review. A scenario must state an intent, a +compose model, and a sequence of steps `(command → expected observables)` — +and nothing else. Everything operational (project naming, cleanup, failure +diagnostics) belongs to the framework, not to the test. + +The DSL lives in [`scenario.go`](scenario.go) (execution, actions, +requirements) and [`checks.go`](checks.go) (the vocabulary of observables). + +## Writing a scenario + +```go +func TestRestart(t *testing.T) { + NewScenario(t, "restart must bring an exited service back up, restarting the same container"). + Compose(` +services: + app: + image: alpine + init: true + command: ash -c "if [[ -f /tmp/restart.lock ]] ; then sleep infinity; else touch /tmp/restart.lock; fi" +`). + Step("up starts the service, whose first run exits at once", + ComposeCmd("up", "-d"), + Eventually(ServiceState("app", "exited"), 10*time.Second)). + Step("restart brings the service back up, reusing the container", + ComposeCmd("restart"), + Eventually(ServiceState("app", "running"), 10*time.Second), + NotRecreated("app")) +} +``` + +Rules: + +- **New e2e tests use `NewScenario`.** The legacy `NewCLI` style remains for + existing tests, converted opportunistically; don't add to it. +- **One intent = one invariant.** The intent is a one-line statement of the + behavior being locked, phrased as an obligation ("X must Y"). If you need + two intents, write two scenarios. +- **Step names are behavior sentences**, not command echoes: "an unchanged + create is a no-op", not "run create again". The transcript of step names + should read as the specification. +- **The compose model is inline.** A scenario is self-contained in the test + source; no fixture directories. Interpolate runtime values via `Env`. When + the project needs more than a `compose.yaml` (a Dockerfile, an env file, a + config file), declare all files with `Files` as a [txtar](https://pkg.go.dev/golang.org/x/tools/txtar) + archive — the format Go's own `cmd/go` tests are written in: diff-friendly, + and one every human and coding agent already knows by heart: + + ```go + s.Files(` + -- compose.yaml -- + services: + app: + build: . + -- Dockerfile -- + FROM alpine + CMD ["sleep", "infinity"] + `) + ``` +- **Regression tests link the issue** in a comment above the test, with a + sentence on the failure mode being locked. + +## Checks: observe real state + +Checks are the shared vocabulary between scenarios; their discipline is what +keeps the contract meaningful. + +- **Prefer state-based checks** (`ServiceState`, `NotRecreated`, `LabelSet`, + `RunsOnPlatform`, …): they observe containers, labels and image manifests — + what the user actually gets — not what the CLI printed. +- **`OutputContains` is a last resort**, legitimate only when the CLI's + reported decision is itself the observable (e.g. "Skipped" vs "Pulled"). +- **Never poll by hand**: wrap a state check in `Eventually(check, timeout)`. + No `time.Sleep` in scenarios. +- **A new check must be generic** — no test-specific logic — **and named + after the observable it asserts**, not after the test that needed it. It + goes in `checks.go`, where the whole vocabulary is reviewed as one file. + Before adding one, verify the observable isn't already expressible. +- A check should also fail loudly on a broken precondition (e.g. + `NotRecreated` errors if the service had no container before the step) + rather than pass vacuously. + +## When a scenario fails + +The report opens with everything needed to diagnose without re-running: + +- **`artifacts: `** — a stable per-project directory holding the + untruncated material: `compose.yaml`, `failure.txt`, each step's full + command and output (`step-NN-*.txt`), `containers.txt`, `events.txt`, full + container logs (`logs-*.txt`) and the per-step state snapshots + (`snapshots.json`). Read these before re-running anything. +- **`E2E_KEEP_FAILED=1`** — rerun with this set to skip teardown of failed + scenarios: containers, volumes and networks stay alive for `docker + inspect`/`exec`. Clean up afterwards with + `docker compose --project-name down -v --remove-orphans`. +- The inline report shows the transcript (every step, exit code, duration), + the failing step's output, project containers, engine events since the + scenario started, and container log tails — truncated for readability; the + artifacts have the full versions. + +Run a single scenario with: + +```sh +go test -tags e2e ./pkg/e2e/ -run TestRestart -v +``` diff --git a/pkg/e2e/build_test.go b/pkg/e2e/build_test.go index d917336ba0..4ae48634c3 100644 --- a/pkg/e2e/build_test.go +++ b/pkg/e2e/build_test.go @@ -673,3 +673,30 @@ func TestBuildEscaped(t *testing.T) { res = c.RunDockerComposeCmd(t, "--project-directory", "./fixtures/build-test/escaped", "build", "--no-cache", "arg") res.Assert(t, icmd.Success) } + +// TestUpBuildUnchangedContext locks the invariant that rebuilding an +// unchanged build context hits the build cache and leaves the running +// service alone: same image, same config hash, same container. +func TestUpBuildUnchangedContext(t *testing.T) { + s := NewScenario(t, "an unchanged up --build must hit the build cache and not recreate the service") + s.Files(` +-- compose.yaml -- +services: + app: + build: . +-- Dockerfile -- +FROM alpine +COPY marker /marker +CMD ["sleep", "infinity"] +-- marker -- +v1 +`). + Defer(DockerCmd("image", "rm", "-f", s.Project()+"-app")). + Step("up builds the image and starts the service", + ComposeCmd("up", "-d", "--build"), + ServiceState("app", "running")). + Step("an unchanged up --build reuses the cached image and the container", + ComposeCmd("up", "-d", "--build"), + NotRecreated("app"), + LabelUnchanged("app", "com.docker.compose.config-hash")) +} diff --git a/pkg/e2e/checks.go b/pkg/e2e/checks.go new file mode 100644 index 0000000000..88a6bb0f8a --- /dev/null +++ b/pkg/e2e/checks.go @@ -0,0 +1,268 @@ +/* + Copyright 2026 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package e2e + +// This file is the scenario vocabulary: the complete set of observables a +// step can expect. It is meant to be read — and reviewed — as a whole; see +// SCENARIO.md for the rules governing its growth. In short: checks observe +// real state (containers, labels, image manifests), are generic (no +// test-specific logic), and are named after the observable they assert. + +import ( + "fmt" + "runtime" + "slices" + "strings" + "time" + + "gotest.tools/v3/icmd" + "gotest.tools/v3/poll" +) + +// CheckContext gives a check access to the step's result and to the project +// state captured before and after the step. +type CheckContext struct { + scenario *Scenario + result *icmd.Result + prev snapshot + curr snapshot +} + +// refresh re-observes the project state, so subsequent checks of the same +// step see the latest state rather than the one captured right after the +// command returned. +func (ctx *CheckContext) refresh() { + s := ctx.scenario + ctx.curr = s.snapshot() + s.snaps[len(s.snaps)-1] = ctx.curr +} + +// Check is a named observable expected to hold after a step. +type Check struct { + name string + fn func(*CheckContext) error +} + +// OutputContains expects the command output to contain a string. Prefer +// state-based checks; use this when the CLI's reported decision is itself +// the observable. +func OutputContains(sub string) Check { + return Check{ + name: fmt.Sprintf("output contains %q", sub), + fn: func(ctx *CheckContext) error { + if !strings.Contains(ctx.result.Combined(), sub) { + return fmt.Errorf("not found in output") + } + return nil + }, + } +} + +// OutputNotContains expects the command output not to contain a string. +func OutputNotContains(sub string) Check { + return Check{ + name: fmt.Sprintf("output does not contain %q", sub), + fn: func(ctx *CheckContext) error { + if strings.Contains(ctx.result.Combined(), sub) { + return fmt.Errorf("found in output") + } + return nil + }, + } +} + +// Eventually retries a state-based check until it holds or the timeout +// expires, re-observing the project state between attempts. Output-based +// checks are not meaningful here: the step's output never changes. +// Polling is delegated to gotest.tools/v3/poll, the same engine the rest of +// the e2e framework uses. +func Eventually(check Check, timeout time.Duration) Check { + return Check{ + name: fmt.Sprintf("%s within %s", check.name, timeout), + fn: func(ctx *CheckContext) error { + first := true + capture := &pollCapture{} + done := make(chan struct{}) + // poll.WaitOn reports timeout through TestingT.Fatalf and relies + // on it halting execution; run it in a goroutine so pollCapture + // can Goexit and hand the error back to the scenario report + // instead of failing the test on the spot. + go func() { + defer close(done) + poll.WaitOn(capture, func(poll.LogT) poll.Result { + if !first { + ctx.refresh() + } + first = false + if err := check.fn(ctx); err != nil { + return poll.Continue("%v", err) + } + return poll.Success() + }, poll.WithDelay(500*time.Millisecond), poll.WithTimeout(timeout)) + }() + <-done + return capture.err + }, + } +} + +// pollCapture is a poll.TestingT that records the failure instead of failing +// the test, so Eventually can feed it to the scenario failure report. +type pollCapture struct { + err error +} + +func (c *pollCapture) Log(args ...any) {} +func (c *pollCapture) Logf(format string, args ...any) {} + +func (c *pollCapture) Fatalf(format string, args ...any) { + c.err = fmt.Errorf(format, args...) + runtime.Goexit() +} + +// ServiceState expects every container of the service to be in the given +// state (running, exited, restarting, …). +func ServiceState(service, state string) Check { + return Check{ + name: fmt.Sprintf("service %q is %s", service, state), + fn: func(ctx *CheckContext) error { + containers := ctx.curr[service] + if len(containers) == 0 { + return fmt.Errorf("service has no container") + } + for _, c := range containers { + if c.State != state { + return fmt.Errorf("container %s is %s", c.Name, c.State) + } + } + return nil + }, + } +} + +// NotRecreated expects the services' containers to be exactly the ones that +// existed before the step (same container IDs). +func NotRecreated(services ...string) Check { + return Check{ + name: fmt.Sprintf("services %s not recreated", strings.Join(services, ", ")), + fn: func(ctx *CheckContext) error { + for _, service := range services { + before, after := containerIDs(ctx.prev[service]), containerIDs(ctx.curr[service]) + if len(before) == 0 { + return fmt.Errorf("service %q had no container before the step", service) + } + if !slices.Equal(before, after) { + return fmt.Errorf("service %q containers changed: %v -> %v", service, before, after) + } + } + return nil + }, + } +} + +func containerIDs(containers []containerState) []string { + ids := make([]string, 0, len(containers)) + for _, c := range containers { + ids = append(ids, c.ID) + } + slices.Sort(ids) + return ids +} + +// LabelSet expects every container of the service to carry a non-empty label. +func LabelSet(service, key string) Check { + return Check{ + name: fmt.Sprintf("service %q has label %q set", service, key), + fn: func(ctx *CheckContext) error { + containers := ctx.curr[service] + if len(containers) == 0 { + return fmt.Errorf("service has no container") + } + for _, c := range containers { + if c.Labels[key] == "" { + return fmt.Errorf("label empty on container %s", c.Name) + } + } + return nil + }, + } +} + +// LabelsDistinct expects the given services to carry pairwise-distinct values +// for a label. +func LabelsDistinct(key string, services ...string) Check { + return Check{ + name: fmt.Sprintf("services %s have distinct %q labels", strings.Join(services, ", "), key), + fn: func(ctx *CheckContext) error { + seen := map[string]string{} + for _, service := range services { + containers := ctx.curr[service] + if len(containers) == 0 { + return fmt.Errorf("service %q has no container", service) + } + value := containers[0].Labels[key] + if other, dup := seen[value]; dup { + return fmt.Errorf("services %q and %q share label value %q", other, service, value) + } + seen[value] = service + } + return nil + }, + } +} + +// LabelUnchanged expects a service's label to have the same value as before +// the step. +func LabelUnchanged(service, key string) Check { + return Check{ + name: fmt.Sprintf("service %q label %q unchanged", service, key), + fn: func(ctx *CheckContext) error { + before, after := ctx.prev[service], ctx.curr[service] + if len(before) == 0 || len(after) == 0 { + return fmt.Errorf("service has no container to compare") + } + if before[0].Labels[key] != after[0].Labels[key] { + return fmt.Errorf("label changed: %q -> %q", before[0].Labels[key], after[0].Labels[key]) + } + return nil + }, + } +} + +// RunsOnPlatform expects the service's container to have been created for +// the given platform (from its image manifest descriptor). +func RunsOnPlatform(service, platform string) Check { + return Check{ + name: fmt.Sprintf("service %q container created for platform %s", service, platform), + fn: func(ctx *CheckContext) error { + containers := ctx.curr[service] + if len(containers) == 0 { + return fmt.Errorf("service has no container") + } + res := icmd.RunCmd(ctx.scenario.cli.NewDockerCmd(ctx.scenario.t, "inspect", "--format", + "{{.ImageManifestDescriptor.Platform.OS}}/{{.ImageManifestDescriptor.Platform.Architecture}}", + containers[0].ID)) + if res.ExitCode != 0 { + return fmt.Errorf("inspect failed: %s", res.Combined()) + } + if actual := strings.TrimSpace(res.Stdout()); actual != platform { + return fmt.Errorf("container platform is %s", actual) + } + return nil + }, + } +} diff --git a/pkg/e2e/fixtures/image-identity/mixed-platforms.yaml b/pkg/e2e/fixtures/image-identity/mixed-platforms.yaml deleted file mode 100644 index ec3f3fa6d5..0000000000 --- a/pkg/e2e/fixtures/image-identity/mixed-platforms.yaml +++ /dev/null @@ -1,8 +0,0 @@ -services: - native: - image: alpine:3.19 - command: ["sleep", "infinity"] - pinned: - image: alpine:3.19 - platform: ${PINNED_PLATFORM} - command: ["sleep", "infinity"] diff --git a/pkg/e2e/fixtures/image-identity/refresh-window.yaml b/pkg/e2e/fixtures/image-identity/refresh-window.yaml deleted file mode 100644 index 8e7311bc9b..0000000000 --- a/pkg/e2e/fixtures/image-identity/refresh-window.yaml +++ /dev/null @@ -1,5 +0,0 @@ -services: - app: - image: alpine:3.18 - pull_policy: daily - command: ["sleep", "infinity"] diff --git a/pkg/e2e/fixtures/multiplatform/compose.yaml b/pkg/e2e/fixtures/multiplatform/compose.yaml deleted file mode 100644 index d93820b76d..0000000000 --- a/pkg/e2e/fixtures/multiplatform/compose.yaml +++ /dev/null @@ -1,5 +0,0 @@ -services: - repro: - image: compose-e2e-multiplatform-local-only:v1 - platform: ${REQUESTED_PLATFORM} - command: ["uname", "-m"] diff --git a/pkg/e2e/image_identity_corner_test.go b/pkg/e2e/image_identity_corner_test.go index 7741a46f53..33be3a2460 100644 --- a/pkg/e2e/image_identity_corner_test.go +++ b/pkg/e2e/image_identity_corner_test.go @@ -17,41 +17,25 @@ package e2e import ( - "fmt" - "strings" "testing" - - "gotest.tools/v3/assert" - "gotest.tools/v3/icmd" ) -// nonNativePlatform returns a linux platform different from the daemon's. -func nonNativePlatform(t *testing.T, c *CLI) string { - t.Helper() - arch := c.RunDockerCmd(t, "info", "--format", "{{.Architecture}}").Stdout() - if strings.Contains(arch, "x86_64") { - return "linux/arm64" - } - return "linux/amd64" -} - // TestUpDryRunMissingImage: the dry-run client fakes the pull, so the // post-pull identity resolution must not inspect the real daemon for an image // that was never actually pulled — that failed with "No such image". func TestUpDryRunMissingImage(t *testing.T) { - c := NewParallelCLI(t) - const projectName = "compose-e2e-identity-dryrun" - const image = "alpine:3.20" - const composeFile = "./fixtures/image-identity/compose.yaml" - - t.Cleanup(func() { - c.RunDockerComposeCmdNoCheck(t, "--project-name", projectName, "down", "--timeout=0") - }) - c.RunDockerOrExitError(t, "rmi", "-f", image) - - res := c.RunDockerComposeCmdNoCheck(t, "--dry-run", "-f", composeFile, "--project-name", projectName, "up", "-d") - res.Assert(t, icmd.Success) - assert.Check(t, !strings.Contains(res.Combined(), "No such image"), res.Combined()) + NewScenario(t, "dry-run up with a locally-missing image must not resolve it against the real daemon"). + Compose(` +services: + app: + image: alpine:3.20 + command: ["sleep", "infinity"] +`). + Step("make sure the image is not in the local store", + DockerCmd("rmi", "-f", "alpine:3.20").MayFail()). + Step("dry-run up succeeds on the faked pull", + ComposeCmd("--dry-run", "up", "-d"), + OutputNotContains("No such image")) } // TestCreateIdempotentDefaultPlatform locks the invariant that with @@ -59,37 +43,27 @@ func TestUpDryRunMissingImage(t *testing.T) { // after the pull and the one recomputed from the local store on the next run // agree, whatever platform each resolution used. func TestCreateIdempotentDefaultPlatform(t *testing.T) { - c := NewCLI(t) - requireContainerdStore(t, c) - - const projectName = "compose-e2e-identity-default-platform" - const image = "alpine:3.20" - const composeFile = "./fixtures/image-identity/compose.yaml" - platform := nonNativePlatform(t, c) - - t.Cleanup(func() { - c.cleanupWithDown(t, projectName) - c.RunDockerOrExitError(t, "rmi", "-f", image) - }) - c.RunDockerOrExitError(t, "rmi", "-f", image) - - create := func() *icmd.Result { - // `create` exercises the same pull/label path as `up` without needing - // emulation to run the non-native binary - cmd := c.NewDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "create") - cmd.Env = append(cmd.Env, "DOCKER_DEFAULT_PLATFORM="+platform) - res := icmd.RunCmd(cmd) - res.Assert(t, icmd.Success) - return res - } - - create() - containerID := c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-app-1", projectName), "-f", "{{.Id}}").Stdout() - - res := create() - assert.Check(t, !strings.Contains(res.Combined(), "Recreate"), "second `create` should not recreate anything, got: %s", res.Combined()) - newContainerID := c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-app-1", projectName), "-f", "{{.Id}}").Stdout() - assert.Equal(t, containerID, newContainerID) + s := NewScenario(t, "with DOCKER_DEFAULT_PLATFORM set to a non-native platform, an unchanged create must not recreate", Serial()). + Requires(ContainerdImageStore) + + // `create` exercises the same pull/label path as `up` without needing + // emulation to run the non-native binary + s.Env("DOCKER_DEFAULT_PLATFORM="+s.NonNativePlatform()). + Compose(` +services: + app: + image: alpine:3.20 + command: ["sleep", "infinity"] +`). + Defer(DockerCmd("rmi", "-f", "alpine:3.20")). + Step("start without the image in the local store", + DockerCmd("rmi", "-f", "alpine:3.20").MayFail()). + Step("create pulls the image and records its identity", + ComposeCmd("create")). + Step("an unchanged create is a no-op", + ComposeCmd("create"), + OutputNotContains("Recreate"), + NotRecreated("app")) } // TestCreateIdempotentSharedImageMixedPlatforms: two services share the same @@ -99,67 +73,53 @@ func TestCreateIdempotentDefaultPlatform(t *testing.T) { // host default platform), and each service must be labeled with its own // platform's manifest digest — idempotently across runs. func TestCreateIdempotentSharedImageMixedPlatforms(t *testing.T) { - c := NewCLI(t) - requireContainerdStore(t, c) - - const projectName = "compose-e2e-identity-mixed-platforms" - const image = "alpine:3.19" - const composeFile = "./fixtures/image-identity/mixed-platforms.yaml" - platform := nonNativePlatform(t, c) - - t.Cleanup(func() { - c.cleanupWithDown(t, projectName) - c.RunDockerOrExitError(t, "rmi", "-f", image) - }) - c.RunDockerOrExitError(t, "rmi", "-f", image) - - create := func() *icmd.Result { - cmd := c.NewDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "create") - cmd.Env = append(cmd.Env, "PINNED_PLATFORM="+platform) - res := icmd.RunCmd(cmd) - res.Assert(t, icmd.Success) - return res - } - - create() - label := func(service string) string { - return strings.TrimSpace(c.RunDockerCmd(t, "inspect", - fmt.Sprintf("%s-%s-1", projectName, service), - "-f", `{{index .Config.Labels "com.docker.compose.image"}}`).Stdout()) - } - nativeDigest, pinnedDigest := label("native"), label("pinned") - assert.Check(t, nativeDigest != "", "native service must be labeled") - assert.Check(t, pinnedDigest != "", "pinned service must be labeled") - assert.Check(t, nativeDigest != pinnedDigest, - "the two services must be labeled with their own platform's manifest digest") - - res := create() - assert.Check(t, !strings.Contains(res.Combined(), "Recreate"), - "second `create` should not recreate anything, got: %s", res.Combined()) - assert.Equal(t, label("native"), nativeDigest) - assert.Equal(t, label("pinned"), pinnedDigest) + s := NewScenario(t, "two services sharing an image, one platform-pinned, must each keep their own platform's manifest digest", Serial()). + Requires(ContainerdImageStore) + + s.Env("PINNED_PLATFORM="+s.NonNativePlatform()). + Compose(` +services: + native: + image: alpine:3.19 + command: ["sleep", "infinity"] + pinned: + image: alpine:3.19 + platform: ${PINNED_PLATFORM} + command: ["sleep", "infinity"] +`). + Defer(DockerCmd("rmi", "-f", "alpine:3.19")). + Step("start without the image in the local store", + DockerCmd("rmi", "-f", "alpine:3.19").MayFail()). + Step("create labels each service with its own platform's manifest digest", + ComposeCmd("create"), + LabelSet("native", "com.docker.compose.image"), + LabelSet("pinned", "com.docker.compose.image"), + LabelsDistinct("com.docker.compose.image", "native", "pinned")). + Step("an unchanged create is a no-op and keeps both digests", + ComposeCmd("create"), + OutputNotContains("Recreate"), + NotRecreated("native", "pinned"), + LabelUnchanged("native", "com.docker.compose.image"), + LabelUnchanged("pinned", "com.docker.compose.image")) } // TestPullRefreshWindowExplicitPull: pull_policy daily/weekly/every_N gates // `up`, but an explicit `compose pull` is the user's way to force a refresh // ahead of the window, so it must pull even when the image is fresh. func TestPullRefreshWindowExplicitPull(t *testing.T) { - c := NewParallelCLI(t) - const projectName = "compose-e2e-identity-refresh-window" - const image = "alpine:3.18" - const composeFile = "./fixtures/image-identity/refresh-window.yaml" - - t.Cleanup(func() { - c.RunDockerComposeCmdNoCheck(t, "--project-name", projectName, "down", "--timeout=0") - c.RunDockerOrExitError(t, "rmi", "-f", image) - }) - - // make the image fresh: the window (daily) is not due - c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "pull") - - res := c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "pull") - assert.Check(t, strings.Contains(res.Combined(), "Pulled"), - "explicit pull must refresh ahead of the window, got: %s", res.Combined()) - assert.Check(t, !strings.Contains(res.Combined(), "Skipped"), - "explicit pull must not skip on the refresh window, got: %s", res.Combined()) + NewScenario(t, "an explicit pull must refresh the image even when the pull_policy window is not due"). + Compose(` +services: + app: + image: alpine:3.18 + pull_policy: daily + command: ["sleep", "infinity"] +`). + Defer(DockerCmd("rmi", "-f", "alpine:3.18")). + Step("make the image fresh: the daily window is not due", + ComposeCmd("pull")). + Step("an explicit pull refreshes ahead of the window", + ComposeCmd("pull"), + OutputContains("Pulled"), + OutputNotContains("Skipped")) } diff --git a/pkg/e2e/multiplatform_test.go b/pkg/e2e/multiplatform_test.go index 49061f7668..fa7edcef93 100644 --- a/pkg/e2e/multiplatform_test.go +++ b/pkg/e2e/multiplatform_test.go @@ -17,11 +17,7 @@ package e2e import ( - "strings" "testing" - - "gotest.tools/v3/assert" - "gotest.tools/v3/icmd" ) func TestCreateLocalMultiPlatformImage(t *testing.T) { @@ -29,49 +25,35 @@ func TestCreateLocalMultiPlatformImage(t *testing.T) { // With the containerd image store, a local multi-platform image holding the // requested non-native variant satisfies the default "missing" pull policy: // compose must use it and not try to pull the (unpublished) tag. - c := NewParallelCLI(t) - - driverStatus := c.RunDockerCmd(t, "info", "--format", "{{json .DriverStatus}}").Stdout() - if !strings.Contains(driverStatus, "io.containerd.snapshotter.v1") { - t.Skip("containerd image store not enabled, can't hold a multi-platform image locally") - } + s := NewScenario(t, "a local multi-platform image must satisfy the default missing pull policy for a non-native platform"). + Requires(ContainerdImageStore) // request the non-native platform, so the requested variant can't be the - // one a platform-less image inspect reports - requested := "linux/amd64" - if arch := c.RunDockerCmd(t, "info", "--format", "{{.Architecture}}").Stdout(); strings.Contains(arch, "x86_64") { - requested = "linux/arm64" - } - - // the tag deliberately doesn't exist on any registry: resolving the - // requested platform from the local image is the only way to succeed + // one a platform-less image inspect reports; the tag deliberately doesn't + // exist on any registry: resolving the requested platform from the local + // image is the only way to succeed + requested := s.NonNativePlatform() const image = "compose-e2e-multiplatform-local-only:v1" - const projectName = "e2e-multiplatform-local" - - cleanup := func() { - c.RunDockerComposeCmdNoCheck(t, "--project-name", projectName, "down", "--timeout=0") - c.RunDockerOrExitError(t, "image", "rm", image) - } - cleanup() - t.Cleanup(cleanup) - - // store both the native and the requested variants under the local-only tag - c.RunDockerCmd(t, "pull", "-q", "--platform", "linux/amd64", "alpine:3.22") - c.RunDockerCmd(t, "pull", "-q", "--platform", "linux/arm64", "alpine:3.22") - c.RunDockerCmd(t, "tag", "alpine:3.22", image) // `create` exercises the same image-resolution/pull-policy path as `up`, // without requiring emulation to actually run the non-native binary - cmd := c.NewDockerComposeCmd(t, "-f", "./fixtures/multiplatform/compose.yaml", - "--project-name", projectName, "create") - cmd.Env = append(cmd.Env, "REQUESTED_PLATFORM="+requested) - res := icmd.RunCmd(cmd) - res.Assert(t, icmd.Success) - assert.Assert(t, !strings.Contains(res.Combined(), "Pulling"), res.Combined()) - - // the created container must be for the requested variant - platform := strings.TrimSpace(c.RunDockerCmd(t, "inspect", "--format", - "{{.ImageManifestDescriptor.Platform.OS}}/{{.ImageManifestDescriptor.Platform.Architecture}}", - projectName+"-repro-1").Stdout()) - assert.Equal(t, requested, platform) + s.Env("REQUESTED_PLATFORM="+requested). + Compose(` +services: + repro: + image: compose-e2e-multiplatform-local-only:v1 + platform: ${REQUESTED_PLATFORM} + command: ["uname", "-m"] +`). + Defer(DockerCmd("image", "rm", image)). + Step("store the amd64 variant in the local store", + DockerCmd("pull", "-q", "--platform", "linux/amd64", "alpine:3.22")). + Step("store the arm64 variant in the local store", + DockerCmd("pull", "-q", "--platform", "linux/arm64", "alpine:3.22")). + Step("tag both variants under a tag that exists on no registry", + DockerCmd("tag", "alpine:3.22", image)). + Step("create uses the local variant for the requested platform without pulling", + ComposeCmd("create"), + OutputNotContains("Pulling"), + RunsOnPlatform("repro", requested)) } diff --git a/pkg/e2e/restart_test.go b/pkg/e2e/restart_test.go index c9df28cb21..7ea2b4bfe2 100644 --- a/pkg/e2e/restart_test.go +++ b/pkg/e2e/restart_test.go @@ -34,34 +34,24 @@ func assertServiceStatus(t *testing.T, projectName, service, status string, ps s } func TestRestart(t *testing.T) { - c := NewParallelCLI(t) - const projectName = "e2e-restart" - - t.Run("Up a project", func(t *testing.T) { - // This is just to ensure the containers do NOT exist - c.RunDockerComposeCmd(t, "--project-name", projectName, "down") - - res := c.RunDockerComposeCmd(t, "-f", "./fixtures/restart-test/compose.yaml", "--project-name", projectName, "up", "-d") - assert.Assert(t, strings.Contains(res.Combined(), "Container e2e-restart-restart-1 Started"), res.Combined()) - - c.WaitForCmdResult(t, c.NewDockerComposeCmd(t, "--project-name", projectName, "ps", "-a", "--format", - "json"), - StdoutContains(`"State":"exited"`), 10*time.Second, 1*time.Second) - - res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "-a") - assertServiceStatus(t, projectName, "restart", "Exited", res.Stdout()) - - c.RunDockerComposeCmd(t, "-f", "./fixtures/restart-test/compose.yaml", "--project-name", projectName, "restart") - - // Give the same time but it must NOT exit - time.Sleep(time.Second) - - res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps") - assertServiceStatus(t, projectName, "restart", "Up", res.Stdout()) - - // Clean up - c.RunDockerComposeCmd(t, "--project-name", projectName, "down") - }) + // the service's first run creates a lock file and exits at once; any + // later run of the same container finds the lock and sleeps forever, so + // staying up after `restart` proves the same container was restarted + NewScenario(t, "restart must bring an exited service back up, restarting the same container"). + Compose(` +services: + app: + image: alpine + init: true + command: ash -c "if [[ -f /tmp/restart.lock ]] ; then sleep infinity; else touch /tmp/restart.lock; fi" +`). + Step("up starts the service, whose first run exits at once", + ComposeCmd("up", "-d"), + Eventually(ServiceState("app", "exited"), 10*time.Second)). + Step("restart brings the service back up, reusing the container", + ComposeCmd("restart"), + Eventually(ServiceState("app", "running"), 10*time.Second), + NotRecreated("app")) } func TestRestartWithDependencies(t *testing.T) { diff --git a/pkg/e2e/scenario.go b/pkg/e2e/scenario.go new file mode 100644 index 0000000000..8734407be8 --- /dev/null +++ b/pkg/e2e/scenario.go @@ -0,0 +1,522 @@ +/* + Copyright 2026 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package e2e + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + "time" + "unicode" + + "golang.org/x/tools/txtar" + "gotest.tools/v3/icmd" +) + +// Scenario is a thin declarative layer over the e2e CLI helpers: a test reads +// as a compose.yaml, a sequence of steps (command + expected observables) and +// nothing else. Project naming, cleanup and failure diagnostics (transcript, +// project state, engine events, container logs) are handled by the framework. +// +// Steps execute eagerly: each Step call runs its command, snapshots the +// project containers and evaluates the checks, failing the test with a full +// scenario report on the first unmet expectation. +type Scenario struct { + t *testing.T + cli *CLI + intent string + project string + file string + env []string + parallel bool + start time.Time + deferred []Action + steps []stepRecord + snaps []snapshot +} + +type stepRecord struct { + name string + command string + result *icmd.Result + duration time.Duration +} + +// containerState is the per-container state captured after each step, used by +// cross-step checks such as NotRecreated or LabelUnchanged. +type containerState struct { + ID string + Name string + State string + Labels map[string]string +} + +// snapshot maps a service name to its containers, sorted by name. +type snapshot map[string][]containerState + +// ScenarioOption customizes a Scenario at creation time. +type ScenarioOption func(*Scenario) + +// Serial disables the default parallel execution, for scenarios that mutate +// shared daemon state (e.g. removing images other tests may pull). +func Serial() ScenarioOption { + return func(s *Scenario) { s.parallel = false } +} + +// NewScenario creates a scenario named after the test, with a unique project +// name, an isolated CLI instance and automatic `down` cleanup registered. +// The intent is a one-line statement of the behavior being locked, displayed +// in logs and failure reports. +func NewScenario(t *testing.T, intent string, opts ...ScenarioOption) *Scenario { + t.Helper() + s := &Scenario{ + t: t, + intent: intent, + parallel: true, + project: projectNameFor(t.Name()), + start: time.Now(), + snaps: []snapshot{{}}, + } + for _, opt := range opts { + opt(s) + } + if s.parallel { + s.cli = NewParallelCLI(t) + } else { + s.cli = NewCLI(t) + } + t.Logf("scenario: %s (project %s)", intent, s.project) + + // start from — and return to — a clean slate, whatever previous runs left + s.cli.RunDockerComposeCmdNoCheck(t, "--project-name", s.project, "down", "-v", "--remove-orphans", "--timeout", "0") + t.Cleanup(func() { + if t.Failed() && os.Getenv("E2E_KEEP_FAILED") != "" { + t.Logf("E2E_KEEP_FAILED set: keeping project %s alive for inspection (docker ps --filter label=com.docker.compose.project=%s)", s.project, s.project) + return + } + s.cli.RunDockerComposeCmdNoCheck(t, "--project-name", s.project, "down", "-v", "--remove-orphans", "--timeout", "0") + for _, action := range s.deferred { + _ = icmd.RunCmd(s.command(action)) + } + }) + return s +} + +// projectNameFor derives a valid, readable compose project name from a test +// name: TestUpDryRunMissingImage -> e2e-up-dry-run-missing-image. +func projectNameFor(testName string) string { + name := strings.TrimPrefix(testName, "Test") + var b strings.Builder + var prev rune + for i, r := range name { + switch { + case unicode.IsUpper(r): + if i > 0 && !unicode.IsUpper(prev) { + b.WriteRune('-') + } + b.WriteRune(unicode.ToLower(r)) + case unicode.IsLower(r) || unicode.IsDigit(r): + b.WriteRune(r) + default: + b.WriteRune('-') + } + prev = r + } + return "e2e-" + strings.Trim(b.String(), "-") +} + +// CLI exposes the underlying CLI instance for the rare setup logic the +// declarative layer doesn't cover. +func (s *Scenario) CLI() *CLI { return s.cli } + +// Project returns the compose project name the scenario runs under, e.g. to +// Defer the removal of an image the project built. +func (s *Scenario) Project() string { return s.project } + +// Compose declares the project's compose model, written to a temporary +// directory so the whole scenario is self-contained in the test source. +func (s *Scenario) Compose(yaml string) *Scenario { + s.t.Helper() + dir := s.t.TempDir() + s.file = filepath.Join(dir, "compose.yaml") + if err := os.WriteFile(s.file, []byte(yaml), 0o644); err != nil { + s.t.Fatalf("failed to write compose.yaml: %v", err) + } + return s +} + +// Files declares the project's files — compose.yaml plus whatever it needs +// (Dockerfile, .env, config files) — as a txtar archive: each file introduced +// by a `-- name --` line, extracted into the project directory. The archive +// must contain a compose.yaml, which becomes the scenario's compose file. +// txtar is the format Go's own cmd/go tests are written in: diff-friendly, +// and trivial to read and write for humans and coding agents alike. +func (s *Scenario) Files(archive string) *Scenario { + s.t.Helper() + dir := s.t.TempDir() + for _, f := range txtar.Parse([]byte(archive)).Files { + path := filepath.Join(dir, f.Name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + s.t.Fatalf("failed to create directory for %s: %v", f.Name, err) + } + if err := os.WriteFile(path, f.Data, 0o644); err != nil { + s.t.Fatalf("failed to write %s: %v", f.Name, err) + } + if f.Name == "compose.yaml" { + s.file = path + } + } + if s.file == "" { + s.t.Fatal("Files archive must contain a compose.yaml") + } + return s +} + +// Env sets environment variables applied to every subsequent step command +// (and interpolated in the compose model). +func (s *Scenario) Env(kv ...string) *Scenario { + s.env = append(s.env, kv...) + return s +} + +// Requires skips the scenario unless every requirement is met by the target +// environment. +func (s *Scenario) Requires(reqs ...Requirement) *Scenario { + s.t.Helper() + for _, req := range reqs { + if reason := req(s.t, s.cli); reason != "" { + s.t.Skip(reason) + } + } + return s +} + +// Defer registers a best-effort cleanup action executed after the project is +// taken down, e.g. removing images the scenario pulled. +func (s *Scenario) Defer(actions ...Action) *Scenario { + s.deferred = append(s.deferred, actions...) + return s +} + +// NonNativePlatform returns a linux platform different from the daemon's, +// for scenarios exercising platform-pinned services without emulation. +func (s *Scenario) NonNativePlatform() string { + s.t.Helper() + arch := s.cli.RunDockerCmd(s.t, "info", "--format", "{{.Architecture}}").Stdout() + if strings.Contains(arch, "x86_64") { + return "linux/arm64" + } + return "linux/amd64" +} + +// Step runs an action and asserts the expected observables. The command must +// succeed unless the action is marked MayFail. On the first unmet +// expectation the scenario fails with a transcript and project diagnostics. +func (s *Scenario) Step(name string, action Action, checks ...Check) *Scenario { + t := s.t + t.Helper() + cmd := s.command(action) + t.Logf("step: %s — %s", name, strings.Join(cmd.Command, " ")) + + begin := time.Now() + res := icmd.RunCmd(cmd) + rec := stepRecord{name: name, command: strings.Join(cmd.Command, " "), result: res, duration: time.Since(begin)} + s.steps = append(s.steps, rec) + + if !action.mayFail && res.ExitCode != 0 { + s.fail(fmt.Errorf("command exited with code %d", res.ExitCode)) + } + + s.snaps = append(s.snaps, s.snapshot()) + ctx := &CheckContext{ + scenario: s, + result: res, + prev: s.snaps[len(s.snaps)-2], + curr: s.snaps[len(s.snaps)-1], + } + for _, check := range checks { + if err := check.fn(ctx); err != nil { + s.fail(fmt.Errorf("expected %s: %w", check.name, err)) + } + } + return s +} + +// command materializes an action into a runnable command, layering scenario +// env then action env on top of the CLI environment. +func (s *Scenario) command(action Action) icmd.Cmd { + s.t.Helper() + var cmd icmd.Cmd + switch action.kind { + case kindCompose: + args := []string{} + if s.file != "" { + args = append(args, "-f", s.file) + } + args = append(args, "--project-name", s.project) + args = append(args, action.args...) + cmd = s.cli.NewDockerComposeCmd(s.t, args...) + case kindDocker: + cmd = s.cli.NewDockerCmd(s.t, action.args...) + } + cmd.Env = append(cmd.Env, s.env...) + cmd.Env = append(cmd.Env, action.env...) + return cmd +} + +// snapshot captures the current state of the project's containers. +func (s *Scenario) snapshot() snapshot { + s.t.Helper() + snap := snapshot{} + ids := s.projectContainerIDs() + if len(ids) == 0 { + return snap + } + res := icmd.RunCmd(s.cli.NewDockerCmd(s.t, append([]string{"inspect"}, ids...)...)) + if res.ExitCode != 0 { + return snap + } + var containers []struct { + ID string `json:"Id"` + Name string `json:"Name"` + State struct { + Status string `json:"Status"` + } `json:"State"` + Config struct { + Labels map[string]string `json:"Labels"` + } `json:"Config"` + } + if err := json.Unmarshal([]byte(res.Stdout()), &containers); err != nil { + return snap + } + for _, c := range containers { + service := c.Config.Labels["com.docker.compose.service"] + snap[service] = append(snap[service], containerState{ + ID: c.ID, + Name: strings.TrimPrefix(c.Name, "/"), + State: c.State.Status, + Labels: c.Config.Labels, + }) + } + for service := range snap { + slices.SortFunc(snap[service], func(a, b containerState) int { return strings.Compare(a.Name, b.Name) }) + } + return snap +} + +func (s *Scenario) projectContainerIDs() []string { + res := icmd.RunCmd(s.cli.NewDockerCmd(s.t, "ps", "-a", "--no-trunc", + "--filter", "label=com.docker.compose.project="+s.project, "--format", "{{.ID}}")) + if res.ExitCode != 0 || strings.TrimSpace(res.Stdout()) == "" { + return nil + } + return Lines(res.Stdout()) +} + +// fail reports the scenario failure: intent, step transcript, output of the +// failing command, then live diagnostics (project state, engine events since +// the scenario started, container logs). Inline sections are truncated for +// readability; the full, untruncated material is written to an artifacts +// directory whose path opens the report. +func (s *Scenario) fail(reason error) { + t := s.t + t.Helper() + + live := s.snapshot() + containersOut := s.diag("ps", "-a", "--no-trunc", "--filter", "label=com.docker.compose.project="+s.project) + eventsOut := s.diag("events", + "--since", s.start.Format(time.RFC3339Nano), "--until", time.Now().Format(time.RFC3339Nano), + "--filter", "label=com.docker.compose.project="+s.project) + artifacts := s.writeArtifacts(reason, live, containersOut, eventsOut) + + var b strings.Builder + fmt.Fprintf(&b, "scenario failed: %s\n", s.intent) + fmt.Fprintf(&b, "project: %s\n", s.project) + if artifacts != "" { + fmt.Fprintf(&b, "artifacts: %s (compose.yaml, full step outputs, events, logs)\n", artifacts) + } + if os.Getenv("E2E_KEEP_FAILED") == "" { + fmt.Fprintf(&b, "hint: rerun with E2E_KEEP_FAILED=1 to keep the project alive for inspection\n") + } + fmt.Fprintf(&b, "\ntranscript:\n") + for i, step := range s.steps { + mark := "✓" + if i == len(s.steps)-1 { + mark = "✗" + } + fmt.Fprintf(&b, " %s %s — %s (exit %d, %s)\n", mark, step.name, step.command, step.result.ExitCode, step.duration.Round(time.Millisecond)) + } + last := s.steps[len(s.steps)-1] + fmt.Fprintf(&b, "\nfailure: %v\n\n--- output of failing step\n%s\n", reason, truncate(last.result.Combined(), 4000)) + + fmt.Fprintf(&b, "\n--- project containers\n%s\n", truncate(containersOut, 2000)) + fmt.Fprintf(&b, "\n--- engine events since scenario start\n%s\n", truncate(eventsOut, 2000)) + for _, containers := range live { + for _, c := range containers { + fmt.Fprintf(&b, "\n--- logs %s\n%s\n", c.Name, truncate(s.diag("logs", "--tail", "30", c.ID), 2000)) + } + } + t.Fatal(b.String()) +} + +// writeArtifacts dumps the untruncated failure material to a stable directory +// (one per project, overwritten on each run) so a failure can be diagnosed — +// by a human or a coding agent — without re-running the scenario: the compose +// model, each step's full command and output, the project containers, the +// engine events, every container's full logs and the per-step state +// snapshots. Returns the directory path, or "" if it could not be written. +func (s *Scenario) writeArtifacts(reason error, live snapshot, containersOut, eventsOut string) string { + dir := filepath.Join(os.TempDir(), "compose-e2e-artifacts", s.project) + if err := os.RemoveAll(dir); err != nil { + return "" + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "" + } + write := func(name, content string) { + _ = os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644) + } + + if s.file != "" { + if data, err := os.ReadFile(s.file); err == nil { + write("compose.yaml", string(data)) + } + } + write("failure.txt", fmt.Sprintf("scenario: %s\nproject: %s\nfailure: %v\n", s.intent, s.project, reason)) + for i, step := range s.steps { + write(fmt.Sprintf("step-%02d-%s.txt", i+1, slugify(step.name)), + fmt.Sprintf("step: %s\ncommand: %s\nexit code: %d\nduration: %s\n\n%s", + step.name, step.command, step.result.ExitCode, step.duration.Round(time.Millisecond), step.result.Combined())) + } + write("containers.txt", containersOut) + write("events.txt", eventsOut) + for _, containers := range live { + for _, c := range containers { + write("logs-"+c.Name+".txt", s.diag("logs", c.ID)) + } + } + if data, err := json.MarshalIndent(s.snaps, "", " "); err == nil { + write("snapshots.json", string(data)) + } + return dir +} + +// slugify turns a free-form step name into a safe file-name fragment. +func slugify(name string) string { + var b strings.Builder + pendingDash := false + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + if pendingDash && b.Len() > 0 { + b.WriteRune('-') + } + pendingDash = false + b.WriteRune(r) + } else { + pendingDash = true + } + } + return b.String() +} + +func (s *Scenario) diag(args ...string) string { + res := icmd.RunCmd(s.cli.NewDockerCmd(s.t, args...)) + return strings.TrimSpace(res.Combined()) +} + +func truncate(out string, limit int) string { + if len(out) <= limit { + return out + } + return out[:limit] + fmt.Sprintf("\n… (%d more bytes)", len(out)-limit) +} + +// ---------- Actions ---------- + +type actionKind int + +const ( + kindCompose actionKind = iota + kindDocker +) + +// Action is a command a step executes: a compose command run against the +// scenario's project, or a raw docker command. +type Action struct { + kind actionKind + args []string + env []string + mayFail bool +} + +// ComposeCmd runs `docker compose ` against the scenario's compose +// file and project name. +func ComposeCmd(args ...string) Action { + return Action{kind: kindCompose, args: args} +} + +// DockerCmd runs a raw `docker ` command. +func DockerCmd(args ...string) Action { + return Action{kind: kindDocker, args: args} +} + +// WithEnv adds environment variables to this action only. +func (a Action) WithEnv(kv ...string) Action { + a.env = append(slices.Clone(a.env), kv...) + return a +} + +// MayFail marks the action as best-effort: a non-zero exit does not fail the +// scenario (e.g. removing an image that may not exist). +func (a Action) MayFail() Action { + a.mayFail = true + return a +} + +// ---------- Requirements ---------- + +// Requirement checks an environment prerequisite; it returns a non-empty +// skip reason when the requirement is not met. +type Requirement func(t testing.TB, c *CLI) string + +// ContainerdImageStore requires the daemon to use the containerd image store. +func ContainerdImageStore(t testing.TB, c *CLI) string { + t.Helper() + res := c.RunDockerCmd(t, "info", "-f", "{{json .DriverStatus}}") + if !strings.Contains(res.Stdout(), "io.containerd.snapshotter.v1") { + return "daemon is not using the containerd image store" + } + return "" +} + +// EngineVersionAtLeast requires a minimum daemon major version. +func EngineVersionAtLeast(major int) Requirement { + return func(t testing.TB, c *CLI) string { + t.Helper() + version := c.RunDockerCmd(t, "version", "-f", "{{.Server.Version}}").Combined() + before, _, _ := strings.Cut(strings.TrimSpace(version), ".") + if v, err := strconv.Atoi(before); err == nil && v < major { + return fmt.Sprintf("engine version %s < %d", strings.TrimSpace(version), major) + } + return "" + } +} diff --git a/pkg/e2e/volumes_test.go b/pkg/e2e/volumes_test.go index a9f5d46652..a5c7ee06d2 100644 --- a/pkg/e2e/volumes_test.go +++ b/pkg/e2e/volumes_test.go @@ -198,28 +198,29 @@ func TestImageVolumeImageAlreadyLocal(t *testing.T) { // digest the daemon can't resolve as a mount source under the containerd // image store ("No such image"). TestImageVolume only covers this path by // accident, when a previous test left the image in the local store. - c := NewCLI(t) - const projectName = "compose-e2e-image-volume-local" - t.Cleanup(func() { - c.cleanupWithDown(t, projectName) - }) - - version := c.RunDockerCmd(t, "version", "-f", "{{.Server.Version}}") - major, _, found := strings.Cut(version.Combined(), ".") - assert.Assert(t, found) - if major == "26" || major == "27" { - t.Skip("Skipping test due to docker version < 28") - } - - // make sure the source image is already in the local store - c.RunDockerCmd(t, "pull", "-q", "nginx:alpine") - - res := c.RunDockerComposeCmd(t, "-f", "./fixtures/volumes/compose.yaml", "--project-name", projectName, "up", "with_image") - assert.Check(t, strings.Contains(res.Combined(), "index.html"), res.Combined()) - - // the unchanged service must not be recreated by a second up - res = c.RunDockerComposeCmd(t, "-f", "./fixtures/volumes/compose.yaml", "--project-name", projectName, "up", "with_image") - assert.Check(t, !strings.Contains(res.Combined(), "Recreate"), res.Combined()) + NewScenario(t, "an image volume whose source image is already local must mount, and stay idempotent", Serial()). + Requires(EngineVersionAtLeast(28)). + Compose(` +services: + app: + image: alpine + command: "ls -al /mnt/image" + volumes: + - type: image + source: nginx:alpine + target: /mnt/image + image: + subpath: usr/share/nginx/html/ +`). + Step("have the source image already in the local store", + DockerCmd("pull", "-q", "nginx:alpine")). + Step("up mounts the image volume content", + ComposeCmd("up", "app"), + OutputContains("index.html")). + Step("an unchanged up does not recreate the service", + ComposeCmd("up", "app"), + OutputNotContains("Recreate"), + NotRecreated("app")) } func TestImageVolumeRecreateOnRebuild(t *testing.T) {