Repository: compose-spec/compose-go
Pull request #449 (2023-08-25 12:58:55)

local environment to override included .env
as we include another compose.yaml file and parse implicit or explicit sibling `.env` file, upper-level project environment should override values (just like `FOO=BAR docker compose up` does)

see https://github.com/docker/compose/issues/10913



Patch:
diff --git a/cli/options.go b/cli/options.go
index f20803b..215cbee 100644
--- a/cli/options.go
+++ b/cli/options.go
@@ -66,7 +66,7 @@ type ProjectOptions struct {
 	// NOTE: For security, the loader does not automatically expose any
 	// process environment variables. For convenience, WithOsEnv can be
 	// used if appropriate.
-	Environment map[string]string
+	Environment types.Mapping
 
 	// EnvFiles are file paths to ".env" files with additional environment
 	// variable data.
@@ -256,11 +256,7 @@ func WithDotEnv(o *ProjectOptions) error {
 	if err != nil {
 		return err
 	}
-	for k, v := range envMap {
-		if _, set := o.Environment[k]; !set {
-			o.Environment[k] = v
-		}
-	}
+	o.Environment.Merge(envMap)
 	return nil
 }
 
diff --git a/loader/include.go b/loader/include.go
index 5d0c39b..78d68a2 100644
--- a/loader/include.go
+++ b/loader/include.go
@@ -73,7 +73,7 @@ func loadInclude(ctx context.Context, filename string, configDetails types.Confi
 		loadOptions.SkipNormalization = true
 		loadOptions.SkipConsistencyCheck = true
 
-		env, err := dotenv.GetEnvFromFile(configDetails.Environment, r.ProjectDirectory, r.EnvFile)
+		envFromFile, err := dotenv.GetEnvFromFile(configDetails.Environment, r.ProjectDirectory, r.EnvFile)
 		if err != nil {
 			return nil, nil, err
 		}
@@ -81,7 +81,7 @@ func loadInclude(ctx context.Context, filename string, configDetails types.Confi
 		config := types.ConfigDetails{
 			WorkingDir:  r.ProjectDirectory,
 			ConfigFiles: types.ToConfigFiles(r.Path),
-			Environment: env,
+			Environment: configDetails.Environment.Clone().Merge(envFromFile),
 		}
 		loadOptions.Interpolate = &interp.Options{
 			Substitute:      options.Interpolate.Substitute,
@@ -114,12 +114,6 @@ func importResources(model *types.Config, imported *types.Project, path []string
 		}
 		model.Services = append(model.Services, service)
 	}
-	for _, service := range imported.DisabledServices {
-		if _, ok := services[service.Name]; ok {
-			return fmt.Errorf("imported compose file %s defines conflicting service %s", path, service.Name)
-		}
-		model.Services = append(model.Services, service)
-	}
 	for n, network := range imported.Networks {
 		if _, ok := model.Networks[n]; ok {
 			return fmt.Errorf("imported compose file %s defines conflicting network %s", path, n)
diff --git a/types/config.go b/types/config.go
index 517e612..25e6f82 100644
--- a/types/config.go
+++ b/types/config.go
@@ -34,7 +34,7 @@ type ConfigDetails struct {
 	Version     string
 	WorkingDir  string
 	ConfigFiles []ConfigFile
-	Environment map[string]string
+	Environment Mapping
 }
 
 // LookupEnv provides a lookup function for environment variables
diff --git a/types/types.go b/types/types.go
index 517d9a4..5864dc0 100644
--- a/types/types.go
+++ b/types/types.go
@@ -516,6 +516,24 @@ func (m Mapping) Resolve(s string) (string, bool) {
 	return v, ok
 }
 
+func (m Mapping) Clone() Mapping {
+	clone := Mapping{}
+	for k, v := range m {
+		clone[k] = v
+	}
+	return clone
+}
+
+// Merge adds all values from second mapping which are not already defined
+func (m Mapping) Merge(o Mapping) Mapping {
+	for k, v := range o {
+		if _, set := m[k]; !set {
+			m[k] = v
+		}
+	}
+	return m
+}
+
 // Labels is a mapping type for labels
 type Labels map[string]string
 


Test patch:
diff --git a/cli/options_test.go b/cli/options_test.go
index 893e073..5608759 100644
--- a/cli/options_test.go
+++ b/cli/options_test.go
@@ -22,6 +22,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/compose-spec/compose-go/types"
 	"gotest.tools/v3/assert"
 
 	"github.com/compose-spec/compose-go/consts"
@@ -314,13 +315,13 @@ func TestEnvVariablePrecedence(t *testing.T) {
 		name     string
 		dotEnv   string
 		osEnv    []string
-		expected map[string]string
+		expected types.Mapping
 	}{
 		{
 			"no value set in environment",
 			"FOO=foo\nBAR=${FOO}",
 			nil,
-			map[string]string{
+			types.Mapping{
 				"FOO": "foo",
 				"BAR": "foo",
 			},
@@ -329,7 +330,7 @@ func TestEnvVariablePrecedence(t *testing.T) {
 			"conflict with value set in environment",
 			"FOO=foo\nBAR=${FOO}",
 			[]string{"FOO=zot"},
-			map[string]string{
+			types.Mapping{
 				"FOO": "zot",
 				"BAR": "zot",
 			},
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 130c778..6dad1e7 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -2555,6 +2555,25 @@ services:
 		},
 	})
 	assert.NilError(t, err)
+
+	p, err = Load(buildConfigDetails(`
+name: 'test-include'
+
+include:
+  - path: ./testdata/subdir/compose-test-extends-imported.yaml
+    env_file: ./testdata/subdir/extra.env
+
+services:
+  foo:
+    image: busybox
+    depends_on:
+      - imported
+`, map[string]string{"SOURCE": "override"}), func(options *Options) {
+		options.SkipNormalization = true
+		options.ResolvePaths = true
+	})
+	assert.NilError(t, err)
+	assert.Equal(t, p.Services[1].ContainerName, "override")
 }
 
 func TestLoadWithIncludeCycle(t *testing.T) {