/* Copyright The Velero Contributors. 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 resourcemodifiers import ( "reflect" "testing" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" 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) { cm1 := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-configmap", Namespace: "test-namespace", }, Data: map[string]string{ "sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupResource: persistentvolumeclaims\n resourceNameRegex: \".*\"\n namespaces:\n - bar\n - foo\n patches:\n - operation: replace\n path: \"/spec/storageClassName\"\n value: \"premium\"\n - operation: remove\n path: \"/metadata/labels/test\"\n\n\n", }, } rules1 := &ResourceModifiers{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "persistentvolumeclaims", ResourceNameRegex: ".*", Namespaces: []string{"bar", "foo"}, }, Patches: []JSONPatch{ { Operation: "replace", Path: "/spec/storageClassName", Value: "premium", }, { Operation: "remove", Path: "/metadata/labels/test", }, }, }, }, } cm2 := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-configmap", Namespace: "test-namespace", }, Data: map[string]string{ "sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupResource: deployments.apps\n resourceNameRegex: \"^test-.*$\"\n namespaces:\n - bar\n - foo\n patches:\n - operation: add\n path: \"/spec/template/spec/containers/0\"\n value: \"{\\\"name\\\": \\\"nginx\\\", \\\"image\\\": \\\"nginx:1.14.2\\\", \\\"ports\\\": [{\\\"containerPort\\\": 80}]}\"\n - operation: copy\n from: \"/spec/template/spec/containers/0\"\n path: \"/spec/template/spec/containers/1\"\n\n\n", }, } rules2 := &ResourceModifiers{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", ResourceNameRegex: "^test-.*$", Namespaces: []string{"bar", "foo"}, }, Patches: []JSONPatch{ { Operation: "add", Path: "/spec/template/spec/containers/0", Value: `{"name": "nginx", "image": "nginx:1.14.2", "ports": [{"containerPort": 80}]}`, }, { Operation: "copy", From: "/spec/template/spec/containers/0", Path: "/spec/template/spec/containers/1", }, }, }, }, } cm3 := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-configmap", Namespace: "test-namespace", }, Data: map[string]string{ "sub.yml": "version1: v1\nresourceModifierRules:\n- conditions:\n groupResource: deployments.apps\n resourceNameRegex: \"^test-.*$\"\n namespaces:\n - bar\n - foo\n patches:\n - operation: add\n path: \"/spec/template/spec/containers/0\"\n value: \"{\\\"name\\\": \\\"nginx\\\", \\\"image\\\": \\\"nginx:1.14.2\\\", \\\"ports\\\": [{\\\"containerPort\\\": 80}]}\"\n - operation: copy\n from: \"/spec/template/spec/containers/0\"\n path: \"/spec/template/spec/containers/1\"\n\n\n", }, } cm4 := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-configmap", Namespace: "test-namespace", }, Data: map[string]string{ "sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupResource: deployments.apps\n labelSelector:\n matchLabels:\n a: b\n", }, } rules4 := &ResourceModifiers{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", LabelSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{ "a": "b", }, }, }, }, }, } cm5 := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-configmap", Namespace: "test-namespace", }, Data: map[string]string{ "sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupResource: pods\n namespaces:\n - ns1\n matches:\n - path: /metadata/annotations/foo\n value: bar\n mergePatches:\n - patchData: |\n metadata:\n annotations:\n foo: null", }, } rules5 := &ResourceModifiers{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "pods", Namespaces: []string{ "ns1", }, Matches: []MatchRule{ { Path: "/metadata/annotations/foo", Value: "bar", }, }, }, MergePatches: []JSONMergePatch{ { PatchData: "metadata:\n annotations:\n foo: null", }, }, }, }, } cm6 := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-configmap", Namespace: "test-namespace", }, Data: map[string]string{ "sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupResource: pods\n namespaces:\n - ns1\n strategicPatches:\n - patchData: |\n spec:\n containers:\n - name: nginx\n image: repo2/nginx", }, } rules6 := &ResourceModifiers{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "pods", Namespaces: []string{ "ns1", }, }, StrategicPatches: []StrategicMergePatch{ { PatchData: "spec:\n containers:\n - name: nginx\n image: repo2/nginx", }, }, }, }, } cm7 := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-configmap", Namespace: "test-namespace", }, Data: map[string]string{ "sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupResource: pods\n namespaces:\n - ns1\n mergePatches:\n - patchData: |\n {\"metadata\":{\"annotations\":{\"foo\":null}}}", }, } rules7 := &ResourceModifiers{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "pods", Namespaces: []string{ "ns1", }, }, MergePatches: []JSONMergePatch{ { PatchData: `{"metadata":{"annotations":{"foo":null}}}`, }, }, }, }, } cm8 := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-configmap", Namespace: "test-namespace", }, Data: map[string]string{ "sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupResource: pods\n namespaces:\n - ns1\n strategicPatches:\n - patchData: |\n {\"spec\":{\"containers\":[{\"name\": \"nginx\",\"image\": \"repo2/nginx\"}]}}", }, } rules8 := &ResourceModifiers{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "pods", Namespaces: []string{ "ns1", }, }, StrategicPatches: []StrategicMergePatch{ { PatchData: `{"spec":{"containers":[{"name": "nginx","image": "repo2/nginx"}]}}`, }, }, }, }, } cm9 := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-configmap", Namespace: "test-namespace", }, Data: map[string]string{ "sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupResource: deployments.apps\n resourceNameRegex: \"^test-.*$\"\n namespaces:\n - bar\n - foo\n patches:\n - operation: replace\n path: \"/value/bool\"\n value: \"\\\"true\\\"\"\n\n\n", }, } rules9 := &ResourceModifiers{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", ResourceNameRegex: "^test-.*$", Namespaces: []string{"bar", "foo"}, }, Patches: []JSONPatch{ { Operation: "replace", Path: "/value/bool", Value: `"true"`, }, }, }, }, } cm10 := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-configmap", Namespace: "test-namespace", }, Data: map[string]string{ "sub.yml": "version: v1\nresourceModifierRules:\n- conditions:\n groupResource: deployments.apps\n resourceNameRegex: \"^test-.*$\"\n namespaces:\n - bar\n - foo\n patches:\n - operation: replace\n path: \"/value/bool\"\n value: \"true\"\n\n\n", }, } rules10 := &ResourceModifiers{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", ResourceNameRegex: "^test-.*$", Namespaces: []string{"bar", "foo"}, }, Patches: []JSONPatch{ { Operation: "replace", Path: "/value/bool", Value: "true", }, }, }, }, } type args struct { cm *v1.ConfigMap } tests := []struct { name string args args want *ResourceModifiers wantErr bool }{ { name: "test 1", args: args{ cm: cm1, }, want: rules1, wantErr: false, }, { name: "complex payload in add and copy operator", args: args{ cm: cm2, }, want: rules2, wantErr: false, }, { name: "invalid payload version1", args: args{ cm: cm3, }, want: nil, wantErr: true, }, { name: "match labels", args: args{ cm: cm4, }, want: rules4, wantErr: false, }, { name: "nil configmap", args: args{ cm: nil, }, want: nil, wantErr: true, }, { name: "complex yaml data with json merge patch", args: args{ cm: cm5, }, want: rules5, wantErr: false, }, { name: "complex yaml data with strategic merge patch", args: args{ cm: cm6, }, want: rules6, wantErr: false, }, { name: "complex json data with json merge patch", args: args{ cm: cm7, }, want: rules7, wantErr: false, }, { name: "complex json data with strategic merge patch", args: args{ cm: cm8, }, want: rules8, wantErr: false, }, { name: "bool value as string", args: args{ cm: cm9, }, want: rules9, wantErr: false, }, { name: "bool value as bool", args: args{ cm: cm10, }, want: rules10, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := GetResourceModifiersFromConfig(tt.args.cm) if (err != nil) != tt.wantErr { t.Errorf("GetResourceModifiersFromConfig() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("GetResourceModifiersFromConfig() = %v, want %v", got, tt.want) } }) } } func TestResourceModifiers_ApplyResourceModifierRules(t *testing.T) { pvcStandardSc := &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", "kind": "PersistentVolumeClaim", "metadata": map[string]interface{}{ "name": "test-pvc", "namespace": "foo", }, "spec": map[string]interface{}{ "storageClassName": "standard", }, }, } pvcPremiumSc := &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", "kind": "PersistentVolumeClaim", "metadata": map[string]interface{}{ "name": "test-pvc", "namespace": "foo", }, "spec": map[string]interface{}{ "storageClassName": "premium", }, }, } pvcGoldSc := &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", "kind": "PersistentVolumeClaim", "metadata": map[string]interface{}{ "name": "test-pvc", "namespace": "foo", }, "spec": map[string]interface{}{ "storageClassName": "gold", }, }, } deployNginxOneReplica := &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "apps/v1", "kind": "Deployment", "metadata": map[string]interface{}{ "name": "test-deployment", "namespace": "foo", "labels": map[string]interface{}{ "app": "nginx", }, }, "spec": map[string]interface{}{ "replicas": int64(1), "template": map[string]interface{}{ "metadata": map[string]interface{}{ "labels": map[string]interface{}{ "app": "nginx", }, }, "spec": map[string]interface{}{ "containers": []interface{}{ map[string]interface{}{ "name": "nginx", "image": "nginx:latest", }, }, }, }, }, }, } deployNginxTwoReplica := &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "apps/v1", "kind": "Deployment", "metadata": map[string]interface{}{ "name": "test-deployment", "namespace": "foo", "labels": map[string]interface{}{ "app": "nginx", }, }, "spec": map[string]interface{}{ "replicas": int64(2), "template": map[string]interface{}{ "metadata": map[string]interface{}{ "labels": map[string]interface{}{ "app": "nginx", }, }, "spec": map[string]interface{}{ "containers": []interface{}{ map[string]interface{}{ "name": "nginx", "image": "nginx:latest", }, }, }, }, }, }, } deployNginxMysql := &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "apps/v1", "kind": "Deployment", "metadata": map[string]interface{}{ "name": "test-deployment", "namespace": "foo", "labels": map[string]interface{}{ "app": "nginx", }, }, "spec": map[string]interface{}{ "replicas": int64(1), "template": map[string]interface{}{ "metadata": map[string]interface{}{ "labels": map[string]interface{}{ "app": "nginx", }, }, "spec": map[string]interface{}{ "containers": []interface{}{ map[string]interface{}{ "name": "nginx", "image": "nginx:latest", }, map[string]interface{}{ "name": "mysql", "image": "mysql:latest", }, }, }, }, }, }, } cmTrue := &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", "kind": "ConfigMap", "data": map[string]interface{}{ "test": "true", }, }, } cmFalse := &unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", "kind": "ConfigMap", "data": map[string]interface{}{ "test": "false", }, }, } type fields struct { Version string ResourceModifierRules []ResourceModifierRule } type args struct { obj *unstructured.Unstructured groupResource string } tests := []struct { name string fields fields args args wantErr bool wantObj *unstructured.Unstructured }{ { name: "configmap true false string", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "configmaps", ResourceNameRegex: ".*", }, Patches: []JSONPatch{ { Operation: "replace", Path: "/data/test", Value: `"false"`, }, }, }, }, }, args: args{ obj: cmTrue.DeepCopy(), groupResource: "configmaps", }, wantErr: false, wantObj: cmFalse.DeepCopy(), }, { name: "Invalid Regex throws error", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "persistentvolumeclaims", ResourceNameRegex: "[a-z", Namespaces: []string{"foo"}, }, Patches: []JSONPatch{ { Operation: "test", Path: "/spec/storageClassName", Value: "standard", }, { Operation: "replace", Path: "/spec/storageClassName", Value: "premium", }, }, }, }, }, args: args{ obj: pvcStandardSc.DeepCopy(), groupResource: "persistentvolumeclaims", }, wantErr: true, wantObj: pvcStandardSc.DeepCopy(), }, { name: "pvc with standard storage class should be patched to premium", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "persistentvolumeclaims", ResourceNameRegex: ".*", Namespaces: []string{"foo"}, }, Patches: []JSONPatch{ { Operation: "test", Path: "/spec/storageClassName", Value: "standard", }, { Operation: "replace", Path: "/spec/storageClassName", Value: "premium", }, }, }, }, }, args: args{ obj: pvcStandardSc.DeepCopy(), groupResource: "persistentvolumeclaims", }, wantErr: false, wantObj: pvcPremiumSc.DeepCopy(), }, { name: "pvc with standard storage class should be patched to premium, even when rules are [standard => premium, premium => gold]", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "persistentvolumeclaims", ResourceNameRegex: ".*", Matches: []MatchRule{ { Path: "/spec/storageClassName", Value: "standard", }, }, }, Patches: []JSONPatch{ { Operation: "replace", Path: "/spec/storageClassName", Value: "premium", }, }, }, { Conditions: Conditions{ GroupResource: "persistentvolumeclaims", ResourceNameRegex: ".*", Matches: []MatchRule{ { Path: "/spec/storageClassName", Value: "premium", }, }, }, Patches: []JSONPatch{ { Operation: "replace", Path: "/spec/storageClassName", Value: "gold", }, }, }, }, }, args: args{ obj: pvcStandardSc.DeepCopy(), groupResource: "persistentvolumeclaims", }, wantErr: false, wantObj: pvcPremiumSc.DeepCopy(), }, { name: "pvc with standard storage class should be patched to gold, even when rules are [standard => premium, standard => gold]", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "persistentvolumeclaims", ResourceNameRegex: ".*", Matches: []MatchRule{ { Path: "/spec/storageClassName", Value: "standard", }, }, }, Patches: []JSONPatch{ { Operation: "replace", Path: "/spec/storageClassName", Value: "premium", }, }, }, { Conditions: Conditions{ GroupResource: "persistentvolumeclaims", ResourceNameRegex: ".*", Matches: []MatchRule{ { Path: "/spec/storageClassName", Value: "standard", }, }, }, Patches: []JSONPatch{ { Operation: "replace", Path: "/spec/storageClassName", Value: "gold", }, }, }, }, }, args: args{ obj: pvcStandardSc.DeepCopy(), groupResource: "persistentvolumeclaims", }, wantErr: false, wantObj: pvcGoldSc.DeepCopy(), }, { name: "nginx deployment: 1 -> 2 replicas", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", ResourceNameRegex: "^test-.*$", Namespaces: []string{"foo"}, }, Patches: []JSONPatch{ { Operation: "test", Path: "/spec/replicas", Value: "1", }, { Operation: "replace", Path: "/spec/replicas", Value: "2", }, }, }, }, }, args: args{ obj: deployNginxOneReplica.DeepCopy(), groupResource: "deployments.apps", }, wantErr: false, wantObj: deployNginxTwoReplica.DeepCopy(), }, { name: "nginx deployment: test operator fails, skips substitution, no error", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", ResourceNameRegex: "^test-.*$", Namespaces: []string{"foo"}, }, Patches: []JSONPatch{ { Operation: "test", Path: "/spec/replicas", Value: "5", }, { Operation: "replace", Path: "/spec/replicas", Value: "2", }, }, }, }, }, args: args{ obj: deployNginxOneReplica.DeepCopy(), groupResource: "deployments.apps", }, wantErr: false, wantObj: deployNginxOneReplica.DeepCopy(), }, { name: "nginx deployment: Empty Resource Regex", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", Namespaces: []string{"foo"}, }, Patches: []JSONPatch{ { Operation: "test", Path: "/spec/replicas", Value: "1", }, { Operation: "replace", Path: "/spec/replicas", Value: "2", }, }, }, }, }, args: args{ obj: deployNginxOneReplica.DeepCopy(), groupResource: "deployments.apps", }, wantErr: false, wantObj: deployNginxTwoReplica.DeepCopy(), }, { name: "nginx deployment: Empty Resource Regex", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", Namespaces: []string{"foo"}, }, Patches: []JSONPatch{ { Operation: "test", Path: "/spec/replicas", Value: "1", }, { Operation: "replace", Path: "/spec/replicas", Value: "2", }, }, }, }, }, args: args{ obj: deployNginxOneReplica.DeepCopy(), groupResource: "deployments.apps", }, wantErr: false, wantObj: deployNginxTwoReplica.DeepCopy(), }, { name: "nginx deployment: Empty Resource Regex and namespaces list", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", }, Patches: []JSONPatch{ { Operation: "test", Path: "/spec/replicas", Value: "1", }, { Operation: "replace", Path: "/spec/replicas", Value: "2", }, }, }, }, }, args: args{ obj: deployNginxOneReplica.DeepCopy(), groupResource: "deployments.apps", }, wantErr: false, wantObj: deployNginxTwoReplica.DeepCopy(), }, { name: "nginx deployment: namespace doesn't match", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", ResourceNameRegex: ".*", Namespaces: []string{"bar"}, }, Patches: []JSONPatch{ { Operation: "test", Path: "/spec/replicas", Value: "1", }, { Operation: "replace", Path: "/spec/replicas", Value: "2", }, }, }, }, }, args: args{ obj: deployNginxOneReplica.DeepCopy(), groupResource: "deployments.apps", }, wantErr: false, wantObj: deployNginxOneReplica.DeepCopy(), }, { name: "add container mysql to deployment", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", ResourceNameRegex: "^test-.*$", Namespaces: []string{"foo"}, }, Patches: []JSONPatch{ { Operation: "add", Path: "/spec/template/spec/containers/1", Value: `{"name": "mysql", "image": "mysql:latest"}`, }, }, }, }, }, args: args{ obj: deployNginxOneReplica, groupResource: "deployments.apps", }, wantErr: false, wantObj: deployNginxMysql, }, { name: "Copy container 0 to container 1 and then modify container 1", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", ResourceNameRegex: "^test-.*$", Namespaces: []string{"foo"}, }, Patches: []JSONPatch{ { Operation: "copy", From: "/spec/template/spec/containers/0", Path: "/spec/template/spec/containers/1", }, { Operation: "test", Path: "/spec/template/spec/containers/1/image", Value: "nginx:latest", }, { Operation: "replace", Path: "/spec/template/spec/containers/1/name", Value: "mysql", }, { Operation: "replace", Path: "/spec/template/spec/containers/1/image", Value: "mysql:latest", }, }, }, }, }, args: args{ obj: deployNginxOneReplica.DeepCopy(), groupResource: "deployments.apps", }, wantErr: false, wantObj: deployNginxMysql.DeepCopy(), }, { name: "nginx deployment: match label selector", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", Namespaces: []string{"foo"}, LabelSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{ "app": "nginx", }, }, }, Patches: []JSONPatch{ { Operation: "test", Path: "/spec/replicas", Value: "1", }, { Operation: "replace", Path: "/spec/replicas", Value: "2", }, }, }, }, }, args: args{ obj: deployNginxOneReplica.DeepCopy(), groupResource: "deployments.apps", }, wantErr: false, wantObj: deployNginxTwoReplica.DeepCopy(), }, { name: "nginx deployment: mismatch label selector", fields: fields{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "deployments.apps", Namespaces: []string{"foo"}, LabelSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{ "app": "nginx-mismatch", }, }, }, Patches: []JSONPatch{ { Operation: "test", Path: "/spec/replicas", Value: "1", }, { Operation: "replace", Path: "/spec/replicas", Value: "2", }, }, }, }, }, args: args{ obj: deployNginxOneReplica.DeepCopy(), groupResource: "deployments.apps", }, wantErr: false, wantObj: deployNginxOneReplica.DeepCopy(), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := &ResourceModifiers{ Version: tt.fields.Version, ResourceModifierRules: tt.fields.ResourceModifierRules, } 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) }) } } 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{ { PatchData: `{"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{ { PatchData: `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{ { PatchData: `{"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{ { PatchData: `{"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{ { PatchData: `{"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{ { PatchData: `{"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{ { PatchData: `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{ { PatchData: `{"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{ { PatchData: `{"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{ { PatchData: `{"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{ { PatchData: `{"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{ { PatchData: `{"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: []MatchRule{ { Path: "/metadata/labels/a", Value: "b", }, }, }, MergePatches: []JSONMergePatch{ { PatchData: `{"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: []MatchRule{ { Path: "/metadata/labels/a", Value: "c", }, }, }, MergePatches: []JSONMergePatch{ { PatchData: `{"metadata":{"labels":{"a":"c"}}}`, }, }, }, }, }, obj: cmWithLabelAToB.DeepCopy(), groupResource: "configmaps", wantErr: false, wantObj: cmWithLabelAToB.DeepCopy(), }, { name: "missing condition path and skip patches", rm: &ResourceModifiers{ Version: "v1", ResourceModifierRules: []ResourceModifierRule{ { Conditions: Conditions{ GroupResource: "*", Namespaces: []string{"fake"}, Matches: []MatchRule{ { Path: "/metadata/labels/a/b", Value: "c", }, }, }, MergePatches: []JSONMergePatch{ { PatchData: `{"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 From string Path string Value string } tests := []struct { name string fields fields want string }{ { name: "test", fields: fields{ Operation: "test", Path: "/spec/replicas", Value: "1", }, want: `{"op": "test", "from": "", "path": "/spec/replicas", "value": 1}`, }, { name: "replace integer", fields: fields{ Operation: "replace", Path: "/spec/replicas", Value: "2", }, want: `{"op": "replace", "from": "", "path": "/spec/replicas", "value": 2}`, }, { name: "replace array", fields: fields{ Operation: "replace", Path: "/spec/template/spec/containers/0/ports", Value: `[{"containerPort": 80}]`, }, want: `{"op": "replace", "from": "", "path": "/spec/template/spec/containers/0/ports", "value": [{"containerPort": 80}]}`, }, { name: "replace with null", fields: fields{ Operation: "replace", Path: "/spec/template/spec/containers/0/ports", Value: `null`, }, want: `{"op": "replace", "from": "", "path": "/spec/template/spec/containers/0/ports", "value": null}`, }, { name: "add json object", fields: fields{ Operation: "add", Path: "/spec/template/spec/containers/0", Value: `{"name": "nginx", "image": "nginx:1.14.2", "ports": [{"containerPort": 80}]}`, }, want: `{"op": "add", "from": "", "path": "/spec/template/spec/containers/0", "value": {"name": "nginx", "image": "nginx:1.14.2", "ports": [{"containerPort": 80}]}}`, }, { name: "remove", fields: fields{ Operation: "remove", Path: "/spec/template/spec/containers/0", }, want: `{"op": "remove", "from": "", "path": "/spec/template/spec/containers/0", "value": ""}`, }, { name: "move", fields: fields{ Operation: "move", From: "/spec/template/spec/containers/0", Path: "/spec/template/spec/containers/1", }, want: `{"op": "move", "from": "/spec/template/spec/containers/0", "path": "/spec/template/spec/containers/1", "value": ""}`, }, { name: "copy", fields: fields{ Operation: "copy", From: "/spec/template/spec/containers/0", Path: "/spec/template/spec/containers/1", }, want: `{"op": "copy", "from": "/spec/template/spec/containers/0", "path": "/spec/template/spec/containers/1", "value": ""}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := &JSONPatch{ Operation: tt.fields.Operation, From: tt.fields.From, Path: tt.fields.Path, Value: tt.fields.Value, } if got := p.ToString(); got != tt.want { t.Errorf("JSONPatch.ToString() = %v, want %v", got, tt.want) } }) } }