From 7a17600fbdf6f12ae24234d940435f68a9487c3b Mon Sep 17 00:00:00 2001 From: justinsb Date: Tue, 16 Apr 2024 22:16:31 -0400 Subject: [PATCH] mockkubeapiserver: Support stringData when creating a secret This is an edge case in the kube apiserver, but there is special handling for the stringData field of a secret, that is mapped to base64 data. Co-authored-by: Tomas Aschan <1550920+tomasaschan@users.noreply.github.com> --- dev/update-golden | 5 +- mockkubeapiserver/patchresource.go | 8 ++ mockkubeapiserver/postresource.go | 48 +++++++++ mockkubeapiserver/storage/hook.go | 1 + mockkubeapiserver/tests/kubeapiserver_test.go | 99 +++++++++++++++++++ .../tests/testdata/configmap/expected.yaml | 44 +++++++++ .../tests/testdata/configmap/manifest.yaml | 14 +++ .../testdata/secrets_stringdata/expected.yaml | 44 +++++++++ .../testdata/secrets_stringdata/manifest.yaml | 16 +++ 9 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 mockkubeapiserver/tests/kubeapiserver_test.go create mode 100644 mockkubeapiserver/tests/testdata/configmap/expected.yaml create mode 100644 mockkubeapiserver/tests/testdata/configmap/manifest.yaml create mode 100644 mockkubeapiserver/tests/testdata/secrets_stringdata/expected.yaml create mode 100644 mockkubeapiserver/tests/testdata/secrets_stringdata/manifest.yaml diff --git a/dev/update-golden b/dev/update-golden index 2daf31bd..04099ce7 100755 --- a/dev/update-golden +++ b/dev/update-golden @@ -19,4 +19,7 @@ chmod +x bin/kubectl export PATH="${REPO_ROOT}/bin:$PATH" echo "kubectl version is $(kubectl version --client)" -WRITE_GOLDEN_OUTPUT=1 go test -count=1 -v ./... \ No newline at end of file +WRITE_GOLDEN_OUTPUT=1 go test -count=1 -v ./... + +cd "${REPO_ROOT}/mockkubeapiserver" +WRITE_GOLDEN_OUTPUT=1 go test -count=1 -v ./... diff --git a/mockkubeapiserver/patchresource.go b/mockkubeapiserver/patchresource.go index 8853302d..467c760f 100644 --- a/mockkubeapiserver/patchresource.go +++ b/mockkubeapiserver/patchresource.go @@ -85,6 +85,10 @@ func (req *patchResource) Run(ctx context.Context, s *MockKubeAPIServer) error { patched.SetGeneration(1) } + if err := beforeObjectCreation(ctx, patched); err != nil { + return err + } + if err := resource.CreateObject(ctx, id, patched); err != nil { return err } @@ -130,6 +134,10 @@ func (req *patchResource) Run(ctx context.Context, s *MockKubeAPIServer) error { klog.Infof("skipping write, object not changed") return req.writeResponse(existingObj) } else { + if err := beforeObjectCreation(ctx, updated); err != nil { + return err + } + if resource.SetsGeneration() { specIsSame := reflect.DeepEqual(existingObj.Object["spec"], updated.Object["spec"]) if !specIsSame { diff --git a/mockkubeapiserver/postresource.go b/mockkubeapiserver/postresource.go index 1b2ddda5..a531dfe7 100644 --- a/mockkubeapiserver/postresource.go +++ b/mockkubeapiserver/postresource.go @@ -18,6 +18,7 @@ package mockkubeapiserver import ( "context" + "encoding/base64" "fmt" "io" "net/http" @@ -73,8 +74,55 @@ func (req *postResource) Run(ctx context.Context, s *MockKubeAPIServer) error { obj.SetGeneration(1) } + if err := beforeObjectCreation(ctx, obj); err != nil { + return err + } + if err := resource.CreateObject(ctx, id, obj); err != nil { return err } return req.writeResponse(obj) } + +var secretGVK = schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Secret", +} + +func beforeObjectCreation(ctx context.Context, obj *unstructured.Unstructured) error { + gvk := obj.GroupVersionKind() + if gvk == secretGVK { + return beforeSecretCreation(ctx, obj) + } + return nil +} + +func beforeSecretCreation(ctx context.Context, obj *unstructured.Unstructured) error { + // If there is any stringData, merge it into data + stringData, _, err := unstructured.NestedStringMap(obj.Object, "stringData") + if err != nil { + return fmt.Errorf("getting Secret stringData: %w", err) + } + if len(stringData) == 0 { + return nil + } + + // Get a copy of data + data, _, err := unstructured.NestedStringMap(obj.Object, "data") + if err != nil { + return fmt.Errorf("getting Secret data: %w", err) + } + if data == nil { + data = make(map[string]string) + } + for k, v := range stringData { + data[k] = base64.StdEncoding.EncodeToString([]byte(v)) + } + if err := unstructured.SetNestedStringMap(obj.Object, data, "data"); err != nil { + return fmt.Errorf("setting Secret data: %w", err) + } + unstructured.RemoveNestedField(obj.Object, "stringData") + + return nil +} diff --git a/mockkubeapiserver/storage/hook.go b/mockkubeapiserver/storage/hook.go index 8e85bbb1..c0ea766e 100644 --- a/mockkubeapiserver/storage/hook.go +++ b/mockkubeapiserver/storage/hook.go @@ -2,5 +2,6 @@ package storage // A Hook implements a lightweight watch on all objects, intended for use to mock controller behaviour. type Hook interface { + // OnWatchEvent is called whenever a watch event is created OnWatchEvent(ev *WatchEvent) } diff --git a/mockkubeapiserver/tests/kubeapiserver_test.go b/mockkubeapiserver/tests/kubeapiserver_test.go new file mode 100644 index 00000000..5f998be3 --- /dev/null +++ b/mockkubeapiserver/tests/kubeapiserver_test.go @@ -0,0 +1,99 @@ +package applier + +import ( + "context" + "net/http" + "path/filepath" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/klog/v2" + "sigs.k8s.io/kubebuilder-declarative-pattern/mockkubeapiserver" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/patterns/declarative/pkg/manifest" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/restmapper" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/test/httprecorder" + "sigs.k8s.io/kubebuilder-declarative-pattern/pkg/test/testharness" +) + +func TestGoldenTests(t *testing.T) { + testharness.RunGoldenTests(t, "testdata", func(h *testharness.Harness, testdir string) { + ctx := context.Background() + + k8s, err := mockkubeapiserver.NewMockKubeAPIServer(":0") + if err != nil { + t.Fatalf("error building mock kube-apiserver: %v", err) + } + defer func() { + if err := k8s.Stop(); err != nil { + t.Fatalf("error closing mock kube-apiserver: %v", err) + } + }() + + addr, err := k8s.StartServing() + if err != nil { + t.Errorf("error starting mock kube-apiserver: %v", err) + } + + klog.Infof("mock kubeapiserver will listen on %v", addr) + + var requestLog httprecorder.RequestLog + wrapTransport := func(rt http.RoundTripper) http.RoundTripper { + return httprecorder.NewRecorder(rt, &requestLog) + } + restConfig := &rest.Config{ + Host: addr.String(), + WrapTransport: wrapTransport, + } + + httpClient := &http.Client{} + + httpClient.Transport = wrapTransport(http.DefaultTransport) + + // var apiserverRequestLog httprecorder.RequestLog + // if interceptHTTPServer { + // k8s.AddHook(&logKubeRequestsHook{log: &apiserverRequestLog}) + // } + + p := filepath.Join(testdir, "manifest.yaml") + manifestYAML := string(h.MustReadFile(p)) + objects, err := manifest.ParseObjects(ctx, manifestYAML) + if err != nil { + t.Errorf("error parsing manifest %q: %v", p, err) + } + + restMapper, err := restmapper.NewForTest(restConfig) + if err != nil { + t.Fatalf("error from controllerrestmapper.NewForTest: %v", err) + } + + dynamicClient, err := dynamic.NewForConfigAndClient(restConfig, httpClient) + if err != nil { + t.Fatalf("building dynamic client: %v", err) + } + for _, obj := range objects.GetItems() { + gvk := obj.GroupVersionKind() + restMapping, err := restMapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + t.Errorf("error getting restmapping for %v: %v", gvk, err) + } + + var applyOptions metav1.ApplyOptions + applyOptions.FieldManager = "test" + resource := dynamicClient.Resource(restMapping.Resource).Namespace(obj.GetNamespace()) + + if _, err := resource.Apply(ctx, obj.GetName(), obj.UnstructuredObject(), applyOptions); err != nil { + t.Fatalf("error applying resource %v: %v", gvk, err) + } + } + + t.Logf("replacing old url prefix %q", "http://"+restConfig.Host) + requestLog.ReplaceURLPrefix("http://"+restConfig.Host, "http://kube-apiserver") + requestLog.RemoveUserAgent() + requestLog.SortGETs() + + requests := requestLog.FormatHTTP() + h.CompareGoldenFile(filepath.Join(testdir, "expected.yaml"), requests) + }) +} diff --git a/mockkubeapiserver/tests/testdata/configmap/expected.yaml b/mockkubeapiserver/tests/testdata/configmap/expected.yaml new file mode 100644 index 00000000..a5f0aa39 --- /dev/null +++ b/mockkubeapiserver/tests/testdata/configmap/expected.yaml @@ -0,0 +1,44 @@ +GET http://kube-apiserver/api/v1 +Accept: application/json, */* + +200 OK +Cache-Control: no-cache, private +Content-Length: 1820 +Content-Type: application/json +Date: (removed) + +{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"v1","resources":[{"name":"componentstatuses","singularName":"","namespaced":false,"version":"v1","kind":"ComponentStatus","verbs":null},{"name":"configmaps","singularName":"","namespaced":true,"version":"v1","kind":"ConfigMap","verbs":null},{"name":"endpoints","singularName":"","namespaced":true,"version":"v1","kind":"Endpoints","verbs":null},{"name":"events","singularName":"","namespaced":true,"version":"v1","kind":"Event","verbs":null},{"name":"limitranges","singularName":"","namespaced":true,"version":"v1","kind":"LimitRange","verbs":null},{"name":"namespaces","singularName":"","namespaced":false,"version":"v1","kind":"Namespace","verbs":null},{"name":"nodes","singularName":"","namespaced":false,"version":"v1","kind":"Node","verbs":null},{"name":"persistentvolumes","singularName":"","namespaced":false,"version":"v1","kind":"PersistentVolume","verbs":null},{"name":"persistentvolumeclaims","singularName":"","namespaced":true,"version":"v1","kind":"PersistentVolumeClaim","verbs":null},{"name":"pods","singularName":"","namespaced":true,"version":"v1","kind":"Pod","verbs":null},{"name":"podtemplates","singularName":"","namespaced":true,"version":"v1","kind":"PodTemplate","verbs":null},{"name":"replicationcontrollers","singularName":"","namespaced":true,"version":"v1","kind":"ReplicationController","verbs":null},{"name":"resourcequotas","singularName":"","namespaced":true,"version":"v1","kind":"ResourceQuota","verbs":null},{"name":"secrets","singularName":"","namespaced":true,"version":"v1","kind":"Secret","verbs":null},{"name":"services","singularName":"","namespaced":true,"version":"v1","kind":"Service","verbs":null},{"name":"serviceaccounts","singularName":"","namespaced":true,"version":"v1","kind":"ServiceAccount","verbs":null}]} + +--- + +PATCH http://kube-apiserver/api/v1/namespaces/default?fieldManager=test&force=false +Accept: application/json +Content-Type: application/apply-patch+yaml + +{"apiVersion":"v1","kind":"Namespace","metadata":{"name":"default"}} + + +200 OK +Cache-Control: no-cache, private +Content-Length: 294 +Content-Type: application/json +Date: (removed) + +{"apiVersion":"v1","kind":"Namespace","metadata":{"creationTimestamp":"2022-01-01T00:00:00Z","labels":{"kubernetes.io/metadata.name":"default"},"name":"default","resourceVersion":"1","uid":"00000000-0000-0000-0000-000000000001"},"spec":{"finalizers":["kubernetes"]},"status":{"phase":"Active"}} + +--- + +PATCH http://kube-apiserver/api/v1/namespaces/default/configmaps/config?fieldManager=test&force=false +Accept: application/json +Content-Type: application/apply-patch+yaml + +{"apiVersion":"v1","data":{"foo":"bar"},"kind":"ConfigMap","metadata":{"name":"config","namespace":"default"}} + + +200 OK +Cache-Control: no-cache, private +Content-Length: 220 +Content-Type: application/json +Date: (removed) + +{"apiVersion":"v1","data":{"foo":"bar"},"kind":"ConfigMap","metadata":{"creationTimestamp":"2022-01-01T00:00:01Z","name":"config","namespace":"default","resourceVersion":"2","uid":"00000000-0000-0000-0000-000000000002"}} diff --git a/mockkubeapiserver/tests/testdata/configmap/manifest.yaml b/mockkubeapiserver/tests/testdata/configmap/manifest.yaml new file mode 100644 index 00000000..adad3779 --- /dev/null +++ b/mockkubeapiserver/tests/testdata/configmap/manifest.yaml @@ -0,0 +1,14 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: default + +--- + +kind: ConfigMap +apiVersion: v1 +metadata: + name: config + namespace: default +data: + foo: bar diff --git a/mockkubeapiserver/tests/testdata/secrets_stringdata/expected.yaml b/mockkubeapiserver/tests/testdata/secrets_stringdata/expected.yaml new file mode 100644 index 00000000..07f22742 --- /dev/null +++ b/mockkubeapiserver/tests/testdata/secrets_stringdata/expected.yaml @@ -0,0 +1,44 @@ +GET http://kube-apiserver/api/v1 +Accept: application/json, */* + +200 OK +Cache-Control: no-cache, private +Content-Length: 1820 +Content-Type: application/json +Date: (removed) + +{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"v1","resources":[{"name":"componentstatuses","singularName":"","namespaced":false,"version":"v1","kind":"ComponentStatus","verbs":null},{"name":"configmaps","singularName":"","namespaced":true,"version":"v1","kind":"ConfigMap","verbs":null},{"name":"endpoints","singularName":"","namespaced":true,"version":"v1","kind":"Endpoints","verbs":null},{"name":"events","singularName":"","namespaced":true,"version":"v1","kind":"Event","verbs":null},{"name":"limitranges","singularName":"","namespaced":true,"version":"v1","kind":"LimitRange","verbs":null},{"name":"namespaces","singularName":"","namespaced":false,"version":"v1","kind":"Namespace","verbs":null},{"name":"nodes","singularName":"","namespaced":false,"version":"v1","kind":"Node","verbs":null},{"name":"persistentvolumes","singularName":"","namespaced":false,"version":"v1","kind":"PersistentVolume","verbs":null},{"name":"persistentvolumeclaims","singularName":"","namespaced":true,"version":"v1","kind":"PersistentVolumeClaim","verbs":null},{"name":"pods","singularName":"","namespaced":true,"version":"v1","kind":"Pod","verbs":null},{"name":"podtemplates","singularName":"","namespaced":true,"version":"v1","kind":"PodTemplate","verbs":null},{"name":"replicationcontrollers","singularName":"","namespaced":true,"version":"v1","kind":"ReplicationController","verbs":null},{"name":"resourcequotas","singularName":"","namespaced":true,"version":"v1","kind":"ResourceQuota","verbs":null},{"name":"secrets","singularName":"","namespaced":true,"version":"v1","kind":"Secret","verbs":null},{"name":"services","singularName":"","namespaced":true,"version":"v1","kind":"Service","verbs":null},{"name":"serviceaccounts","singularName":"","namespaced":true,"version":"v1","kind":"ServiceAccount","verbs":null}]} + +--- + +PATCH http://kube-apiserver/api/v1/namespaces/default?fieldManager=test&force=false +Accept: application/json +Content-Type: application/apply-patch+yaml + +{"apiVersion":"v1","kind":"Namespace","metadata":{"name":"default"}} + + +200 OK +Cache-Control: no-cache, private +Content-Length: 294 +Content-Type: application/json +Date: (removed) + +{"apiVersion":"v1","kind":"Namespace","metadata":{"creationTimestamp":"2022-01-01T00:00:00Z","labels":{"kubernetes.io/metadata.name":"default"},"name":"default","resourceVersion":"1","uid":"00000000-0000-0000-0000-000000000001"},"spec":{"finalizers":["kubernetes"]},"status":{"phase":"Active"}} + +--- + +PATCH http://kube-apiserver/api/v1/namespaces/default/secrets/secret?fieldManager=test&force=false +Accept: application/json +Content-Type: application/apply-patch+yaml + +{"apiVersion":"v1","kind":"Secret","metadata":{"name":"secret","namespace":"default"},"stringData":{"foo":"bar","foo2":"bar2"},"type":"Opaque"} + + +200 OK +Cache-Control: no-cache, private +Content-Length: 252 +Content-Type: application/json +Date: (removed) + +{"apiVersion":"v1","data":{"foo":"YmFy","foo2":"YmFyMg=="},"kind":"Secret","metadata":{"creationTimestamp":"2022-01-01T00:00:01Z","name":"secret","namespace":"default","resourceVersion":"2","uid":"00000000-0000-0000-0000-000000000002"},"type":"Opaque"} diff --git a/mockkubeapiserver/tests/testdata/secrets_stringdata/manifest.yaml b/mockkubeapiserver/tests/testdata/secrets_stringdata/manifest.yaml new file mode 100644 index 00000000..1836f7ed --- /dev/null +++ b/mockkubeapiserver/tests/testdata/secrets_stringdata/manifest.yaml @@ -0,0 +1,16 @@ +kind: Namespace +apiVersion: v1 +metadata: + name: default + +--- + +kind: Secret +apiVersion: v1 +metadata: + name: secret + namespace: default +type: Opaque +stringData: + foo: bar + foo2: bar2 \ No newline at end of file