diff --git a/internal/resourcemodifiers/json_merge_patch.go b/internal/resourcemodifiers/json_merge_patch.go new file mode 100644 index 000000000..6201e75c7 --- /dev/null +++ b/internal/resourcemodifiers/json_merge_patch.go @@ -0,0 +1,46 @@ +package resourcemodifiers + +import ( + "fmt" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" +) + +type JSONMergePatch struct { + PatchBytes []byte `json:"patchBytes,omitempty"` +} + +type JSONMergePatcher struct { + patches []JSONMergePatch +} + +func (p *JSONMergePatcher) Patch(u *unstructured.Unstructured, _ logrus.FieldLogger) (*unstructured.Unstructured, error) { + objBytes, err := u.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("error in marshaling object %s", err) + } + + var modifiedObjBytes []byte + for _, patch := range p.patches { + patchBytes, err := yaml.YAMLToJSON(patch.PatchBytes) + if err != nil { + return nil, fmt.Errorf("error in converting YAML to JSON %s", err) + } + + modifiedObjBytes, err = jsonpatch.MergePatch(objBytes, patchBytes) + if err != nil { + return nil, fmt.Errorf("error in applying JSON Patch %s", err.Error()) + } + } + + updated := &unstructured.Unstructured{} + err = updated.UnmarshalJSON(modifiedObjBytes) + if err != nil { + return nil, fmt.Errorf("error in unmarshalling modified object %s", err.Error()) + } + + return updated, nil +} diff --git a/internal/resourcemodifiers/json_patch.go b/internal/resourcemodifiers/json_patch.go new file mode 100644 index 000000000..b5af7c362 --- /dev/null +++ b/internal/resourcemodifiers/json_patch.go @@ -0,0 +1,96 @@ +package resourcemodifiers + +import ( + "errors" + "fmt" + "strconv" + "strings" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/sirupsen/logrus" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +type JSONPatch struct { + Operation string `json:"operation"` + From string `json:"from,omitempty"` + Path string `json:"path"` + Value string `json:"value,omitempty"` +} + +func (p *JSONPatch) ToString() string { + if addQuotes(p.Value) { + return fmt.Sprintf(`{"op": "%s", "from": "%s", "path": "%s", "value": "%s"}`, p.Operation, p.From, p.Path, p.Value) + } + return fmt.Sprintf(`{"op": "%s", "from": "%s", "path": "%s", "value": %s}`, p.Operation, p.From, p.Path, p.Value) +} + +func addQuotes(value string) bool { + if value == "" { + return true + } + // if value is null, then don't add quotes + if value == "null" { + return false + } + // if value is a boolean, then don't add quotes + if _, err := strconv.ParseBool(value); err == nil { + return false + } + // if value is a json object or array, then don't add quotes. + if strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[") { + return false + } + // if value is a number, then don't add quotes + if _, err := strconv.ParseFloat(value, 64); err == nil { + return false + } + return true +} + +type JSONPatcher struct { + patches []JSONPatch `yaml:"patches"` +} + +func (p *JSONPatcher) Patch(u *unstructured.Unstructured, logger logrus.FieldLogger) (*unstructured.Unstructured, error) { + modifiedObjBytes, err := p.applyPatch(u) + if err != nil { + if errors.Is(err, jsonpatch.ErrTestFailed) { + logger.Infof("Test operation failed for JSON Patch %s", err.Error()) + return u.DeepCopy(), nil + } + return nil, fmt.Errorf("error in applying JSON Patch %s", err.Error()) + } + + updated := &unstructured.Unstructured{} + err = updated.UnmarshalJSON(modifiedObjBytes) + if err != nil { + return nil, fmt.Errorf("error in unmarshalling modified object %s", err.Error()) + } + + return updated, nil +} + +func (p *JSONPatcher) applyPatch(u *unstructured.Unstructured) ([]byte, error) { + patchBytes := p.patchArrayToByteArray() + jsonPatch, err := jsonpatch.DecodePatch(patchBytes) + if err != nil { + return nil, fmt.Errorf("error in decoding json patch %s", err.Error()) + } + + objBytes, err := u.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("error in marshaling object %s", err.Error()) + } + + return jsonPatch.Apply(objBytes) +} + +func (p *JSONPatcher) patchArrayToByteArray() []byte { + var patches []string + for _, patch := range p.patches { + patches = append(patches, patch.ToString()) + } + patchesStr := strings.Join(patches, ",\n\t") + return []byte(fmt.Sprintf(`[%s]`, patchesStr)) +} diff --git a/internal/resourcemodifiers/resource_modifiers.go b/internal/resourcemodifiers/resource_modifiers.go index dbcd8e7ba..419c3c6df 100644 --- a/internal/resourcemodifiers/resource_modifiers.go +++ b/internal/resourcemodifiers/resource_modifiers.go @@ -3,16 +3,16 @@ package resourcemodifiers import ( "fmt" "regexp" - "strconv" - "strings" jsonpatch "github.com/evanphx/json-patch" + "github.com/gobwas/glob" "github.com/pkg/errors" "github.com/sirupsen/logrus" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/yaml" "github.com/vmware-tanzu/velero/pkg/util/collections" @@ -23,23 +23,20 @@ const ( ResourceModifierSupportedVersionV1 = "v1" ) -type JSONPatch struct { - Operation string `json:"operation"` - From string `json:"from,omitempty"` - Path string `json:"path"` - Value string `json:"value,omitempty"` -} - type Conditions struct { Namespaces []string `json:"namespaces,omitempty"` GroupResource string `json:"groupResource"` ResourceNameRegex string `json:"resourceNameRegex,omitempty"` LabelSelector *metav1.LabelSelector `json:"labelSelector,omitempty"` + Matches []JSONPatch `json:"matches,omitempty"` } type ResourceModifierRule struct { - Conditions Conditions `json:"conditions"` - Patches []JSONPatch `json:"patches"` + Conditions Conditions `json:"conditions"` + PatchData string `json:"patchData,omitempty"` + Patches []JSONPatch `json:"patches,omitempty"` + MergePatches []JSONMergePatch `json:"mergePatches,omitempty"` + StrategicPatches []StrategicMergePatch `json:"strategicPatches,omitempty"` } type ResourceModifiers struct { @@ -68,10 +65,10 @@ func GetResourceModifiersFromConfig(cm *v1.ConfigMap) (*ResourceModifiers, error return resModifiers, nil } -func (p *ResourceModifiers) ApplyResourceModifierRules(obj *unstructured.Unstructured, groupResource string, log logrus.FieldLogger) []error { +func (p *ResourceModifiers) ApplyResourceModifierRules(obj *unstructured.Unstructured, groupResource string, scheme *runtime.Scheme, log logrus.FieldLogger) []error { var errs []error for _, rule := range p.ResourceModifierRules { - err := rule.Apply(obj, groupResource, log) + err := rule.apply(obj, groupResource, scheme, log) if err != nil { errs = append(errs, err) } @@ -80,13 +77,19 @@ func (p *ResourceModifiers) ApplyResourceModifierRules(obj *unstructured.Unstruc return errs } -func (r *ResourceModifierRule) Apply(obj *unstructured.Unstructured, groupResource string, log logrus.FieldLogger) error { +func (r *ResourceModifierRule) apply(obj *unstructured.Unstructured, groupResource string, scheme *runtime.Scheme, log logrus.FieldLogger) error { namespaceInclusion := collections.NewIncludesExcludes().Includes(r.Conditions.Namespaces...) if !namespaceInclusion.ShouldInclude(obj.GetNamespace()) { return nil } - if r.Conditions.GroupResource != groupResource { + g, err := glob.Compile(r.Conditions.GroupResource) + if err != nil { + log.Errorf("bad glob pattern %s, err: %s", r.Conditions.GroupResource, err) + return err + } + + if !g.Match(groupResource) { return nil } @@ -110,57 +113,44 @@ func (r *ResourceModifierRule) Apply(obj *unstructured.Unstructured, groupResour } } - patches, err := r.PatchArrayToByteArray() + match, err := matchConditions(obj, r.Conditions.Matches, log) if err != nil { return err + } else if !match { + log.Info("Conditions do not match, skip it") + return nil } + log.Infof("Applying resource modifier patch on %s/%s", obj.GetNamespace(), obj.GetName()) - err = ApplyPatch(patches, obj, log) + err = r.applyPatch(obj, scheme, log) if err != nil { return err } return nil } -// PatchArrayToByteArray converts all JsonPatch to string array with the format of jsonpatch.Patch and then convert it to byte array -func (r *ResourceModifierRule) PatchArrayToByteArray() ([]byte, error) { - var patches []string - for _, patch := range r.Patches { - patches = append(patches, patch.ToString()) +func matchConditions(u *unstructured.Unstructured, patches []JSONPatch, logger logrus.FieldLogger) (bool, error) { + if len(patches) == 0 { + return true, nil } - patchesStr := strings.Join(patches, ",\n\t") - return []byte(fmt.Sprintf(`[%s]`, patchesStr)), nil -} -func (p *JSONPatch) ToString() string { - if addQuotes(p.Value) { - return fmt.Sprintf(`{"op": "%s", "from": "%s", "path": "%s", "value": "%s"}`, p.Operation, p.From, p.Path, p.Value) + var fixed []JSONPatch + for _, patch := range patches { + patch.From = "" + patch.Operation = "test" + fixed = append(fixed, patch) } - return fmt.Sprintf(`{"op": "%s", "from": "%s", "path": "%s", "value": %s}`, p.Operation, p.From, p.Path, p.Value) -} -func ApplyPatch(patch []byte, obj *unstructured.Unstructured, log logrus.FieldLogger) error { - jsonPatch, err := jsonpatch.DecodePatch(patch) - if err != nil { - return fmt.Errorf("error in decoding json patch %s", err.Error()) - } - objBytes, err := obj.MarshalJSON() - if err != nil { - return fmt.Errorf("error in marshaling object %s", err.Error()) - } - modifiedObjBytes, err := jsonPatch.Apply(objBytes) + p := &JSONPatcher{patches: fixed} + _, err := p.applyPatch(u) if err != nil { if errors.Is(err, jsonpatch.ErrTestFailed) { - log.Infof("Test operation failed for JSON Patch %s", err.Error()) - return nil + return false, nil } - return fmt.Errorf("error in applying JSON Patch %s", err.Error()) + return false, err } - err = obj.UnmarshalJSON(modifiedObjBytes) - if err != nil { - return fmt.Errorf("error in unmarshalling modified object %s", err.Error()) - } - return nil + + return true, nil } func unmarshalResourceModifiers(yamlData []byte) (*ResourceModifiers, error) { @@ -172,25 +162,27 @@ func unmarshalResourceModifiers(yamlData []byte) (*ResourceModifiers, error) { return resModifiers, nil } -func addQuotes(value string) bool { - if value == "" { - return true - } - // if value is null, then don't add quotes - if value == "null" { - return false - } - // if value is a boolean, then don't add quotes - if _, err := strconv.ParseBool(value); err == nil { - return false - } - // if value is a json object or array, then don't add quotes. - if strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[") { - return false - } - // if value is a number, then don't add quotes - if _, err := strconv.ParseFloat(value, 64); err == nil { - return false - } - return true +type patcher interface { + Patch(u *unstructured.Unstructured, logger logrus.FieldLogger) (*unstructured.Unstructured, error) +} + +func (r *ResourceModifierRule) applyPatch(u *unstructured.Unstructured, scheme *runtime.Scheme, logger logrus.FieldLogger) error { + var p patcher + if len(r.Patches) > 0 { + p = &JSONPatcher{patches: r.Patches} + } else if len(r.MergePatches) > 0 { + p = &JSONMergePatcher{patches: r.MergePatches} + } else if len(r.StrategicPatches) > 0 { + p = &StrategicMergePatcher{patches: r.StrategicPatches, scheme: scheme} + } else { + return fmt.Errorf("no patch data found") + } + + updated, err := p.Patch(u, logger) + if err != nil { + return fmt.Errorf("error in applying patch %s", err) + } + + u.SetUnstructuredContent(updated.Object) + return nil } diff --git a/internal/resourcemodifiers/resource_modifiers_test.go b/internal/resourcemodifiers/resource_modifiers_test.go index c2150bbc8..594a33255 100644 --- a/internal/resourcemodifiers/resource_modifiers_test.go +++ b/internal/resourcemodifiers/resource_modifiers_test.go @@ -9,6 +9,10 @@ import ( v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/yaml" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" ) func TestGetResourceModifiersFromConfig(t *testing.T) { @@ -704,7 +708,7 @@ func TestResourceModifiers_ApplyResourceModifierRules(t *testing.T) { Version: tt.fields.Version, ResourceModifierRules: tt.fields.ResourceModifierRules, } - got := p.ApplyResourceModifierRules(tt.args.obj, tt.args.groupResource, logrus.New()) + got := p.ApplyResourceModifierRules(tt.args.obj, tt.args.groupResource, nil, logrus.New()) assert.Equal(t, tt.wantErr, len(got) > 0) assert.Equal(t, *tt.wantObj, *tt.args.obj) @@ -712,6 +716,633 @@ func TestResourceModifiers_ApplyResourceModifierRules(t *testing.T) { } } +var podYAMLWithNginxImage = ` +apiVersion: v1 +kind: Pod +metadata: + name: pod1 + namespace: fake +spec: + containers: + - image: nginx + name: nginx +` + +var podYAMLWithNginx1Image = ` +apiVersion: v1 +kind: Pod +metadata: + name: pod1 + namespace: fake +spec: + containers: + - image: nginx1 + name: nginx +` + +var podYAMLWithNFSVolume = ` +apiVersion: v1 +kind: Pod +metadata: + name: pod1 + namespace: fake +spec: + containers: + - image: fake + name: fake + volumeMounts: + - mountPath: /fake1 + name: vol1 + - mountPath: /fake2 + name: vol2 + volumes: + - name: vol1 + nfs: + path: /fake2 + - name: vol2 + emptyDir: {} +` + +var podYAMLWithPVCVolume = ` +apiVersion: v1 +kind: Pod +metadata: + name: pod1 + namespace: fake +spec: + containers: + - image: fake + name: fake + volumeMounts: + - mountPath: /fake1 + name: vol1 + - mountPath: /fake2 + name: vol2 + volumes: + - name: vol1 + persistentVolumeClaim: + claimName: pvc1 + - name: vol2 + emptyDir: {} +` + +var svcYAMLWithPort8000 = ` +apiVersion: v1 +kind: Service +metadata: + name: svc1 + namespace: fake +spec: + ports: + - name: fake1 + port: 8001 + protocol: TCP + targetPort: 8001 + - name: fake + port: 8000 + protocol: TCP + targetPort: 8000 + - name: fake2 + port: 8002 + protocol: TCP + targetPort: 8002 +` + +var svcYAMLWithPort9000 = ` +apiVersion: v1 +kind: Service +metadata: + name: svc1 + namespace: fake +spec: + ports: + - name: fake1 + port: 8001 + protocol: TCP + targetPort: 8001 + - name: fake + port: 9000 + protocol: TCP + targetPort: 9000 + - name: fake2 + port: 8002 + protocol: TCP + targetPort: 8002 +` + +var cmYAMLWithLabelAToB = ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 + namespace: fake + labels: + a: b + c: d +` + +var cmYAMLWithLabelAToC = ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 + namespace: fake + labels: + a: c + c: d +` + +var cmYAMLWithoutLabelA = ` +apiVersion: v1 +kind: ConfigMap +metadata: + name: cm1 + namespace: fake + labels: + c: d +` + +func TestResourceModifiers_ApplyResourceModifierRules_StrategicMergePatch(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + unstructuredSerializer := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) + o1, _, err := unstructuredSerializer.Decode([]byte(podYAMLWithNFSVolume), nil, nil) + assert.NoError(t, err) + podWithNFSVolume := o1.(*unstructured.Unstructured) + + o2, _, err := unstructuredSerializer.Decode([]byte(podYAMLWithPVCVolume), nil, nil) + assert.NoError(t, err) + podWithPVCVolume := o2.(*unstructured.Unstructured) + + o3, _, err := unstructuredSerializer.Decode([]byte(svcYAMLWithPort8000), nil, nil) + assert.NoError(t, err) + svcWithPort8000 := o3.(*unstructured.Unstructured) + + o4, _, err := unstructuredSerializer.Decode([]byte(svcYAMLWithPort9000), nil, nil) + assert.NoError(t, err) + svcWithPort9000 := o4.(*unstructured.Unstructured) + + o5, _, err := unstructuredSerializer.Decode([]byte(podYAMLWithNginxImage), nil, nil) + assert.NoError(t, err) + podWithNginxImage := o5.(*unstructured.Unstructured) + + o6, _, err := unstructuredSerializer.Decode([]byte(podYAMLWithNginx1Image), nil, nil) + assert.NoError(t, err) + podWithNginx1Image := o6.(*unstructured.Unstructured) + + tests := []struct { + name string + rm *ResourceModifiers + obj *unstructured.Unstructured + groupResource string + wantErr bool + wantObj *unstructured.Unstructured + }{ + { + name: "update image", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "pods", + Namespaces: []string{"fake"}, + }, + StrategicPatches: []StrategicMergePatch{ + { + PatchBytes: []byte(`{"spec":{"containers":[{"name":"nginx","image":"nginx1"}]}}`), + }, + }, + }, + }, + }, + obj: podWithNginxImage.DeepCopy(), + groupResource: "pods", + wantErr: false, + wantObj: podWithNginx1Image.DeepCopy(), + }, + { + name: "update image with yaml format", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "pods", + Namespaces: []string{"fake"}, + }, + StrategicPatches: []StrategicMergePatch{ + { + PatchBytes: []byte(`spec: + containers: + - name: nginx + image: nginx1`), + }, + }, + }, + }, + }, + obj: podWithNginxImage.DeepCopy(), + groupResource: "pods", + wantErr: false, + wantObj: podWithNginx1Image.DeepCopy(), + }, + { + name: "replace nfs with pvc in volume", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "pods", + Namespaces: []string{"fake"}, + }, + StrategicPatches: []StrategicMergePatch{ + { + PatchBytes: []byte(`{"spec":{"volumes":[{"nfs":null,"name":"vol1","persistentVolumeClaim":{"claimName":"pvc1"}}]}}`), + }, + }, + }, + }, + }, + obj: podWithNFSVolume.DeepCopy(), + groupResource: "pods", + wantErr: false, + wantObj: podWithPVCVolume.DeepCopy(), + }, + { + name: "replace any other volume source with pvc in volume", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "pods", + Namespaces: []string{"fake"}, + }, + StrategicPatches: []StrategicMergePatch{ + { + PatchBytes: []byte(`{"spec":{"volumes":[{"$retainKeys":["name","persistentVolumeClaim"],"name":"vol1","persistentVolumeClaim":{"claimName":"pvc1"}}]}}`), + }, + }, + }, + }, + }, + obj: podWithNFSVolume.DeepCopy(), + groupResource: "pods", + wantErr: false, + wantObj: podWithPVCVolume.DeepCopy(), + }, + { + name: "update a service port", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "services", + Namespaces: []string{"fake"}, + }, + StrategicPatches: []StrategicMergePatch{ + { + PatchBytes: []byte(`{"spec":{"$setElementOrder/ports":[{"port":8001},{"port":9000},{"port":8002}],"ports":[{"name":"fake","port":9000,"protocol":"TCP","targetPort":9000},{"$patch":"delete","port":8000}]}}`), + }, + }, + }, + }, + }, + obj: svcWithPort8000.DeepCopy(), + groupResource: "services", + wantErr: false, + wantObj: svcWithPort9000.DeepCopy(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.rm.ApplyResourceModifierRules(tt.obj, tt.groupResource, scheme, logrus.New()) + + assert.Equal(t, tt.wantErr, len(got) > 0) + assert.Equal(t, *tt.wantObj, *tt.obj) + }) + } +} + +func TestResourceModifiers_ApplyResourceModifierRules_JSONMergePatch(t *testing.T) { + unstructuredSerializer := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) + o1, _, err := unstructuredSerializer.Decode([]byte(cmYAMLWithLabelAToB), nil, nil) + assert.NoError(t, err) + cmWithLabelAToB := o1.(*unstructured.Unstructured) + + o2, _, err := unstructuredSerializer.Decode([]byte(cmYAMLWithLabelAToC), nil, nil) + assert.NoError(t, err) + cmWithLabelAToC := o2.(*unstructured.Unstructured) + + o3, _, err := unstructuredSerializer.Decode([]byte(cmYAMLWithoutLabelA), nil, nil) + assert.NoError(t, err) + cmWithoutLabelA := o3.(*unstructured.Unstructured) + + tests := []struct { + name string + rm *ResourceModifiers + obj *unstructured.Unstructured + groupResource string + wantErr bool + wantObj *unstructured.Unstructured + }{ + { + name: "update labels", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "configmaps", + Namespaces: []string{"fake"}, + }, + MergePatches: []JSONMergePatch{ + { + PatchBytes: []byte(`{"metadata":{"labels":{"a":"c"}}}`), + }, + }, + }, + }, + }, + obj: cmWithLabelAToB.DeepCopy(), + groupResource: "configmaps", + wantErr: false, + wantObj: cmWithLabelAToC.DeepCopy(), + }, + { + name: "update labels in yaml format", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "configmaps", + Namespaces: []string{"fake"}, + }, + MergePatches: []JSONMergePatch{ + { + PatchBytes: []byte(`metadata: + labels: + a: c`), + }, + }, + }, + }, + }, + obj: cmWithLabelAToB.DeepCopy(), + groupResource: "configmaps", + wantErr: false, + wantObj: cmWithLabelAToC.DeepCopy(), + }, + { + name: "delete labels", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "configmaps", + Namespaces: []string{"fake"}, + }, + MergePatches: []JSONMergePatch{ + { + PatchBytes: []byte(`{"metadata":{"labels":{"a":null}}}`), + }, + }, + }, + }, + }, + obj: cmWithLabelAToB.DeepCopy(), + groupResource: "configmaps", + wantErr: false, + wantObj: cmWithoutLabelA.DeepCopy(), + }, + { + name: "add labels", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "configmaps", + Namespaces: []string{"fake"}, + }, + MergePatches: []JSONMergePatch{ + { + PatchBytes: []byte(`{"metadata":{"labels":{"a":"b"}}}`), + }, + }, + }, + }, + }, + obj: cmWithoutLabelA.DeepCopy(), + groupResource: "configmaps", + wantErr: false, + wantObj: cmWithLabelAToB.DeepCopy(), + }, + { + name: "delete non-existing labels", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "configmaps", + Namespaces: []string{"fake"}, + }, + MergePatches: []JSONMergePatch{ + { + PatchBytes: []byte(`{"metadata":{"labels":{"a":null}}}`), + }, + }, + }, + }, + }, + obj: cmWithoutLabelA.DeepCopy(), + groupResource: "configmaps", + wantErr: false, + wantObj: cmWithoutLabelA.DeepCopy(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.rm.ApplyResourceModifierRules(tt.obj, tt.groupResource, nil, logrus.New()) + + assert.Equal(t, tt.wantErr, len(got) > 0) + assert.Equal(t, *tt.wantObj, *tt.obj) + }) + } +} + +func TestResourceModifiers_wildcard_in_GroupResource(t *testing.T) { + unstructuredSerializer := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) + o1, _, err := unstructuredSerializer.Decode([]byte(cmYAMLWithLabelAToB), nil, nil) + assert.NoError(t, err) + cmWithLabelAToB := o1.(*unstructured.Unstructured) + + o2, _, err := unstructuredSerializer.Decode([]byte(cmYAMLWithLabelAToC), nil, nil) + assert.NoError(t, err) + cmWithLabelAToC := o2.(*unstructured.Unstructured) + + tests := []struct { + name string + rm *ResourceModifiers + obj *unstructured.Unstructured + groupResource string + wantErr bool + wantObj *unstructured.Unstructured + }{ + { + name: "match all groups and resources", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "*", + Namespaces: []string{"fake"}, + }, + MergePatches: []JSONMergePatch{ + { + PatchBytes: []byte(`{"metadata":{"labels":{"a":"c"}}}`), + }, + }, + }, + }, + }, + obj: cmWithLabelAToB.DeepCopy(), + groupResource: "configmaps", + wantErr: false, + wantObj: cmWithLabelAToC.DeepCopy(), + }, + { + name: "match all resources in group apps", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "*.apps", + Namespaces: []string{"fake"}, + }, + MergePatches: []JSONMergePatch{ + { + PatchBytes: []byte(`{"metadata":{"labels":{"a":"c"}}}`), + }, + }, + }, + }, + }, + obj: cmWithLabelAToB.DeepCopy(), + groupResource: "fake.apps", + wantErr: false, + wantObj: cmWithLabelAToC.DeepCopy(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.rm.ApplyResourceModifierRules(tt.obj, tt.groupResource, nil, logrus.New()) + + assert.Equal(t, tt.wantErr, len(got) > 0) + assert.Equal(t, *tt.wantObj, *tt.obj) + }) + } +} + +func TestResourceModifiers_conditional_patches(t *testing.T) { + unstructuredSerializer := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) + o1, _, err := unstructuredSerializer.Decode([]byte(cmYAMLWithLabelAToB), nil, nil) + assert.NoError(t, err) + cmWithLabelAToB := o1.(*unstructured.Unstructured) + + o2, _, err := unstructuredSerializer.Decode([]byte(cmYAMLWithLabelAToC), nil, nil) + assert.NoError(t, err) + cmWithLabelAToC := o2.(*unstructured.Unstructured) + + tests := []struct { + name string + rm *ResourceModifiers + obj *unstructured.Unstructured + groupResource string + wantErr bool + wantObj *unstructured.Unstructured + }{ + { + name: "match conditions and apply patches", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "*", + Namespaces: []string{"fake"}, + Matches: []JSONPatch{ + { + Path: "/metadata/labels/a", + Value: "b", + }, + }, + }, + MergePatches: []JSONMergePatch{ + { + PatchBytes: []byte(`{"metadata":{"labels":{"a":"c"}}}`), + }, + }, + }, + }, + }, + obj: cmWithLabelAToB.DeepCopy(), + groupResource: "configmaps", + wantErr: false, + wantObj: cmWithLabelAToC.DeepCopy(), + }, + { + name: "mismatch conditions and skip patches", + rm: &ResourceModifiers{ + Version: "v1", + ResourceModifierRules: []ResourceModifierRule{ + { + Conditions: Conditions{ + GroupResource: "*", + Namespaces: []string{"fake"}, + Matches: []JSONPatch{ + { + Path: "/metadata/labels/a", + Value: "c", + }, + }, + }, + MergePatches: []JSONMergePatch{ + { + PatchBytes: []byte(`{"metadata":{"labels":{"a":"c"}}}`), + }, + }, + }, + }, + }, + obj: cmWithLabelAToB.DeepCopy(), + groupResource: "configmaps", + wantErr: false, + wantObj: cmWithLabelAToB.DeepCopy(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.rm.ApplyResourceModifierRules(tt.obj, tt.groupResource, nil, logrus.New()) + + assert.Equal(t, tt.wantErr, len(got) > 0) + assert.Equal(t, *tt.wantObj, *tt.obj) + }) + } +} + func TestJSONPatch_ToString(t *testing.T) { type fields struct { Operation string diff --git a/internal/resourcemodifiers/strategic_merge_patch.go b/internal/resourcemodifiers/strategic_merge_patch.go new file mode 100644 index 000000000..936043eca --- /dev/null +++ b/internal/resourcemodifiers/strategic_merge_patch.go @@ -0,0 +1,157 @@ +package resourcemodifiers + +import ( + "fmt" + "net/http" + + "github.com/sirupsen/logrus" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/mergepatch" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/json" + "sigs.k8s.io/yaml" +) + +type StrategicMergePatch struct { + PatchBytes []byte `json:"patchBytes,omitempty"` +} + +type StrategicMergePatcher struct { + patches []StrategicMergePatch + scheme *runtime.Scheme +} + +func (p *StrategicMergePatcher) Patch(u *unstructured.Unstructured, _ logrus.FieldLogger) (*unstructured.Unstructured, error) { + gvk := u.GetObjectKind().GroupVersionKind() + schemaReferenceObj, err := p.scheme.New(gvk) + if err != nil { + return nil, err + } + + if err != nil { + return nil, fmt.Errorf("error in unmarshaling object %s", err.Error()) + } + + origin := u.DeepCopy() + updated := u.DeepCopy() + for _, patch := range p.patches { + patchBytes, err := yaml.YAMLToJSON(patch.PatchBytes) + if err != nil { + return nil, fmt.Errorf("error in converting YAML to JSON %s", err) + } + + err = strategicPatchObject(origin, patchBytes, updated, schemaReferenceObj, metav1.FieldValidationStrict) + if err != nil { + return nil, fmt.Errorf("error in applying JSON Patch %s", err.Error()) + } + + origin = updated.DeepCopy() + } + + return updated, nil +} + +// strategicPatchObject applies a strategic merge patch of `patchBytes` to +// `originalObject` and stores the result in `objToUpdate`. +// It additionally returns the map[string]interface{} representation of the +// `originalObject` and `patchBytes`. +// NOTE: Both `originalObject` and `objToUpdate` are supposed to be versioned. +func strategicPatchObject( + originalObject runtime.Object, + patchBytes []byte, + objToUpdate runtime.Object, + schemaReferenceObj runtime.Object, + validationDirective string, +) error { + originalObjMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(originalObject) + if err != nil { + return err + } + + patchMap := make(map[string]interface{}) + var strictErrs []error + if validationDirective == metav1.FieldValidationWarn || validationDirective == metav1.FieldValidationStrict { + strictErrs, err = json.UnmarshalStrict(patchBytes, &patchMap) + if err != nil { + return apierrors.NewBadRequest(err.Error()) + } + } else { + if err = json.UnmarshalCaseSensitivePreserveInts(patchBytes, &patchMap); err != nil { + return apierrors.NewBadRequest(err.Error()) + } + } + + if err := applyPatchToObject(originalObjMap, patchMap, objToUpdate, schemaReferenceObj, strictErrs, validationDirective); err != nil { + return err + } + return nil +} + +// applyPatchToObject applies a strategic merge patch of to +// and stores the result in . +// NOTE: must be a versioned object. +func applyPatchToObject( + originalMap map[string]interface{}, + patchMap map[string]interface{}, + objToUpdate runtime.Object, + schemaReferenceObj runtime.Object, + strictErrs []error, + validationDirective string, +) error { + patchedObjMap, err := strategicpatch.StrategicMergeMapPatch(originalMap, patchMap, schemaReferenceObj) + if err != nil { + return interpretStrategicMergePatchError(err) + } + + // Rather than serialize the patched map to JSON, then decode it to an object, we go directly from a map to an object + converter := runtime.DefaultUnstructuredConverter + returnUnknownFields := validationDirective == metav1.FieldValidationWarn || validationDirective == metav1.FieldValidationStrict + if err := converter.FromUnstructuredWithValidation(patchedObjMap, objToUpdate, returnUnknownFields); err != nil { + strictError, isStrictError := runtime.AsStrictDecodingError(err) + switch { + case !isStrictError: + // disregard any sttrictErrs, because it's an incomplete + // list of strict errors given that we don't know what fields were + // unknown because StrategicMergeMapPatch failed. + // Non-strict errors trump in this case. + return apierrors.NewInvalid(schema.GroupKind{}, "", field.ErrorList{ + field.Invalid(field.NewPath("patch"), fmt.Sprintf("%+v", patchMap), err.Error()), + }) + case validationDirective == metav1.FieldValidationWarn: + //addStrictDecodingWarnings(requestContext, append(strictErrs, strictError.Errors()...)) + default: + strictDecodingError := runtime.NewStrictDecodingError(append(strictErrs, strictError.Errors()...)) + return apierrors.NewInvalid(schema.GroupKind{}, "", field.ErrorList{ + field.Invalid(field.NewPath("patch"), fmt.Sprintf("%+v", patchMap), strictDecodingError.Error()), + }) + } + } else if len(strictErrs) > 0 { + switch { + case validationDirective == metav1.FieldValidationWarn: + //addStrictDecodingWarnings(requestContext, strictErrs) + default: + return apierrors.NewInvalid(schema.GroupKind{}, "", field.ErrorList{ + field.Invalid(field.NewPath("patch"), fmt.Sprintf("%+v", patchMap), runtime.NewStrictDecodingError(strictErrs).Error()), + }) + } + } + + return nil +} + +// interpretStrategicMergePatchError interprets the error type and returns an error with appropriate HTTP code. +func interpretStrategicMergePatchError(err error) error { + switch err { + case mergepatch.ErrBadJSONDoc, mergepatch.ErrBadPatchFormatForPrimitiveList, mergepatch.ErrBadPatchFormatForRetainKeys, mergepatch.ErrBadPatchFormatForSetElementOrderList, mergepatch.ErrUnsupportedStrategicMergePatchFormat: + return apierrors.NewBadRequest(err.Error()) + case mergepatch.ErrNoListOfLists, mergepatch.ErrPatchContentNotMatchRetainKeys: + return apierrors.NewGenericServerResponse(http.StatusUnprocessableEntity, "", schema.GroupResource{}, "", err.Error(), 0, false) + default: + return err + } +} diff --git a/pkg/restore/restore.go b/pkg/restore/restore.go index 6d0d814ca..0f1e89c85 100644 --- a/pkg/restore/restore.go +++ b/pkg/restore/restore.go @@ -1353,7 +1353,7 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso } if ctx.resourceModifiers != nil { - if errList := ctx.resourceModifiers.ApplyResourceModifierRules(obj, groupResource.String(), ctx.log); errList != nil { + if errList := ctx.resourceModifiers.ApplyResourceModifierRules(obj, groupResource.String(), ctx.kbClient.Scheme(), ctx.log); errList != nil { for _, err := range errList { errs.Add(namespace, err) }