Repository: compose-spec/compose-go
Pull request #435 (2023-07-13 10:06:11)

introduce ResourceResolver to accept remote resources
`ResourceLoaders` can be configured by Compose implementation to support remote resources with `extends` and `include`. This will allow users to rely on compose.yaml files published by others, without the need for a mono-repo.

illustration example:
```yaml 
include:
   - git@github.com:acme/commons.git#v2.0:compose.yaml
```

If this feature demonstrates some significant use for a specific protocol (git, http, oci-artifacts ?) we _could_ make those part of the compose specification and offer a default loader implementation in compose-go

We also could extend this support to other places compose loads files, typically with `env_file` and `secret.file` but I'd prefer we keep this focussed on a simpler use-case for a first round.

https://docker.atlassian.net/browse/ENV-251

ndeloof: added example code using this feature to support git resources: https://github.com/docker/compose/pull/10811

Patch:
diff --git a/cli/options.go b/cli/options.go
index e6d49cd..f20803b 100644
--- a/cli/options.go
+++ b/cli/options.go
@@ -17,6 +17,7 @@
 package cli
 
 import (
+	"context"
 	"io"
 	"os"
 	"path/filepath"
@@ -35,6 +36,8 @@ import (
 
 // ProjectOptions provides common configuration for loading a project.
 type ProjectOptions struct {
+	ctx context.Context
+
 	// Name is a valid Compose project name to be used or empty.
 	//
 	// If empty, the project loader will automatically infer a reasonable
@@ -301,6 +304,24 @@ func WithResolvedPaths(resolve bool) ProjectOptionsFn {
 	}
 }
 
+// WithContext sets the context used to load model and resources
+func WithContext(ctx context.Context) ProjectOptionsFn {
+	return func(o *ProjectOptions) error {
+		o.ctx = ctx
+		return nil
+	}
+}
+
+// WithResourceLoader register support for ResourceLoader to manage remote resources
+func WithResourceLoader(r loader.ResourceLoader) ProjectOptionsFn {
+	return func(o *ProjectOptions) error {
+		o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
+			options.ResourceLoaders = append(options.ResourceLoaders, r)
+		})
+		return nil
+	}
+}
+
 // DefaultFileNames defines the Compose file names for auto-discovery (in order of preference)
 var DefaultFileNames = []string{"compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"}
 
@@ -367,7 +388,12 @@ func ProjectFromOptions(options *ProjectOptions) (*types.Project, error) {
 		withNamePrecedenceLoad(absWorkingDir, options),
 		withConvertWindowsPaths(options))
 
-	project, err := loader.Load(types.ConfigDetails{
+	ctx := options.ctx
+	if ctx == nil {
+		ctx = context.Background()
+	}
+
+	project, err := loader.LoadWithContext(ctx, types.ConfigDetails{
 		ConfigFiles: configs,
 		WorkingDir:  workingDir,
 		Environment: options.Environment,
diff --git a/loader/include.go b/loader/include.go
index eeddf8b..5bc86b7 100644
--- a/loader/include.go
+++ b/loader/include.go
@@ -17,6 +17,7 @@
 package loader
 
 import (
+	"context"
 	"fmt"
 	"path/filepath"
 
@@ -43,12 +44,20 @@ var transformIncludeConfig TransformerFunc = func(data interface{}) (interface{}
 	}
 }
 
-func loadInclude(configDetails types.ConfigDetails, model *types.Config, options *Options, loaded []string) (*types.Config, error) {
+func loadInclude(ctx context.Context, configDetails types.ConfigDetails, model *types.Config, options *Options, loaded []string) (*types.Config, error) {
 	for _, r := range model.Include {
 		for i, p := range r.Path {
-			if !filepath.IsAbs(p) {
-				r.Path[i] = filepath.Join(configDetails.WorkingDir, p)
+			for _, loader := range options.ResourceLoaders {
+				if loader.Accept(p) {
+					path, err := loader.Load(ctx, p)
+					if err != nil {
+						return nil, err
+					}
+					p = path
+					break
+				}
 			}
+			r.Path[i] = absPath(configDetails.WorkingDir, p)
 		}
 		if r.ProjectDirectory == "" {
 			r.ProjectDirectory = filepath.Dir(r.Path[0])
@@ -65,7 +74,7 @@ func loadInclude(configDetails types.ConfigDetails, model *types.Config, options
 			return nil, err
 		}
 
-		imported, err := load(types.ConfigDetails{
+		imported, err := load(ctx, types.ConfigDetails{
 			WorkingDir:  r.ProjectDirectory,
 			ConfigFiles: types.ToConfigFiles(r.Path),
 			Environment: env,
diff --git a/loader/loader.go b/loader/loader.go
index 8228346..b6f8fe9 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -17,6 +17,7 @@
 package loader
 
 import (
+	"context"
 	"fmt"
 	"os"
 	paths "path"
@@ -68,6 +69,16 @@ type Options struct {
 	projectNameImperativelySet bool
 	// Profiles set profiles to enable
 	Profiles []string
+	// ResourceLoaders manages support for remote resources
+	ResourceLoaders []ResourceLoader
+}
+
+// ResourceLoader is a plugable remote resource resolver
+type ResourceLoader interface {
+	// Accept returns `true` is the resource reference matches ResourceLoader supported protocol(s)
+	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)
 }
 
 func (o *Options) clone() *Options {
@@ -85,6 +96,7 @@ func (o *Options) clone() *Options {
 		projectName:                o.projectName,
 		projectNameImperativelySet: o.projectNameImperativelySet,
 		Profiles:                   o.Profiles,
+		ResourceLoaders:            o.ResourceLoaders,
 	}
 }
 
@@ -193,8 +205,14 @@ func parseYAML(source []byte) (map[string]interface{}, PostProcessor, error) {
 	return converted.(map[string]interface{}), &processor, nil
 }
 
-// Load reads a ConfigDetails and returns a fully loaded configuration
+// Load reads a ConfigDetails and returns a fully loaded configuration.
+// Deprecated: use LoadWithContext.
 func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.Project, error) {
+	return LoadWithContext(context.Background(), configDetails, options...)
+}
+
+// LoadWithContext reads a ConfigDetails and returns a fully loaded configuration
+func LoadWithContext(ctx context.Context, configDetails types.ConfigDetails, options ...func(*Options)) (*types.Project, error) {
 	if len(configDetails.ConfigFiles) < 1 {
 		return nil, errors.Errorf("No files specified")
 	}
@@ -217,10 +235,10 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 		return nil, err
 	}
 	opts.projectName = projectName
-	return load(configDetails, opts, nil)
+	return load(ctx, configDetails, opts, nil)
 }
 
-func load(configDetails types.ConfigDetails, opts *Options, loaded []string) (*types.Project, error) {
+func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options, loaded []string) (*types.Project, error) {
 	var model *types.Config
 
 	mainFile := configDetails.ConfigFiles[0].Filename
@@ -261,13 +279,13 @@ func load(configDetails types.ConfigDetails, opts *Options, loaded []string) (*t
 
 		configDict = groupXFieldsIntoExtensions(configDict)
 
-		cfg, err := loadSections(file.Filename, configDict, configDetails, opts)
+		cfg, err := loadSections(ctx, file.Filename, configDict, configDetails, opts)
 		if err != nil {
 			return nil, err
 		}
 
 		if !opts.SkipInclude {
-			cfg, err = loadInclude(configDetails, cfg, opts, loaded)
+			cfg, err = loadInclude(ctx, configDetails, cfg, opts, loaded)
 			if err != nil {
 				return nil, err
 			}
@@ -453,7 +471,7 @@ func groupXFieldsIntoExtensions(dict map[string]interface{}) map[string]interfac
 	return dict
 }
 
-func loadSections(filename string, config map[string]interface{}, configDetails types.ConfigDetails, opts *Options) (*types.Config, error) {
+func loadSections(ctx context.Context, filename string, config map[string]interface{}, configDetails types.ConfigDetails, opts *Options) (*types.Config, error) {
 	var err error
 	cfg := types.Config{
 		Filename: filename,
@@ -466,7 +484,7 @@ func loadSections(filename string, config map[string]interface{}, configDetails
 		}
 	}
 	cfg.Name = name
-	cfg.Services, err = LoadServices(filename, getSection(config, "services"), configDetails.WorkingDir, configDetails.LookupEnv, opts)
+	cfg.Services, err = LoadServices(ctx, filename, getSection(config, "services"), configDetails.WorkingDir, configDetails.LookupEnv, opts)
 	if err != nil {
 		return nil, err
 	}
@@ -659,7 +677,7 @@ func formatInvalidKeyError(keyPrefix string, key interface{}) error {
 
 // LoadServices produces a ServiceConfig map from a compose file Dict
 // the servicesDict is not validated if directly used. Use Load() to enable validation
-func LoadServices(filename string, servicesDict map[string]interface{}, workingDir string, lookupEnv template.Mapping, opts *Options) ([]types.ServiceConfig, error) {
+func LoadServices(ctx context.Context, filename string, servicesDict map[string]interface{}, workingDir string, lookupEnv template.Mapping, opts *Options) ([]types.ServiceConfig, error) {
 	var services []types.ServiceConfig
 
 	x, ok := servicesDict[extensions]
@@ -672,7 +690,7 @@ func LoadServices(filename string, servicesDict map[string]interface{}, workingD
 	}
 
 	for name := range servicesDict {
-		serviceConfig, err := loadServiceWithExtends(filename, name, servicesDict, workingDir, lookupEnv, opts, &cycleTracker{})
+		serviceConfig, err := loadServiceWithExtends(ctx, filename, name, servicesDict, workingDir, lookupEnv, opts, &cycleTracker{})
 		if err != nil {
 			return nil, err
 		}
@@ -683,7 +701,7 @@ func LoadServices(filename string, servicesDict map[string]interface{}, workingD
 	return services, nil
 }
 
-func loadServiceWithExtends(filename, name string, servicesDict map[string]interface{}, workingDir string, lookupEnv template.Mapping, opts *Options, ct *cycleTracker) (*types.ServiceConfig, error) {
+func loadServiceWithExtends(ctx context.Context, filename, name string, servicesDict map[string]interface{}, workingDir string, lookupEnv template.Mapping, opts *Options, ct *cycleTracker) (*types.ServiceConfig, error) {
 	if err := ct.Add(filename, name); err != nil {
 		return nil, err
 	}
@@ -707,11 +725,21 @@ func loadServiceWithExtends(filename, name string, servicesDict map[string]inter
 		var baseService *types.ServiceConfig
 		file := serviceConfig.Extends.File
 		if file == "" {
-			baseService, err = loadServiceWithExtends(filename, baseServiceName, servicesDict, workingDir, lookupEnv, opts, ct)
+			baseService, err = loadServiceWithExtends(ctx, filename, baseServiceName, servicesDict, workingDir, lookupEnv, opts, ct)
 			if err != nil {
 				return nil, err
 			}
 		} else {
+			for _, loader := range opts.ResourceLoaders {
+				if loader.Accept(file) {
+					path, err := loader.Load(ctx, file)
+					if err != nil {
+						return nil, err
+					}
+					file = path
+					break
+				}
+			}
 			// Resolve the path to the imported file, and load it.
 			baseFilePath := absPath(workingDir, file)
 
@@ -726,7 +754,7 @@ func loadServiceWithExtends(filename, name string, servicesDict map[string]inter
 			}
 
 			baseFileServices := getSection(baseFile, "services")
-			baseService, err = loadServiceWithExtends(baseFilePath, baseServiceName, baseFileServices, filepath.Dir(baseFilePath), lookupEnv, opts, ct)
+			baseService, err = loadServiceWithExtends(ctx, baseFilePath, baseServiceName, baseFileServices, filepath.Dir(baseFilePath), lookupEnv, opts, ct)
 			if err != nil {
 				return nil, err
 			}


Test patch:
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 234378c..697ef8d 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -18,6 +18,7 @@ package loader
 
 import (
 	"bytes"
+	"context"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -2592,3 +2593,119 @@ services:
 		},
 	})
 }
+
+type customLoader struct {
+	prefix string
+}
+
+func (c customLoader) Accept(s string) bool {
+	return strings.HasPrefix(s, c.prefix+":")
+}
+
+func (c customLoader) Load(ctx context.Context, s string) (string, error) {
+	path := filepath.Join("testdata", c.prefix, s[len(c.prefix)+1:])
+	_, err := os.Stat(path)
+	if err != nil {
+		return "", err
+	}
+	return filepath.Abs(path)
+}
+
+func TestLoadWithRemoteResources(t *testing.T) {
+	config := buildConfigDetails(`
+name: test-remote-resources
+services:
+  foo:
+    extends:
+      file: remote:compose.yaml
+      service: foo
+
+`, nil)
+	p, err := LoadWithContext(context.Background(), config, func(options *Options) {
+		options.SkipConsistencyCheck = true
+		options.SkipNormalization = true
+		options.ResolvePaths = true
+		options.ResourceLoaders = []ResourceLoader{
+			customLoader{prefix: "remote"},
+		}
+	})
+	assert.NilError(t, err)
+	assert.DeepEqual(t, p.Services, types.Services{
+		{
+			Name:        "foo",
+			Image:       "foo",
+			Environment: types.MappingWithEquals{"FOO": strPtr("BAR")},
+			EnvFile: types.StringList{
+				filepath.Join(config.WorkingDir, "testdata", "remote", "env"),
+			},
+			Scale: 1,
+			Volumes: []types.ServiceVolumeConfig{
+				{
+					Type:   types.VolumeTypeBind,
+					Source: filepath.Join(config.WorkingDir, "testdata", "remote"),
+					Target: "/foo",
+					Bind:   &types.ServiceVolumeBind{CreateHostPath: true},
+				},
+			},
+		},
+	})
+}
+
+func TestLoadWithMissingResources(t *testing.T) {
+	config := buildConfigDetails(`
+name: test-missing-resources
+services:
+  foo:
+    extends:
+      file: remote:unavailable.yaml
+      service: foo
+
+`, nil)
+	_, err := LoadWithContext(context.Background(), config, func(options *Options) {
+		options.SkipConsistencyCheck = true
+		options.SkipNormalization = true
+		options.ResolvePaths = true
+		options.ResourceLoaders = []ResourceLoader{
+			customLoader{prefix: "remote"},
+		}
+	})
+	assert.Check(t, os.IsNotExist(err))
+}
+
+func TestLoadWithNestedResources(t *testing.T) {
+	config := buildConfigDetails(`
+name: test-nested-resources
+include: 
+  - remote:nested/compose.yaml
+`, nil)
+	_, err := LoadWithContext(context.Background(), config, func(options *Options) {
+		options.SkipConsistencyCheck = true
+		options.SkipNormalization = true
+		options.ResolvePaths = true
+		options.ResourceLoaders = []ResourceLoader{
+			customLoader{prefix: "remote"},
+		}
+	})
+	assert.NilError(t, err)
+}
+
+func TestLoadWithResourcesCycle(t *testing.T) {
+	config := buildConfigDetails(`
+name: test-resources-cycle
+services:
+  foo:
+    extends:
+      file: remote:cycle/compose.yaml
+      service: foo
+
+`, nil)
+	_, err := LoadWithContext(context.Background(), config, func(options *Options) {
+		options.SkipConsistencyCheck = true
+		options.SkipNormalization = true
+		options.ResolvePaths = true
+		options.ResourceLoaders = []ResourceLoader{
+			customLoader{prefix: "remote"},
+		}
+	})
+	assert.ErrorContains(t, err, "Circular reference")
+}
diff --git a/loader/testdata/remote/compose.yaml b/loader/testdata/remote/compose.yaml
new file mode 100644
index 0000000..6d51ebc
--- /dev/null
+++ b/loader/testdata/remote/compose.yaml
@@ -0,0 +1,7 @@
+services:
+  foo:
+    image: foo
+    env_file:
+      - ./env
+    volumes:
+      - .:/foo
diff --git a/loader/testdata/remote/cycle/compose-cycle.yaml b/loader/testdata/remote/cycle/compose-cycle.yaml
new file mode 100644
index 0000000..93f2c53
--- /dev/null
+++ b/loader/testdata/remote/cycle/compose-cycle.yaml
@@ -0,0 +1,5 @@
+services:
+  bar:
+    extends:
+      file: remote:cycle/compose.yaml
+      service: foo
diff --git a/loader/testdata/remote/cycle/compose.yaml b/loader/testdata/remote/cycle/compose.yaml
new file mode 100644
index 0000000..10e2f77
--- /dev/null
+++ b/loader/testdata/remote/cycle/compose.yaml
@@ -0,0 +1,5 @@
+services:
+  foo:
+    extends:
+      file: remote:cycle/compose-cycle.yaml
+      service: bar
diff --git a/loader/testdata/remote/env b/loader/testdata/remote/env
new file mode 100644
index 0000000..6ac867a
--- /dev/null
+++ b/loader/testdata/remote/env
@@ -0,0 +1,1 @@
+FOO=BAR
diff --git a/loader/testdata/remote/nested/compose-nested.yaml b/loader/testdata/remote/nested/compose-nested.yaml
new file mode 100644
index 0000000..2b50bfd
--- /dev/null
+++ b/loader/testdata/remote/nested/compose-nested.yaml
@@ -0,0 +1,3 @@
+services:
+  bar:
+    image: bar
diff --git a/loader/testdata/remote/nested/compose.yaml b/loader/testdata/remote/nested/compose.yaml
new file mode 100644
index 0000000..ef74115
--- /dev/null
+++ b/loader/testdata/remote/nested/compose.yaml
@@ -0,0 +1,5 @@
+services:
+  foo:
+    extends:
+      file: remote:nested/compose-nested.yaml
+      service: bar