Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pkg/e2e/SCENARIO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<TestName>/`, 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

Expand Down
31 changes: 31 additions & 0 deletions pkg/e2e/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ package e2e
// test-specific logic), and are named after the observable they assert.

import (
"encoding/json"
"errors"
"fmt"
"os"
Expand Down Expand Up @@ -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{
Expand Down
147 changes: 147 additions & 0 deletions pkg/e2e/remote_git_test.go
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)
Comment thread
ndeloof marked this conversation as resolved.
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(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] git() appends env overrides to os.Environ() — first-match semantics on Linux mean pre-existing CI values shadow the /dev/null overrides

cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", ...) produces a slice where the original env vars come first and the isolation overrides come last. On Linux, glibc's getenv() — which git uses internally — returns the first match when a key appears multiple times. If the CI runner already exports GIT_CONFIG_GLOBAL (e.g. pointing at a signing config), the override at the tail of the slice has no effect: git reads the runner's config and the intended isolation breaks.

The same applies to GIT_AUTHOR_NAME/GIT_COMMITTER_NAME if the CI sets those via standard git env vars.

Fix: filter out the keys you're about to override before appending:

Suggested change
cmd.Env = append(os.Environ(),
env := slices.DeleteFunc(os.Environ(), func(e string) bool {
return strings.HasPrefix(e, "GIT_CONFIG_GLOBAL=") ||
strings.HasPrefix(e, "GIT_CONFIG_SYSTEM=") ||
strings.HasPrefix(e, "GIT_AUTHOR_NAME=") ||
strings.HasPrefix(e, "GIT_AUTHOR_EMAIL=") ||
strings.HasPrefix(e, "GIT_COMMITTER_NAME=") ||
strings.HasPrefix(e, "GIT_COMMITTER_EMAIL=")
})
cmd.Env = append(env,
Confidence Score
🟡 moderate 75/100

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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"))
}
93 changes: 93 additions & 0 deletions pkg/e2e/remote_oci_test.go
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"))
}
24 changes: 22 additions & 2 deletions pkg/e2e/scenario.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ type Scenario struct {
intent string
project string
file string
remote string
rootArgs []string
env []string
parallel bool
start time.Time
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pkg/e2e/testdata/TestGitRemoteBranchSelection/app.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FLAVOR=main
7 changes: 7 additions & 0 deletions pkg/e2e/testdata/TestGitRemoteBranchSelection/compose.yaml
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
1 change: 1 addition & 0 deletions pkg/e2e/testdata/TestGitRemoteSubdir/app.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FLAVOR=root
1 change: 1 addition & 0 deletions pkg/e2e/testdata/TestGitRemoteSubdir/apps/web/app.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FLAVOR=web
7 changes: 7 additions & 0 deletions pkg/e2e/testdata/TestGitRemoteSubdir/apps/web/compose.yaml
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
7 changes: 7 additions & 0 deletions pkg/e2e/testdata/TestGitRemoteSubdir/compose.yaml
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
1 change: 1 addition & 0 deletions pkg/e2e/testdata/TestGitRemoteUp/app.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FLAVOR=main
7 changes: 7 additions & 0 deletions pkg/e2e/testdata/TestGitRemoteUp/compose.yaml
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
1 change: 1 addition & 0 deletions pkg/e2e/testdata/TestOciRemoteTagSelection/app.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FLAVOR=v1
7 changes: 7 additions & 0 deletions pkg/e2e/testdata/TestOciRemoteTagSelection/compose.yaml
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
1 change: 1 addition & 0 deletions pkg/e2e/testdata/TestOciRemoteUp/app.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FLAVOR=published
Loading
Loading