From 23051f0f4cba7d35d4c42c8050a48ad30672e321 Mon Sep 17 00:00:00 2001 From: Martin Hrabovcin Date: Tue, 4 Apr 2023 16:57:04 +0200 Subject: [PATCH] feat: add resource rewriter component --- cmd/serve.go | 3 +- pkg/importer/import.go | 2 +- pkg/importer/prepare.go | 26 ++++------- pkg/proxy/rewrite.go | 62 ++++++++++++++++--------- pkg/proxy/server.go | 5 +- pkg/rewriter/generated.go | 13 ++++++ pkg/rewriter/generated_test.go | 53 +++++++++++++++++++++ pkg/rewriter/multi.go | 34 ++++++++++++++ pkg/rewriter/rewriter.go | 85 ++++++++++++++++++++++++++++++++++ pkg/rewriter/rewriter_test.go | 74 +++++++++++++++++++++++++++++ 10 files changed, 313 insertions(+), 44 deletions(-) create mode 100644 pkg/rewriter/generated.go create mode 100644 pkg/rewriter/generated_test.go create mode 100644 pkg/rewriter/multi.go create mode 100644 pkg/rewriter/rewriter.go create mode 100644 pkg/rewriter/rewriter_test.go diff --git a/cmd/serve.go b/cmd/serve.go index d03a976..46925b5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -14,6 +14,7 @@ import ( "github.com/mhrabovcin/troubleshoot-live/pkg/importer" "github.com/mhrabovcin/troubleshoot-live/pkg/kubernetes" "github.com/mhrabovcin/troubleshoot-live/pkg/proxy" + "github.com/mhrabovcin/troubleshoot-live/pkg/rewriter" ) type serveOptions struct { @@ -95,7 +96,7 @@ func runServe(bundlePath string, o *serveOptions, out output.Output) error { out.Infof("Running HTTPs proxy service on: %s", proxyHTTPAddress) out.Infof("Kubeconfig path: %s", kubeconfigPath) - http.Handle("/", proxy.New(testEnv.Config, supportBundle)) + http.Handle("/", proxy.New(testEnv.Config, supportBundle, rewriter.GeneratedValues())) return http.ListenAndServe(o.proxyAddress, nil) //nolint:gosec // not a production server } diff --git a/pkg/importer/import.go b/pkg/importer/import.go index d6b9029..8044e4a 100644 --- a/pkg/importer/import.go +++ b/pkg/importer/import.go @@ -28,7 +28,7 @@ import ( // AnnotationForOriginalValue creates annotation key for given value. func AnnotationForOriginalValue(name string) string { - return fmt.Sprintf("support-bundle-live/%s", name) + return fmt.Sprintf("troubleshoot-live/%s", name) } // ImportBundle creates resources in provided API server. diff --git a/pkg/importer/prepare.go b/pkg/importer/prepare.go index 63754f0..bde8f3f 100644 --- a/pkg/importer/prepare.go +++ b/pkg/importer/prepare.go @@ -1,31 +1,21 @@ package importer import ( - "time" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/api/meta" + "github.com/mhrabovcin/troubleshoot-live/pkg/rewriter" ) // prepareForImport modifies object loaded from support bundle file in a way // that can be imported. func prepareForImport(in any) error { - obj, err := meta.Accessor(in) - if err != nil { - return err - } - - annotations := obj.GetAnnotations() - if annotations == nil { - annotations = map[string]string{} - } + // TODO(mh): inject + rr := rewriter.GeneratedValues() - if obj.GetResourceVersion() != "" { - annotations[AnnotationForOriginalValue("resourceVersion")] = obj.GetResourceVersion() - obj.SetResourceVersion("") + u, ok := in.(*unstructured.Unstructured) + if !ok { + panic("non unstructured obj") } - annotations[AnnotationForOriginalValue("creationTimestamp")] = obj.GetCreationTimestamp().Format(time.RFC3339) - obj.SetAnnotations(annotations) - - return nil + return rr.BeforeImport(u) } diff --git a/pkg/proxy/rewrite.go b/pkg/proxy/rewrite.go index 15d4251..a3fbff7 100644 --- a/pkg/proxy/rewrite.go +++ b/pkg/proxy/rewrite.go @@ -4,21 +4,28 @@ import ( "bytes" "compress/gzip" "encoding/json" + "fmt" "io" "log" "net/http" "strconv" - "time" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" - "github.com/mhrabovcin/troubleshoot-live/pkg/importer" + "github.com/mhrabovcin/troubleshoot-live/pkg/rewriter" ) -func rewriteResponseResourceFields(r *http.Response) (returnErr error) { +func proxyModifyResponse(rr rewriter.ResourceRewriter) func(*http.Response) error { + r := &resourceRewriter{rewriter: rr} + return r.rewriteResponseResourceFields +} + +type resourceRewriter struct { + rewriter rewriter.ResourceRewriter +} + +func (rr *resourceRewriter) rewriteResponseResourceFields(r *http.Response) (returnErr error) { if r.StatusCode != http.StatusOK { return nil } @@ -41,7 +48,7 @@ func rewriteResponseResourceFields(r *http.Response) (returnErr error) { // list requests. if err := json.Unmarshal(data, &list); err == nil && len(list.Items) > 0 { err := list.EachListItem(func(o runtime.Object) error { - if err := remapFields(o); err != nil { + if err := remapFields(o, rr.rewriter); err != nil { log.Println(err) } return nil @@ -63,7 +70,7 @@ func rewriteResponseResourceFields(r *http.Response) (returnErr error) { u := &unstructured.Unstructured{} if err := json.Unmarshal(data, &u); err == nil { - if err := remapFields(u); err != nil { + if err := remapFields(u, rr.rewriter); err != nil { log.Println(err) return nil } @@ -109,22 +116,33 @@ func writeResponseBody(r *http.Response, data []byte) error { return nil } -func remapFields(in runtime.Object) error { - o, err := meta.Accessor(in) - if err != nil { - return err +func remapFields(in runtime.Object, rr rewriter.ResourceRewriter) error { + if rr == nil { + return fmt.Errorf("resource rewriter missing") } - annotations := o.GetAnnotations() - if originalTime, ok := annotations[importer.AnnotationForOriginalValue("creationTimestamp")]; ok { - parsedTime, err := time.Parse(time.RFC3339, originalTime) - if err != nil { - return nil - } - o.SetCreationTimestamp(metav1.NewTime(parsedTime)) - delete(annotations, importer.AnnotationForOriginalValue("creationTimestamp")) - log.Printf("[%s] %s/%s: resource creationTimestamp modified\n", in.GetObjectKind().GroupVersionKind(), o.GetNamespace(), o.GetName()) + + u, ok := in.(*unstructured.Unstructured) + if !ok { + // TODO(mh): + return nil } - o.SetAnnotations(annotations) - return nil + return rr.BeforeServing(u) + // o, err := meta.Accessor(in) + // if err != nil { + // return err + // } + // annotations := o.GetAnnotations() + // if originalTime, ok := annotations[importer.AnnotationForOriginalValue("creationTimestamp")]; ok { + // parsedTime, err := time.Parse(time.RFC3339, originalTime) + // if err != nil { + // return nil + // } + // o.SetCreationTimestamp(metav1.NewTime(parsedTime)) + // delete(annotations, importer.AnnotationForOriginalValue("creationTimestamp")) + // log.Printf("[%s] %s/%s: resource creationTimestamp modified\n", in.GetObjectKind().GroupVersionKind(), o.GetNamespace(), o.GetName()) + // } + // o.SetAnnotations(annotations) + + // return nil } diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go index 32a11f9..78c32d3 100644 --- a/pkg/proxy/server.go +++ b/pkg/proxy/server.go @@ -8,15 +8,16 @@ import ( "k8s.io/client-go/rest" "github.com/mhrabovcin/troubleshoot-live/pkg/bundle" + "github.com/mhrabovcin/troubleshoot-live/pkg/rewriter" ) // New create new proxy handler that can be used by HTTP library. -func New(cfg *rest.Config, b bundle.Bundle) http.Handler { +func New(cfg *rest.Config, b bundle.Bundle, rr rewriter.ResourceRewriter) http.Handler { proxyHandler, err := ReverseProxyForAPIServerHandler(cfg) if err != nil { log.Fatalln(err) } - proxyHandler.ModifyResponse = rewriteResponseResourceFields + proxyHandler.ModifyResponse = proxyModifyResponse(rr) r := mux.NewRouter() r.Handle("/api/v1/namespaces/{namespace}/pods/{pod}/log", LogsHandler(b)) diff --git a/pkg/rewriter/generated.go b/pkg/rewriter/generated.go new file mode 100644 index 0000000..e6f817c --- /dev/null +++ b/pkg/rewriter/generated.go @@ -0,0 +1,13 @@ +package rewriter + +// GeneratedValues removes generated values. +// See: https://kubernetes.io/docs/reference/using-api/api-concepts/#generated-values +func GeneratedValues() ResourceRewriter { + return Multi( + RemoveField("metadata", "generateName"), + RemoveField("metadata", "creationTimestamp"), + RemoveField("metadata", "deletionTimestamp"), + RemoveField("metadata", "uid"), + RemoveField("metadata", "resourceVersion"), + ) +} diff --git a/pkg/rewriter/generated_test.go b/pkg/rewriter/generated_test.go new file mode 100644 index 0000000..0b2db66 --- /dev/null +++ b/pkg/rewriter/generated_test.go @@ -0,0 +1,53 @@ +package rewriter + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestGeneratedValues(t *testing.T) { + r := GeneratedValues() + timestamp := metav1.NewTime(time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "generated-", + CreationTimestamp: timestamp, + DeletionTimestamp: ×tamp, + UID: types.UID("1000"), + ResourceVersion: "2000", + }, + } + pod = testRewriterBeforeImport(t, r, pod) + assert.Empty(t, pod.GetGenerateName()) + assert.Empty(t, pod.GetCreationTimestamp()) + assert.Empty(t, pod.GetDeletionTimestamp()) + assert.Empty(t, pod.GetUID()) + assert.Empty(t, pod.GetResourceVersion()) + + expectedFields := map[string]string{ + "generateName": "generated-", + "creationTimestamp": "2023-01-01T00:00:00Z", + "deletionTimestamp": "2023-01-01T00:00:00Z", + "uid": "1000", + "resourceVersion": "2000", + } + for k, v := range expectedFields { + fieldName := fmt.Sprintf("metadata.%s", k) + annotation := annotationForOriginalValue(fieldName) + assert.Contains(t, pod.GetAnnotations(), annotation, "annotation %q missing", fieldName) + assert.Equal(t, v, pod.GetAnnotations()[annotation], "wrong value stored in %q", fieldName) + } + + pod = testRewriterBeforeServing(t, r, pod) + assert.Equal(t, "generated-", pod.GetGenerateName()) + assert.Equal(t, timestamp.Format(time.RFC3339), pod.GetCreationTimestamp().In(time.UTC).Format(time.RFC3339)) + assert.Equal(t, timestamp.Format(time.RFC3339), pod.GetDeletionTimestamp().In(time.UTC).Format(time.RFC3339)) + assert.Equal(t, types.UID("1000"), pod.GetUID()) + assert.Equal(t, "2000", pod.GetResourceVersion()) +} diff --git a/pkg/rewriter/multi.go b/pkg/rewriter/multi.go new file mode 100644 index 0000000..5c91f41 --- /dev/null +++ b/pkg/rewriter/multi.go @@ -0,0 +1,34 @@ +package rewriter + +import "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + +type multiRewriter struct { + rewriters []ResourceRewriter +} + +var _ ResourceRewriter = (*multiRewriter)(nil) + +// Multi executes serially multiple rewriters. +func Multi(rewriters ...ResourceRewriter) ResourceRewriter { + return &multiRewriter{ + rewriters: rewriters, + } +} + +func (r *multiRewriter) BeforeImport(u *unstructured.Unstructured) error { + for _, rewriter := range r.rewriters { + if err := rewriter.BeforeImport(u); err != nil { + return err + } + } + return nil +} + +func (r *multiRewriter) BeforeServing(u *unstructured.Unstructured) error { + for _, rewriter := range r.rewriters { + if err := rewriter.BeforeServing(u); err != nil { + return err + } + } + return nil +} diff --git a/pkg/rewriter/rewriter.go b/pkg/rewriter/rewriter.go new file mode 100644 index 0000000..ff416ec --- /dev/null +++ b/pkg/rewriter/rewriter.go @@ -0,0 +1,85 @@ +package rewriter + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// annotationForOriginalValue creates annotation key for given value. +func annotationForOriginalValue(name string) string { + return fmt.Sprintf("troubleshoot-live/%s", name) +} + +// ResourceRewriter prepares object for saving on import and rewrites the object +// before its returned back from proxy server. +type ResourceRewriter interface { + // BeforeImport is invoked when object is created in API server. + BeforeImport(u *unstructured.Unstructured) error + + // BeforeServing is applied when object passes proxy (via List or Get request). + BeforeServing(u *unstructured.Unstructured) error +} + +var _ ResourceRewriter = (*removeField)(nil) + +// RemoveField removes a field from original object. This should be used for metadata +// fields that are generated by API server on write. +func RemoveField(path ...string) ResourceRewriter { + return &removeField{ + fieldPath: path, + } +} + +type removeField struct { + fieldPath []string +} + +func (r *removeField) annotationName() string { + return annotationForOriginalValue(strings.Join(r.fieldPath, ".")) +} + +func (r *removeField) BeforeImport(u *unstructured.Unstructured) error { + s, ok, err := unstructured.NestedString(u.Object, r.fieldPath...) + if err != nil { + return err + } + + if !ok { + return nil + } + + unstructured.RemoveNestedField(u.Object, r.fieldPath...) + return addAnnotation(u, r.annotationName(), s) +} + +func (r *removeField) BeforeServing(u *unstructured.Unstructured) error { + value, ok, err := unstructured.NestedString(u.Object, "metadata", "annotations", r.annotationName()) + if err != nil { + return err + } + if !ok { + return nil + } + + if err := unstructured.SetNestedField(u.Object, value, r.fieldPath...); err != nil { + return err + } + unstructured.RemoveNestedField(u.Object, "metadata", "annotations", r.annotationName()) + return nil +} + +func addAnnotation(u *unstructured.Unstructured, key, value string) error { + annotations, ok, err := unstructured.NestedStringMap(u.Object, "metadata", "annotations") + if err != nil { + return err + } + + if !ok { + annotations = map[string]string{} + } + + annotations[key] = value + return unstructured.SetNestedStringMap(u.Object, annotations, "metadata", "annotations") +} diff --git a/pkg/rewriter/rewriter_test.go b/pkg/rewriter/rewriter_test.go new file mode 100644 index 0000000..d6ecef9 --- /dev/null +++ b/pkg/rewriter/rewriter_test.go @@ -0,0 +1,74 @@ +package rewriter + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "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/types" +) + +func testRewriterBeforeImport[R any](t *testing.T, r ResourceRewriter, resource R) R { + t.Helper() + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(resource) + u := &unstructured.Unstructured{Object: obj} + + require.NoError(t, err) + assert.NoError(t, r.BeforeImport(u)) + + assert.NoError(t, runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, resource)) + return resource +} + +func testRewriterBeforeServing[R any](t *testing.T, r ResourceRewriter, resource R) R { + t.Helper() + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(resource) + u := &unstructured.Unstructured{Object: obj} + + require.NoError(t, err) + assert.NoError(t, r.BeforeServing(u)) + + assert.NoError(t, runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, resource)) + return resource +} + +func TestRemoveField(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "1000", + UID: types.UID("1000"), + }, + } + removeResourceVersion := RemoveField("metadata", "resourceVersion") + removeUID := RemoveField("metadata", "uid") + + pod = testRewriterBeforeImport(t, removeResourceVersion, pod) + pod = testRewriterBeforeImport(t, removeUID, pod) + + assert.Contains(t, pod.GetAnnotations(), annotationForOriginalValue("metadata.resourceVersion")) + assert.Contains(t, pod.GetAnnotations(), annotationForOriginalValue("metadata.uid")) + assert.Empty(t, pod.GetResourceVersion()) + assert.Empty(t, pod.GetUID()) + + pod = testRewriterBeforeServing(t, removeResourceVersion, pod) + pod = testRewriterBeforeServing(t, removeUID, pod) + assert.Equal(t, pod.GetResourceVersion(), "1000") + assert.Equal(t, pod.GetUID(), types.UID("1000")) +} + +func TestRemoveField_SkipWithoutAnnotation(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + ResourceVersion: "1000", + }, + } + removeResourceVersion := RemoveField("metadata", "resourceVersion") + pod = testRewriterBeforeServing(t, removeResourceVersion, pod) + assert.Equal(t, "1000", pod.GetResourceVersion()) +}