diff --git a/README.md b/README.md index bfb9004..0d37506 100644 --- a/README.md +++ b/README.md @@ -33,19 +33,19 @@ Kai provides a bridge between large language models (LLMs) and your Kubernetes c - [x] **Cluster Health** - Cluster status and resource metrics (cluster health, node/pod metrics) ### Storage -- [ ] **Persistent Volumes** - PV and PVC management -- [ ] **Storage Classes** - Storage class operations +- [x] **Persistent Volumes** - PV management (list, get, delete) and PVC management (create, list, get, delete) +- [x] **Storage Classes** - Storage class operations (list, get) ### Security -- [ ] **RBAC** - Roles, RoleBindings, and ServiceAccounts +- [x] **RBAC** - Roles, RoleBindings, ClusterRoles, ClusterRoleBindings, and ServiceAccounts (list, get) ### Utilities - [x] **Port Forwarding** - Forward ports to pods and services (start, stop, list sessions) ### Advanced -- [ ] **Custom Resources** - CRD and custom resource operations +- [x] **Custom Resources** - CRD and custom resource operations (list/get CRDs, list/get custom resources) - [x] **Events** - Event listing and filtering (by namespace, type, involved object) -- [ ] **API Discovery** - API resource exploration +- [x] **API Discovery** - API resource exploration (list_api_resources) ## Requirements diff --git a/cluster/customresource.go b/cluster/customresource.go new file mode 100644 index 0000000..ee9bacd --- /dev/null +++ b/cluster/customresource.go @@ -0,0 +1,250 @@ +package cluster + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/basebandit/kai" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var crdGVR = schema.GroupVersionResource{ + Group: "apiextensions.k8s.io", + Version: "v1", + Resource: "customresourcedefinitions", +} + +// CustomResource provides access to CRDs and arbitrary custom resources via +// the dynamic client. +type CustomResource struct { + Group string + Version string + Resource string + Name string + Namespace string +} + +// ListCRDs lists all CustomResourceDefinitions registered in the cluster. +func (c *CustomResource) ListCRDs(ctx context.Context, cm kai.ClusterManager) (string, error) { + dyn, err := cm.GetCurrentDynamicClient() + if err != nil { + return "", fmt.Errorf("error getting dynamic client: %w", err) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() + + list, err := dyn.Resource(crdGVR).List(timeoutCtx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list CRDs: %w", err) + } + if len(list.Items) == 0 { + return "No custom resource definitions found", nil + } + + var sb strings.Builder + fmt.Fprintf(&sb, "Custom Resource Definitions (%d):\n", len(list.Items)) + for i := range list.Items { + item := list.Items[i] + group, _, _ := unstructured.NestedString(item.Object, "spec", "group") + scope, _, _ := unstructured.NestedString(item.Object, "spec", "scope") + kind, _, _ := unstructured.NestedString(item.Object, "spec", "names", "kind") + fmt.Fprintf(&sb, "• %s\tgroup: %s\tkind: %s\tscope: %s\n", item.GetName(), group, kind, scope) + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +// GetCRD returns details for a single CustomResourceDefinition. +func (c *CustomResource) GetCRD(ctx context.Context, cm kai.ClusterManager) (string, error) { + if c.Name == "" { + return "", fmt.Errorf("CRD name is required") + } + dyn, err := cm.GetCurrentDynamicClient() + if err != nil { + return "", fmt.Errorf("error getting dynamic client: %w", err) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + crd, err := dyn.Resource(crdGVR).Get(timeoutCtx, c.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get CRD %q: %w", c.Name, err) + } + + group, _, _ := unstructured.NestedString(crd.Object, "spec", "group") + scope, _, _ := unstructured.NestedString(crd.Object, "spec", "scope") + kind, _, _ := unstructured.NestedString(crd.Object, "spec", "names", "kind") + plural, _, _ := unstructured.NestedString(crd.Object, "spec", "names", "plural") + versions, _, _ := unstructured.NestedSlice(crd.Object, "spec", "versions") + + var sb strings.Builder + fmt.Fprintf(&sb, "CRD: %s\nGroup: %s\nKind: %s\nPlural: %s\nScope: %s\n", crd.GetName(), group, kind, plural, scope) + if len(versions) > 0 { + names := make([]string, 0, len(versions)) + for _, v := range versions { + if vm, ok := v.(map[string]interface{}); ok { + if name, ok := vm["name"].(string); ok { + served, _ := vm["served"].(bool) + names = append(names, fmt.Sprintf("%s(served=%t)", name, served)) + } + } + } + fmt.Fprintf(&sb, "Versions: %s\n", strings.Join(names, ", ")) + } + if group != "" && plural != "" { + sb.WriteString("\nQuery instances with list_custom_resources using:\n") + fmt.Fprintf(&sb, " group=%s, resource=%s, version=\n", group, plural) + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +// List lists instances of a custom resource identified by group/version/resource. +func (c *CustomResource) List(ctx context.Context, cm kai.ClusterManager, allNamespaces bool) (string, error) { + if c.Version == "" || c.Resource == "" { + return "", fmt.Errorf("version and resource are required") + } + dyn, err := cm.GetCurrentDynamicClient() + if err != nil { + return "", fmt.Errorf("error getting dynamic client: %w", err) + } + + gvr := schema.GroupVersionResource{Group: c.Group, Version: c.Version, Resource: c.Resource} + + timeoutCtx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() + + var list *unstructured.UnstructuredList + if allNamespaces { + list, err = dyn.Resource(gvr).List(timeoutCtx, metav1.ListOptions{}) + } else { + ns := c.Namespace + if ns == "" { + ns = cm.GetCurrentNamespace() + } + list, err = dyn.Resource(gvr).Namespace(ns).List(timeoutCtx, metav1.ListOptions{}) + } + if err != nil { + return "", fmt.Errorf("failed to list custom resources: %w", err) + } + if len(list.Items) == 0 { + return "No custom resources found", nil + } + + var sb strings.Builder + fmt.Fprintf(&sb, "%s (%d):\n", c.Resource, len(list.Items)) + for i := range list.Items { + item := list.Items[i] + if ns := item.GetNamespace(); ns != "" { + fmt.Fprintf(&sb, "• %s/%s\n", ns, item.GetName()) + } else { + fmt.Fprintf(&sb, "• %s\n", item.GetName()) + } + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +// Get returns a single custom resource instance as YAML-ish key listing. +func (c *CustomResource) Get(ctx context.Context, cm kai.ClusterManager) (string, error) { + if c.Version == "" || c.Resource == "" || c.Name == "" { + return "", fmt.Errorf("version, resource and name are required") + } + dyn, err := cm.GetCurrentDynamicClient() + if err != nil { + return "", fmt.Errorf("error getting dynamic client: %w", err) + } + + gvr := schema.GroupVersionResource{Group: c.Group, Version: c.Version, Resource: c.Resource} + + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + var ( + obj *unstructured.Unstructured + getErr error + ) + ns := c.Namespace + if ns == "" { + ns = cm.GetCurrentNamespace() + } + obj, getErr = dyn.Resource(gvr).Namespace(ns).Get(timeoutCtx, c.Name, metav1.GetOptions{}) + if getErr != nil { + // Retry cluster-scoped if namespaced lookup failed. + obj, err = dyn.Resource(gvr).Get(timeoutCtx, c.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get custom resource %q: %w", c.Name, getErr) + } + } + + var sb strings.Builder + fmt.Fprintf(&sb, "%s: %s\n", obj.GetKind(), obj.GetName()) + if obj.GetNamespace() != "" { + fmt.Fprintf(&sb, "Namespace: %s\n", obj.GetNamespace()) + } + fmt.Fprintf(&sb, "API Version: %s\n", obj.GetAPIVersion()) + if labels := obj.GetLabels(); len(labels) > 0 { + fmt.Fprintf(&sb, "Labels: %v\n", labels) + } + if status, found, _ := unstructured.NestedMap(obj.Object, "status"); found && len(status) > 0 { + keys := make([]string, 0, len(status)) + for k := range status { + keys = append(keys, k) + } + sort.Strings(keys) + fmt.Fprintf(&sb, "Status fields: %s\n", strings.Join(keys, ", ")) + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +// ListAPIResources lists the server's preferred API resources (discovery). +func (c *CustomResource) ListAPIResources(ctx context.Context, cm kai.ClusterManager) (string, error) { + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + + lists, err := client.Discovery().ServerPreferredResources() + if err != nil && len(lists) == 0 { + return "", fmt.Errorf("failed to discover API resources: %w", err) + } + + return formatAPIResources(lists), nil +} + +func formatAPIResources(lists []*metav1.APIResourceList) string { + type apiResource struct{ name, group, kind string } + var resources []apiResource + for _, list := range lists { + if list == nil { + continue + } + gv, _ := schema.ParseGroupVersion(list.GroupVersion) + for _, res := range list.APIResources { + if strings.Contains(res.Name, "/") { + continue // skip subresources + } + resources = append(resources, apiResource{name: res.Name, group: gv.Group, kind: res.Kind}) + } + } + sort.Slice(resources, func(i, j int) bool { + if resources[i].group != resources[j].group { + return resources[i].group < resources[j].group + } + return resources[i].name < resources[j].name + }) + + var sb strings.Builder + fmt.Fprintf(&sb, "API Resources (%d):\n", len(resources)) + for _, r := range resources { + group := r.group + if group == "" { + group = "core" + } + fmt.Fprintf(&sb, "• %s\tgroup: %s\tkind: %s\n", r.name, group, r.kind) + } + return strings.TrimRight(sb.String(), "\n") +} diff --git a/cluster/customresource_test.go b/cluster/customresource_test.go new file mode 100644 index 0000000..76bc70f --- /dev/null +++ b/cluster/customresource_test.go @@ -0,0 +1,151 @@ +package cluster + +import ( + "context" + "testing" + + "github.com/basebandit/kai/testmocks" + "github.com/stretchr/testify/assert" + 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/runtime/schema" + "k8s.io/client-go/dynamic" + dynamicfake "k8s.io/client-go/dynamic/fake" + kfake "k8s.io/client-go/kubernetes/fake" +) + +var widgetGVR = schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "widgets"} + +func crdObject(name, group, kind, plural, scope string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]interface{}{"name": name}, + "spec": map[string]interface{}{ + "group": group, + "scope": scope, + "names": map[string]interface{}{"kind": kind, "plural": plural}, + "versions": []interface{}{ + map[string]interface{}{"name": "v1", "served": true, "storage": true}, + }, + }, + }} +} + +func widgetObject(name, namespace string) *unstructured.Unstructured { + obj := map[string]interface{}{ + "apiVersion": "example.com/v1", + "kind": "Widget", + "metadata": map[string]interface{}{"name": name}, + "status": map[string]interface{}{"phase": "Ready"}, + } + if namespace != "" { + obj["metadata"].(map[string]interface{})["namespace"] = namespace + } + return &unstructured.Unstructured{Object: obj} +} + +func crListKinds() map[schema.GroupVersionResource]string { + return map[schema.GroupVersionResource]string{ + crdGVR: "CustomResourceDefinitionList", + widgetGVR: "WidgetList", + } +} + +func newCRDynamic(t *testing.T) dynamic.Interface { + t.Helper() + return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), crListKinds()) +} + +func TestCustomResourceCRDs(t *testing.T) { + ctx := context.Background() + + dyn := newCRDynamic(t) + _, err := dyn.Resource(crdGVR).Create(ctx, crdObject("widgets.example.com", "example.com", "Widget", "widgets", "Namespaced"), metav1.CreateOptions{}) + assert.NoError(t, err) + + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentDynamicClient").Return(dyn, nil) + + list, err := (&CustomResource{}).ListCRDs(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, list, "widgets.example.com") + assert.Contains(t, list, "Widget") + + get, err := (&CustomResource{Name: "widgets.example.com"}).GetCRD(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, get, "Group: example.com") + assert.Contains(t, get, "v1(served=true)") + + _, err = (&CustomResource{}).GetCRD(ctx, mockCM) + assert.Error(t, err) +} + +func TestCustomResourceInstances(t *testing.T) { + ctx := context.Background() + + dyn := newCRDynamic(t) + _, err := dyn.Resource(widgetGVR).Namespace(defaultNamespace).Create(ctx, widgetObject("w1", defaultNamespace), metav1.CreateOptions{}) + assert.NoError(t, err) + + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentDynamicClient").Return(dyn, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + + list, err := (&CustomResource{Group: "example.com", Version: "v1", Resource: "widgets"}).List(ctx, mockCM, false) + assert.NoError(t, err) + assert.Contains(t, list, "w1") + + all, err := (&CustomResource{Group: "example.com", Version: "v1", Resource: "widgets"}).List(ctx, mockCM, true) + assert.NoError(t, err) + assert.Contains(t, all, "w1") + + get, err := (&CustomResource{Group: "example.com", Version: "v1", Resource: "widgets", Name: "w1"}).Get(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, get, "Widget: w1") + assert.Contains(t, get, "phase") + + _, err = (&CustomResource{Version: "v1"}).List(ctx, mockCM, false) + assert.Error(t, err) + _, err = (&CustomResource{Resource: "widgets"}).Get(ctx, mockCM) + assert.Error(t, err) +} + +func TestListAPIResources(t *testing.T) { + ctx := context.Background() + + // The fake discovery client returns no preferred resources, so this + // exercises the discovery call + empty path. + clientset := kfake.NewSimpleClientset() + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(clientset, nil) + + result, err := (&CustomResource{}).ListAPIResources(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, result, "API Resources") +} + +func TestFormatAPIResources(t *testing.T) { + lists := []*metav1.APIResourceList{ + nil, + { + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + {Name: "pods", Kind: "Pod"}, + {Name: "pods/log", Kind: "Pod"}, // subresource, skipped + }, + }, + { + GroupVersion: "apps/v1", + APIResources: []metav1.APIResource{{Name: "deployments", Kind: "Deployment"}}, + }, + } + + result := formatAPIResources(lists) + assert.Contains(t, result, "API Resources (2)") + assert.Contains(t, result, "pods") + assert.Contains(t, result, "group: core") + assert.Contains(t, result, "deployments") + assert.NotContains(t, result, "pods/log") +} diff --git a/cluster/persistentvolume.go b/cluster/persistentvolume.go new file mode 100644 index 0000000..4f7e5b1 --- /dev/null +++ b/cluster/persistentvolume.go @@ -0,0 +1,152 @@ +package cluster + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/basebandit/kai" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// PersistentVolume represents an operation target for a cluster-scoped PV. +type PersistentVolume struct { + Name string +} + +func (p *PersistentVolume) validate() error { + if p.Name == "" { + return fmt.Errorf("persistent volume name is required") + } + return nil +} + +// List returns all persistent volumes in the cluster. +func (p *PersistentVolume) List(ctx context.Context, cm kai.ClusterManager) (string, error) { + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() + + pvs, err := client.CoreV1().PersistentVolumes().List(timeoutCtx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list persistent volumes: %w", err) + } + + if len(pvs.Items) == 0 { + return "No persistent volumes found", nil + } + + return formatPersistentVolumeList(pvs), nil +} + +// Get returns details for a single persistent volume. +func (p *PersistentVolume) Get(ctx context.Context, cm kai.ClusterManager) (string, error) { + if err := p.validate(); err != nil { + return "", err + } + + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + pv, err := client.CoreV1().PersistentVolumes().Get(timeoutCtx, p.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get persistent volume %q: %w", p.Name, err) + } + + return formatPersistentVolume(pv), nil +} + +// Delete removes a persistent volume. +func (p *PersistentVolume) Delete(ctx context.Context, cm kai.ClusterManager) (string, error) { + if err := p.validate(); err != nil { + return "", err + } + + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + if err := client.CoreV1().PersistentVolumes().Delete(timeoutCtx, p.Name, metav1.DeleteOptions{}); err != nil { + return "", fmt.Errorf("failed to delete persistent volume %q: %w", p.Name, err) + } + + return fmt.Sprintf("PersistentVolume %q deleted successfully", p.Name), nil +} + +func pvCapacity(pv *corev1.PersistentVolume) string { + if storage, ok := pv.Spec.Capacity[corev1.ResourceStorage]; ok { + return storage.String() + } + return "" +} + +func accessModesToString(modes []corev1.PersistentVolumeAccessMode) string { + if len(modes) == 0 { + return "" + } + out := make([]string, 0, len(modes)) + for _, m := range modes { + switch m { + case corev1.ReadWriteOnce: + out = append(out, "RWO") + case corev1.ReadOnlyMany: + out = append(out, "ROX") + case corev1.ReadWriteMany: + out = append(out, "RWX") + case corev1.ReadWriteOncePod: + out = append(out, "RWOP") + default: + out = append(out, string(m)) + } + } + return strings.Join(out, ",") +} + +func formatPersistentVolumeList(pvs *corev1.PersistentVolumeList) string { + var sb strings.Builder + fmt.Fprintf(&sb, "Persistent Volumes (%d):\n", len(pvs.Items)) + for i := range pvs.Items { + pv := pvs.Items[i] + claim := "" + if pv.Spec.ClaimRef != nil { + claim = fmt.Sprintf("%s/%s", pv.Spec.ClaimRef.Namespace, pv.Spec.ClaimRef.Name) + } + fmt.Fprintf(&sb, "• %s\tcapacity: %s\taccess: %s\treclaim: %s\tstatus: %s\tclaim: %s\tstorageClass: %s\n", + pv.Name, pvCapacity(&pv), accessModesToString(pv.Spec.AccessModes), + pv.Spec.PersistentVolumeReclaimPolicy, pv.Status.Phase, claim, pv.Spec.StorageClassName) + } + return strings.TrimRight(sb.String(), "\n") +} + +func formatPersistentVolume(pv *corev1.PersistentVolume) string { + var sb strings.Builder + fmt.Fprintf(&sb, "PersistentVolume: %s\n", pv.Name) + fmt.Fprintf(&sb, "Capacity: %s\n", pvCapacity(pv)) + fmt.Fprintf(&sb, "Access Modes: %s\n", accessModesToString(pv.Spec.AccessModes)) + fmt.Fprintf(&sb, "Reclaim Policy: %s\n", pv.Spec.PersistentVolumeReclaimPolicy) + fmt.Fprintf(&sb, "Status: %s\n", pv.Status.Phase) + fmt.Fprintf(&sb, "Storage Class: %s\n", pv.Spec.StorageClassName) + if pv.Spec.ClaimRef != nil { + fmt.Fprintf(&sb, "Claim: %s/%s\n", pv.Spec.ClaimRef.Namespace, pv.Spec.ClaimRef.Name) + } + if pv.Spec.VolumeMode != nil { + fmt.Fprintf(&sb, "Volume Mode: %s\n", *pv.Spec.VolumeMode) + } + fmt.Fprintf(&sb, "Age: %s\n", formatDuration(time.Since(pv.CreationTimestamp.Time))) + return strings.TrimRight(sb.String(), "\n") +} diff --git a/cluster/persistentvolumeclaim.go b/cluster/persistentvolumeclaim.go new file mode 100644 index 0000000..f11cf50 --- /dev/null +++ b/cluster/persistentvolumeclaim.go @@ -0,0 +1,217 @@ +package cluster + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/basebandit/kai" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// PersistentVolumeClaim represents an operation target for a namespaced PVC. +type PersistentVolumeClaim struct { + Name string + Namespace string + StorageClassName string + AccessModes []string + Storage string + VolumeMode string + Labels map[string]interface{} + Annotations map[string]interface{} +} + +func (p *PersistentVolumeClaim) namespace(cm kai.ClusterManager) string { + if p.Namespace != "" { + return p.Namespace + } + return cm.GetCurrentNamespace() +} + +// Create provisions a new PersistentVolumeClaim. +func (p *PersistentVolumeClaim) Create(ctx context.Context, cm kai.ClusterManager) (string, error) { + if p.Name == "" { + return "", fmt.Errorf("persistent volume claim name is required") + } + if p.Storage == "" { + return "", fmt.Errorf("storage request is required (e.g. '1Gi')") + } + + quantity, err := resource.ParseQuantity(p.Storage) + if err != nil { + return "", fmt.Errorf("invalid storage quantity %q: %w", p.Storage, err) + } + + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + + ns := p.namespace(cm) + + accessModes := []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} + if len(p.AccessModes) > 0 { + accessModes = accessModes[:0] + for _, m := range p.AccessModes { + accessModes = append(accessModes, corev1.PersistentVolumeAccessMode(m)) + } + } + + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: p.Name, Namespace: ns}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: accessModes, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{corev1.ResourceStorage: quantity}, + }, + }, + } + + if p.StorageClassName != "" { + pvc.Spec.StorageClassName = &p.StorageClassName + } + if p.VolumeMode != "" { + mode := corev1.PersistentVolumeMode(p.VolumeMode) + pvc.Spec.VolumeMode = &mode + } + if labels := convertToStringMap(p.Labels); len(labels) > 0 { + pvc.ObjectMeta.Labels = labels + } + if annotations := convertToStringMap(p.Annotations); len(annotations) > 0 { + pvc.ObjectMeta.Annotations = annotations + } + + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + created, err := client.CoreV1().PersistentVolumeClaims(ns).Create(timeoutCtx, pvc, metav1.CreateOptions{}) + if err != nil { + return "", fmt.Errorf("failed to create persistent volume claim: %w", err) + } + + return fmt.Sprintf("PersistentVolumeClaim %q created successfully in namespace %q", created.Name, ns), nil +} + +// List returns PVCs in the requested namespace. +func (p *PersistentVolumeClaim) List(ctx context.Context, cm kai.ClusterManager, allNamespaces bool, labelSelector string) (string, error) { + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + + ns := "" + if !allNamespaces { + ns = p.namespace(cm) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() + + pvcs, err := client.CoreV1().PersistentVolumeClaims(ns).List(timeoutCtx, metav1.ListOptions{LabelSelector: labelSelector}) + if err != nil { + return "", fmt.Errorf("failed to list persistent volume claims: %w", err) + } + + if len(pvcs.Items) == 0 { + return "No persistent volume claims found", nil + } + + return formatPVCList(pvcs, allNamespaces), nil +} + +// Get returns details for a single PVC. +func (p *PersistentVolumeClaim) Get(ctx context.Context, cm kai.ClusterManager) (string, error) { + if p.Name == "" { + return "", fmt.Errorf("persistent volume claim name is required") + } + + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + + ns := p.namespace(cm) + + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + pvc, err := client.CoreV1().PersistentVolumeClaims(ns).Get(timeoutCtx, p.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get persistent volume claim %q: %w", p.Name, err) + } + + return formatPVC(pvc), nil +} + +// Delete removes a PVC. +func (p *PersistentVolumeClaim) Delete(ctx context.Context, cm kai.ClusterManager) (string, error) { + if p.Name == "" { + return "", fmt.Errorf("persistent volume claim name is required") + } + + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + + ns := p.namespace(cm) + + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + if err := client.CoreV1().PersistentVolumeClaims(ns).Delete(timeoutCtx, p.Name, metav1.DeleteOptions{}); err != nil { + return "", fmt.Errorf("failed to delete persistent volume claim %q: %w", p.Name, err) + } + + return fmt.Sprintf("PersistentVolumeClaim %q deleted successfully from namespace %q", p.Name, ns), nil +} + +func pvcCapacity(pvc *corev1.PersistentVolumeClaim) string { + if storage, ok := pvc.Spec.Resources.Requests[corev1.ResourceStorage]; ok { + return storage.String() + } + return "" +} + +func formatPVCList(pvcs *corev1.PersistentVolumeClaimList, allNamespaces bool) string { + var sb strings.Builder + fmt.Fprintf(&sb, "Persistent Volume Claims (%d):\n", len(pvcs.Items)) + for i := range pvcs.Items { + pvc := pvcs.Items[i] + sc := "" + if pvc.Spec.StorageClassName != nil { + sc = *pvc.Spec.StorageClassName + } + line := fmt.Sprintf("• %s", pvc.Name) + if allNamespaces { + line = fmt.Sprintf("• %s/%s", pvc.Namespace, pvc.Name) + } + fmt.Fprintf(&sb, "%s\tstatus: %s\tvolume: %s\tcapacity: %s\taccess: %s\tstorageClass: %s\n", + line, pvc.Status.Phase, pvc.Spec.VolumeName, pvcCapacity(&pvc), + accessModesToString(pvc.Spec.AccessModes), sc) + } + return strings.TrimRight(sb.String(), "\n") +} + +func formatPVC(pvc *corev1.PersistentVolumeClaim) string { + var sb strings.Builder + fmt.Fprintf(&sb, "PersistentVolumeClaim: %s\n", pvc.Name) + fmt.Fprintf(&sb, "Namespace: %s\n", pvc.Namespace) + fmt.Fprintf(&sb, "Status: %s\n", pvc.Status.Phase) + fmt.Fprintf(&sb, "Capacity: %s\n", pvcCapacity(pvc)) + fmt.Fprintf(&sb, "Access Modes: %s\n", accessModesToString(pvc.Spec.AccessModes)) + if pvc.Spec.StorageClassName != nil { + fmt.Fprintf(&sb, "Storage Class: %s\n", *pvc.Spec.StorageClassName) + } + if pvc.Spec.VolumeName != "" { + fmt.Fprintf(&sb, "Bound Volume: %s\n", pvc.Spec.VolumeName) + } + if pvc.Spec.VolumeMode != nil { + fmt.Fprintf(&sb, "Volume Mode: %s\n", *pvc.Spec.VolumeMode) + } + fmt.Fprintf(&sb, "Age: %s\n", formatDuration(time.Since(pvc.CreationTimestamp.Time))) + return strings.TrimRight(sb.String(), "\n") +} diff --git a/cluster/rbac.go b/cluster/rbac.go new file mode 100644 index 0000000..2922e2f --- /dev/null +++ b/cluster/rbac.go @@ -0,0 +1,327 @@ +package cluster + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/basebandit/kai" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// RBAC provides read access to RBAC resources. Kind selects the resource: +// "role", "rolebinding", "clusterrole", "clusterrolebinding" or +// "serviceaccount". Roles, RoleBindings and ServiceAccounts are namespaced. +type RBAC struct { + Name string + Namespace string +} + +func (r *RBAC) namespace(cm kai.ClusterManager) string { + if r.Namespace != "" { + return r.Namespace + } + return cm.GetCurrentNamespace() +} + +// ---- Roles ---- + +func (r *RBAC) ListRoles(ctx context.Context, cm kai.ClusterManager, allNamespaces bool) (string, error) { + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + ns := "" + if !allNamespaces { + ns = r.namespace(cm) + } + timeoutCtx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() + + roles, err := client.RbacV1().Roles(ns).List(timeoutCtx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list roles: %w", err) + } + if len(roles.Items) == 0 { + return "No roles found", nil + } + var sb strings.Builder + fmt.Fprintf(&sb, "Roles (%d):\n", len(roles.Items)) + for i := range roles.Items { + role := roles.Items[i] + name := role.Name + if allNamespaces { + name = fmt.Sprintf("%s/%s", role.Namespace, role.Name) + } + fmt.Fprintf(&sb, "• %s\trules: %d\tage: %s\n", name, len(role.Rules), formatDuration(time.Since(role.CreationTimestamp.Time))) + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +func (r *RBAC) GetRole(ctx context.Context, cm kai.ClusterManager) (string, error) { + if r.Name == "" { + return "", fmt.Errorf("role name is required") + } + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + ns := r.namespace(cm) + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + role, err := client.RbacV1().Roles(ns).Get(timeoutCtx, r.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get role %q: %w", r.Name, err) + } + var sb strings.Builder + fmt.Fprintf(&sb, "Role: %s\nNamespace: %s\n", role.Name, role.Namespace) + sb.WriteString(formatPolicyRules(role.Rules)) + return strings.TrimRight(sb.String(), "\n"), nil +} + +// ---- ClusterRoles ---- + +func (r *RBAC) ListClusterRoles(ctx context.Context, cm kai.ClusterManager) (string, error) { + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + timeoutCtx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() + + roles, err := client.RbacV1().ClusterRoles().List(timeoutCtx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list cluster roles: %w", err) + } + if len(roles.Items) == 0 { + return "No cluster roles found", nil + } + var sb strings.Builder + fmt.Fprintf(&sb, "ClusterRoles (%d):\n", len(roles.Items)) + for i := range roles.Items { + role := roles.Items[i] + fmt.Fprintf(&sb, "• %s\trules: %d\tage: %s\n", role.Name, len(role.Rules), formatDuration(time.Since(role.CreationTimestamp.Time))) + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +func (r *RBAC) GetClusterRole(ctx context.Context, cm kai.ClusterManager) (string, error) { + if r.Name == "" { + return "", fmt.Errorf("cluster role name is required") + } + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + role, err := client.RbacV1().ClusterRoles().Get(timeoutCtx, r.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get cluster role %q: %w", r.Name, err) + } + var sb strings.Builder + fmt.Fprintf(&sb, "ClusterRole: %s\n", role.Name) + sb.WriteString(formatPolicyRules(role.Rules)) + return strings.TrimRight(sb.String(), "\n"), nil +} + +// ---- RoleBindings ---- + +func (r *RBAC) ListRoleBindings(ctx context.Context, cm kai.ClusterManager, allNamespaces bool) (string, error) { + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + ns := "" + if !allNamespaces { + ns = r.namespace(cm) + } + timeoutCtx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() + + bindings, err := client.RbacV1().RoleBindings(ns).List(timeoutCtx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list role bindings: %w", err) + } + if len(bindings.Items) == 0 { + return "No role bindings found", nil + } + var sb strings.Builder + fmt.Fprintf(&sb, "RoleBindings (%d):\n", len(bindings.Items)) + for i := range bindings.Items { + b := bindings.Items[i] + name := b.Name + if allNamespaces { + name = fmt.Sprintf("%s/%s", b.Namespace, b.Name) + } + fmt.Fprintf(&sb, "• %s\trole: %s/%s\tsubjects: %s\n", name, b.RoleRef.Kind, b.RoleRef.Name, formatSubjects(b.Subjects)) + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +func (r *RBAC) GetRoleBinding(ctx context.Context, cm kai.ClusterManager) (string, error) { + if r.Name == "" { + return "", fmt.Errorf("role binding name is required") + } + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + ns := r.namespace(cm) + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + b, err := client.RbacV1().RoleBindings(ns).Get(timeoutCtx, r.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get role binding %q: %w", r.Name, err) + } + var sb strings.Builder + fmt.Fprintf(&sb, "RoleBinding: %s\nNamespace: %s\nRole: %s/%s\nSubjects: %s\n", + b.Name, b.Namespace, b.RoleRef.Kind, b.RoleRef.Name, formatSubjects(b.Subjects)) + return strings.TrimRight(sb.String(), "\n"), nil +} + +// ---- ClusterRoleBindings ---- + +func (r *RBAC) ListClusterRoleBindings(ctx context.Context, cm kai.ClusterManager) (string, error) { + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + timeoutCtx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() + + bindings, err := client.RbacV1().ClusterRoleBindings().List(timeoutCtx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list cluster role bindings: %w", err) + } + if len(bindings.Items) == 0 { + return "No cluster role bindings found", nil + } + var sb strings.Builder + fmt.Fprintf(&sb, "ClusterRoleBindings (%d):\n", len(bindings.Items)) + for i := range bindings.Items { + b := bindings.Items[i] + fmt.Fprintf(&sb, "• %s\trole: %s/%s\tsubjects: %s\n", b.Name, b.RoleRef.Kind, b.RoleRef.Name, formatSubjects(b.Subjects)) + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +func (r *RBAC) GetClusterRoleBinding(ctx context.Context, cm kai.ClusterManager) (string, error) { + if r.Name == "" { + return "", fmt.Errorf("cluster role binding name is required") + } + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + b, err := client.RbacV1().ClusterRoleBindings().Get(timeoutCtx, r.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get cluster role binding %q: %w", r.Name, err) + } + var sb strings.Builder + fmt.Fprintf(&sb, "ClusterRoleBinding: %s\nRole: %s/%s\nSubjects: %s\n", + b.Name, b.RoleRef.Kind, b.RoleRef.Name, formatSubjects(b.Subjects)) + return strings.TrimRight(sb.String(), "\n"), nil +} + +// ---- ServiceAccounts ---- + +func (r *RBAC) ListServiceAccounts(ctx context.Context, cm kai.ClusterManager, allNamespaces bool) (string, error) { + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + ns := "" + if !allNamespaces { + ns = r.namespace(cm) + } + timeoutCtx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() + + sas, err := client.CoreV1().ServiceAccounts(ns).List(timeoutCtx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list service accounts: %w", err) + } + if len(sas.Items) == 0 { + return "No service accounts found", nil + } + var sb strings.Builder + fmt.Fprintf(&sb, "ServiceAccounts (%d):\n", len(sas.Items)) + for i := range sas.Items { + sa := sas.Items[i] + name := sa.Name + if allNamespaces { + name = fmt.Sprintf("%s/%s", sa.Namespace, sa.Name) + } + fmt.Fprintf(&sb, "• %s\tsecrets: %d\tage: %s\n", name, len(sa.Secrets), formatDuration(time.Since(sa.CreationTimestamp.Time))) + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +func (r *RBAC) GetServiceAccount(ctx context.Context, cm kai.ClusterManager) (string, error) { + if r.Name == "" { + return "", fmt.Errorf("service account name is required") + } + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + ns := r.namespace(cm) + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + sa, err := client.CoreV1().ServiceAccounts(ns).Get(timeoutCtx, r.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get service account %q: %w", r.Name, err) + } + var sb strings.Builder + fmt.Fprintf(&sb, "ServiceAccount: %s\nNamespace: %s\n", sa.Name, sa.Namespace) + if len(sa.Secrets) > 0 { + names := make([]string, 0, len(sa.Secrets)) + for _, s := range sa.Secrets { + names = append(names, s.Name) + } + fmt.Fprintf(&sb, "Secrets: %s\n", strings.Join(names, ", ")) + } + if sa.AutomountServiceAccountToken != nil { + fmt.Fprintf(&sb, "Automount Token: %t\n", *sa.AutomountServiceAccountToken) + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +func formatPolicyRules(rules []rbacv1.PolicyRule) string { + var sb strings.Builder + fmt.Fprintf(&sb, "Rules (%d):\n", len(rules)) + for _, rule := range rules { + fmt.Fprintf(&sb, " apiGroups: [%s] resources: [%s] verbs: [%s]", + strings.Join(rule.APIGroups, ","), strings.Join(rule.Resources, ","), strings.Join(rule.Verbs, ",")) + if len(rule.ResourceNames) > 0 { + fmt.Fprintf(&sb, " resourceNames: [%s]", strings.Join(rule.ResourceNames, ",")) + } + sb.WriteString("\n") + } + return sb.String() +} + +func formatSubjects(subjects []rbacv1.Subject) string { + if len(subjects) == 0 { + return "" + } + parts := make([]string, 0, len(subjects)) + for _, s := range subjects { + if s.Namespace != "" { + parts = append(parts, fmt.Sprintf("%s:%s/%s", s.Kind, s.Namespace, s.Name)) + } else { + parts = append(parts, fmt.Sprintf("%s:%s", s.Kind, s.Name)) + } + } + return strings.Join(parts, ", ") +} diff --git a/cluster/rbac_test.go b/cluster/rbac_test.go new file mode 100644 index 0000000..8dceb3b --- /dev/null +++ b/cluster/rbac_test.go @@ -0,0 +1,136 @@ +package cluster + +import ( + "context" + "testing" + + "github.com/basebandit/kai/testmocks" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestRBACRoles(t *testing.T) { + ctx := context.Background() + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{Name: "reader", Namespace: defaultNamespace}, + Rules: []rbacv1.PolicyRule{{ + APIGroups: []string{""}, Resources: []string{"pods"}, Verbs: []string{"get", "list"}, ResourceNames: []string{"p1"}, + }}, + } + fakeClient := fake.NewSimpleClientset(role) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + + list, err := (&RBAC{}).ListRoles(ctx, mockCM, false) + assert.NoError(t, err) + assert.Contains(t, list, "reader") + + all, err := (&RBAC{}).ListRoles(ctx, mockCM, true) + assert.NoError(t, err) + assert.Contains(t, all, "reader") + + get, err := (&RBAC{Name: "reader"}).GetRole(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, get, "pods") + assert.Contains(t, get, "p1") + + _, err = (&RBAC{}).GetRole(ctx, mockCM) + assert.Error(t, err) +} + +func TestRBACClusterRoles(t *testing.T) { + ctx := context.Background() + cr := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: "admin"}, + Rules: []rbacv1.PolicyRule{{APIGroups: []string{"*"}, Resources: []string{"*"}, Verbs: []string{"*"}}}, + } + fakeClient := fake.NewSimpleClientset(cr) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + list, err := (&RBAC{}).ListClusterRoles(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, list, "admin") + + get, err := (&RBAC{Name: "admin"}).GetClusterRole(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, get, "ClusterRole: admin") + + _, err = (&RBAC{}).GetClusterRole(ctx, mockCM) + assert.Error(t, err) +} + +func TestRBACBindings(t *testing.T) { + ctx := context.Background() + rb := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "rb1", Namespace: defaultNamespace}, + RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "reader"}, + Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Name: "sa1", Namespace: defaultNamespace}}, + } + crb := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "crb1"}, + RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "admin"}, + Subjects: []rbacv1.Subject{{Kind: "User", Name: "alice"}}, + } + fakeClient := fake.NewSimpleClientset(rb, crb) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + + rbList, err := (&RBAC{}).ListRoleBindings(ctx, mockCM, false) + assert.NoError(t, err) + assert.Contains(t, rbList, "rb1") + + _, err = (&RBAC{}).ListRoleBindings(ctx, mockCM, true) + assert.NoError(t, err) + + rbGet, err := (&RBAC{Name: "rb1"}).GetRoleBinding(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, rbGet, "ServiceAccount:default/sa1") + + _, err = (&RBAC{}).GetRoleBinding(ctx, mockCM) + assert.Error(t, err) + + crbList, err := (&RBAC{}).ListClusterRoleBindings(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, crbList, "crb1") + + crbGet, err := (&RBAC{Name: "crb1"}).GetClusterRoleBinding(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, crbGet, "User:alice") + + _, err = (&RBAC{}).GetClusterRoleBinding(ctx, mockCM) + assert.Error(t, err) +} + +func TestRBACServiceAccounts(t *testing.T) { + ctx := context.Background() + automount := true + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "sa1", Namespace: defaultNamespace}, + Secrets: []corev1.ObjectReference{{Name: "sa1-token"}}, + AutomountServiceAccountToken: &automount, + } + fakeClient := fake.NewSimpleClientset(sa) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + + list, err := (&RBAC{}).ListServiceAccounts(ctx, mockCM, false) + assert.NoError(t, err) + assert.Contains(t, list, "sa1") + + _, err = (&RBAC{}).ListServiceAccounts(ctx, mockCM, true) + assert.NoError(t, err) + + get, err := (&RBAC{Name: "sa1"}).GetServiceAccount(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, get, "sa1-token") + + _, err = (&RBAC{}).GetServiceAccount(ctx, mockCM) + assert.Error(t, err) +} diff --git a/cluster/storage_test.go b/cluster/storage_test.go new file mode 100644 index 0000000..f6433a9 --- /dev/null +++ b/cluster/storage_test.go @@ -0,0 +1,224 @@ +package cluster + +import ( + "context" + "testing" + + "github.com/basebandit/kai/testmocks" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func newPV(name string) *corev1.PersistentVolume { + return &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: corev1.PersistentVolumeSpec{ + Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("5Gi")}, + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimRetain, + StorageClassName: "standard", + }, + Status: corev1.PersistentVolumeStatus{Phase: corev1.VolumeBound}, + } +} + +func TestPersistentVolumeOperations(t *testing.T) { + ctx := context.Background() + + t.Run("List", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(newPV("pv-1")) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + pv := &PersistentVolume{} + result, err := pv.List(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, result, "pv-1") + assert.Contains(t, result, "RWO") + }) + + t.Run("ListEmpty", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset() + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + pv := &PersistentVolume{} + result, err := pv.List(ctx, mockCM) + assert.NoError(t, err) + assert.Equal(t, "No persistent volumes found", result) + }) + + t.Run("GetAndValidate", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(newPV("pv-1")) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + pv := &PersistentVolume{Name: "pv-1"} + result, err := pv.Get(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, result, "PersistentVolume: pv-1") + + _, err = (&PersistentVolume{}).Get(ctx, mockCM) + assert.Error(t, err) + }) + + t.Run("Delete", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(newPV("pv-1")) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + pv := &PersistentVolume{Name: "pv-1"} + result, err := pv.Delete(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, result, "deleted successfully") + }) +} + +func TestPersistentVolumeClaimOperations(t *testing.T) { + ctx := context.Background() + + t.Run("Create", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset() + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + + pvc := &PersistentVolumeClaim{ + Name: "pvc-1", + Storage: "1Gi", + StorageClassName: "standard", + AccessModes: []string{"ReadWriteOnce"}, + VolumeMode: "Filesystem", + Labels: map[string]interface{}{"app": "db"}, + } + result, err := pvc.Create(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, result, "pvc-1") + + got, err := fakeClient.CoreV1().PersistentVolumeClaims(defaultNamespace).Get(ctx, "pvc-1", metav1.GetOptions{}) + assert.NoError(t, err) + assert.Equal(t, "db", got.Labels["app"]) + }) + + t.Run("CreateValidation", func(t *testing.T) { + mockCM := testmocks.NewMockClusterManager() + _, err := (&PersistentVolumeClaim{}).Create(ctx, mockCM) + assert.Error(t, err) + _, err = (&PersistentVolumeClaim{Name: "x"}).Create(ctx, mockCM) + assert.Error(t, err) + _, err = (&PersistentVolumeClaim{Name: "x", Storage: "bad-qty"}).Create(ctx, mockCM) + assert.Error(t, err) + }) + + t.Run("ListGetDelete", func(t *testing.T) { + existing := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "pvc-1", Namespace: defaultNamespace}, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("1Gi")}}, + }, + Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound}, + } + fakeClient := fake.NewSimpleClientset(existing) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + + list, err := (&PersistentVolumeClaim{}).List(ctx, mockCM, false, "") + assert.NoError(t, err) + assert.Contains(t, list, "pvc-1") + + all, err := (&PersistentVolumeClaim{}).List(ctx, mockCM, true, "") + assert.NoError(t, err) + assert.Contains(t, all, "pvc-1") + + got, err := (&PersistentVolumeClaim{Name: "pvc-1"}).Get(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, got, "PersistentVolumeClaim: pvc-1") + + del, err := (&PersistentVolumeClaim{Name: "pvc-1"}).Delete(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, del, "deleted successfully") + }) +} + +func TestStorageFormattingHelpers(t *testing.T) { + assert.Equal(t, "RWO,ROX,RWX,RWOP", accessModesToString([]corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, corev1.ReadOnlyMany, corev1.ReadWriteMany, corev1.ReadWriteOncePod, + })) + assert.Equal(t, "", accessModesToString(nil)) + assert.Equal(t, "Custom", accessModesToString([]corev1.PersistentVolumeAccessMode{"Custom"})) + + assert.Equal(t, "", pvCapacity(&corev1.PersistentVolume{})) + assert.Equal(t, "", pvcCapacity(&corev1.PersistentVolumeClaim{})) +} + +func TestPVCNamespaceOverride(t *testing.T) { + ctx := context.Background() + fakeClient := fake.NewSimpleClientset() + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + // Explicit namespace must be honored without consulting GetCurrentNamespace. + pvc := &PersistentVolumeClaim{Name: "pvc-x", Namespace: otherNamespace, Storage: "1Gi"} + result, err := pvc.Create(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, result, otherNamespace) +} + +func TestRBACNamespaceOverride(t *testing.T) { + ctx := context.Background() + role := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: "r1", Namespace: defaultNamespace}} + fakeClient := fake.NewSimpleClientset(role) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + // Explicit namespace exercises the non-default branch of RBAC.namespace. + _, err := (&RBAC{Namespace: defaultNamespace}).ListRoles(ctx, mockCM, false) + assert.NoError(t, err) +} + +func TestStorageClassOperations(t *testing.T) { + ctx := context.Background() + + reclaim := corev1.PersistentVolumeReclaimDelete + binding := storagev1.VolumeBindingWaitForFirstConsumer + expand := true + sc := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: "standard", Annotations: map[string]string{defaultStorageClassAnnotation: "true"}}, + Provisioner: "kubernetes.io/aws-ebs", + ReclaimPolicy: &reclaim, + VolumeBindingMode: &binding, + AllowVolumeExpansion: &expand, + Parameters: map[string]string{"type": "gp3"}, + } + + t.Run("List", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(sc) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + result, err := (&StorageClass{}).List(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, result, "standard (default)") + }) + + t.Run("Get", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(sc) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + result, err := (&StorageClass{Name: "standard"}).Get(ctx, mockCM) + assert.NoError(t, err) + assert.Contains(t, result, "Default: true") + assert.Contains(t, result, "gp3") + + _, err = (&StorageClass{}).Get(ctx, mockCM) + assert.Error(t, err) + }) +} diff --git a/cluster/storageclass.go b/cluster/storageclass.go new file mode 100644 index 0000000..d51dfd6 --- /dev/null +++ b/cluster/storageclass.go @@ -0,0 +1,114 @@ +package cluster + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/basebandit/kai" + storagev1 "k8s.io/api/storage/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const defaultStorageClassAnnotation = "storageclass.kubernetes.io/is-default-class" + +// StorageClass represents an operation target for a cluster-scoped storage class. +type StorageClass struct { + Name string +} + +// List returns all storage classes in the cluster. +func (s *StorageClass) List(ctx context.Context, cm kai.ClusterManager) (string, error) { + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() + + classes, err := client.StorageV1().StorageClasses().List(timeoutCtx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list storage classes: %w", err) + } + + if len(classes.Items) == 0 { + return "No storage classes found", nil + } + + return formatStorageClassList(classes), nil +} + +// Get returns details for a single storage class. +func (s *StorageClass) Get(ctx context.Context, cm kai.ClusterManager) (string, error) { + if s.Name == "" { + return "", fmt.Errorf("storage class name is required") + } + + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + + sc, err := client.StorageV1().StorageClasses().Get(timeoutCtx, s.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get storage class %q: %w", s.Name, err) + } + + return formatStorageClass(sc), nil +} + +func isDefaultStorageClass(sc *storagev1.StorageClass) bool { + return sc.Annotations[defaultStorageClassAnnotation] == "true" +} + +func formatStorageClassList(classes *storagev1.StorageClassList) string { + var sb strings.Builder + fmt.Fprintf(&sb, "Storage Classes (%d):\n", len(classes.Items)) + for i := range classes.Items { + sc := classes.Items[i] + name := sc.Name + if isDefaultStorageClass(&sc) { + name += " (default)" + } + reclaim := "" + if sc.ReclaimPolicy != nil { + reclaim = string(*sc.ReclaimPolicy) + } + binding := "" + if sc.VolumeBindingMode != nil { + binding = string(*sc.VolumeBindingMode) + } + fmt.Fprintf(&sb, "• %s\tprovisioner: %s\treclaim: %s\tbinding: %s\n", + name, sc.Provisioner, reclaim, binding) + } + return strings.TrimRight(sb.String(), "\n") +} + +func formatStorageClass(sc *storagev1.StorageClass) string { + var sb strings.Builder + fmt.Fprintf(&sb, "StorageClass: %s\n", sc.Name) + fmt.Fprintf(&sb, "Default: %t\n", isDefaultStorageClass(sc)) + fmt.Fprintf(&sb, "Provisioner: %s\n", sc.Provisioner) + if sc.ReclaimPolicy != nil { + fmt.Fprintf(&sb, "Reclaim Policy: %s\n", *sc.ReclaimPolicy) + } + if sc.VolumeBindingMode != nil { + fmt.Fprintf(&sb, "Volume Binding Mode: %s\n", *sc.VolumeBindingMode) + } + if sc.AllowVolumeExpansion != nil { + fmt.Fprintf(&sb, "Allow Volume Expansion: %t\n", *sc.AllowVolumeExpansion) + } + if len(sc.Parameters) > 0 { + sb.WriteString("Parameters:\n") + for k, v := range sc.Parameters { + fmt.Fprintf(&sb, " %s: %s\n", k, v) + } + } + fmt.Fprintf(&sb, "Age: %s\n", formatDuration(time.Since(sc.CreationTimestamp.Time))) + return strings.TrimRight(sb.String(), "\n") +} diff --git a/cmd/kai/main.go b/cmd/kai/main.go index 40c89c1..dcb0e4d 100644 --- a/cmd/kai/main.go +++ b/cmd/kai/main.go @@ -209,4 +209,7 @@ func registerAllTools(s *kai.Server, cm *cluster.Manager) { tools.RegisterEventTools(s, cm) tools.RegisterNodeTools(s, cm) tools.RegisterHealthTools(s, cm) + tools.RegisterStorageTools(s, cm) + tools.RegisterRBACTools(s, cm) + tools.RegisterCustomResourceTools(s, cm) } diff --git a/tools/customresource.go b/tools/customresource.go new file mode 100644 index 0000000..efc4b96 --- /dev/null +++ b/tools/customresource.go @@ -0,0 +1,149 @@ +package tools + +import ( + "context" + "fmt" + "log/slog" + + "github.com/basebandit/kai" + "github.com/basebandit/kai/cluster" + "github.com/mark3labs/mcp-go/mcp" +) + +// RegisterCustomResourceTools registers CRD, custom resource and API discovery tools. +func RegisterCustomResourceTools(s kai.ServerInterface, cm kai.ClusterManager) { + s.AddTool(mcp.NewTool("list_crds", + mcp.WithDescription("List all CustomResourceDefinitions registered in the cluster"), + readOnlyAnnotation("List CRDs"), + ), listCRDsHandler(cm)) + + s.AddTool(mcp.NewTool("get_crd", + mcp.WithDescription("Get details about a CustomResourceDefinition, including how to query its instances"), + readOnlyAnnotation("Get CRD"), + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the CRD (e.g. 'widgets.example.com')")), + ), getCRDHandler(cm)) + + s.AddTool(mcp.NewTool("list_custom_resources", + mcp.WithDescription("List instances of a custom resource by group/version/resource"), + readOnlyAnnotation("List custom resources"), + mcp.WithString("group", mcp.Description("API group (e.g. 'example.com'; empty for core)")), + mcp.WithString("version", mcp.Required(), mcp.Description("API version (e.g. 'v1')")), + mcp.WithString("resource", mcp.Required(), mcp.Description("Plural resource name (e.g. 'widgets')")), + mcp.WithString("namespace", mcp.Description("Namespace (defaults to current; ignored for cluster-scoped)")), + mcp.WithBoolean("all_namespaces", mcp.Description("List across all namespaces")), + ), listCustomResourcesHandler(cm)) + + s.AddTool(mcp.NewTool("get_custom_resource", + mcp.WithDescription("Get a single custom resource instance by group/version/resource/name"), + readOnlyAnnotation("Get custom resource"), + mcp.WithString("group", mcp.Description("API group (e.g. 'example.com'; empty for core)")), + mcp.WithString("version", mcp.Required(), mcp.Description("API version (e.g. 'v1')")), + mcp.WithString("resource", mcp.Required(), mcp.Description("Plural resource name (e.g. 'widgets')")), + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the resource instance")), + mcp.WithString("namespace", mcp.Description("Namespace (defaults to current; ignored for cluster-scoped)")), + ), getCustomResourceHandler(cm)) + + s.AddTool(mcp.NewTool("list_api_resources", + mcp.WithDescription("List the server's preferred API resources (like 'kubectl api-resources')"), + readOnlyAnnotation("List API resources"), + ), listAPIResourcesHandler(cm)) +} + +func listCRDsHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Debug("tool invoked", slog.String("tool", "list_crds")) + cr := cluster.CustomResource{} + result, err := cr.ListCRDs(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to list CRDs: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func getCRDHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, errResult := requireName(request) + if errResult != nil { + return errResult, nil + } + cr := cluster.CustomResource{Name: name} + result, err := cr.GetCRD(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to get CRD: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func customResourceFromRequest(request mcp.CallToolRequest) (cluster.CustomResource, *mcp.CallToolResult) { + cr := cluster.CustomResource{} + if g, ok := request.GetArguments()["group"].(string); ok { + cr.Group = g + } + version, ok := request.GetArguments()["version"].(string) + if !ok || version == "" { + return cr, mcp.NewToolResultText("Required parameter 'version' is missing") + } + cr.Version = version + resource, ok := request.GetArguments()["resource"].(string) + if !ok || resource == "" { + return cr, mcp.NewToolResultText("Required parameter 'resource' is missing") + } + cr.Resource = resource + if ns, ok := request.GetArguments()["namespace"].(string); ok { + cr.Namespace = ns + } + return cr, nil +} + +func listCustomResourcesHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Debug("tool invoked", slog.String("tool", "list_custom_resources")) + cr, errResult := customResourceFromRequest(request) + if errResult != nil { + return errResult, nil + } + allNamespaces := false + if all, ok := request.GetArguments()["all_namespaces"].(bool); ok { + allNamespaces = all + } + result, err := cr.List(ctx, cm, allNamespaces) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to list custom resources: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func getCustomResourceHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Debug("tool invoked", slog.String("tool", "get_custom_resource")) + cr, errResult := customResourceFromRequest(request) + if errResult != nil { + return errResult, nil + } + name, errResult := requireName(request) + if errResult != nil { + return errResult, nil + } + cr.Name = name + result, err := cr.Get(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to get custom resource: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func listAPIResourcesHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Debug("tool invoked", slog.String("tool", "list_api_resources")) + cr := cluster.CustomResource{} + result, err := cr.ListAPIResources(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to list API resources: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} diff --git a/tools/customresource_test.go b/tools/customresource_test.go new file mode 100644 index 0000000..7fc7982 --- /dev/null +++ b/tools/customresource_test.go @@ -0,0 +1,95 @@ +package tools + +import ( + "context" + "testing" + + "github.com/basebandit/kai/testmocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + 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/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + kfake "k8s.io/client-go/kubernetes/fake" +) + +var ( + crdGVRTest = schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"} + widgetGVRTest = schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "widgets"} + crListKinds = map[schema.GroupVersionResource]string{ + crdGVRTest: "CustomResourceDefinitionList", + widgetGVRTest: "WidgetList", + } +) + +func TestRegisterCustomResourceTools(t *testing.T) { + mockServer := &testmocks.MockServer{} + mockCM := testmocks.NewMockClusterManager() + mockServer.On("AddTool", mock.AnythingOfType("mcp.Tool"), mock.AnythingOfType("server.ToolHandlerFunc")).Return().Times(5) + RegisterCustomResourceTools(mockServer, mockCM) + mockServer.AssertExpectations(t) +} + +func TestCustomResourceHandlers(t *testing.T) { + ctx := context.Background() + + crd := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]interface{}{"name": "widgets.example.com"}, + "spec": map[string]interface{}{ + "group": "example.com", "scope": "Namespaced", + "names": map[string]interface{}{"kind": "Widget", "plural": "widgets"}, + "versions": []interface{}{map[string]interface{}{"name": "v1", "served": true}}, + }, + }} + widget := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "example.com/v1", "kind": "Widget", + "metadata": map[string]interface{}{"name": "w1", "namespace": defaultNamespace}, + }} + + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), crListKinds) + _, err := dyn.Resource(crdGVRTest).Create(ctx, crd, metav1.CreateOptions{}) + assert.NoError(t, err) + _, err = dyn.Resource(widgetGVRTest).Namespace(defaultNamespace).Create(ctx, widget, metav1.CreateOptions{}) + assert.NoError(t, err) + + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentDynamicClient").Return(dyn, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + + r, err := listCRDsHandler(mockCM)(ctx, toolRequest(nil)) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "widgets.example.com") + + r, err = getCRDHandler(mockCM)(ctx, toolRequest(map[string]interface{}{"name": "widgets.example.com"})) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "Group: example.com") + + r, err = listCustomResourcesHandler(mockCM)(ctx, toolRequest(map[string]interface{}{ + "group": "example.com", "version": "v1", "resource": "widgets", + })) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "w1") + + r, err = getCustomResourceHandler(mockCM)(ctx, toolRequest(map[string]interface{}{ + "group": "example.com", "version": "v1", "resource": "widgets", "name": "w1", + })) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "Widget: w1") + + // Missing required version. + r, err = listCustomResourcesHandler(mockCM)(ctx, toolRequest(map[string]interface{}{"resource": "widgets"})) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "version") + + // API discovery (fake returns none). + clientset := kfake.NewSimpleClientset() + discCM := testmocks.NewMockClusterManager() + discCM.On("GetCurrentClient").Return(clientset, nil) + r, err = listAPIResourcesHandler(discCM)(ctx, toolRequest(nil)) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "API Resources") +} diff --git a/tools/rbac.go b/tools/rbac.go new file mode 100644 index 0000000..af7f6a1 --- /dev/null +++ b/tools/rbac.go @@ -0,0 +1,113 @@ +package tools + +import ( + "context" + "fmt" + "log/slog" + + "github.com/basebandit/kai" + "github.com/basebandit/kai/cluster" + "github.com/mark3labs/mcp-go/mcp" +) + +// RegisterRBACTools registers read-only RBAC inspection tools. +func RegisterRBACTools(s kai.ServerInterface, cm kai.ClusterManager) { + nsArg := mcp.WithString("namespace", mcp.Description("Namespace (defaults to current)")) + allNsArg := mcp.WithBoolean("all_namespaces", mcp.Description("List across all namespaces")) + nameArg := mcp.WithString("name", mcp.Required(), mcp.Description("Resource name")) + + s.AddTool(mcp.NewTool("list_roles", mcp.WithDescription("List RBAC roles in a namespace"), + readOnlyAnnotation("List roles"), nsArg, allNsArg), rbacListHandler(cm, "role")) + s.AddTool(mcp.NewTool("get_role", mcp.WithDescription("Get an RBAC role with its rules"), + readOnlyAnnotation("Get role"), nameArg, nsArg), rbacGetHandler(cm, "role")) + + s.AddTool(mcp.NewTool("list_role_bindings", mcp.WithDescription("List RBAC role bindings in a namespace"), + readOnlyAnnotation("List role bindings"), nsArg, allNsArg), rbacListHandler(cm, "rolebinding")) + s.AddTool(mcp.NewTool("get_role_binding", mcp.WithDescription("Get an RBAC role binding"), + readOnlyAnnotation("Get role binding"), nameArg, nsArg), rbacGetHandler(cm, "rolebinding")) + + s.AddTool(mcp.NewTool("list_cluster_roles", mcp.WithDescription("List cluster roles"), + readOnlyAnnotation("List cluster roles")), rbacListHandler(cm, "clusterrole")) + s.AddTool(mcp.NewTool("get_cluster_role", mcp.WithDescription("Get a cluster role with its rules"), + readOnlyAnnotation("Get cluster role"), nameArg), rbacGetHandler(cm, "clusterrole")) + + s.AddTool(mcp.NewTool("list_cluster_role_bindings", mcp.WithDescription("List cluster role bindings"), + readOnlyAnnotation("List cluster role bindings")), rbacListHandler(cm, "clusterrolebinding")) + s.AddTool(mcp.NewTool("get_cluster_role_binding", mcp.WithDescription("Get a cluster role binding"), + readOnlyAnnotation("Get cluster role binding"), nameArg), rbacGetHandler(cm, "clusterrolebinding")) + + s.AddTool(mcp.NewTool("list_service_accounts", mcp.WithDescription("List service accounts in a namespace"), + readOnlyAnnotation("List service accounts"), nsArg, allNsArg), rbacListHandler(cm, "serviceaccount")) + s.AddTool(mcp.NewTool("get_service_account", mcp.WithDescription("Get a service account"), + readOnlyAnnotation("Get service account"), nameArg, nsArg), rbacGetHandler(cm, "serviceaccount")) +} + +func rbacListHandler(cm kai.ClusterManager, kind string) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Debug("tool invoked", slog.String("tool", "list_"+kind)) + rbac := cluster.RBAC{} + if ns, ok := request.GetArguments()["namespace"].(string); ok { + rbac.Namespace = ns + } + allNamespaces := false + if all, ok := request.GetArguments()["all_namespaces"].(bool); ok { + allNamespaces = all + } + + var ( + result string + err error + ) + switch kind { + case "role": + result, err = rbac.ListRoles(ctx, cm, allNamespaces) + case "rolebinding": + result, err = rbac.ListRoleBindings(ctx, cm, allNamespaces) + case "clusterrole": + result, err = rbac.ListClusterRoles(ctx, cm) + case "clusterrolebinding": + result, err = rbac.ListClusterRoleBindings(ctx, cm) + case "serviceaccount": + result, err = rbac.ListServiceAccounts(ctx, cm, allNamespaces) + } + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to list %s: %s", kind, err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func rbacGetHandler(cm kai.ClusterManager, kind string) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Debug("tool invoked", slog.String("tool", "get_"+kind)) + name, errResult := requireName(request) + if errResult != nil { + return errResult, nil + } + rbac := cluster.RBAC{Name: name} + if ns, ok := request.GetArguments()["namespace"].(string); ok { + rbac.Namespace = ns + } + + var ( + result string + err error + ) + switch kind { + case "role": + result, err = rbac.GetRole(ctx, cm) + case "rolebinding": + result, err = rbac.GetRoleBinding(ctx, cm) + case "clusterrole": + result, err = rbac.GetClusterRole(ctx, cm) + case "clusterrolebinding": + result, err = rbac.GetClusterRoleBinding(ctx, cm) + case "serviceaccount": + result, err = rbac.GetServiceAccount(ctx, cm) + } + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to get %s: %s", kind, err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} diff --git a/tools/rbac_test.go b/tools/rbac_test.go new file mode 100644 index 0000000..8c78976 --- /dev/null +++ b/tools/rbac_test.go @@ -0,0 +1,79 @@ +package tools + +import ( + "context" + "testing" + + "github.com/basebandit/kai/testmocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestRegisterRBACTools(t *testing.T) { + mockServer := &testmocks.MockServer{} + mockCM := testmocks.NewMockClusterManager() + mockServer.On("AddTool", mock.AnythingOfType("mcp.Tool"), mock.AnythingOfType("server.ToolHandlerFunc")).Return().Times(10) + RegisterRBACTools(mockServer, mockCM) + mockServer.AssertExpectations(t) +} + +func TestRBACHandlers(t *testing.T) { + ctx := context.Background() + + newCM := func() (*testmocks.MockClusterManager, *fake.Clientset) { + fakeClient := fake.NewSimpleClientset( + &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: "r1", Namespace: defaultNamespace}}, + &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "rb1", Namespace: defaultNamespace}, RoleRef: rbacv1.RoleRef{Kind: "Role", Name: "r1"}}, + &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "cr1"}}, + &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "crb1"}, RoleRef: rbacv1.RoleRef{Kind: "ClusterRole", Name: "cr1"}}, + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "sa1", Namespace: defaultNamespace}}, + ) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + return mockCM, fakeClient + } + + listCases := []struct { + kind, want string + }{ + {"role", "r1"}, + {"rolebinding", "rb1"}, + {"clusterrole", "cr1"}, + {"clusterrolebinding", "crb1"}, + {"serviceaccount", "sa1"}, + } + for _, tc := range listCases { + mockCM, _ := newCM() + r, err := rbacListHandler(mockCM, tc.kind)(ctx, toolRequest(nil)) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), tc.want) + } + + getCases := []struct { + kind, name, want string + }{ + {"role", "r1", "Role: r1"}, + {"rolebinding", "rb1", "RoleBinding: rb1"}, + {"clusterrole", "cr1", "ClusterRole: cr1"}, + {"clusterrolebinding", "crb1", "ClusterRoleBinding: crb1"}, + {"serviceaccount", "sa1", "ServiceAccount: sa1"}, + } + for _, tc := range getCases { + mockCM, _ := newCM() + r, err := rbacGetHandler(mockCM, tc.kind)(ctx, toolRequest(map[string]interface{}{"name": tc.name})) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), tc.want) + } + + t.Run("GetMissingName", func(t *testing.T) { + mockCM, _ := newCM() + r, err := rbacGetHandler(mockCM, "role")(ctx, toolRequest(map[string]interface{}{})) + assert.NoError(t, err) + assert.Equal(t, errMissingName, resultText(t, r)) + }) +} diff --git a/tools/storage.go b/tools/storage.go new file mode 100644 index 0000000..7a0cfc6 --- /dev/null +++ b/tools/storage.go @@ -0,0 +1,250 @@ +package tools + +import ( + "context" + "fmt" + "log/slog" + + "github.com/basebandit/kai" + "github.com/basebandit/kai/cluster" + "github.com/mark3labs/mcp-go/mcp" +) + +// RegisterStorageTools registers persistent volume, PVC and storage class tools. +func RegisterStorageTools(s kai.ServerInterface, cm kai.ClusterManager) { + s.AddTool(mcp.NewTool("list_persistent_volumes", + mcp.WithDescription("List all persistent volumes (cluster-scoped)"), + readOnlyAnnotation("List persistent volumes"), + ), listPVHandler(cm)) + + s.AddTool(mcp.NewTool("get_persistent_volume", + mcp.WithDescription("Get details about a specific persistent volume"), + readOnlyAnnotation("Get persistent volume"), + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the persistent volume")), + ), getPVHandler(cm)) + + s.AddTool(mcp.NewTool("delete_persistent_volume", + mcp.WithDescription("Delete a persistent volume"), + destructiveAnnotation("Delete persistent volume"), + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the persistent volume")), + ), deletePVHandler(cm)) + + s.AddTool(mcp.NewTool("create_persistent_volume_claim", + mcp.WithDescription("Create a persistent volume claim"), + creationAnnotation("Create PVC"), + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the PVC")), + mcp.WithString("namespace", mcp.Description("Namespace (defaults to current)")), + mcp.WithString("storage", mcp.Required(), mcp.Description("Requested storage, e.g. '1Gi'")), + mcp.WithString("storage_class", mcp.Description("Storage class name")), + mcp.WithString("volume_mode", mcp.Description("Volume mode: Filesystem (default) or Block")), + mcp.WithArray("access_modes", mcp.Description("Access modes (ReadWriteOnce, ReadOnlyMany, ReadWriteMany, ReadWriteOncePod)")), + ), createPVCHandler(cm)) + + s.AddTool(mcp.NewTool("list_persistent_volume_claims", + mcp.WithDescription("List persistent volume claims in a namespace"), + readOnlyAnnotation("List PVCs"), + mcp.WithString("namespace", mcp.Description("Namespace (defaults to current)")), + mcp.WithBoolean("all_namespaces", mcp.Description("List across all namespaces")), + mcp.WithString("label_selector", mcp.Description("Label selector to filter PVCs")), + ), listPVCHandler(cm)) + + s.AddTool(mcp.NewTool("get_persistent_volume_claim", + mcp.WithDescription("Get details about a specific persistent volume claim"), + readOnlyAnnotation("Get PVC"), + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the PVC")), + mcp.WithString("namespace", mcp.Description("Namespace (defaults to current)")), + ), getPVCHandler(cm)) + + s.AddTool(mcp.NewTool("delete_persistent_volume_claim", + mcp.WithDescription("Delete a persistent volume claim"), + destructiveAnnotation("Delete PVC"), + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the PVC")), + mcp.WithString("namespace", mcp.Description("Namespace (defaults to current)")), + ), deletePVCHandler(cm)) + + s.AddTool(mcp.NewTool("list_storage_classes", + mcp.WithDescription("List all storage classes in the cluster"), + readOnlyAnnotation("List storage classes"), + ), listStorageClassHandler(cm)) + + s.AddTool(mcp.NewTool("get_storage_class", + mcp.WithDescription("Get details about a specific storage class"), + readOnlyAnnotation("Get storage class"), + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the storage class")), + ), getStorageClassHandler(cm)) +} + +func requireName(request mcp.CallToolRequest) (string, *mcp.CallToolResult) { + nameArg, ok := request.GetArguments()["name"] + if !ok || nameArg == nil { + return "", mcp.NewToolResultText(errMissingName) + } + name, ok := nameArg.(string) + if !ok || name == "" { + return "", mcp.NewToolResultText(errEmptyName) + } + return name, nil +} + +func listPVHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Debug("tool invoked", slog.String("tool", "list_persistent_volumes")) + pv := cluster.PersistentVolume{} + result, err := pv.List(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to list persistent volumes: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func getPVHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, errResult := requireName(request) + if errResult != nil { + return errResult, nil + } + pv := cluster.PersistentVolume{Name: name} + result, err := pv.Get(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to get persistent volume: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func deletePVHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, errResult := requireName(request) + if errResult != nil { + return errResult, nil + } + pv := cluster.PersistentVolume{Name: name} + result, err := pv.Delete(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to delete persistent volume: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func createPVCHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Debug("tool invoked", slog.String("tool", "create_persistent_volume_claim")) + name, errResult := requireName(request) + if errResult != nil { + return errResult, nil + } + pvc := cluster.PersistentVolumeClaim{Name: name} + if ns, ok := request.GetArguments()["namespace"].(string); ok { + pvc.Namespace = ns + } + if storage, ok := request.GetArguments()["storage"].(string); ok { + pvc.Storage = storage + } + if sc, ok := request.GetArguments()["storage_class"].(string); ok { + pvc.StorageClassName = sc + } + if vm, ok := request.GetArguments()["volume_mode"].(string); ok { + pvc.VolumeMode = vm + } + if modes, ok := request.GetArguments()["access_modes"].([]interface{}); ok { + for _, m := range modes { + if s, ok := m.(string); ok { + pvc.AccessModes = append(pvc.AccessModes, s) + } + } + } + result, err := pvc.Create(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to create PVC: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func listPVCHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Debug("tool invoked", slog.String("tool", "list_persistent_volume_claims")) + pvc := cluster.PersistentVolumeClaim{} + if ns, ok := request.GetArguments()["namespace"].(string); ok { + pvc.Namespace = ns + } + allNamespaces := false + if all, ok := request.GetArguments()["all_namespaces"].(bool); ok { + allNamespaces = all + } + labelSelector := "" + if ls, ok := request.GetArguments()["label_selector"].(string); ok { + labelSelector = ls + } + result, err := pvc.List(ctx, cm, allNamespaces, labelSelector) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to list PVCs: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func getPVCHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, errResult := requireName(request) + if errResult != nil { + return errResult, nil + } + pvc := cluster.PersistentVolumeClaim{Name: name} + if ns, ok := request.GetArguments()["namespace"].(string); ok { + pvc.Namespace = ns + } + result, err := pvc.Get(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to get PVC: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func deletePVCHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, errResult := requireName(request) + if errResult != nil { + return errResult, nil + } + pvc := cluster.PersistentVolumeClaim{Name: name} + if ns, ok := request.GetArguments()["namespace"].(string); ok { + pvc.Namespace = ns + } + result, err := pvc.Delete(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to delete PVC: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func listStorageClassHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + slog.Debug("tool invoked", slog.String("tool", "list_storage_classes")) + sc := cluster.StorageClass{} + result, err := sc.List(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to list storage classes: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func getStorageClassHandler(cm kai.ClusterManager) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, errResult := requireName(request) + if errResult != nil { + return errResult, nil + } + sc := cluster.StorageClass{Name: name} + result, err := sc.Get(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to get storage class: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} diff --git a/tools/storage_test.go b/tools/storage_test.go new file mode 100644 index 0000000..dbe5afb --- /dev/null +++ b/tools/storage_test.go @@ -0,0 +1,103 @@ +package tools + +import ( + "context" + "testing" + + "github.com/basebandit/kai/testmocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestRegisterStorageTools(t *testing.T) { + mockServer := &testmocks.MockServer{} + mockCM := testmocks.NewMockClusterManager() + mockServer.On("AddTool", mock.AnythingOfType("mcp.Tool"), mock.AnythingOfType("server.ToolHandlerFunc")).Return().Times(9) + RegisterStorageTools(mockServer, mockCM) + mockServer.AssertExpectations(t) +} + +func TestStorageHandlers(t *testing.T) { + ctx := context.Background() + + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "pv-1"}, + Spec: corev1.PersistentVolumeSpec{ + Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("5Gi")}, + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + } + scReclaim := corev1.PersistentVolumeReclaimDelete + sc := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: "standard"}, + Provisioner: "p", + ReclaimPolicy: &scReclaim, + } + + t.Run("PVHandlers", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(pv) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + r, err := listPVHandler(mockCM)(ctx, toolRequest(nil)) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "pv-1") + + r, err = getPVHandler(mockCM)(ctx, toolRequest(map[string]interface{}{"name": "pv-1"})) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "PersistentVolume: pv-1") + + r, err = getPVHandler(mockCM)(ctx, toolRequest(map[string]interface{}{})) + assert.NoError(t, err) + assert.Equal(t, errMissingName, resultText(t, r)) + + r, err = deletePVHandler(mockCM)(ctx, toolRequest(map[string]interface{}{"name": "pv-1"})) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "deleted") + }) + + t.Run("PVCHandlers", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset() + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + + r, err := createPVCHandler(mockCM)(ctx, toolRequest(map[string]interface{}{ + "name": "pvc-1", "storage": "1Gi", "storage_class": "standard", + "volume_mode": "Filesystem", "access_modes": []interface{}{"ReadWriteOnce"}, + })) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "pvc-1") + + r, err = listPVCHandler(mockCM)(ctx, toolRequest(map[string]interface{}{"all_namespaces": true})) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "pvc-1") + + r, err = getPVCHandler(mockCM)(ctx, toolRequest(map[string]interface{}{"name": "pvc-1"})) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "PersistentVolumeClaim: pvc-1") + + r, err = deletePVCHandler(mockCM)(ctx, toolRequest(map[string]interface{}{"name": "pvc-1"})) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "deleted") + }) + + t.Run("StorageClassHandlers", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(sc) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + r, err := listStorageClassHandler(mockCM)(ctx, toolRequest(nil)) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "standard") + + r, err = getStorageClassHandler(mockCM)(ctx, toolRequest(map[string]interface{}{"name": "standard"})) + assert.NoError(t, err) + assert.Contains(t, resultText(t, r), "StorageClass: standard") + }) +} diff --git a/types.go b/types.go index dfeacc2..858a716 100644 --- a/types.go +++ b/types.go @@ -170,3 +170,15 @@ type IngressBackend struct { ServiceName string ServicePort interface{} // Can be int32 or string } + +// PVCParams holds all possible PersistentVolumeClaim configuration parameters +type PVCParams struct { + Name string + Namespace string + StorageClassName string + AccessModes []string + Storage string // requested storage, e.g. "1Gi" + VolumeMode string // Filesystem or Block + Labels map[string]interface{} + Annotations map[string]interface{} +}