cli-utils/pkg/apply/mutator/apply_time_mutator_test.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)
}