Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
110 changes: 110 additions & 0 deletions cluster/event.go
Original file line number Diff line number Diff line change
@@ -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")
}
96 changes: 96 additions & 0 deletions cluster/event_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
Loading
Loading