Repository: compose-spec/compose-go
Pull request #348 (2023-01-20 14:59:36)

support mulitple env-files to be set
Allow to pass multiple env files to loader, see https://github.com/docker/compose/issues/7326


Patch:
diff --git a/cli/options.go b/cli/options.go
index ebe61d2..a85ca6a 100644
--- a/cli/options.go
+++ b/cli/options.go
@@ -17,6 +17,7 @@
 package cli
 
 import (
+	"bytes"
 	"fmt"
 	"io"
 	"os"
@@ -39,7 +40,7 @@ type ProjectOptions struct {
 	WorkingDir  string
 	ConfigPaths []string
 	Environment map[string]string
-	EnvFile     string
+	EnvFiles    []string
 	loadOptions []func(*loader.Options)
 }
 
@@ -187,9 +188,15 @@ func WithOsEnv(o *ProjectOptions) error {
 }
 
 // WithEnvFile set an alternate env file
+// deprecated - use WithEnvFiles
 func WithEnvFile(file string) ProjectOptionsFn {
+	return WithEnvFiles(file)
+}
+
+// WithEnvFiles set alternate env files
+func WithEnvFiles(file ...string) ProjectOptionsFn {
 	return func(options *ProjectOptions) error {
-		options.EnvFile = file
+		options.EnvFiles = file
 		return nil
 	}
 }
@@ -200,7 +207,7 @@ func WithDotEnv(o *ProjectOptions) error {
 	if err != nil {
 		return err
 	}
-	envMap, err := GetEnvFromFile(o.Environment, wd, o.EnvFile)
+	envMap, err := GetEnvFromFile(o.Environment, wd, o.EnvFiles)
 	if err != nil {
 		return err
 	}
@@ -213,55 +220,48 @@ func WithDotEnv(o *ProjectOptions) error {
 	return nil
 }
 
-func GetEnvFromFile(currentEnv map[string]string, workingDir string, filename string) (map[string]string, error) {
+func GetEnvFromFile(currentEnv map[string]string, workingDir string, filenames []string) (map[string]string, error) {
 	envMap := make(map[string]string)
 
-	dotEnvFile := filename
-	if dotEnvFile == "" {
-		dotEnvFile = filepath.Join(workingDir, ".env")
-	}
-	abs, err := filepath.Abs(dotEnvFile)
-	if err != nil {
-		return envMap, err
+	dotEnvFiles := filenames
+	if len(dotEnvFiles) == 0 {
+		dotEnvFiles = append(dotEnvFiles, filepath.Join(workingDir, ".env"))
 	}
-	dotEnvFile = abs
-
-	s, err := os.Stat(dotEnvFile)
-	if os.IsNotExist(err) {
-		if filename != "" {
-			return nil, errors.Errorf("Couldn't find env file: %s", filename)
+	for _, dotEnvFile := range dotEnvFiles {
+		abs, err := filepath.Abs(dotEnvFile)
+		if err != nil {
+			return envMap, err
 		}
-		return envMap, nil
-	}
-	if err != nil {
-		return envMap, err
-	}
+		dotEnvFile = abs
 
-	if s.IsDir() {
-		if filename == "" {
+		b, err := os.ReadFile(dotEnvFile)
+		if os.IsNotExist(err) {
+			if len(filenames) > 0 {
+				return nil, errors.Errorf("Couldn't read env file: %s", dotEnvFile)
+			}
 			return envMap, nil
 		}
-		return envMap, errors.Errorf("%s is a directory", dotEnvFile)
-	}
-
-	file, err := os.Open(dotEnvFile)
-	if err != nil {
-		return envMap, errors.Wrapf(err, "failed to read %s", dotEnvFile)
-	}
-	defer file.Close()
+		if err != nil {
+			return envMap, err
+		}
 
-	env, err := dotenv.ParseWithLookup(file, func(k string) (string, bool) {
-		v, ok := currentEnv[k]
-		if !ok {
-			return "", false
+		env, err := dotenv.ParseWithLookup(bytes.NewReader(b), func(k string) (string, bool) {
+			v, ok := envMap[k]
+			if ok {
+				return v, true
+			}
+			v, ok = currentEnv[k]
+			if !ok {
+				return "", false
+			}
+			return v, true
+		})
+		if err != nil {
+			return envMap, errors.Wrapf(err, "failed to read %s", dotEnvFile)
+		}
+		for k, v := range env {
+			envMap[k] = v
 		}
-		return v, true
-	})
-	if err != nil {
-		return envMap, errors.Wrapf(err, "failed to read %s", dotEnvFile)
-	}
-	for k, v := range env {
-		envMap[k] = v
 	}
 
 	return envMap, nil


Test patch:
diff --git a/cli/options_test.go b/cli/options_test.go
index ffbb5e5..7e41c77 100644
--- a/cli/options_test.go
+++ b/cli/options_test.go
@@ -226,6 +226,24 @@ func TestProjectWithDiscardEnvFile(t *testing.T) {
 	assert.NilError(t, err)
 	assert.Equal(t, *service.Environment["DEFAULT_PORT"], "8080")
 	assert.Assert(t, service.EnvFile == nil)
+	assert.Equal(t, service.Ports[0].Published, "8000")
+}
+
+func TestProjectWithMultipleEnvFile(t *testing.T) {
+	opts, err := NewProjectOptions([]string{
+		"testdata/env-file/compose-with-env-files.yaml",
+	}, WithDiscardEnvFile,
+		WithEnvFiles("testdata/env-file/.env", "testdata/env-file/override.env"),
+		WithDotEnv)
+
+	assert.NilError(t, err)
+	p, err := ProjectFromOptions(opts)
+	assert.NilError(t, err)
+	service, err := p.GetService("simple")
+	assert.NilError(t, err)
+	assert.Equal(t, *service.Environment["DEFAULT_PORT"], "9090")
+	assert.Assert(t, service.EnvFile == nil)
+	assert.Equal(t, service.Ports[0].Published, "9000")
 }
 
 func TestProjectNameFromWorkingDir(t *testing.T) {
diff --git a/cli/testdata/env-file/.env b/cli/testdata/env-file/.env
index 7171c68..876feea 100644
--- a/cli/testdata/env-file/.env
+++ b/cli/testdata/env-file/.env
@@ -1,2 +1,3 @@
 COMPOSE_FILE=compose-with-env-file.yaml
-COMPOSE_PROJECT_NAME=my_project_from_dot_env
\ No newline at end of file
+COMPOSE_PROJECT_NAME=my_project_from_dot_env
+PORT=8000
diff --git a/cli/testdata/env-file/compose-with-env-files.yaml b/cli/testdata/env-file/compose-with-env-files.yaml
new file mode 100644
index 0000000..5b31397
--- /dev/null
+++ b/cli/testdata/env-file/compose-with-env-files.yaml
@@ -0,0 +1,9 @@
+version: "3"
+services:
+  simple:
+    image: nginx
+    env_file:
+      - ./simple-env
+      - ./second-env
+    ports:
+    - ${PORT}:80
diff --git a/cli/testdata/env-file/override.env b/cli/testdata/env-file/override.env
new file mode 100644
index 0000000..00ee76b
--- /dev/null
+++ b/cli/testdata/env-file/override.env
@@ -0,0 +1,1 @@
+PORT=9000
diff --git a/cli/testdata/env-file/second-env b/cli/testdata/env-file/second-env
new file mode 100644
index 0000000..3f5001b
--- /dev/null
+++ b/cli/testdata/env-file/second-env
@@ -0,0 +1,1 @@
+DEFAULT_PORT=9090
diff --git a/cli/testdata/env-file/simple-env b/cli/testdata/env-file/simple-env
index e2ba368..d2a8165 100644
--- a/cli/testdata/env-file/simple-env
+++ b/cli/testdata/env-file/simple-env
@@ -1,1 +1,1 @@
-DEFAULT_PORT=8080
\ No newline at end of file
+DEFAULT_PORT=8080