Repository: compose-spec/compose-go
Pull request #255 (2022-04-30 08:30:14)

avoid conflict with `extension` used as service name
closes https://github.com/compose-spec/compose-go/issues/247


Patch:
diff --git a/loader/loader.go b/loader/loader.go
index 3843dc5..1f62e7c 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -313,6 +313,8 @@ func parseConfig(b []byte, opts *Options) (map[string]interface{}, error) {
 	return yml, err
 }
 
+const extensions = "#extensions" // Using # prefix, we prevent risk to conflict with an actual yaml key
+
 func groupXFieldsIntoExtensions(dict map[string]interface{}) map[string]interface{} {
 	extras := map[string]interface{}{}
 	for key, value := range dict {
@@ -325,7 +327,7 @@ func groupXFieldsIntoExtensions(dict map[string]interface{}) map[string]interfac
 		}
 	}
 	if len(extras) > 0 {
-		dict["extensions"] = extras
+		dict[extensions] = extras
 	}
 	return dict
 }
@@ -364,7 +366,7 @@ func loadSections(filename string, config map[string]interface{}, configDetails
 	if err != nil {
 		return nil, err
 	}
-	extensions := getSection(config, "extensions")
+	extensions := getSection(config, extensions)
 	if len(extensions) > 0 {
 		cfg.Extensions = extensions
 	}
@@ -526,7 +528,7 @@ func formatInvalidKeyError(keyPrefix string, key interface{}) error {
 func LoadServices(filename string, servicesDict map[string]interface{}, workingDir string, lookupEnv template.Mapping, opts *Options) ([]types.ServiceConfig, error) {
 	var services []types.ServiceConfig
 
-	x, ok := servicesDict["extensions"]
+	x, ok := servicesDict[extensions]
 	if ok {
 		// as a top-level attribute, "services" doesn't support extensions, and a service can be named `x-foo`
 		for k, v := range x.(map[string]interface{}) {
diff --git a/types/project.go b/types/project.go
index b4882ab..fdbd261 100644
--- a/types/project.go
+++ b/types/project.go
@@ -41,7 +41,7 @@ type Project struct {
 	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
+	Extensions   Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"` // https://github.com/golang/go/issues/6213
 	ComposeFiles []string   `yaml:"-" json:"-"`
 	Environment  Mapping    `yaml:"-" json:"-"`
 
diff --git a/types/types.go b/types/types.go
index 6fce198..9176b27 100644
--- a/types/types.go
+++ b/types/types.go
@@ -186,7 +186,7 @@ type ServiceConfig struct {
 	VolumesFrom     []string                         `mapstructure:"volumes_from" yaml:"volumes_from,omitempty" json:"volumes_from,omitempty"`
 	WorkingDir      string                           `mapstructure:"working_dir" yaml:"working_dir,omitempty" json:"working_dir,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // NetworksByPriority return the service networks IDs sorted according to Priority
@@ -314,7 +314,7 @@ type BuildConfig struct {
 	Platforms          StringList            `mapstructure:"platforms" yaml:"platforms,omitempty" json:"platforms,omitempty"`
 	Privileged         bool                  `yaml:",omitempty" json:"privileged,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // BlkioConfig define blkio config
@@ -326,7 +326,7 @@ type BlkioConfig struct {
 	DeviceWriteBps  []ThrottleDevice `mapstructure:"device_write_bps" yaml:",omitempty" json:"device_write_bps,omitempty"`
 	DeviceWriteIOps []ThrottleDevice `mapstructure:"device_write_iops" yaml:",omitempty" json:"device_write_iops,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // WeightDevice is a structure that holds device:weight pair
@@ -334,7 +334,7 @@ type WeightDevice struct {
 	Path   string
 	Weight uint16
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // ThrottleDevice is a structure that holds device:rate_per_second pair
@@ -342,7 +342,7 @@ type ThrottleDevice struct {
 	Path string
 	Rate uint64
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // ShellCommand is a string or list of string args.
@@ -565,7 +565,7 @@ type LoggingConfig struct {
 	Driver  string            `yaml:",omitempty" json:"driver,omitempty"`
 	Options map[string]string `yaml:",omitempty" json:"options,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // DeployConfig the deployment configuration for a service
@@ -580,7 +580,7 @@ type DeployConfig struct {
 	Placement      Placement      `yaml:",omitempty" json:"placement,omitempty"`
 	EndpointMode   string         `mapstructure:"endpoint_mode" yaml:"endpoint_mode,omitempty" json:"endpoint_mode,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // HealthCheckConfig the healthcheck configuration for a service
@@ -592,7 +592,7 @@ type HealthCheckConfig struct {
 	StartPeriod *Duration       `mapstructure:"start_period" yaml:"start_period,omitempty" json:"start_period,omitempty"`
 	Disable     bool            `yaml:",omitempty" json:"disable,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // HealthCheckTest is the command run to test the health of a service
@@ -607,7 +607,7 @@ type UpdateConfig struct {
 	MaxFailureRatio float32  `mapstructure:"max_failure_ratio" yaml:"max_failure_ratio,omitempty" json:"max_failure_ratio,omitempty"`
 	Order           string   `yaml:",omitempty" json:"order,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // Resources the resource limits and reservations
@@ -615,7 +615,7 @@ type Resources struct {
 	Limits       *Resource `yaml:",omitempty" json:"limits,omitempty"`
 	Reservations *Resource `yaml:",omitempty" json:"reservations,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // Resource is a resource to be limited or reserved
@@ -627,7 +627,7 @@ type Resource struct {
 	Devices          []DeviceRequest   `mapstructure:"devices" yaml:"devices,omitempty" json:"devices,omitempty"`
 	GenericResources []GenericResource `mapstructure:"generic_resources" yaml:"generic_resources,omitempty" json:"generic_resources,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 type DeviceRequest struct {
@@ -642,7 +642,7 @@ type DeviceRequest struct {
 type GenericResource struct {
 	DiscreteResourceSpec *DiscreteGenericResource `mapstructure:"discrete_resource_spec" yaml:"discrete_resource_spec,omitempty" json:"discrete_resource_spec,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // DiscreteGenericResource represents a "user defined" resource which is defined
@@ -653,7 +653,7 @@ type DiscreteGenericResource struct {
 	Kind  string `json:"kind"`
 	Value int64  `json:"value"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // UnitBytes is the bytes type
@@ -676,7 +676,7 @@ type RestartPolicy struct {
 	MaxAttempts *uint64   `mapstructure:"max_attempts" yaml:"max_attempts,omitempty" json:"max_attempts,omitempty"`
 	Window      *Duration `yaml:",omitempty" json:"window,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // Placement constraints for the service
@@ -685,14 +685,14 @@ type Placement struct {
 	Preferences []PlacementPreferences `yaml:",omitempty" json:"preferences,omitempty"`
 	MaxReplicas uint64                 `mapstructure:"max_replicas_per_node" yaml:"max_replicas_per_node,omitempty" json:"max_replicas_per_node,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // PlacementPreferences is the preferences for a service placement
 type PlacementPreferences struct {
 	Spread string `yaml:",omitempty" json:"spread,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // ServiceNetworkConfig is the network configuration for a service
@@ -703,7 +703,7 @@ type ServiceNetworkConfig struct {
 	Ipv6Address  string   `mapstructure:"ipv6_address" yaml:"ipv6_address,omitempty" json:"ipv6_address,omitempty"`
 	LinkLocalIPs []string `mapstructure:"link_local_ips" yaml:"link_local_ips,omitempty" json:"link_local_ips,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // ServicePortConfig is the port configuration for a service
@@ -714,7 +714,7 @@ type ServicePortConfig struct {
 	Published string `yaml:",omitempty" json:"published,omitempty"`
 	Protocol  string `yaml:",omitempty" json:"protocol,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // ParsePortConfig parse short syntax for service port configuration
@@ -767,7 +767,7 @@ type ServiceVolumeConfig struct {
 	Volume      *ServiceVolumeVolume `yaml:",omitempty" json:"volume,omitempty"`
 	Tmpfs       *ServiceVolumeTmpfs  `yaml:",omitempty" json:"tmpfs,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // String render ServiceVolumeConfig as a volume string, one can parse back using loader.ParseVolume
@@ -811,7 +811,7 @@ type ServiceVolumeBind struct {
 	Propagation    string `yaml:",omitempty" json:"propagation,omitempty"`
 	CreateHostPath bool   `mapstructure:"create_host_path" yaml:"create_host_path,omitempty" json:"create_host_path,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // SELinux represents the SELinux re-labeling options.
@@ -842,7 +842,7 @@ const (
 type ServiceVolumeVolume struct {
 	NoCopy bool `mapstructure:"nocopy" yaml:"nocopy,omitempty" json:"nocopy,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // ServiceVolumeTmpfs are options for a service volume of type tmpfs
@@ -851,7 +851,7 @@ type ServiceVolumeTmpfs struct {
 
 	Mode uint32 `yaml:",omitempty" json:"mode,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // FileReferenceConfig for a reference to a swarm file object
@@ -862,7 +862,7 @@ type FileReferenceConfig struct {
 	GID    string  `yaml:",omitempty" json:"gid,omitempty"`
 	Mode   *uint32 `yaml:",omitempty" json:"mode,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // ServiceConfigObjConfig is the config obj configuration for a service
@@ -877,7 +877,7 @@ type UlimitsConfig struct {
 	Soft   int `yaml:",omitempty" json:"soft,omitempty"`
 	Hard   int `yaml:",omitempty" json:"hard,omitempty"`
 
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // MarshalYAML makes UlimitsConfig implement yaml.Marshaller
@@ -905,23 +905,23 @@ func (u *UlimitsConfig) MarshalJSON() ([]byte, error) {
 
 // NetworkConfig for a network
 type NetworkConfig struct {
-	Name       string                 `yaml:",omitempty" json:"name,omitempty"`
-	Driver     string                 `yaml:",omitempty" json:"driver,omitempty"`
-	DriverOpts map[string]string      `mapstructure:"driver_opts" yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"`
-	Ipam       IPAMConfig             `yaml:",omitempty" json:"ipam,omitempty"`
-	External   External               `yaml:",omitempty" json:"external,omitempty"`
-	Internal   bool                   `yaml:",omitempty" json:"internal,omitempty"`
-	Attachable bool                   `yaml:",omitempty" json:"attachable,omitempty"`
-	Labels     Labels                 `yaml:",omitempty" json:"labels,omitempty"`
-	EnableIPv6 bool                   `mapstructure:"enable_ipv6" yaml:"enable_ipv6,omitempty" json:"enable_ipv6,omitempty"`
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Name       string            `yaml:",omitempty" json:"name,omitempty"`
+	Driver     string            `yaml:",omitempty" json:"driver,omitempty"`
+	DriverOpts map[string]string `mapstructure:"driver_opts" yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"`
+	Ipam       IPAMConfig        `yaml:",omitempty" json:"ipam,omitempty"`
+	External   External          `yaml:",omitempty" json:"external,omitempty"`
+	Internal   bool              `yaml:",omitempty" json:"internal,omitempty"`
+	Attachable bool              `yaml:",omitempty" json:"attachable,omitempty"`
+	Labels     Labels            `yaml:",omitempty" json:"labels,omitempty"`
+	EnableIPv6 bool              `mapstructure:"enable_ipv6" yaml:"enable_ipv6,omitempty" json:"enable_ipv6,omitempty"`
+	Extensions Extensions        `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // IPAMConfig for a network
 type IPAMConfig struct {
-	Driver     string                 `yaml:",omitempty" json:"driver,omitempty"`
-	Config     []*IPAMPool            `yaml:",omitempty" json:"config,omitempty"`
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Driver     string      `yaml:",omitempty" json:"driver,omitempty"`
+	Config     []*IPAMPool `yaml:",omitempty" json:"config,omitempty"`
+	Extensions Extensions  `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // IPAMPool for a network
@@ -935,21 +935,21 @@ type IPAMPool struct {
 
 // VolumeConfig for a volume
 type VolumeConfig struct {
-	Name       string                 `yaml:",omitempty" json:"name,omitempty"`
-	Driver     string                 `yaml:",omitempty" json:"driver,omitempty"`
-	DriverOpts map[string]string      `mapstructure:"driver_opts" yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"`
-	External   External               `yaml:",omitempty" json:"external,omitempty"`
-	Labels     Labels                 `yaml:",omitempty" json:"labels,omitempty"`
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Name       string            `yaml:",omitempty" json:"name,omitempty"`
+	Driver     string            `yaml:",omitempty" json:"driver,omitempty"`
+	DriverOpts map[string]string `mapstructure:"driver_opts" yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"`
+	External   External          `yaml:",omitempty" json:"external,omitempty"`
+	Labels     Labels            `yaml:",omitempty" json:"labels,omitempty"`
+	Extensions Extensions        `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // External identifies a Volume or Network as a reference to a resource that is
 // not managed, and should already exist.
 // External.name is deprecated and replaced by Volume.name
 type External struct {
-	Name       string                 `yaml:",omitempty" json:"name,omitempty"`
-	External   bool                   `yaml:",omitempty" json:"external,omitempty"`
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Name       string     `yaml:",omitempty" json:"name,omitempty"`
+	External   bool       `yaml:",omitempty" json:"external,omitempty"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // MarshalYAML makes External implement yaml.Marshaller
@@ -970,23 +970,23 @@ func (e External) MarshalJSON() ([]byte, error) {
 
 // CredentialSpecConfig for credential spec on Windows
 type CredentialSpecConfig struct {
-	Config     string                 `yaml:",omitempty" json:"config,omitempty"` // Config was added in API v1.40
-	File       string                 `yaml:",omitempty" json:"file,omitempty"`
-	Registry   string                 `yaml:",omitempty" json:"registry,omitempty"`
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Config     string     `yaml:",omitempty" json:"config,omitempty"` // Config was added in API v1.40
+	File       string     `yaml:",omitempty" json:"file,omitempty"`
+	Registry   string     `yaml:",omitempty" json:"registry,omitempty"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 // FileObjectConfig is a config type for a file used by a service
 type FileObjectConfig struct {
-	Name           string                 `yaml:",omitempty" json:"name,omitempty"`
-	File           string                 `yaml:",omitempty" json:"file,omitempty"`
-	Environment    string                 `yaml:",omitempty" json:"environment,omitempty"`
-	External       External               `yaml:",omitempty" json:"external,omitempty"`
-	Labels         Labels                 `yaml:",omitempty" json:"labels,omitempty"`
-	Driver         string                 `yaml:",omitempty" json:"driver,omitempty"`
-	DriverOpts     map[string]string      `mapstructure:"driver_opts" yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"`
-	TemplateDriver string                 `mapstructure:"template_driver" yaml:"template_driver,omitempty" json:"template_driver,omitempty"`
-	Extensions     map[string]interface{} `yaml:",inline" json:"-"`
+	Name           string            `yaml:",omitempty" json:"name,omitempty"`
+	File           string            `yaml:",omitempty" json:"file,omitempty"`
+	Environment    string            `yaml:",omitempty" json:"environment,omitempty"`
+	External       External          `yaml:",omitempty" json:"external,omitempty"`
+	Labels         Labels            `yaml:",omitempty" json:"labels,omitempty"`
+	Driver         string            `yaml:",omitempty" json:"driver,omitempty"`
+	DriverOpts     map[string]string `mapstructure:"driver_opts" yaml:"driver_opts,omitempty" json:"driver_opts,omitempty"`
+	TemplateDriver string            `mapstructure:"template_driver" yaml:"template_driver,omitempty" json:"template_driver,omitempty"`
+	Extensions     Extensions        `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 const (
@@ -1003,9 +1003,9 @@ const (
 type DependsOnConfig map[string]ServiceDependency
 
 type ServiceDependency struct {
-	Condition  string                 `yaml:",omitempty" json:"condition,omitempty"`
-	Restart    bool                   `yaml:",omitempty" json:"restart,omitempty"`
-	Extensions map[string]interface{} `yaml:",inline" json:"-"`
+	Condition  string     `yaml:",omitempty" json:"condition,omitempty"`
+	Restart    bool       `yaml:",omitempty" json:"restart,omitempty"`
+	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }
 
 type ExtendsConfig MappingWithEquals


Test patch:
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 4199d37..ae0c1db 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -308,7 +308,7 @@ services:
 	assert.Check(t, is.Len(actual.Services, 1))
 	service := actual.Services[0]
 	assert.Check(t, is.Equal("busybox", service.Image))
-	extras := map[string]interface{}{
+	extras := types.Extensions{
 		"x-foo": "bar",
 	}
 	assert.Check(t, is.DeepEqual(extras, service.Extensions))
@@ -2215,3 +2215,18 @@ volumes:
 	path := project.Volumes["data"].DriverOpts["device"]
 	assert.Check(t, filepath.IsAbs(path))
 }
+
+func TestLoadServiceExtension(t *testing.T) {
+	dict := `
+services:
+  extension: # this name should be allowed
+    image: web
+    x-foo: bar
+`
+	configDetails := buildConfigDetails(dict, nil)
+
+	project, err := Load(configDetails)
+	assert.NilError(t, err)
+	assert.Equal(t, project.Services[0].Name, "extension")
+	assert.Equal(t, project.Services[0].Extensions["x-foo"], "bar")
+}