Repository: compose-spec/compose-go
Pull request #510 (2023-12-15 10:48:54)

COMPOSE_DISABLE_ENV_FILE allows to disable use of default .env file
COMPOSE_DISABLE_ENV_FILE allows to disable use of default .env file
closes https://github.com/docker/compose/issues/6741

this also removes default value management from  `GetEnvFromFile` which makes it a more general-purpose function


Patch:
diff --git a/cli/options.go b/cli/options.go
index 13be481..4e0b91d 100644
--- a/cli/options.go
+++ b/cli/options.go
@@ -21,6 +21,7 @@ import (
 	"io"
 	"os"
 	"path/filepath"
+	"strconv"
 	"strings"
 
 	"github.com/pkg/errors"
@@ -248,21 +249,44 @@ func WithEnvFile(file string) ProjectOptionsFn {
 	return WithEnvFiles(files...)
 }
 
-// WithEnvFiles set alternate env files
+// WithEnvFiles set env file(s) to be loaded to set project environment.
+// defaults to local .env file if no explicit file is selected, until COMPOSE_DISABLE_ENV_FILE is set
 func WithEnvFiles(file ...string) ProjectOptionsFn {
-	return func(options *ProjectOptions) error {
-		options.EnvFiles = file
+	return func(o *ProjectOptions) error {
+		if len(file) > 0 {
+			o.EnvFiles = file
+			return nil
+		}
+		if v, ok := os.LookupEnv(consts.ComposeDisableDefaultEnvFile); ok {
+			b, err := strconv.ParseBool(v)
+			if err != nil {
+				return err
+			}
+			if b {
+				return nil
+			}
+		}
+
+		wd, err := o.GetWorkingDir()
+		if err != nil {
+			return err
+		}
+		defaultDotEnv := filepath.Join(wd, ".env")
+
+		s, err := os.Stat(defaultDotEnv)
+		if os.IsNotExist(err) {
+			return nil
+		}
+		if !s.IsDir() {
+			o.EnvFiles = []string{defaultDotEnv}
+		}
 		return nil
 	}
 }
 
 // WithDotEnv imports environment variables from .env file
 func WithDotEnv(o *ProjectOptions) error {
-	wd, err := o.GetWorkingDir()
-	if err != nil {
-		return err
-	}
-	envMap, err := dotenv.GetEnvFromFile(o.Environment, wd, o.EnvFiles)
+	envMap, err := dotenv.GetEnvFromFile(o.Environment, o.EnvFiles)
 	if err != nil {
 		return err
 	}
diff --git a/consts/consts.go b/consts/consts.go
index 01e560d..592e6f0 100644
--- a/consts/consts.go
+++ b/consts/consts.go
@@ -17,10 +17,11 @@
 package consts
 
 const (
-	ComposeProjectName   = "COMPOSE_PROJECT_NAME"
-	ComposePathSeparator = "COMPOSE_PATH_SEPARATOR"
-	ComposeFilePath      = "COMPOSE_FILE"
-	ComposeProfiles      = "COMPOSE_PROFILES"
+	ComposeProjectName           = "COMPOSE_PROJECT_NAME"
+	ComposePathSeparator         = "COMPOSE_PATH_SEPARATOR"
+	ComposeFilePath              = "COMPOSE_FILE"
+	ComposeDisableDefaultEnvFile = "COMPOSE_DISABLE_ENV_FILE"
+	ComposeProfiles              = "COMPOSE_PROFILES"
 )
 
 const Extensions = "#extensions" // Using # prefix, we prevent risk to conflict with an actual yaml key
diff --git a/dotenv/env.go b/dotenv/env.go
index c8a538b..f534d11 100644
--- a/dotenv/env.go
+++ b/dotenv/env.go
@@ -24,14 +24,10 @@ import (
 	"github.com/pkg/errors"
 )
 
-func GetEnvFromFile(currentEnv map[string]string, workingDir string, filenames []string) (map[string]string, error) {
+func GetEnvFromFile(currentEnv map[string]string, filenames []string) (map[string]string, error) {
 	envMap := make(map[string]string)
 
-	dotEnvFiles := filenames
-	if len(dotEnvFiles) == 0 {
-		dotEnvFiles = append(dotEnvFiles, filepath.Join(workingDir, ".env"))
-	}
-	for _, dotEnvFile := range dotEnvFiles {
+	for _, dotEnvFile := range filenames {
 		abs, err := filepath.Abs(dotEnvFile)
 		if err != nil {
 			return envMap, err
@@ -40,9 +36,6 @@ func GetEnvFromFile(currentEnv map[string]string, workingDir string, filenames [
 
 		s, err := os.Stat(dotEnvFile)
 		if os.IsNotExist(err) {
-			if len(filenames) == 0 {
-				return envMap, nil
-			}
 			return envMap, errors.Errorf("Couldn't find env file: %s", dotEnvFile)
 		}
 		if err != nil {
diff --git a/loader/include.go b/loader/include.go
index 037a402..8d4e613 100644
--- a/loader/include.go
+++ b/loader/include.go
@@ -19,6 +19,7 @@ package loader
 import (
 	"context"
 	"fmt"
+	"os"
 	"path/filepath"
 	"reflect"
 	"strings"
@@ -76,7 +77,14 @@ func ApplyInclude(ctx context.Context, configDetails types.ConfigDetails, model
 		loadOptions.SkipNormalization = true
 		loadOptions.SkipConsistencyCheck = true
 
-		envFromFile, err := dotenv.GetEnvFromFile(configDetails.Environment, r.ProjectDirectory, r.EnvFile)
+		if len(r.EnvFile) == 0 {
+			f := filepath.Join(r.ProjectDirectory, ".env")
+			if s, err := os.Stat(f); err == nil && !s.IsDir() {
+				r.EnvFile = types.StringList{f}
+			}
+		}
+
+		envFromFile, err := dotenv.GetEnvFromFile(configDetails.Environment, r.EnvFile)
 		if err != nil {
 			return err
 		}
diff --git a/loader/loader.go b/loader/loader.go
index 3977da7..2501dab 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -342,6 +342,9 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
 				if err == io.EOF {
 					break
 				}
+				if err != nil {
+					return nil, err
+				}
 				if err := processRawYaml(raw, processor); err != nil {
 					return nil, err
 				}


Test patch:
diff --git a/cli/options_test.go b/cli/options_test.go
index a3ecf9b..07565c3 100644
--- a/cli/options_test.go
+++ b/cli/options_test.go
@@ -182,7 +182,7 @@ func TestProjectName(t *testing.T) {
 		assert.NilError(t, err)
 		defer os.Chdir(wd) //nolint:errcheck
 
-		opts, err := NewProjectOptions(nil, WithDotEnv, WithConfigFileEnv)
+		opts, err := NewProjectOptions(nil, WithEnvFiles(), WithDotEnv, WithConfigFileEnv)
 		assert.NilError(t, err)
 		p, err := ProjectFromOptions(opts)
 		assert.NilError(t, err)
@@ -250,7 +250,7 @@ func TestProjectWithDotEnv(t *testing.T) {
 
 	opts, err := NewProjectOptions([]string{
 		"compose-with-variables.yaml",
-	}, WithName("my_project"), WithDotEnv)
+	}, WithName("my_project"), WithEnvFiles(), WithDotEnv)
 	assert.NilError(t, err)
 	p, err := ProjectFromOptions(opts)
 	assert.NilError(t, err)
@@ -346,7 +346,7 @@ func TestEnvVariablePrecedence(t *testing.T) {
 				// First load os.Env variable, higher in precedence rule
 				WithEnv(test.osEnv),
 				// Then load dotEnv file
-				WithWorkingDirectory(wd), WithDotEnv)
+				WithWorkingDirectory(wd), WithEnvFiles(), WithDotEnv)
 			assert.NilError(t, err)
 			assert.DeepEqual(t, test.expected, options.Environment)
 		})
diff --git a/dotenv/godotenv_test.go b/dotenv/godotenv_test.go
index 4f6588e..1db02a5 100644
--- a/dotenv/godotenv_test.go
+++ b/dotenv/godotenv_test.go
@@ -703,9 +703,6 @@ func TestGetEnvFromFile(t *testing.T) {
 	err := os.Mkdir(f, 0o700)
 	assert.NilError(t, err)
 
-	_, err = GetEnvFromFile(nil, wd, nil)
-	assert.NilError(t, err)
-
-	_, err = GetEnvFromFile(nil, wd, []string{f})
+	_, err = GetEnvFromFile(nil, []string{f})
 	assert.Check(t, strings.HasSuffix(err.Error(), ".env is a directory"))
 }
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 3e1ad48..ad66e66 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -944,6 +944,14 @@ services:
 	assert.DeepEqual(t, configWithoutEnvFiles.Services["web"].Environment, expectedEnvironmentMap)
 }
 
+func TestDecodeErrors(t *testing.T) {
+	dict := "name: test\nservices:\n  web:\n    image: nginx\n\tbuild: ."
+
+	configDetails := buildConfigDetails(dict, nil)
+	_, err := Load(configDetails)
+	assert.Error(t, err, "yaml: line 4: found a tab character that violates indentation")
+}
+
 func TestBuildProperties(t *testing.T) {
 	dict := `
 name: test
@@ -2768,7 +2776,7 @@ services:
     image: example/proxy
     build: ./proxy
     develop:
-      watch: 
+      watch:
         # rebuild image and recreate service
         - path: ./proxy/proxy.conf
           action: sync+restart