Repository: compose-spec/compose-go
Pull request #614 (2024-04-02 12:15:15)

fix: `include.project_directory` path resolution
Fixes https://github.com/docker/compose/issues/11626 and https://github.com/docker/compose/issues/11573

https://docker.atlassian.net/browse/COMP-496


Patch:
diff --git a/loader/include.go b/loader/include.go
index baba9ec..d40b24e 100644
--- a/loader/include.go
+++ b/loader/include.go
@@ -29,7 +29,7 @@ import (
 	"github.com/compose-spec/compose-go/v2/types"
 )
 
-// loadIncludeConfig parse the require config from raw yaml
+// loadIncludeConfig parse the required config from raw yaml
 func loadIncludeConfig(source any) ([]types.IncludeConfig, error) {
 	if source == nil {
 		return nil, nil
@@ -76,11 +76,19 @@ func ApplyInclude(ctx context.Context, configDetails types.ConfigDetails, model
 				p = path
 
 				if i == 0 { // This is the "main" file, used to define project-directory. Others are overrides
-					relworkingdir = loader.Dir(path)
-					if r.ProjectDirectory == "" {
+
+					switch {
+					case r.ProjectDirectory == "":
+						relworkingdir = loader.Dir(path)
 						r.ProjectDirectory = filepath.Dir(path)
-					}
+					case !filepath.IsAbs(r.ProjectDirectory):
+						relworkingdir = loader.Dir(r.ProjectDirectory)
+						r.ProjectDirectory = filepath.Join(configDetails.WorkingDir, r.ProjectDirectory)
 
+					default:
+						relworkingdir = r.ProjectDirectory
+
+					}
 					for _, f := range included {
 						if f == path {
 							included = append(included, path)
diff --git a/loader/loader.go b/loader/loader.go
index 1ed3977..4bcb0f8 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -148,8 +148,11 @@ func (l localResourceLoader) Load(_ context.Context, p string) (string, error) {
 	return l.abs(p), nil
 }
 
-func (l localResourceLoader) Dir(path string) string {
-	path = l.abs(filepath.Dir(path))
+func (l localResourceLoader) Dir(originalPath string) string {
+	path := l.abs(originalPath)
+	if !l.isDir(path) {
+		path = l.abs(filepath.Dir(originalPath))
+	}
 	rel, err := filepath.Rel(l.WorkingDir, path)
 	if err != nil {
 		return path
@@ -157,6 +160,14 @@ func (l localResourceLoader) Dir(path string) string {
 	return rel
 }
 
+func (l localResourceLoader) isDir(path string) bool {
+	fileInfo, err := os.Stat(path)
+	if err != nil {
+		return false
+	}
+	return fileInfo.IsDir()
+}
+
 func (o *Options) clone() *Options {
 	return &Options{
 		SkipValidation:             o.SkipValidation,


Test patch:
diff --git a/loader/include_test.go b/loader/include_test.go
index 8188023..f44d417 100644
--- a/loader/include_test.go
+++ b/loader/include_test.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"os"
 	"path/filepath"
+	"runtime"
 	"testing"
 
 	"github.com/compose-spec/compose-go/v2/types"
@@ -162,6 +163,67 @@ services:
 
 }
 
+func TestIncludeWithProjectDirectory(t *testing.T) {
+	var envs map[string]string
+	if runtime.GOOS == "windows" {
+		envs = map[string]string{"COMPOSE_CONVERT_WINDOWS_PATHS": "1"}
+	}
+	p, err := LoadWithContext(context.Background(), types.ConfigDetails{
+		WorkingDir:  "testdata/include",
+		Environment: envs,
+		ConfigFiles: []types.ConfigFile{
+			{
+				Filename: "testdata/include/project-directory.yaml",
+			},
+		},
+	}, withProjectName("test-load-project-directory", true))
+	assert.NilError(t, err)
+	assert.Equal(t, filepath.ToSlash(p.Services["service"].Build.Context), "testdata/subdir")
+	assert.Equal(t, filepath.ToSlash(p.Services["service"].Volumes[0].Source), "testdata/subdir/compose-test-extends-imported.yaml")
+	assert.Equal(t, filepath.ToSlash(p.Services["service"].EnvFiles[0].Path), "testdata/subdir/extra.env")
+
+}
+
+func TestNestedIncludeAndExtends(t *testing.T) {
+	fileName := "compose.yml"
+	yaml := `
+include:
+  - project_directory: .
+    path: dir/included.yaml
+`
+	tmpdir := t.TempDir()
+	path := createFile(t, tmpdir, yaml, fileName)
+
+	yaml = `
+services:
+  included:
+    extends:
+      file: dir/extended.yaml
+      service: extended
+`
+	createFileSubDir(t, tmpdir, "dir", yaml, "included.yaml")
+
+	yaml = `
+services:
+  extended:
+    image: alpine
+`
+	createFile(t, filepath.Join(tmpdir, "dir"), yaml, "extended.yaml")
+	p, err := Load(types.ConfigDetails{
+		WorkingDir: tmpdir,
+		ConfigFiles: []types.ConfigFile{{
+			Filename: path,
+		}},
+		Environment: nil,
+	}, func(options *Options) {
+		options.SkipNormalization = true
+		options.ResolvePaths = true
+		options.SetProjectName("project", true)
+	})
+	assert.NilError(t, err)
+	assert.Equal(t, p.Services["included"].Image, "alpine")
+}
+
 func createFile(t *testing.T, rootDir, content, fileName string) string {
 	path := filepath.Join(rootDir, fileName)
 	assert.NilError(t, os.WriteFile(path, []byte(content), 0o600))
diff --git a/loader/testdata/include/dir/compose.yaml b/loader/testdata/include/dir/compose.yaml
new file mode 100644
index 0000000..14223d3
--- /dev/null
+++ b/loader/testdata/include/dir/compose.yaml
@@ -0,0 +1,12 @@
+services:
+  service:
+    build:
+      dockerfile_inline: |
+        FROM busybox
+        COPY compose-test-extends-imported.yaml compose.yaml
+    volumes:
+      - type: bind
+        source: compose-test-extends-imported.yaml
+        target: /mnt/bind/compose.yaml
+    env_file:
+      - extra.env
\ No newline at end of file
diff --git a/loader/testdata/include/project-directory.yaml b/loader/testdata/include/project-directory.yaml
new file mode 100644
index 0000000..ff91fd8
--- /dev/null
+++ b/loader/testdata/include/project-directory.yaml
@@ -0,0 +1,3 @@
+include:
+  - project_directory: ../subdir
+    path: ./dir/compose.yaml
\ No newline at end of file