Repository: compose-spec/compose-go
Pull request #713 (2024-11-22 08:52:18)

feat: Add label_file support to service
Issue: https://github.com/docker/compose/issues/12311

idsulik: @ndeloof hi! What do you think about the PR, should I continue implementing the feature like I do(it uses the same way as we load env_file) or should I do it in a different way?
ndeloof: Sounds good to me. I'd prefer we can rely on docker/cli parser for this file so we don't have 2 implementations, but this would mean we resolve labels in docker/compose, and on the other hand format is pretty minimalist, so let's go this way 
idsulik: > Do you have a use-case in mind

I did it to follow the current logic for the env_file. removed it + added tests
idsulik: @ndeloof pushed changes, now label_file has type `[]string`

Patch:
diff --git a/loader/example1.label b/loader/example1.label
new file mode 100644
index 0000000..27d43cf
--- /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 0000000..aa667c3
--- /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 2f7b8c4..944b2d4 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/loader.go b/loader/loader.go
index 8fb9508..612b91c 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/override/merge.go b/override/merge.go
index 697dbc7..8cb0ed5 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 303f39e..8bab0b4 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 b95a149..a0310c5 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 d33ae28..295a851 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 000476b..713c28f 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 fa4acc7..d8005c0 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 3774945..5da8b85 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"`


Test patch:
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index 7e8ac9e..187eea5 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_test.go b/loader/loader_test.go
index b223e21..8f062c0 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