1
2
3
4 package mutator
5
6 import (
7 "context"
8 "testing"
9
10 apierrors "k8s.io/apimachinery/pkg/api/errors"
11 "k8s.io/apimachinery/pkg/api/meta/testrestmapper"
12 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
13 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
14 "k8s.io/apimachinery/pkg/runtime/schema"
15 "k8s.io/client-go/dynamic"
16 "k8s.io/client-go/dynamic/fake"
17 "k8s.io/kubectl/pkg/scheme"
18 "sigs.k8s.io/cli-utils/pkg/apply/cache"
19 ktestutil "sigs.k8s.io/cli-utils/pkg/kstatus/polling/testutil"
20 "sigs.k8s.io/cli-utils/pkg/object"
21
22
23
24
25 "gopkg.in/yaml.v3"
26
27 "github.com/stretchr/testify/require"
28 )
29
30 var expectedReason = "object contained annotation: config.kubernetes.io/apply-time-mutation"
31
32 var pod1y = `
33 apiVersion: v1
34 kind: Pod
35 metadata:
36 name: pod-name
37 namespace: pod-namespace
38 annotations:
39 config.kubernetes.io/apply-time-mutation: |
40 - sourceRef:
41 group: networking.k8s.io
42 kind: Ingress
43 name: ingress1-name
44 namespace: ingress-namespace
45 sourcePath: $.spec.rules[0].http.paths[0].backend.service.port.number
46 targetPath: $.spec.containers[0].env[0].value
47 token: ${service-port}
48 spec:
49 containers:
50 - name: app
51 image: example:1.0
52 ports:
53 - containerPort: 80
54 env:
55 - name: SERVICE_PORT
56 value: ${service-port}
57 `
58
59 var ingress1y = `
60 apiVersion: networking.k8s.io/v1
61 kind: Ingress
62 metadata:
63 name: ingress1-name
64 namespace: ingress-namespace
65 annotations:
66 nginx.ingress.kubernetes.io/rewrite-target: /
67 spec:
68 rules:
69 - http:
70 paths:
71 - path: /old
72 pathType: Prefix
73 backend:
74 service:
75 name: old
76 port:
77 number: 80
78 `
79
80 var pod2y = `
81 apiVersion: v1
82 kind: Pod
83 metadata:
84 name: pod-name
85 namespace: pod-namespace
86 annotations:
87 config.kubernetes.io/apply-time-mutation: |
88 - sourceRef:
89 group: networking.k8s.io
90 kind: Ingress
91 name: ingress1-name
92 namespace: ingress-namespace
93 sourcePath: $.spec.rules[?(@.http)].http.paths[?(@.path=="/old")].backend.service.port.number
94 targetPath: $.spec.containers[?(@.name=="app")].env[?(@.name=="SERVICE_PORT")].value
95 token: ${service-port}
96 - sourceRef:
97 group: networking.k8s.io
98 kind: Ingress
99 name: ingress1-name
100 namespace: ingress-namespace
101 sourcePath: $.spec.rules[?(@.http)].http.paths[?(@.path=="/old")].backend.service.name
102 targetPath: $.spec.containers[?(@.name=="app")].env[?(@.name=="SERVICE_NAME")].value
103 spec:
104 containers:
105 - name: app
106 image: example:1.0
107 ports:
108 - containerPort: 80
109 env:
110 - name: SERVICE_PORT
111 value: ${service-port}
112 - name: SERVICE_NAME
113 value: "" # field must exist to be mutated
114 `
115
116 var pod3y = `
117 apiVersion: v1
118 kind: Pod
119 metadata:
120 name: pod-name
121 namespace: pod-namespace
122 annotations:
123 config.kubernetes.io/apply-time-mutation: |
124 - sourceRef:
125 kind: ConfigMap
126 name: map1-name
127 namespace: map-namespace
128 sourcePath: $.data.image
129 targetPath: $.spec.containers[?(@.name=="app")].image
130 token: ${app-image}
131 - sourceRef:
132 kind: ConfigMap
133 name: map1-name
134 namespace: map-namespace
135 sourcePath: $.data.version
136 targetPath: $.spec.containers[?(@.name=="app")].image
137 token: ${app-version}
138 - sourceRef:
139 group: networking.k8s.io
140 kind: Ingress
141 name: ingress1-name
142 namespace: ingress-namespace
143 sourcePath: $.spec.rules[?(@.http)].http.paths[?(@.path=="/old")].backend.service.port.number
144 targetPath: $.spec.containers[?(@.name=="app")].env[?(@.name=="SERVICE_PORT")].value
145 token: ${service-port}
146 spec:
147 containers:
148 - name: app
149 image: ${app-image}:${app-version}
150 ports:
151 - containerPort: 80
152 env:
153 - name: SERVICE_PORT
154 value: ${service-port}
155 `
156
157 var configmap1y = `
158 apiVersion: v1
159 kind: ConfigMap
160 metadata:
161 name: map1-name
162 namespace: map-namespace
163 data:
164 image: traefik/whoami
165 version: "1.0"
166 `
167
168 var configmap2y = `
169 apiVersion: v1
170 kind: ConfigMap
171 metadata:
172 name: map2-name
173 namespace: map-namespace
174 annotations:
175 config.kubernetes.io/apply-time-mutation: |
176 - sourceRef:
177 kind: ConfigMap
178 name: map1-name
179 namespace: map-namespace
180 sourcePath: $.data
181 targetPath: $.data.json
182 token: ${map-data-json}
183 data:
184 json: "[{\"π\":3.14},${map-data-json}]"
185 `
186
187
188 var configmap3y = `
189 apiVersion: v1
190 kind: ConfigMap
191 metadata:
192 name: map3-name
193 namespace: map-namespace
194 annotations:
195 config.kubernetes.io/apply-time-mutation: "not a valid substitution list"
196 data: {}
197 `
198
199
200 var configmap4y = `
201 apiVersion: v1
202 kind: ConfigMap
203 metadata:
204 name: map4-name
205 namespace: map-namespace
206 annotations:
207 config.kubernetes.io/apply-time-mutation: |
208 - sourceRef:
209 kind: ConfigMap
210 name: map4-name
211 namespace: map-namespace
212 sourcePath: $.data
213 targetPath: $.data
214 data:
215 movie: inception
216 slogan: we need to go deeper
217 `
218
219 var ingress2y = `
220 apiVersion: networking.k8s.io/v1
221 kind: Ingress
222 metadata:
223 name: ingress2-name
224 namespace: ingress-namespace
225 annotations:
226 nginx.ingress.kubernetes.io/rewrite-target: /
227 config.kubernetes.io/apply-time-mutation: |
228 - sourceRef:
229 apiVersion: networking.k8s.io/v1
230 kind: Ingress
231 name: ingress1-name
232 namespace: ingress-namespace
233 sourcePath: $.spec.rules[0].http.paths[?(@.path=="/old")]
234 targetPath: $.spec.rules[0].http.paths[(@.length-1)]
235 spec:
236 rules:
237 - http:
238 paths:
239 - path: /new
240 pathType: Prefix
241 backend:
242 service:
243 name: new
244 port:
245 number: 80
246 - {} # field must exist to be mutated
247 `
248
249 var joinedPathsYaml = `
250 - path: /new
251 pathType: Prefix
252 backend:
253 service:
254 name: new
255 port:
256 number: 80
257 - path: /old
258 pathType: Prefix
259 backend:
260 service:
261 name: old
262 port:
263 number: 80
264 `
265
266 var service1y = `
267 apiVersion: v1
268 kind: Service
269 metadata:
270 name: service1-name
271 namespace: service1-namespace
272 annotations:
273 config.kubernetes.io/apply-time-mutation: |
274 - sourceRef:
275 group: apps
276 kind: Deployment
277 name: deployment1-name
278 namespace: deployment1-namespace
279 sourcePath: $.spec.template.spec.containers[?(@.name=="tcp-handler")].ports[0].containerPort
280 targetPath: $.spec.ports[?(@.protocol=="TCP" && @.port==80)].targetPort
281 - sourceRef:
282 group: apps
283 kind: Deployment
284 name: deployment1-name
285 namespace: deployment1-namespace
286 sourcePath: $.spec.template.spec.containers[?(@.name=="udp-handler")].ports[0].containerPort
287 targetPath: $.spec.ports[?(@.protocol=="UDP" && @.port==80)].targetPort
288 spec:
289 selector:
290 app: MyApp
291 ports:
292 - protocol: TCP
293 port: 80
294 targetPort: 0 # field must exist to be mutated
295 - protocol: TCP
296 port: 443
297 targetPort: 443
298 - protocol: UDP
299 port: 80
300 targetPort: 0 # field must exist to be mutated
301 `
302
303 var deployment1y = `
304 apiVersion: apps/v1
305 kind: Deployment
306 metadata:
307 name: deployment1-name
308 namespace: deployment1-namespace
309 spec:
310 selector:
311 matchLabels:
312 app: example
313 replicas: 2
314 template:
315 metadata:
316 labels:
317 app: example
318 spec:
319 containers:
320 - name: tcp-handler
321 image: example-tcp
322 ports:
323 - containerPort: 8080
324 - name: udp-handler
325 image: example-udp
326 ports:
327 - containerPort: 8081
328 `
329
330 var clusterrole1y = `
331 apiVersion: rbac.authorization.k8s.io/v1
332 kind: ClusterRole
333 metadata:
334 name: example-role
335 labels:
336 domain: example.com
337 rules:
338 - apiGroups: [""]
339 resources: ["pods"]
340 verbs: ["get", "watch", "list"]
341 `
342
343 var clusterrolebinding1y = `
344 apiVersion: rbac.authorization.k8s.io/v1
345 kind: ClusterRoleBinding
346 metadata:
347 name: read-secrets
348 annotations:
349 config.kubernetes.io/apply-time-mutation: |
350 - sourceRef:
351 apiVersion: rbac.authorization.k8s.io/v1
352 kind: ClusterRole
353 name: example-role
354 sourcePath: $.metadata.labels.domain
355 targetPath: $.subjects[0].name
356 token: ${domain}
357 subjects:
358 - kind: User
359 name: "bob@${domain}"
360 apiGroup: rbac.authorization.k8s.io
361 roleRef:
362 kind: ClusterRole
363 name: secret-reader
364 apiGroup: rbac.authorization.k8s.io
365 `
366
367 type nestedFieldValue struct {
368 Field []interface{}
369 Value interface{}
370 }
371
372 func TestMutate(t *testing.T) {
373 pod1 := ktestutil.YamlToUnstructured(t, pod1y)
374 ingress1 := ktestutil.YamlToUnstructured(t, ingress1y)
375 pod2 := ktestutil.YamlToUnstructured(t, pod2y)
376 pod3 := ktestutil.YamlToUnstructured(t, pod3y)
377 configmap1 := ktestutil.YamlToUnstructured(t, configmap1y)
378 configmap2 := ktestutil.YamlToUnstructured(t, configmap2y)
379 configmap3 := ktestutil.YamlToUnstructured(t, configmap3y)
380 configmap4 := ktestutil.YamlToUnstructured(t, configmap4y)
381 ingress2 := ktestutil.YamlToUnstructured(t, ingress2y)
382 service1 := ktestutil.YamlToUnstructured(t, service1y)
383 deployment1 := ktestutil.YamlToUnstructured(t, deployment1y)
384 clusterrole1 := ktestutil.YamlToUnstructured(t, clusterrole1y)
385 clusterrolebinding1 := ktestutil.YamlToUnstructured(t, clusterrolebinding1y)
386
387 joinedPaths := make([]interface{}, 0)
388 err := yaml.Unmarshal([]byte(joinedPathsYaml), &joinedPaths)
389 if err != nil {
390 t.Fatalf("error parsing yaml: %v", err)
391 }
392
393 tests := map[string]struct {
394 target *unstructured.Unstructured
395 sources []*unstructured.Unstructured
396 cache cache.ResourceCache
397 mutated bool
398 reason string
399 errMsg string
400 expected []nestedFieldValue
401 }{
402 "no annotation": {
403 target: configmap1,
404 mutated: false,
405 reason: "",
406 },
407 "invalid annotation": {
408 target: configmap3,
409 mutated: false,
410 reason: "",
411
412 errMsg: `failed to read annotation in object (v1/namespaces/map-namespace/ConfigMap/map3-name): ` +
413 `invalid "config.kubernetes.io/apply-time-mutation" annotation: ` +
414 `error unmarshaling JSON: ` +
415 `while decoding JSON: ` +
416 `json: cannot unmarshal string into Go value of type mutation.ApplyTimeMutation`,
417 },
418 "invalid self-reference": {
419 target: configmap4,
420 mutated: false,
421 reason: "",
422
423 errMsg: `invalid self-reference (/namespaces/map-namespace/ConfigMap/map4-name)`,
424 },
425 "missing source": {
426 target: pod1,
427 mutated: false,
428 reason: "",
429
430 errMsg: `failed to get source object (networking.k8s.io/namespaces/ingress-namespace/Ingress/ingress1-name): ` +
431 `object not found: ` +
432 `ingresses.networking.k8s.io "ingress1-name" not found`,
433 },
434 "pod env var string from ingress port int": {
435 target: pod1,
436 sources: []*unstructured.Unstructured{ingress1},
437 mutated: true,
438 reason: expectedReason,
439 expected: []nestedFieldValue{
440 {
441 Field: []interface{}{"spec", "containers", 0, "env", 0, "value"},
442 Value: "80",
443 },
444 },
445 },
446 "two subs, one source, no token, missing target field, field selector": {
447 target: pod2,
448 sources: []*unstructured.Unstructured{ingress1, ingress1},
449 mutated: true,
450 reason: expectedReason,
451 expected: []nestedFieldValue{
452 {
453 Field: []interface{}{"spec", "containers", 0, "env", 0, "value"},
454 Value: "80",
455 },
456 {
457 Field: []interface{}{"spec", "containers", 0, "env", 1, "value"},
458 Value: "old",
459 },
460 },
461 },
462 "two subs, one source, no token, missing target field, field selector (cached)": {
463 target: pod2,
464 sources: []*unstructured.Unstructured{ingress1},
465 cache: cache.NewResourceCacheMap(),
466 mutated: true,
467 reason: expectedReason,
468 expected: []nestedFieldValue{
469 {
470 Field: []interface{}{"spec", "containers", 0, "env", 0, "value"},
471 Value: "80",
472 },
473 {
474 Field: []interface{}{"spec", "containers", 0, "env", 1, "value"},
475 Value: "old",
476 },
477 },
478 },
479 "three subs, two sources, two tokens in the same target field, float string": {
480 target: pod3,
481 sources: []*unstructured.Unstructured{configmap1, configmap1, ingress1},
482 mutated: true,
483 reason: expectedReason,
484 expected: []nestedFieldValue{
485 {
486 Field: []interface{}{"spec", "containers", 0, "env", 0, "value"},
487 Value: "80",
488 },
489 {
490 Field: []interface{}{"spec", "containers", 0, "image"},
491 Value: "traefik/whoami:1.0",
492 },
493 },
494 },
495 "three subs, two sources, two tokens in the same target field, float string (cached)": {
496 target: pod3,
497 sources: []*unstructured.Unstructured{configmap1, ingress1},
498 cache: cache.NewResourceCacheMap(),
499 mutated: true,
500 reason: expectedReason,
501 expected: []nestedFieldValue{
502 {
503 Field: []interface{}{"spec", "containers", 0, "env", 0, "value"},
504 Value: "80",
505 },
506 {
507 Field: []interface{}{"spec", "containers", 0, "image"},
508 Value: "traefik/whoami:1.0",
509 },
510 },
511 },
512 "map to json string": {
513 target: configmap2,
514 sources: []*unstructured.Unstructured{configmap1},
515 mutated: true,
516 reason: expectedReason,
517 expected: []nestedFieldValue{
518 {
519 Field: []interface{}{"data", "json"},
520 Value: `[{"π":3.14},{"image":"traefik/whoami","version":"1.0"}]`,
521 },
522 },
523 },
524 "map to map, array append": {
525 target: ingress2,
526 sources: []*unstructured.Unstructured{ingress1},
527 mutated: true,
528 reason: expectedReason,
529 expected: []nestedFieldValue{
530 {
531 Field: []interface{}{"spec", "rules", 0, "http", "paths"},
532 Value: joinedPaths,
533 },
534 },
535 },
536 "multi-field selector": {
537 target: service1,
538 sources: []*unstructured.Unstructured{deployment1, deployment1},
539 mutated: true,
540 reason: expectedReason,
541 expected: []nestedFieldValue{
542 {
543 Field: []interface{}{"spec", "ports", 0, "targetPort"},
544 Value: 8080,
545 },
546 {
547 Field: []interface{}{"spec", "ports", 2, "targetPort"},
548 Value: 8081,
549 },
550 },
551 },
552 "cluster-scoped": {
553 target: clusterrolebinding1,
554 sources: []*unstructured.Unstructured{clusterrole1},
555 mutated: true,
556 reason: expectedReason,
557 expected: []nestedFieldValue{
558 {
559 Field: []interface{}{"subjects", 0, "name"},
560 Value: "bob@example.com",
561 },
562 },
563 },
564 }
565
566 for name, tc := range tests {
567 t.Run(name, func(t *testing.T) {
568 getChan := make(chan unstructured.Unstructured)
569
570 mutator := &ApplyTimeMutator{
571 Client: &fakeDynamicClient{
572 resourceInterfaceFunc: newFakeNamespaceClientFunc(getChan),
573 },
574 Mapper: testrestmapper.TestOnlyStaticRESTMapper(
575 scheme.Scheme,
576 scheme.Scheme.PrioritizedVersionsAllGroups()...,
577 ),
578 ResourceCache: tc.cache,
579 }
580
581
582 sources := tc.sources
583 go func() {
584 defer close(getChan)
585 for _, source := range sources {
586 getChan <- *source
587 }
588 }()
589
590 mutated, reason, err := mutator.Mutate(context.TODO(), tc.target)
591 if tc.errMsg != "" {
592 require.EqualError(t, err, tc.errMsg)
593 } else {
594 require.NoError(t, err)
595 }
596 require.Equal(t, tc.mutated, mutated, "unexpected mutated bool")
597 require.Equal(t, tc.reason, reason, "unexpected mutated reason")
598
599 for _, efv := range tc.expected {
600 received, found, err := object.NestedField(tc.target.Object, efv.Field...)
601 require.NoError(t, err)
602 require.True(t, found, "target field not found")
603 require.Equal(t, efv.Value, received, "unexpected target field value")
604 }
605 })
606 }
607 }
608
609 func TestValueToString(t *testing.T) {
610 tests := map[string]struct {
611 value interface{}
612 expected string
613 }{
614 "int": {
615 value: 1,
616 expected: "1",
617 },
618 "float": {
619 value: 1.2345,
620 expected: "1.2345",
621 },
622 "string": {
623 value: "nothing to see",
624 expected: "nothing to see",
625 },
626 "bool": {
627 value: false,
628 expected: "false",
629 },
630 "interface map": {
631 value: map[string]interface{}{
632 "apiVersion": "v1",
633 "kind": "Pod",
634 "metadata": map[string]interface{}{
635 "name": "pod-name",
636 "namespace": "test-namespace",
637 },
638 },
639 expected: `{"apiVersion":"v1","kind":"Pod","metadata":{"name":"pod-name","namespace":"test-namespace"}}`,
640 },
641 "interface list": {
642 value: []interface{}{
643 "x",
644 map[string]interface{}{
645 "?": nil,
646 },
647 0,
648 },
649 expected: `["x",{"?":null},0]`,
650 },
651 "string list": {
652 value: []string{
653 "x",
654 "y",
655 "z",
656 },
657 expected: `["x","y","z"]`,
658 },
659 }
660
661 for name, tc := range tests {
662 t.Run(name, func(t *testing.T) {
663 received, err := valueToString(tc.value)
664 require.NoError(t, err)
665 require.Equal(t, tc.expected, received, "unexpected result")
666 })
667 }
668 }
669
670
671 type fakeNamespaceClient struct {
672 dynamic.ResourceInterface
673 resource schema.GroupVersionResource
674 namespace string
675 getChan <-chan unstructured.Unstructured
676 }
677
678 func newFakeNamespaceClientFunc(getChan <-chan unstructured.Unstructured) func(resource schema.GroupVersionResource, namespace string) dynamic.ResourceInterface {
679 innerGetChan := getChan
680 return func(resource schema.GroupVersionResource, namespace string) dynamic.ResourceInterface {
681 return &fakeNamespaceClient{
682 resource: resource,
683 namespace: namespace,
684 getChan: innerGetChan,
685 }
686 }
687 }
688
689 func (c *fakeNamespaceClient) Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
690 obj, open := <-c.getChan
691 if !open {
692 return nil, apierrors.NewNotFound(c.resource.GroupResource(), name)
693 }
694 return &obj, nil
695 }
696
697
698 type fakeDynamicClient struct {
699 resourceInterfaceFunc func(resource schema.GroupVersionResource, namespace string) dynamic.ResourceInterface
700 }
701
702 func (c *fakeDynamicClient) Resource(resource schema.GroupVersionResource) dynamic.NamespaceableResourceInterface {
703 return &fakeDynamicResourceClient{
704 resourceInterfaceFunc: c.resourceInterfaceFunc,
705 NamespaceableResourceInterface: fake.NewSimpleDynamicClient(scheme.Scheme).Resource(resource),
706 resource: resource,
707 }
708 }
709
710 type fakeDynamicResourceClient struct {
711 dynamic.NamespaceableResourceInterface
712 resourceInterfaceFunc func(resource schema.GroupVersionResource, namespace string) dynamic.ResourceInterface
713 resource schema.GroupVersionResource
714 }
715
716 func (c *fakeDynamicResourceClient) Namespace(ns string) dynamic.ResourceInterface {
717 return c.resourceInterfaceFunc(c.resource, ns)
718 }
719
View as plain text