From c76edaaad500c55798c634d8cf6e28fabc233b3c Mon Sep 17 00:00:00 2001 From: Todd Treece Date: Tue, 10 Jan 2023 08:02:19 -0500 Subject: [PATCH 1/3] add support for CRDs --- pkg/api/api.go | 25 +++++++++++++++++- pkg/resources/resources.go | 53 ++++++++++++++++++-------------------- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 5fec3de..28fa35e 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -8,8 +8,11 @@ import ( "github.com/grafana/xk6-kubernetes/pkg/helpers" "github.com/grafana/xk6-kubernetes/pkg/resources" + "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" ) // Kubernetes defines an interface that extends kubernetes interface[k8s.io/client-go/kubernetes.Interface] adding @@ -28,23 +31,38 @@ type KubernetesConfig struct { Config *rest.Config // Client is a pre-configured dynamic client. If provided, the rest config is not used Client dynamic.Interface + REST rest.Interface } // kubernetes holds references to implementation of the Kubernetes interface type kubernetes struct { ctx context.Context *resources.Client + *restmapper.DeferredDiscoveryRESTMapper } // NewFromConfig returns a Kubernetes instance func NewFromConfig(c KubernetesConfig) (Kubernetes, error) { + var ( + err error + discoveryClient *discovery.DiscoveryClient + ) + ctx := c.Context if ctx == nil { ctx = context.TODO() } + if c.REST != nil { + discoveryClient = discovery.NewDiscoveryClient(c.REST) + } else if c.Config != nil { + discoveryClient, err = discovery.NewDiscoveryClientForConfig(c.Config) + if err != nil { + return nil, err + } + } + var client *resources.Client - var err error if c.Client != nil { client = resources.NewFromClient(ctx, c.Client) } else { @@ -54,6 +72,11 @@ func NewFromConfig(c KubernetesConfig) (Kubernetes, error) { } } + if discoveryClient != nil { + mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)) + client.WithMapper(mapper) + } + return &kubernetes{ ctx: ctx, Client: client, diff --git a/pkg/resources/resources.go b/pkg/resources/resources.go index 5dfa5b0..7f4fcf5 100644 --- a/pkg/resources/resources.go +++ b/pkg/resources/resources.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/xk6-kubernetes/pkg/utils" + "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" @@ -16,6 +17,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer/yaml" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" ) // UnstructuredOperations defines generic functions that operate on any kind of Kubernetes object @@ -55,6 +57,7 @@ type structured struct { type Client struct { ctx context.Context dynamic dynamic.Interface + mapper *restmapper.DeferredDiscoveryRESTMapper serializer runtime.Serializer } @@ -77,34 +80,28 @@ func NewFromClient(ctx context.Context, dynamic dynamic.Interface) *Client { } } +func (c *Client) WithMapper(mapper *restmapper.DeferredDiscoveryRESTMapper) *Client { + c.mapper = mapper + return c +} + // getResource maps kinds to api resources -func (c *Client) getResource(kind string, namespace string) (dynamic.ResourceInterface, error) { - kindMapping := map[string]schema.GroupVersionResource{ - "ConfigMap": {Group: "", Version: "v1", Resource: "configmaps"}, - "Deployment": {Group: "apps", Version: "v1", Resource: "deployments"}, - "Endpoint": {Group: "", Version: "v1", Resource: "endpoints"}, - "Ingress": {Group: "networking.k8s.io", Version: "v1", Resource: "ingresses"}, - "Job": {Group: "batch", Version: "v1", Resource: "jobs"}, - "PersistentVolume": {Group: "", Version: "v1", Resource: "persistentvolumes"}, - "PersistentVolumeClaim": {Group: "", Version: "v1", Resource: "persistentvolumeclaims"}, - "Pod": {Group: "", Version: "v1", Resource: "pods"}, - "Namespace": {Group: "", Version: "v1", Resource: "namespaces"}, - "Node": {Group: "", Version: "v1", Resource: "nodes"}, - "Secret": {Group: "", Version: "v1", Resource: "secrets"}, - "Service": {Group: "", Version: "v1", Resource: "services"}, - "StatefulSet": {Group: "apps", Version: "v1", Resource: "statefulsets"}, - } - - gvr, found := kindMapping[kind] - if !found { - return nil, fmt.Errorf("unknown kind: '%s'", kind) +func (c *Client) getResource(kind string, namespace string, versions ...string) (dynamic.ResourceInterface, error) { + gk := schema.ParseGroupKind(kind) + if c.mapper == nil { + return nil, fmt.Errorf("REST mapper not initialized") + } + + mapping, err := c.mapper.RESTMapping(gk, versions...) + if err != nil { + return nil, err } var resource dynamic.ResourceInterface - if kind == "Namespace" { - resource = c.dynamic.Resource(gvr) + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + resource = c.dynamic.Resource(mapping.Resource).Namespace(namespace) } else { - resource = c.dynamic.Resource(gvr).Namespace(namespace) + resource = c.dynamic.Resource(mapping.Resource) } return resource, nil @@ -115,7 +112,7 @@ func (c *Client) Apply(manifest string) error { uObj := &unstructured.Unstructured{} _, gvk, err := c.serializer.Decode([]byte(manifest), nil, uObj) if err != nil { - return err + return fmt.Errorf("failed to decode manifest: %w", err) } name := uObj.GetName() @@ -124,9 +121,9 @@ func (c *Client) Apply(manifest string) error { namespace = "default" } - resource, err := c.getResource(gvk.Kind, namespace) + resource, err := c.getResource(gvk.GroupKind().String(), namespace, gvk.Version) if err != nil { - return err + return fmt.Errorf("failed to get resource: %w", err) } _, err = resource.Apply( @@ -152,7 +149,7 @@ func (c *Client) Create(obj map[string]interface{}) (map[string]interface{}, err namespace = "default" } - resource, err := c.getResource(gvk.Kind, namespace) + resource, err := c.getResource(gvk.GroupKind().String(), namespace) if err != nil { return nil, err } @@ -227,7 +224,7 @@ func (c *Client) Update(obj map[string]interface{}) (map[string]interface{}, err if namespace == "" { namespace = "default" } - resource, err := c.getResource(gvk.Kind, namespace) + resource, err := c.getResource(gvk.GroupKind().String(), namespace) if err != nil { return nil, err } From 8c1b732df93171696d0b7ded6ca0e5f924dbe0da Mon Sep 17 00:00:00 2001 From: Todd Treece Date: Tue, 10 Jan 2023 23:40:37 -0500 Subject: [PATCH 2/3] update test coverage --- internal/testutils/fake.go | 41 +++++++++++++++++++++++++++++++++ kubernetes.go | 6 ++++- kubernetes_test.go | 1 + pkg/api/api.go | 21 ++++++++--------- pkg/helpers/pods_test.go | 2 +- pkg/helpers/services_test.go | 4 ++-- pkg/resources/resources.go | 7 +++--- pkg/resources/resources_test.go | 14 +++++------ 8 files changed, 69 insertions(+), 27 deletions(-) diff --git a/internal/testutils/fake.go b/internal/testutils/fake.go index 40edd9d..4a7863b 100644 --- a/internal/testutils/fake.go +++ b/internal/testutils/fake.go @@ -1,7 +1,11 @@ package testutils import ( + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/kubernetes/fake" ) @@ -16,3 +20,40 @@ func NewFakeDynamic(objs ...runtime.Object) (*dynamicfake.FakeDynamicClient, err return dynamicfake.NewSimpleDynamicClient(scheme, objs...), nil } + +type FakeRESTMapper struct { + meta.RESTMapper +} + +func (f *FakeRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) { + kindMapping := map[string]schema.GroupVersionResource{ + "ConfigMap": {Group: "", Version: "v1", Resource: "configmaps"}, + "Deployment": {Group: "apps", Version: "v1", Resource: "deployments"}, + "Endpoint": {Group: "", Version: "v1", Resource: "endpoints"}, + "Ingress": {Group: "networking.k8s.io", Version: "v1", Resource: "ingresses"}, + "Job": {Group: "batch", Version: "v1", Resource: "jobs"}, + "PersistentVolume": {Group: "", Version: "v1", Resource: "persistentvolumes"}, + "PersistentVolumeClaim": {Group: "", Version: "v1", Resource: "persistentvolumeclaims"}, + "Pod": {Group: "", Version: "v1", Resource: "pods"}, + "Namespace": {Group: "", Version: "v1", Resource: "namespaces"}, + "Node": {Group: "", Version: "v1", Resource: "nodes"}, + "Secret": {Group: "", Version: "v1", Resource: "secrets"}, + "Service": {Group: "", Version: "v1", Resource: "services"}, + "StatefulSet": {Group: "apps", Version: "v1", Resource: "statefulsets"}, + } + + gvr, found := kindMapping[gk.Kind] + if !found { + return nil, fmt.Errorf("unknown kind: '%s'", gk.Kind) + } + scope := meta.RESTScopeNamespace + if gk.Kind == "Namespace" { + scope = meta.RESTScopeRoot + } + + return &meta.RESTMapping{ + Resource: gvr, + GroupVersionKind: gvr.GroupVersion().WithKind(gk.Kind), + Scope: scope, + }, nil +} diff --git a/kubernetes.go b/kubernetes.go index 642ffad..af114a1 100644 --- a/kubernetes.go +++ b/kubernetes.go @@ -25,6 +25,7 @@ import ( "github.com/grafana/xk6-kubernetes/pkg/services" "go.k6.io/k6/js/modules" + "k8s.io/apimachinery/pkg/api/meta" metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" @@ -48,6 +49,8 @@ type ModuleInstance struct { clientset kubernetes.Interface // dynamic enables injection of a fake dynamic client for unit tests dynamic dynamic.Interface + // mapper enables injection of a fake RESTMapper for unit tests + mapper meta.RESTMapper } // Kubernetes is the exported object used within JavaScript. @@ -141,10 +144,11 @@ func (mi *ModuleInstance) newClient(c goja.ConstructorCall) *goja.Object { } obj.Kubernetes = k8s } else { - // Pre-configured dynamic client is injected for unit testing + // Pre-configured dynamic client and RESTMapper are injected for unit testing k8s, err := api.NewFromConfig( api.KubernetesConfig{ Client: mi.dynamic, + Mapper: mi.mapper, Context: ctx, }, ) diff --git a/kubernetes_test.go b/kubernetes_test.go index 039eea4..bb7e459 100644 --- a/kubernetes_test.go +++ b/kubernetes_test.go @@ -56,6 +56,7 @@ func setupTestEnv(t *testing.T, objs ...runtime.Object) *goja.Runtime { t.Errorf("unexpected error creating fake client %v", err) } m.dynamic = dynamic + m.mapper = &localutils.FakeRESTMapper{} return rt } diff --git a/pkg/api/api.go b/pkg/api/api.go index 28fa35e..f20ecc5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/xk6-kubernetes/pkg/helpers" "github.com/grafana/xk6-kubernetes/pkg/resources" + "k8s.io/apimachinery/pkg/api/meta" "k8s.io/client-go/discovery" "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/dynamic" @@ -31,7 +32,8 @@ type KubernetesConfig struct { Config *rest.Config // Client is a pre-configured dynamic client. If provided, the rest config is not used Client dynamic.Interface - REST rest.Interface + // Mapper is a pre-configured RESTMapper. If provided, the rest config is not used + Mapper meta.RESTMapper } // kubernetes holds references to implementation of the Kubernetes interface @@ -53,18 +55,9 @@ func NewFromConfig(c KubernetesConfig) (Kubernetes, error) { ctx = context.TODO() } - if c.REST != nil { - discoveryClient = discovery.NewDiscoveryClient(c.REST) - } else if c.Config != nil { - discoveryClient, err = discovery.NewDiscoveryClientForConfig(c.Config) - if err != nil { - return nil, err - } - } - var client *resources.Client if c.Client != nil { - client = resources.NewFromClient(ctx, c.Client) + client = resources.NewFromClient(ctx, c.Client).WithMapper(c.Mapper) } else { client, err = resources.NewFromConfig(ctx, c.Config) if err != nil { @@ -72,8 +65,12 @@ func NewFromConfig(c KubernetesConfig) (Kubernetes, error) { } } - if discoveryClient != nil { + if c.Mapper == nil { + discoveryClient, err = discovery.NewDiscoveryClientForConfig(c.Config) mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)) + if err != nil { + return nil, err + } client.WithMapper(mapper) } diff --git a/pkg/helpers/pods_test.go b/pkg/helpers/pods_test.go index 88d6416..74a7e46 100644 --- a/pkg/helpers/pods_test.go +++ b/pkg/helpers/pods_test.go @@ -84,7 +84,7 @@ func TestPods_Wait(t *testing.T) { t.Run(tc.test, func(t *testing.T) { t.Parallel() fake, _ := testutils.NewFakeDynamic() - client := resources.NewFromClient(context.TODO(), fake) + client := resources.NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}) h := NewHelper(context.TODO(), client, testNamespace) pod := buildPod() _, err := client.Structured().Create(pod) diff --git a/pkg/helpers/services_test.go b/pkg/helpers/services_test.go index 0f0c3b9..38078df 100644 --- a/pkg/helpers/services_test.go +++ b/pkg/helpers/services_test.go @@ -191,7 +191,7 @@ func Test_WaitServiceReady(t *testing.T) { objs = append(objs, tc.endpoints) } fake, _ := testutils.NewFakeDynamic(objs...) - client := resources.NewFromClient(context.TODO(), fake) + client := resources.NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}) h := NewHelper(context.TODO(), client, "default") go func(tc TestCase) { @@ -265,7 +265,7 @@ func Test_GetServiceIP(t *testing.T) { t.Parallel() fake, _ := testutils.NewFakeDynamic() - client := resources.NewFromClient(context.TODO(), fake) + client := resources.NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}) h := NewHelper(context.TODO(), client, "default") svc := buildService() diff --git a/pkg/resources/resources.go b/pkg/resources/resources.go index 7f4fcf5..5e86e6a 100644 --- a/pkg/resources/resources.go +++ b/pkg/resources/resources.go @@ -17,7 +17,6 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer/yaml" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" - "k8s.io/client-go/restmapper" ) // UnstructuredOperations defines generic functions that operate on any kind of Kubernetes object @@ -57,7 +56,7 @@ type structured struct { type Client struct { ctx context.Context dynamic dynamic.Interface - mapper *restmapper.DeferredDiscoveryRESTMapper + mapper meta.RESTMapper serializer runtime.Serializer } @@ -80,7 +79,7 @@ func NewFromClient(ctx context.Context, dynamic dynamic.Interface) *Client { } } -func (c *Client) WithMapper(mapper *restmapper.DeferredDiscoveryRESTMapper) *Client { +func (c *Client) WithMapper(mapper meta.RESTMapper) *Client { c.mapper = mapper return c } @@ -89,7 +88,7 @@ func (c *Client) WithMapper(mapper *restmapper.DeferredDiscoveryRESTMapper) *Cli func (c *Client) getResource(kind string, namespace string, versions ...string) (dynamic.ResourceInterface, error) { gk := schema.ParseGroupKind(kind) if c.mapper == nil { - return nil, fmt.Errorf("REST mapper not initialized") + return nil, fmt.Errorf("RESTMapper not initialized") } mapping, err := c.mapper.RESTMapping(gk, versions...) diff --git a/pkg/resources/resources_test.go b/pkg/resources/resources_test.go index 2200ebc..63b5da6 100644 --- a/pkg/resources/resources_test.go +++ b/pkg/resources/resources_test.go @@ -98,7 +98,7 @@ func newForTest(objs ...runtime.Object) (*Client, error) { if err != nil { return nil, err } - return NewFromClient(context.TODO(), dynamic), nil + return NewFromClient(context.TODO(), dynamic).WithMapper(&testutils.FakeRESTMapper{}), nil } func TestCreate(t *testing.T) { @@ -122,7 +122,7 @@ func TestCreate(t *testing.T) { { test: "Create Namespace", obj: buildUnstructuredNamespace(), - kind: "Job", + kind: "Namespace", resource: schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}, name: "testns", ns: "", @@ -134,7 +134,7 @@ func TestCreate(t *testing.T) { t.Run(tc.test, func(t *testing.T) { t.Parallel() fake, _ := testutils.NewFakeDynamic() - c := NewFromClient(context.TODO(), fake) + c := NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}) created, err := c.Create(tc.obj) if err != nil { @@ -317,7 +317,7 @@ func TestDelete(t *testing.T) { t.Errorf("unexpected error creating fake client %v", err) return } - c, err := NewFromClient(context.TODO(), fake), nil + c, err := NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}), nil if err != nil { t.Errorf("failed %v", err) return @@ -385,7 +385,7 @@ func TestGet(t *testing.T) { t.Errorf("unexpected error creating fake client %v", err) return } - c, err := NewFromClient(context.TODO(), fake), nil + c, err := NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}), nil if err != nil { t.Errorf("failed %v", err) return @@ -460,7 +460,7 @@ func TestList(t *testing.T) { t.Errorf("unexpected error creating fake client %v", err) return } - c, err := NewFromClient(context.TODO(), fake), nil + c, err := NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}), nil if err != nil { t.Errorf("failed %v", err) return @@ -484,7 +484,7 @@ func TestStructuredCreate(t *testing.T) { t.Parallel() fake, _ := testutils.NewFakeDynamic() - c := NewFromClient(context.TODO(), fake) + c := NewFromClient(context.TODO(), fake).WithMapper(&testutils.FakeRESTMapper{}) pod := buildPod() created, err := c.Structured().Create(*pod) From 42b72eceef4db8e5660aa32a36aa1e8ff6111c82 Mon Sep 17 00:00:00 2001 From: Todd Treece Date: Wed, 11 Jan 2023 18:24:26 -0500 Subject: [PATCH 3/3] update ingress example --- examples/ingress_operations.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/ingress_operations.js b/examples/ingress_operations.js index b5a93b2..573e4e6 100644 --- a/examples/ingress_operations.js +++ b/examples/ingress_operations.js @@ -68,11 +68,11 @@ export default function () { }) describe('Retrieve all available ingresses', () => { - expect(kubernetes.list("Ingress", ns).length).to.be.at.least(1) + expect(kubernetes.list("Ingress.networking.k8s.io", ns).length).to.be.at.least(1) }) describe('Retrieve our ingress by name and namespace', () => { - let fetched = kubernetes.get("Ingress", name, ns) + let fetched = kubernetes.get("Ingress.networking.k8s.io", name, ns) expect(ingress.metadata.uid).to.equal(fetched.metadata.uid) }) @@ -81,12 +81,12 @@ export default function () { json.spec.rules[0].http.paths[0].path = newValue kubernetes.update(json) - let updated = kubernetes.get("Ingress", name, ns) + let updated = kubernetes.get("Ingress.networking.k8s.io", name, ns) expect(updated.spec.rules[0].http.paths[0].path).to.be.equal(newValue) }) describe('Remove our ingresses to cleanup', () => { - kubernetes.delete("Ingress", name, ns) + kubernetes.delete("Ingress.networking.k8s.io", name, ns) }) }) @@ -98,19 +98,19 @@ export default function () { describe('Create our ingress using the YAML definition', () => { kubernetes.apply(yaml) - let created = kubernetes.get("Ingress", name, ns) + let created = kubernetes.get("Ingress.networking.k8s.io", name, ns) expect(created.metadata).to.have.property('uid') uid = created.metadata.uid }) describe('Update our ingress with a modified YAML definition', () => { kubernetes.apply(yaml) - let updated = kubernetes.get("Ingress", name, ns) + let updated = kubernetes.get("Ingress.networking.k8s.io", name, ns) expect(updated.metadata.uid).to.be.equal(uid) }) describe('Remove our ingresses to cleanup', () => { - kubernetes.delete("Ingress", name, ns) + kubernetes.delete("Ingress.networking.k8s.io", name, ns) }) })