diff --git a/loader/example1.label b/loader/example1.label new file mode 100644 index 00000000..27d43cff --- /dev/null +++ b/loader/example1.label @@ -0,0 +1,10 @@ +# passed through +FOO=foo_from_label_file +LABEL.WITH.DOT=ok +LABEL_WITH_UNDERSCORE=ok + +# overridden in example2.label +BAR=bar_from_label_file + +# overridden in full-example.yml +BAZ=baz_from_label_file diff --git a/loader/example2.label b/loader/example2.label new file mode 100644 index 00000000..aa667c30 --- /dev/null +++ b/loader/example2.label @@ -0,0 +1,4 @@ +BAR=bar_from_label_file_2 + +# overridden in configDetails.Labels +QUX=quz_from_label_file_2 diff --git a/loader/full-example.yml b/loader/full-example.yml index 2f7b8c43..944b2d47 100644 --- a/loader/full-example.yml +++ b/loader/full-example.yml @@ -210,6 +210,10 @@ services: # - "com.example.number=42" # - "com.example.empty-label" + label_file: + - ./example1.label + - ./example2.label + links: - db - db:database diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go index 7e8ac9e4..187eea59 100644 --- a/loader/full-struct_test.go +++ b/loader/full-struct_test.go @@ -217,10 +217,20 @@ func services(workingDir, homeDir string) types.Services { Ipc: "host", Uts: "host", Labels: map[string]string{ + "FOO": "foo_from_label_file", + "BAR": "bar_from_label_file_2", + "BAZ": "baz_from_label_file", + "QUX": "quz_from_label_file_2", + "LABEL.WITH.DOT": "ok", + "LABEL_WITH_UNDERSCORE": "ok", "com.example.description": "Accounting webapp", "com.example.number": "42", "com.example.empty-label": "", }, + LabelFiles: []string{ + filepath.Join(workingDir, "example1.label"), + filepath.Join(workingDir, "example2.label"), + }, Links: []string{ "db", "db:database", @@ -770,9 +780,18 @@ services: image: redis ipc: host labels: + BAR: bar_from_label_file_2 + BAZ: baz_from_label_file + FOO: foo_from_label_file + LABEL.WITH.DOT: ok + LABEL_WITH_UNDERSCORE: ok + QUX: quz_from_label_file_2 com.example.description: Accounting webapp com.example.empty-label: "" com.example.number: "42" + label_file: + - %s + - %s links: - db - db:database @@ -1058,6 +1077,8 @@ x-nested: filepath.Join(workingDir, "bar"), filepath.Join(workingDir, "example1.env"), filepath.Join(workingDir, "example2.env"), + filepath.Join(workingDir, "example1.label"), + filepath.Join(workingDir, "example2.label"), workingDir, filepath.Join(workingDir, "static"), filepath.Join(homeDir, "configs"), @@ -1382,10 +1403,20 @@ func fullExampleJSON(workingDir, homeDir string) string { "image": "redis", "ipc": "host", "labels": { + "BAR": "bar_from_label_file_2", + "BAZ": "baz_from_label_file", + "FOO": "foo_from_label_file", + "LABEL.WITH.DOT": "ok", + "LABEL_WITH_UNDERSCORE": "ok", + "QUX": "quz_from_label_file_2", "com.example.description": "Accounting webapp", "com.example.empty-label": "", "com.example.number": "42" }, + "label_file": [ + "%s", + "%s" + ], "links": [ "db", "db:database", @@ -1707,6 +1738,8 @@ func fullExampleJSON(workingDir, homeDir string) string { toPath(workingDir, "bar"), toPath(workingDir, "example1.env"), toPath(workingDir, "example2.env"), + toPath(workingDir, "example1.label"), + toPath(workingDir, "example2.label"), toPath(workingDir), toPath(workingDir, "static"), toPath(homeDir, "configs"), diff --git a/loader/loader.go b/loader/loader.go index 8fb95088..612b91ca 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -637,6 +637,12 @@ func modelToProject(dict map[string]interface{}, opts *Options, configDetails ty return nil, err } } + + project, err = project.WithServicesLabelsResolved(opts.discardEnvFiles) + if err != nil { + return nil, err + } + return project, nil } diff --git a/loader/loader_test.go b/loader/loader_test.go index b223e213..8f062c00 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -2319,6 +2319,46 @@ 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: []string{ + file.Name(), + }, + }, + }, + } + 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 TestLoadServiceWithLabelFile_NotExists(t *testing.T) { + p := &types.Project{ + Services: types.Services{ + "test": { + Name: "test", + LabelFiles: []string{ + "test", + }, + }, + }, + } + p, err := p.WithServicesLabelsResolved(false) + assert.ErrorContains(t, err, "label file test not found") +} + 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/paths/resolve.go b/paths/resolve.go index 303f39e2..8bab0b43 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.*": 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..a0310c59 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": { @@ -884,6 +885,16 @@ ] }, + "label_file": { + "oneOf": [ + {"type": "string"}, + { + "type": "array", + "items": {"type": "string"} + } + ] + }, + "string_or_list": { "oneOf": [ {"type": "string"}, diff --git a/types/derived.gen.go b/types/derived.gen.go index d33ae280..295a8514 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([]string, len(src.LabelFiles)) + } + } else if len(src.LabelFiles) < len(dst.LabelFiles) { + dst.LabelFiles = (dst.LabelFiles)[:len(src.LabelFiles)] + } + } else { + dst.LabelFiles = make([]string, 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) @@ -1473,6 +1503,7 @@ func deriveDeepCopy_26(dst, src *SecretConfig) { dst.File = src.File dst.Environment = src.Environment dst.Content = src.Content + dst.marshallContent = src.marshallContent dst.External = src.External if src.Labels != nil { dst.Labels = make(map[string]string, len(src.Labels)) @@ -1502,6 +1533,7 @@ func deriveDeepCopy_27(dst, src *ConfigObjConfig) { dst.File = src.File dst.Environment = src.Environment dst.Content = src.Content + dst.marshallContent = src.marshallContent dst.External = src.External if src.Labels != nil { dst.Labels = make(map[string]string, len(src.Labels)) diff --git a/types/labels.go b/types/labels.go index 000476bf..713c28f9 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,15 @@ func (l Labels) AsList() []string { return s } +func (l Labels) ToMappingWithEquals() MappingWithEquals { + mapping := MappingWithEquals{} + for k, v := range l { + v := v + 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 fa4acc7c..d8005c0a 100644 --- a/types/project.go +++ b/types/project.go @@ -663,6 +663,44 @@ 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()) + if len(labels) == 0 { + labels = nil + } else { + 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 { @@ -670,15 +708,28 @@ 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 string, resolve dotenv.LookupFn) (Mapping, error) { + if _, err := os.Stat(labelFile); os.IsNotExist(err) { + return nil, fmt.Errorf("label file %s not found: %w", labelFile, err) + } + + return loadMappingFile(labelFile, "", 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 37749450..5da8b853 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 []string `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"`