test(e2e): reproduce --project-directory ignored on an oci:// project

TestOciRemoteProjectDirectory publishes a project whose only service
mounts a relative volume, then runs `up` on the oci:// artifact with an
explicit --project-directory pointing elsewhere. The relative volume
must resolve against that directory.

It currently fails against the pinned compose-go: LoadConfigFiles
defaults the working dir to the downloaded artifact's own cache
directory whenever a remote resource loader is involved, silently
overriding the explicit --project-directory the same way it would a
mere default -- docker/compose#14224. On Docker Desktop this surfaces
as a hard failure (the cache directory isn't a shared mount); on Linux
it silently mounts the wrong directory, matching the original report.

New BindMountSource check (pkg/e2e/checks.go) pins a service's bind
mount source to an exact expected path, for tests that need to verify
which working directory a relative volume path resolved against.

Needs the compose-go fix (github.com/ndeloof/compose-go@a626c70,
branch 14224-working-dir) to pass -- not yet reflected in go.mod here.
Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-09-23 10:56:23 +02:00
committed by Guillaume Lours
parent 34b170c798
commit 207a3eb6f7
3 changed files with 83 additions and 0 deletions
+48
View File
@@ -27,6 +27,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
@@ -553,6 +554,53 @@ func ContainerEnv(service, name, value string) Check {
}
}
// BindMountSource expects the service's bind mount at target to resolve to
// exactly wantSource — pinning down which working directory a relative
// volume path was resolved against (e.g. an explicit --project-directory,
// as opposed to wherever the compose file itself was loaded from).
func BindMountSource(service, target, wantSource string) Check {
return Check{
name: fmt.Sprintf("service %q mount %q resolves to %s", service, target, wantSource),
fn: func(ctx *CheckContext) error {
containers := ctx.curr.service(service)
if len(containers) == 0 {
return errors.New("service has no container")
}
wantAbs, err := filepath.Abs(wantSource)
if err != nil {
return err
}
for _, c := range containers {
res := icmd.RunCmd(ctx.scenario.cli.NewDockerCmd(ctx.scenario.t, "inspect", "--format", "{{json .Mounts}}", c.ID))
if res.ExitCode != 0 {
return fmt.Errorf("inspect failed: %s", res.Combined())
}
var mounts []struct {
Destination string
Source string
}
if err := json.Unmarshal([]byte(strings.TrimSpace(res.Stdout())), &mounts); err != nil {
return err
}
found := false
for _, m := range mounts {
if m.Destination != target {
continue
}
found = true
if m.Source != wantAbs {
return fmt.Errorf("container %s mount %s resolves to %s, want %s", c.Name, target, m.Source, wantAbs)
}
}
if !found {
return fmt.Errorf("container %s has no mount at %s", c.Name, target)
}
}
return nil
},
}
}
// LabelSet expects every container of the service to carry a non-empty label.
func LabelSet(service, key string) Check {
return Check{
+28
View File
@@ -91,3 +91,31 @@ func TestOciRemoteTagSelection(t *testing.T) {
ServiceState("app", "running"),
ContainerEnv("app", "FLAVOR", "v1"))
}
// TestOciRemoteProjectDirectory is the docker/compose#14224 repro: an
// explicit --project-directory must resolve a relative volume path against
// itself, not against the local copy compose downloaded the oci:// artifact
// into (its self-contained-artifact default, otherwise correct for extends
// and bundled env files, but not meant to override an explicit request).
func TestOciRemoteProjectDirectory(t *testing.T) {
s := NewScenario(t, "--project-directory must resolve a relative volume path for an oci:// project, not the artifact's own download directory")
registry := startLocalRegistry(t, s)
ref := registry + "/remote-project-directory:v1"
s.Env("XDG_CACHE_HOME=" + t.TempDir())
s.Step("publish pushes the project to the registry",
ComposeCmd("publish", "--yes", "--insecure-registry", ref))
// distinct from both the anchored testdata copy and the artifact's own
// download cache, so a bind source under it can only come from
// --project-directory being honored
projectDir := t.TempDir()
if err := os.Mkdir(filepath.Join(projectDir, "data"), 0o755); err != nil {
t.Fatal(err)
}
s.FromRemote("oci://"+ref, "--insecure-registry", registry, "--project-directory", projectDir)
s.Step("up resolves the relative volume against --project-directory",
ComposeCmd("up", "-d", "--wait", "--yes").Within(60*time.Second),
ServiceState("app", "running"),
BindMountSource("app", "/data", filepath.Join(projectDir, "data")))
}
@@ -0,0 +1,7 @@
services:
app:
image: alpine
init: true
command: sleep infinity
volumes:
- ./data:/data