diff --git a/pkg/e2e/SCENARIO.md b/pkg/e2e/SCENARIO.md index 18676b9610..46622cb041 100644 --- a/pkg/e2e/SCENARIO.md +++ b/pkg/e2e/SCENARIO.md @@ -56,6 +56,12 @@ Rules: `testdata` directory no test owns. Interpolate runtime values via `Env`. - **Regression tests link the issue** in a comment above the test, with a sentence on the failure mode being locked. +- **Remote sources**: a scenario consuming its project through a remote + loader (a git URL, an `oci://` artifact) still anchors the content in + `testdata//`, builds the remote from that copy — `serveGitRepo` + serves it over in-process smart HTTP, a `publish` step pushes it to a local + registry — then switches with `FromRemote(source, rootFlags...)`. Steps + before the switch run against the local copy (e.g. the `publish` itself). ## Checks: observe real state diff --git a/pkg/e2e/checks.go b/pkg/e2e/checks.go index 43366c3b28..65a9646cc2 100644 --- a/pkg/e2e/checks.go +++ b/pkg/e2e/checks.go @@ -23,6 +23,7 @@ package e2e // test-specific logic), and are named after the observable they assert. import ( + "encoding/json" "errors" "fmt" "os" @@ -507,6 +508,36 @@ func FileAbsent(path string) Check { } } +// ContainerEnv expects every container of the service to carry the given +// environment variable with the exact value, as recorded in the container +// config — the observable effect of `environment`, `env_file` or provider +// injection, whatever the source of the model. +func ContainerEnv(service, name, value string) Check { + return Check{ + name: fmt.Sprintf("service %q containers have env %s=%s", service, name, value), + fn: func(ctx *CheckContext) error { + containers := ctx.curr.service(service) + if len(containers) == 0 { + return errors.New("service has no container") + } + for _, c := range containers { + res := icmd.RunCmd(ctx.scenario.cli.NewDockerCmd(ctx.scenario.t, "inspect", "--format", "{{json .Config.Env}}", c.ID)) + if res.ExitCode != 0 { + return fmt.Errorf("inspect failed: %s", res.Combined()) + } + var env []string + if err := json.Unmarshal([]byte(strings.TrimSpace(res.Stdout())), &env); err != nil { + return err + } + if !slices.Contains(env, name+"="+value) { + return fmt.Errorf("not in container %s environment: %v", c.Name, env) + } + } + return nil + }, + } +} + // LabelSet expects every container of the service to carry a non-empty label. func LabelSet(service, key string) Check { return Check{ diff --git a/pkg/e2e/remote_git_test.go b/pkg/e2e/remote_git_test.go new file mode 100644 index 0000000000..8057c294b5 --- /dev/null +++ b/pkg/e2e/remote_git_test.go @@ -0,0 +1,147 @@ +//go:build e2e + +/* +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 ( + "net/http/cgi" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// gitRepo is a throwaway git repository served over the smart HTTP protocol +// by an in-process server: `git http-backend` run as a CGI per request, on a +// random port picked by httptest. No daemon process, no container — hermetic +// and port-collision free. The smart protocol is required: compose's git +// loader resolves the ref with ls-remote then shallow-fetches the raw commit, +// which the dumb protocol supports neither of (no shallow capability), and +// fetching a commit by hash needs uploadpack.allowAnySHA1InWant. +type gitRepo struct { + t *testing.T + work string // working tree the fixture content is committed from + bare string // bare repository the server exposes + URL string // smart-HTTP URL of the repository (…/repo.git) +} + +// serveGitRepo commits the content of dir on a `main` branch and serves the +// resulting repository over smart HTTP for the lifetime of the test. +func serveGitRepo(t *testing.T, dir string) *gitRepo { + t.Helper() + gitPath, err := exec.LookPath("git") + if err != nil { + t.Skip("git is not available in PATH") + } + root := t.TempDir() + r := &gitRepo{t: t, work: dir, bare: filepath.Join(root, "repo.git")} + // init then set HEAD explicitly: `git init -b` requires git >= 2.28, + // symbolic-ref names the initial branch on any version + r.git(dir, "init", "-q", ".") + r.git(dir, "symbolic-ref", "HEAD", "refs/heads/main") + r.git(dir, "add", "-A") + r.git(dir, "commit", "-q", "-m", "e2e fixture") + r.git(dir, "clone", "-q", "--bare", ".", r.bare) + r.git(r.bare, "config", "uploadpack.allowAnySHA1InWant", "true") + + server := httptest.NewServer(&cgi.Handler{ + Path: gitPath, + Args: []string{"http-backend"}, + Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}, + }) + t.Cleanup(server.Close) + r.URL = server.URL + "/repo.git" + return r +} + +// Branch publishes a variant of the fixture under a new branch: mutate edits +// the working tree, and the resulting commit is pushed to the served +// repository. +func (r *gitRepo) Branch(name string, mutate func(dir string)) { + r.t.Helper() + r.git(r.work, "checkout", "-q", "-b", name) + mutate(r.work) + r.git(r.work, "add", "-A") + r.git(r.work, "commit", "-q", "-m", "branch "+name) + r.git(r.work, "push", "-q", r.bare, name) + // return to main so each Branch call cuts from the same base + r.git(r.work, "checkout", "-q", "main") +} + +// git runs a git command against a fully isolated configuration: no user or +// system gitconfig (so a developer's signing or hook setup cannot leak into +// the fixture) and a fixed identity. +func (r *gitRepo) git(dir string, args ...string) { + r.t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_AUTHOR_NAME=compose-e2e", + "GIT_AUTHOR_EMAIL=e2e@compose.invalid", + "GIT_COMMITTER_NAME=compose-e2e", + "GIT_COMMITTER_EMAIL=e2e@compose.invalid", + ) + out, err := cmd.CombinedOutput() + if err != nil { + r.t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +func TestGitRemoteUp(t *testing.T) { + s := NewScenario(t, "up on a git remote must deploy the project, resolving its files against the fetched copy") + repo := serveGitRepo(t, s.Dir()) + s.Env("XDG_CACHE_HOME=" + t.TempDir()) + s.FromRemote(repo.URL) + s.Step("up fetches the repository and starts the service", + ComposeCmd("up", "-d", "--wait", "--yes").Within(60*time.Second), + ServiceState("app", "running"), + // the env_file exists only inside the repository: its effect proves + // the relative reference was resolved against the fetched copy + ContainerEnv("app", "FLAVOR", "main")) +} + +func TestGitRemoteBranchSelection(t *testing.T) { + s := NewScenario(t, "a #branch fragment on a git remote must deploy that branch's revision of the project") + repo := serveGitRepo(t, s.Dir()) + repo.Branch("feature", func(dir string) { + if err := os.WriteFile(filepath.Join(dir, "app.env"), []byte("FLAVOR=feature\n"), 0o644); err != nil { + t.Fatal(err) + } + }) + s.Env("XDG_CACHE_HOME=" + t.TempDir()) + s.FromRemote(repo.URL + "#feature") + s.Step("up deploys the feature branch, not the default one", + ComposeCmd("up", "-d", "--wait", "--yes").Within(60*time.Second), + ServiceState("app", "running"), + ContainerEnv("app", "FLAVOR", "feature")) +} + +func TestGitRemoteSubdir(t *testing.T) { + s := NewScenario(t, "a #ref:subdir fragment must load the project from the repository subdirectory, not its root") + repo := serveGitRepo(t, s.Dir()) + s.Env("XDG_CACHE_HOME=" + t.TempDir()) + s.FromRemote(repo.URL + "#main:apps/web") + s.Step("up deploys the subdirectory project, ignoring the decoy at the repository root", + ComposeCmd("up", "-d", "--wait", "--yes").Within(60*time.Second), + ServiceState("app", "running"), + ContainerEnv("app", "FLAVOR", "web")) +} diff --git a/pkg/e2e/remote_oci_test.go b/pkg/e2e/remote_oci_test.go new file mode 100644 index 0000000000..f06c3a2763 --- /dev/null +++ b/pkg/e2e/remote_oci_test.go @@ -0,0 +1,93 @@ +//go:build e2e + +/* +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 ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gotest.tools/v3/poll" +) + +// startLocalRegistry runs a throwaway registry container on a random host +// port for the lifetime of the scenario and returns its host:port address. +func startLocalRegistry(t *testing.T, s *Scenario) string { + t.Helper() + c := s.CLI() + name := s.Project() + "-registry" + c.RunDockerCmd(t, "run", "--name", name, "-P", "-d", "registry:3") + s.Defer(DockerCmd("rm", "--force", name)) + port := c.RunDockerCmd(t, "inspect", "--format", `{{ (index (index .NetworkSettings.Ports "5000/tcp") 0).HostPort }}`, name).Stdout() + registry := "localhost:" + strings.TrimSpace(port) + + registryURL := "http://" + registry + "/v2/" + poll.WaitOn(t, func(l poll.LogT) poll.Result { + resp, err := http.Get(registryURL) //nolint:gosec,noctx + if err != nil { + return poll.Continue("registry not ready: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode < 500 { + return poll.Success() + } + return poll.Continue("registry not ready, status %d", resp.StatusCode) + }, poll.WithTimeout(10*time.Second), poll.WithDelay(500*time.Millisecond)) + return registry +} + +func TestOciRemoteUp(t *testing.T) { + s := NewScenario(t, "up on an oci:// artifact must deploy the published project, bundled env files included") + registry := startLocalRegistry(t, s) + ref := registry + "/remote-up:v1" + s.Env("XDG_CACHE_HOME=" + t.TempDir()) + s.Step("publish pushes the project and its env file to the registry", + ComposeCmd("publish", "--with-env", "--yes", "--insecure-registry", ref)) + s.FromRemote("oci://"+ref, "--insecure-registry", registry) + s.Step("up pulls the artifact and starts the service", + ComposeCmd("up", "-d", "--wait", "--yes").Within(60*time.Second), + ServiceState("app", "running"), + // the env file travels as an artifact layer: its effect proves the + // bundle was consumed whole, not just the compose.yaml + ContainerEnv("app", "FLAVOR", "published")) +} + +func TestOciRemoteTagSelection(t *testing.T) { + s := NewScenario(t, "the tag of an oci:// reference must select which published revision is deployed") + registry := startLocalRegistry(t, s) + refV1 := registry + "/remote-tags:v1" + refV2 := registry + "/remote-tags:v2" + s.Env("XDG_CACHE_HOME=" + t.TempDir()) + s.Step("publish the v1 revision", + ComposeCmd("publish", "--with-env", "--yes", "--insecure-registry", refV1)) + // fixture preparation for the second revision, like a git branch: the + // anchored copy is edited before publishing under the other tag + if err := os.WriteFile(filepath.Join(s.Dir(), "app.env"), []byte("FLAVOR=v2\n"), 0o644); err != nil { + t.Fatal(err) + } + s.Step("publish the v2 revision under another tag", + ComposeCmd("publish", "--with-env", "--yes", "--insecure-registry", refV2)) + s.FromRemote("oci://"+refV1, "--insecure-registry", registry) + s.Step("up on the v1 tag deploys the v1 revision, not the latest published one", + ComposeCmd("up", "-d", "--wait", "--yes").Within(60*time.Second), + ServiceState("app", "running"), + ContainerEnv("app", "FLAVOR", "v1")) +} diff --git a/pkg/e2e/scenario.go b/pkg/e2e/scenario.go index 680f4bf079..3d43752c7e 100644 --- a/pkg/e2e/scenario.go +++ b/pkg/e2e/scenario.go @@ -45,6 +45,8 @@ type Scenario struct { intent string project string file string + remote string + rootArgs []string env []string parallel bool start time.Time @@ -253,6 +255,19 @@ func (s *Scenario) Env(kv ...string) *Scenario { return s } +// FromRemote switches the scenario's compose source to a remote reference +// handled by compose's remote loaders — a git URL or an oci:// artifact — +// with optional extra root flags (e.g. --insecure-registry) inserted before +// the subcommand on every subsequent step. Steps executed before the switch +// still run against the anchored testdata copy, which remains available +// through Dir() as the local content the remote was built from (a repository +// to commit, a project to publish). +func (s *Scenario) FromRemote(source string, rootFlags ...string) *Scenario { + s.remote = source + s.rootArgs = rootFlags + return s +} + // Requires skips the scenario unless every requirement is met by the target // environment. func (s *Scenario) Requires(reqs ...Requirement) *Scenario { @@ -324,10 +339,15 @@ func (s *Scenario) command(action Action) icmd.Cmd { switch action.kind { case kindCompose: args := []string{} - if s.file != "" { - args = append(args, "-f", s.file) + file := s.file + if s.remote != "" { + file = s.remote + } + if file != "" { + args = append(args, "-f", file) } args = append(args, "--project-name", s.project) + args = append(args, s.rootArgs...) args = append(args, action.args...) cmd = s.cli.NewDockerComposeCmd(s.t, args...) case kindDocker: diff --git a/pkg/e2e/testdata/TestGitRemoteBranchSelection/app.env b/pkg/e2e/testdata/TestGitRemoteBranchSelection/app.env new file mode 100644 index 0000000000..fbf5e9b6c2 --- /dev/null +++ b/pkg/e2e/testdata/TestGitRemoteBranchSelection/app.env @@ -0,0 +1 @@ +FLAVOR=main diff --git a/pkg/e2e/testdata/TestGitRemoteBranchSelection/compose.yaml b/pkg/e2e/testdata/TestGitRemoteBranchSelection/compose.yaml new file mode 100644 index 0000000000..ee475f168d --- /dev/null +++ b/pkg/e2e/testdata/TestGitRemoteBranchSelection/compose.yaml @@ -0,0 +1,7 @@ +services: + app: + image: alpine + init: true + command: sleep infinity + env_file: + - ./app.env diff --git a/pkg/e2e/testdata/TestGitRemoteSubdir/app.env b/pkg/e2e/testdata/TestGitRemoteSubdir/app.env new file mode 100644 index 0000000000..0f788a688f --- /dev/null +++ b/pkg/e2e/testdata/TestGitRemoteSubdir/app.env @@ -0,0 +1 @@ +FLAVOR=root diff --git a/pkg/e2e/testdata/TestGitRemoteSubdir/apps/web/app.env b/pkg/e2e/testdata/TestGitRemoteSubdir/apps/web/app.env new file mode 100644 index 0000000000..b2d0ed1c6d --- /dev/null +++ b/pkg/e2e/testdata/TestGitRemoteSubdir/apps/web/app.env @@ -0,0 +1 @@ +FLAVOR=web diff --git a/pkg/e2e/testdata/TestGitRemoteSubdir/apps/web/compose.yaml b/pkg/e2e/testdata/TestGitRemoteSubdir/apps/web/compose.yaml new file mode 100644 index 0000000000..ee475f168d --- /dev/null +++ b/pkg/e2e/testdata/TestGitRemoteSubdir/apps/web/compose.yaml @@ -0,0 +1,7 @@ +services: + app: + image: alpine + init: true + command: sleep infinity + env_file: + - ./app.env diff --git a/pkg/e2e/testdata/TestGitRemoteSubdir/compose.yaml b/pkg/e2e/testdata/TestGitRemoteSubdir/compose.yaml new file mode 100644 index 0000000000..ee475f168d --- /dev/null +++ b/pkg/e2e/testdata/TestGitRemoteSubdir/compose.yaml @@ -0,0 +1,7 @@ +services: + app: + image: alpine + init: true + command: sleep infinity + env_file: + - ./app.env diff --git a/pkg/e2e/testdata/TestGitRemoteUp/app.env b/pkg/e2e/testdata/TestGitRemoteUp/app.env new file mode 100644 index 0000000000..fbf5e9b6c2 --- /dev/null +++ b/pkg/e2e/testdata/TestGitRemoteUp/app.env @@ -0,0 +1 @@ +FLAVOR=main diff --git a/pkg/e2e/testdata/TestGitRemoteUp/compose.yaml b/pkg/e2e/testdata/TestGitRemoteUp/compose.yaml new file mode 100644 index 0000000000..ee475f168d --- /dev/null +++ b/pkg/e2e/testdata/TestGitRemoteUp/compose.yaml @@ -0,0 +1,7 @@ +services: + app: + image: alpine + init: true + command: sleep infinity + env_file: + - ./app.env diff --git a/pkg/e2e/testdata/TestOciRemoteTagSelection/app.env b/pkg/e2e/testdata/TestOciRemoteTagSelection/app.env new file mode 100644 index 0000000000..5ff2abc64f --- /dev/null +++ b/pkg/e2e/testdata/TestOciRemoteTagSelection/app.env @@ -0,0 +1 @@ +FLAVOR=v1 diff --git a/pkg/e2e/testdata/TestOciRemoteTagSelection/compose.yaml b/pkg/e2e/testdata/TestOciRemoteTagSelection/compose.yaml new file mode 100644 index 0000000000..ee475f168d --- /dev/null +++ b/pkg/e2e/testdata/TestOciRemoteTagSelection/compose.yaml @@ -0,0 +1,7 @@ +services: + app: + image: alpine + init: true + command: sleep infinity + env_file: + - ./app.env diff --git a/pkg/e2e/testdata/TestOciRemoteUp/app.env b/pkg/e2e/testdata/TestOciRemoteUp/app.env new file mode 100644 index 0000000000..adfb0cb0a1 --- /dev/null +++ b/pkg/e2e/testdata/TestOciRemoteUp/app.env @@ -0,0 +1 @@ +FLAVOR=published diff --git a/pkg/e2e/testdata/TestOciRemoteUp/compose.yaml b/pkg/e2e/testdata/TestOciRemoteUp/compose.yaml new file mode 100644 index 0000000000..ee475f168d --- /dev/null +++ b/pkg/e2e/testdata/TestOciRemoteUp/compose.yaml @@ -0,0 +1,7 @@ +services: + app: + image: alpine + init: true + command: sleep infinity + env_file: + - ./app.env