diff --git a/cli/options_test.go b/cli/options_test.go index fcacf235..a3ecf9b5 100644 --- a/cli/options_test.go +++ b/cli/options_test.go @@ -270,7 +270,7 @@ func TestProjectWithDiscardEnvFile(t *testing.T) { service, err := p.GetService("simple") assert.NilError(t, err) assert.Equal(t, *service.Environment["DEFAULT_PORT"], "8080") - assert.Assert(t, service.EnvFile == nil) + assert.Assert(t, len(service.EnvFiles) == 0) assert.Equal(t, service.Ports[0].Published, "8000") } @@ -287,7 +287,7 @@ func TestProjectWithMultipleEnvFile(t *testing.T) { service, err := p.GetService("simple") assert.NilError(t, err) assert.Equal(t, *service.Environment["DEFAULT_PORT"], "9090") - assert.Assert(t, service.EnvFile == nil) + assert.Assert(t, len(service.EnvFiles) == 0) assert.Equal(t, service.Ports[0].Published, "9000") } diff --git a/loader/full-example.yml b/loader/full-example.yml index a021235e..494e16d6 100644 --- a/loader/full-example.yml +++ b/loader/full-example.yml @@ -141,7 +141,8 @@ services: # env_file: .env env_file: - ./example1.env - - ./example2.env + - path: ./example2.env + required: false # Mapping or list # Mapping values can be strings, numbers or null diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go index cc11967a..bbbd9116 100644 --- a/loader/full-struct_test.go +++ b/loader/full-struct_test.go @@ -176,9 +176,15 @@ func services(workingDir, homeDir string) types.Services { "ENV.WITH.DOT": strPtr("ok"), "ENV_WITH_UNDERSCORE": strPtr("ok"), }, - EnvFile: []string{ - filepath.Join(workingDir, "example1.env"), - filepath.Join(workingDir, "example2.env"), + EnvFiles: []types.EnvFile{ + { + Path: filepath.Join(workingDir, "example1.env"), + Required: true, + }, + { + Path: filepath.Join(workingDir, "example2.env"), + Required: false, + }, }, Expose: []string{"3000", "8000"}, ExternalLinks: []string{ @@ -729,7 +735,8 @@ services: QUX: qux_from_environment env_file: - %s - - %s + - path: %s + required: false expose: - "3000" - "8000" @@ -1324,7 +1331,10 @@ func fullExampleJSON(workingDir, homeDir string) string { }, "env_file": [ "%s", - "%s" + { + "path": "%s", + "required": false + } ], "expose": [ "3000", diff --git a/loader/loader_test.go b/loader/loader_test.go index 0ab4f6c1..3e1ad481 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -907,7 +907,8 @@ services: image: nginx env_file: - example1.env - - example2.env + - path: example2.env + required: false ` expectedEnvironmentMap := types.MappingWithEquals{ "FOO": strPtr("foo_from_env_file"), @@ -925,14 +926,21 @@ services: options.ResolvePaths = false }) assert.NilError(t, err) - assert.DeepEqual(t, configWithEnvFiles.Services["web"].EnvFile, types.StringList{"example1.env", - "example2.env"}) + assert.DeepEqual(t, configWithEnvFiles.Services["web"].EnvFiles, []types.EnvFile{ + { + Path: "example1.env", + Required: true, + }, + { + Path: "example2.env", + Required: false, + }}) assert.DeepEqual(t, configWithEnvFiles.Services["web"].Environment, expectedEnvironmentMap) // Custom behavior removes the `env_file` entries configWithoutEnvFiles, err := Load(configDetails, WithDiscardEnvFiles) assert.NilError(t, err) - assert.DeepEqual(t, configWithoutEnvFiles.Services["web"].EnvFile, types.StringList(nil)) + assert.Equal(t, len(configWithoutEnvFiles.Services["web"].EnvFiles), 0) assert.DeepEqual(t, configWithoutEnvFiles.Services["web"].Environment, expectedEnvironmentMap) } @@ -1929,7 +1937,11 @@ func TestLoadWithExtends(t *testing.T) { Environment: types.MappingWithEquals{ "SOURCE": strPtr("extends"), }, - EnvFile: []string{expectedEnvFilePath}, + EnvFiles: []types.EnvFile{ + { + Path: expectedEnvFilePath, + Required: true, + }}, Networks: map[string]*types.ServiceNetworkConfig{"default": nil}, Volumes: []types.ServiceVolumeConfig{{ Type: "bind", @@ -2090,8 +2102,10 @@ func TestLoadServiceWithEnvFile(t *testing.T) { }, Services: types.Services{ "test": { - Name: "test", - EnvFile: []string{file.Name()}, + Name: "test", + EnvFiles: []types.EnvFile{ + {Path: file.Name(), Required: true}, + }, }, }, } @@ -2450,8 +2464,11 @@ services: Name: "imported", ContainerName: "extends", // as defined by ./testdata/subdir/extra.env Environment: types.MappingWithEquals{"SOURCE": strPtr("extends")}, - EnvFile: types.StringList{ - filepath.Join(workingDir, "testdata", "subdir", "extra.env"), + EnvFiles: []types.EnvFile{ + { + Path: filepath.Join(workingDir, "testdata", "subdir", "extra.env"), + Required: true, + }, }, Image: "nginx", Volumes: []types.ServiceVolumeConfig{ @@ -2630,8 +2647,11 @@ services: Name: "foo", Image: "foo", Environment: types.MappingWithEquals{"FOO": strPtr("BAR")}, - EnvFile: types.StringList{ - filepath.Join(config.WorkingDir, "testdata", "remote", "env"), + EnvFiles: []types.EnvFile{ + { + Path: filepath.Join(config.WorkingDir, "testdata", "remote", "env"), + Required: true, + }, }, Volumes: []types.ServiceVolumeConfig{ { diff --git a/paths/resolve.go b/paths/resolve.go index 62a250a7..70a3fa09 100644 --- a/paths/resolve.go +++ b/paths/resolve.go @@ -33,7 +33,7 @@ func ResolveRelativePaths(project map[string]any, base string) error { r.resolvers = map[tree.Path]resolver{ "services.*.build.context": r.absContextPath, "services.*.build.additional_contexts.*": r.absContextPath, - "services.*.env_file": r.absPath, + "services.*.env_file.*.path": r.absPath, "services.*.extends.file": r.absPath, "services.*.develop.watch.*.path": r.absPath, "services.*.volumes.*": r.absVolumeMount, diff --git a/schema/compose-spec.json b/schema/compose-spec.json index 0469c9e0..1b12822d 100644 --- a/schema/compose-spec.json +++ b/schema/compose-spec.json @@ -215,7 +215,7 @@ "dns_search": {"$ref": "#/definitions/string_or_list"}, "domainname": {"type": "string"}, "entrypoint": {"$ref": "#/definitions/command"}, - "env_file": {"$ref": "#/definitions/string_or_list"}, + "env_file": {"$ref": "#/definitions/env_file"}, "environment": {"$ref": "#/definitions/list_or_dict"}, "expose": { @@ -775,6 +775,36 @@ ] }, + "env_file": { + "oneOf": [ + {"type": "string"}, + { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": true + } + }, + "required": [ + "path" + ] + } + ] + } + } + ] + }, + "string_or_list": { "oneOf": [ {"type": "string"}, diff --git a/transform/build.go b/transform/build.go index 505228c3..526dedbd 100644 --- a/transform/build.go +++ b/transform/build.go @@ -33,6 +33,6 @@ func transformBuild(data any, p tree.Path) (any, error) { "context": v, }, nil default: - return data, errors.Errorf("invalid type %T for build", v) + return data, errors.Errorf("%s: invalid type %T for build", p, v) } } diff --git a/transform/canonical.go b/transform/canonical.go index 6569f437..1c9f986d 100644 --- a/transform/canonical.go +++ b/transform/canonical.go @@ -28,6 +28,7 @@ func init() { transformers["services.*"] = transformService transformers["services.*.build.secrets.*"] = transformFileMount transformers["services.*.depends_on"] = transformDependsOn + transformers["services.*.env_file"] = transformEnvFile transformers["services.*.extends"] = transformExtends transformers["services.*.networks"] = transformServiceNetworks transformers["services.*.volumes.*"] = transformVolumeMount diff --git a/transform/dependson.go b/transform/dependson.go index fbaaff26..29772a5f 100644 --- a/transform/dependson.go +++ b/transform/dependson.go @@ -49,6 +49,6 @@ func transformDependsOn(data any, p tree.Path) (any, error) { } return d, nil default: - return data, errors.Errorf("invalid type %T for depend_on", v) + return data, errors.Errorf("%s: invalid type %T for depend_on", p, v) } } diff --git a/transform/envfile.go b/transform/envfile.go new file mode 100644 index 00000000..9715ad82 --- /dev/null +++ b/transform/envfile.go @@ -0,0 +1,54 @@ +/* + 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 transform + +import ( + "github.com/compose-spec/compose-go/v2/tree" + "github.com/pkg/errors" +) + +func transformEnvFile(data any, p tree.Path) (any, error) { + switch v := data.(type) { + case string: + return []any{ + transformEnvFileValue(v), + }, nil + case []any: + for i, e := range v { + v[i] = transformEnvFileValue(e) + } + return v, nil + default: + return nil, errors.Errorf("%s: invalid type %T for env_file", p, v) + } +} + +func transformEnvFileValue(data any) any { + switch v := data.(type) { + case string: + return map[string]any{ + "path": v, + "required": true, + } + case map[string]any: + if _, ok := v["required"]; !ok { + v["required"] = true + } + return v + } + return nil +} diff --git a/transform/envfile_test.go b/transform/envfile_test.go new file mode 100644 index 00000000..71cbc0e0 --- /dev/null +++ b/transform/envfile_test.go @@ -0,0 +1,79 @@ +/* + 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 transform + +import ( + "testing" + + "github.com/compose-spec/compose-go/v2/tree" + "gopkg.in/yaml.v3" + "gotest.tools/v3/assert" +) + +func TestSingle(t *testing.T) { + env, err := transformEnvFile(".env", tree.NewPath("service.test.env_file")) + assert.NilError(t, err) + assert.DeepEqual(t, env, []any{ + map[string]any{ + "path": ".env", + "required": true, + }, + }) +} + +func TestSequence(t *testing.T) { + var in any + err := yaml.Unmarshal([]byte(` + - .env + - other.env +`), &in) + assert.NilError(t, err) + env, err := transformEnvFile(in, tree.NewPath("service.test.env_file")) + assert.NilError(t, err) + assert.DeepEqual(t, env, []any{ + map[string]any{ + "path": ".env", + "required": true, + }, + map[string]any{ + "path": "other.env", + "required": true, + }, + }) +} + +func TestOptional(t *testing.T) { + var in any + err := yaml.Unmarshal([]byte(` + - .env + - path: other.env + required: false +`), &in) + assert.NilError(t, err) + env, err := transformEnvFile(in, tree.NewPath("service.test.env_file")) + assert.NilError(t, err) + assert.DeepEqual(t, env, []any{ + map[string]any{ + "path": ".env", + "required": true, + }, + map[string]any{ + "path": "other.env", + "required": false, + }, + }) +} diff --git a/transform/extends.go b/transform/extends.go index 6c65f7bb..feb965d1 100644 --- a/transform/extends.go +++ b/transform/extends.go @@ -30,6 +30,6 @@ func transformExtends(data any, p tree.Path) (any, error) { "service": v, }, nil default: - return data, errors.Errorf("invalid type %T for extends", v) + return data, errors.Errorf("%s: invalid type %T for extends", p, v) } } diff --git a/transform/include.go b/transform/include.go index c28a94ff..b967c185 100644 --- a/transform/include.go +++ b/transform/include.go @@ -21,7 +21,7 @@ import ( "github.com/pkg/errors" ) -func transformInclude(data any, _ tree.Path) (any, error) { +func transformInclude(data any, p tree.Path) (any, error) { switch v := data.(type) { case map[string]any: return v, nil @@ -30,6 +30,6 @@ func transformInclude(data any, _ tree.Path) (any, error) { "path": v, }, nil default: - return data, errors.Errorf("invalid type %T for external", v) + return data, errors.Errorf("%s: invalid type %T for external", p, v) } } diff --git a/transform/ports.go b/transform/ports.go index b6180738..231a75a5 100644 --- a/transform/ports.go +++ b/transform/ports.go @@ -25,7 +25,7 @@ import ( "github.com/pkg/errors" ) -func transformPorts(data any, _ tree.Path) (any, error) { +func transformPorts(data any, p tree.Path) (any, error) { switch entries := data.(type) { case []any: // We process the list instead of individual items here. @@ -64,12 +64,12 @@ func transformPorts(data any, _ tree.Path) (any, error) { case map[string]any: ports = append(ports, value) default: - return data, errors.Errorf("invalid type %T for port", value) + return data, errors.Errorf("%s: invalid type %T for port", p, value) } } return ports, nil default: - return data, errors.Errorf("invalid type %T for port", entries) + return data, errors.Errorf("%s: invalid type %T for port", p, entries) } } diff --git a/transform/ssh.go b/transform/ssh.go index 640a1088..ca71e13c 100644 --- a/transform/ssh.go +++ b/transform/ssh.go @@ -24,7 +24,7 @@ import ( "github.com/pkg/errors" ) -func transformSSH(data any, _ tree.Path) (any, error) { +func transformSSH(data any, p tree.Path) (any, error) { switch v := data.(type) { case map[string]any: return v, nil @@ -47,6 +47,6 @@ func transformSSH(data any, _ tree.Path) (any, error) { } return result, nil default: - return data, errors.Errorf("invalid type %T for ssh", v) + return data, errors.Errorf("%s: invalid type %T for ssh", p, v) } } diff --git a/transform/ulimits.go b/transform/ulimits.go index ba9e3879..80a997af 100644 --- a/transform/ulimits.go +++ b/transform/ulimits.go @@ -21,7 +21,7 @@ import ( "github.com/pkg/errors" ) -func transformUlimits(data any, _ tree.Path) (any, error) { +func transformUlimits(data any, p tree.Path) (any, error) { switch v := data.(type) { case map[string]any: return v, nil @@ -30,6 +30,6 @@ func transformUlimits(data any, _ tree.Path) (any, error) { "single": v, }, nil default: - return data, errors.Errorf("invalid type %T for external", v) + return data, errors.Errorf("%s: invalid type %T for external", p, v) } } diff --git a/transform/volume.go b/transform/volume.go index c1d592b2..757177fc 100644 --- a/transform/volume.go +++ b/transform/volume.go @@ -22,7 +22,7 @@ import ( "github.com/pkg/errors" ) -func transformVolumeMount(data any, _ tree.Path) (any, error) { +func transformVolumeMount(data any, p tree.Path) (any, error) { switch v := data.(type) { case map[string]any: return v, nil @@ -34,6 +34,6 @@ func transformVolumeMount(data any, _ tree.Path) (any, error) { return encode(volume) default: - return data, errors.Errorf("invalid type %T for service volume mount", v) + return data, errors.Errorf("%s: invalid type %T for service volume mount", p, v) } } diff --git a/types/envfile.go b/types/envfile.go new file mode 100644 index 00000000..f0fa7221 --- /dev/null +++ b/types/envfile.go @@ -0,0 +1,46 @@ +/* + 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 types + +import ( + "encoding/json" +) + +type EnvFile struct { + Path string `yaml:"path,omitempty" json:"path,omitempty"` + Required bool `yaml:"required" json:"required"` +} + +// MarshalYAML makes EnvFile implement yaml.Marshaler +func (e EnvFile) MarshalYAML() (interface{}, error) { + if e.Required { + return e.Path, nil + } + return map[string]any{ + "path": e.Path, + "required": e.Required, + }, nil +} + +// MarshalJSON makes EnvFile implement json.Marshaler +func (e *EnvFile) MarshalJSON() ([]byte, error) { + if e.Required { + return json.Marshal(e.Path) + } + // Pass as a value to avoid re-entering this method and use the default implementation + return json.Marshal(*e) +} diff --git a/types/project.go b/types/project.go index a3c4e7f7..87404f8f 100644 --- a/types/project.go +++ b/types/project.go @@ -533,15 +533,21 @@ func (p Project) ResolveServicesEnvironment(discardEnvFiles bool) error { return p.Environment.Resolve(s) } - for _, envFile := range service.EnvFile { - b, err := os.ReadFile(envFile) + for _, envFile := range service.EnvFiles { + if _, err := os.Stat(envFile.Path); os.IsNotExist(err) { + if envFile.Required { + return errors.Wrapf(err, "env file %s not found", envFile.Path) + } + continue + } + b, err := os.ReadFile(envFile.Path) if err != nil { - return errors.Wrapf(err, "Failed to load %s", envFile) + return errors.Wrapf(err, "failed to load %s", envFile.Path) } fileVars, err := dotenv.ParseWithLookup(bytes.NewBuffer(b), resolve) if err != nil { - return errors.Wrapf(err, "failed to read %s", envFile) + return errors.Wrapf(err, "failed to read %s", envFile.Path) } environment.OverrideBy(Mapping(fileVars).ToMappingWithEquals()) } @@ -549,7 +555,7 @@ func (p Project) ResolveServicesEnvironment(discardEnvFiles bool) error { service.Environment = environment.OverrideBy(service.Environment) if discardEnvFiles { - service.EnvFile = nil + service.EnvFiles = nil } p.Services[i] = service } diff --git a/types/types.go b/types/types.go index 17229af1..2eb75680 100644 --- a/types/types.go +++ b/types/types.go @@ -75,7 +75,7 @@ type ServiceConfig struct { Entrypoint ShellCommand `yaml:"entrypoint,omitempty" json:"entrypoint"` // NOTE: we can NOT omitempty for JSON! see ShellCommand type for details. Environment MappingWithEquals `yaml:"environment,omitempty" json:"environment,omitempty"` - EnvFile StringList `yaml:"env_file,omitempty" json:"env_file,omitempty"` + EnvFiles []EnvFile `yaml:"env_file,omitempty" json:"env_file,omitempty"` Expose StringOrNumberList `yaml:"expose,omitempty" json:"expose,omitempty"` Extends *ExtendsConfig `yaml:"extends,omitempty" json:"extends,omitempty"` ExternalLinks []string `yaml:"external_links,omitempty" json:"external_links,omitempty"`