Repository: compose-spec/compose-go
Pull request #690 (2024-10-01 15:39:48)

introduce ability to use custom env_file format
This allows to register additional env_file format parser, so one can plug https://github.com/docker/cli/blob/master/opts/envfile.go as an alternate parser, to support reading "raw" env_file just like `docker run --env-file` does.

ndeloof: @jhrotko a test in `loader` would make sense if we had an actual alternate format implementation hosted in this repo, but here we just define an extension point. raw loader test is [here](https://github.com/docker/compose/pull/12179/files#diff-f16f866ca631acf1a7182a4354c0e9bd3c0c2ebcb56c5a8754a703a5585037f6)

Patch:
diff --git a/dotenv/fixtures/custom.format b/dotenv/fixtures/custom.format
new file mode 100644
index 0000000..6b5acf8
--- /dev/null
+++ b/dotenv/fixtures/custom.format
@@ -0,0 +1,2 @@
+FOO:BAR
+ZOT:QIX
\ No newline at end of file
diff --git a/dotenv/format.go b/dotenv/format.go
new file mode 100644
index 0000000..c583d21
--- /dev/null
+++ b/dotenv/format.go
@@ -0,0 +1,38 @@
+/*
+   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 dotenv
+
+import (
+	"fmt"
+	"io"
+)
+
+var formats = map[string]Parser{}
+
+type Parser func(r io.Reader, filename string, lookup func(key string) (string, bool)) (map[string]string, error)
+
+func RegisterFormat(format string, p Parser) {
+	formats[format] = p
+}
+
+func ParseWithFormat(r io.Reader, filename string, resolve LookupFn, format string) (map[string]string, error) {
+	parser, ok := formats[format]
+	if !ok {
+		return nil, fmt.Errorf("unsupported env_file format %q", format)
+	}
+	return parser(r, filename, resolve)
+}
diff --git a/dotenv/godotenv.go b/dotenv/godotenv.go
index e6635ce..7690724 100644
--- a/dotenv/godotenv.go
+++ b/dotenv/godotenv.go
@@ -86,7 +86,7 @@ func ReadWithLookup(lookupFn LookupFn, filenames ...string) (map[string]string,
 	envMap := make(map[string]string)
 
 	for _, filename := range filenames {
-		individualEnvMap, individualErr := readFile(filename, lookupFn)
+		individualEnvMap, individualErr := ReadFile(filename, lookupFn)
 
 		if individualErr != nil {
 			return envMap, individualErr
@@ -129,7 +129,7 @@ func filenamesOrDefault(filenames []string) []string {
 }
 
 func loadFile(filename string, overload bool) error {
-	envMap, err := readFile(filename, nil)
+	envMap, err := ReadFile(filename, nil)
 	if err != nil {
 		return err
 	}
@@ -150,7 +150,7 @@ func loadFile(filename string, overload bool) error {
 	return nil
 }
 
-func readFile(filename string, lookupFn LookupFn) (map[string]string, error) {
+func ReadFile(filename string, lookupFn LookupFn) (map[string]string, error) {
 	file, err := os.Open(filename)
 	if err != nil {
 		return nil, err
diff --git a/schema/compose-spec.json b/schema/compose-spec.json
index abd564f..79d0a3f 100644
--- a/schema/compose-spec.json
+++ b/schema/compose-spec.json
@@ -831,6 +831,9 @@
                   "path": {
                     "type": "string"
                   },
+                  "format": {
+                    "type": "string"
+                  },
                   "required": {
                     "type": ["boolean", "string"],
                     "default": true
diff --git a/types/envfile.go b/types/envfile.go
index f0fa722..1348f13 100644
--- a/types/envfile.go
+++ b/types/envfile.go
@@ -23,6 +23,7 @@ import (
 type EnvFile struct {
 	Path     string `yaml:"path,omitempty" json:"path,omitempty"`
 	Required bool   `yaml:"required" json:"required"`
+	Format   string `yaml:"format,omitempty" json:"format,omitempty"`
 }
 
 // MarshalYAML makes EnvFile implement yaml.Marshaler
diff --git a/types/project.go b/types/project.go
index 7e3ef5f..19d6e32 100644
--- a/types/project.go
+++ b/types/project.go
@@ -616,22 +616,11 @@ func (p Project) WithServicesEnvironmentResolved(discardEnvFiles bool) (*Project
 		}
 
 		for _, envFile := range service.EnvFiles {
-			if _, err := os.Stat(envFile.Path); os.IsNotExist(err) {
-				if envFile.Required {
-					return nil, fmt.Errorf("env file %s not found: %w", envFile.Path, err)
-				}
-				continue
-			}
-			b, err := os.ReadFile(envFile.Path)
+			vars, err := loadEnvFile(envFile, resolve)
 			if err != nil {
-				return nil, fmt.Errorf("failed to load %s: %w", envFile.Path, err)
+				return nil, err
 			}
-
-			fileVars, err := dotenv.ParseWithLookup(bytes.NewBuffer(b), resolve)
-			if err != nil {
-				return nil, fmt.Errorf("failed to read %s: %w", envFile.Path, err)
-			}
-			environment.OverrideBy(Mapping(fileVars).ToMappingWithEquals())
+			environment.OverrideBy(vars.ToMappingWithEquals())
 		}
 
 		service.Environment = environment.OverrideBy(service.Environment)
@@ -644,6 +633,31 @@ func (p Project) WithServicesEnvironmentResolved(discardEnvFiles bool) (*Project
 	return newProject, nil
 }
 
+func loadEnvFile(envFile EnvFile, resolve dotenv.LookupFn) (Mapping, error) {
+	if _, err := os.Stat(envFile.Path); os.IsNotExist(err) {
+		if envFile.Required {
+			return nil, fmt.Errorf("env file %s not found: %w", envFile.Path, err)
+		}
+		return nil, nil
+	}
+	file, err := os.Open(envFile.Path)
+	if err != nil {
+		return nil, err
+	}
+	defer file.Close() //nolint:errcheck
+
+	var fileVars map[string]string
+	if envFile.Format != "" {
+		fileVars, err = dotenv.ParseWithFormat(file, envFile.Path, resolve, envFile.Format)
+	} else {
+		fileVars, err = dotenv.ParseWithLookup(file, resolve)
+	}
+	if err != nil {
+		return nil, err
+	}
+	return fileVars, nil
+}
+
 func (p *Project) deepCopy() *Project {
 	if p == nil {
 		return nil


Test patch:
diff --git a/dotenv/godotenv_test.go b/dotenv/godotenv_test.go
index 0d77906..9e71b7a 100644
--- a/dotenv/godotenv_test.go
+++ b/dotenv/godotenv_test.go
@@ -1,8 +1,10 @@
 package dotenv
 
 import (
+	"bufio"
 	"bytes"
 	"errors"
+	"io"
 	"os"
 	"path/filepath"
 	"strings"
@@ -708,3 +710,35 @@ func TestGetEnvFromFile(t *testing.T) {
 	_, err = GetEnvFromFile(nil, []string{f})
 	assert.Check(t, strings.HasSuffix(err.Error(), ".env is a directory"))
 }
+
+func TestLoadWithFormat(t *testing.T) {
+	envFileName := "fixtures/custom.format"
+	expectedValues := map[string]string{
+		"FOO": "BAR",
+		"ZOT": "QIX",
+	}
+
+	custom := func(r io.Reader, f string, lookup func(key string) (string, bool)) (map[string]string, error) {
+		vars := map[string]string{}
+		scanner := bufio.NewScanner(r)
+		for scanner.Scan() {
+			key, value, found := strings.Cut(scanner.Text(), ":")
+			if !found {
+				value, found = lookup(key)
+				if !found {
+					continue
+				}
+			}
+			vars[key] = value
+		}
+		return vars, nil
+	}
+
+	RegisterFormat("custom", custom)
+
+	f, err := os.Open(envFileName)
+	assert.NilError(t, err)
+	env, err := ParseWithFormat(f, envFileName, nil, "custom")
+	assert.NilError(t, err)
+	assert.DeepEqual(t, expectedValues, env)
+}