Repository: keikoproj/instance-manager (go)

Issue:
GitOps: Configmap boundary "shared" merge strategy might be too vague
Today the following boundaries are allowed:
```
data:
  boundaries:
    restricted:
    - .spec.eks.configuration.image
    shared:
    - .spec.eks.configuration.securityGroups
```

using `shared` might be too generic and vague in terms of what type of merge.
We should consider a more concrete approach to defining the merge strategy of a shared configuration.

For example:

```
data:
  boundaries:
    restricted:
    # stays the same - 1st priority given to default value
    - .spec.eks.configuration.image
    shared:
      merge:
      # merge CR values with default values 
      - .spec.eks.configuration.securityGroups
      replace:
      # replace default values with CR values
      - spec.eks.configuration.taints
```


Pull request description:
GitOps: Merge Strategy for Default/Resource values
Fixes #149 

This provides more control over merge strategy of default and resource values by adding 3 shared boundary types `merge`, `mergeOverride` and `replace`.

- [x] Functional test passing

Patch:
diff --git a/controllers/common/utils.go b/controllers/common/utils.go
index 9c8330e..578e07d 100644
--- a/controllers/common/utils.go
+++ b/controllers/common/utils.go
@@ -24,6 +24,8 @@ import (
 	"sort"
 	"strings"
 	"time"
+
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 )
 
 // ContainsString returns true if a given slice 'slice' contains string 's', otherwise return false
@@ -61,6 +63,91 @@ func StringMapSliceContains(m []map[string]string, contains map[string]string) b
 	return false
 }
 
+func FieldValue(path string, obj map[string]interface{}) interface{} {
+	var resourceField interface{}
+
+	p := FieldPath(path)
+
+	if field, ok, _ := unstructured.NestedFieldCopy(obj, p...); ok {
+		resourceField = field
+	}
+
+	return resourceField
+}
+
+func SetFieldValue(path string, obj map[string]interface{}, value interface{}) error {
+	p := FieldPath(path)
+	if err := unstructured.SetNestedField(obj, value, p...); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func FieldPathString(path ...string) string {
+	return strings.Join(path, ".")
+}
+
+func FieldPath(path string) []string {
+	return strings.Split(path, ".")
+}
+
+func AppendUnique(slice []interface{}, i interface{}) []interface{} {
+	for _, ele := range slice {
+		if reflect.DeepEqual(ele, i) {
+			return slice
+		}
+	}
+	return append(slice, i)
+}
+
+func AppendUniqueIndex(slice []interface{}, i interface{}, idx string, override bool) []interface{} {
+	var fieldStr, fieldStr2 string
+	appendIdxVal := reflect.ValueOf(i)
+
+	switch appendIdxVal.Kind() {
+	case reflect.Map:
+		for _, e := range appendIdxVal.MapKeys() {
+			if strings.EqualFold(e.String(), idx) {
+				fieldStr = appendIdxVal.MapIndex(e).Elem().String()
+			}
+		}
+
+		for ix, ele := range slice {
+			compareIdxVal := reflect.ValueOf(ele)
+			for _, e := range compareIdxVal.MapKeys() {
+				if strings.EqualFold(e.String(), idx) {
+					fieldStr2 = compareIdxVal.MapIndex(e).Elem().String()
+				}
+			}
+			if strings.EqualFold(fieldStr, fieldStr2) {
+				if override {
+					slice[ix] = i
+				}
+				return slice
+			}
+		}
+	default:
+		return slice
+	}
+
+	return append(slice, i)
+}
+
+func MergeSliceByUnique(sl1, sl2 []interface{}) []interface{} {
+	for _, ele := range sl2 {
+		sl1 = AppendUnique(sl1, ele)
+	}
+	return sl1
+}
+
+func MergeSliceByIndex(sl1, sl2 []interface{}, idx string, override bool) []interface{} {
+	for _, ele := range sl2 {
+		sl1 = AppendUniqueIndex(sl1, ele, idx, override)
+	}
+	return sl1
+}
+
 func StringSliceEqualFold(x []string, y []string) bool {
 	if len(x) != len(y) {
 		return false
diff --git a/controllers/instancegroup_controller.go b/controllers/instancegroup_controller.go
index b12ac25..0afaa5d 100644
--- a/controllers/instancegroup_controller.go
+++ b/controllers/instancegroup_controller.go
@@ -113,29 +113,29 @@ func (r *InstanceGroupReconciler) Reconcile(req ctrl.Request) (ctrl.Result, erro
 	r.SetFinalizer(instanceGroup)
 	defer r.Finalize(instanceGroup)
 
-	var defaultConfig *provisioners.DefaultConfiguration
-	if defaultConfig, err = provisioners.UnmarshalConfiguration(r.ConfigMap); err != nil {
-		r.Log.Error(err, "failed to unmarshal configuration", "instancegroup", instanceGroup.NamespacedName())
-		return ctrl.Result{}, err
-	}
-
-	configuredInstanceGroup := &v1alpha1.InstanceGroup{}
-	instanceGroup.DeepCopyInto(configuredInstanceGroup)
-
-	if configuredInstanceGroup, err = provisioners.SetConfigurationDefaults(configuredInstanceGroup, defaultConfig); err != nil {
-		r.Log.Error(err, "failed to set configuration defaults", "instancegroup", instanceGroup.NamespacedName())
-		return ctrl.Result{}, err
-	}
-
 	input := provisioners.ProvisionerInput{
 		AwsWorker:     r.Auth.Aws,
 		Kubernetes:    r.Auth.Kubernetes,
-		InstanceGroup: configuredInstanceGroup,
 		Configuration: r.ConfigMap,
+		InstanceGroup: instanceGroup,
 		Log:           r.Log,
 	}
 
-	provisionerKind := strings.ToLower(configuredInstanceGroup.Spec.Provisioner)
+	if r.ConfigMap != nil {
+		var defaultConfig *provisioners.ProvisionerConfiguration
+		if defaultConfig, err = provisioners.NewProvisionerConfiguration(r.ConfigMap, instanceGroup); err != nil {
+			return ctrl.Result{}, err
+		}
+
+		if err = defaultConfig.SetDefaults(); err != nil {
+			r.Log.Error(err, "failed to set configuration defaults", "instancegroup", instanceGroup.NamespacedName())
+			return ctrl.Result{}, err
+		}
+
+		input.InstanceGroup = defaultConfig.InstanceGroup
+	}
+
+	provisionerKind := strings.ToLower(input.InstanceGroup.Spec.Provisioner)
 
 	if !common.ContainsEqualFold(v1alpha1.Provisioners, provisionerKind) {
 		return ctrl.Result{}, errors.Errorf("provisioner '%v' does not exist", provisionerKind)
@@ -144,13 +144,13 @@ func (r *InstanceGroupReconciler) Reconcile(req ctrl.Request) (ctrl.Result, erro
 	r.Log.Info("reconcile event started", "instancegroup", req.NamespacedName, "provisioner", provisionerKind)
 
 	// defer updates for the instanceGroup CR
-	defer r.UpdateStatus(configuredInstanceGroup)
+	defer r.UpdateStatus(input.InstanceGroup)
 
 	var isRetryable bool
 	if strings.EqualFold(provisionerKind, eks.ProvisionerName) {
 		ctx := eks.New(input)
 
-		if err = configuredInstanceGroup.Validate(); err != nil {
+		if err = input.InstanceGroup.Validate(); err != nil {
 			ctx.SetState(v1alpha1.ReconcileErr)
 			return ctrl.Result{}, errors.Wrapf(err, "provisioner %v reconcile failed", provisionerKind)
 		}
@@ -160,13 +160,13 @@ func (r *InstanceGroupReconciler) Reconcile(req ctrl.Request) (ctrl.Result, erro
 			return ctrl.Result{}, errors.Wrapf(err, "provisioner %v reconcile failed", provisionerKind)
 		}
 
-		isRetryable = eks.IsRetryable(configuredInstanceGroup)
+		isRetryable = eks.IsRetryable(input.InstanceGroup)
 	}
 
 	if strings.EqualFold(provisionerKind, eksmanaged.ProvisionerName) {
 		ctx := eksmanaged.New(input)
 
-		if err = configuredInstanceGroup.Validate(); err != nil {
+		if err = input.InstanceGroup.Validate(); err != nil {
 			ctx.SetState(v1alpha1.ReconcileErr)
 			return ctrl.Result{}, errors.Wrapf(err, "provisioner %v reconcile failed", provisionerKind)
 		}
@@ -176,13 +176,13 @@ func (r *InstanceGroupReconciler) Reconcile(req ctrl.Request) (ctrl.Result, erro
 			return ctrl.Result{}, errors.Wrapf(err, "provisioner %v reconcile failed", provisionerKind)
 		}
 
-		isRetryable = eksmanaged.IsRetryable(configuredInstanceGroup)
+		isRetryable = eksmanaged.IsRetryable(input.InstanceGroup)
 	}
 
 	if strings.EqualFold(provisionerKind, eksfargate.ProvisionerName) {
 		ctx := eksfargate.New(input)
 
-		if err = configuredInstanceGroup.Validate(); err != nil {
+		if err = input.InstanceGroup.Validate(); err != nil {
 			ctx.SetState(v1alpha1.ReconcileErr)
 			return ctrl.Result{}, errors.Wrapf(err, "provisioner %v reconcile failed", provisionerKind)
 		}
@@ -192,7 +192,7 @@ func (r *InstanceGroupReconciler) Reconcile(req ctrl.Request) (ctrl.Result, erro
 			return ctrl.Result{}, errors.Wrapf(err, "provisioner %v reconcile failed", provisionerKind)
 		}
 
-		isRetryable = eksfargate.IsRetryable(configuredInstanceGroup)
+		isRetryable = eksfargate.IsRetryable(input.InstanceGroup)
 	}
 
 	if isRetryable {
diff --git a/controllers/provisioners/config.go b/controllers/provisioners/config.go
index 5e0f89c..06ae481 100644
--- a/controllers/provisioners/config.go
+++ b/controllers/provisioners/config.go
@@ -16,7 +16,7 @@ limitations under the License.
 package provisioners
 
 import (
-	"reflect"
+	"fmt"
 	"strings"
 
 	"github.com/ghodss/yaml"
@@ -25,142 +25,236 @@ import (
 	runtime "k8s.io/apimachinery/pkg/runtime"
 
 	"github.com/keikoproj/instance-manager/api/v1alpha1"
+	"github.com/keikoproj/instance-manager/controllers/common"
 	corev1 "k8s.io/api/core/v1"
 )
 
-type DefaultConfiguration struct {
-	Boundaries BoundaryConfiguration
-	Defaults   map[string]interface{}
+var (
+	EKSConfigurationPath = "spec.eks.configuration"
+	EKSTagsPath          = fmt.Sprintf("%v.tags", EKSConfigurationPath)
+	EKSVolumesPath       = fmt.Sprintf("%v.volumes", EKSConfigurationPath)
+
+	// MergeSchema defines the key to merge by
+	MergeSchema = map[string]string{
+		EKSTagsPath:    "key",
+		EKSVolumesPath: "name",
+	}
+)
+
+type ProvisionerConfiguration struct {
+	Boundaries    ResourceFieldBoundary
+	Defaults      map[string]interface{}
+	InstanceGroup *v1alpha1.InstanceGroup
 }
 
-type BoundaryConfiguration struct {
-	Restricted []string `yaml:"restricted,omitempty"`
-	Shared     []string `yaml:"shared,omitempty"`
+func NewProvisionerConfiguration(config *corev1.ConfigMap, instanceGroup *v1alpha1.InstanceGroup) (*ProvisionerConfiguration, error) {
+	var c = &ProvisionerConfiguration{}
+	c.InstanceGroup = &v1alpha1.InstanceGroup{}
+	if err := c.Unmarshal(config); err != nil {
+		return c, errors.Wrap(err, "failed to unmarshal configuration")
+	}
+	instanceGroup.DeepCopyInto(c.InstanceGroup)
+	return c, nil
 }
 
-func SetConfigurationDefaults(instanceGroup *v1alpha1.InstanceGroup, config *DefaultConfiguration) (*v1alpha1.InstanceGroup, error) {
+type SharedBoundaries struct {
+	MergeOverride []string `yaml:"mergeOverride,omitempty"`
+	Merge         []string `yaml:"merge,omitempty"`
+	Replace       []string `yaml:"replace,omitempty"`
+}
+
+type ResourceFieldBoundary struct {
+	Restricted []string         `yaml:"restricted,omitempty"`
+	Shared     SharedBoundaries `yaml:"shared,omitempty"`
+}
+
+func (c *ProvisionerConfiguration) Unmarshal(cm *corev1.ConfigMap) error {
 	var (
-		modifiedInstanceGroup = &v1alpha1.InstanceGroup{}
+		boundariesPath = common.FieldPath("data.boundaries")
+		defaultsPath   = common.FieldPath("data.defaults")
 	)
 
-	if reflect.DeepEqual(config, &DefaultConfiguration{}) {
-		return instanceGroup, nil
+	config, err := runtime.DefaultUnstructuredConverter.ToUnstructured(cm)
+	if err != nil {
+		return errors.Wrap(err, "failed to convert configmap to unstructured")
+	}
+
+	if boundaries, ok, _ := unstructured.NestedString(config, boundariesPath...); ok {
+		boundaryConfig := &ResourceFieldBoundary{}
+		err := yaml.Unmarshal([]byte(boundaries), boundaryConfig)
+		if err != nil {
+			return errors.Wrap(err, "failed to unmarshal boundaries")
+		}
+		c.Boundaries = *boundaryConfig
 	}
 
-	log.Info("applying defaults from configmap")
+	if defaults, ok, _ := unstructured.NestedString(config, defaultsPath...); ok {
+		defaultConfig := &map[string]interface{}{}
+		err := yaml.Unmarshal([]byte(defaults), defaultConfig)
+		if err != nil {
+			return errors.Wrap(err, "failed to unmarshal defaults")
+		}
 
-	unstructuredInstanceGroup, err := runtime.DefaultUnstructuredConverter.ToUnstructured(instanceGroup)
+		c.Defaults, err = runtime.DefaultUnstructuredConverter.ToUnstructured(defaultConfig)
+		if err != nil {
+			return errors.Wrap(err, "failed to convert defaults to unstructured")
+		}
+	}
+
+	return nil
+}
+
+func (c *ProvisionerConfiguration) SetDefaults() error {
+
+	log.Info("applying managed defaults from configmap")
+	unstructuredInstanceGroup, err := runtime.DefaultUnstructuredConverter.ToUnstructured(c.InstanceGroup)
+	if err != nil {
+		return errors.Wrap(err, "failed to convert instance group to unstructured")
+	}
+
+	if err := c.setSharedFields(unstructuredInstanceGroup); err != nil {
+		return errors.Wrap(err, "failed to set shared fields")
+	}
+
+	if err := c.setRestrictedFields(unstructuredInstanceGroup); err != nil {
+		return errors.Wrap(err, "failed to set restricted fields")
+	}
+
+	err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredInstanceGroup, c.InstanceGroup)
 	if err != nil {
-		return instanceGroup, errors.Wrap(err, "failed to convert instance group to unstructured")
+		return errors.Wrap(err, "failed to convert instance group from unstructured")
 	}
 
+	return nil
+}
+
+func (c *ProvisionerConfiguration) setRestrictedFields(unstructuredInstanceGroup map[string]interface{}) error {
 	// apply restricted paths to instance group
-	for _, pathStr := range config.Boundaries.Restricted {
-		path := strings.Split(pathStr, ".")
+	for _, pathStr := range c.Boundaries.Restricted {
+		path := common.FieldPath(pathStr)
 		// if a default value exists for the path, set it on the instance group
-		if field, ok, _ := unstructured.NestedFieldCopy(config.Defaults, path...); ok {
+		if field, ok, _ := unstructured.NestedFieldCopy(c.Defaults, path...); ok {
 			// default value exists for restricted path
 			err := unstructured.SetNestedField(unstructuredInstanceGroup, field, path...)
 			if err != nil {
-				return instanceGroup, errors.Wrap(err, "failed to set field")
+				errors.Wrap(err, "failed to set nested field")
 			}
 		}
 	}
+	return nil
+}
+
+func isConflict(defaultVal, resourceVal interface{}) bool {
+	if resourceVal != nil && defaultVal != nil {
+		return true
+	}
+	return false
+}
+
+func (c *ProvisionerConfiguration) setSharedFields(obj map[string]interface{}) error {
 
-	// merge shared paths with instance group
-	for _, pathStr := range config.Boundaries.Shared {
-		path := strings.Split(pathStr, ".")
-		// if a default value exists for the path, merge it with the instance group
+	for _, pathStr := range c.Boundaries.Shared.Replace {
 		var (
-			defaultField interface{}
-			crField      interface{}
+			defaultVal  = common.FieldValue(pathStr, c.Defaults)
+			resourceVal = common.FieldValue(pathStr, obj)
 		)
-		if field, ok, _ := unstructured.NestedFieldCopy(config.Defaults, path...); ok {
-			defaultField = field
-		}
-		if field, ok, _ := unstructured.NestedFieldCopy(unstructuredInstanceGroup, path...); ok {
-			crField = field
-		}
 
-		if defaultField == nil {
+		if defaultVal == nil {
 			continue
 		}
 
-		if crField == nil {
-			if err := unstructured.SetNestedField(unstructuredInstanceGroup, defaultField, path...); err != nil {
-				return instanceGroup, errors.Wrap(err, "failed to set field")
+		if isConflict(defaultVal, resourceVal) {
+			if err := common.SetFieldValue(pathStr, obj, resourceVal); err != nil {
+				return errors.Wrap(err, "failed to replace field")
 			}
 			continue
 		}
-
-		switch v := defaultField.(type) {
-		case []interface{}:
-			ifaceSlc := crField.([]interface{})
-			ifaceSlc = append(ifaceSlc, v...)
-			if err := unstructured.SetNestedField(unstructuredInstanceGroup, ifaceSlc, path...); err != nil {
-				return instanceGroup, errors.Wrap(err, "failed to set field")
-			}
-		case map[string]interface{}:
-			crMapObj := crField.(map[string]interface{})
-			for key, val := range v {
-				crMapObj[key] = val
-			}
-			if err := unstructured.SetNestedField(unstructuredInstanceGroup, crMapObj, path...); err != nil {
-				return instanceGroup, errors.Wrap(err, "failed to set field")
-			}
-		default:
-			// any other type will prefer the CR value
-			if err := unstructured.SetNestedField(unstructuredInstanceGroup, crField, path...); err != nil {
-				return instanceGroup, errors.Wrap(err, "failed to set field")
-			}
+		if err := common.SetFieldValue(pathStr, obj, defaultVal); err != nil {
+			return errors.Wrap(err, "failed to replace field")
 		}
 	}
 
-	err = runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredInstanceGroup, modifiedInstanceGroup)
-	if err != nil {
-		return instanceGroup, errors.Wrap(err, "failed to convert instance group from unstructured")
+	for _, pathStr := range c.Boundaries.Shared.Merge {
+		var (
+			defaultVal  = common.FieldValue(pathStr, c.Defaults)
+			resourceVal = common.FieldValue(pathStr, obj)
+		)
+
+		if defaultVal == nil {
+			continue
+		}
+
+		if isConflict(defaultVal, resourceVal) {
+			merge := Merge(defaultVal, resourceVal, pathStr, false)
+			if err := common.SetFieldValue(pathStr, obj, merge); err != nil {
+				return errors.Wrap(err, "failed to merge field")
+			}
+			continue
+		}
+		if err := common.SetFieldValue(pathStr, obj, defaultVal); err != nil {
+			return errors.Wrap(err, "failed to merge field")
+		}
 	}
 
-	return modifiedInstanceGroup, nil
-}
+	for _, pathStr := range c.Boundaries.Shared.MergeOverride {
+		var (
+			defaultVal  = common.FieldValue(pathStr, c.Defaults)
+			resourceVal = common.FieldValue(pathStr, obj)
+		)
 
-func UnmarshalConfiguration(cm *corev1.ConfigMap) (*DefaultConfiguration, error) {
-	var (
-		config                  = &DefaultConfiguration{}
-		configmapBoundariesPath = []string{"data", "boundaries"}
-		configmapDefaultsPath   = []string{"data", "defaults"}
-	)
+		if defaultVal == nil {
+			continue
+		}
 
-	if cm == nil {
-		return config, nil
+		if isConflict(defaultVal, resourceVal) {
+			merge := Merge(defaultVal, resourceVal, pathStr, true)
+			if err := common.SetFieldValue(pathStr, obj, merge); err != nil {
+				return errors.Wrap(err, "failed to merge field")
+			}
+			continue
+		}
+		if err := common.SetFieldValue(pathStr, obj, defaultVal); err != nil {
+			return errors.Wrap(err, "failed to merge field")
+		}
 	}
 
-	unstructuredConfig, err := runtime.DefaultUnstructuredConverter.ToUnstructured(cm)
-	if err != nil {
-		return config, errors.Wrap(err, "failed to convert configmap to unstructured")
-	}
+	return nil
+}
 
-	if boundaries, ok, _ := unstructured.NestedString(unstructuredConfig, configmapBoundariesPath...); ok {
-		boundaryConfig := &BoundaryConfiguration{}
-		err := yaml.Unmarshal([]byte(boundaries), boundaryConfig)
-		if err != nil {
-			return config, errors.Wrap(err, "failed to unmarshal boundaries")
+func Merge(x, y interface{}, path string, override bool) interface{} {
+	switch xValue := x.(type) {
+	case []interface{}:
+		var (
+			idx       string
+			pathMatch bool
+		)
+
+		yValue := y.([]interface{})
+		for k, v := range MergeSchema {
+			if strings.HasSuffix(path, k) {
+				idx = v
+				pathMatch = true
+			}
 		}
-		config.Boundaries = *boundaryConfig
-	}
 
-	if defaults, ok, _ := unstructured.NestedString(unstructuredConfig, configmapDefaultsPath...); ok {
-		defaultConfig := &map[string]interface{}{}
-		err := yaml.Unmarshal([]byte(defaults), defaultConfig)
-		if err != nil {
-			return config, errors.Wrap(err, "failed to unmarshal defaults")
+		if !pathMatch {
+			return common.MergeSliceByUnique(xValue, yValue)
 		}
 
-		config.Defaults, err = runtime.DefaultUnstructuredConverter.ToUnstructured(defaultConfig)
-		if err != nil {
-			return config, errors.Wrap(err, "failed to convert defaults to unstructured")
+		return common.MergeSliceByIndex(xValue, yValue, idx, override)
+	case map[string]interface{}:
+		yValue := y.(map[string]interface{})
+		for key, val := range xValue {
+			if v, ok := yValue[key]; ok {
+				if override {
+					yValue[key] = v
+					continue
+				}
+			}
+			yValue[key] = val
 		}
+		return yValue
+	default:
+		return y
 	}
-
-	return config, nil
 }
diff --git a/controllers/provisioners/eks/eks.go b/controllers/provisioners/eks/eks.go
index bec2b6b..9af5fd0 100644
--- a/controllers/provisioners/eks/eks.go
+++ b/controllers/provisioners/eks/eks.go
@@ -71,7 +71,7 @@ type EksInstanceGroupContext struct {
 	AwsWorker        awsprovider.AwsWorker
 	DiscoveredState  *DiscoveredState
 	Log              logr.Logger
-	Configuration    *provisioners.DefaultConfiguration
+	Configuration    *provisioners.ProvisionerConfiguration
 	ResourcePrefix   string
 }
 
diff --git a/docs/EKS.md b/docs/EKS.md
index 01a2e73..7fd5303 100644
--- a/docs/EKS.md
+++ b/docs/EKS.md
@@ -285,6 +285,58 @@ spec:
 
 In order to support use-cases around GitOps or platform management, the controller allows operators to define 'boundaries' of configurations into `restricted` and `shared` configurations, along with the default values to enforce.
 
+`shared` fields can be referenced with a `merge`, `mergeOverride` or `replace` directive, which provide controls over how default values should be combined with the resource values. `merge` will provide a simple merge, if there is a conflict between the default value and the resource value, the resource value will not be allowed to override the default, for example consider the following default / resource values.
+
+```yaml
+# default values
+tags:
+- key: tag-A
+  value: value-A
+- key: tag-B
+  value: value-B
+
+# resource values
+tags:
+- key: tag-A
+  value: value-D
+- key: tag-C
+  value: value-C
+```
+
+If `spec.eks.configuration.tags` was referenced as a `merge` field the result would be:
+
+```yaml
+tags:
+- key: tag-A
+  value: value-A
+- key: tag-B
+  value: value-B
+- key: tag-C
+  value: value-C
+```
+
+`mergeOverride` allows the CR to override the default will have a different result:
+
+```yaml
+tags:
+- key: tag-A
+  value: value-D
+- key: tag-B
+  value: value-B
+- key: tag-C
+  value: value-C
+```
+
+`replace` would allow the CR to completely replace the default values:
+
+```yaml
+tags:
+- key: tag-A
+  value: value-D
+- key: tag-C
+  value: value-C
+```
+
 For example, if you'd like your cluster tenants to only be able to control certain fields such as `minSize`, `maxSize`, `instanceType`, `labels` and `tags` while the rest is controlled via the operator you could achieve this by creating a config-map the defines these boundaries.
 
 By default the controller watches configmaps with the name `instance-manager` in the namespaace `instance-manager`, namespace can be customized via a controller flag `--config-namespace`.
@@ -304,8 +356,12 @@ metadata:
 data:
   boundaries: |
     shared:
-    - spec.eks.configuration.tags
-    - spec.eks.configuration.labels
+      merge:
+      - spec.eks.configuration.labels
+      mergeOverride:
+      - spec.eks.configuration.tags
+      replace:
+      - spec.eks.strategy
     restricted:
     - spec.eks.configuration.clusterName
     - spec.eks.configuration.image
@@ -348,7 +404,7 @@ The user (or GitOps) can now submit a shortened custom resource that looks like
 apiVersion: instancemgr.keikoproj.io/v1alpha1
 kind: InstanceGroup
 metadata:
-  name: bdd-test-gitops
+  name: my-instance-group
   namespace: instance-manager
 spec:
   eks:
@@ -356,11 +412,11 @@ spec:
     maxSize: 4
     configuration:
       labels:
-        custom-resource-label: "true"
+        test-label: "true"
       instanceType: t2.small
       tags:
-      - key: a-customresource-key
-        value: a-customresource-value
+      - key: tag-key
+        value: tag-value
 ```
 
 The resulting scaling group will be a result of merging the shared values, and prefering the restricted values.


Test patch:
diff --git a/controllers/provisioners/config_test.go b/controllers/provisioners/config_test.go
index 7173460..63f8f11 100644
--- a/controllers/provisioners/config_test.go
+++ b/controllers/provisioners/config_test.go
@@ -47,58 +47,219 @@ func MockConfigData(keysAndValues ...string) map[string]string {
 	return d
 }
 
-func TestSetConfigurationDefaults(t *testing.T) {
+func MockResource() *v1alpha1.InstanceGroup {
+	return &v1alpha1.InstanceGroup{
+		Spec: v1alpha1.InstanceGroupSpec{
+			EKSSpec: &v1alpha1.EKSSpec{
+				EKSConfiguration: &v1alpha1.EKSConfiguration{},
+			},
+		},
+	}
+}
+
+func MockVolume(name, tp string, size int64) v1alpha1.NodeVolume {
+	return v1alpha1.NodeVolume{
+		Name: name,
+		Type: tp,
+		Size: size,
+	}
+}
+
+func MockLabels(keysAndValues ...string) map[string]string {
+	lbl := map[string]string{}
+	for i := 0; i < len(keysAndValues); i = i + 2 {
+		lbl[keysAndValues[i]] = keysAndValues[i+1]
+	}
+	return lbl
+}
+
+func MockTag(key, value string) map[string]string {
+	tag := map[string]string{}
+	tag["key"] = key
+	tag["value"] = value
+	return tag
+}
+
+func MockTaint(key, value, effect string) corev1.Taint {
+	return corev1.Taint{
+		Key:    key,
+		Value:  value,
+		Effect: corev1.TaintEffect(effect),
+	}
+}
+
+func TestSetDefaultsRestricted(t *testing.T) {
 	var (
 		g = gomega.NewGomegaWithT(t)
 	)
 
-	crVolume := v1alpha1.NodeVolume{
-		Name: "/dev/crvolume",
-		Type: "gp2",
-		Size: 60,
-	}
+	// Restricted fields are always replaced with default values
 
-	crTags := map[string]string{
-		"key":   "cr-tag-key",
-		"value": "cr-tag-value",
-	}
+	mockBoundaries := `
+    restricted:
+    - spec.eks.configuration.keyPairName
+    - spec.eks.configuration.taints
+    - spec.eks.configuration.labels
+    - spec.eks.configuration.securityGroups
+    - spec.eks.configuration.instanceType
+    - spec.strategy`
 
-	crSecurityGroups := []string{"sg-123456789012"}
+	mockDefaults := `
+spec:
+  strategy:
+    type: rollingUpdate
+    rollingUpdate:
+      maxUnavailable: 30%
+  eks:
+    configuration:
+      keyPairName: TestKeyPair
+      image: ami-025bf02d663404bbc
+      securityGroups:
+      - sg-123456789012
+      instanceType: m5.large
+      labels:
+        label-key: label-value
+      taints:
+      - key: taint-key
+        value: taint-value
+        effect: NoSchedule`
 
-	instanceGroup := &v1alpha1.InstanceGroup{
-		Spec: v1alpha1.InstanceGroupSpec{
-			AwsUpgradeStrategy: v1alpha1.AwsUpgradeStrategy{
-				Type:    "crd",
-				CRDType: &v1alpha1.CRDUpdateStrategy{},
-			},
-			EKSSpec: &v1alpha1.EKSSpec{
-				EKSConfiguration: &v1alpha1.EKSConfiguration{
-					Taints: []corev1.Taint{
-						{
-							Key:    "cr-taint-key",
-							Value:  "cr-taint-value",
-							Effect: "NoSchedule",
-						},
-					},
-					Labels:             map[string]string{},
-					Volumes:            []v1alpha1.NodeVolume{crVolume},
-					Tags:               []map[string]string{crTags},
-					NodeSecurityGroups: crSecurityGroups,
-				},
-			},
+	cm := MockConfigMap(MockConfigData("boundaries", mockBoundaries, "defaults", mockDefaults))
+	cr := MockResource()
+	cr.Spec.EKSSpec.EKSConfiguration.EksClusterName = "someCluster"
+	cr.Spec.EKSSpec.EKSConfiguration.NodeSecurityGroups = []string{"sg-000000000000"}
+	cr.Spec.EKSSpec.EKSConfiguration.InstanceType = "m5.xlarge"
+	cr.Spec.EKSSpec.EKSConfiguration.Labels = MockLabels("other-label-key", "other-label-value")
+	cr.Spec.EKSSpec.EKSConfiguration.Taints = []corev1.Taint{MockTaint("other-taint-key", "other-taint-value", "NoExecute")}
+	cr.Spec.AwsUpgradeStrategy = v1alpha1.AwsUpgradeStrategy{Type: "crd"}
+	c, err := NewProvisionerConfiguration(cm, cr)
+	g.Expect(err).NotTo(gomega.HaveOccurred())
+
+	err = c.SetDefaults()
+	g.Expect(err).NotTo(gomega.HaveOccurred())
+
+	// All restricted fields must be overwritten with default value even if the user sets them
+	g.Expect(c.InstanceGroup.Spec.AwsUpgradeStrategy).To(gomega.Equal(v1alpha1.AwsUpgradeStrategy{
+		Type: "rollingUpdate",
+		RollingUpdateType: &v1alpha1.RollingUpdateStrategy{
+			MaxUnavailable: &intstr.IntOrString{Type: intstr.String, StrVal: "30%"},
+		},
+	}))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.NodeSecurityGroups).To(gomega.Equal([]string{"sg-123456789012"}))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.InstanceType).To(gomega.Equal("m5.large"))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Labels).To(gomega.Equal(MockLabels("label-key", "label-value")))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Taints).To(gomega.Equal([]corev1.Taint{MockTaint("taint-key", "taint-value", "NoSchedule")}))
+
+	// Fields without defaults should stay as provided
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.EksClusterName).To(gomega.Equal("someCluster"))
+
+	// Defaults without boundary should not be set
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Image).To(gomega.Equal(""))
+
+	// Fields with defaults are used when CR does not provide it
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.KeyPairName).To(gomega.Equal("TestKeyPair"))
+}
+
+func TestSetDefaultsSharedMergeOverride(t *testing.T) {
+	var (
+		g = gomega.NewGomegaWithT(t)
+	)
+
+	// Shared merge override fields are resource-provided values merged with default values (favoring the custom resource on conflict)
+
+	mockBoundaries := `
+    shared:
+      mergeOverride:
+      - spec.eks.configuration.roleName
+      - spec.eks.configuration.keyPairName
+      - spec.eks.configuration.taints
+      - spec.eks.configuration.labels
+      - spec.eks.configuration.securityGroups
+      - spec.eks.configuration.instanceType
+      - spec.strategy`
+
+	mockDefaults := `
+spec:
+  strategy:
+    type: rollingUpdate
+    rollingUpdate:
+      maxUnavailable: 30%
+  eks:
+    configuration:
+      image: ami-025bf02d663404bbc
+      securityGroups:
+      - sg-123456789012
+      instanceType: m5.large
+      keyPairName: TestKeyPair
+      labels:
+        label-key: label-value
+      taints:
+      - key: taint-key
+        value: taint-value
+        effect: NoSchedule`
+
+	cm := MockConfigMap(MockConfigData("boundaries", mockBoundaries, "defaults", mockDefaults))
+	cr := MockResource()
+	cr.Spec.AwsUpgradeStrategy = v1alpha1.AwsUpgradeStrategy{
+		Type: "crd",
+		CRDType: &v1alpha1.CRDUpdateStrategy{
+			CRDName: "myCrd",
 		},
+		RollingUpdateType: &v1alpha1.RollingUpdateStrategy{},
 	}
+	cr.Spec.EKSSpec.EKSConfiguration.EksClusterName = "someCluster"
+	cr.Spec.EKSSpec.EKSConfiguration.NodeSecurityGroups = []string{"sg-000000000000"}
+	cr.Spec.EKSSpec.EKSConfiguration.InstanceType = "m5.xlarge"
+	cr.Spec.EKSSpec.EKSConfiguration.Labels = MockLabels("other-label-key", "other-label-value", "label-key", "other-value")
+	cr.Spec.EKSSpec.EKSConfiguration.Taints = []corev1.Taint{MockTaint("other-taint-key", "other-taint-value", "NoExecute")}
+	c, err := NewProvisionerConfiguration(cm, cr)
+	g.Expect(err).NotTo(gomega.HaveOccurred())
+
+	err = c.SetDefaults()
+	g.Expect(err).NotTo(gomega.HaveOccurred())
+
+	// Shared merge fields must merge slices/maps and consist of both CR/Default objects if there is no conflict
+	g.Expect(c.InstanceGroup.Spec.AwsUpgradeStrategy).To(gomega.Equal(v1alpha1.AwsUpgradeStrategy{
+		Type: "crd",
+		CRDType: &v1alpha1.CRDUpdateStrategy{
+			CRDName: "myCrd",
+		},
+		RollingUpdateType: &v1alpha1.RollingUpdateStrategy{},
+	}))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.NodeSecurityGroups).To(gomega.ConsistOf("sg-000000000000", "sg-123456789012"))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.InstanceType).To(gomega.Equal("m5.xlarge"))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Labels).To(gomega.Equal(MockLabels("label-key", "other-value", "other-label-key", "other-label-value")))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Taints).To(gomega.Equal([]corev1.Taint{
+		MockTaint("taint-key", "taint-value", "NoSchedule"),
+		MockTaint("other-taint-key", "other-taint-value", "NoExecute"),
+	}))
+
+	// Fields without defaults should stay as provided
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.EksClusterName).To(gomega.Equal("someCluster"))
+
+	// Defaults without boundary should not be set
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Image).To(gomega.Equal(""))
+
+	// Fields with defaults are used when CR does not provide it
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.KeyPairName).To(gomega.Equal("TestKeyPair"))
+}
+
+func TestSetDefaultsSharedMerge(t *testing.T) {
+	var (
+		g = gomega.NewGomegaWithT(t)
+	)
+
+	// Shared merge fields are resource-provided values merged with default values (favoring the default on conflict)
 
 	mockBoundaries := `
-restricted:
-- spec.eks.configuration.taints
-- spec.eks.configuration.labels
-shared:
-- spec.eks.configuration.volumes
-- spec.eks.configuration.tags
-- spec.eks.configuration.securityGroups
-- spec.eks.configuration.instanceType
-- spec.strategy`
+    shared:
+      merge:
+      - spec.eks.configuration.roleName
+      - spec.eks.configuration.keyPairName
+      - spec.eks.configuration.taints
+      - spec.eks.configuration.labels
+      - spec.eks.configuration.securityGroups
+      - spec.eks.configuration.instanceType`
 
 	mockDefaults := `
 spec:
@@ -108,62 +269,219 @@ spec:
       maxUnavailable: 30%
   eks:
     configuration:
+      image: ami-025bf02d663404bbc
+      securityGroups:
+      - sg-123456789012
       instanceType: m5.large
+      keyPairName: TestKeyPair
       labels:
         label-key: label-value
       taints:
       - key: taint-key
         value: taint-value
-        effect: NoSchedule
+        effect: NoSchedule`
+
+	cm := MockConfigMap(MockConfigData("boundaries", mockBoundaries, "defaults", mockDefaults))
+	cr := MockResource()
+
+	cr.Spec.EKSSpec.EKSConfiguration.EksClusterName = "someCluster"
+	cr.Spec.EKSSpec.EKSConfiguration.NodeSecurityGroups = []string{"sg-000000000000"}
+	cr.Spec.EKSSpec.EKSConfiguration.InstanceType = "m5.xlarge"
+	cr.Spec.EKSSpec.EKSConfiguration.Labels = MockLabels("other-label-key", "other-label-value", "label-key", "other-value")
+	cr.Spec.EKSSpec.EKSConfiguration.Taints = []corev1.Taint{MockTaint("other-taint-key", "other-taint-value", "NoExecute")}
+	c, err := NewProvisionerConfiguration(cm, cr)
+	g.Expect(err).NotTo(gomega.HaveOccurred())
+
+	err = c.SetDefaults()
+	g.Expect(err).NotTo(gomega.HaveOccurred())
+
+	// Shared merge fields must merge slices/maps and consist of both CR/Default objects if there is no conflict
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.NodeSecurityGroups).To(gomega.ConsistOf("sg-000000000000", "sg-123456789012"))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.InstanceType).To(gomega.Equal("m5.xlarge"))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Labels).To(gomega.Equal(MockLabels("label-key", "label-value", "other-label-key", "other-label-value")))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Taints).To(gomega.Equal([]corev1.Taint{
+		MockTaint("taint-key", "taint-value", "NoSchedule"),
+		MockTaint("other-taint-key", "other-taint-value", "NoExecute"),
+	}))
+
+	// Fields without defaults should stay as provided
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.EksClusterName).To(gomega.Equal("someCluster"))
+
+	// Defaults without boundary should not be set
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Image).To(gomega.Equal(""))
+
+	// Fields with defaults are used when CR does not provide it
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.KeyPairName).To(gomega.Equal("TestKeyPair"))
+}
+
+func TestSetDefaultsSharedMergeOverrideConflict(t *testing.T) {
+	var (
+		g = gomega.NewGomegaWithT(t)
+	)
+
+	// When there are merge conflicts, the resource value should override the default
+
+	mockBoundaries := `
+    shared:
+      mergeOverride:
+      - spec.eks.configuration.tags
+      - spec.eks.configuration.volumes
+      - spec.eks.configuration.taints
+      - spec.eks.configuration.labels
+      - spec.eks.configuration.securityGroups
+      - spec.eks.configuration.instanceType
+      - spec.strategy`
+
+	mockDefaults := `
+spec:
+  strategy:
+    type: rollingUpdate
+    rollingUpdate:
+      maxUnavailable: 30%
+  eks:
+    configuration:
+      image: ami-025bf02d663404bbc
+      securityGroups:
+      - sg-123456789012
+      instanceType: m5.large
+      tags:
+      - key: tag
+        value: tag-value
+      - key: tag2
+        value: tag-value-2
       volumes:
-      - size: 50
+      - name: /dev/xvda
         type: gp2
-        name: /dev/xvda
-      tags:
-      - key: tag-key
-        value: tag-value`
+        size: 30
+      - name: /dev/xvdc
+        type: gp2
+        size: 30
+      labels:
+        test: test
+        label-key: label-value
+      taints:
+      - key: taint-key
+        value: taint-value
+        effect: NoSchedule`
 
-	defaultLabels := map[string]string{"label-key": "label-value"}
-	defaultTags := map[string]string{"key": "tag-key", "value": "tag-value"}
-	defaultVolume := v1alpha1.NodeVolume{
-		Name: "/dev/xvda",
-		Type: "gp2",
-		Size: 50,
-	}
+	cm := MockConfigMap(MockConfigData("boundaries", mockBoundaries, "defaults", mockDefaults))
+	cr := MockResource()
 
-	defaultTaints := []corev1.Taint{
-		{
-			Key:    "taint-key",
-			Value:  "taint-value",
-			Effect: "NoSchedule",
-		},
+	cr.Spec.EKSSpec.EKSConfiguration.NodeSecurityGroups = []string{"sg-025bf02d663404bbc"}
+	cr.Spec.EKSSpec.EKSConfiguration.InstanceType = "m5.large"
+	cr.Spec.EKSSpec.EKSConfiguration.Labels = MockLabels("label-key", "other-label-value")
+	cr.Spec.EKSSpec.EKSConfiguration.Taints = []corev1.Taint{MockTaint("taint-key", "taint-value", "NoExecute")}
+	cr.Spec.EKSSpec.EKSConfiguration.Tags = []map[string]string{
+		MockTag("tag", "other-value"),
+		MockTag("other-tag", "value"),
+		MockTag("tag2", "tag-value-2"),
 	}
+	cr.Spec.EKSSpec.EKSConfiguration.Volumes = []v1alpha1.NodeVolume{
+		MockVolume("/dev/xvda", "gp2", 35),
+		MockVolume("/dev/xvdb", "gp2", 30),
+		MockVolume("/dev/xvdc", "gp2", 30),
+	}
+	c, err := NewProvisionerConfiguration(cm, cr)
+	g.Expect(err).NotTo(gomega.HaveOccurred())
 
-	defaultStrategy := v1alpha1.AwsUpgradeStrategy{
-		Type:    "rollingUpdate",
-		CRDType: &v1alpha1.CRDUpdateStrategy{},
-		RollingUpdateType: &v1alpha1.RollingUpdateStrategy{
-			MaxUnavailable: &intstr.IntOrString{
-				Type:   intstr.String,
-				StrVal: "30%",
-			},
+	err = c.SetDefaults()
+	g.Expect(err).NotTo(gomega.HaveOccurred())
+
+	// Shared merge fields must merge slices/maps and consist of both CR/Default objects if there is no conflict
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.NodeSecurityGroups).To(gomega.ConsistOf("sg-025bf02d663404bbc", "sg-123456789012"))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.InstanceType).To(gomega.Equal("m5.large"))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Labels).To(gomega.Equal(MockLabels("test", "test", "label-key", "other-label-value")))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Taints).To(gomega.Equal([]corev1.Taint{
+		MockTaint("taint-key", "taint-value", "NoSchedule"),
+		MockTaint("taint-key", "taint-value", "NoExecute"),
+	}))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Tags).To(gomega.ConsistOf(
+		MockTag("tag", "other-value"),
+		MockTag("other-tag", "value"),
+		MockTag("tag2", "tag-value-2"),
+	))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Volumes).To(gomega.ConsistOf(
+		MockVolume("/dev/xvda", "gp2", 35),
+		MockVolume("/dev/xvdb", "gp2", 30),
+		MockVolume("/dev/xvdc", "gp2", 30),
+	))
+}
+
+func TestSetDefaultsSharedReplace(t *testing.T) {
+	var (
+		g = gomega.NewGomegaWithT(t)
+	)
+
+	// Shared replace fields are resource-provided values which can replace default values
+
+	mockBoundaries := `
+    shared:
+      replace:
+      - spec.eks.configuration.roleName
+      - spec.eks.configuration.keyPairName
+      - spec.eks.configuration.taints
+      - spec.eks.configuration.labels
+      - spec.eks.configuration.securityGroups
+      - spec.eks.configuration.instanceType
+      - spec.strategy`
+
+	mockDefaults := `
+spec:
+  strategy:
+    type: rollingUpdate
+    rollingUpdate:
+      maxUnavailable: 30%
+  eks:
+    configuration:
+      keyPairName: TestKeyPair
+      image: ami-025bf02d663404bbc
+      securityGroups:
+      - sg-123456789012
+      instanceType: m5.large
+      labels:
+        label-key: label-value
+      taints:
+      - key: taint-key
+        value: taint-value
+        effect: NoSchedule`
+
+	cm := MockConfigMap(MockConfigData("boundaries", mockBoundaries, "defaults", mockDefaults))
+	cr := MockResource()
+	cr.Spec.AwsUpgradeStrategy = v1alpha1.AwsUpgradeStrategy{
+		Type: "crd",
+		CRDType: &v1alpha1.CRDUpdateStrategy{
+			CRDName: "myCrd",
 		},
+		RollingUpdateType: &v1alpha1.RollingUpdateStrategy{},
 	}
+	cr.Spec.EKSSpec.EKSConfiguration.EksClusterName = "someCluster"
+	cr.Spec.EKSSpec.EKSConfiguration.NodeSecurityGroups = []string{"sg-000000000000"}
+	cr.Spec.EKSSpec.EKSConfiguration.InstanceType = "m5.xlarge"
+	cr.Spec.EKSSpec.EKSConfiguration.Labels = MockLabels("other-label-key", "other-label-value")
+	cr.Spec.EKSSpec.EKSConfiguration.Taints = []corev1.Taint{MockTaint("other-taint-key", "other-taint-value", "NoExecute")}
 
-	defaultConfig, err := UnmarshalConfiguration(MockConfigMap(MockConfigData("boundaries", mockBoundaries, "defaults", mockDefaults)))
+	c, err := NewProvisionerConfiguration(cm, cr)
 	g.Expect(err).NotTo(gomega.HaveOccurred())
 
-	instanceGroup, err = SetConfigurationDefaults(instanceGroup, defaultConfig)
+	err = c.SetDefaults()
 	g.Expect(err).NotTo(gomega.HaveOccurred())
-	// Restricted values get 1st priority
-	g.Expect(instanceGroup.Spec.EKSSpec.EKSConfiguration.Taints).To(gomega.Equal(defaultTaints))
-	g.Expect(instanceGroup.Spec.EKSSpec.EKSConfiguration.Labels).To(gomega.Equal(defaultLabels))
-	// Shared values get merged
-	g.Expect(instanceGroup.Spec.EKSSpec.EKSConfiguration.Volumes).To(gomega.ConsistOf(defaultVolume, crVolume))
-	g.Expect(instanceGroup.Spec.EKSSpec.EKSConfiguration.Tags).To(gomega.ConsistOf(defaultTags, crTags))
-	g.Expect(instanceGroup.Spec.AwsUpgradeStrategy).To(gomega.Equal(defaultStrategy))
-	g.Expect(instanceGroup.Spec.EKSSpec.EKSConfiguration.NodeSecurityGroups).To(gomega.Equal(crSecurityGroups))
-	g.Expect(instanceGroup.Spec.EKSSpec.EKSConfiguration.InstanceType).To(gomega.Equal("m5.large"))
+
+	// Shared merge fields must merge slices/maps and consist of both CR/Default objects if there is no conflict
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.NodeSecurityGroups).To(gomega.ConsistOf("sg-000000000000"))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.InstanceType).To(gomega.Equal("m5.xlarge"))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Labels).To(gomega.Equal(MockLabels("other-label-key", "other-label-value")))
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Taints).To(gomega.Equal([]corev1.Taint{
+		MockTaint("other-taint-key", "other-taint-value", "NoExecute"),
+	}))
+
+	// Fields without defaults should stay as provided
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.EksClusterName).To(gomega.Equal("someCluster"))
+
+	// Defaults without boundary should not be set
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.Image).To(gomega.Equal(""))
+
+	// Fields with defaults are used when CR does not provide it
+	g.Expect(c.InstanceGroup.Spec.EKSSpec.EKSConfiguration.KeyPairName).To(gomega.Equal("TestKeyPair"))
 }
 
 func TestUnmarshalConfiguration(t *testing.T) {
@@ -171,17 +489,19 @@ func TestUnmarshalConfiguration(t *testing.T) {
 		g = gomega.NewGomegaWithT(t)
 	)
 
-	defaultConfig, err := UnmarshalConfiguration(nil)
-	g.Expect(err).NotTo(gomega.HaveOccurred())
-	g.Expect(defaultConfig).To(gomega.Equal(&DefaultConfiguration{}))
-
 	mockBoundaries := `
 restricted:
 - spec.eks.configuration.taints
 - spec.eks.configuration.labels
 shared:
-- spec.eks.configuration.volumes
-- spec.eks.configuration.tags`
+  mergeOverride:
+  - spec.eks.configuration.volumes
+  merge:
+  - spec.eks.configuration.volumes
+  - spec.eks.configuration.tags
+  replace:
+  - spec.eks.configuration.taints
+  - spec.eks.configuration.labels`
 
 	mockDefaults := `
 spec:
@@ -202,12 +522,15 @@ spec:
         value: tag-value`
 
 	expectedDefaults := map[string]interface{}{}
-	err = yaml.Unmarshal([]byte(mockDefaults), &expectedDefaults)
+	err := yaml.Unmarshal([]byte(mockDefaults), &expectedDefaults)
 	g.Expect(err).NotTo(gomega.HaveOccurred())
 
-	defaultConfig, err = UnmarshalConfiguration(MockConfigMap(MockConfigData("boundaries", mockBoundaries, "defaults", mockDefaults)))
+	cm := MockConfigMap(MockConfigData("boundaries", mockBoundaries, "defaults", mockDefaults))
+	c, err := NewProvisionerConfiguration(cm, &v1alpha1.InstanceGroup{})
 	g.Expect(err).NotTo(gomega.HaveOccurred())
-	g.Expect(defaultConfig.Boundaries.Restricted).To(gomega.ConsistOf("spec.eks.configuration.taints", "spec.eks.configuration.labels"))
-	g.Expect(defaultConfig.Boundaries.Shared).To(gomega.ConsistOf("spec.eks.configuration.volumes", "spec.eks.configuration.tags"))
-	g.Expect(defaultConfig.Defaults).To(gomega.Equal(expectedDefaults))
+	g.Expect(c.Boundaries.Restricted).To(gomega.ConsistOf("spec.eks.configuration.taints", "spec.eks.configuration.labels"))
+	g.Expect(c.Boundaries.Shared.Merge).To(gomega.ConsistOf("spec.eks.configuration.volumes", "spec.eks.configuration.tags"))
+	g.Expect(c.Boundaries.Shared.MergeOverride).To(gomega.ConsistOf("spec.eks.configuration.volumes"))
+	g.Expect(c.Boundaries.Shared.Replace).To(gomega.ConsistOf("spec.eks.configuration.taints", "spec.eks.configuration.labels"))
+	g.Expect(c.Defaults).To(gomega.Equal(expectedDefaults))
 }
diff --git a/test-bdd/templates/instance-group-gitops.yaml b/test-bdd/templates/instance-group-gitops.yaml
index b429c3c..08d856f 100644
--- a/test-bdd/templates/instance-group-gitops.yaml
+++ b/test-bdd/templates/instance-group-gitops.yaml
@@ -12,5 +12,7 @@ spec:
         test: bdd-test-gitops
       instanceType: t2.small
       tags:
+      - key: a-default-tag
+        value: some-other-value
       - key: cr-tag
         value: a-tag
diff --git a/test-bdd/templates/manager-configmap.yaml b/test-bdd/templates/manager-configmap.yaml
index 283bbe4..b870fe7 100644
--- a/test-bdd/templates/manager-configmap.yaml
+++ b/test-bdd/templates/manager-configmap.yaml
@@ -2,11 +2,13 @@ apiVersion: v1
 data:
   boundaries: |
     shared:
-    - spec.strategy
-    - spec.eks.configuration.securityGroups
-    - spec.eks.configuration.tags
-    - spec.eks.configuration.labels
-    - spec.eks.configuration.volumes
+      mergeOverride:
+      - spec.eks.configuration.tags
+      merge:
+      - spec.strategy
+      - spec.eks.configuration.securityGroups
+      - spec.eks.configuration.labels
+      - spec.eks.configuration.volumes
     restricted:
     - spec.provisioner
     - spec.eks.configuration.clusterName