Skip to content

Commit b35ee99

Browse files
ndeloofglours
authored andcommitted
Fix interpolation priorities
Signed-off-by: Nicolas De Loof <[email protected]>
1 parent 83fc004 commit b35ee99

File tree

8 files changed

+120
-41
lines changed

8 files changed

+120
-41
lines changed

Diff for: dotenv/format.go

+8-8
Original file line numberDiff line numberDiff line change
@@ -24,28 +24,28 @@ import (
2424
const DotEnv = ".env"
2525

2626
var formats = map[string]Parser{
27-
DotEnv: func(r io.Reader, filename string, lookup func(key string) (string, bool)) (map[string]string, error) {
28-
m, err := ParseWithLookup(r, lookup)
27+
DotEnv: func(r io.Reader, filename string, vars map[string]string, lookup func(key string) (string, bool)) error {
28+
err := parseWithLookup(r, vars, lookup)
2929
if err != nil {
30-
return nil, fmt.Errorf("failed to read %s: %w", filename, err)
30+
return fmt.Errorf("failed to read %s: %w", filename, err)
3131
}
32-
return m, nil
32+
return nil
3333
},
3434
}
3535

36-
type Parser func(r io.Reader, filename string, lookup func(key string) (string, bool)) (map[string]string, error)
36+
type Parser func(r io.Reader, filename string, vars map[string]string, lookup func(key string) (string, bool)) error
3737

3838
func RegisterFormat(format string, p Parser) {
3939
formats[format] = p
4040
}
4141

42-
func ParseWithFormat(r io.Reader, filename string, resolve LookupFn, format string) (map[string]string, error) {
42+
func ParseWithFormat(r io.Reader, filename string, vars map[string]string, resolve LookupFn, format string) error {
4343
if format == "" {
4444
format = DotEnv
4545
}
4646
fn, ok := formats[format]
4747
if !ok {
48-
return nil, fmt.Errorf("unsupported env_file format %q", format)
48+
return fmt.Errorf("unsupported env_file format %q", format)
4949
}
50-
return fn(r, filename, resolve)
50+
return fn(r, filename, vars, resolve)
5151
}

Diff for: dotenv/godotenv.go

+9-2
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,23 @@ func Parse(r io.Reader) (map[string]string, error) {
4141

4242
// ParseWithLookup reads an env file from io.Reader, returning a map of keys and values.
4343
func ParseWithLookup(r io.Reader, lookupFn LookupFn) (map[string]string, error) {
44+
vars := map[string]string{}
45+
err := parseWithLookup(r, vars, lookupFn)
46+
return vars, err
47+
}
48+
49+
// ParseWithLookup reads an env file from io.Reader, returning a map of keys and values.
50+
func parseWithLookup(r io.Reader, vars map[string]string, lookupFn LookupFn) error {
4451
data, err := io.ReadAll(r)
4552
if err != nil {
46-
return nil, err
53+
return err
4754
}
4855

4956
// seek past the UTF-8 BOM if it exists (particularly on Windows, some
5057
// editors tend to add it, and it'll cause parsing to fail)
5158
data = bytes.TrimPrefix(data, utf8BOM)
5259

53-
return UnmarshalBytesWithLookup(data, lookupFn)
60+
return newParser().parse(string(data), vars, lookupFn)
5461
}
5562

5663
// Load will read your env file(s) and load them into ENV for this process.

Diff for: dotenv/godotenv_test.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -717,8 +717,7 @@ func TestLoadWithFormat(t *testing.T) {
717717
"ZOT": "QIX",
718718
}
719719

720-
custom := func(r io.Reader, _ string, lookup func(key string) (string, bool)) (map[string]string, error) {
721-
vars := map[string]string{}
720+
custom := func(r io.Reader, _ string, vars map[string]string, lookup func(key string) (string, bool)) error {
722721
scanner := bufio.NewScanner(r)
723722
for scanner.Scan() {
724723
key, value, found := strings.Cut(scanner.Text(), ":")
@@ -730,14 +729,15 @@ func TestLoadWithFormat(t *testing.T) {
730729
}
731730
vars[key] = value
732731
}
733-
return vars, nil
732+
return nil
734733
}
735734

736735
RegisterFormat("custom", custom)
737736

738737
f, err := os.Open(envFileName)
739738
assert.NilError(t, err)
740-
env, err := ParseWithFormat(f, envFileName, nil, "custom")
739+
env := map[string]string{}
740+
err = ParseWithFormat(f, envFileName, env, nil, "custom")
741741
assert.NilError(t, err)
742742
assert.DeepEqual(t, expectedValues, env)
743743
}

Diff for: loader/loader_test.go

-1
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,6 @@ services:
596596
"BAR": strPtr("2"),
597597
"GA": strPtr("2.5"),
598598
"BU": strPtr(""),
599-
"ZO": nil,
600599
"MEU": strPtr("Shadoks"),
601600
}
602601

Diff for: types/fixtures/base.env

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
FOO=foo_from_base.env
2+
INTERPOLATED_FOO=${FOO}
3+
BAR=bar_from_base.env
4+
INTERPOLATED_BAR=${BAR}
5+
ZOT=zot_from_base.env
6+
INTERPOLATED_ZOT=${ZOT}

Diff for: types/fixtures/override.env

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
FOO=foo_from_override.env
2+
INTERPOLATED_FOO=${FOO}
3+
BAR=bar_from_override.env
4+
INTERPOLATED_BAR=${BAR}

Diff for: types/project.go

+25-26
Original file line numberDiff line numberDiff line change
@@ -639,27 +639,27 @@ func (p *Project) MarshalJSON(options ...func(*marshallOptions)) ([]byte, error)
639639
func (p Project) WithServicesEnvironmentResolved(discardEnvFiles bool) (*Project, error) {
640640
newProject := p.deepCopy()
641641
for i, service := range newProject.Services {
642-
service.Environment = service.Environment.Resolve(newProject.Environment.Resolve)
643-
644-
environment := MappingWithEquals{}
645-
// resolve variables based on other files we already parsed, + project's environment
646-
var resolve dotenv.LookupFn = func(s string) (string, bool) {
647-
v, ok := environment[s]
648-
if ok && v != nil {
649-
return *v, ok
650-
}
651-
return newProject.Environment.Resolve(s)
652-
}
642+
service.Environment = service.Environment.Resolve(newProject.Environment.Resolve).RemoveEmpty()
653643

644+
environment := service.Environment.ToMapping()
654645
for _, envFile := range service.EnvFiles {
655-
vars, err := loadEnvFile(envFile, resolve)
646+
err := loadEnvFile(envFile, environment, func(k string) (string, bool) {
647+
// project.env has precedence doing interpolation
648+
if resolve, ok := p.Environment.Resolve(k); ok {
649+
return resolve, true
650+
}
651+
// then service.environment
652+
if s, ok := service.Environment[k]; ok {
653+
return *s, true
654+
}
655+
return "", false
656+
})
656657
if err != nil {
657658
return nil, err
658659
}
659-
environment.OverrideBy(vars.ToMappingWithEquals())
660660
}
661661

662-
service.Environment = environment.OverrideBy(service.Environment)
662+
service.Environment = environment.ToMappingWithEquals().OverrideBy(service.Environment)
663663

664664
if discardEnvFiles {
665665
service.EnvFiles = nil
@@ -707,37 +707,36 @@ func (p Project) WithServicesLabelsResolved(discardLabelFiles bool) (*Project, e
707707
return newProject, nil
708708
}
709709

710-
func loadEnvFile(envFile EnvFile, resolve dotenv.LookupFn) (Mapping, error) {
710+
func loadEnvFile(envFile EnvFile, environment Mapping, resolve dotenv.LookupFn) error {
711711
if _, err := os.Stat(envFile.Path); os.IsNotExist(err) {
712712
if envFile.Required {
713-
return nil, fmt.Errorf("env file %s not found: %w", envFile.Path, err)
713+
return fmt.Errorf("env file %s not found: %w", envFile.Path, err)
714714
}
715-
return nil, nil
715+
return nil
716716
}
717717

718-
return loadMappingFile(envFile.Path, envFile.Format, resolve)
718+
err := loadMappingFile(envFile.Path, envFile.Format, environment, resolve)
719+
return err
719720
}
720721

721722
func loadLabelFile(labelFile string, resolve dotenv.LookupFn) (Mapping, error) {
722723
if _, err := os.Stat(labelFile); os.IsNotExist(err) {
723724
return nil, fmt.Errorf("label file %s not found: %w", labelFile, err)
724725
}
725726

726-
return loadMappingFile(labelFile, "", resolve)
727+
labels := Mapping{}
728+
err := loadMappingFile(labelFile, "", labels, resolve)
729+
return labels, err
727730
}
728731

729-
func loadMappingFile(path string, format string, resolve dotenv.LookupFn) (Mapping, error) {
732+
func loadMappingFile(path string, format string, vars Mapping, resolve dotenv.LookupFn) error {
730733
file, err := os.Open(path)
731734
if err != nil {
732-
return nil, err
735+
return err
733736
}
734737
defer file.Close()
735738

736-
fileVars, err := dotenv.ParseWithFormat(file, path, resolve, format)
737-
if err != nil {
738-
return nil, err
739-
}
740-
return fileVars, nil
739+
return dotenv.ParseWithFormat(file, path, vars, resolve, format)
741740
}
742741

743742
func (p *Project) deepCopy() *Project {

Diff for: types/project_test.go

+64
Original file line numberDiff line numberDiff line change
@@ -432,3 +432,67 @@ secrets:
432432
content: SECRET`
433433
assert.Equal(t, strings.TrimSpace(string(yaml)), strings.TrimSpace(expected))
434434
}
435+
436+
func TestProject_WithServicesEnvironmentResolved(t *testing.T) {
437+
p := &Project{
438+
Services: Services{
439+
"base": ServiceConfig{
440+
Environment: MappingWithEquals{
441+
"FOO": ptr("foo_from_environment"),
442+
"BAR": ptr("bar_from_environment"),
443+
"QIX": nil,
444+
},
445+
EnvFiles: []EnvFile{
446+
{Path: "fixtures/base.env"},
447+
},
448+
},
449+
"override": ServiceConfig{
450+
Environment: MappingWithEquals{
451+
"FOO": ptr("foo_from_environment"),
452+
},
453+
EnvFiles: []EnvFile{
454+
{Path: "fixtures/base.env"},
455+
{Path: "fixtures/override.env"},
456+
},
457+
},
458+
},
459+
Environment: map[string]string{
460+
"FOO": "FOO_from_os.env",
461+
"QIX": "QIX_from_os.env",
462+
},
463+
}
464+
p, err := p.WithServicesEnvironmentResolved(true)
465+
assert.NilError(t, err)
466+
assert.DeepEqual(t, p.Services["base"].Environment, MappingWithEquals{
467+
// service.environment has precedence over env_file
468+
"FOO": ptr("foo_from_environment"),
469+
"BAR": ptr("bar_from_environment"),
470+
// service.environment without a value propagates os.env to container
471+
"QIX": ptr("QIX_from_os.env"),
472+
// value from env_file is loaded
473+
"ZOT": ptr("zot_from_base.env"),
474+
// os.env is always preferred for interpolation
475+
"INTERPOLATED_FOO": ptr("FOO_from_os.env"),
476+
// then service.environment is preferred for interpolation
477+
"INTERPOLATED_BAR": ptr("bar_from_environment"),
478+
// interpolation uses the value previously set in env file
479+
"INTERPOLATED_ZOT": ptr("zot_from_base.env"),
480+
})
481+
assert.DeepEqual(t, p.Services["override"].Environment, MappingWithEquals{
482+
// service.environment has precedence over env_file
483+
"FOO": ptr("foo_from_environment"),
484+
// value from env_file are loaded, with override
485+
"BAR": ptr("bar_from_override.env"),
486+
"ZOT": ptr("zot_from_base.env"),
487+
// os.env is always preferred for interpolation
488+
"INTERPOLATED_FOO": ptr("FOO_from_os.env"),
489+
// interpolation uses the overridden value from override.env
490+
"INTERPOLATED_BAR": ptr("bar_from_override.env"),
491+
// interpolation uses the value previously set in env file
492+
"INTERPOLATED_ZOT": ptr("zot_from_base.env"),
493+
})
494+
}
495+
496+
func ptr[T any](s T) *T {
497+
return &s
498+
}

0 commit comments

Comments
 (0)