Repository: compose-spec/compose-go
Pull request #380 (2023-03-30 11:33:59)

[EN-158] allow override compose.yaml to REMOVE elements
this is an alternative to https://github.com/compose-spec/compose-go/pull/379

using a custom yaml tag `!reset` (final name to be debated), this let user define compose.override.yaml to REMOVE elements from original model. 
Also supports removal from maps, like `environment`, but miss support for removal from list (but feasible)


```yaml
services:
  foo:
    image: foo
    build: !reset  
    environment:
      FOO: !reset
```


Patch:
diff --git a/interpolation/interpolation.go b/interpolation/interpolation.go
index befc6f0..16e12ac 100644
--- a/interpolation/interpolation.go
+++ b/interpolation/interpolation.go
@@ -18,9 +18,9 @@ package interpolation
 
 import (
 	"os"
-	"strings"
 
 	"github.com/compose-spec/compose-go/template"
+	"github.com/compose-spec/compose-go/tree"
 	"github.com/pkg/errors"
 )
 
@@ -29,7 +29,7 @@ type Options struct {
 	// LookupValue from a key
 	LookupValue LookupValue
 	// TypeCastMapping maps key paths to functions to cast to a type
-	TypeCastMapping map[Path]Cast
+	TypeCastMapping map[tree.Path]Cast
 	// Substitution function to use
 	Substitute func(string, template.Mapping) (string, error)
 }
@@ -49,7 +49,7 @@ func Interpolate(config map[string]interface{}, opts Options) (map[string]interf
 		opts.LookupValue = os.LookupEnv
 	}
 	if opts.TypeCastMapping == nil {
-		opts.TypeCastMapping = make(map[Path]Cast)
+		opts.TypeCastMapping = make(map[tree.Path]Cast)
 	}
 	if opts.Substitute == nil {
 		opts.Substitute = template.Substitute
@@ -58,7 +58,7 @@ func Interpolate(config map[string]interface{}, opts Options) (map[string]interf
 	out := map[string]interface{}{}
 
 	for key, value := range config {
-		interpolatedValue, err := recursiveInterpolate(value, NewPath(key), opts)
+		interpolatedValue, err := recursiveInterpolate(value, tree.NewPath(key), opts)
 		if err != nil {
 			return out, err
 		}
@@ -68,7 +68,7 @@ func Interpolate(config map[string]interface{}, opts Options) (map[string]interf
 	return out, nil
 }
 
-func recursiveInterpolate(value interface{}, path Path, opts Options) (interface{}, error) {
+func recursiveInterpolate(value interface{}, path tree.Path, opts Options) (interface{}, error) {
 	switch value := value.(type) {
 	case string:
 		newValue, err := opts.Substitute(value, template.Mapping(opts.LookupValue))
@@ -96,7 +96,7 @@ func recursiveInterpolate(value interface{}, path Path, opts Options) (interface
 	case []interface{}:
 		out := make([]interface{}, len(value))
 		for i, elem := range value {
-			interpolatedElem, err := recursiveInterpolate(elem, path.Next(PathMatchList), opts)
+			interpolatedElem, err := recursiveInterpolate(elem, path.Next(tree.PathMatchList), opts)
 			if err != nil {
 				return nil, err
 			}
@@ -109,7 +109,7 @@ func recursiveInterpolate(value interface{}, path Path, opts Options) (interface
 	}
 }
 
-func newPathError(path Path, err error) error {
+func newPathError(path tree.Path, err error) error {
 	switch err := err.(type) {
 	case nil:
 		return nil
@@ -122,54 +122,9 @@ func newPathError(path Path, err error) error {
 	}
 }
 
-const pathSeparator = "."
-
-// PathMatchAll is a token used as part of a Path to match any key at that level
-// in the nested structure
-const PathMatchAll = "*"
-
-// PathMatchList is a token used as part of a Path to match items in a list
-const PathMatchList = "[]"
-
-// Path is a dotted path of keys to a value in a nested mapping structure. A *
-// section in a path will match any key in the mapping structure.
-type Path string
-
-// NewPath returns a new Path
-func NewPath(items ...string) Path {
-	return Path(strings.Join(items, pathSeparator))
-}
-
-// Next returns a new path by append part to the current path
-func (p Path) Next(part string) Path {
-	return Path(string(p) + pathSeparator + part)
-}
-
-func (p Path) parts() []string {
-	return strings.Split(string(p), pathSeparator)
-}
-
-func (p Path) matches(pattern Path) bool {
-	patternParts := pattern.parts()
-	parts := p.parts()
-
-	if len(patternParts) != len(parts) {
-		return false
-	}
-	for index, part := range parts {
-		switch patternParts[index] {
-		case PathMatchAll, part:
-			continue
-		default:
-			return false
-		}
-	}
-	return true
-}
-
-func (o Options) getCasterForPath(path Path) (Cast, bool) {
+func (o Options) getCasterForPath(path tree.Path) (Cast, bool) {
 	for pattern, caster := range o.TypeCastMapping {
-		if path.matches(pattern) {
+		if path.Matches(pattern) {
 			return caster, true
 		}
 	}
diff --git a/loader/interpolate.go b/loader/interpolate.go
index 97a19f5..61916d9 100644
--- a/loader/interpolate.go
+++ b/loader/interpolate.go
@@ -21,66 +21,67 @@ import (
 	"strings"
 
 	interp "github.com/compose-spec/compose-go/interpolation"
+	"github.com/compose-spec/compose-go/tree"
 	"github.com/pkg/errors"
 )
 
-var interpolateTypeCastMapping = map[interp.Path]interp.Cast{
-	servicePath("configs", interp.PathMatchList, "mode"):             toInt,
-	servicePath("cpu_count"):                                         toInt64,
-	servicePath("cpu_percent"):                                       toFloat,
-	servicePath("cpu_period"):                                        toInt64,
-	servicePath("cpu_quota"):                                         toInt64,
-	servicePath("cpu_rt_period"):                                     toInt64,
-	servicePath("cpu_rt_runtime"):                                    toInt64,
-	servicePath("cpus"):                                              toFloat32,
-	servicePath("cpu_shares"):                                        toInt64,
-	servicePath("init"):                                              toBoolean,
-	servicePath("deploy", "replicas"):                                toInt,
-	servicePath("deploy", "update_config", "parallelism"):            toInt,
-	servicePath("deploy", "update_config", "max_failure_ratio"):      toFloat,
-	servicePath("deploy", "rollback_config", "parallelism"):          toInt,
-	servicePath("deploy", "rollback_config", "max_failure_ratio"):    toFloat,
-	servicePath("deploy", "restart_policy", "max_attempts"):          toInt,
-	servicePath("deploy", "placement", "max_replicas_per_node"):      toInt,
-	servicePath("healthcheck", "retries"):                            toInt,
-	servicePath("healthcheck", "disable"):                            toBoolean,
-	servicePath("mem_limit"):                                         toUnitBytes,
-	servicePath("mem_reservation"):                                   toUnitBytes,
-	servicePath("memswap_limit"):                                     toUnitBytes,
-	servicePath("mem_swappiness"):                                    toUnitBytes,
-	servicePath("oom_kill_disable"):                                  toBoolean,
-	servicePath("oom_score_adj"):                                     toInt64,
-	servicePath("pids_limit"):                                        toInt64,
-	servicePath("ports", interp.PathMatchList, "target"):             toInt,
-	servicePath("privileged"):                                        toBoolean,
-	servicePath("read_only"):                                         toBoolean,
-	servicePath("scale"):                                             toInt,
-	servicePath("secrets", interp.PathMatchList, "mode"):             toInt,
-	servicePath("shm_size"):                                          toUnitBytes,
-	servicePath("stdin_open"):                                        toBoolean,
-	servicePath("stop_grace_period"):                                 toDuration,
-	servicePath("tty"):                                               toBoolean,
-	servicePath("ulimits", interp.PathMatchAll):                      toInt,
-	servicePath("ulimits", interp.PathMatchAll, "hard"):              toInt,
-	servicePath("ulimits", interp.PathMatchAll, "soft"):              toInt,
-	servicePath("volumes", interp.PathMatchList, "read_only"):        toBoolean,
-	servicePath("volumes", interp.PathMatchList, "volume", "nocopy"): toBoolean,
-	servicePath("volumes", interp.PathMatchList, "tmpfs", "size"):    toUnitBytes,
-	iPath("networks", interp.PathMatchAll, "external"):               toBoolean,
-	iPath("networks", interp.PathMatchAll, "internal"):               toBoolean,
-	iPath("networks", interp.PathMatchAll, "attachable"):             toBoolean,
-	iPath("networks", interp.PathMatchAll, "enable_ipv6"):            toBoolean,
-	iPath("volumes", interp.PathMatchAll, "external"):                toBoolean,
-	iPath("secrets", interp.PathMatchAll, "external"):                toBoolean,
-	iPath("configs", interp.PathMatchAll, "external"):                toBoolean,
+var interpolateTypeCastMapping = map[tree.Path]interp.Cast{
+	servicePath("configs", tree.PathMatchList, "mode"):             toInt,
+	servicePath("cpu_count"):                                       toInt64,
+	servicePath("cpu_percent"):                                     toFloat,
+	servicePath("cpu_period"):                                      toInt64,
+	servicePath("cpu_quota"):                                       toInt64,
+	servicePath("cpu_rt_period"):                                   toInt64,
+	servicePath("cpu_rt_runtime"):                                  toInt64,
+	servicePath("cpus"):                                            toFloat32,
+	servicePath("cpu_shares"):                                      toInt64,
+	servicePath("init"):                                            toBoolean,
+	servicePath("deploy", "replicas"):                              toInt,
+	servicePath("deploy", "update_config", "parallelism"):          toInt,
+	servicePath("deploy", "update_config", "max_failure_ratio"):    toFloat,
+	servicePath("deploy", "rollback_config", "parallelism"):        toInt,
+	servicePath("deploy", "rollback_config", "max_failure_ratio"):  toFloat,
+	servicePath("deploy", "restart_policy", "max_attempts"):        toInt,
+	servicePath("deploy", "placement", "max_replicas_per_node"):    toInt,
+	servicePath("healthcheck", "retries"):                          toInt,
+	servicePath("healthcheck", "disable"):                          toBoolean,
+	servicePath("mem_limit"):                                       toUnitBytes,
+	servicePath("mem_reservation"):                                 toUnitBytes,
+	servicePath("memswap_limit"):                                   toUnitBytes,
+	servicePath("mem_swappiness"):                                  toUnitBytes,
+	servicePath("oom_kill_disable"):                                toBoolean,
+	servicePath("oom_score_adj"):                                   toInt64,
+	servicePath("pids_limit"):                                      toInt64,
+	servicePath("ports", tree.PathMatchList, "target"):             toInt,
+	servicePath("privileged"):                                      toBoolean,
+	servicePath("read_only"):                                       toBoolean,
+	servicePath("scale"):                                           toInt,
+	servicePath("secrets", tree.PathMatchList, "mode"):             toInt,
+	servicePath("shm_size"):                                        toUnitBytes,
+	servicePath("stdin_open"):                                      toBoolean,
+	servicePath("stop_grace_period"):                               toDuration,
+	servicePath("tty"):                                             toBoolean,
+	servicePath("ulimits", tree.PathMatchAll):                      toInt,
+	servicePath("ulimits", tree.PathMatchAll, "hard"):              toInt,
+	servicePath("ulimits", tree.PathMatchAll, "soft"):              toInt,
+	servicePath("volumes", tree.PathMatchList, "read_only"):        toBoolean,
+	servicePath("volumes", tree.PathMatchList, "volume", "nocopy"): toBoolean,
+	servicePath("volumes", tree.PathMatchList, "tmpfs", "size"):    toUnitBytes,
+	iPath("networks", tree.PathMatchAll, "external"):               toBoolean,
+	iPath("networks", tree.PathMatchAll, "internal"):               toBoolean,
+	iPath("networks", tree.PathMatchAll, "attachable"):             toBoolean,
+	iPath("networks", tree.PathMatchAll, "enable_ipv6"):            toBoolean,
+	iPath("volumes", tree.PathMatchAll, "external"):                toBoolean,
+	iPath("secrets", tree.PathMatchAll, "external"):                toBoolean,
+	iPath("configs", tree.PathMatchAll, "external"):                toBoolean,
 }
 
-func iPath(parts ...string) interp.Path {
-	return interp.NewPath(parts...)
+func iPath(parts ...string) tree.Path {
+	return tree.NewPath(parts...)
 }
 
-func servicePath(parts ...string) interp.Path {
-	return iPath(append([]string{"services", interp.PathMatchAll}, parts...)...)
+func servicePath(parts ...string) tree.Path {
+	return iPath(append([]string{"services", tree.PathMatchAll}, parts...)...)
 }
 
 func toInt(value string) (interface{}, error) {
diff --git a/loader/loader.go b/loader/loader.go
index 59f0ed9..06bbcab 100644
--- a/loader/loader.go
+++ b/loader/loader.go
@@ -134,27 +134,43 @@ func WithProfiles(profiles []string) func(*Options) {
 // ParseYAML reads the bytes from a file, parses the bytes into a mapping
 // structure, and returns it.
 func ParseYAML(source []byte) (map[string]interface{}, error) {
+	m, _, err := parseYAML(source)
+	return m, err
+}
+
+// PostProcessor is used to tweak compose model based on metadata extracted during yaml Unmarshal phase
+// that hardly can be implemented using go-yaml and mapstructure
+type PostProcessor interface {
+	yaml.Unmarshaler
+
+	// Apply changes to compose model based on recorder metadata
+	Apply(config *types.Config) error
+}
+
+func parseYAML(source []byte) (map[string]interface{}, PostProcessor, error) {
 	var cfg interface{}
-	if err := yaml.Unmarshal(source, &cfg); err != nil {
-		return nil, err
+	processor := ResetProcessor{target: &cfg}
+
+	if err := yaml.Unmarshal(source, &processor); err != nil {
+		return nil, nil, err
 	}
 	stringMap, ok := cfg.(map[string]interface{})
 	if ok {
 		converted, err := convertToStringKeysRecursive(stringMap, "")
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
-		return converted.(map[string]interface{}), nil
+		return converted.(map[string]interface{}), &processor, nil
 	}
 	cfgMap, ok := cfg.(map[interface{}]interface{})
 	if !ok {
-		return nil, errors.Errorf("Top-level object must be a mapping")
+		return nil, nil, errors.Errorf("Top-level object must be a mapping")
 	}
 	converted, err := convertToStringKeysRecursive(cfgMap, "")
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
-	return converted.(map[string]interface{}), nil
+	return converted.(map[string]interface{}), &processor, nil
 }
 
 // Load reads a ConfigDetails and returns a fully loaded configuration
@@ -180,8 +196,9 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 		return nil, err
 	}
 
-	var configs []*types.Config
+	var model *types.Config
 	for i, file := range configDetails.ConfigFiles {
+		var postProcessor PostProcessor
 		configDict := file.Config
 		if configDict == nil {
 			if len(file.Content) == 0 {
@@ -191,13 +208,14 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 				}
 				file.Content = content
 			}
-			dict, err := parseConfig(file.Content, opts)
+			dict, p, err := parseConfig(file.Content, opts)
 			if err != nil {
 				return nil, err
 			}
 			configDict = dict
 			file.Config = dict
 			configDetails.ConfigFiles[i] = file
+			postProcessor = p
 		}
 
 		if !opts.SkipValidation {
@@ -212,12 +230,22 @@ func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.
 		if err != nil {
 			return nil, err
 		}
-		configs = append(configs, cfg)
-	}
 
-	model, err := merge(configs)
-	if err != nil {
-		return nil, err
+		if i == 0 {
+			model = cfg
+			continue
+		}
+		merged, err := merge([]*types.Config{model, cfg})
+		if err != nil {
+			return nil, err
+		}
+		if postProcessor != nil {
+			err = postProcessor.Apply(merged)
+			if err != nil {
+				return nil, err
+			}
+		}
+		model = merged
 	}
 
 	for _, s := range model.Services {
@@ -325,15 +353,16 @@ func NormalizeProjectName(s string) string {
 	return strings.TrimLeft(s, "_-")
 }
 
-func parseConfig(b []byte, opts *Options) (map[string]interface{}, error) {
-	yml, err := ParseYAML(b)
+func parseConfig(b []byte, opts *Options) (map[string]interface{}, PostProcessor, error) {
+	yml, postProcessor, err := parseYAML(b)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 	if !opts.SkipInterpolation {
-		return interp.Interpolate(yml, *opts.Interpolate)
+		interpolated, err := interp.Interpolate(yml, *opts.Interpolate)
+		return interpolated, postProcessor, err
 	}
-	return yml, err
+	return yml, postProcessor, err
 }
 
 const extensions = "#extensions" // Using # prefix, we prevent risk to conflict with an actual yaml key
@@ -608,7 +637,7 @@ func loadServiceWithExtends(filename, name string, servicesDict map[string]inter
 				return nil, err
 			}
 
-			baseFile, err := parseConfig(b, opts)
+			baseFile, _, err := parseConfig(b, opts)
 			if err != nil {
 				return nil, err
 			}
diff --git a/loader/null.go b/loader/null.go
new file mode 100644
index 0000000..e2a6f19
--- /dev/null
+++ b/loader/null.go
@@ -0,0 +1,159 @@
+/*
+   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 loader
+
+import (
+	"fmt"
+	"reflect"
+	"strconv"
+	"strings"
+
+	"github.com/compose-spec/compose-go/tree"
+	"github.com/compose-spec/compose-go/types"
+	"gopkg.in/yaml.v3"
+)
+
+type ResetProcessor struct {
+	target interface{}
+	paths  []tree.Path
+}
+
+// UnmarshalYAML implement yaml.Unmarshaler
+func (p *ResetProcessor) UnmarshalYAML(value *yaml.Node) error {
+	resolved, err := p.resolveReset(value, tree.NewPath())
+	if err != nil {
+		return err
+	}
+	return resolved.Decode(p.target)
+}
+
+// resolveReset detects `!reset` tag being set on yaml nodes and record position in the yaml tree
+func (p *ResetProcessor) resolveReset(node *yaml.Node, path tree.Path) (*yaml.Node, error) {
+	if node.Tag == "!reset" {
+		p.paths = append(p.paths, path)
+	}
+	switch node.Kind {
+	case yaml.SequenceNode:
+		var err error
+		for idx, v := range node.Content {
+			node.Content[idx], err = p.resolveReset(v, path.Next(strconv.Itoa(idx)))
+			if err != nil {
+				return nil, err
+			}
+		}
+	case yaml.MappingNode:
+		var err error
+		var key string
+		for idx, v := range node.Content {
+			if idx%2 == 0 {
+				key = v.Value
+			} else {
+				node.Content[idx], err = p.resolveReset(v, path.Next(key))
+				if err != nil {
+					return nil, err
+				}
+			}
+		}
+	}
+	return node, nil
+}
+
+// Apply finds the go attributes matching recorded paths and reset them to zero value
+func (p *ResetProcessor) Apply(target *types.Config) error {
+	for i, path := range p.paths {
+		parts := path.Parts()
+		// services is a mapping in yaml but a slice in types.Config, so need to translate the paths
+		if len(parts) > 1 && parts[0] == "services" {
+			name := parts[1]
+			for idx, service := range target.Services {
+				if service.Name == name {
+					parts[1] = fmt.Sprintf("[%d]", idx)
+					p.paths[i] = tree.NewPath(parts...)
+					break
+				}
+			}
+		}
+	}
+	return p.applyNullOverrides(reflect.ValueOf(target), tree.NewPath())
+}
+
+// applyNullOverrides set val to Zero if it matches any of the recorded paths
+func (p *ResetProcessor) applyNullOverrides(val reflect.Value, path tree.Path) error {
+	val = reflect.Indirect(val)
+	if !val.IsValid() {
+		return nil
+	}
+	typ := val.Type()
+	switch typ.Kind() {
+	case reflect.Map:
+		iter := val.MapRange()
+	KEYS:
+		for iter.Next() {
+			k := iter.Key()
+			next := path.Next(k.String())
+			for _, pattern := range p.paths {
+				if next.Matches(pattern) {
+					val.SetMapIndex(k, reflect.Value{})
+					continue KEYS
+				}
+			}
+			return p.applyNullOverrides(iter.Value(), next)
+		}
+	case reflect.Slice:
+	ITER:
+		for i := 0; i < val.Len(); i++ {
+			next := path.Next(fmt.Sprintf("[%d]", i))
+			for _, pattern := range p.paths {
+				if next.Matches(pattern) {
+
+					continue ITER
+				}
+			}
+			// TODO(ndeloof) support removal from sequence
+			return p.applyNullOverrides(val.Index(i), next)
+		}
+
+	case reflect.Struct:
+	FIELDS:
+		for i := 0; i < typ.NumField(); i++ {
+			field := typ.Field(i)
+			name := field.Name
+			attr := strings.ToLower(name)
+			if tag := field.Tag.Get("mapstructure"); tag != "" {
+				attr = tag
+			}
+			next := path.Next(attr)
+			f := val.Field(i)
+			for _, pattern := range p.paths {
+				if next.Matches(pattern) {
+					f := f
+					if !f.CanSet() {
+						return fmt.Errorf("can't override attribute %s", name)
+					}
+					// f.SetZero() requires go 1.20
+					f.Set(reflect.Zero(f.Type()))
+					continue FIELDS
+				}
+			}
+			err := p.applyNullOverrides(f, next)
+			if err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
diff --git a/tree/path.go b/tree/path.go
new file mode 100644
index 0000000..59c2503
--- /dev/null
+++ b/tree/path.go
@@ -0,0 +1,67 @@
+/*
+   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 tree
+
+import "strings"
+
+const pathSeparator = "."
+
+// PathMatchAll is a token used as part of a Path to match any key at that level
+// in the nested structure
+const PathMatchAll = "*"
+
+// PathMatchList is a token used as part of a Path to match items in a list
+const PathMatchList = "[]"
+
+// Path is a dotted path of keys to a value in a nested mapping structure. A *
+// section in a path will match any key in the mapping structure.
+type Path string
+
+// NewPath returns a new Path
+func NewPath(items ...string) Path {
+	return Path(strings.Join(items, pathSeparator))
+}
+
+// Next returns a new path by append part to the current path
+func (p Path) Next(part string) Path {
+	if p == "" {
+		return Path(part)
+	}
+	return Path(string(p) + pathSeparator + part)
+}
+
+func (p Path) Parts() []string {
+	return strings.Split(string(p), pathSeparator)
+}
+
+func (p Path) Matches(pattern Path) bool {
+	patternParts := pattern.Parts()
+	parts := p.Parts()
+
+	if len(patternParts) != len(parts) {
+		return false
+	}
+	for index, part := range parts {
+		switch patternParts[index] {
+		case PathMatchAll, part:
+			continue
+		default:
+			return false
+		}
+	}
+	return true
+}
diff --git a/types/types.go b/types/types.go
index c59b540..f739959 100644
--- a/types/types.go
+++ b/types/types.go
@@ -340,7 +340,7 @@ type WeightDevice struct {
 // ThrottleDevice is a structure that holds device:rate_per_second pair
 type ThrottleDevice struct {
 	Path string
-	Rate UnitBytes
+	Rate uint64
 
 	Extensions Extensions `mapstructure:"#extensions" yaml:",inline" json:"-"`
 }


Test patch:
diff --git a/interpolation/interpolation_test.go b/interpolation/interpolation_test.go
index 8502731..973fe36 100644
--- a/interpolation/interpolation_test.go
+++ b/interpolation/interpolation_test.go
@@ -21,6 +21,7 @@ import (
 	"strconv"
 	"testing"
 
+	"github.com/compose-spec/compose-go/tree"
 	"gotest.tools/v3/assert"
 	is "gotest.tools/v3/assert/cmp"
 )
@@ -198,7 +199,7 @@ func TestInterpolateWithCast(t *testing.T) {
 	}
 	result, err := Interpolate(config, Options{
 		LookupValue:     defaultMapping,
-		TypeCastMapping: map[Path]Cast{NewPath(PathMatchAll, "replicas"): toInt},
+		TypeCastMapping: map[tree.Path]Cast{tree.NewPath(tree.PathMatchAll, "replicas"): toInt},
 	})
 	assert.NilError(t, err)
 	expected := map[string]interface{}{
@@ -212,44 +213,44 @@ func TestInterpolateWithCast(t *testing.T) {
 func TestPathMatches(t *testing.T) {
 	var testcases = []struct {
 		doc      string
-		path     Path
-		pattern  Path
+		path     tree.Path
+		pattern  tree.Path
 		expected bool
 	}{
 		{
 			doc:     "pattern too short",
-			path:    NewPath("one", "two", "three"),
-			pattern: NewPath("one", "two"),
+			path:    tree.NewPath("one", "two", "three"),
+			pattern: tree.NewPath("one", "two"),
 		},
 		{
 			doc:     "pattern too long",
-			path:    NewPath("one", "two"),
-			pattern: NewPath("one", "two", "three"),
+			path:    tree.NewPath("one", "two"),
+			pattern: tree.NewPath("one", "two", "three"),
 		},
 		{
 			doc:     "pattern mismatch",
-			path:    NewPath("one", "three", "two"),
-			pattern: NewPath("one", "two", "three"),
+			path:    tree.NewPath("one", "three", "two"),
+			pattern: tree.NewPath("one", "two", "three"),
 		},
 		{
 			doc:     "pattern mismatch with match-all part",
-			path:    NewPath("one", "three", "two"),
-			pattern: NewPath(PathMatchAll, "two", "three"),
+			path:    tree.NewPath("one", "three", "two"),
+			pattern: tree.NewPath(tree.PathMatchAll, "two", "three"),
 		},
 		{
 			doc:      "pattern match with match-all part",
-			path:     NewPath("one", "two", "three"),
-			pattern:  NewPath("one", "*", "three"),
+			path:     tree.NewPath("one", "two", "three"),
+			pattern:  tree.NewPath("one", "*", "three"),
 			expected: true,
 		},
 		{
 			doc:      "pattern match",
-			path:     NewPath("one", "two", "three"),
-			pattern:  NewPath("one", "two", "three"),
+			path:     tree.NewPath("one", "two", "three"),
+			pattern:  tree.NewPath("one", "two", "three"),
 			expected: true,
 		},
 	}
 	for _, testcase := range testcases {
-		assert.Check(t, is.Equal(testcase.expected, testcase.path.matches(testcase.pattern)))
+		assert.Check(t, is.Equal(testcase.expected, testcase.path.Matches(testcase.pattern)))
 	}
 }
diff --git a/loader/loader_test.go b/loader/loader_test.go
index 59902af..2956d7e 100644
--- a/loader/loader_test.go
+++ b/loader/loader_test.go
@@ -2297,43 +2297,3 @@ services:
 	assert.Equal(t, project.Services[0].Name, "extension")
 	assert.Equal(t, project.Services[0].Extensions["x-foo"], "bar")
 }
-
-func TestDeviceWriteBps(t *testing.T) {
-	p, err := loadYAML(`
-        name: test
-        services:
-          foo:
-            image: busybox
-            blkio_config:
-              device_read_bps:
-              - path: /dev/test
-                rate: 1024k
-              device_write_bps:
-              - path: /dev/test
-                rate: 1024
-`)
-	assert.NilError(t, err)
-	assert.DeepEqual(t, p.Services, types.Services{
-		{
-			Name:        "foo",
-			Image:       "busybox",
-			Environment: types.MappingWithEquals{},
-			Scale:       1,
-			BlkioConfig: &types.BlkioConfig{
-				DeviceReadBps: []types.ThrottleDevice{
-					{
-						Path: "/dev/test",
-						Rate: types.UnitBytes(1024 * 1024),
-					},
-				},
-				DeviceWriteBps: []types.ThrottleDevice{
-					{
-						Path: "/dev/test",
-						Rate: types.UnitBytes(1024),
-					},
-				},
-			},
-		},
-	})
-
-}
diff --git a/loader/merge_test.go b/loader/merge_test.go
index 96301d4..9d0d600 100644
--- a/loader/merge_test.go
+++ b/loader/merge_test.go
@@ -20,6 +20,7 @@ import (
 	"reflect"
 	"testing"
 
+	"github.com/compose-spec/compose-go/consts"
 	"github.com/imdario/mergo"
 
 	"github.com/compose-spec/compose-go/types"
@@ -1364,3 +1365,51 @@ func TestMergeExtraHosts(t *testing.T) {
 		},
 	)
 }
+
+func TestLoadWithNullOverride(t *testing.T) {
+	base := `
+name: test
+services:
+  foo:
+    build:
+      context: .
+      dockerfile: foo.Dockerfile
+    environment:
+      FOO: BAR
+`
+	override := `
+services:
+  foo:
+    image: foo
+    build: !reset  
+    environment:
+      FOO: !reset
+`
+	configDetails := types.ConfigDetails{
+		Environment: map[string]string{},
+		ConfigFiles: []types.ConfigFile{
+			{Filename: "base.yml", Content: []byte(base)},
+			{Filename: "override.yml", Content: []byte(override)},
+		},
+	}
+	config, err := loadTestProject(configDetails)
+	assert.NilError(t, err)
+	assert.DeepEqual(t, &types.Project{
+		Name:       "test",
+		WorkingDir: "",
+		Services: []types.ServiceConfig{
+			{
+				Name:        "foo",
+				Image:       "foo",
+				Environment: types.MappingWithEquals{},
+				Scale:       1,
+			},
+		},
+		Networks:    types.Networks{},
+		Volumes:     types.Volumes{},
+		Secrets:     types.Secrets{},
+		Configs:     types.Configs{},
+		Extensions:  types.Extensions{},
+		Environment: map[string]string{consts.ComposeProjectName: "test"},
+	}, config)
+}
diff --git a/tree/path_test.go b/tree/path_test.go
new file mode 100644
index 0000000..dbb25a4
--- /dev/null
+++ b/tree/path_test.go
@@ -0,0 +1,69 @@
+/*
+   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 tree
+
+import (
+	"testing"
+
+	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
+)
+
+func TestPathMatches(t *testing.T) {
+	var testcases = []struct {
+		doc      string
+		path     Path
+		pattern  Path
+		expected bool
+	}{
+		{
+			doc:     "pattern too short",
+			path:    NewPath("one", "two", "three"),
+			pattern: NewPath("one", "two"),
+		},
+		{
+			doc:     "pattern too long",
+			path:    NewPath("one", "two"),
+			pattern: NewPath("one", "two", "three"),
+		},
+		{
+			doc:     "pattern mismatch",
+			path:    NewPath("one", "three", "two"),
+			pattern: NewPath("one", "two", "three"),
+		},
+		{
+			doc:     "pattern mismatch with match-all part",
+			path:    NewPath("one", "three", "two"),
+			pattern: NewPath(PathMatchAll, "two", "three"),
+		},
+		{
+			doc:      "pattern match with match-all part",
+			path:     NewPath("one", "two", "three"),
+			pattern:  NewPath("one", "*", "three"),
+			expected: true,
+		},
+		{
+			doc:      "pattern match",
+			path:     NewPath("one", "two", "three"),
+			pattern:  NewPath("one", "two", "three"),
+			expected: true,
+		},
+	}
+	for _, testcase := range testcases {
+		assert.Check(t, is.Equal(testcase.expected, testcase.path.Matches(testcase.pattern)))
+	}
+}