Repository: compose-spec/compose-go
Pull request #102 (2021-02-03 14:18:03)

Merge top-level extensions
fixed `merge` to also handle top-level `x-*` extension fields


Patch:
diff --git a/go.sum b/go.sum
index 497b640..3fd0c3d 100644
--- a/go.sum
+++ b/go.sum
@@ -13,11 +13,9 @@ github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
 github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
 github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
-github.com/mattn/go-shellwords v1.0.10 h1:Y7Xqm8piKOO3v10Thp7Z36h4FYFjt5xB//6XvOrs2Gw=
-github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
+github.com/mattn/go-shellwords v1.0.11 h1:vCoR9VPpsk/TZFW2JwK5I9S0xdrtUq2bph6/YjEPnaw=
 github.com/mattn/go-shellwords v1.0.11/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
-github.com/mitchellh/mapstructure v1.4.0 h1:7ks8ZkOP5/ujthUsT07rNv+nkLXCQWKNHuwzOAesEks=
-github.com/mitchellh/mapstructure v1.4.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
 github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
diff --git a/loader/merge.go b/loader/merge.go
index 521dac3..9e13cd4 100644
--- a/loader/merge.go
+++ b/loader/merge.go
@@ -72,6 +72,10 @@ func merge(configs []*types.Config) (*types.Config, error) {
 		if err != nil {
 			return base, errors.Wrapf(err, "cannot merge configs from %s", override.Filename)
 		}
+		base.Extensions, err = mergeExtensions(base.Extensions, override.Extensions)
+		if err != nil {
+			return base, errors.Wrapf(err, "cannot merge extensions from %s", override.Filename)
+		}
 	}
 	return base, nil
 }
@@ -274,3 +278,11 @@ func mergeConfigs(base, override map[string]types.ConfigObjConfig) (map[string]t
 	err := mergo.Map(&base, &override, mergo.WithOverride)
 	return base, err
 }
+
+func mergeExtensions(base, override map[string]interface{}) (map[string]interface{}, error) {
+	if base == nil {
+		base = map[string]interface{}{}
+	}
+	err := mergo.Map(&base, &override, mergo.WithOverride)
+	return base, err
+}
diff --git a/types/config.go b/types/config.go
index 7e2acf3..067f339 100644
--- a/types/config.go
+++ b/types/config.go
@@ -18,6 +18,8 @@ package types
 
 import (
 	"encoding/json"
+
+	"github.com/mitchellh/mapstructure"
 )
 
 // ConfigDetails are the details about a group of ConfigFiles
@@ -42,13 +44,13 @@ type ConfigFile struct {
 
 // Config is a full compose file configuration and model
 type Config struct {
-	Filename   string                 `yaml:"-" json:"-"`
-	Services   Services               `json:"services"`
-	Networks   Networks               `yaml:",omitempty" json:"networks,omitempty"`
-	Volumes    Volumes                `yaml:",omitempty" json:"volumes,omitempty"`
-	Secrets    Secrets                `yaml:",omitempty" json:"secrets,omitempty"`
-	Configs    Configs                `yaml:",omitempty" json:"configs,omitempty"`
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Filename   string     `yaml:"-" json:"-"`
+	Services   Services   `json:"services"`
+	Networks   Networks   `yaml:",omitempty" json:"networks,omitempty"`
+	Volumes    Volumes    `yaml:",omitempty" json:"volumes,omitempty"`
+	Secrets    Secrets    `yaml:",omitempty" json:"secrets,omitempty"`
+	Configs    Configs    `yaml:",omitempty" json:"configs,omitempty"`
+	Extensions Extensions `yaml:",inline" json:"-"`
 }
 
 // Volumes is a map of VolumeConfig
@@ -63,6 +65,9 @@ type Secrets map[string]SecretConfig
 // Configs is a map of ConfigObjConfig
 type Configs map[string]ConfigObjConfig
 
+// Extensions is a map of custom extension
+type Extensions map[string]interface{}
+
 // MarshalJSON makes Config implement json.Marshaler
 func (c Config) MarshalJSON() ([]byte, error) {
 	m := map[string]interface{}{
@@ -86,3 +91,11 @@ func (c Config) MarshalJSON() ([]byte, error) {
 	}
 	return json.Marshal(m)
 }
+
+func (e Extensions) Get(name string, target interface{}) (bool, error) {
+	if v, ok := e[name]; ok {
+		err := mapstructure.Decode(v, target)
+		return true, err
+	}
+	return false, nil
+}
diff --git a/types/project.go b/types/project.go
index 922a1da..f4e947b 100644
--- a/types/project.go
+++ b/types/project.go
@@ -25,15 +25,15 @@ import (
 
 // Project is the result of loading a set of compose files
 type Project struct {
-	Name         string                 `yaml:"-" json:"-"`
-	WorkingDir   string                 `yaml:"-" json:"-"`
-	Services     Services               `json:"services"`
-	Networks     Networks               `yaml:",omitempty" json:"networks,omitempty"`
-	Volumes      Volumes                `yaml:",omitempty" json:"volumes,omitempty"`
-	Secrets      Secrets                `yaml:",omitempty" json:"secrets,omitempty"`
-	Configs      Configs                `yaml:",omitempty" json:"configs,omitempty"`
-	Extensions   map[string]interface{} `yaml:",inline" json:"-"` // https://github.com/golang/go/issues/6213
-	ComposeFiles []string               `yaml:"-" json:"-"`
+	Name         string     `yaml:"-" json:"-"`
+	WorkingDir   string     `yaml:"-" json:"-"`
+	Services     Services   `json:"services"`
+	Networks     Networks   `yaml:",omitempty" json:"networks,omitempty"`
+	Volumes      Volumes    `yaml:",omitempty" json:"volumes,omitempty"`
+	Secrets      Secrets    `yaml:",omitempty" json:"secrets,omitempty"`
+	Configs      Configs    `yaml:",omitempty" json:"configs,omitempty"`
+	Extensions   Extensions `yaml:",inline" json:"-"` // https://github.com/golang/go/issues/6213
+	ComposeFiles []string   `yaml:"-" json:"-"`
 
 	// DisabledServices track services which have been disable as profile is not active
 	DisabledServices Services `yaml:"-" json:"-"`


Test patch:
diff --git a/loader/merge_test.go b/loader/merge_test.go
index 00e4415..07966cc 100644
--- a/loader/merge_test.go
+++ b/loader/merge_test.go
@@ -217,10 +217,11 @@ func TestLoadLogging(t *testing.T) {
 						Environment: types.MappingWithEquals{},
 					},
 				},
-				Networks: types.Networks{},
-				Volumes:  types.Volumes{},
-				Secrets:  types.Secrets{},
-				Configs:  types.Configs{},
+				Networks:   types.Networks{},
+				Volumes:    types.Volumes{},
+				Secrets:    types.Secrets{},
+				Configs:    types.Configs{},
+				Extensions: types.Extensions{},
 			}, config)
 		})
 	}
@@ -341,10 +342,11 @@ func TestLoadMultipleServicePorts(t *testing.T) {
 						Environment: types.MappingWithEquals{},
 					},
 				},
-				Networks: types.Networks{},
-				Volumes:  types.Volumes{},
-				Secrets:  types.Secrets{},
-				Configs:  types.Configs{},
+				Networks:   types.Networks{},
+				Volumes:    types.Volumes{},
+				Secrets:    types.Secrets{},
+				Configs:    types.Configs{},
+				Extensions: types.Extensions{},
 			}, config)
 		})
 	}
@@ -465,10 +467,11 @@ func TestLoadMultipleSecretsConfig(t *testing.T) {
 						Environment: types.MappingWithEquals{},
 					},
 				},
-				Networks: types.Networks{},
-				Volumes:  types.Volumes{},
-				Secrets:  types.Secrets{},
-				Configs:  types.Configs{},
+				Networks:   types.Networks{},
+				Volumes:    types.Volumes{},
+				Secrets:    types.Secrets{},
+				Configs:    types.Configs{},
+				Extensions: types.Extensions{},
 			}, config)
 		})
 	}
@@ -589,10 +592,11 @@ func TestLoadMultipleConfigobjsConfig(t *testing.T) {
 						Environment: types.MappingWithEquals{},
 					},
 				},
-				Networks: types.Networks{},
-				Volumes:  types.Volumes{},
-				Secrets:  types.Secrets{},
-				Configs:  types.Configs{},
+				Networks:   types.Networks{},
+				Volumes:    types.Volumes{},
+				Secrets:    types.Secrets{},
+				Configs:    types.Configs{},
+				Extensions: types.Extensions{},
 			}, config)
 		})
 	}
@@ -703,10 +707,11 @@ func TestLoadMultipleUlimits(t *testing.T) {
 						Environment: types.MappingWithEquals{},
 					},
 				},
-				Networks: types.Networks{},
-				Volumes:  types.Volumes{},
-				Secrets:  types.Secrets{},
-				Configs:  types.Configs{},
+				Networks:   types.Networks{},
+				Volumes:    types.Volumes{},
+				Secrets:    types.Secrets{},
+				Configs:    types.Configs{},
+				Extensions: types.Extensions{},
 			}, config)
 		})
 	}
@@ -820,10 +825,11 @@ func TestLoadMultipleServiceNetworks(t *testing.T) {
 						Environment: types.MappingWithEquals{},
 					},
 				},
-				Networks: types.Networks{},
-				Volumes:  types.Volumes{},
-				Secrets:  types.Secrets{},
-				Configs:  types.Configs{},
+				Networks:   types.Networks{},
+				Volumes:    types.Volumes{},
+				Secrets:    types.Secrets{},
+				Configs:    types.Configs{},
+				Extensions: types.Extensions{},
 			}, config)
 		})
 	}
@@ -934,10 +940,11 @@ func TestLoadMultipleConfigs(t *testing.T) {
 				CapAdd:      []string{"NET_ADMIN", "SYS_ADMIN"},
 				Environment: types.MappingWithEquals{},
 			}},
-		Networks: types.Networks{},
-		Volumes:  types.Volumes{},
-		Secrets:  types.Secrets{},
-		Configs:  types.Configs{},
+		Networks:   types.Networks{},
+		Volumes:    types.Volumes{},
+		Secrets:    types.Secrets{},
+		Configs:    types.Configs{},
+		Extensions: types.Extensions{},
 	}, config)
 }
 
@@ -1004,9 +1011,10 @@ func TestLoadMultipleNetworks(t *testing.T) {
 				},
 			},
 		},
-		Volumes: types.Volumes{},
-		Secrets: types.Secrets{},
-		Configs: types.Configs{},
+		Volumes:    types.Volumes{},
+		Secrets:    types.Secrets{},
+		Configs:    types.Configs{},
+		Extensions: types.Extensions{},
 	}, config)
 }
 
@@ -1100,3 +1108,42 @@ func TestMergeServiceNetworkConfig(t *testing.T) {
 		},
 	)
 }
+
+func TestMergeTopLevelExtensions(t *testing.T) {
+	base := map[string]interface{}{
+		"x-foo": "foo",
+		"x-bar": map[string]interface{}{
+			"base": map[string]interface{}{},
+		},
+	}
+	override := map[string]interface{}{
+		"x-bar": map[string]interface{}{
+			"base": "qix",
+		},
+		"x-zot": "zot",
+	}
+	configDetails := types.ConfigDetails{
+		ConfigFiles: []types.ConfigFile{
+			{Filename: "base.yml", Config: base},
+			{Filename: "override.yml", Config: override},
+		},
+	}
+	config, err := loadTestProject(configDetails)
+	assert.NilError(t, err)
+	assert.DeepEqual(t, &types.Project{
+		Name:       "",
+		WorkingDir: "",
+		Services:   types.Services{},
+		Networks:   types.Networks{},
+		Volumes:    types.Volumes{},
+		Secrets:    types.Secrets{},
+		Configs:    types.Configs{},
+		Extensions: types.Extensions{
+			"x-foo": "foo",
+			"x-bar": map[string]interface{}{
+				"base": "qix",
+			},
+			"x-zot": "zot",
+		},
+	}, config)
+}
diff --git a/types/types_test.go b/types/types_test.go
index 11c5621..143ad8d 100644
--- a/types/types_test.go
+++ b/types/types_test.go
@@ -214,3 +214,24 @@ func TestSet(t *testing.T) {
 	s.append("two")
 	assert.Equal(t, len(s.toSlice()), 3)
 }
+
+type foo struct {
+	Bar string
+}
+
+func TestExtension(t *testing.T) {
+	x := Extensions{
+		"foo": map[string]interface{}{
+			"bar": "zot",
+		},
+	}
+	var foo foo
+	ok, err := x.Get("foo", &foo)
+	assert.NilError(t, err)
+	assert.Check(t, ok == true)
+	assert.Check(t, foo.Bar == "zot")
+
+	ok, err = x.Get("qiz", &foo)
+	assert.NilError(t, err)
+	assert.Check(t, ok == false)
+}