Repository: compose-spec/compose-go
Pull request #558 (2024-02-02 06:52:03)

set default values to required attributes
This is an alternative to https://github.com/compose-spec/compose-go/pull/557

having an explicit "set-defaults" step parsing compose.yaml files allows to keep the `extends` source in its original form, without an implicit `context: .` value, and prevent incorrect path resolution to take place depending we extends within same file (already parsed) or from another one (to be parsed).



Patch:
diff --git a/loader/extends.go b/loader/extends.go
index b24a4d5..7a224cf 100644
--- a/loader/extends.go
+++ b/loader/extends.go
@@ -140,6 +140,7 @@ func getExtendsBaseFromFile(ctx context.Context, name string, path string, opts
 		extendsOpts.SkipInclude = true
 		extendsOpts.SkipExtends = true    // we manage extends recursively based on raw service definition
 		extendsOpts.SkipValidation = true // we validate the merge result
+		extendsOpts.SkipDefaultValues = true
 		source, err := loadYamlModel(ctx, types.ConfigDetails{
 			WorkingDir: relworkingdir,
 			ConfigFiles: []types.ConfigFile{
diff --git a/loader/full-example.yml b/loader/full-example.yml
index e489bbe..25dfbce 100644
--- a/loader/full-example.yml
+++ b/loader/full-example.yml
@@ -26,7 +26,8 @@ services:
       additional_contexts:
         foo: ./bar
       secrets:
-        - secret1
+        - source: secret1
+          target: /run/secrets/secret1
         - source: secret2
           target: my_secret
           uid: '103'
@@ -257,7 +258,8 @@ services:
     restart: always
 
     secrets:
-      - secret1
+      - source: secret1
+        target: /run/secrets/secret1
       - source: secret2
         target: my_secret
         uid: '103'
diff --git a/loader/loader.go b/loader/loader.go
index ec5d406..bf570d3 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -64,6 +64,8 @@ type Options struct {
 	SkipInclude bool
 	// SkipResolveEnvironment will ignore computing `environment` for services
 	SkipResolveEnvironment bool
+	// SkipDefaultValues will ignore missing required attributes
+	SkipDefaultValues bool
 	// Interpolation options
 	Interpolate *interp.Options
 	// Discard 'env_file' entries after resolving to 'environment' section
@@ -417,6 +419,13 @@ func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Option
 		return nil, err
 	}
 
+	if !opts.SkipDefaultValues {
+		dict, err = transform.SetDefaultValues(dict)
+		if err != nil {
+			return nil, err
+		}
+	}
+
 	if !opts.SkipValidation {
 		if err := validation.Validate(dict); err != nil {
 			return nil, err
diff --git a/parsing.md b/parsing.md
index 0badb73..0e82b28 100644
--- a/parsing.md
+++ b/parsing.md
@@ -127,13 +127,19 @@ During loading, all those attributes are transformed into canonical
 representation, so that we get a single format that will match to go structs
 for binding.
 
-# Phase 12: extensions
+# Phase 12: set-defaults
+
+Some attributes are required by the model but optional in the compose file, as an implicit 
+default value is defined by the specification, like [`build.context`](https://github.com/compose-spec/compose-spec/blob/master/build.md#context)
+During this phase, such unset attributes get default value assigned.
+
+# Phase 13: extensions
 
 Extension (`x-*` attributes) can be used in any place in the yaml document.
 To make unmarshalling easier, parsing move them all into a custom `#extension`
 attribute. This hack is very specific to the go binding.
 
-# Phase 13: relative paths
+# Phase 14: relative paths
 
 Compose allows paths to be set relative to the project directory. Those get resolved
 into absolute paths during this phase. This involves a few corner cases, as
@@ -152,7 +158,7 @@ volumes:
       device: './data' # such a relative path must be resolved
 ```
 
-# Phase 14: go binding
+# Phase 15: go binding
 
 Eventually, the yaml tree can be unmarshalled into go structs. We rely on
 [mapstructure](https://github.com/mitchellh/mapstructure) library for this purpose.
diff --git a/transform/build.go b/transform/build.go
index f56e1a3..88ac05b 100644
--- a/transform/build.go
+++ b/transform/build.go
@@ -25,9 +25,6 @@ import (
 func transformBuild(data any, p tree.Path) (any, error) {
 	switch v := data.(type) {
 	case map[string]any:
-		if _, ok := v["context"]; !ok {
-			v["context"] = "." // TODO(ndeloof) maybe we miss an explicit "set-defaults" loading phase
-		}
 		return transformMapping(v, p)
 	case string:
 		return map[string]any{
@@ -37,3 +34,15 @@ func transformBuild(data any, p tree.Path) (any, error) {
 		return data, fmt.Errorf("%s: invalid type %T for build", p, v)
 	}
 }
+
+func defaultBuildContext(data any, _ tree.Path) (any, error) {
+	switch v := data.(type) {
+	case map[string]any:
+		if _, ok := v["context"]; !ok {
+			v["context"] = "."
+		}
+		return v, nil
+	default:
+		return data, nil
+	}
+}
diff --git a/transform/defaults.go b/transform/defaults.go
new file mode 100644
index 0000000..49293b7
--- /dev/null
+++ b/transform/defaults.go
@@ -0,0 +1,87 @@
+/*
+   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"
+)
+
+var defaultValues = map[tree.Path]transformFunc{}
+
+func init() {
+	defaultValues["services.*.build"] = defaultBuildContext
+	defaultValues["services.*.secrets.*"] = defaultSecretMount
+}
+
+// SetDefaultValues transforms a compose model to set default values to missing attributes
+func SetDefaultValues(yaml map[string]any) (map[string]any, error) {
+	result, err := setDefaults(yaml, tree.NewPath())
+	if err != nil {
+		return nil, err
+	}
+	return result.(map[string]any), nil
+}
+
+func setDefaults(data any, p tree.Path) (any, error) {
+	for pattern, transformer := range defaultValues {
+		if p.Matches(pattern) {
+			t, err := transformer(data, p)
+			if err != nil {
+				return nil, err
+			}
+			return t, nil
+		}
+	}
+	switch v := data.(type) {
+	case map[string]any:
+		a, err := setDefaultsMapping(v, p)
+		if err != nil {
+			return a, err
+		}
+		return v, nil
+	case []any:
+		a, err := setDefaultsSequence(v, p)
+		if err != nil {
+			return a, err
+		}
+		return v, nil
+	default:
+		return data, nil
+	}
+}
+
+func setDefaultsSequence(v []any, p tree.Path) ([]any, error) {
+	for i, e := range v {
+		t, err := setDefaults(e, p.Next("[]"))
+		if err != nil {
+			return nil, err
+		}
+		v[i] = t
+	}
+	return v, nil
+}
+
+func setDefaultsMapping(v map[string]any, p tree.Path) (map[string]any, error) {
+	for k, e := range v {
+		t, err := setDefaults(e, p.Next(k))
+		if err != nil {
+			return nil, err
+		}
+		v[k] = t
+	}
+	return v, nil
+}
diff --git a/transform/secrets.go b/transform/secrets.go
index 11f8855..1e84d18 100644
--- a/transform/secrets.go
+++ b/transform/secrets.go
@@ -34,3 +34,16 @@ func transformFileMount(data any, p tree.Path) (any, error) {
 		return nil, fmt.Errorf("%s: unsupported type %T", p, data)
 	}
 }
+
+func defaultSecretMount(data any, p tree.Path) (any, error) {
+	switch v := data.(type) {
+	case map[string]any:
+		source := v["source"]
+		if _, ok := v["target"]; !ok {
+			v["target"] = fmt.Sprintf("/run/secrets/%s", source)
+		}
+		return v, nil
+	default:
+		return nil, fmt.Errorf("%s: unsupported type %T", p, data)
+	}
+}


Test patch:
diff --git a/loader/extends_test.go b/loader/extends_test.go
index d798b91..c2721c0 100644
--- a/loader/extends_test.go
+++ b/loader/extends_test.go
@@ -18,11 +18,13 @@ package loader
 
 import (
 	"context"
+	"os"
 	"path/filepath"
 	"testing"
 
 	"github.com/compose-spec/compose-go/v2/types"
 	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
 )
 
 func TestExtends(t *testing.T) {
@@ -202,3 +204,58 @@ services:
 	assert.Equal(t, len(p.Services["test"].Ports), 1)
 
 }
+
+func TestLoadExtendsSameFile(t *testing.T) {
+	tmpdir := t.TempDir()
+
+	aDir := filepath.Join(tmpdir, "sub")
+	assert.NilError(t, os.Mkdir(aDir, 0o700))
+	aYAML := `
+services:
+  base:
+    build:
+      context: ..
+  service:
+    extends: base
+    build:
+      target: target
+`
+
+	assert.NilError(t, os.WriteFile(filepath.Join(tmpdir, "sub", "compose.yaml"), []byte(aYAML), 0o600))
+
+	rootYAML := `
+services:
+  out-base:
+    extends:
+      file: sub/compose.yaml
+      service: base
+  out-service:
+    extends:
+      file: sub/compose.yaml
+      service: service
+`
+
+	assert.NilError(t, os.WriteFile(filepath.Join(tmpdir, "compose.yaml"), []byte(rootYAML), 0o600))
+
+	actual, err := Load(types.ConfigDetails{
+		WorkingDir: tmpdir,
+		ConfigFiles: []types.ConfigFile{{
+			Filename: filepath.Join(tmpdir, "compose.yaml"),
+		}},
+		Environment: nil,
+	}, func(options *Options) {
+		options.SkipNormalization = true
+		options.SkipConsistencyCheck = true
+		options.SetProjectName("project", true)
+	})
+	assert.NilError(t, err)
+	assert.Assert(t, is.Len(actual.Services, 2))
+
+	svcA, err := actual.GetService("out-base")
+	assert.NilError(t, err)
+	assert.Equal(t, svcA.Build.Context, tmpdir)
+
+	svcB, err := actual.GetService("out-service")
+	assert.NilError(t, err)
+	assert.Equal(t, svcB.Build.Context, tmpdir)
+}
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index 01aa026..57decff 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -65,6 +65,7 @@ func services(workingDir, homeDir string) types.Services {
 				Secrets: []types.ServiceSecretConfig{
 					{
 						Source: "secret1",
+						Target: "/run/secrets/secret1",
 					},
 					{
 						Source: "secret2",
@@ -396,6 +397,7 @@ func services(workingDir, homeDir string) types.Services {
 			Secrets: []types.ServiceSecretConfig{
 				{
 					Source: "secret1",
+					Target: "/run/secrets/secret1",
 				},
 				{
 					Source: "secret2",
@@ -627,6 +629,7 @@ services:
       target: foo
       secrets:
         - source: secret1
+          target: /run/secrets/secret1
         - source: secret2
           target: my_secret
           uid: "103"
@@ -885,6 +888,7 @@ services:
     restart: always
     secrets:
       - source: secret1
+        target: /run/secrets/secret1
       - source: secret2
         target: my_secret
         uid: "103"
@@ -1180,7 +1184,8 @@ func fullExampleJSON(workingDir, homeDir string) string {
         "target": "foo",
         "secrets": [
           {
-            "source": "secret1"
+            "source": "secret1",
+            "target": "/run/secrets/secret1"
           },
           {
             "source": "secret2",
@@ -1544,7 +1549,8 @@ func fullExampleJSON(workingDir, homeDir string) string {
       "restart": "always",
       "secrets": [
         {
-          "source": "secret1"
+          "source": "secret1",
+          "target": "/run/secrets/secret1"
         },
         {
           "source": "secret2",
diff --git a/loader/loader_test.go b/loader/loader_test.go
index a94d874..41cb7a8 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -438,7 +438,7 @@ services:
 
 	svcB, err := actual.GetService("b")
 	assert.NilError(t, err)
-	assert.Equal(t, svcB.Build.Context, bDir)
+	assert.Equal(t, svcB.Build.Context, tmpdir)
 }
 
 func TestLoadExtendsWihReset(t *testing.T) {
@@ -828,6 +828,7 @@ networks:
 				Secrets: []types.ServiceSecretConfig{
 					{
 						Source: "super",
+						Target: "/run/secrets/super",
 						Mode:   uint32Ptr(555),
 					},
 				},
@@ -1842,6 +1843,7 @@ secrets:
 				Secrets: []types.ServiceSecretConfig{
 					{
 						Source: "secret",
+						Target: "/run/secrets/secret",
 					},
 				},
 			},
@@ -1911,6 +1913,7 @@ secrets:
 				Secrets: []types.ServiceSecretConfig{
 					{
 						Source: "secret",
+						Target: "/run/secrets/secret",
 					},
 				},
 			},
diff --git a/transform/build_test.go b/transform/build_test.go
index d9eab59..11b2566 100644
--- a/transform/build_test.go
+++ b/transform/build_test.go
@@ -43,7 +43,6 @@ func Test_transformBuild(t *testing.T) {
 				"dockerfile": "foo.Dockerfile",
 			},
 			want: map[string]any{
-				"context":    ".",
 				"dockerfile": "foo.Dockerfile",
 			},
 		},