Repository: compose-spec/compose-go
Pull request #582 (2024-02-19 13:52:55)

Fix env file read from include
When reading a custom env file from include, we were not passing the read enviroenmnt variables to the config Environments, thus the read vars were being "forgotten".
Also fixed the default directory for environment variables, which should be the same as the compose.yml that is invoking the `include`

`withListeners` change forgotten in previous [PR](https://github.com/compose-spec/compose-go/pull/571)

Fixes: 
https://github.com/docker/compose/issues/11509
https://docker.atlassian.net/browse/COMP-438


Patch:
diff --git a/cli/options.go b/cli/options.go
index d2c91fb..adb2f75 100644
--- a/cli/options.go
+++ b/cli/options.go
@@ -442,7 +442,7 @@ func ProjectFromOptions(ctx context.Context, options *ProjectOptions) (*types.Pr
 	options.loadOptions = append(options.loadOptions,
 		withNamePrecedenceLoad(workingDir, options),
 		withConvertWindowsPaths(options),
-		withListener(options))
+		withListeners(options))
 
 	project, err := loader.LoadWithContext(ctx, types.ConfigDetails{
 		ConfigFiles: configs,
@@ -484,9 +484,9 @@ func withConvertWindowsPaths(options *ProjectOptions) func(*loader.Options) {
 }
 
 // save listeners from ProjectOptions (compose) to loader.Options
-func withListener(options *ProjectOptions) func(*loader.Options) {
+func withListeners(options *ProjectOptions) func(*loader.Options) {
 	return func(opts *loader.Options) {
-		opts.Listeners = options.Listeners
+		opts.Listeners = append(opts.Listeners, options.Listeners...)
 	}
 }
 
diff --git a/loader/environment.go b/loader/environment.go
new file mode 100644
index 0000000..e6983bb
--- /dev/null
+++ b/loader/environment.go
@@ -0,0 +1,59 @@
+/*
+   Copyright 2020 The Compose Specification Authors.
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+*/
+
+package loader
+
+import (
+	"fmt"
+
+	"github.com/compose-spec/compose-go/v2/types"
+)
+
+// Will update the environment variables for the format {- VAR} (without interpolation)
+// This function should resolve context environment vars for include (passed in env_file)
+func resolveServicesEnvironment(dict map[string]any, config types.ConfigDetails) {
+	services, ok := dict["services"].(map[string]any)
+	if !ok {
+		return
+	}
+
+	for service, cfg := range services {
+		serviceConfig, ok := cfg.(map[string]any)
+		if !ok {
+			continue
+		}
+		serviceEnv, ok := serviceConfig["environment"].([]any)
+		if !ok {
+			continue
+		}
+		envs := []any{}
+		for _, env := range serviceEnv {
+			varEnv, ok := env.(string)
+			if !ok {
+				continue
+			}
+			if found, ok := config.Environment[varEnv]; ok {
+				envs = append(envs, fmt.Sprintf("%s=%s", varEnv, found))
+			} else {
+				// either does not exist or it was already resolved in interpolation
+				envs = append(envs, varEnv)
+			}
+		}
+		serviceConfig["environment"] = envs
+		services[service] = serviceConfig
+	}
+	dict["services"] = services
+}
diff --git a/loader/include.go b/loader/include.go
index 21d5871..a42e0d0 100644
--- a/loader/include.go
+++ b/loader/include.go
@@ -104,6 +104,22 @@ func ApplyInclude(ctx context.Context, configDetails types.ConfigDetails, model
 			if s, err := os.Stat(f); err == nil && !s.IsDir() {
 				r.EnvFile = types.StringList{f}
 			}
+		} else {
+			envFile := []string{}
+			for _, f := range r.EnvFile {
+				if !filepath.IsAbs(f) {
+					f = filepath.Join(configDetails.WorkingDir, f)
+					s, err := os.Stat(f)
+					if err != nil {
+						return err
+					}
+					if s.IsDir() {
+						return fmt.Errorf("%s is not a file", f)
+					}
+				}
+				envFile = append(envFile, f)
+			}
+			r.EnvFile = envFile
 		}
 
 		envFromFile, err := dotenv.GetEnvFromFile(configDetails.Environment, r.EnvFile)
diff --git a/loader/loader.go b/loader/loader.go
index 35dc982..985b4b5 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -453,6 +453,7 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
 			return nil, err
 		}
 	}
+	resolveServicesEnvironment(dict, config)
 
 	return dict, nil
 }


Test patch:
diff --git a/loader/include_test.go b/loader/include_test.go
index c0d17ff..7c011e3 100644
--- a/loader/include_test.go
+++ b/loader/include_test.go
@@ -18,6 +18,7 @@ package loader
 
 import (
 	"context"
+	"os"
 	"path/filepath"
 	"testing"
 
@@ -50,7 +51,7 @@ services:
 	assert.Equal(t, imported.ContainerName, "override")
 
 	// include 2 different services with same name should trigger an error
-	p, err = Load(buildConfigDetails(`
+	_, err = Load(buildConfigDetails(`
 name: 'test-multi-include'
 
 include:
@@ -89,3 +90,71 @@ func TestIncludeRelative(t *testing.T) {
 	assert.Equal(t, included.Build.Context, ".")
 	assert.Equal(t, included.Volumes[0].Source, ".")
 }
+
+func TestLoadWithIncludeEnv(t *testing.T) {
+	fileName := "compose.yml"
+	tmpdir := t.TempDir()
+	// file in root
+	yaml := `
+include:
+  - path:
+    - ./module/compose.yml
+    env_file:
+      - ./custom.env
+services:
+  a:
+    image: alpine
+    environment:
+      - VAR_NAME`
+	createFile(t, tmpdir, `VAR_NAME=value`, "custom.env")
+	path := createFile(t, tmpdir, yaml, fileName)
+	// file in /module
+	yaml = `
+services:
+  b:
+    image: alpine
+    environment:
+      - VAR_NAME
+  c:
+    image: alpine
+    environment:
+      - VAR_NAME`
+	createFileSubDir(t, tmpdir, "module", yaml, fileName)
+
+	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)
+	a := p.Services["a"]
+	// make sure VAR_NAME is only accessible in include context
+	assert.Check(t, a.Environment["VAR_NAME"] == nil, "VAR_NAME should not be defined in environment")
+	b := p.Services["b"]
+	assert.Check(t, b.Environment["VAR_NAME"] != nil, "VAR_NAME is not defined in environment")
+	assert.Equal(t, *b.Environment["VAR_NAME"], "value")
+	c := p.Services["c"]
+	assert.Check(t, c.Environment["VAR_NAME"] != nil, "VAR_NAME is not defined in environment")
+	assert.Equal(t, *c.Environment["VAR_NAME"], "value")
+
+}
+
+func createFile(t *testing.T, rootDir, content, fileName string) string {
+	path := filepath.Join(rootDir, fileName)
+	assert.NilError(t, os.WriteFile(path, []byte(content), 0o600))
+	return path
+}
+
+func createFileSubDir(t *testing.T, rootDir, subDir, content, fileName string) string {
+	subDirPath := filepath.Join(rootDir, subDir)
+	assert.NilError(t, os.Mkdir(subDirPath, 0o700))
+	path := filepath.Join(subDirPath, fileName)
+	assert.NilError(t, os.WriteFile(path, []byte(content), 0o600))
+	return path
+}