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 ec3f3fa6d..000000000 --- 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 8e7311bc9..000000000 --- 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 d93820b76..000000000 --- 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 7741a46f5..33be3a246 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) + s := NewScenario(t, "with DOCKER_DEFAULT_PLATFORM set to a non-native platform, an unchanged create must not recreate", Serial()). + Requires(ContainerdImageStore) - 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) + // `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) + s := NewScenario(t, "two services sharing an image, one platform-pinned, must each keep their own platform's manifest digest", Serial()). + Requires(ContainerdImageStore) - 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.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 49061f766..fa7edcef9 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/scenario.go b/pkg/e2e/scenario.go new file mode 100644 index 000000000..46b1a28a5 --- /dev/null +++ b/pkg/e2e/scenario.go @@ -0,0 +1,564 @@ +/* + 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" + + "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 + 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() { + 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 } + +// 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 +} + +// 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"` + 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, "/"), + 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). +func (s *Scenario) fail(reason error) { + t := s.t + t.Helper() + var b strings.Builder + fmt.Fprintf(&b, "scenario failed: %s\n", s.intent) + fmt.Fprintf(&b, "project: %s\n\ntranscript:\n", s.project) + 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(s.diag("ps", "-a", "--no-trunc", "--filter", "label=com.docker.compose.project="+s.project), 2000)) + fmt.Fprintf(&b, "\n--- engine events since scenario start\n%s\n", truncate(s.diag("events", + "--since", s.start.Format(time.RFC3339Nano), "--until", time.Now().Format(time.RFC3339Nano), + "--filter", "label=com.docker.compose.project="+s.project), 2000)) + for _, containers := range s.snapshot() { + 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()) +} + +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 "" + } +} + +// ---------- Checks ---------- + +// 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 +} + +// 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 + }, + } +} + +// 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/volumes_test.go b/pkg/e2e/volumes_test.go index a9f5d4665..a5c7ee06d 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) {