Skip to content

Commit c10e405

Browse files
author
Aamod
committed
feat: HTTP server, schema-grounded prompts, React UI, and deployment
- Add gorilla/mux server with CORS, logging, and embedded React SPA - Implement prompt builder with live CRD schema discovery from cluster - Add apply/rollback with 60s TTL window and pre-apply state snapshots - Add WebSocket hub for real-time resource status streaming - Build React frontend with Monaco YAML editor, chat input, live status panel - Add multi-stage Dockerfile (Node build -> Go build -> distroless) - Add kind cluster config and RBAC manifests - Add GitHub Actions CI pipeline
1 parent ddfd652 commit c10e405

40 files changed

Lines changed: 2204 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
build-and-test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v3
14+
15+
- name: Set up Go
16+
uses: actions/setup-go@v4
17+
with:
18+
go-version: '1.22'
19+
20+
- name: Go Test
21+
run: go test -v ./internal/...
22+
23+
- name: Set up Node
24+
uses: actions/setup-node@v3
25+
with:
26+
node-version: '20'
27+
28+
- name: Build Web
29+
run: |
30+
cd web
31+
npm ci
32+
npm run build

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# LLM Infrastructure Assistant
2+
3+
An LLM-powered infrastructure assistant that accepts natural language prompts, fetches live CRD schemas from a connected Kubernetes cluster, and returns valid, validated YAML manifests ready for `kubectl apply`.
4+
5+
## Features
6+
- **Natural Language to YAML:** Generates valid Kubernetes YAML from English prompts.
7+
- **Pluggable LLM Adapter:** Supports Ollama (local), OpenAI, and Anthropic.
8+
- **Schema-Grounded System Prompt:** Injects live CRD schemas from your cluster into the system prompt.
9+
- **YAML Validation:** Parses and runs `kubectl apply --dry-run=server` before presenting the YAML.
10+
- **Apply & Rollback:** One-click apply with a 60-second undo window.
11+
- **Minimal React Frontend:** Syntax-highlighted YAML editor and cluster status sidebar.
12+
13+
## Prerequisites
14+
- Go 1.22+
15+
- Node.js 20+
16+
- Docker & kind
17+
- Ollama (latest)
18+
- kubectl 1.29+
19+
20+
## Running Locally
21+
22+
1. Create a local cluster: `kind create cluster --config deploy/kind-config.yaml`
23+
2. Start the dev servers: `make dev`
24+
25+
Access the UI at `http://localhost:5173`.

cmd/server/main.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package main
2+
3+
import (
4+
"os"
5+
6+
"github.com/aamod/llm-infra-assistant/internal/adapter"
7+
"github.com/aamod/llm-infra-assistant/internal/k8s"
8+
"github.com/aamod/llm-infra-assistant/internal/prompt"
9+
"github.com/aamod/llm-infra-assistant/internal/server"
10+
"github.com/aamod/llm-infra-assistant/internal/store"
11+
"github.com/aamod/llm-infra-assistant/web"
12+
"go.uber.org/zap"
13+
)
14+
15+
func main() {
16+
logger, _ := zap.NewDevelopment()
17+
defer logger.Sync()
18+
19+
providerCfg := adapter.Config{
20+
Provider: getEnv("PROVIDER", "ollama"),
21+
OllamaHost: getEnv("OLLAMA_HOST", "http://localhost:11434"),
22+
OllamaModel: getEnv("OLLAMA_MODEL", "llama3.2"),
23+
OpenAIKey: getEnv("OPENAI_API_KEY", ""),
24+
OpenAIModel: getEnv("OPENAI_MODEL", "gpt-4o-mini"),
25+
AnthropicKey: getEnv("ANTHROPIC_API_KEY", ""),
26+
AnthropicModel: getEnv("ANTHROPIC_MODEL", "claude-3-5-sonnet-20240620"),
27+
}
28+
29+
modelProvider, err := adapter.New(providerCfg)
30+
if err != nil {
31+
logger.Fatal("Failed to initialize model provider", zap.Error(err))
32+
}
33+
34+
dbPath := getEnv("DB_PATH", "./data/snapshots.db")
35+
os.MkdirAll("./data", 0755)
36+
dbStore, err := store.NewStore(dbPath)
37+
if err != nil {
38+
logger.Fatal("Failed to initialize store", zap.Error(err))
39+
}
40+
41+
k8sClient, err := k8s.NewClient()
42+
if err != nil {
43+
logger.Warn("Failed to init kubernetes client (running without cluster mode)", zap.Error(err))
44+
}
45+
46+
var cm *k8s.ClusterManager
47+
var pb *prompt.Builder
48+
if k8sClient != nil {
49+
cm = k8s.NewClusterManager(k8sClient, dbStore)
50+
pb = prompt.NewBuilder(k8sClient.DiscoveryClient)
51+
} else {
52+
pb = prompt.NewBuilder(nil)
53+
}
54+
55+
srv := server.NewServer(logger, modelProvider, cm, pb, k8sClient, web.DistFS)
56+
57+
port := getEnv("PORT", "8080")
58+
if err := srv.Start(port); err != nil {
59+
logger.Fatal("Server failed", zap.Error(err))
60+
}
61+
}
62+
63+
func getEnv(key, fallback string) string {
64+
if value, exists := os.LookupEnv(key); exists {
65+
return value
66+
}
67+
return fallback
68+
}

deploy/Dockerfile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Stage 1: Build React
2+
FROM node:20-alpine AS web-builder
3+
WORKDIR /app/web
4+
COPY web/package*.json ./
5+
RUN npm ci
6+
COPY web/ .
7+
RUN npm run build
8+
9+
# Stage 2: Build Go binary
10+
FROM golang:1.22-alpine AS go-builder
11+
WORKDIR /app
12+
COPY go.mod go.sum* ./
13+
RUN go mod download || true
14+
COPY . .
15+
COPY --from=web-builder /app/web/dist ./web/dist
16+
RUN go mod tidy
17+
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./cmd/server
18+
19+
# Stage 3: Minimal final image
20+
FROM gcr.io/distroless/static-debian12
21+
COPY --from=go-builder /app/server /server
22+
EXPOSE 8080
23+
ENTRYPOINT ["/server"]

deploy/k8s/rbac.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
apiVersion: v1
2+
kind: ServiceAccount
3+
metadata:
4+
name: llm-infra-assistant
5+
namespace: default
6+
---
7+
apiVersion: rbac.authorization.k8s.io/v1
8+
kind: ClusterRole
9+
metadata:
10+
name: llm-infra-assistant-role
11+
rules:
12+
- apiGroups: ["*"]
13+
resources: ["*"]
14+
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
15+
---
16+
apiVersion: rbac.authorization.k8s.io/v1
17+
kind: ClusterRoleBinding
18+
metadata:
19+
name: llm-infra-assistant-binding
20+
roleRef:
21+
apiGroup: rbac.authorization.k8s.io
22+
kind: ClusterRole
23+
name: llm-infra-assistant-role
24+
subjects:
25+
- kind: ServiceAccount
26+
name: llm-infra-assistant
27+
namespace: default

deploy/kind-config.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: Cluster
2+
apiVersion: kind.x-k8s.io/v1alpha4
3+
name: llm-infra
4+
nodes:
5+
- role: control-plane

internal/k8s/apply.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package k8s
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"strings"
10+
"time"
11+
12+
"k8s.io/apimachinery/pkg/api/errors"
13+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
15+
"k8s.io/apimachinery/pkg/runtime/schema"
16+
"k8s.io/apimachinery/pkg/types"
17+
"k8s.io/apimachinery/pkg/util/yaml"
18+
"k8s.io/client-go/discovery/cached/memory"
19+
"k8s.io/client-go/restmapper"
20+
"github.com/aamod/llm-infra-assistant/internal/store"
21+
)
22+
23+
type ApplyResult struct {
24+
ID string
25+
Resources []string
26+
AppliedAt time.Time
27+
CanUndo bool
28+
}
29+
30+
type ClusterManager struct {
31+
client *Client
32+
store *store.Store
33+
}
34+
35+
func NewClusterManager(client *Client, dbStore *store.Store) *ClusterManager {
36+
return &ClusterManager{
37+
client: client,
38+
store: dbStore,
39+
}
40+
}
41+
42+
func (cm *ClusterManager) Apply(ctx context.Context, yamlData string) (ApplyResult, error) {
43+
decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader([]byte(yamlData)), 4096)
44+
mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(cm.client.DiscoveryClient))
45+
46+
var appliedResources []string
47+
applyID := fmt.Sprintf("apply-%d", time.Now().UnixNano())
48+
49+
for {
50+
var obj unstructured.Unstructured
51+
if err := decoder.Decode(&obj); err != nil {
52+
if err == io.EOF {
53+
break
54+
}
55+
return ApplyResult{}, err
56+
}
57+
if len(obj.Object) == 0 {
58+
continue
59+
}
60+
61+
gvk := obj.GroupVersionKind()
62+
mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
63+
if err != nil {
64+
return ApplyResult{}, fmt.Errorf("failed to map %s: %v", gvk, err)
65+
}
66+
67+
ns := obj.GetNamespace()
68+
if ns == "" && mapping.Scope.Name() == "namespace" {
69+
ns = "default"
70+
}
71+
72+
dr := cm.client.DynamicClient.Resource(mapping.Resource).Namespace(ns)
73+
name := obj.GetName()
74+
75+
// Snapshot previous state
76+
var prevJSON []byte
77+
existing, err := dr.Get(ctx, name, metav1.GetOptions{})
78+
if err == nil {
79+
prevJSON, _ = existing.MarshalJSON()
80+
} else if !errors.IsNotFound(err) {
81+
return ApplyResult{}, fmt.Errorf("failed to get existing %s: %v", name, err)
82+
}
83+
84+
// Apply
85+
data, _ := json.Marshal(obj.Object)
86+
_, err = dr.Patch(ctx, name, types.ApplyPatchType, data, metav1.PatchOptions{FieldManager: "llm-infra"})
87+
if err != nil {
88+
return ApplyResult{}, fmt.Errorf("failed to apply %s: %v", name, err)
89+
}
90+
91+
resKey := fmt.Sprintf("%s/%s/%s", ns, gvk.Kind, name)
92+
appliedResources = append(appliedResources, resKey)
93+
94+
// Build a GVR key: "group/version/resource" so rollback can map back
95+
gvrKey := fmt.Sprintf("%s/%s/%s", mapping.Resource.Group, mapping.Resource.Version, mapping.Resource.Resource)
96+
97+
// Record snapshot (pass gvrKey for rollback)
98+
if err := cm.store.SaveSnapshot(ctx, applyID, resKey, gvrKey, string(prevJSON)); err != nil {
99+
return ApplyResult{}, fmt.Errorf("failed to save snapshot: %v", err)
100+
}
101+
}
102+
103+
return ApplyResult{
104+
ID: applyID,
105+
Resources: appliedResources,
106+
AppliedAt: time.Now(),
107+
CanUndo: true,
108+
}, nil
109+
}
110+
111+
func (cm *ClusterManager) CheckRollbackTTL(ctx context.Context, applyID string) error {
112+
return cm.store.CheckRollbackTTL(ctx, applyID)
113+
}
114+
115+
func (cm *ClusterManager) Rollback(ctx context.Context, applyID string) error {
116+
snapshots, err := cm.store.GetSnapshots(ctx, applyID)
117+
if err != nil {
118+
return err
119+
}
120+
if len(snapshots) == 0 {
121+
return fmt.Errorf("no snapshots found for %s or TTL expired", applyID)
122+
}
123+
124+
for _, snap := range snapshots {
125+
// Parse resource key "namespace/Kind/name"
126+
parts := strings.SplitN(snap.ResourceKey, "/", 3)
127+
if len(parts) != 3 {
128+
return fmt.Errorf("invalid resource key format: %s", snap.ResourceKey)
129+
}
130+
ns, _, name := parts[0], parts[1], parts[2]
131+
132+
// Parse GVR key "group/version/resource"
133+
gvrParts := strings.SplitN(snap.GVRKey, "/", 3)
134+
if len(gvrParts) != 3 {
135+
return fmt.Errorf("invalid gvr key format: %s", snap.GVRKey)
136+
}
137+
gvr := schema.GroupVersionResource{
138+
Group: gvrParts[0],
139+
Version: gvrParts[1],
140+
Resource: gvrParts[2],
141+
}
142+
143+
dr := cm.client.DynamicClient.Resource(gvr).Namespace(ns)
144+
145+
if snap.StateJSON == "" {
146+
// Resource didn't exist before apply — delete it to roll back
147+
err := dr.Delete(ctx, name, metav1.DeleteOptions{})
148+
if err != nil && !errors.IsNotFound(err) {
149+
return fmt.Errorf("rollback delete failed for %s: %v", snap.ResourceKey, err)
150+
}
151+
} else {
152+
// Resource existed before — patch it back to its original state
153+
var obj unstructured.Unstructured
154+
if jsonErr := json.Unmarshal([]byte(snap.StateJSON), &obj); jsonErr != nil {
155+
return fmt.Errorf("rollback unmarshal failed for %s: %v", snap.ResourceKey, jsonErr)
156+
}
157+
// Clear resource version to avoid conflicts with the current state
158+
obj.SetResourceVersion("")
159+
patchData, _ := json.Marshal(obj.Object)
160+
_, err := dr.Patch(ctx, name, types.MergePatchType, patchData, metav1.PatchOptions{})
161+
if err != nil {
162+
// If it no longer exists, recreate it
163+
if errors.IsNotFound(err) {
164+
_, createErr := dr.Create(ctx, &obj, metav1.CreateOptions{FieldManager: "llm-infra"})
165+
if createErr != nil {
166+
return fmt.Errorf("rollback recreate failed for %s: %v", snap.ResourceKey, createErr)
167+
}
168+
} else {
169+
return fmt.Errorf("rollback patch failed for %s: %v", snap.ResourceKey, err)
170+
}
171+
}
172+
}
173+
}
174+
175+
return nil
176+
}

0 commit comments

Comments
 (0)