-
Notifications
You must be signed in to change notification settings - Fork 5.8k
e2e coverage for git and OCI remote stacks #14226
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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(), | ||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [medium]
The same applies to Fix: filter out the keys you're about to override before appending:
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not applicable to processes spawned through os/exec: exec.Cmd dedupes the environment before starting the child, keeping the LATER entry (dedupEnv, os/exec/exec.go: 'removed, in favor of later values'). Verified empirically: with GIT_CONFIG_GLOBAL=/ci/injected/config exported in the parent and cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null"), the child observes /dev/null. glibc first-match semantics never come into play because the duplicate never reaches the child's environment block. Keeping the idiomatic append. |
||||||||||||||||||||||||||
| "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")) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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")) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| FLAVOR=main |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| services: | ||
| app: | ||
| image: alpine | ||
| init: true | ||
| command: sleep infinity | ||
| env_file: | ||
| - ./app.env |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| FLAVOR=root |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| FLAVOR=web |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| services: | ||
| app: | ||
| image: alpine | ||
| init: true | ||
| command: sleep infinity | ||
| env_file: | ||
| - ./app.env |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| services: | ||
| app: | ||
| image: alpine | ||
| init: true | ||
| command: sleep infinity | ||
| env_file: | ||
| - ./app.env |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| FLAVOR=main |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| services: | ||
| app: | ||
| image: alpine | ||
| init: true | ||
| command: sleep infinity | ||
| env_file: | ||
| - ./app.env |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| FLAVOR=v1 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| services: | ||
| app: | ||
| image: alpine | ||
| init: true | ||
| command: sleep infinity | ||
| env_file: | ||
| - ./app.env |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| FLAVOR=published |
Uh oh!
There was an error while loading. Please reload this page.