Repository: compose-spec/compose-go
Pull request #234 (2022-03-04 16:00:27)

add mapping for ssh property in build section
This implement https://github.com/compose-spec/compose-spec/pull/234


Patch:
diff --git a/loader/full-example.yml b/loader/full-example.yml
index 71e7820..4f17450 100644
--- a/loader/full-example.yml
+++ b/loader/full-example.yml
@@ -7,6 +7,8 @@ services:
       dockerfile: Dockerfile
       args:
         foo: bar
+      ssh:
+        - default
       target: foo
       network: foo
       cache_from:
diff --git a/loader/loader.go b/loader/loader.go
index 8394287..5aeeb21 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -390,6 +390,7 @@ func createTransformHook(additionalTransformers ...Transformer) mapstructure.Dec
 		reflect.TypeOf(types.DependsOnConfig{}):                  transformDependsOnConfig,
 		reflect.TypeOf(types.ExtendsConfig{}):                    transformExtendsConfig,
 		reflect.TypeOf(types.DeviceRequest{}):                    transformServiceDeviceRequest,
+		reflect.TypeOf(types.SSHConfig{}):                        transformSSHConfig,
 	}
 
 	for _, transformer := range additionalTransformers {
@@ -978,6 +979,35 @@ var transformServiceNetworkMap TransformerFunc = func(value interface{}) (interf
 	return value, nil
 }
 
+var transformSSHConfig TransformerFunc = func(data interface{}) (interface{}, error) {
+	switch value := data.(type) {
+	case map[string]interface{}:
+		var result []types.SSHKey
+		for key, val := range value {
+			if val == nil {
+				val = ""
+			}
+			result = append(result, types.SSHKey{ID: key, Path: val.(string)})
+		}
+		return result, nil
+	case []interface{}:
+		var result []types.SSHKey
+		for _, v := range value {
+			key, val := transformValueToMapEntry(v.(string), "=", false)
+			result = append(result, types.SSHKey{ID: key, Path: val.(string)})
+		}
+		return result, nil
+	case string:
+		if value == "" {
+			value = "default"
+		}
+		key, val := transformValueToMapEntry(value, "=", false)
+		result := []types.SSHKey{{ID: key, Path: val.(string)}}
+		return result, nil
+	}
+	return nil, errors.Errorf("expected a sting, map or a list, got %T: %#v", data, data)
+}
+
 var transformStringOrNumberList TransformerFunc = func(value interface{}) (interface{}, error) {
 	list := value.([]interface{})
 	result := make([]string, len(list))
@@ -1000,47 +1030,52 @@ var transformStringList TransformerFunc = func(data interface{}) (interface{}, e
 
 func transformMappingOrListFunc(sep string, allowNil bool) TransformerFunc {
 	return func(data interface{}) (interface{}, error) {
-		return transformMappingOrList(data, sep, allowNil), nil
+		return transformMappingOrList(data, sep, allowNil)
 	}
 }
 
 func transformListOrMappingFunc(sep string, allowNil bool) TransformerFunc {
 	return func(data interface{}) (interface{}, error) {
-		return transformListOrMapping(data, sep, allowNil), nil
+		return transformListOrMapping(data, sep, allowNil)
 	}
 }
 
-func transformListOrMapping(listOrMapping interface{}, sep string, allowNil bool) interface{} {
+func transformListOrMapping(listOrMapping interface{}, sep string, allowNil bool) (interface{}, error) {
 	switch value := listOrMapping.(type) {
 	case map[string]interface{}:
-		return toStringList(value, sep, allowNil)
+		return toStringList(value, sep, allowNil), nil
 	case []interface{}:
-		return listOrMapping
+		return listOrMapping, nil
 	}
-	panic(errors.Errorf("expected a map or a list, got %T: %#v", listOrMapping, listOrMapping))
+	return nil, errors.Errorf("expected a map or a list, got %T: %#v", listOrMapping, listOrMapping)
 }
 
-func transformMappingOrList(mappingOrList interface{}, sep string, allowNil bool) interface{} {
+func transformMappingOrList(mappingOrList interface{}, sep string, allowNil bool) (interface{}, error) {
 	switch value := mappingOrList.(type) {
 	case map[string]interface{}:
-		return toMapStringString(value, allowNil)
+		return toMapStringString(value, allowNil), nil
 	case []interface{}:
 		result := make(map[string]interface{})
 		for _, value := range value {
-			parts := strings.SplitN(value.(string), sep, 2)
-			key := parts[0]
-			switch {
-			case len(parts) == 1 && allowNil:
-				result[key] = nil
-			case len(parts) == 1 && !allowNil:
-				result[key] = ""
-			default:
-				result[key] = parts[1]
-			}
+			key, val := transformValueToMapEntry(value.(string), sep, allowNil)
+			result[key] = val
 		}
-		return result
+		return result, nil
+	}
+	return nil, errors.Errorf("expected a map or a list, got %T: %#v", mappingOrList, mappingOrList)
+}
+
+func transformValueToMapEntry(value string, separator string, allowNil bool) (string, interface{}) {
+	parts := strings.SplitN(value, separator, 2)
+	key := parts[0]
+	switch {
+	case len(parts) == 1 && allowNil:
+		return key, nil
+	case len(parts) == 1 && !allowNil:
+		return key, ""
+	default:
+		return key, parts[1]
 	}
-	panic(errors.Errorf("expected a map or a list, got %T: %#v", mappingOrList, mappingOrList))
 }
 
 var transformShellCommand TransformerFunc = func(value interface{}) (interface{}, error) {
diff --git a/schema/compose-spec.json b/schema/compose-spec.json
index 91a3473..5bf4ddd 100644
--- a/schema/compose-spec.json
+++ b/schema/compose-spec.json
@@ -91,6 +91,7 @@
                 "context": {"type": "string"},
                 "dockerfile": {"type": "string"},
                 "args": {"$ref": "#/definitions/list_or_dict"},
+                "ssh": {"$ref": "#/definitions/list_or_dict"},
                 "labels": {"$ref": "#/definitions/list_or_dict"},
                 "cache_from": {"type": "array", "items": {"type": "string"}},
                 "cache_to": {"type": "array", "items": {"type": "string"}},
diff --git a/types/types.go b/types/types.go
index f23c114..e25150e 100644
--- a/types/types.go
+++ b/types/types.go
@@ -294,6 +294,7 @@ type BuildConfig struct {
 	Context    string            `yaml:",omitempty" json:"context,omitempty"`
 	Dockerfile string            `yaml:",omitempty" json:"dockerfile,omitempty"`
 	Args       MappingWithEquals `yaml:",omitempty" json:"args,omitempty"`
+	SSH        SSHConfig         `yaml:"ssh,omitempty" json:"ssh,omitempty"`
 	Labels     Labels            `yaml:",omitempty" json:"labels,omitempty"`
 	CacheFrom  StringList        `mapstructure:"cache_from" yaml:"cache_from,omitempty" json:"cache_from,omitempty"`
 	CacheTo    StringList        `mapstructure:"cache_to" yaml:"cache_to,omitempty" json:"cache_to,omitempty"`
@@ -426,6 +427,39 @@ func (l Labels) Add(key, value string) Labels {
 	return l
 }
 
+type SSHKey struct {
+	ID   string
+	Path string
+}
+
+// SSHConfig is a mapping type for SSH build config
+type SSHConfig []SSHKey
+
+func (s SSHConfig) Get(id string) (string, error) {
+	for _, sshKey := range s {
+		if sshKey.ID == id {
+			return sshKey.Path, nil
+		}
+	}
+	return "", fmt.Errorf("ID %s not found in SSH keys", id)
+}
+
+// MarshalYAML makes SSHKey implement yaml.Marshaller
+func (s SSHKey) MarshalYAML() (interface{}, error) {
+	if s.Path == "" {
+		return s.ID, nil
+	}
+	return fmt.Sprintf("%s: %s", s.ID, s.Path), nil
+}
+
+// MarshalJSON makes SSHKey implement json.Marshaller
+func (s SSHKey) MarshalJSON() ([]byte, error) {
+	if s.Path == "" {
+		return []byte(fmt.Sprintf(`"%s"`, s.ID)), nil
+	}
+	return []byte(fmt.Sprintf(`"%s": %s`, s.ID, s.Path)), nil
+}
+
 // MappingWithColon is a mapping type that can be converted from a list of
 // 'key: value' strings
 type MappingWithColon map[string]string


Test patch:
diff --git a/loader/full-struct_test.go b/loader/full-struct_test.go
index 8a1623e..ee19fd2 100644
--- a/loader/full-struct_test.go
+++ b/loader/full-struct_test.go
@@ -53,6 +53,7 @@ func services(workingDir, homeDir string) []types.ServiceConfig {
 				Context:    "./dir",
 				Dockerfile: "Dockerfile",
 				Args:       map[string]*string{"foo": strPtr("bar")},
+				SSH:        []types.SSHKey{{ID: "default", Path: ""}},
 				Target:     "foo",
 				Network:    "foo",
 				CacheFrom:  []string{"foo", "bar"},
@@ -569,6 +570,8 @@ services:
       dockerfile: Dockerfile
       args:
         foo: bar
+      ssh:
+      - default
       labels:
         FOO: BAR
       cache_from:
@@ -1082,6 +1085,9 @@ func fullExampleJSON(workingDir, homeDir string) string {
         "args": {
           "foo": "bar"
         },
+        "ssh": [
+          "default"
+        ],
         "labels": {
           "FOO": "BAR"
         },
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 78625be..a4106c8 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -1867,3 +1867,83 @@ func TestLoadServiceWithVolumes(t *testing.T) {
 	assert.Equal(t, len(s.Secrets), 1)
 	assert.Equal(t, "/path 4", s.Secrets[0].Target)
 }
+
+func TestLoadNoSSHInBuildConfig(t *testing.T) {
+	actual, err := loadYAML(`
+services:
+  test:
+    build:
+      context: .
+`)
+	assert.NilError(t, err)
+	svc, err := actual.GetService("test")
+	assert.NilError(t, err)
+	assert.Check(t, nil == svc.Build.SSH)
+}
+
+func TestLoadSSHWithoutValueInBuildConfig(t *testing.T) {
+	_, err := loadYAML(`
+services:
+  test:
+    build:
+      context: .
+      ssh:
+`)
+	assert.ErrorContains(t, err, "services.test.build.ssh must be a mapping")
+}
+
+func TestLoadSSHWithDefaultValueInBuildConfig(t *testing.T) {
+	actual, err := loadYAML(`
+services:
+  test:
+    build:
+      context: .
+      ssh: [default]
+`)
+	assert.NilError(t, err)
+	svc, err := actual.GetService("test")
+	assert.NilError(t, err)
+	sshValue, err := svc.Build.SSH.Get("default")
+	assert.NilError(t, err)
+	assert.Equal(t, "", sshValue)
+}
+
+func TestLoadSSHWithKeyValueInBuildConfig(t *testing.T) {
+	actual, err := loadYAML(`
+services:
+  test:
+    build:
+      context: .
+      ssh: 
+        key1: value1
+`)
+	assert.NilError(t, err)
+	svc, err := actual.GetService("test")
+	assert.NilError(t, err)
+	sshValue, err := svc.Build.SSH.Get("key1")
+	assert.NilError(t, err)
+	assert.Equal(t, "value1", sshValue)
+}
+
+func TestLoadSSHWithKeysValuesInBuildConfig(t *testing.T) {
+	actual, err := loadYAML(`
+services:
+  test:
+    build:
+      context: .
+      ssh: 
+        - key1=value1
+        - key2=value2
+`)
+	assert.NilError(t, err)
+	svc, err := actual.GetService("test")
+	assert.NilError(t, err)
+
+	sshValue, err := svc.Build.SSH.Get("key1")
+	assert.NilError(t, err)
+	assert.Equal(t, "value1", sshValue)
+
+	sshValue, err = svc.Build.SSH.Get("key2")
+	assert.NilError(t, err)
+	assert.Equal(t, "value2", sshValue)
+}