diff --git a/README.md b/README.md index 8bcf467..bfb9004 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ Kai provides a bridge between large language models (LLMs) and your Kubernetes c ### Cluster Operations - [x] **Context Management** - Switch contexts, list contexts, rename, delete -- [ ] **Nodes** - Node monitoring, cordoning, and draining -- [ ] **Cluster Health** - Cluster status and resource metrics +- [x] **Nodes** - Node monitoring, cordoning, and draining (list, get, cordon, uncordon, drain) +- [x] **Cluster Health** - Cluster status and resource metrics (cluster health, node/pod metrics) ### Storage - [ ] **Persistent Volumes** - PV and PVC management @@ -44,7 +44,7 @@ Kai provides a bridge between large language models (LLMs) and your Kubernetes c ### Advanced - [ ] **Custom Resources** - CRD and custom resource operations -- [ ] **Events** - Event streaming and filtering +- [x] **Events** - Event listing and filtering (by namespace, type, involved object) - [ ] **API Discovery** - API resource exploration ## Requirements @@ -65,6 +65,7 @@ kai [options] Options: -kubeconfig string Path to kubeconfig file (default "~/.kube/config") -context string Name for the loaded context (default "local") + -in-cluster Use in-cluster Kubernetes configuration (for running inside a pod) -transport string Transport mode: stdio (default) or sse -sse-addr string Address for SSE server (default ":8080") -log-format string Log format: json (default) or text @@ -169,6 +170,42 @@ By default, Kai uses `~/.kube/config`. You can specify a different kubeconfig: kai -kubeconfig=/path/to/custom/kubeconfig -context=my-cluster ``` +### Running Inside a Kubernetes Cluster + +When deploying Kai inside a Kubernetes cluster, use the `-in-cluster` flag to automatically use the pod's service account credentials: + +```sh +kai -in-cluster -transport=sse -sse-addr=:8080 +``` + +Example Kubernetes deployment: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kai +spec: + replicas: 1 + selector: + matchLabels: + app: kai + template: + metadata: + labels: + app: kai + spec: + serviceAccountName: kai + containers: + - name: kai + image: ghcr.io/basebandit/kai:latest + args: ["-in-cluster", "-transport=sse", "-sse-addr=:8080"] + ports: + - containerPort: 8080 +``` + +Make sure the service account has appropriate RBAC permissions for the Kubernetes resources you want to manage. + ## Usage Examples Once configured, you can interact with your cluster using natural language: diff --git a/cluster/event.go b/cluster/event.go new file mode 100644 index 0000000..77e8784 --- /dev/null +++ b/cluster/event.go @@ -0,0 +1,110 @@ +package cluster + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/basebandit/kai" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" +) + +// Event represents a query for Kubernetes events. +type Event struct { + Namespace string + AllNamespaces bool + Type string // "Warning" or "Normal"; empty means all types + InvolvedObject string // filter to a single involved object by name + Limit int64 +} + +// List returns events for the requested scope, most recent first. +func (e *Event) List(ctx context.Context, cm kai.ClusterManager) (string, error) { + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + + namespace := "" + if !e.AllNamespaces { + namespace = e.Namespace + if namespace == "" { + namespace = cm.GetCurrentNamespace() + } + } + + listOptions := metav1.ListOptions{} + if e.Limit > 0 { + listOptions.Limit = e.Limit + } + + var selectors []fields.Selector + if e.Type != "" { + selectors = append(selectors, fields.OneTermEqualSelector("type", e.Type)) + } + if e.InvolvedObject != "" { + selectors = append(selectors, fields.OneTermEqualSelector("involvedObject.name", e.InvolvedObject)) + } + if len(selectors) > 0 { + listOptions.FieldSelector = fields.AndSelectors(selectors...).String() + } + + timeoutCtx, cancel := context.WithTimeout(ctx, listTimeout) + defer cancel() + + events, err := client.CoreV1().Events(namespace).List(timeoutCtx, listOptions) + if err != nil { + return "", fmt.Errorf("failed to list events: %w", err) + } + + if len(events.Items) == 0 { + return "No events found", nil + } + + return formatEventList(events, e.AllNamespaces), nil +} + +func eventTime(e corev1.Event) metav1.Time { + if !e.LastTimestamp.IsZero() { + return e.LastTimestamp + } + if !e.EventTime.IsZero() { + return metav1.Time{Time: e.EventTime.Time} + } + return e.FirstTimestamp +} + +func formatEventList(events *corev1.EventList, allNamespaces bool) string { + items := make([]corev1.Event, len(events.Items)) + copy(items, events.Items) + sort.Slice(items, func(i, j int) bool { + return eventTime(items[i]).After(eventTime(items[j]).Time) + }) + + var sb strings.Builder + fmt.Fprintf(&sb, "Events (%d):\n", len(items)) + for _, ev := range items { + obj := ev.InvolvedObject.Kind + if ev.InvolvedObject.Name != "" { + obj = fmt.Sprintf("%s/%s", ev.InvolvedObject.Kind, ev.InvolvedObject.Name) + } + age := formatDuration(time.Since(eventTime(ev).Time)) + line := fmt.Sprintf("• [%s] %s", ev.Type, ev.Reason) + if allNamespaces { + line += fmt.Sprintf(" (ns: %s)", ev.Namespace) + } + sb.WriteString(line + "\n") + fmt.Fprintf(&sb, " object: %s\n", obj) + if ev.Count > 1 { + fmt.Fprintf(&sb, " count: %d, last seen: %s ago\n", ev.Count, age) + } else { + fmt.Fprintf(&sb, " last seen: %s ago\n", age) + } + fmt.Fprintf(&sb, " message: %s\n", strings.TrimSpace(ev.Message)) + } + return strings.TrimRight(sb.String(), "\n") +} diff --git a/cluster/event_test.go b/cluster/event_test.go new file mode 100644 index 0000000..9503140 --- /dev/null +++ b/cluster/event_test.go @@ -0,0 +1,96 @@ +package cluster + +import ( + "context" + "testing" + + "github.com/basebandit/kai/testmocks" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func newEvent(name, namespace, evType, reason, objName string) *corev1.Event { + return &corev1.Event{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Type: evType, + Reason: reason, + Message: reason + " message", + Count: 1, + LastTimestamp: metav1.Now(), + InvolvedObject: corev1.ObjectReference{Kind: "Pod", Name: objName, Namespace: namespace}, + } +} + +func TestEventList(t *testing.T) { + ctx := context.Background() + + t.Run("ListsEventsInNamespace", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset( + newEvent("e1", defaultNamespace, "Warning", "BackOff", "pod-a"), + newEvent("e2", defaultNamespace, "Normal", "Pulled", "pod-b"), + ) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + + event := &Event{Namespace: defaultNamespace} + result, err := event.List(ctx, mockCM) + + assert.NoError(t, err) + assert.Contains(t, result, "Events (2)") + assert.Contains(t, result, "BackOff") + assert.Contains(t, result, "Pod/pod-a") + }) + + t.Run("NoEvents", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset() + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + + event := &Event{Namespace: defaultNamespace} + result, err := event.List(ctx, mockCM) + + assert.NoError(t, err) + assert.Equal(t, "No events found", result) + }) + + t.Run("DefaultsToCurrentNamespace", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(newEvent("e1", defaultNamespace, "Warning", "Failed", "pod-a")) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + + event := &Event{} + result, err := event.List(ctx, mockCM) + + assert.NoError(t, err) + assert.Contains(t, result, "Failed") + }) + + t.Run("AllNamespacesFormatting", func(t *testing.T) { + e1 := newEvent("e1", defaultNamespace, "Warning", "BackOff", "pod-a") + e1.Count = 5 + // Event with only EventTime set (no LastTimestamp) exercises eventTime fallback. + e2 := newEvent("e2", otherNamespace, "Normal", "Pulled", "pod-b") + e2.LastTimestamp = metav1.Time{} + e2.EventTime = metav1.NowMicro() + // Event with only FirstTimestamp set. + e3 := newEvent("e3", otherNamespace, "Normal", "Created", "pod-c") + e3.LastTimestamp = metav1.Time{} + e3.FirstTimestamp = metav1.Now() + + fakeClient := fake.NewSimpleClientset(e1, e2, e3) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + event := &Event{AllNamespaces: true} + result, err := event.List(ctx, mockCM) + + assert.NoError(t, err) + assert.Contains(t, result, "ns: "+otherNamespace) + assert.Contains(t, result, "count: 5") + }) +} diff --git a/cluster/health.go b/cluster/health.go new file mode 100644 index 0000000..a3058bf --- /dev/null +++ b/cluster/health.go @@ -0,0 +1,157 @@ +package cluster + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/basebandit/kai" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// Health reports overall cluster status and resource usage. +type Health struct{} + +var ( + nodeMetricsGVR = schema.GroupVersionResource{Group: "metrics.k8s.io", Version: "v1beta1", Resource: "nodes"} + podMetricsGVR = schema.GroupVersionResource{Group: "metrics.k8s.io", Version: "v1beta1", Resource: "pods"} +) + +// Cluster summarises node readiness and pod phase distribution. +func (h *Health) Cluster(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() + + nodes, err := client.CoreV1().Nodes().List(timeoutCtx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list nodes: %w", err) + } + + pods, err := client.CoreV1().Pods("").List(timeoutCtx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list pods: %w", err) + } + + var ready, notReady, unschedulable int + for i := range nodes.Items { + node := nodes.Items[i] + if nodeReadyStatus(&node) == "Ready" { + ready++ + } else { + notReady++ + } + if node.Spec.Unschedulable { + unschedulable++ + } + } + + phases := map[corev1.PodPhase]int{} + for i := range pods.Items { + phases[pods.Items[i].Status.Phase]++ + } + + var sb strings.Builder + sb.WriteString("Cluster Health\n") + fmt.Fprintf(&sb, "Nodes: %d total, %d ready, %d not ready", len(nodes.Items), ready, notReady) + if unschedulable > 0 { + fmt.Fprintf(&sb, ", %d unschedulable", unschedulable) + } + sb.WriteString("\n") + fmt.Fprintf(&sb, "Pods: %d total\n", len(pods.Items)) + + phaseOrder := []corev1.PodPhase{corev1.PodRunning, corev1.PodPending, corev1.PodSucceeded, corev1.PodFailed, corev1.PodUnknown} + for _, phase := range phaseOrder { + if count, ok := phases[phase]; ok { + fmt.Fprintf(&sb, " %s: %d\n", phase, count) + delete(phases, phase) + } + } + for phase, count := range phases { + fmt.Fprintf(&sb, " %s: %d\n", phase, count) + } + + overall := "Healthy" + if notReady > 0 || phases[corev1.PodFailed] > 0 { + overall = "Degraded" + } + fmt.Fprintf(&sb, "Overall: %s", overall) + + return strings.TrimRight(sb.String(), "\n"), nil +} + +// NodeMetrics reports CPU/memory usage per node via the metrics API. +func (h *Health) NodeMetrics(ctx context.Context, cm kai.ClusterManager) (string, error) { + return h.resourceMetrics(ctx, cm, nodeMetricsGVR, "", "Node metrics") +} + +// PodMetrics reports CPU/memory usage per pod via the metrics API. +func (h *Health) PodMetrics(ctx context.Context, cm kai.ClusterManager, namespace string, allNamespaces bool) (string, error) { + ns := "" + if !allNamespaces { + ns = namespace + if ns == "" { + ns = cm.GetCurrentNamespace() + } + } + return h.resourceMetrics(ctx, cm, podMetricsGVR, ns, "Pod metrics") +} + +func (h *Health) resourceMetrics(ctx context.Context, cm kai.ClusterManager, gvr schema.GroupVersionResource, namespace, title string) (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() + + var list *unstructured.UnstructuredList + if namespace != "" { + list, err = dyn.Resource(gvr).Namespace(namespace).List(timeoutCtx, metav1.ListOptions{}) + } else { + list, err = dyn.Resource(gvr).List(timeoutCtx, metav1.ListOptions{}) + } + if err != nil { + // metrics-server may not be installed; degrade gracefully. + return fmt.Sprintf("%s unavailable: %v\n(Is metrics-server installed in the cluster?)", title, err), nil + } + + if len(list.Items) == 0 { + return fmt.Sprintf("No %s available", strings.ToLower(title)), nil + } + + type usage struct{ name, ns, cpu, mem string } + rows := make([]usage, 0, len(list.Items)) + for i := range list.Items { + item := list.Items[i] + u := usage{name: item.GetName(), ns: item.GetNamespace()} + if c, found, _ := unstructured.NestedString(item.Object, "usage", "cpu"); found { + u.cpu = c + } + if m, found, _ := unstructured.NestedString(item.Object, "usage", "memory"); found { + u.mem = m + } + rows = append(rows, u) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].name < rows[j].name }) + + var sb strings.Builder + fmt.Fprintf(&sb, "%s (%d):\n", title, len(rows)) + for _, r := range rows { + if r.ns != "" { + fmt.Fprintf(&sb, "• %s/%s\tcpu: %s\tmemory: %s\n", r.ns, r.name, r.cpu, r.mem) + } else { + fmt.Fprintf(&sb, "• %s\tcpu: %s\tmemory: %s\n", r.name, r.cpu, r.mem) + } + } + return strings.TrimRight(sb.String(), "\n"), nil +} diff --git a/cluster/health_metrics_test.go b/cluster/health_metrics_test.go new file mode 100644 index 0000000..5e464a6 --- /dev/null +++ b/cluster/health_metrics_test.go @@ -0,0 +1,110 @@ +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" +) + +var testMetricsListKinds = map[schema.GroupVersionResource]string{ + nodeMetricsGVR: "NodeMetricsList", + podMetricsGVR: "PodMetricsList", +} + +func nodeMetric(name, cpu, mem string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "metrics.k8s.io/v1beta1", + "kind": "NodeMetrics", + "metadata": map[string]interface{}{"name": name}, + "usage": map[string]interface{}{"cpu": cpu, "memory": mem}, + }} +} + +func podMetric(name, namespace, cpu, mem string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "metrics.k8s.io/v1beta1", + "kind": "PodMetrics", + "metadata": map[string]interface{}{"name": name, "namespace": namespace}, + "usage": map[string]interface{}{"cpu": cpu, "memory": mem}, + }} +} + +func newMetricsClient(t *testing.T) dynamic.Interface { + t.Helper() + return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), testMetricsListKinds) +} + +func TestHealthMetrics(t *testing.T) { + ctx := context.Background() + + t.Run("NodeMetricsWithData", func(t *testing.T) { + dyn := newMetricsClient(t) + _, err := dyn.Resource(nodeMetricsGVR).Create(ctx, nodeMetric("node-b", "200m", "300Mi"), metav1.CreateOptions{}) + assert.NoError(t, err) + _, err = dyn.Resource(nodeMetricsGVR).Create(ctx, nodeMetric("node-a", "100m", "200Mi"), metav1.CreateOptions{}) + assert.NoError(t, err) + + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentDynamicClient").Return(dyn, nil) + + health := &Health{} + result, err := health.NodeMetrics(ctx, mockCM) + + assert.NoError(t, err) + assert.Contains(t, result, "Node metrics (2)") + assert.Contains(t, result, "node-a") + assert.Contains(t, result, "cpu: 100m") + }) + + t.Run("NodeMetricsEmpty", func(t *testing.T) { + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), testMetricsListKinds) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentDynamicClient").Return(dyn, nil) + + health := &Health{} + result, err := health.NodeMetrics(ctx, mockCM) + + assert.NoError(t, err) + assert.Contains(t, result, "No node metrics available") + }) + + t.Run("PodMetricsWithData", func(t *testing.T) { + dyn := newMetricsClient(t) + _, err := dyn.Resource(podMetricsGVR).Namespace(defaultNamespace).Create(ctx, podMetric("pod-a", defaultNamespace, "10m", "20Mi"), metav1.CreateOptions{}) + assert.NoError(t, err) + + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + mockCM.On("GetCurrentDynamicClient").Return(dyn, nil) + + health := &Health{} + result, err := health.PodMetrics(ctx, mockCM, "", false) + + assert.NoError(t, err) + assert.Contains(t, result, "Pod metrics (1)") + assert.Contains(t, result, "default/pod-a") + }) + + t.Run("PodMetricsAllNamespaces", func(t *testing.T) { + dyn := newMetricsClient(t) + _, err := dyn.Resource(podMetricsGVR).Namespace(defaultNamespace).Create(ctx, podMetric("pod-a", defaultNamespace, "10m", "20Mi"), metav1.CreateOptions{}) + assert.NoError(t, err) + + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentDynamicClient").Return(dyn, nil) + + health := &Health{} + result, err := health.PodMetrics(ctx, mockCM, "", true) + + assert.NoError(t, err) + assert.Contains(t, result, "Pod metrics (1)") + }) +} diff --git a/cluster/health_test.go b/cluster/health_test.go new file mode 100644 index 0000000..3721b1f --- /dev/null +++ b/cluster/health_test.go @@ -0,0 +1,59 @@ +package cluster + +import ( + "context" + "testing" + + "github.com/basebandit/kai/testmocks" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func newPodWithPhase(name string, phase corev1.PodPhase) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: defaultNamespace}, + Status: corev1.PodStatus{Phase: phase}, + } +} + +func TestHealthCluster(t *testing.T) { + ctx := context.Background() + + t.Run("HealthySummary", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset( + newNode("node-1", true, false), + newNode("node-2", true, false), + newPodWithPhase("pod-a", corev1.PodRunning), + newPodWithPhase("pod-b", corev1.PodRunning), + ) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + health := &Health{} + result, err := health.Cluster(ctx, mockCM) + + assert.NoError(t, err) + assert.Contains(t, result, "2 total, 2 ready, 0 not ready") + assert.Contains(t, result, "Running: 2") + assert.Contains(t, result, "Overall: Healthy") + }) + + t.Run("DegradedWhenNodeNotReady", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset( + newNode("node-1", true, false), + newNode("node-2", false, false), + newPodWithPhase("pod-a", corev1.PodFailed), + ) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + health := &Health{} + result, err := health.Cluster(ctx, mockCM) + + assert.NoError(t, err) + assert.Contains(t, result, "1 not ready") + assert.Contains(t, result, "Overall: Degraded") + }) +} diff --git a/cluster/manager.go b/cluster/manager.go index 7716d5e..a9d5a1f 100644 --- a/cluster/manager.go +++ b/cluster/manager.go @@ -18,6 +18,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/portforward" "k8s.io/client-go/transport/spdy" @@ -27,6 +28,7 @@ import ( // Manager maintains connections to Kubernetes clusters type Manager struct { kubeconfigs map[string]string + restConfigs map[string]*rest.Config clients map[string]kubernetes.Interface dynamicClients map[string]dynamic.Interface contexts map[string]*kai.ContextInfo @@ -54,6 +56,7 @@ func WithRequestTimeout(d time.Duration) Option { func New(opts ...Option) *Manager { cm := &Manager{ kubeconfigs: make(map[string]string), + restConfigs: make(map[string]*rest.Config), clients: make(map[string]kubernetes.Interface), dynamicClients: make(map[string]dynamic.Interface), contexts: make(map[string]*kai.ContextInfo), @@ -71,6 +74,66 @@ func (cm *Manager) RequestTimeout() time.Duration { return cm.requestTimeout } +// LoadInClusterConfig loads the in-cluster Kubernetes configuration +// This is used when kai is running inside a Kubernetes pod +func (cm *Manager) LoadInClusterConfig(name string) error { + if name == "" { + name = "in-cluster" + } + + if _, exists := cm.contexts[name]; exists { + return fmt.Errorf("context %s already exists", name) + } + + config, err := rest.InClusterConfig() + if err != nil { + return fmt.Errorf("failed to load in-cluster config: %w", err) + } + + config.Timeout = 30 * time.Second + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return fmt.Errorf("error creating client: %w", err) + } + + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return fmt.Errorf("error creating dynamic client: %w", err) + } + + if err := testConnection(clientset); err != nil { + return fmt.Errorf("failed to connect to cluster: %w", err) + } + + // Detect the namespace from the service account namespace file + namespace := detectInClusterNamespace("") + + contextInfo := &kai.ContextInfo{ + Name: name, + Cluster: "in-cluster", + User: "service-account", + Namespace: namespace, + ServerURL: config.Host, + ConfigPath: "", + IsActive: true, + } + + cm.kubeconfigs[name] = "" + cm.restConfigs[name] = config + cm.clients[name] = clientset + cm.dynamicClients[name] = dynamicClient + cm.contexts[name] = contextInfo + cm.currentContext = name + + slog.Info("in-cluster config loaded", + slog.String("context", name), + slog.String("server", config.Host), + ) + + return nil +} + // LoadKubeConfig loads a kubeconfig file into the manager func (cm *Manager) LoadKubeConfig(name, path string) error { if err := validateInputs(name, path); err != nil { @@ -95,7 +158,7 @@ func (cm *Manager) LoadKubeConfig(name, path string) error { return err } - clientset, dynamicClient, err := cm.createClients(resolvedPath) + restConfig, clientset, dynamicClient, err := cm.createClients(resolvedPath) if err != nil { return err } @@ -113,6 +176,7 @@ func (cm *Manager) LoadKubeConfig(name, path string) error { if _, exists := cm.contexts[uniqueName]; !exists { cm.kubeconfigs[uniqueName] = resolvedPath + cm.restConfigs[uniqueName] = restConfig cm.clients[uniqueName] = clientset cm.dynamicClients[uniqueName] = dynamicClient cm.contexts[uniqueName] = contextInfo @@ -148,6 +212,7 @@ func (cm *Manager) DeleteContext(name string) error { delete(cm.clients, name) delete(cm.dynamicClients, name) delete(cm.kubeconfigs, name) + delete(cm.restConfigs, name) cm.currentContext = "" for contextName := range cm.contexts { @@ -163,6 +228,7 @@ func (cm *Manager) DeleteContext(name string) error { delete(cm.clients, name) delete(cm.dynamicClients, name) delete(cm.kubeconfigs, name) + delete(cm.restConfigs, name) slog.Info("context deleted", slog.String("context", name)) return nil @@ -199,11 +265,13 @@ func (cm *Manager) RenameContext(oldName, newName string) error { cm.clients[newName] = cm.clients[oldName] cm.dynamicClients[newName] = cm.dynamicClients[oldName] cm.kubeconfigs[newName] = cm.kubeconfigs[oldName] + cm.restConfigs[newName] = cm.restConfigs[oldName] delete(cm.contexts, oldName) delete(cm.clients, oldName) delete(cm.dynamicClients, oldName) delete(cm.kubeconfigs, oldName) + delete(cm.restConfigs, oldName) if cm.currentContext == oldName { cm.currentContext = newName @@ -432,28 +500,29 @@ func (cm *Manager) updateKubeconfigCurrentContext(contextName, configPath string return nil } -// createClients builds Kubernetes typed and dynamic clients from a kubeconfig -// path. The per-request timeout is taken from the Manager so the user-facing -// --request-timeout flag is honored end-to-end. -func (cm *Manager) createClients(path string) (kubernetes.Interface, dynamic.Interface, error) { +// createClients builds the rest.Config plus Kubernetes typed and dynamic +// clients from a kubeconfig path. The rest.Config is returned so callers can +// reuse it for port forwarding. The per-request timeout is taken from the +// Manager so the user-facing --request-timeout flag is honored end-to-end. +func (cm *Manager) createClients(path string) (*rest.Config, kubernetes.Interface, dynamic.Interface, error) { config, err := clientcmd.BuildConfigFromFlags("", path) if err != nil { - return nil, nil, fmt.Errorf("error building config from flags: %w", err) + return nil, nil, nil, fmt.Errorf("error building config from flags: %w", err) } config.Timeout = cm.requestTimeout clientset, err := kubernetes.NewForConfig(config) if err != nil { - return nil, nil, fmt.Errorf("error creating client: %w", err) + return nil, nil, nil, fmt.Errorf("error creating client: %w", err) } dynamicClient, err := dynamic.NewForConfig(config) if err != nil { - return nil, nil, fmt.Errorf("error creating dynamic client: %w", err) + return nil, nil, nil, fmt.Errorf("error creating dynamic client: %w", err) } - return clientset, dynamicClient, nil + return config, clientset, dynamicClient, nil } // testConnection tests the connection to the Kubernetes cluster @@ -482,6 +551,36 @@ func validateFile(path string) error { return nil } +// detectInClusterNamespace reads the namespace from the service account namespace file +// when running inside a Kubernetes pod. Falls back to "default" if the file cannot be read. +// If customPath is provided and not empty, it will be used instead of the default Kubernetes path. +func detectInClusterNamespace(customPath string) string { + namespaceFile := "/var/run/secrets/kubernetes.io/serviceaccount/namespace" + if customPath != "" { + namespaceFile = customPath + } + + // #nosec G304 - This is a well-known Kubernetes service account file path + data, err := os.ReadFile(namespaceFile) + if err != nil { + slog.Debug("failed to read namespace from service account file, using default", + slog.String("file", namespaceFile), + slog.String("error", err.Error()), + ) + return "default" + } + + namespace := strings.TrimSpace(string(data)) + if namespace == "" { + slog.Debug("namespace file is empty, using default", + slog.String("file", namespaceFile), + ) + return "default" + } + + return namespace +} + func ptr[T any](v T) *T { return &v } @@ -515,14 +614,9 @@ func (cm *Manager) StartPortForward( remotePort int, ) (*PortForwardSession, error) { currentContext := cm.GetCurrentContext() - kubeconfigPath, exists := cm.kubeconfigs[currentContext] + config, exists := cm.restConfigs[currentContext] if !exists { - return nil, fmt.Errorf("kubeconfig path not found for context %s", currentContext) - } - - config, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath) - if err != nil { - return nil, fmt.Errorf("failed to build config: %w", err) + return nil, fmt.Errorf("config not found for context %s", currentContext) } client, err := cm.GetCurrentClient() diff --git a/cluster/manager_test.go b/cluster/manager_test.go index 748e855..16c13f7 100644 --- a/cluster/manager_test.go +++ b/cluster/manager_test.go @@ -12,6 +12,7 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" ) @@ -48,6 +49,102 @@ func TestExtendedClusterManager(t *testing.T) { t.Run("UpdateKubeconfigCurrentContext", testUpdateKubeconfigCurrentContext) } +func TestInClusterConfig(t *testing.T) { + t.Run("LoadInClusterConfig", testLoadInClusterConfig) + t.Run("DetectInClusterNamespace", testDetectInClusterNamespace) +} + +func testLoadInClusterConfig(t *testing.T) { + t.Run("NotInCluster", func(t *testing.T) { + // When not running in a cluster, LoadInClusterConfig should fail + cm := New() + err := cm.LoadInClusterConfig("test-context") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to load in-cluster config") + }) + + t.Run("DefaultContextName", func(t *testing.T) { + // When empty name is provided, should use "in-cluster" as default + cm := New() + err := cm.LoadInClusterConfig("") + // Will fail because we're not in a cluster, but the error shouldn't be about empty name + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to load in-cluster config") + assert.NotContains(t, err.Error(), "empty") + }) + + t.Run("DuplicateContextName", func(t *testing.T) { + cm := New() + + // Pre-populate with an existing context + fakeClient := fake.NewSimpleClientset() + contextInfo := &kai.ContextInfo{Name: "existing-context"} + cm.clients["existing-context"] = fakeClient + cm.contexts["existing-context"] = contextInfo + + err := cm.LoadInClusterConfig("existing-context") + assert.Error(t, err) + assert.Contains(t, err.Error(), "context existing-context already exists") + }) +} + +func testDetectInClusterNamespace(t *testing.T) { + t.Run("NamespaceFileDoesNotExist", func(t *testing.T) { + // When the namespace file doesn't exist, should return "default" + namespace := detectInClusterNamespace("/nonexistent/path/namespace") + assert.Equal(t, "default", namespace) + }) + + t.Run("NamespaceFileExists", func(t *testing.T) { + // Create a temporary directory and file to simulate the service account namespace file + tmpDir := t.TempDir() + namespaceFile := filepath.Join(tmpDir, "namespace") + + // Write a test namespace to the file + testNs := "my-custom-namespace" + err := os.WriteFile(namespaceFile, []byte(testNs), 0600) + require.NoError(t, err) + + // Test the actual function + namespace := detectInClusterNamespace(namespaceFile) + assert.Equal(t, testNs, namespace) + }) + + t.Run("NamespaceFileIsEmpty", func(t *testing.T) { + // Create a temporary empty file + tmpDir := t.TempDir() + namespaceFile := filepath.Join(tmpDir, "namespace") + + err := os.WriteFile(namespaceFile, []byte(""), 0600) + require.NoError(t, err) + + // Test that empty file returns "default" + namespace := detectInClusterNamespace(namespaceFile) + assert.Equal(t, "default", namespace) + }) + + t.Run("NamespaceFileWithWhitespace", func(t *testing.T) { + // Create a temporary file with whitespace + tmpDir := t.TempDir() + namespaceFile := filepath.Join(tmpDir, "namespace") + + testNs := "my-namespace" + err := os.WriteFile(namespaceFile, []byte(" "+testNs+" \n"), 0600) + require.NoError(t, err) + + // Test that whitespace is trimmed + namespace := detectInClusterNamespace(namespaceFile) + assert.Equal(t, testNs, namespace) + }) + + t.Run("DefaultPath", func(t *testing.T) { + // When no custom path is provided, should use default path + // Since the default path won't exist in test environment, it should return "default" + namespace := detectInClusterNamespace("") + assert.Equal(t, "default", namespace) + }) +} + func testNewClusterManager(t *testing.T) { cm := New() assert.NotNil(t, cm) @@ -777,8 +874,8 @@ func testStartPortForwardErrors(t *testing.T) { portForwardSessions = make(map[string]*PortForwardSession) pfMutex.Unlock() - t.Run("NoKubeconfigPath", func(t *testing.T) { - // Manager without kubeconfig path should fail + t.Run("NoConfig", func(t *testing.T) { + // Manager without rest config should fail _, err := cm.StartPortForward( t.Context(), "default", @@ -788,14 +885,35 @@ func testStartPortForwardErrors(t *testing.T) { 80, ) assert.Error(t, err) - assert.Contains(t, err.Error(), "kubeconfig path not found") + assert.Contains(t, err.Error(), "config not found") }) - t.Run("InvalidKubeconfigPath", func(t *testing.T) { - // Set invalid kubeconfig path - cm.kubeconfigs["test-context"] = "/nonexistent/path/config" - cm.currentContext = "test-context" + t.Run("InClusterConfigPortForward", func(t *testing.T) { + // Simulate an in-cluster context with a stored rest.Config + // This tests that port forwarding works with in-cluster config + // where kubeconfig path is empty but rest.Config is stored + cm := New() + + fakeClient := fake.NewSimpleClientset() + contextInfo := &kai.ContextInfo{ + Name: "in-cluster", + Cluster: "in-cluster", + User: "service-account", + Namespace: "default", + ConfigPath: "", // Empty for in-cluster + IsActive: true, + } + + // Simulate what LoadInClusterConfig does + cm.kubeconfigs["in-cluster"] = "" // Empty path for in-cluster + cm.restConfigs["in-cluster"] = &rest.Config{ + Host: "https://kubernetes.default.svc", + } + cm.clients["in-cluster"] = fakeClient + cm.contexts["in-cluster"] = contextInfo + cm.currentContext = "in-cluster" + // Now try port forwarding - it should find the config from restConfigs _, err := cm.StartPortForward( t.Context(), "default", @@ -804,9 +922,12 @@ func testStartPortForwardErrors(t *testing.T) { 8080, 80, ) + // Will fail because we don't have a real cluster, but should NOT fail + // with "config not found" error - it should fail later in the process assert.Error(t, err) - // Should fail when building config - assert.Contains(t, err.Error(), "failed to build config") + assert.NotContains(t, err.Error(), "config not found") + // Should fail when trying to get the client or pod + assert.Contains(t, err.Error(), "not found") }) // Cleanup diff --git a/cluster/node.go b/cluster/node.go new file mode 100644 index 0000000..829e832 --- /dev/null +++ b/cluster/node.go @@ -0,0 +1,283 @@ +package cluster + +import ( + "context" + "fmt" + "log/slog" + "sort" + "strings" + "time" + + "github.com/basebandit/kai" + corev1 "k8s.io/api/core/v1" + policyv1 "k8s.io/api/policy/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" +) + +// Node represents an operation target for a cluster node. +type Node struct { + Name string +} + +func (n *Node) validate() error { + if n.Name == "" { + return fmt.Errorf("node name is required") + } + return nil +} + +// List returns a summary of all nodes in the cluster. +func (n *Node) 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() + + nodes, err := client.CoreV1().Nodes().List(timeoutCtx, metav1.ListOptions{}) + if err != nil { + return "", fmt.Errorf("failed to list nodes: %w", err) + } + + if len(nodes.Items) == 0 { + return "No nodes found", nil + } + + return formatNodeList(nodes), nil +} + +// Get returns detailed information about a single node. +func (n *Node) Get(ctx context.Context, cm kai.ClusterManager) (string, error) { + if err := n.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() + + node, err := client.CoreV1().Nodes().Get(timeoutCtx, n.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get node %q: %w", n.Name, err) + } + + return formatNode(node), nil +} + +// Cordon marks the node unschedulable. +func (n *Node) Cordon(ctx context.Context, cm kai.ClusterManager) (string, error) { + return n.setSchedulable(ctx, cm, true) +} + +// Uncordon marks the node schedulable again. +func (n *Node) Uncordon(ctx context.Context, cm kai.ClusterManager) (string, error) { + return n.setSchedulable(ctx, cm, false) +} + +func (n *Node) setSchedulable(ctx context.Context, cm kai.ClusterManager, unschedulable bool) (string, error) { + if err := n.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() + + node, err := client.CoreV1().Nodes().Get(timeoutCtx, n.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get node %q: %w", n.Name, err) + } + + verb := "cordoned" + if !unschedulable { + verb = "uncordoned" + } + + if node.Spec.Unschedulable == unschedulable { + return fmt.Sprintf("Node %q already %s", n.Name, verb), nil + } + + node.Spec.Unschedulable = unschedulable + if _, err := client.CoreV1().Nodes().Update(timeoutCtx, node, metav1.UpdateOptions{}); err != nil { + return "", fmt.Errorf("failed to update node %q: %w", n.Name, err) + } + + slog.Info("node schedulability changed", slog.String("node", n.Name), slog.Bool("unschedulable", unschedulable)) + return fmt.Sprintf("Node %q %s successfully", n.Name, verb), nil +} + +// Drain cordons the node and evicts its pods. DaemonSet-managed and +// mirror (static) pods are skipped, matching kubectl drain behaviour. +func (n *Node) Drain(ctx context.Context, cm kai.ClusterManager, ignoreDaemonSets, deleteLocalData bool, gracePeriod int64) (string, error) { + if err := n.validate(); err != nil { + return "", err + } + + client, err := cm.GetCurrentClient() + if err != nil { + return "", fmt.Errorf("error getting client: %w", err) + } + + if _, err := n.Cordon(ctx, cm); err != nil { + return "", err + } + + pods, err := client.CoreV1().Pods("").List(ctx, metav1.ListOptions{ + FieldSelector: fields.OneTermEqualSelector("spec.nodeName", n.Name).String(), + }) + if err != nil { + return "", fmt.Errorf("failed to list pods on node %q: %w", n.Name, err) + } + + var ( + evicted []string + skipped []string + failed []string + ) + + for i := range pods.Items { + pod := pods.Items[i] + if reason, skip := shouldSkipPod(&pod, ignoreDaemonSets, deleteLocalData); skip { + skipped = append(skipped, fmt.Sprintf("%s/%s (%s)", pod.Namespace, pod.Name, reason)) + continue + } + + eviction := &policyv1.Eviction{ + ObjectMeta: metav1.ObjectMeta{Name: pod.Name, Namespace: pod.Namespace}, + } + if gracePeriod >= 0 { + eviction.DeleteOptions = &metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod} + } + + if err := client.PolicyV1().Evictions(pod.Namespace).Evict(ctx, eviction); err != nil { + failed = append(failed, fmt.Sprintf("%s/%s: %v", pod.Namespace, pod.Name, err)) + continue + } + evicted = append(evicted, fmt.Sprintf("%s/%s", pod.Namespace, pod.Name)) + } + + var sb strings.Builder + fmt.Fprintf(&sb, "Node %q drained (cordoned).\n", n.Name) + fmt.Fprintf(&sb, "Evicted %d pod(s)", len(evicted)) + if len(evicted) > 0 { + sb.WriteString(":\n- " + strings.Join(evicted, "\n- ")) + } + sb.WriteString("\n") + if len(skipped) > 0 { + fmt.Fprintf(&sb, "Skipped %d pod(s):\n- %s\n", len(skipped), strings.Join(skipped, "\n- ")) + } + if len(failed) > 0 { + fmt.Fprintf(&sb, "Failed to evict %d pod(s):\n- %s\n", len(failed), strings.Join(failed, "\n- ")) + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +func shouldSkipPod(pod *corev1.Pod, ignoreDaemonSets, deleteLocalData bool) (string, bool) { + for _, owner := range pod.OwnerReferences { + if owner.Kind == "DaemonSet" { + if ignoreDaemonSets { + return "DaemonSet-managed", true + } + return "", false + } + } + // Mirror (static) pods cannot be evicted. + if _, ok := pod.Annotations[corev1.MirrorPodAnnotationKey]; ok { + return "mirror pod", true + } + if !deleteLocalData { + for _, vol := range pod.Spec.Volumes { + if vol.EmptyDir != nil { + return "uses emptyDir (set delete_local_data=true to evict)", true + } + } + } + return "", false +} + +func nodeReadyStatus(node *corev1.Node) string { + for _, cond := range node.Status.Conditions { + if cond.Type == corev1.NodeReady { + if cond.Status == corev1.ConditionTrue { + return "Ready" + } + return "NotReady" + } + } + return "Unknown" +} + +func nodeRoles(node *corev1.Node) string { + var roles []string + for label := range node.Labels { + if role, ok := strings.CutPrefix(label, "node-role.kubernetes.io/"); ok { + if role != "" { + roles = append(roles, role) + } + } + } + if len(roles) == 0 { + return "" + } + sort.Strings(roles) + return strings.Join(roles, ",") +} + +func formatNodeList(nodes *corev1.NodeList) string { + var sb strings.Builder + fmt.Fprintf(&sb, "Nodes (%d):\n", len(nodes.Items)) + for i := range nodes.Items { + node := nodes.Items[i] + status := nodeReadyStatus(&node) + if node.Spec.Unschedulable { + status += ",SchedulingDisabled" + } + age := formatDuration(time.Since(node.CreationTimestamp.Time)) + fmt.Fprintf(&sb, "• %s\tstatus: %s\troles: %s\tversion: %s\tage: %s\n", + node.Name, status, nodeRoles(&node), node.Status.NodeInfo.KubeletVersion, age) + } + return strings.TrimRight(sb.String(), "\n") +} + +func formatNode(node *corev1.Node) string { + var sb strings.Builder + fmt.Fprintf(&sb, "Node: %s\n", node.Name) + status := nodeReadyStatus(node) + if node.Spec.Unschedulable { + status += ",SchedulingDisabled" + } + fmt.Fprintf(&sb, "Status: %s\n", status) + fmt.Fprintf(&sb, "Roles: %s\n", nodeRoles(node)) + fmt.Fprintf(&sb, "Kubelet Version: %s\n", node.Status.NodeInfo.KubeletVersion) + fmt.Fprintf(&sb, "OS Image: %s\n", node.Status.NodeInfo.OSImage) + fmt.Fprintf(&sb, "Kernel: %s\n", node.Status.NodeInfo.KernelVersion) + fmt.Fprintf(&sb, "Container Runtime: %s\n", node.Status.NodeInfo.ContainerRuntimeVersion) + fmt.Fprintf(&sb, "Age: %s\n", formatDuration(time.Since(node.CreationTimestamp.Time))) + + for _, addr := range node.Status.Addresses { + fmt.Fprintf(&sb, "%s: %s\n", addr.Type, addr.Address) + } + + if cpu, ok := node.Status.Capacity[corev1.ResourceCPU]; ok { + fmt.Fprintf(&sb, "Capacity: cpu=%s, memory=%s, pods=%s\n", + cpu.String(), node.Status.Capacity.Memory().String(), node.Status.Capacity.Pods().String()) + } + + sb.WriteString("Conditions:\n") + for _, cond := range node.Status.Conditions { + fmt.Fprintf(&sb, " %s: %s (%s)\n", cond.Type, cond.Status, cond.Reason) + } + + return strings.TrimRight(sb.String(), "\n") +} diff --git a/cluster/node_test.go b/cluster/node_test.go new file mode 100644 index 0000000..7264944 --- /dev/null +++ b/cluster/node_test.go @@ -0,0 +1,188 @@ +package cluster + +import ( + "context" + "testing" + + "github.com/basebandit/kai/testmocks" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +const testNodeName = "node-1" + +func resourceQty(s string) resource.Quantity { return resource.MustParse(s) } + +func newNode(name string, ready, unschedulable bool) *corev1.Node { + status := corev1.ConditionFalse + if ready { + status = corev1.ConditionTrue + } + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{"node-role.kubernetes.io/control-plane": ""}, + }, + Spec: corev1.NodeSpec{Unschedulable: unschedulable}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: status}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.30.0"}, + }, + } +} + +func TestNodeOperations(t *testing.T) { + ctx := context.Background() + + t.Run("List", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(newNode(testNodeName, true, false)) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + node := &Node{} + result, err := node.List(ctx, mockCM) + + assert.NoError(t, err) + assert.Contains(t, result, testNodeName) + assert.Contains(t, result, "Ready") + assert.Contains(t, result, "control-plane") + }) + + t.Run("GetRequiresName", func(t *testing.T) { + mockCM := testmocks.NewMockClusterManager() + node := &Node{} + _, err := node.Get(ctx, mockCM) + assert.Error(t, err) + }) + + t.Run("GetSuccess", func(t *testing.T) { + n := newNode(testNodeName, true, false) + n.Status.NodeInfo.OSImage = "Ubuntu 22.04" + n.Status.Addresses = []corev1.NodeAddress{{Type: corev1.NodeInternalIP, Address: "10.0.0.1"}} + n.Status.Capacity = corev1.ResourceList{ + corev1.ResourceCPU: resourceQty("4"), + corev1.ResourceMemory: resourceQty("8Gi"), + corev1.ResourcePods: resourceQty("110"), + } + fakeClient := fake.NewSimpleClientset(n) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + node := &Node{Name: testNodeName} + result, err := node.Get(ctx, mockCM) + + assert.NoError(t, err) + assert.Contains(t, result, "Node: "+testNodeName) + assert.Contains(t, result, "Ubuntu 22.04") + assert.Contains(t, result, "10.0.0.1") + assert.Contains(t, result, "Conditions:") + }) + + t.Run("DrainSkipsManagedPods", func(t *testing.T) { + dsPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ds-pod", Namespace: defaultNamespace, + OwnerReferences: []metav1.OwnerReference{{Kind: "DaemonSet", Name: "ds"}}, + }, + Spec: corev1.PodSpec{NodeName: testNodeName}, + } + mirrorPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mirror-pod", Namespace: defaultNamespace, + Annotations: map[string]string{corev1.MirrorPodAnnotationKey: "x"}, + }, + Spec: corev1.PodSpec{NodeName: testNodeName}, + } + emptyDirPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "data-pod", Namespace: defaultNamespace}, + Spec: corev1.PodSpec{ + NodeName: testNodeName, + Volumes: []corev1.Volume{{Name: "cache", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}}, + }, + } + fakeClient := fake.NewSimpleClientset(newNode(testNodeName, true, false), dsPod, mirrorPod, emptyDirPod) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + node := &Node{Name: testNodeName} + result, err := node.Drain(ctx, mockCM, true, false, 30) + + assert.NoError(t, err) + assert.Contains(t, result, "Skipped 3 pod(s)") + assert.Contains(t, result, "Evicted 0 pod(s)") + }) + + t.Run("Cordon", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(newNode(testNodeName, true, false)) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + node := &Node{Name: testNodeName} + result, err := node.Cordon(ctx, mockCM) + + assert.NoError(t, err) + assert.Contains(t, result, "cordoned successfully") + + updated, _ := fakeClient.CoreV1().Nodes().Get(ctx, testNodeName, metav1.GetOptions{}) + assert.True(t, updated.Spec.Unschedulable) + }) + + t.Run("CordonAlreadyCordoned", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(newNode(testNodeName, true, true)) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + node := &Node{Name: testNodeName} + result, err := node.Cordon(ctx, mockCM) + + assert.NoError(t, err) + assert.Contains(t, result, "already cordoned") + }) + + t.Run("Uncordon", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(newNode(testNodeName, true, true)) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + node := &Node{Name: testNodeName} + result, err := node.Uncordon(ctx, mockCM) + + assert.NoError(t, err) + assert.Contains(t, result, "uncordoned successfully") + + updated, _ := fakeClient.CoreV1().Nodes().Get(ctx, testNodeName, metav1.GetOptions{}) + assert.False(t, updated.Spec.Unschedulable) + }) + + t.Run("DrainNoPods", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(newNode(testNodeName, true, false)) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + node := &Node{Name: testNodeName} + result, err := node.Drain(ctx, mockCM, true, false, -1) + + assert.NoError(t, err) + assert.Contains(t, result, "drained") + assert.Contains(t, result, "Evicted 0 pod(s)") + }) + + t.Run("DrainEvictsNormalPod", func(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "app-pod", Namespace: defaultNamespace}, + Spec: corev1.PodSpec{NodeName: testNodeName}, + } + fakeClient := fake.NewSimpleClientset(newNode(testNodeName, true, false), pod) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + node := &Node{Name: testNodeName} + result, err := node.Drain(ctx, mockCM, true, true, -1) + + assert.NoError(t, err) + assert.Contains(t, result, "app-pod") + }) +} diff --git a/cmd/kai/main.go b/cmd/kai/main.go index 85e2c88..40c89c1 100644 --- a/cmd/kai/main.go +++ b/cmd/kai/main.go @@ -29,6 +29,7 @@ func main() { var ( kubeconfig string contextName string + inCluster bool transport string sseAddr string logFormat string @@ -44,6 +45,7 @@ func main() { flag.StringVar(&kubeconfig, "kubeconfig", defaultKubeconfig, "Path to kubeconfig file") flag.StringVar(&contextName, "context", "local", "Name for the loaded context") + flag.BoolVar(&inCluster, "in-cluster", false, "Use in-cluster Kubernetes configuration (for running inside a pod)") flag.StringVar(&transport, "transport", "stdio", "Transport mode: stdio (default), streamable-http, or sse-legacy. \"sse\" is accepted as a deprecated alias of \"sse-legacy\".") flag.StringVar(&sseAddr, "sse-addr", ":8080", "Address for the HTTP listener (used with streamable-http or sse-legacy). The flag name is kept for backwards compatibility.") flag.StringVar(&logFormat, "log-format", "json", "Log format: json (default) or text") @@ -67,19 +69,30 @@ func main() { // Initialize cluster manager cm := cluster.New(cluster.WithRequestTimeout(requestTimeout)) - if err := cm.LoadKubeConfig(contextName, kubeconfig); err != nil { - logger.Error("failed to load kubeconfig", + if inCluster { + if err := cm.LoadInClusterConfig(contextName); err != nil { + logger.Error("failed to load in-cluster config", + slog.String("error", err.Error()), + ) + os.Exit(1) + } + logger.Info("in-cluster config loaded", + slog.String("context", contextName), + ) + } else { + if err := cm.LoadKubeConfig(contextName, kubeconfig); err != nil { + logger.Error("failed to load kubeconfig", + slog.String("path", kubeconfig), + slog.String("error", err.Error()), + ) + os.Exit(1) + } + logger.Info("kubeconfig loaded", slog.String("path", kubeconfig), - slog.String("error", err.Error()), + slog.String("context", contextName), ) - os.Exit(1) } - logger.Info("kubeconfig loaded", - slog.String("path", kubeconfig), - slog.String("context", contextName), - ) - // Create and configure server serverOpts := []kai.ServerOption{ kai.WithVersion(version), @@ -193,4 +206,7 @@ func registerAllTools(s *kai.Server, cm *cluster.Manager) { tools.RegisterCronJobTools(s, cm) tools.RegisterIngressTools(s, cm) tools.RegisterOperationsTools(s, cm) + tools.RegisterEventTools(s, cm) + tools.RegisterNodeTools(s, cm) + tools.RegisterHealthTools(s, cm) } diff --git a/tools/event.go b/tools/event.go new file mode 100644 index 0000000..235e87c --- /dev/null +++ b/tools/event.go @@ -0,0 +1,67 @@ +package tools + +import ( + "context" + "fmt" + "log/slog" + + "github.com/basebandit/kai" + "github.com/basebandit/kai/cluster" + "github.com/mark3labs/mcp-go/mcp" +) + +// RegisterEventTools registers event query tools. +func RegisterEventTools(s kai.ServerInterface, cm kai.ClusterManager) { + listEventsTool := mcp.NewTool("list_events", + mcp.WithDescription("List Kubernetes events, optionally filtered by namespace, type or involved object"), + readOnlyAnnotation("List events"), + mcp.WithString("namespace", + mcp.Description("Namespace to list events from (defaults to current namespace)"), + ), + mcp.WithBoolean("all_namespaces", + mcp.Description("List events across all namespaces"), + ), + mcp.WithString("type", + mcp.Description("Filter by event type: 'Warning' or 'Normal'"), + ), + mcp.WithString("involved_object", + mcp.Description("Filter to events about a specific object by name (e.g. a pod name)"), + ), + mcp.WithNumber("limit", + mcp.Description("Maximum number of events to return"), + ), + ) + s.AddTool(listEventsTool, listEventsHandler(cm)) +} + +func listEventsHandler(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_events")) + + event := cluster.Event{} + + if ns, ok := request.GetArguments()["namespace"].(string); ok { + event.Namespace = ns + } + if all, ok := request.GetArguments()["all_namespaces"].(bool); ok { + event.AllNamespaces = all + } + if t, ok := request.GetArguments()["type"].(string); ok { + event.Type = t + } + if obj, ok := request.GetArguments()["involved_object"].(string); ok { + event.InvolvedObject = obj + } + if limit, ok := request.GetArguments()["limit"].(float64); ok { + event.Limit = int64(limit) + } + + result, err := event.List(ctx, cm) + if err != nil { + slog.Warn("failed to list events", slog.String("error", err.Error())) + return mcp.NewToolResultText(fmt.Sprintf("Failed to list events: %s", err.Error())), nil + } + + return mcp.NewToolResultText(result), nil + } +} diff --git a/tools/health.go b/tools/health.go new file mode 100644 index 0000000..15ad320 --- /dev/null +++ b/tools/health.go @@ -0,0 +1,83 @@ +package tools + +import ( + "context" + "fmt" + "log/slog" + + "github.com/basebandit/kai" + "github.com/basebandit/kai/cluster" + "github.com/mark3labs/mcp-go/mcp" +) + +// RegisterHealthTools registers cluster health and metrics tools. +func RegisterHealthTools(s kai.ServerInterface, cm kai.ClusterManager) { + clusterHealthTool := mcp.NewTool("cluster_health", + mcp.WithDescription("Summarize cluster health: node readiness and pod phase distribution"), + readOnlyAnnotation("Cluster health"), + ) + s.AddTool(clusterHealthTool, clusterHealthHandler(cm)) + + nodeMetricsTool := mcp.NewTool("node_metrics", + mcp.WithDescription("Show CPU and memory usage per node (requires metrics-server)"), + readOnlyAnnotation("Node metrics"), + ) + s.AddTool(nodeMetricsTool, nodeMetricsHandler(cm)) + + podMetricsTool := mcp.NewTool("pod_metrics", + mcp.WithDescription("Show CPU and memory usage per pod (requires metrics-server)"), + readOnlyAnnotation("Pod metrics"), + mcp.WithString("namespace", + mcp.Description("Namespace to report (defaults to current namespace)"), + ), + mcp.WithBoolean("all_namespaces", + mcp.Description("Report pods across all namespaces"), + ), + ) + s.AddTool(podMetricsTool, podMetricsHandler(cm)) +} + +func clusterHealthHandler(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", "cluster_health")) + health := cluster.Health{} + result, err := health.Cluster(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to get cluster health: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func nodeMetricsHandler(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", "node_metrics")) + health := cluster.Health{} + result, err := health.NodeMetrics(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to get node metrics: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func podMetricsHandler(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", "pod_metrics")) + namespace := "" + if ns, ok := request.GetArguments()["namespace"].(string); ok { + namespace = ns + } + allNamespaces := false + if all, ok := request.GetArguments()["all_namespaces"].(bool); ok { + allNamespaces = all + } + + health := cluster.Health{} + result, err := health.PodMetrics(ctx, cm, namespace, allNamespaces) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to get pod metrics: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} diff --git a/tools/node.go b/tools/node.go new file mode 100644 index 0000000..2b293aa --- /dev/null +++ b/tools/node.go @@ -0,0 +1,153 @@ +package tools + +import ( + "context" + "fmt" + "log/slog" + + "github.com/basebandit/kai" + "github.com/basebandit/kai/cluster" + "github.com/mark3labs/mcp-go/mcp" +) + +const errMissingNode = "Required parameter 'name' (node name) is missing" + +// RegisterNodeTools registers node management tools. +func RegisterNodeTools(s kai.ServerInterface, cm kai.ClusterManager) { + listNodesTool := mcp.NewTool("list_nodes", + mcp.WithDescription("List all nodes in the cluster with status, roles and version"), + readOnlyAnnotation("List nodes"), + ) + s.AddTool(listNodesTool, listNodesHandler(cm)) + + getNodeTool := mcp.NewTool("get_node", + mcp.WithDescription("Get detailed information about a specific node"), + readOnlyAnnotation("Get node"), + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the node")), + ) + s.AddTool(getNodeTool, getNodeHandler(cm)) + + cordonNodeTool := mcp.NewTool("cordon_node", + mcp.WithDescription("Mark a node as unschedulable so no new pods are scheduled onto it"), + idempotentMutationAnnotation("Cordon node"), + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the node")), + ) + s.AddTool(cordonNodeTool, cordonNodeHandler(cm, false)) + + uncordonNodeTool := mcp.NewTool("uncordon_node", + mcp.WithDescription("Mark a node as schedulable again"), + idempotentMutationAnnotation("Uncordon node"), + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the node")), + ) + s.AddTool(uncordonNodeTool, cordonNodeHandler(cm, true)) + + drainNodeTool := mcp.NewTool("drain_node", + mcp.WithDescription("Cordon a node and evict its pods (DaemonSet and mirror pods are skipped)"), + destructiveAnnotation("Drain node"), + mcp.WithString("name", mcp.Required(), mcp.Description("Name of the node")), + mcp.WithBoolean("ignore_daemonsets", + mcp.Description("Skip DaemonSet-managed pods instead of failing (default true)"), + ), + mcp.WithBoolean("delete_local_data", + mcp.Description("Evict pods using emptyDir volumes, losing their local data (default false)"), + ), + mcp.WithNumber("grace_period", + mcp.Description("Eviction grace period in seconds (-1 uses the pod default)"), + ), + ) + s.AddTool(drainNodeTool, drainNodeHandler(cm)) +} + +func nodeNameFromRequest(request mcp.CallToolRequest) (string, *mcp.CallToolResult) { + nameArg, ok := request.GetArguments()["name"] + if !ok || nameArg == nil { + return "", mcp.NewToolResultText(errMissingNode) + } + name, ok := nameArg.(string) + if !ok || name == "" { + return "", mcp.NewToolResultText(errEmptyName) + } + return name, nil +} + +func listNodesHandler(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_nodes")) + node := cluster.Node{} + result, err := node.List(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to list nodes: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func getNodeHandler(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_node")) + name, errResult := nodeNameFromRequest(request) + if errResult != nil { + return errResult, nil + } + node := cluster.Node{Name: name} + result, err := node.Get(ctx, cm) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to get node: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func cordonNodeHandler(cm kai.ClusterManager, uncordon bool) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, errResult := nodeNameFromRequest(request) + if errResult != nil { + return errResult, nil + } + node := cluster.Node{Name: name} + + var ( + result string + err error + ) + if uncordon { + result, err = node.Uncordon(ctx, cm) + } else { + result, err = node.Cordon(ctx, cm) + } + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to update node: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} + +func drainNodeHandler(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", "drain_node")) + name, errResult := nodeNameFromRequest(request) + if errResult != nil { + return errResult, nil + } + node := cluster.Node{Name: name} + + ignoreDaemonSets := true + if v, ok := request.GetArguments()["ignore_daemonsets"].(bool); ok { + ignoreDaemonSets = v + } + deleteLocalData := false + if v, ok := request.GetArguments()["delete_local_data"].(bool); ok { + deleteLocalData = v + } + gracePeriod := int64(-1) + if v, ok := request.GetArguments()["grace_period"].(float64); ok { + gracePeriod = int64(v) + } + + result, err := node.Drain(ctx, cm, ignoreDaemonSets, deleteLocalData, gracePeriod) + if err != nil { + return mcp.NewToolResultText(fmt.Sprintf("Failed to drain node: %s", err.Error())), nil + } + return mcp.NewToolResultText(result), nil + } +} diff --git a/tools/observability_handlers_test.go b/tools/observability_handlers_test.go new file mode 100644 index 0000000..70ac5eb --- /dev/null +++ b/tools/observability_handlers_test.go @@ -0,0 +1,203 @@ +package tools + +import ( + "context" + "testing" + + "github.com/basebandit/kai/testmocks" + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" + + dynamicfake "k8s.io/client-go/dynamic/fake" +) + +var metricsListKinds = map[schema.GroupVersionResource]string{ + {Group: "metrics.k8s.io", Version: "v1beta1", Resource: "nodes"}: "NodeMetricsList", + {Group: "metrics.k8s.io", Version: "v1beta1", Resource: "pods"}: "PodMetricsList", +} + +func toolRequest(args map[string]interface{}) mcp.CallToolRequest { + return mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: args}} +} + +func resultText(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + assert.NotNil(t, result) + return result.Content[0].(mcp.TextContent).Text +} + +func makeNode(name string, ready, unschedulable bool) *corev1.Node { + status := corev1.ConditionFalse + if ready { + status = corev1.ConditionTrue + } + return &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: corev1.NodeSpec{Unschedulable: unschedulable}, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{{Type: corev1.NodeReady, Status: status}}, + NodeInfo: corev1.NodeSystemInfo{KubeletVersion: "v1.30.0"}, + }, + } +} + +func TestListEventsHandler(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(&corev1.Event{ + ObjectMeta: metav1.ObjectMeta{Name: "e1", Namespace: defaultNamespace}, + Type: "Warning", + Reason: "BackOff", + Message: "back-off restarting", + Count: 3, + LastTimestamp: metav1.Now(), + InvolvedObject: corev1.ObjectReference{Kind: "Pod", Name: "pod-a"}, + }) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + + result, err := listEventsHandler(mockCM)(ctx, toolRequest(map[string]interface{}{ + "type": "Warning", "involved_object": "pod-a", "limit": float64(10), + })) + + assert.NoError(t, err) + assert.Contains(t, resultText(t, result), "BackOff") + }) + + t.Run("AllNamespaces", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset() + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + result, err := listEventsHandler(mockCM)(ctx, toolRequest(map[string]interface{}{ + "all_namespaces": true, + })) + + assert.NoError(t, err) + assert.Equal(t, "No events found", resultText(t, result)) + }) +} + +func TestNodeHandlers(t *testing.T) { + ctx := context.Background() + + t.Run("ListNodes", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(makeNode("node-1", true, false)) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + result, err := listNodesHandler(mockCM)(ctx, toolRequest(nil)) + assert.NoError(t, err) + assert.Contains(t, resultText(t, result), "node-1") + }) + + t.Run("GetNodeMissingName", func(t *testing.T) { + mockCM := testmocks.NewMockClusterManager() + result, err := getNodeHandler(mockCM)(ctx, toolRequest(map[string]interface{}{})) + assert.NoError(t, err) + assert.Equal(t, errMissingNode, resultText(t, result)) + }) + + t.Run("GetNodeEmptyName", func(t *testing.T) { + mockCM := testmocks.NewMockClusterManager() + result, err := getNodeHandler(mockCM)(ctx, toolRequest(map[string]interface{}{"name": ""})) + assert.NoError(t, err) + assert.Equal(t, errEmptyName, resultText(t, result)) + }) + + t.Run("GetNodeSuccess", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(makeNode("node-1", true, false)) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + result, err := getNodeHandler(mockCM)(ctx, toolRequest(map[string]interface{}{"name": "node-1"})) + assert.NoError(t, err) + assert.Contains(t, resultText(t, result), "Node: node-1") + }) + + t.Run("Cordon", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(makeNode("node-1", true, false)) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + result, err := cordonNodeHandler(mockCM, false)(ctx, toolRequest(map[string]interface{}{"name": "node-1"})) + assert.NoError(t, err) + assert.Contains(t, resultText(t, result), "cordoned") + }) + + t.Run("Uncordon", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset(makeNode("node-1", true, true)) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + result, err := cordonNodeHandler(mockCM, true)(ctx, toolRequest(map[string]interface{}{"name": "node-1"})) + assert.NoError(t, err) + assert.Contains(t, resultText(t, result), "uncordoned") + }) + + t.Run("DrainSkipsManagedPods", func(t *testing.T) { + dsPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ds-pod", Namespace: defaultNamespace, + OwnerReferences: []metav1.OwnerReference{{Kind: "DaemonSet", Name: "ds"}}, + }, + Spec: corev1.PodSpec{NodeName: "node-1"}, + } + fakeClient := fake.NewSimpleClientset(makeNode("node-1", true, false), dsPod) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + result, err := drainNodeHandler(mockCM)(ctx, toolRequest(map[string]interface{}{ + "name": "node-1", "ignore_daemonsets": true, "grace_period": float64(30), + })) + assert.NoError(t, err) + text := resultText(t, result) + assert.Contains(t, text, "drained") + assert.Contains(t, text, "Skipped") + }) +} + +func TestHealthHandlers(t *testing.T) { + ctx := context.Background() + + t.Run("ClusterHealth", func(t *testing.T) { + fakeClient := fake.NewSimpleClientset( + makeNode("node-1", true, false), + &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "p", Namespace: defaultNamespace}, Status: corev1.PodStatus{Phase: corev1.PodRunning}}, + ) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentClient").Return(fakeClient, nil) + + result, err := clusterHealthHandler(mockCM)(ctx, toolRequest(nil)) + assert.NoError(t, err) + assert.Contains(t, resultText(t, result), "Cluster Health") + }) + + t.Run("NodeMetricsDegradesGracefully", func(t *testing.T) { + dynClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), metricsListKinds) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentDynamicClient").Return(dynClient, nil) + + result, err := nodeMetricsHandler(mockCM)(ctx, toolRequest(nil)) + assert.NoError(t, err) + assert.NotEmpty(t, resultText(t, result)) + }) + + t.Run("PodMetricsDegradesGracefully", func(t *testing.T) { + dynClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), metricsListKinds) + mockCM := testmocks.NewMockClusterManager() + mockCM.On("GetCurrentNamespace").Return(defaultNamespace) + mockCM.On("GetCurrentDynamicClient").Return(dynClient, nil) + + result, err := podMetricsHandler(mockCM)(ctx, toolRequest(map[string]interface{}{"namespace": defaultNamespace})) + assert.NoError(t, err) + assert.NotEmpty(t, resultText(t, result)) + }) +} diff --git a/tools/observability_test.go b/tools/observability_test.go new file mode 100644 index 0000000..c1e4f22 --- /dev/null +++ b/tools/observability_test.go @@ -0,0 +1,41 @@ +package tools + +import ( + "testing" + + "github.com/basebandit/kai/testmocks" + "github.com/stretchr/testify/mock" +) + +func TestRegisterEventTools(t *testing.T) { + mockServer := &testmocks.MockServer{} + mockCM := testmocks.NewMockClusterManager() + + mockServer.On("AddTool", mock.AnythingOfType("mcp.Tool"), mock.AnythingOfType("server.ToolHandlerFunc")).Return().Times(1) + + RegisterEventTools(mockServer, mockCM) + + mockServer.AssertExpectations(t) +} + +func TestRegisterNodeTools(t *testing.T) { + mockServer := &testmocks.MockServer{} + mockCM := testmocks.NewMockClusterManager() + + mockServer.On("AddTool", mock.AnythingOfType("mcp.Tool"), mock.AnythingOfType("server.ToolHandlerFunc")).Return().Times(5) + + RegisterNodeTools(mockServer, mockCM) + + mockServer.AssertExpectations(t) +} + +func TestRegisterHealthTools(t *testing.T) { + mockServer := &testmocks.MockServer{} + mockCM := testmocks.NewMockClusterManager() + + mockServer.On("AddTool", mock.AnythingOfType("mcp.Tool"), mock.AnythingOfType("server.ToolHandlerFunc")).Return().Times(3) + + RegisterHealthTools(mockServer, mockCM) + + mockServer.AssertExpectations(t) +}