diff --git a/loader/loader.go b/loader/loader.go index 8fb95088..af6df117 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -637,6 +637,8 @@ func modelToProject(dict map[string]interface{}, opts *Options, configDetails ty return nil, err } } + + project, err = project.WithServicesLabelsResolved(opts.discardEnvFiles) return project, nil } diff --git a/loader/loader_test.go b/loader/loader_test.go index 53186366..8f5f2cd2 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -2315,6 +2315,31 @@ func TestLoadServiceWithEnvFile(t *testing.T) { assert.Equal(t, "YES", *service.Environment["HALLO"]) } +func TestLoadServiceWithLabelFile(t *testing.T) { + file, err := os.CreateTemp("", "test-compose-go") + assert.NilError(t, err) + defer os.Remove(file.Name()) + + _, err = file.Write([]byte("MY_LABEL=MY_VALUE")) + assert.NilError(t, err) + + p := &types.Project{ + Services: types.Services{ + "test": { + Name: "test", + LabelFiles: []types.LabelFile{ + {Path: file.Name(), Required: true}, + }, + }, + }, + } + p, err = p.WithServicesLabelsResolved(false) + assert.NilError(t, err) + service, err := p.GetService("test") + assert.NilError(t, err) + assert.Equal(t, "MY_VALUE", service.Labels["MY_LABEL"]) +} + func TestLoadNoSSHInBuildConfig(t *testing.T) { actual, err := loadYAML(` name: load-no-ssh-in-build-config diff --git a/override/merge.go b/override/merge.go index 697dbc74..8cb0ed52 100644 --- a/override/merge.go +++ b/override/merge.go @@ -57,6 +57,7 @@ func init() { mergeSpecials["services.*.dns_search"] = mergeToSequence mergeSpecials["services.*.entrypoint"] = override mergeSpecials["services.*.env_file"] = mergeToSequence + mergeSpecials["services.*.label_file"] = mergeToSequence mergeSpecials["services.*.environment"] = mergeToSequence mergeSpecials["services.*.extra_hosts"] = mergeExtraHosts mergeSpecials["services.*.healthcheck.test"] = override diff --git a/override/uncity.go b/override/uncity.go index 3b0c63d3..6b5b8ae9 100644 --- a/override/uncity.go +++ b/override/uncity.go @@ -51,6 +51,7 @@ func init() { unique["services.*.env_file"] = envFileIndexer unique["services.*.expose"] = exposeIndexer unique["services.*.labels"] = keyValueIndexer + unique["services.*.label_file"] = labelFileIndexer unique["services.*.links"] = keyValueIndexer unique["services.*.networks.*.aliases"] = keyValueIndexer unique["services.*.networks.*.link_local_ips"] = keyValueIndexer @@ -227,3 +228,16 @@ func envFileIndexer(y any, p tree.Path) (string, error) { } return "", nil } + +func labelFileIndexer(y any, p tree.Path) (string, error) { + switch value := y.(type) { + case string: + return value, nil + case map[string]any: + if pathValue, ok := value["path"]; ok { + return pathValue.(string), nil + } + return "", fmt.Errorf("label path attribute %s is missing", p) + } + return "", nil +} diff --git a/paths/resolve.go b/paths/resolve.go index 303f39e2..0569e658 100644 --- a/paths/resolve.go +++ b/paths/resolve.go @@ -37,6 +37,7 @@ func ResolveRelativePaths(project map[string]any, base string, remotes []RemoteR "services.*.build.context": r.absContextPath, "services.*.build.additional_contexts.*": r.absContextPath, "services.*.env_file.*.path": r.absPath, + "services.*.label_file.*.path": r.absPath, "services.*.extends.file": r.absExtendsPath, "services.*.develop.watch.*.path": r.absSymbolicLink, "services.*.volumes.*": r.absVolumeMount, diff --git a/schema/compose-spec.json b/schema/compose-spec.json index b95a1498..fab1698f 100644 --- a/schema/compose-spec.json +++ b/schema/compose-spec.json @@ -240,6 +240,7 @@ "domainname": {"type": "string"}, "entrypoint": {"$ref": "#/definitions/command"}, "env_file": {"$ref": "#/definitions/env_file"}, + "label_file": {"$ref": "#/definitions/label_file"}, "environment": {"$ref": "#/definitions/list_or_dict"}, "expose": { @@ -852,6 +853,36 @@ }, "env_file": { + "oneOf": [ + {"type": "string"}, + { + "type": "array", + "items": { + "oneOf": [ + {"type": "string"}, + { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "required": { + "type": ["boolean", "string"], + "default": true + } + }, + "required": [ + "path" + ] + } + ] + } + } + ] + }, + + "label_file": { "oneOf": [ {"type": "string"}, { diff --git a/transform/canonical.go b/transform/canonical.go index ff5bb37d..27d03810 100644 --- a/transform/canonical.go +++ b/transform/canonical.go @@ -30,6 +30,7 @@ func init() { transformers["services.*.build.additional_contexts"] = transformKeyValue transformers["services.*.depends_on"] = transformDependsOn transformers["services.*.env_file"] = transformEnvFile + transformers["services.*.label_file"] = transformLabelFile transformers["services.*.extends"] = transformExtends transformers["services.*.networks"] = transformServiceNetworks transformers["services.*.volumes.*"] = transformVolumeMount diff --git a/transform/labelfile.go b/transform/labelfile.go new file mode 100644 index 00000000..db8470df --- /dev/null +++ b/transform/labelfile.go @@ -0,0 +1,55 @@ +/* + 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 ( + "fmt" + + "github.com/compose-spec/compose-go/v2/tree" +) + +func transformLabelFile(data any, p tree.Path, _ bool) (any, error) { + switch v := data.(type) { + case string: + return []any{ + transformLabelFileValue(v), + }, nil + case []any: + for i, e := range v { + v[i] = transformLabelFileValue(e) + } + return v, nil + default: + return nil, fmt.Errorf("%s: invalid type %T for label_file", p, v) + } +} + +func transformLabelFileValue(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/types/derived.gen.go b/types/derived.gen.go index d33ae280..3f8d92b7 100644 --- a/types/derived.gen.go +++ b/types/derived.gen.go @@ -485,6 +485,24 @@ func deriveDeepCopyService(dst, src *ServiceConfig) { } else { dst.Labels = nil } + if src.LabelFiles == nil { + dst.LabelFiles = nil + } else { + if dst.LabelFiles != nil { + if len(src.LabelFiles) > len(dst.LabelFiles) { + if cap(dst.LabelFiles) >= len(src.LabelFiles) { + dst.LabelFiles = (dst.LabelFiles)[:len(src.LabelFiles)] + } else { + dst.LabelFiles = make([]LabelFile, len(src.LabelFiles)) + } + } else if len(src.LabelFiles) < len(dst.LabelFiles) { + dst.LabelFiles = (dst.LabelFiles)[:len(src.LabelFiles)] + } + } else { + dst.LabelFiles = make([]LabelFile, len(src.LabelFiles)) + } + copy(dst.LabelFiles, src.LabelFiles) + } if src.CustomLabels != nil { dst.CustomLabels = make(map[string]string, len(src.CustomLabels)) deriveDeepCopy_4(dst.CustomLabels, src.CustomLabels) @@ -1428,6 +1446,12 @@ func deriveDeepCopy_24(dst, src *NetworkConfig) { } else { dst.Labels = nil } + if src.CustomLabels != nil { + dst.CustomLabels = make(map[string]string, len(src.CustomLabels)) + deriveDeepCopy_4(dst.CustomLabels, src.CustomLabels) + } else { + dst.CustomLabels = nil + } if src.EnableIPv6 == nil { dst.EnableIPv6 = nil } else { @@ -1459,6 +1483,12 @@ func deriveDeepCopy_25(dst, src *VolumeConfig) { } else { dst.Labels = nil } + if src.CustomLabels != nil { + dst.CustomLabels = make(map[string]string, len(src.CustomLabels)) + deriveDeepCopy_4(dst.CustomLabels, src.CustomLabels) + } else { + dst.CustomLabels = nil + } if src.Extensions != nil { dst.Extensions = make(map[string]any, len(src.Extensions)) src.Extensions.DeepCopy(dst.Extensions) diff --git a/types/labelfile.go b/types/labelfile.go new file mode 100644 index 00000000..ff0980e0 --- /dev/null +++ b/types/labelfile.go @@ -0,0 +1,47 @@ +/* + 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 LabelFile 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 LabelFile implement yaml.Marshaler +func (e LabelFile) MarshalYAML() (interface{}, error) { + if e.Required { + return e.Path, nil + } + return map[string]any{ + "path": e.Path, + "required": e.Required, + }, nil +} + +// MarshalJSON makes LabelFile implement json.Marshaler +func (e *LabelFile) 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/labels.go b/types/labels.go index 000476bf..7ea5edc4 100644 --- a/types/labels.go +++ b/types/labels.go @@ -24,6 +24,16 @@ import ( // Labels is a mapping type for labels type Labels map[string]string +func NewLabelsFromMappingWithEquals(mapping MappingWithEquals) Labels { + labels := Labels{} + for k, v := range mapping { + if v != nil { + labels[k] = *v + } + } + return labels +} + func (l Labels) Add(key, value string) Labels { if l == nil { l = Labels{} @@ -42,6 +52,14 @@ func (l Labels) AsList() []string { return s } +func (l Labels) ToMappingWithEquals() MappingWithEquals { + mapping := MappingWithEquals{} + for k, v := range l { + mapping[k] = &v + } + return mapping +} + // label value can be a string | number | boolean | null (empty) func labelValue(e interface{}) string { if e == nil { diff --git a/types/project.go b/types/project.go index 19d6e32b..9b8d75c6 100644 --- a/types/project.go +++ b/types/project.go @@ -633,6 +633,40 @@ func (p Project) WithServicesEnvironmentResolved(discardEnvFiles bool) (*Project return newProject, nil } +// WithServicesLabelsResolved parses label_files set for services to resolve the actual label map for services +// It returns a new Project instance with the changes and keep the original Project unchanged +func (p Project) WithServicesLabelsResolved(discardLabelFiles bool) (*Project, error) { + newProject := p.deepCopy() + for i, service := range newProject.Services { + labels := MappingWithEquals{} + // resolve variables based on other files we already parsed + var resolve dotenv.LookupFn = func(s string) (string, bool) { + v, ok := labels[s] + if ok && v != nil { + return *v, ok + } + return "", false + } + + for _, labelFile := range service.LabelFiles { + vars, err := loadLabelFile(labelFile, resolve) + if err != nil { + return nil, err + } + labels.OverrideBy(vars.ToMappingWithEquals()) + } + + labels = labels.OverrideBy(service.Labels.ToMappingWithEquals()) + service.Labels = NewLabelsFromMappingWithEquals(labels) + + if discardLabelFiles { + service.LabelFiles = nil + } + newProject.Services[i] = service + } + return newProject, nil +} + func loadEnvFile(envFile EnvFile, resolve dotenv.LookupFn) (Mapping, error) { if _, err := os.Stat(envFile.Path); os.IsNotExist(err) { if envFile.Required { @@ -640,15 +674,31 @@ func loadEnvFile(envFile EnvFile, resolve dotenv.LookupFn) (Mapping, error) { } return nil, nil } - file, err := os.Open(envFile.Path) + + return loadMappingFile(envFile.Path, envFile.Format, resolve) +} + +func loadLabelFile(labelFile LabelFile, resolve dotenv.LookupFn) (Mapping, error) { + if _, err := os.Stat(labelFile.Path); os.IsNotExist(err) { + if labelFile.Required { + return nil, fmt.Errorf("label file %s not found: %w", labelFile.Path, err) + } + return nil, nil + } + + return loadMappingFile(labelFile.Path, labelFile.Format, resolve) +} + +func loadMappingFile(path string, format string, resolve dotenv.LookupFn) (Mapping, error) { + file, err := os.Open(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) + if format != "" { + fileVars, err = dotenv.ParseWithFormat(file, path, resolve, format) } else { fileVars, err = dotenv.ParseWithLookup(file, resolve) } diff --git a/types/types.go b/types/types.go index 3cae5390..7be08c30 100644 --- a/types/types.go +++ b/types/types.go @@ -89,6 +89,7 @@ type ServiceConfig struct { Ipc string `yaml:"ipc,omitempty" json:"ipc,omitempty"` Isolation string `yaml:"isolation,omitempty" json:"isolation,omitempty"` Labels Labels `yaml:"labels,omitempty" json:"labels,omitempty"` + LabelFiles []LabelFile `yaml:"label_file,omitempty" json:"label_file,omitempty"` CustomLabels Labels `yaml:"-" json:"-"` Links []string `yaml:"links,omitempty" json:"links,omitempty"` Logging *LoggingConfig `yaml:"logging,omitempty" json:"logging,omitempty"`