Repository: compose-spec/compose-go
Pull request #538 (2024-01-19 14:45:43)

always resolve paths relative to extending file's working dir
closes https://github.com/docker/compose/issues/11377
closes https://github.com/docker/compose/issues/11379

note: custom resource loaders defined by docker/compose will have to implement `Dir(path string)` returning (absolute) path for file within the remote download cache


Patch:
diff --git a/loader/extends.go b/loader/extends.go
index a56ca1f..97c6ca3 100644
--- a/loader/extends.go
+++ b/loader/extends.go
@@ -26,7 +26,7 @@ import (
 	"github.com/compose-spec/compose-go/v2/types"
 )
 
-func ApplyExtends(ctx context.Context, dict map[string]any, workingdir string, opts *Options, tracker *cycleTracker, post ...PostProcessor) error {
+func ApplyExtends(ctx context.Context, dict map[string]any, opts *Options, tracker *cycleTracker, post ...PostProcessor) error {
 	a, ok := dict["services"]
 	if !ok {
 		return nil
@@ -71,14 +71,15 @@ func ApplyExtends(ctx context.Context, dict map[string]any, workingdir string, o
 				if err != nil {
 					return err
 				}
-				relworkingdir := filepath.Dir(local)
-				if !filepath.IsAbs(local) {
-					relworkingdir, err = filepath.Rel(workingdir, relworkingdir)
-					if err != nil {
-						return err
-					}
-				}
+				localdir := filepath.Dir(local)
+				relworkingdir := loader.Dir(path)
+
 				extendsOpts := opts.clone()
+				extendsOpts.ResourceLoaders = append([]ResourceLoader{}, opts.ResourceLoaders...)
+				// replace localResourceLoader with a new flavour, using extended file base path
+				extendsOpts.ResourceLoaders[len(opts.ResourceLoaders)-1] = localResourceLoader{
+					WorkingDir: localdir,
+				}
 				extendsOpts.ResolvePaths = true
 				extendsOpts.SkipNormalization = true
 				extendsOpts.SkipConsistencyCheck = true
diff --git a/loader/loader.go b/loader/loader.go
index 08b28db..5100d9f 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -83,6 +83,8 @@ type ResourceLoader interface {
 	Accept(path string) bool
 	// Load returns the path to a local copy of remote resource identified by `path`.
 	Load(ctx context.Context, path string) (string, error)
+	// Dir computes path to resource"s parent folder, made relative if possible
+	Dir(path string) string
 }
 
 type localResourceLoader struct {
@@ -105,6 +107,15 @@ 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))
+	rel, err := filepath.Rel(l.WorkingDir, path)
+	if err != nil {
+		return path
+	}
+	return rel
+}
+
 func (o *Options) clone() *Options {
 	return &Options{
 		SkipValidation:             o.SkipValidation,
@@ -313,7 +324,7 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
 			fixEmptyNotNull(cfg)
 
 			if !opts.SkipExtends {
-				err = ApplyExtends(fctx, cfg, config.WorkingDir, opts, ct, processors...)
+				err = ApplyExtends(fctx, cfg, opts, ct, processors...)
 				if err != nil {
 					return err
 				}


Test patch:
diff --git a/loader/extends_test.go b/loader/extends_test.go
index fa57ec9..e5bafaa 100644
--- a/loader/extends_test.go
+++ b/loader/extends_test.go
@@ -115,3 +115,30 @@ services:
 	assert.NilError(t, err)
 	assert.Equal(t, p.Services["test"].Ulimits["nproc"].Single, 65535)
 }
+
+func TestExtendsRelativePath(t *testing.T) {
+	yaml := `
+name: test-extends-port
+services:
+  test:
+    extends: 
+      file: testdata/extends/base.yaml
+      service: with-build
+`
+	abs, err := filepath.Abs(".")
+	assert.NilError(t, err)
+
+	p, err := LoadWithContext(context.Background(), types.ConfigDetails{
+		ConfigFiles: []types.ConfigFile{
+			{
+				Content:  []byte(yaml),
+				Filename: "(inline)",
+			},
+		},
+		WorkingDir: abs,
+	}, func(options *Options) {
+		options.ResolvePaths = false
+	})
+	assert.NilError(t, err)
+	assert.Equal(t, p.Services["test"].Build.Context, filepath.Join("testdata", "extends"))
+}
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 693b973..b2f499b 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -2636,8 +2636,12 @@ func (c customLoader) Accept(s string) bool {
 	return strings.HasPrefix(s, c.prefix+":")
 }
 
+func (c customLoader) path(s string) string {
+	return filepath.Join("testdata", c.prefix, s[len(c.prefix)+1:])
+}
+
 func (c customLoader) Load(_ context.Context, s string) (string, error) {
-	path := filepath.Join("testdata", c.prefix, s[len(c.prefix)+1:])
+	path := c.path(s)
 	_, err := os.Stat(path)
 	if err != nil {
 		return "", err
@@ -2645,6 +2649,10 @@ func (c customLoader) Load(_ context.Context, s string) (string, error) {
 	return filepath.Abs(path)
 }
 
+func (c customLoader) Dir(s string) string {
+	return filepath.Dir(c.path(s))
+}
+
 func TestLoadWithRemoteResources(t *testing.T) {
 	config := buildConfigDetails(`
 name: test-remote-resources
diff --git a/loader/testdata/extends/base.yaml b/loader/testdata/extends/base.yaml
index a194db1..3c2f693 100644
--- a/loader/testdata/extends/base.yaml
+++ b/loader/testdata/extends/base.yaml
@@ -16,4 +16,9 @@ services:
       nofile:
         soft: 20000
         hard: 40000
-      
+
+  with-build:
+    extends:
+      file: sibling.yaml
+      service: test
+
diff --git a/loader/testdata/extends/sibling.yaml b/loader/testdata/extends/sibling.yaml
new file mode 100644
index 0000000..cd109c7
--- /dev/null
+++ b/loader/testdata/extends/sibling.yaml
@@ -0,0 +1,3 @@
+services:
+  test:
+    build: .