mirror of https://github.com/fluxcd/cli-utils.git
				
				
				
			
		
			
				
	
	
		
			719 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			719 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Go
		
	
	
	
| // Copyright 2021 The Kubernetes Authors.
 | |
| // SPDX-License-Identifier: Apache-2.0
 | |
| 
 | |
| package mutator
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"testing"
 | |
| 
 | |
| 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 | |
| 	"k8s.io/apimachinery/pkg/api/meta/testrestmapper"
 | |
| 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 | |
| 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 | |
| 	"k8s.io/apimachinery/pkg/runtime/schema"
 | |
| 	"k8s.io/client-go/dynamic"
 | |
| 	"k8s.io/client-go/dynamic/fake"
 | |
| 	"k8s.io/kubectl/pkg/scheme"
 | |
| 	"sigs.k8s.io/cli-utils/pkg/apply/cache"
 | |
| 	ktestutil "sigs.k8s.io/cli-utils/pkg/kstatus/polling/testutil"
 | |
| 	"sigs.k8s.io/cli-utils/pkg/object"
 | |
| 
 | |
| 	// Using gopkg.in/yaml.v3 instead of sigs.k8s.io/yaml on purpose.
 | |
| 	// yaml.v3 correctly parses ints:
 | |
| 	// https://github.com/kubernetes-sigs/yaml/issues/45
 | |
| 	"gopkg.in/yaml.v3"
 | |
| 
 | |
| 	"github.com/stretchr/testify/require"
 | |
| )
 | |
| 
 | |
| var expectedReason = "resource contained annotation: config.kubernetes.io/apply-time-mutation"
 | |
| 
 | |
| var pod1y = `
 | |
| apiVersion: v1
 | |
| kind: Pod
 | |
| metadata:
 | |
|   name: pod-name
 | |
|   namespace: pod-namespace
 | |
|   annotations:
 | |
|     config.kubernetes.io/apply-time-mutation: |
 | |
|       - sourceRef:
 | |
|           group: networking.k8s.io
 | |
|           kind: Ingress
 | |
|           name: ingress1-name
 | |
|           namespace: ingress-namespace
 | |
|         sourcePath: $.spec.rules[0].http.paths[0].backend.service.port.number
 | |
|         targetPath: $.spec.containers[0].env[0].value
 | |
|         token: ${service-port}
 | |
| spec:
 | |
|   containers:
 | |
|   - name: app
 | |
|     image: example:1.0
 | |
|     ports:
 | |
|     - containerPort: 80
 | |
|     env:
 | |
|     - name: SERVICE_PORT
 | |
|       value: ${service-port}
 | |
| `
 | |
| 
 | |
| var ingress1y = `
 | |
| apiVersion: networking.k8s.io/v1
 | |
| kind: Ingress
 | |
| metadata:
 | |
|   name: ingress1-name
 | |
|   namespace: ingress-namespace
 | |
|   annotations:
 | |
|     nginx.ingress.kubernetes.io/rewrite-target: /
 | |
| spec:
 | |
|   rules:
 | |
|   - http:
 | |
|       paths:
 | |
|       - path: /old
 | |
|         pathType: Prefix
 | |
|         backend:
 | |
|           service:
 | |
|             name: old
 | |
|             port:
 | |
|               number: 80
 | |
| `
 | |
| 
 | |
| var pod2y = `
 | |
| apiVersion: v1
 | |
| kind: Pod
 | |
| metadata:
 | |
|   name: pod-name
 | |
|   namespace: pod-namespace
 | |
|   annotations:
 | |
|     config.kubernetes.io/apply-time-mutation: |
 | |
|       - sourceRef:
 | |
|           group: networking.k8s.io
 | |
|           kind: Ingress
 | |
|           name: ingress1-name
 | |
|           namespace: ingress-namespace
 | |
|         sourcePath: $.spec.rules[?(@.http)].http.paths[?(@.path=="/old")].backend.service.port.number
 | |
|         targetPath: $.spec.containers[?(@.name=="app")].env[?(@.name=="SERVICE_PORT")].value
 | |
|         token: ${service-port}
 | |
|       - sourceRef:
 | |
|           group: networking.k8s.io
 | |
|           kind: Ingress
 | |
|           name: ingress1-name
 | |
|           namespace: ingress-namespace
 | |
|         sourcePath: $.spec.rules[?(@.http)].http.paths[?(@.path=="/old")].backend.service.name
 | |
|         targetPath: $.spec.containers[?(@.name=="app")].env[?(@.name=="SERVICE_NAME")].value
 | |
| spec:
 | |
|   containers:
 | |
|   - name: app
 | |
|     image: example:1.0
 | |
|     ports:
 | |
|     - containerPort: 80
 | |
|     env:
 | |
|     - name: SERVICE_PORT
 | |
|       value: ${service-port}
 | |
|     - name: SERVICE_NAME
 | |
|       value: "" # field must exist to be mutated
 | |
| `
 | |
| 
 | |
| var pod3y = `
 | |
| apiVersion: v1
 | |
| kind: Pod
 | |
| metadata:
 | |
|   name: pod-name
 | |
|   namespace: pod-namespace
 | |
|   annotations:
 | |
|     config.kubernetes.io/apply-time-mutation: |
 | |
|       - sourceRef:
 | |
|           kind: ConfigMap
 | |
|           name: map1-name
 | |
|           namespace: map-namespace
 | |
|         sourcePath: $.data.image
 | |
|         targetPath: $.spec.containers[?(@.name=="app")].image
 | |
|         token: ${app-image}
 | |
|       - sourceRef:
 | |
|           kind: ConfigMap
 | |
|           name: map1-name
 | |
|           namespace: map-namespace
 | |
|         sourcePath: $.data.version
 | |
|         targetPath: $.spec.containers[?(@.name=="app")].image
 | |
|         token: ${app-version}
 | |
|       - sourceRef:
 | |
|           group: networking.k8s.io
 | |
|           kind: Ingress
 | |
|           name: ingress1-name
 | |
|           namespace: ingress-namespace
 | |
|         sourcePath: $.spec.rules[?(@.http)].http.paths[?(@.path=="/old")].backend.service.port.number
 | |
|         targetPath: $.spec.containers[?(@.name=="app")].env[?(@.name=="SERVICE_PORT")].value
 | |
|         token: ${service-port}
 | |
| spec:
 | |
|   containers:
 | |
|   - name: app
 | |
|     image: ${app-image}:${app-version}
 | |
|     ports:
 | |
|     - containerPort: 80
 | |
|     env:
 | |
|     - name: SERVICE_PORT
 | |
|       value: ${service-port}
 | |
| `
 | |
| 
 | |
| var configmap1y = `
 | |
| apiVersion: v1
 | |
| kind: ConfigMap
 | |
| metadata:
 | |
|   name: map1-name
 | |
|   namespace: map-namespace
 | |
| data:
 | |
|   image: traefik/whoami
 | |
|   version: "1.0"
 | |
| `
 | |
| 
 | |
| var configmap2y = `
 | |
| apiVersion: v1
 | |
| kind: ConfigMap
 | |
| metadata:
 | |
|   name: map2-name
 | |
|   namespace: map-namespace
 | |
|   annotations:
 | |
|     config.kubernetes.io/apply-time-mutation: |
 | |
|       - sourceRef:
 | |
|           kind: ConfigMap
 | |
|           name: map1-name
 | |
|           namespace: map-namespace
 | |
|         sourcePath: $.data
 | |
|         targetPath: $.data.json
 | |
|         token: ${map-data-json}
 | |
| data:
 | |
|   json: "[{\"π\":3.14},${map-data-json}]"
 | |
| `
 | |
| 
 | |
| // invalid
 | |
| var configmap3y = `
 | |
| apiVersion: v1
 | |
| kind: ConfigMap
 | |
| metadata:
 | |
|   name: map3-name
 | |
|   namespace: map-namespace
 | |
|   annotations:
 | |
|     config.kubernetes.io/apply-time-mutation: "not a valid substitution list"
 | |
| data: {}
 | |
| `
 | |
| 
 | |
| // self-reference
 | |
| var configmap4y = `
 | |
| apiVersion: v1
 | |
| kind: ConfigMap
 | |
| metadata:
 | |
|   name: map4-name
 | |
|   namespace: map-namespace
 | |
|   annotations:
 | |
|     config.kubernetes.io/apply-time-mutation: |
 | |
|       - sourceRef:
 | |
|           kind: ConfigMap
 | |
|           name: map4-name
 | |
|           namespace: map-namespace
 | |
|         sourcePath: $.data
 | |
|         targetPath: $.data
 | |
| data:
 | |
|   movie: inception
 | |
|   slogan: we need to go deeper
 | |
| `
 | |
| 
 | |
| var ingress2y = `
 | |
| apiVersion: networking.k8s.io/v1
 | |
| kind: Ingress
 | |
| metadata:
 | |
|   name: ingress2-name
 | |
|   namespace: ingress-namespace
 | |
|   annotations:
 | |
|     nginx.ingress.kubernetes.io/rewrite-target: /
 | |
|     config.kubernetes.io/apply-time-mutation: |
 | |
|       - sourceRef:
 | |
|           apiVersion: networking.k8s.io/v1
 | |
|           kind: Ingress
 | |
|           name: ingress1-name
 | |
|           namespace: ingress-namespace
 | |
|         sourcePath: $.spec.rules[0].http.paths[?(@.path=="/old")]
 | |
|         targetPath: $.spec.rules[0].http.paths[(@.length-1)]
 | |
| spec:
 | |
|   rules:
 | |
|   - http:
 | |
|       paths:
 | |
|       - path: /new
 | |
|         pathType: Prefix
 | |
|         backend:
 | |
|           service:
 | |
|             name: new
 | |
|             port:
 | |
|               number: 80
 | |
|       - {} # field must exist to be mutated
 | |
| `
 | |
| 
 | |
| var joinedPathsYaml = `
 | |
| - path: /new
 | |
|   pathType: Prefix
 | |
|   backend:
 | |
|     service:
 | |
|       name: new
 | |
|       port:
 | |
|         number: 80
 | |
| - path: /old
 | |
|   pathType: Prefix
 | |
|   backend:
 | |
|     service:
 | |
|       name: old
 | |
|       port:
 | |
|         number: 80
 | |
| `
 | |
| 
 | |
| var service1y = `
 | |
| apiVersion: v1
 | |
| kind: Service
 | |
| metadata:
 | |
|   name: service1-name
 | |
|   namespace: service1-namespace
 | |
|   annotations:
 | |
|     config.kubernetes.io/apply-time-mutation: |
 | |
|       - sourceRef:
 | |
|           group: apps
 | |
|           kind: Deployment
 | |
|           name: deployment1-name
 | |
|           namespace: deployment1-namespace
 | |
|         sourcePath: $.spec.template.spec.containers[?(@.name=="tcp-handler")].ports[0].containerPort
 | |
|         targetPath: $.spec.ports[?(@.protocol=="TCP" && @.port==80)].targetPort
 | |
|       - sourceRef:
 | |
|           group: apps
 | |
|           kind: Deployment
 | |
|           name: deployment1-name
 | |
|           namespace: deployment1-namespace
 | |
|         sourcePath: $.spec.template.spec.containers[?(@.name=="udp-handler")].ports[0].containerPort
 | |
|         targetPath: $.spec.ports[?(@.protocol=="UDP" && @.port==80)].targetPort
 | |
| spec:
 | |
|   selector:
 | |
|     app: MyApp
 | |
|   ports:
 | |
|     - protocol: TCP
 | |
|       port: 80
 | |
|       targetPort: 0 # field must exist to be mutated
 | |
|     - protocol: TCP
 | |
|       port: 443
 | |
|       targetPort: 443
 | |
|     - protocol: UDP
 | |
|       port: 80
 | |
|       targetPort: 0 # field must exist to be mutated
 | |
| `
 | |
| 
 | |
| var deployment1y = `
 | |
| apiVersion: apps/v1
 | |
| kind: Deployment
 | |
| metadata:
 | |
|   name: deployment1-name
 | |
|   namespace: deployment1-namespace
 | |
| spec:
 | |
|   selector:
 | |
|     matchLabels:
 | |
|       app: example
 | |
|   replicas: 2
 | |
|   template:
 | |
|     metadata:
 | |
|       labels:
 | |
|         app: example
 | |
|     spec:
 | |
|       containers:
 | |
|       - name: tcp-handler
 | |
|         image: example-tcp
 | |
|         ports:
 | |
|         - containerPort: 8080
 | |
|       - name: udp-handler
 | |
|         image: example-udp
 | |
|         ports:
 | |
|         - containerPort: 8081
 | |
| `
 | |
| 
 | |
| var clusterrole1y = `
 | |
| apiVersion: rbac.authorization.k8s.io/v1
 | |
| kind: ClusterRole
 | |
| metadata:
 | |
|   name: example-role
 | |
|   labels:
 | |
|     domain: example.com
 | |
| rules:
 | |
| - apiGroups: [""]
 | |
|   resources: ["pods"]
 | |
|   verbs: ["get", "watch", "list"]
 | |
| `
 | |
| 
 | |
| var clusterrolebinding1y = `
 | |
| apiVersion: rbac.authorization.k8s.io/v1
 | |
| kind: ClusterRoleBinding
 | |
| metadata:
 | |
|   name: read-secrets
 | |
|   annotations:
 | |
|     config.kubernetes.io/apply-time-mutation: |
 | |
|       - sourceRef:
 | |
|           apiVersion: rbac.authorization.k8s.io/v1
 | |
|           kind: ClusterRole
 | |
|           name: example-role
 | |
|         sourcePath: $.metadata.labels.domain
 | |
|         targetPath: $.subjects[0].name
 | |
|         token: ${domain}
 | |
| subjects:
 | |
| - kind: User
 | |
|   name: "bob@${domain}"
 | |
|   apiGroup: rbac.authorization.k8s.io
 | |
| roleRef:
 | |
|   kind: ClusterRole
 | |
|   name: secret-reader
 | |
|   apiGroup: rbac.authorization.k8s.io
 | |
| `
 | |
| 
 | |
| type nestedFieldValue struct {
 | |
| 	Field []interface{}
 | |
| 	Value interface{}
 | |
| }
 | |
| 
 | |
| func TestMutate(t *testing.T) {
 | |
| 	pod1 := ktestutil.YamlToUnstructured(t, pod1y)
 | |
| 	ingress1 := ktestutil.YamlToUnstructured(t, ingress1y)
 | |
| 	pod2 := ktestutil.YamlToUnstructured(t, pod2y)
 | |
| 	pod3 := ktestutil.YamlToUnstructured(t, pod3y)
 | |
| 	configmap1 := ktestutil.YamlToUnstructured(t, configmap1y)
 | |
| 	configmap2 := ktestutil.YamlToUnstructured(t, configmap2y)
 | |
| 	configmap3 := ktestutil.YamlToUnstructured(t, configmap3y)
 | |
| 	configmap4 := ktestutil.YamlToUnstructured(t, configmap4y)
 | |
| 	ingress2 := ktestutil.YamlToUnstructured(t, ingress2y)
 | |
| 	service1 := ktestutil.YamlToUnstructured(t, service1y)
 | |
| 	deployment1 := ktestutil.YamlToUnstructured(t, deployment1y)
 | |
| 	clusterrole1 := ktestutil.YamlToUnstructured(t, clusterrole1y)
 | |
| 	clusterrolebinding1 := ktestutil.YamlToUnstructured(t, clusterrolebinding1y)
 | |
| 
 | |
| 	joinedPaths := make([]interface{}, 0)
 | |
| 	err := yaml.Unmarshal([]byte(joinedPathsYaml), &joinedPaths)
 | |
| 	if err != nil {
 | |
| 		t.Fatalf("error parsing yaml: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	tests := map[string]struct {
 | |
| 		target   *unstructured.Unstructured
 | |
| 		sources  []*unstructured.Unstructured
 | |
| 		cache    cache.ResourceCache
 | |
| 		mutated  bool
 | |
| 		reason   string
 | |
| 		errMsg   string
 | |
| 		expected []nestedFieldValue
 | |
| 	}{
 | |
| 		"no annotation": {
 | |
| 			target:  configmap1,
 | |
| 			mutated: false,
 | |
| 			reason:  "",
 | |
| 		},
 | |
| 		"invalid annotation": {
 | |
| 			target:  configmap3,
 | |
| 			mutated: false,
 | |
| 			reason:  "",
 | |
| 			// exact error message isn't very important. Feel free to update if the error text changes.
 | |
| 			errMsg: `failed to read annotation in resource (v1/namespaces/map-namespace/ConfigMap/map3-name): ` +
 | |
| 				`invalid "config.kubernetes.io/apply-time-mutation" annotation: ` +
 | |
| 				`error unmarshaling JSON: ` +
 | |
| 				`while decoding JSON: ` +
 | |
| 				`json: cannot unmarshal string into Go value of type mutation.ApplyTimeMutation`,
 | |
| 		},
 | |
| 		"invalid self-reference": {
 | |
| 			target:  configmap4,
 | |
| 			mutated: false,
 | |
| 			reason:  "",
 | |
| 			// exact error message isn't very important. Feel free to update if the error text changes.
 | |
| 			errMsg: `invalid self-reference (/namespaces/map-namespace/ConfigMap/map4-name)`,
 | |
| 		},
 | |
| 		"missing source": {
 | |
| 			target:  pod1,
 | |
| 			mutated: false,
 | |
| 			reason:  "",
 | |
| 			// exact error message isn't very important. Feel free to update if the error text changes.
 | |
| 			errMsg: `failed to get source resource (networking.k8s.io/namespaces/ingress-namespace/Ingress/ingress1-name): ` +
 | |
| 				`resource not found: ` +
 | |
| 				`ingresses.networking.k8s.io "ingress1-name" not found`,
 | |
| 		},
 | |
| 		"pod env var string from ingress port int": {
 | |
| 			target:  pod1,
 | |
| 			sources: []*unstructured.Unstructured{ingress1},
 | |
| 			mutated: true,
 | |
| 			reason:  expectedReason,
 | |
| 			expected: []nestedFieldValue{
 | |
| 				{
 | |
| 					Field: []interface{}{"spec", "containers", 0, "env", 0, "value"},
 | |
| 					Value: "80", // must be string, not int
 | |
| 				},
 | |
| 			},
 | |
| 		},
 | |
| 		"two subs, one source, no token, missing target field, field selector": {
 | |
| 			target:  pod2,
 | |
| 			sources: []*unstructured.Unstructured{ingress1, ingress1}, // twice, because not cached
 | |
| 			mutated: true,
 | |
| 			reason:  expectedReason,
 | |
| 			expected: []nestedFieldValue{
 | |
| 				{
 | |
| 					Field: []interface{}{"spec", "containers", 0, "env", 0, "value"},
 | |
| 					Value: "80", // must be string, not int
 | |
| 				},
 | |
| 				{
 | |
| 					Field: []interface{}{"spec", "containers", 0, "env", 1, "value"},
 | |
| 					Value: "old",
 | |
| 				},
 | |
| 			},
 | |
| 		},
 | |
| 		"two subs, one source, no token, missing target field, field selector (cached)": {
 | |
| 			target:  pod2,
 | |
| 			sources: []*unstructured.Unstructured{ingress1}, // only once, because cached
 | |
| 			cache:   cache.NewResourceCacheMap(),
 | |
| 			mutated: true,
 | |
| 			reason:  expectedReason,
 | |
| 			expected: []nestedFieldValue{
 | |
| 				{
 | |
| 					Field: []interface{}{"spec", "containers", 0, "env", 0, "value"},
 | |
| 					Value: "80", // must be string, not int
 | |
| 				},
 | |
| 				{
 | |
| 					Field: []interface{}{"spec", "containers", 0, "env", 1, "value"},
 | |
| 					Value: "old",
 | |
| 				},
 | |
| 			},
 | |
| 		},
 | |
| 		"three subs, two sources, two tokens in the same target field, float string": {
 | |
| 			target:  pod3,
 | |
| 			sources: []*unstructured.Unstructured{configmap1, configmap1, ingress1}, // repeats, because not cached
 | |
| 			mutated: true,
 | |
| 			reason:  expectedReason,
 | |
| 			expected: []nestedFieldValue{
 | |
| 				{
 | |
| 					Field: []interface{}{"spec", "containers", 0, "env", 0, "value"},
 | |
| 					Value: "80", // must be string, not int
 | |
| 				},
 | |
| 				{
 | |
| 					Field: []interface{}{"spec", "containers", 0, "image"},
 | |
| 					Value: "traefik/whoami:1.0", // make sure float string isn't trucated to "1"
 | |
| 				},
 | |
| 			},
 | |
| 		},
 | |
| 		"three subs, two sources, two tokens in the same target field, float string (cached)": {
 | |
| 			target:  pod3,
 | |
| 			sources: []*unstructured.Unstructured{configmap1, ingress1}, // no repeats, because cached
 | |
| 			cache:   cache.NewResourceCacheMap(),
 | |
| 			mutated: true,
 | |
| 			reason:  expectedReason,
 | |
| 			expected: []nestedFieldValue{
 | |
| 				{
 | |
| 					Field: []interface{}{"spec", "containers", 0, "env", 0, "value"},
 | |
| 					Value: "80", // must be string, not int
 | |
| 				},
 | |
| 				{
 | |
| 					Field: []interface{}{"spec", "containers", 0, "image"},
 | |
| 					Value: "traefik/whoami:1.0", // make sure float string isn't trucated to "1"
 | |
| 				},
 | |
| 			},
 | |
| 		},
 | |
| 		"map to json string": {
 | |
| 			target:  configmap2,
 | |
| 			sources: []*unstructured.Unstructured{configmap1},
 | |
| 			mutated: true,
 | |
| 			reason:  expectedReason,
 | |
| 			expected: []nestedFieldValue{
 | |
| 				{
 | |
| 					Field: []interface{}{"data", "json"},
 | |
| 					Value: `[{"π":3.14},{"image":"traefik/whoami","version":"1.0"}]`, // string, not object
 | |
| 				},
 | |
| 			},
 | |
| 		},
 | |
| 		"map to map, array append": {
 | |
| 			target:  ingress2,
 | |
| 			sources: []*unstructured.Unstructured{ingress1},
 | |
| 			mutated: true,
 | |
| 			reason:  expectedReason,
 | |
| 			expected: []nestedFieldValue{
 | |
| 				{
 | |
| 					Field: []interface{}{"spec", "rules", 0, "http", "paths"},
 | |
| 					Value: joinedPaths, // object, not string
 | |
| 				},
 | |
| 			},
 | |
| 		},
 | |
| 		"multi-field selector": {
 | |
| 			target:  service1,
 | |
| 			sources: []*unstructured.Unstructured{deployment1, deployment1}, // repeats, because not cached
 | |
| 			mutated: true,
 | |
| 			reason:  expectedReason,
 | |
| 			expected: []nestedFieldValue{
 | |
| 				{
 | |
| 					Field: []interface{}{"spec", "ports", 0, "targetPort"},
 | |
| 					Value: 8080,
 | |
| 				},
 | |
| 				{
 | |
| 					Field: []interface{}{"spec", "ports", 2, "targetPort"},
 | |
| 					Value: 8081,
 | |
| 				},
 | |
| 			},
 | |
| 		},
 | |
| 		"cluster-scoped": {
 | |
| 			target:  clusterrolebinding1,
 | |
| 			sources: []*unstructured.Unstructured{clusterrole1},
 | |
| 			mutated: true,
 | |
| 			reason:  expectedReason,
 | |
| 			expected: []nestedFieldValue{
 | |
| 				{
 | |
| 					Field: []interface{}{"subjects", 0, "name"},
 | |
| 					Value: "bob@example.com",
 | |
| 				},
 | |
| 			},
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	for name, tc := range tests {
 | |
| 		t.Run(name, func(t *testing.T) {
 | |
| 			getChan := make(chan unstructured.Unstructured)
 | |
| 
 | |
| 			mutator := &ApplyTimeMutator{
 | |
| 				Client: &fakeDynamicClient{
 | |
| 					resourceInterfaceFunc: newFakeNamespaceClientFunc(getChan),
 | |
| 				},
 | |
| 				Mapper: testrestmapper.TestOnlyStaticRESTMapper(
 | |
| 					scheme.Scheme,
 | |
| 					scheme.Scheme.PrioritizedVersionsAllGroups()...,
 | |
| 				),
 | |
| 				ResourceCache: tc.cache, // optional!
 | |
| 			}
 | |
| 
 | |
| 			// send sources when GET is called
 | |
| 			sources := tc.sources
 | |
| 			go func() {
 | |
| 				defer close(getChan)
 | |
| 				for _, source := range sources {
 | |
| 					getChan <- *source
 | |
| 				}
 | |
| 			}()
 | |
| 
 | |
| 			mutated, reason, err := mutator.Mutate(context.TODO(), tc.target)
 | |
| 			if tc.errMsg != "" {
 | |
| 				require.EqualError(t, err, tc.errMsg)
 | |
| 			} else {
 | |
| 				require.NoError(t, err)
 | |
| 			}
 | |
| 			require.Equal(t, tc.mutated, mutated, "unexpected mutated bool")
 | |
| 			require.Equal(t, tc.reason, reason, "unexpected mutated reason")
 | |
| 
 | |
| 			for _, efv := range tc.expected {
 | |
| 				received, found, err := object.NestedField(tc.target.Object, efv.Field...)
 | |
| 				require.NoError(t, err)
 | |
| 				require.True(t, found, "target field not found")
 | |
| 				require.Equal(t, efv.Value, received, "unexpected target field value")
 | |
| 			}
 | |
| 		})
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func TestValueToString(t *testing.T) {
 | |
| 	tests := map[string]struct {
 | |
| 		value    interface{}
 | |
| 		expected string
 | |
| 	}{
 | |
| 		"int": {
 | |
| 			value:    1,
 | |
| 			expected: "1",
 | |
| 		},
 | |
| 		"float": {
 | |
| 			value:    1.2345,
 | |
| 			expected: "1.2345",
 | |
| 		},
 | |
| 		"string": {
 | |
| 			value:    "nothing to see",
 | |
| 			expected: "nothing to see",
 | |
| 		},
 | |
| 		"bool": {
 | |
| 			value:    false,
 | |
| 			expected: "false",
 | |
| 		},
 | |
| 		"interface map": {
 | |
| 			value: map[string]interface{}{
 | |
| 				"apiVersion": "v1",
 | |
| 				"kind":       "Pod",
 | |
| 				"metadata": map[string]interface{}{
 | |
| 					"name":      "pod-name",
 | |
| 					"namespace": "test-namespace",
 | |
| 				},
 | |
| 			},
 | |
| 			expected: `{"apiVersion":"v1","kind":"Pod","metadata":{"name":"pod-name","namespace":"test-namespace"}}`,
 | |
| 		},
 | |
| 		"interface list": {
 | |
| 			value: []interface{}{
 | |
| 				"x",
 | |
| 				map[string]interface{}{
 | |
| 					"?": nil,
 | |
| 				},
 | |
| 				0,
 | |
| 			},
 | |
| 			expected: `["x",{"?":null},0]`,
 | |
| 		},
 | |
| 		"string list": {
 | |
| 			value: []string{
 | |
| 				"x",
 | |
| 				"y",
 | |
| 				"z",
 | |
| 			},
 | |
| 			expected: `["x","y","z"]`,
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	for name, tc := range tests {
 | |
| 		t.Run(name, func(t *testing.T) {
 | |
| 			received, err := valueToString(tc.value)
 | |
| 			require.NoError(t, err)
 | |
| 			require.Equal(t, tc.expected, received, "unexpected result")
 | |
| 		})
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // fakeNamespaceClient wraps ResourceInterface, overwriting the Get func.
 | |
| type fakeNamespaceClient struct {
 | |
| 	dynamic.ResourceInterface
 | |
| 	resource  schema.GroupVersionResource
 | |
| 	namespace string
 | |
| 	getChan   <-chan unstructured.Unstructured
 | |
| }
 | |
| 
 | |
| func newFakeNamespaceClientFunc(getChan <-chan unstructured.Unstructured) func(resource schema.GroupVersionResource, namespace string) dynamic.ResourceInterface {
 | |
| 	innerGetChan := getChan
 | |
| 	return func(resource schema.GroupVersionResource, namespace string) dynamic.ResourceInterface {
 | |
| 		return &fakeNamespaceClient{
 | |
| 			resource:  resource,
 | |
| 			namespace: namespace,
 | |
| 			getChan:   innerGetChan,
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (c *fakeNamespaceClient) Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
 | |
| 	obj, open := <-c.getChan
 | |
| 	if !open {
 | |
| 		return nil, apierrors.NewNotFound(c.resource.GroupResource(), name)
 | |
| 	}
 | |
| 	return &obj, nil
 | |
| }
 | |
| 
 | |
| // fakeDynamicClient accepts always returns the same client, just with a different
 | |
| type fakeDynamicClient struct {
 | |
| 	resourceInterfaceFunc func(resource schema.GroupVersionResource, namespace string) dynamic.ResourceInterface
 | |
| }
 | |
| 
 | |
| func (c *fakeDynamicClient) Resource(resource schema.GroupVersionResource) dynamic.NamespaceableResourceInterface {
 | |
| 	return &fakeDynamicResourceClient{
 | |
| 		resourceInterfaceFunc:          c.resourceInterfaceFunc,
 | |
| 		NamespaceableResourceInterface: fake.NewSimpleDynamicClient(scheme.Scheme).Resource(resource),
 | |
| 		resource:                       resource,
 | |
| 	}
 | |
| }
 | |
| 
 | |
| type fakeDynamicResourceClient struct {
 | |
| 	dynamic.NamespaceableResourceInterface
 | |
| 	resourceInterfaceFunc func(resource schema.GroupVersionResource, namespace string) dynamic.ResourceInterface
 | |
| 	resource              schema.GroupVersionResource
 | |
| }
 | |
| 
 | |
| func (c *fakeDynamicResourceClient) Namespace(ns string) dynamic.ResourceInterface {
 | |
| 	return c.resourceInterfaceFunc(c.resource, ns)
 | |
| }
 |