Spaces:
Sleeping
Sleeping
GitHub Actions
commited on
Commit
·
f20ebef
1
Parent(s):
74db197
Sync from GitHub on master (Wed Nov 19 11:34:36 UTC 2025)
Browse files- README.md +13 -1
- hf-space/.github/workflows/sync-to-hf.yml +39 -0
- hf-space/README.md +216 -7
- hf-space/app.py +49 -183
- hf-space/hf-space/.gitignore +97 -0
- hf-space/hf-space/README.md +16 -0
- hf-space/hf-space/agentctl/__init__.py +1 -0
- hf-space/hf-space/agentctl/agent.py +297 -0
- hf-space/hf-space/agentctl/config.py +11 -0
- hf-space/hf-space/agentctl/k8s_client.py +209 -0
- hf-space/hf-space/agentctl/schemas.py +44 -0
- hf-space/hf-space/app.py +345 -0
- hf-space/hf-space/requirements.txt +6 -0
- hf-space/k8s/deployment-example.yaml +22 -0
- hf-space/k8s/job-example.yaml +21 -0
README.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# AgentCTL — LLM-Driven Kubernetes Automation
|
| 2 |
### Natural Language → Kubernetes Jobs, Deployments & CronJobs
|
| 3 |
Built for real clusters via Minikube + kubectl proxy + ngrok or Cloudflare Tunnel.
|
|
@@ -204,7 +216,7 @@ spec:
|
|
| 204 |
## ❤️ Credits
|
| 205 |
|
| 206 |
Built by **Murali Chandran (codeninja3d)**
|
| 207 |
-
AI Agents • Kubernetes • MLOps
|
| 208 |
|
| 209 |
---
|
| 210 |
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AgentCTL – Kubernetes Agent UI
|
| 3 |
+
emoji: 🧠
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "4.42.0"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
|
| 13 |
# AgentCTL — LLM-Driven Kubernetes Automation
|
| 14 |
### Natural Language → Kubernetes Jobs, Deployments & CronJobs
|
| 15 |
Built for real clusters via Minikube + kubectl proxy + ngrok or Cloudflare Tunnel.
|
|
|
|
| 216 |
## ❤️ Credits
|
| 217 |
|
| 218 |
Built by **Murali Chandran (codeninja3d)**
|
| 219 |
+
AI Agents • Kubernetes • MLOps
|
| 220 |
|
| 221 |
---
|
| 222 |
|
hf-space/.github/workflows/sync-to-hf.yml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync GitHub (master) → Hugging Face Space (main)
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- master
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
sync:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
|
| 12 |
+
steps:
|
| 13 |
+
- name: Checkout repository
|
| 14 |
+
uses: actions/checkout@v3
|
| 15 |
+
|
| 16 |
+
- name: Sync files to Hugging Face Space
|
| 17 |
+
env:
|
| 18 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 19 |
+
run: |
|
| 20 |
+
echo "🔐 Configuring Git"
|
| 21 |
+
git config --global user.email "actions@github.com"
|
| 22 |
+
git config --global user.name "GitHub Actions"
|
| 23 |
+
|
| 24 |
+
echo "📥 Cloning Hugging Face Space repo"
|
| 25 |
+
git clone https://codeninja3d:${HF_TOKEN}@huggingface.co/spaces/codeninja3d/agentctl hf-space
|
| 26 |
+
|
| 27 |
+
echo "📤 Syncing GitHub files → HF Space (rsync)"
|
| 28 |
+
rsync -av --delete --exclude=".git" ./ hf-space/ || true
|
| 29 |
+
|
| 30 |
+
cd hf-space
|
| 31 |
+
|
| 32 |
+
echo "📌 Committing changes"
|
| 33 |
+
git add .
|
| 34 |
+
git commit -m "Sync from GitHub on master ($(date))" || echo "No changes"
|
| 35 |
+
|
| 36 |
+
echo "⬆️ Pushing to Hugging Face Space (main branch)"
|
| 37 |
+
git push https://codeninja3d:${HF_TOKEN}@huggingface.co/spaces/codeninja3d/agentctl HEAD:main
|
| 38 |
+
|
| 39 |
+
echo "🎉 Sync complete!"
|
hf-space/README.md
CHANGED
|
@@ -1,16 +1,225 @@
|
|
| 1 |
---
|
| 2 |
-
title: AgentCTL
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
|
| 13 |
-
AgentCTL
|
| 14 |
-
|
|
|
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: AgentCTL – Kubernetes Agent UI
|
| 3 |
+
emoji: 🧠
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: "4.42.0"
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
|
| 13 |
+
# AgentCTL — LLM-Driven Kubernetes Automation
|
| 14 |
+
### Natural Language → Kubernetes Jobs, Deployments & CronJobs
|
| 15 |
+
Built for real clusters via Minikube + kubectl proxy + ngrok or Cloudflare Tunnel.
|
| 16 |
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 🚀 Overview
|
| 20 |
+
|
| 21 |
+
**AgentCTL** is an agentic workflow system that converts **natural language instructions** into **Kubernetes manifests** (Jobs, Deployments, CronJobs) and applies them to a **real Kubernetes cluster**.
|
| 22 |
+
|
| 23 |
+
It works by exposing your cluster via:
|
| 24 |
+
|
| 25 |
+
- `kubectl proxy`
|
| 26 |
+
- a public tunnel (`ngrok` or `cloudflared`)
|
| 27 |
+
- a Gradio web UI
|
| 28 |
+
- a lightweight NL → YAML agent (heuristic + optional OpenAI LLM)
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## ✨ Features
|
| 33 |
+
|
| 34 |
+
- Natural language → Kubernetes YAML
|
| 35 |
+
- Supports:
|
| 36 |
+
- Jobs
|
| 37 |
+
- Deployments
|
| 38 |
+
- CronJobs
|
| 39 |
+
- Optional OpenAI LLM YAML refinement
|
| 40 |
+
- Cluster dashboard (Jobs, Pods, Deployments, CronJobs)
|
| 41 |
+
- Pod logs viewer
|
| 42 |
+
- Pure HTTP client — no kubeconfig required
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
## 🧱 Architecture
|
| 47 |
+
|
| 48 |
+
```
|
| 49 |
+
Gradio UI → AgentCTL (NL→YAML) → K8sClient (HTTP) → Kubernetes API
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## 📦 Installation
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
pip install -r requirements.txt
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
or
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
conda create -n agentctl python=3.10 -y
|
| 64 |
+
conda activate agentctl
|
| 65 |
+
pip install -r requirements.txt
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## 🧪 Minikube Setup
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
minikube start --driver=docker
|
| 74 |
+
kubectl get pods -A
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
Expose API publicly:
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
kubectl proxy --port=8001 --address=0.0.0.0 --accept-hosts='.*' --accept-paths='.*'
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
Tunnel:
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
ngrok http 8001
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
---
|
| 90 |
+
|
| 91 |
+
## 🌐 Environment Variables
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
export K8S_API_BASE_URL="https://xxxx.ngrok-free.dev"
|
| 95 |
+
export K8S_NAMESPACE="default"
|
| 96 |
+
export K8S_VERIFY_SSL=false
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
Enable LLM YAML:
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
export AGENTCTL_USE_LLM=true
|
| 103 |
+
export OPENAI_API_KEY="sk-..."
|
| 104 |
+
export OPENAI_MODEL="gpt-4o-mini"
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
---
|
| 108 |
+
|
| 109 |
+
## ▶️ Run the App
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
python app.py
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
Open:
|
| 116 |
+
|
| 117 |
+
```
|
| 118 |
+
http://localhost:7860
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
---
|
| 122 |
+
|
| 123 |
+
## 📝 Usage Examples
|
| 124 |
+
|
| 125 |
+
### 🟦 Job
|
| 126 |
+
```
|
| 127 |
+
run a python job to preprocess data
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
### 🟧 Deployment
|
| 131 |
+
```
|
| 132 |
+
Prompt
|
| 133 |
+
create an nginx deployment with 3 replicas
|
| 134 |
+
|
| 135 |
+
Example
|
| 136 |
+
**Generated YAML:**
|
| 137 |
+
|
| 138 |
+
```yaml
|
| 139 |
+
apiVersion: apps/v1
|
| 140 |
+
kind: Deployment
|
| 141 |
+
metadata:
|
| 142 |
+
name: nginx-deployment
|
| 143 |
+
namespace: default
|
| 144 |
+
labels:
|
| 145 |
+
app: agentctl-deployment
|
| 146 |
+
spec:
|
| 147 |
+
replicas: 3
|
| 148 |
+
selector:
|
| 149 |
+
matchLabels:
|
| 150 |
+
app: agentctl-deployment
|
| 151 |
+
template:
|
| 152 |
+
metadata:
|
| 153 |
+
labels:
|
| 154 |
+
app: agentctl-deployment
|
| 155 |
+
spec:
|
| 156 |
+
containers:
|
| 157 |
+
- name: main
|
| 158 |
+
image: nginx:1.27-alpine
|
| 159 |
+
ports:
|
| 160 |
+
- containerPort: 80
|
| 161 |
+
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
### 🟩 CronJob
|
| 165 |
+
```
|
| 166 |
+
Prompt
|
| 167 |
+
schedule a python cleanup script every 5 minutes
|
| 168 |
+
|
| 169 |
+
example
|
| 170 |
+
apiVersion: batch/v1
|
| 171 |
+
kind: CronJob
|
| 172 |
+
metadata:
|
| 173 |
+
name: agentctl-cronjob
|
| 174 |
+
namespace: default
|
| 175 |
+
spec:
|
| 176 |
+
schedule: "*/5 * * * *"
|
| 177 |
+
jobTemplate:
|
| 178 |
+
spec:
|
| 179 |
+
template:
|
| 180 |
+
metadata:
|
| 181 |
+
labels:
|
| 182 |
+
app: agentctl-cronjob
|
| 183 |
+
spec:
|
| 184 |
+
restartPolicy: Never
|
| 185 |
+
containers:
|
| 186 |
+
- name: main
|
| 187 |
+
image: python:3.11-slim
|
| 188 |
+
command:
|
| 189 |
+
- /bin/sh
|
| 190 |
+
- -c
|
| 191 |
+
- python cleanup.py
|
| 192 |
+
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
---
|
| 196 |
+
|
| 197 |
+
## 🚀 Deploy to Hugging Face Spaces
|
| 198 |
+
|
| 199 |
+
1. Create a Space (Gradio)
|
| 200 |
+
2. Upload project files
|
| 201 |
+
3. Set environment variables
|
| 202 |
+
4. Run the UI remotely controlling your real cluster
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## 🔐 Security Notes
|
| 207 |
+
|
| 208 |
+
- ngrok + kubectl proxy is for demos only
|
| 209 |
+
- For real environments use:
|
| 210 |
+
- Cloudflare Tunnel
|
| 211 |
+
- RBAC service accounts
|
| 212 |
+
- Namespaced permissions
|
| 213 |
+
|
| 214 |
+
---
|
| 215 |
+
|
| 216 |
+
## ❤️ Credits
|
| 217 |
+
|
| 218 |
+
Built by **Murali Chandran (codeninja3d)**
|
| 219 |
+
AI Agents • Kubernetes • MLOps
|
| 220 |
+
|
| 221 |
+
---
|
| 222 |
+
|
| 223 |
+
## 📄 License
|
| 224 |
+
|
| 225 |
+
MIT License
|
hf-space/app.py
CHANGED
|
@@ -1,8 +1,6 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
|
| 3 |
import os
|
| 4 |
-
import re
|
| 5 |
-
import time
|
| 6 |
from typing import Tuple
|
| 7 |
|
| 8 |
import gradio as gr
|
|
@@ -12,65 +10,43 @@ from agentctl.k8s_client import K8sClient
|
|
| 12 |
from agentctl.agent import K8sAgent
|
| 13 |
|
| 14 |
|
| 15 |
-
# ---------------------------------------------------------------------
|
| 16 |
-
# Core objects
|
| 17 |
-
# ---------------------------------------------------------------------
|
| 18 |
-
|
| 19 |
agent = K8sAgent()
|
| 20 |
|
| 21 |
|
| 22 |
def _get_client() -> K8sClient:
|
| 23 |
-
"""Create a Kubernetes HTTP client using env-based config."""
|
| 24 |
return K8sClient()
|
| 25 |
|
| 26 |
|
| 27 |
-
#
|
| 28 |
-
#
|
| 29 |
-
#
|
| 30 |
|
| 31 |
|
| 32 |
def generate_yaml_from_prompt(prompt: str, namespace: str, kind: str) -> str:
|
| 33 |
-
"""Convert natural language + kind selection into YAML."""
|
| 34 |
if not prompt.strip():
|
| 35 |
return "# Enter a description, e.g. 'run a python preprocessing job'"
|
| 36 |
-
|
| 37 |
-
ns = namespace.strip() or settings.k8s_namespace
|
| 38 |
-
kind_sel = None if kind == "Auto" else kind
|
| 39 |
-
|
| 40 |
_, yaml_text = agent.nl_to_resource_yaml(
|
| 41 |
prompt,
|
| 42 |
-
namespace=
|
| 43 |
-
kind=
|
| 44 |
)
|
| 45 |
return yaml_text
|
| 46 |
|
| 47 |
|
| 48 |
def apply_yaml_to_cluster(yaml_text: str) -> Tuple[str, str]:
|
| 49 |
-
"""Apply a YAML manifest to the cluster via K8s API."""
|
| 50 |
if not yaml_text.strip():
|
| 51 |
return "No YAML to apply.", ""
|
| 52 |
|
| 53 |
client = _get_client()
|
| 54 |
result = client.apply_manifest(yaml_text)
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
except Exception:
|
| 61 |
-
raw_str = "<unserialisable response>"
|
| 62 |
-
|
| 63 |
-
prefix = "✅" if result.success else "❌"
|
| 64 |
-
return f"{prefix} {result.message}", raw_str
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
# ---------------------------------------------------------------------
|
| 68 |
-
# Dashboard snapshot
|
| 69 |
-
# ---------------------------------------------------------------------
|
| 70 |
|
| 71 |
|
| 72 |
def get_cluster_snapshot() -> str:
|
| 73 |
-
"""Return a human-readable snapshot of Jobs, Pods, Deployments, CronJobs."""
|
| 74 |
client = _get_client()
|
| 75 |
snap = client.snapshot()
|
| 76 |
|
|
@@ -83,8 +59,7 @@ def get_cluster_snapshot() -> str:
|
|
| 83 |
else:
|
| 84 |
for j in snap.jobs:
|
| 85 |
lines.append(
|
| 86 |
-
f" - {j.name}: succeeded={j.succeeded}, "
|
| 87 |
-
f"failed={j.failed}, active={j.active}"
|
| 88 |
)
|
| 89 |
|
| 90 |
# Pods
|
|
@@ -122,109 +97,41 @@ def get_cluster_snapshot() -> str:
|
|
| 122 |
return "\n".join(lines)
|
| 123 |
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
-
|
| 131 |
-
"""Internal: raw log text from Kubernetes."""
|
| 132 |
-
client = _get_client()
|
| 133 |
-
return client.get_pod_logs(pod_name.strip(), tail_lines=tail_lines)
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
def _colorize_logs(text: str) -> str:
|
| 137 |
-
"""Convert log text into HTML with basic severity colouring."""
|
| 138 |
-
if not text:
|
| 139 |
-
return "<span style='color:#888;'>No logs</span>"
|
| 140 |
-
|
| 141 |
-
lines = text.splitlines()
|
| 142 |
-
out: list[str] = []
|
| 143 |
-
|
| 144 |
-
for line in lines:
|
| 145 |
-
if re.search(r"(error|failed|exception)", line, re.IGNORECASE):
|
| 146 |
-
colour = "#ff4b4b" # red
|
| 147 |
-
elif re.search(r"(warn)", line, re.IGNORECASE):
|
| 148 |
-
colour = "#f7c843" # yellow
|
| 149 |
-
elif re.search(r"(info|started|running|completed)", line, re.IGNORECASE):
|
| 150 |
-
colour = "#5ad55a" # green
|
| 151 |
-
else:
|
| 152 |
-
colour = "#d0d0d0" # grey
|
| 153 |
-
|
| 154 |
-
safe = line.replace("<", "<").replace(">", ">")
|
| 155 |
-
out.append(f"<span style='color:{colour};'>{safe}</span>")
|
| 156 |
-
|
| 157 |
-
return "<br>".join(out)
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def get_pod_logs_once(pod_name: str, tail_lines: int) -> str:
|
| 161 |
-
"""One-shot logs fetch, used for the 'Get logs' button."""
|
| 162 |
-
pod_name = pod_name.strip()
|
| 163 |
-
if not pod_name:
|
| 164 |
-
return "<span style='color:#ff4b4b;'>Enter a pod name from the dashboard.</span>"
|
| 165 |
-
|
| 166 |
-
raw = _fetch_pod_logs_raw(pod_name, tail_lines)
|
| 167 |
-
return _colorize_logs(raw)
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def follow_pod_logs(pod_name: str, tail_lines: int):
|
| 171 |
-
"""Live streaming logs using Gradio generator output."""
|
| 172 |
-
pod_name = pod_name.strip()
|
| 173 |
-
if not pod_name:
|
| 174 |
-
yield "<span style='color:#ff4b4b;'>Enter a pod name from the dashboard.</span>"
|
| 175 |
-
return
|
| 176 |
-
|
| 177 |
-
while True:
|
| 178 |
-
raw = _fetch_pod_logs_raw(pod_name, tail_lines)
|
| 179 |
-
html = _colorize_logs(raw)
|
| 180 |
-
yield html
|
| 181 |
-
time.sleep(2)
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
# CSS for the logs "terminal"
|
| 185 |
-
TERMINAL_CSS = """
|
| 186 |
-
#logs_terminal {
|
| 187 |
-
background-color: #111111 !important;
|
| 188 |
-
color: #d0d0d0 !important;
|
| 189 |
-
font-family: monospace !important;
|
| 190 |
-
padding: 14px;
|
| 191 |
-
border-radius: 8px;
|
| 192 |
-
border: 1px solid #444444;
|
| 193 |
-
height: 420px;
|
| 194 |
-
overflow-y: scroll;
|
| 195 |
-
white-space: pre-wrap;
|
| 196 |
-
}
|
| 197 |
-
"""
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
# ---------------------------------------------------------------------
|
| 201 |
# Gradio UI
|
| 202 |
-
#
|
| 203 |
|
| 204 |
|
| 205 |
def build_app() -> gr.Blocks:
|
| 206 |
-
with gr.Blocks(
|
| 207 |
-
title="AgentCTL: LLM-Driven Kubernetes Automation",
|
| 208 |
-
css=TERMINAL_CSS,
|
| 209 |
-
) as demo:
|
| 210 |
-
# Top environment hint (like your screenshot)
|
| 211 |
gr.Markdown(
|
| 212 |
"""
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
-
|
| 216 |
-
- `K8S_NAMESPACE` → **default namespace** (e.g. `default`)
|
| 217 |
-
- `K8S_VERIFY_SSL` → **true/false** (set `false` for self-signed ngrok endpoints)
|
| 218 |
-
- `AGENTCTL_USE_LLM` → **true/false** (optional; uses OpenAI if enabled in `agent.py`)
|
| 219 |
-
- `OPENAI_API_KEY` → your key (if LLM mode enabled)
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
)
|
| 224 |
|
| 225 |
-
#
|
| 226 |
-
# Create Resource tab
|
| 227 |
-
# ============================================================
|
| 228 |
with gr.Tab("Create Resource"):
|
| 229 |
with gr.Row():
|
| 230 |
prompt = gr.Textbox(
|
|
@@ -237,7 +144,6 @@ def build_app() -> gr.Blocks:
|
|
| 237 |
),
|
| 238 |
lines=5,
|
| 239 |
)
|
| 240 |
-
|
| 241 |
with gr.Column():
|
| 242 |
namespace = gr.Textbox(
|
| 243 |
label="Kubernetes namespace",
|
|
@@ -249,25 +155,18 @@ def build_app() -> gr.Blocks:
|
|
| 249 |
choices=["Auto", "Job", "Deployment", "CronJob"],
|
| 250 |
value="Auto",
|
| 251 |
)
|
| 252 |
-
gr.Markdown(
|
| 253 |
-
"Tip: leave **Auto** to let the agent infer Job vs Deployment vs CronJob."
|
| 254 |
-
)
|
| 255 |
|
| 256 |
-
generate_btn = gr.Button("Generate YAML"
|
| 257 |
-
yaml_box = gr.Code(
|
| 258 |
-
label="Generated manifest (YAML)",
|
| 259 |
-
language="yaml",
|
| 260 |
-
lines=22,
|
| 261 |
-
)
|
| 262 |
|
| 263 |
-
apply_btn = gr.Button("Apply to
|
| 264 |
apply_msg = gr.Textbox(label="Status", interactive=False)
|
| 265 |
apply_raw = gr.Textbox(label="Raw API response (debug)")
|
| 266 |
|
| 267 |
generate_btn.click(
|
| 268 |
fn=generate_yaml_from_prompt,
|
| 269 |
inputs=[prompt, namespace, kind],
|
| 270 |
-
outputs=yaml_box,
|
| 271 |
)
|
| 272 |
|
| 273 |
apply_btn.click(
|
|
@@ -276,39 +175,17 @@ def build_app() -> gr.Blocks:
|
|
| 276 |
outputs=[apply_msg, apply_raw],
|
| 277 |
)
|
| 278 |
|
| 279 |
-
#
|
| 280 |
-
# Cluster Dashboard tab
|
| 281 |
-
# ============================================================
|
| 282 |
with gr.Tab("Cluster Dashboard"):
|
| 283 |
-
gr.Markdown(
|
| 284 |
-
|
| 285 |
-
)
|
| 286 |
-
snapshot_btn = gr.Button("Refresh snapshot", variant="primary")
|
| 287 |
-
snapshot_box = gr.Textbox(
|
| 288 |
-
label="Cluster overview",
|
| 289 |
-
lines=25,
|
| 290 |
-
interactive=False,
|
| 291 |
-
)
|
| 292 |
|
| 293 |
-
snapshot_btn.click(
|
| 294 |
-
fn=get_cluster_snapshot,
|
| 295 |
-
inputs=[],
|
| 296 |
-
outputs=[snapshot_box],
|
| 297 |
-
)
|
| 298 |
|
| 299 |
-
#
|
| 300 |
-
# Pod Logs tab
|
| 301 |
-
# ============================================================
|
| 302 |
with gr.Tab("Pod Logs"):
|
| 303 |
-
gr.
|
| 304 |
-
"Paste a pod name from the **Cluster Dashboard** (e.g. `agentctl-job-lnrcp`) "
|
| 305 |
-
"and fetch its logs. Use **Follow logs** for a live `kubectl logs -f` style view."
|
| 306 |
-
)
|
| 307 |
-
|
| 308 |
-
pod_name = gr.Textbox(
|
| 309 |
-
label="Pod name",
|
| 310 |
-
placeholder="e.g. preprocess-job-z48rz",
|
| 311 |
-
)
|
| 312 |
tail_lines = gr.Slider(
|
| 313 |
label="Tail lines",
|
| 314 |
minimum=10,
|
|
@@ -316,24 +193,14 @@ def build_app() -> gr.Blocks:
|
|
| 316 |
step=10,
|
| 317 |
value=100,
|
| 318 |
)
|
|
|
|
|
|
|
| 319 |
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
follow_logs_btn = gr.Button("Follow logs (live)", variant="primary")
|
| 323 |
-
|
| 324 |
-
logs_box = gr.HTML(label="Logs", elem_id="logs_terminal")
|
| 325 |
-
|
| 326 |
-
get_logs_btn.click(
|
| 327 |
-
fn=get_pod_logs_once,
|
| 328 |
inputs=[pod_name, tail_lines],
|
| 329 |
outputs=[logs_box],
|
| 330 |
)
|
| 331 |
-
|
| 332 |
-
follow_logs_btn.click(
|
| 333 |
-
fn=follow_pod_logs,
|
| 334 |
-
inputs=[pod_name, tail_lines],
|
| 335 |
-
outputs=logs_box,
|
| 336 |
-
)
|
| 337 |
|
| 338 |
return demo
|
| 339 |
|
|
@@ -341,5 +208,4 @@ def build_app() -> gr.Blocks:
|
|
| 341 |
app = build_app()
|
| 342 |
|
| 343 |
if __name__ == "__main__":
|
| 344 |
-
|
| 345 |
-
app.launch(server_name="0.0.0.0", server_port=port)
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
|
| 3 |
import os
|
|
|
|
|
|
|
| 4 |
from typing import Tuple
|
| 5 |
|
| 6 |
import gradio as gr
|
|
|
|
| 10 |
from agentctl.agent import K8sAgent
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
agent = K8sAgent()
|
| 14 |
|
| 15 |
|
| 16 |
def _get_client() -> K8sClient:
|
|
|
|
| 17 |
return K8sClient()
|
| 18 |
|
| 19 |
|
| 20 |
+
# ------------------------------------------------------------
|
| 21 |
+
# Handlers
|
| 22 |
+
# ------------------------------------------------------------
|
| 23 |
|
| 24 |
|
| 25 |
def generate_yaml_from_prompt(prompt: str, namespace: str, kind: str) -> str:
|
|
|
|
| 26 |
if not prompt.strip():
|
| 27 |
return "# Enter a description, e.g. 'run a python preprocessing job'"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
_, yaml_text = agent.nl_to_resource_yaml(
|
| 29 |
prompt,
|
| 30 |
+
namespace=namespace or settings.k8s_namespace,
|
| 31 |
+
kind=kind if kind != "Auto" else None,
|
| 32 |
)
|
| 33 |
return yaml_text
|
| 34 |
|
| 35 |
|
| 36 |
def apply_yaml_to_cluster(yaml_text: str) -> Tuple[str, str]:
|
|
|
|
| 37 |
if not yaml_text.strip():
|
| 38 |
return "No YAML to apply.", ""
|
| 39 |
|
| 40 |
client = _get_client()
|
| 41 |
result = client.apply_manifest(yaml_text)
|
| 42 |
|
| 43 |
+
if result.success:
|
| 44 |
+
return result.message, (result.raw_response and str(result.raw_response) or "")
|
| 45 |
+
else:
|
| 46 |
+
return f"❌ {result.message}", (result.raw_response and str(result.raw_response) or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
def get_cluster_snapshot() -> str:
|
|
|
|
| 50 |
client = _get_client()
|
| 51 |
snap = client.snapshot()
|
| 52 |
|
|
|
|
| 59 |
else:
|
| 60 |
for j in snap.jobs:
|
| 61 |
lines.append(
|
| 62 |
+
f" - {j.name}: succeeded={j.succeeded}, failed={j.failed}, active={j.active}"
|
|
|
|
| 63 |
)
|
| 64 |
|
| 65 |
# Pods
|
|
|
|
| 97 |
return "\n".join(lines)
|
| 98 |
|
| 99 |
|
| 100 |
+
def get_pod_logs_handler(pod_name: str, tail_lines: int) -> str:
|
| 101 |
+
if not pod_name.strip():
|
| 102 |
+
return "Enter a pod name from the snapshot above."
|
| 103 |
+
client = _get_client()
|
| 104 |
+
logs = client.get_pod_logs(pod_name.strip(), tail_lines=tail_lines)
|
| 105 |
+
return logs
|
| 106 |
|
| 107 |
|
| 108 |
+
# ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
# Gradio UI
|
| 110 |
+
# ------------------------------------------------------------
|
| 111 |
|
| 112 |
|
| 113 |
def build_app() -> gr.Blocks:
|
| 114 |
+
with gr.Blocks(title="AgentCTL: LLM-Driven Kubernetes Automation") as demo:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
gr.Markdown(
|
| 116 |
"""
|
| 117 |
+
# AgentCTL: LLM-Driven Kubernetes Automation
|
| 118 |
+
|
| 119 |
+
This Space connects to a **real Kubernetes cluster** (e.g. Minikube
|
| 120 |
+
exposed via `kubectl proxy` + ngrok). It demonstrates an
|
| 121 |
+
**agentic workflow** where natural language is converted into
|
| 122 |
+
Kubernetes Job / Deployment / CronJob manifests.
|
| 123 |
|
| 124 |
+
Environment variables:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
+
- `K8S_API_BASE_URL` → public K8s API URL (ngrok over `kubectl proxy`)
|
| 127 |
+
- `K8S_NAMESPACE` → default namespace
|
| 128 |
+
- `K8S_VERIFY_SSL` → true/false
|
| 129 |
+
- `AGENTCTL_USE_LLM` → true/false (optional; uses OpenAI if enabled)
|
| 130 |
+
- `OPENAI_API_KEY` → your key (if LLM enabled)
|
| 131 |
+
"""
|
| 132 |
)
|
| 133 |
|
| 134 |
+
# --------- Create Resource tab --------- #
|
|
|
|
|
|
|
| 135 |
with gr.Tab("Create Resource"):
|
| 136 |
with gr.Row():
|
| 137 |
prompt = gr.Textbox(
|
|
|
|
| 144 |
),
|
| 145 |
lines=5,
|
| 146 |
)
|
|
|
|
| 147 |
with gr.Column():
|
| 148 |
namespace = gr.Textbox(
|
| 149 |
label="Kubernetes namespace",
|
|
|
|
| 155 |
choices=["Auto", "Job", "Deployment", "CronJob"],
|
| 156 |
value="Auto",
|
| 157 |
)
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
generate_btn = gr.Button("Generate YAML")
|
| 160 |
+
yaml_box = gr.Code(label="Generated Manifest (YAML)", language="yaml", lines=22)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
+
apply_btn = gr.Button("Apply to Cluster")
|
| 163 |
apply_msg = gr.Textbox(label="Status", interactive=False)
|
| 164 |
apply_raw = gr.Textbox(label="Raw API response (debug)")
|
| 165 |
|
| 166 |
generate_btn.click(
|
| 167 |
fn=generate_yaml_from_prompt,
|
| 168 |
inputs=[prompt, namespace, kind],
|
| 169 |
+
outputs=[yaml_box],
|
| 170 |
)
|
| 171 |
|
| 172 |
apply_btn.click(
|
|
|
|
| 175 |
outputs=[apply_msg, apply_raw],
|
| 176 |
)
|
| 177 |
|
| 178 |
+
# --------- Dashboard tab --------- #
|
|
|
|
|
|
|
| 179 |
with gr.Tab("Cluster Dashboard"):
|
| 180 |
+
gr.Markdown("Snapshot of Jobs, Pods, Deployments and CronJobs in the namespace.")
|
| 181 |
+
snapshot_btn = gr.Button("Refresh snapshot")
|
| 182 |
+
snapshot_box = gr.Textbox(label="Cluster Overview", lines=25)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
+
snapshot_btn.click(fn=get_cluster_snapshot, inputs=[], outputs=[snapshot_box])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
+
# --------- Pod Logs tab --------- #
|
|
|
|
|
|
|
| 187 |
with gr.Tab("Pod Logs"):
|
| 188 |
+
pod_name = gr.Textbox(label="Pod name")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
tail_lines = gr.Slider(
|
| 190 |
label="Tail lines",
|
| 191 |
minimum=10,
|
|
|
|
| 193 |
step=10,
|
| 194 |
value=100,
|
| 195 |
)
|
| 196 |
+
logs_btn = gr.Button("Get logs")
|
| 197 |
+
logs_box = gr.Textbox(label="Logs", lines=25)
|
| 198 |
|
| 199 |
+
logs_btn.click(
|
| 200 |
+
fn=get_pod_logs_handler,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
inputs=[pod_name, tail_lines],
|
| 202 |
outputs=[logs_box],
|
| 203 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
return demo
|
| 206 |
|
|
|
|
| 208 |
app = build_app()
|
| 209 |
|
| 210 |
if __name__ == "__main__":
|
| 211 |
+
app.launch(server_name="0.0.0.0", server_port=int(os.getenv("PORT", 7860)))
|
|
|
hf-space/hf-space/.gitignore
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Python ---
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
*.pkl
|
| 7 |
+
*.pyd
|
| 8 |
+
*.so
|
| 9 |
+
|
| 10 |
+
# Virtual environment
|
| 11 |
+
.venv/
|
| 12 |
+
env/
|
| 13 |
+
venv/
|
| 14 |
+
|
| 15 |
+
# Jupyter
|
| 16 |
+
.ipynb_checkpoints/
|
| 17 |
+
*.ipynb
|
| 18 |
+
|
| 19 |
+
# --- OS files ---
|
| 20 |
+
.DS_Store
|
| 21 |
+
Thumbs.db
|
| 22 |
+
|
| 23 |
+
# --- VSCode / JetBrains ---
|
| 24 |
+
.vscode/
|
| 25 |
+
.idea/
|
| 26 |
+
|
| 27 |
+
# --- Logs ---
|
| 28 |
+
*.log
|
| 29 |
+
logs/
|
| 30 |
+
*.out
|
| 31 |
+
*.err
|
| 32 |
+
|
| 33 |
+
# --- Build / Packaging ---
|
| 34 |
+
build/
|
| 35 |
+
dist/
|
| 36 |
+
*.egg-info/
|
| 37 |
+
.eggs/
|
| 38 |
+
|
| 39 |
+
# --- Cache ---
|
| 40 |
+
.cache/
|
| 41 |
+
*.cache
|
| 42 |
+
*.mypy_cache/
|
| 43 |
+
*.pytest_cache/
|
| 44 |
+
*.ruff_cache/
|
| 45 |
+
|
| 46 |
+
# --- Hugging Face / HF Spaces ---
|
| 47 |
+
hf_cache/
|
| 48 |
+
huggingface/
|
| 49 |
+
.environment/
|
| 50 |
+
|
| 51 |
+
# --- Temp artifacts ---
|
| 52 |
+
tmp/
|
| 53 |
+
temp/
|
| 54 |
+
*.tmp
|
| 55 |
+
|
| 56 |
+
# --- Credentials / secrets (DO NOT COMMIT) ---
|
| 57 |
+
.env
|
| 58 |
+
.env.local
|
| 59 |
+
.env.* # safe because no secrets go into repo
|
| 60 |
+
secrets.yml
|
| 61 |
+
*.json.secret
|
| 62 |
+
*.key
|
| 63 |
+
*.pem
|
| 64 |
+
|
| 65 |
+
# --- Kubernetes ---
|
| 66 |
+
# kubeconfigs or kube generated files
|
| 67 |
+
kubeconfig
|
| 68 |
+
.kube/
|
| 69 |
+
*.yaml.bak
|
| 70 |
+
*~ # backups
|
| 71 |
+
|
| 72 |
+
# --- Models / Checkpoints ---
|
| 73 |
+
*.pt
|
| 74 |
+
*.bin
|
| 75 |
+
*.onnx
|
| 76 |
+
models/
|
| 77 |
+
checkpoints/
|
| 78 |
+
|
| 79 |
+
# --- Node (if future UI additions) ---
|
| 80 |
+
node_modules/
|
| 81 |
+
npm-debug.log*
|
| 82 |
+
|
| 83 |
+
# --- Gradio ---
|
| 84 |
+
gradio_cached_examples/
|
| 85 |
+
|
| 86 |
+
# --- Ngrok (just in case) ---
|
| 87 |
+
ngrok.yml
|
| 88 |
+
.ngrok/
|
| 89 |
+
|
| 90 |
+
# --- Minikube artifacts ---
|
| 91 |
+
minikube/
|
| 92 |
+
.kubectl-proxy/
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# Block OpenAI keys if accidentally named
|
| 96 |
+
openai_key.txt
|
| 97 |
+
openai_api_key.txt
|
hf-space/hf-space/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: AgentCTL
|
| 3 |
+
emoji: ⚙️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.49.1
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
AgentCTL is a next-generation, agentic control plane for Kubernetes.
|
| 14 |
+
It allows you to create, manage, and observe Kubernetes resources using natural language, backed by a minimal REST client and optional LLM-powered YAML generation.
|
| 15 |
+
|
| 16 |
+
AgentCTL runs locally or can be deployed to Hugging Face Spaces, allowing cloud-hosted UI + locally hosted Kubernetes clusters.
|
hf-space/hf-space/agentctl/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
__all__ = ["config", "k8s_client", "agent", "schemas"]
|
hf-space/hf-space/agentctl/agent.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Natural language → Kubernetes YAML generator (Job / Deployment / CronJob).
|
| 2 |
+
|
| 3 |
+
Heuristic templates + optional LLM (OpenAI) for richer YAML.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import Optional, Tuple
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
|
| 13 |
+
# Optional LLM support (OpenAI)
|
| 14 |
+
try:
|
| 15 |
+
from openai import OpenAI # pip install openai
|
| 16 |
+
except Exception: # pragma: no cover
|
| 17 |
+
OpenAI = None # type: ignore
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class JobSpecRequest:
|
| 22 |
+
prompt: str
|
| 23 |
+
name: str = "agentctl-job"
|
| 24 |
+
image: str = "busybox:1.36"
|
| 25 |
+
command: Optional[str] = "echo Hello && sleep 5"
|
| 26 |
+
cpu: str = "500m"
|
| 27 |
+
memory: str = "512Mi"
|
| 28 |
+
namespace: str = "default"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class K8sAgent:
|
| 32 |
+
"""Agent that turns NL into Job / Deployment / CronJob YAML."""
|
| 33 |
+
|
| 34 |
+
# --------------------- Kind detection --------------------- #
|
| 35 |
+
|
| 36 |
+
def infer_kind(self, text: str) -> str:
|
| 37 |
+
t = text.lower()
|
| 38 |
+
|
| 39 |
+
# CronJob-ish language
|
| 40 |
+
if any(w in t for w in ["every minute", "every 5 minutes", "every hour", "cron", "schedule", "scheduled job"]):
|
| 41 |
+
return "CronJob"
|
| 42 |
+
|
| 43 |
+
# Deployment-ish language
|
| 44 |
+
if any(w in t for w in ["deployment", "service", "web", "api", "server", "nginx", "frontend", "backend"]):
|
| 45 |
+
return "Deployment"
|
| 46 |
+
|
| 47 |
+
# Default to Job
|
| 48 |
+
return "Job"
|
| 49 |
+
|
| 50 |
+
# --------------------- Job templates --------------------- #
|
| 51 |
+
|
| 52 |
+
def nl_to_job_request(self, text: str, namespace: str = "default") -> JobSpecRequest:
|
| 53 |
+
t = text.lower()
|
| 54 |
+
name = "agentctl-job"
|
| 55 |
+
|
| 56 |
+
if "preprocess" in t:
|
| 57 |
+
name = "preprocess-job"
|
| 58 |
+
elif "inference" in t:
|
| 59 |
+
name = "inference-job"
|
| 60 |
+
elif "train" in t or "training" in t:
|
| 61 |
+
name = "training-job"
|
| 62 |
+
|
| 63 |
+
image = "busybox:1.36"
|
| 64 |
+
if "python" in t:
|
| 65 |
+
image = "python:3.11-slim"
|
| 66 |
+
if "pytorch" in t:
|
| 67 |
+
image = "pytorch/pytorch:2.3.0-cuda12.1-cudnn9-runtime"
|
| 68 |
+
|
| 69 |
+
command = "echo Hello && sleep 5"
|
| 70 |
+
if "run python" in t:
|
| 71 |
+
idx = t.find("run python")
|
| 72 |
+
if idx != -1:
|
| 73 |
+
# everything from "python" onwards becomes the command
|
| 74 |
+
fragment = text[idx:].replace("run ", "").strip()
|
| 75 |
+
command = fragment
|
| 76 |
+
|
| 77 |
+
cpu = "500m"
|
| 78 |
+
memory = "512Mi"
|
| 79 |
+
if "gpu" in t:
|
| 80 |
+
cpu = "2000m"
|
| 81 |
+
memory = "8Gi"
|
| 82 |
+
|
| 83 |
+
return JobSpecRequest(
|
| 84 |
+
prompt=text,
|
| 85 |
+
name=name,
|
| 86 |
+
image=image,
|
| 87 |
+
command=command,
|
| 88 |
+
cpu=cpu,
|
| 89 |
+
memory=memory,
|
| 90 |
+
namespace=namespace,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
def job_request_to_yaml(self, req: JobSpecRequest) -> str:
|
| 94 |
+
cmd = ["/bin/sh", "-c", req.command] if req.command else None
|
| 95 |
+
|
| 96 |
+
lines = [
|
| 97 |
+
"apiVersion: batch/v1",
|
| 98 |
+
"kind: Job",
|
| 99 |
+
"metadata:",
|
| 100 |
+
f" name: {req.name}",
|
| 101 |
+
f" namespace: {req.namespace}",
|
| 102 |
+
"spec:",
|
| 103 |
+
" template:",
|
| 104 |
+
" metadata:",
|
| 105 |
+
" labels:",
|
| 106 |
+
" app: agentctl",
|
| 107 |
+
" spec:",
|
| 108 |
+
" restartPolicy: Never",
|
| 109 |
+
" containers:",
|
| 110 |
+
" - name: main",
|
| 111 |
+
f" image: {req.image}",
|
| 112 |
+
]
|
| 113 |
+
if cmd:
|
| 114 |
+
lines.append(" command:")
|
| 115 |
+
for c in cmd:
|
| 116 |
+
lines.append(f" - {c}")
|
| 117 |
+
lines.extend(
|
| 118 |
+
[
|
| 119 |
+
" resources:",
|
| 120 |
+
" requests:",
|
| 121 |
+
f" cpu: {req.cpu}",
|
| 122 |
+
f" memory: {req.memory}",
|
| 123 |
+
" limits:",
|
| 124 |
+
f" cpu: {req.cpu}",
|
| 125 |
+
f" memory: {req.memory}",
|
| 126 |
+
]
|
| 127 |
+
)
|
| 128 |
+
return "\n".join(lines) + "\n"
|
| 129 |
+
|
| 130 |
+
# --------------------- Deployment template --------------------- #
|
| 131 |
+
|
| 132 |
+
def nl_to_deployment_yaml(self, text: str, namespace: str = "default") -> str:
|
| 133 |
+
t = text.lower()
|
| 134 |
+
name = "agentctl-deployment"
|
| 135 |
+
image = "nginx:1.27-alpine"
|
| 136 |
+
replicas = 1
|
| 137 |
+
|
| 138 |
+
if "nginx" in t:
|
| 139 |
+
name = "nginx-deployment"
|
| 140 |
+
if "python" in t:
|
| 141 |
+
image = "python:3.11-slim"
|
| 142 |
+
if "pytorch" in t:
|
| 143 |
+
image = "pytorch/pytorch:2.3.0-cuda12.1-cudnn9-runtime"
|
| 144 |
+
|
| 145 |
+
m = re.search(r"(\d+)\s+replica", t)
|
| 146 |
+
if m:
|
| 147 |
+
replicas = int(m.group(1))
|
| 148 |
+
|
| 149 |
+
lines = [
|
| 150 |
+
"apiVersion: apps/v1",
|
| 151 |
+
"kind: Deployment",
|
| 152 |
+
"metadata:",
|
| 153 |
+
f" name: {name}",
|
| 154 |
+
f" namespace: {namespace}",
|
| 155 |
+
" labels:",
|
| 156 |
+
" app: agentctl-deployment",
|
| 157 |
+
"spec:",
|
| 158 |
+
f" replicas: {replicas}",
|
| 159 |
+
" selector:",
|
| 160 |
+
" matchLabels:",
|
| 161 |
+
" app: agentctl-deployment",
|
| 162 |
+
" template:",
|
| 163 |
+
" metadata:",
|
| 164 |
+
" labels:",
|
| 165 |
+
" app: agentctl-deployment",
|
| 166 |
+
" spec:",
|
| 167 |
+
" containers:",
|
| 168 |
+
" - name: main",
|
| 169 |
+
f" image: {image}",
|
| 170 |
+
" ports:",
|
| 171 |
+
" - containerPort: 80",
|
| 172 |
+
]
|
| 173 |
+
return "\n".join(lines) + "\n"
|
| 174 |
+
|
| 175 |
+
# --------------------- CronJob template --------------------- #
|
| 176 |
+
|
| 177 |
+
def nl_to_cronjob_yaml(self, text: str, namespace: str = "default") -> str:
|
| 178 |
+
t = text.lower()
|
| 179 |
+
name = "agentctl-cronjob"
|
| 180 |
+
image = "busybox:1.36"
|
| 181 |
+
command = "echo Hello from CronJob && date"
|
| 182 |
+
schedule = "*/5 * * * *" # default every 5 minutes
|
| 183 |
+
|
| 184 |
+
if "every minute" in t:
|
| 185 |
+
schedule = "* * * * *"
|
| 186 |
+
elif "every 5 minutes" in t:
|
| 187 |
+
schedule = "*/5 * * * *"
|
| 188 |
+
elif "every hour" in t:
|
| 189 |
+
schedule = "0 * * * *"
|
| 190 |
+
elif "every day" in t:
|
| 191 |
+
schedule = "0 0 * * *"
|
| 192 |
+
|
| 193 |
+
if "python" in t:
|
| 194 |
+
image = "python:3.11-slim"
|
| 195 |
+
if "run python" in t:
|
| 196 |
+
idx = t.find("run python")
|
| 197 |
+
fragment = text[idx:].replace("run ", "").strip()
|
| 198 |
+
command = fragment
|
| 199 |
+
|
| 200 |
+
lines = [
|
| 201 |
+
"apiVersion: batch/v1",
|
| 202 |
+
"kind: CronJob",
|
| 203 |
+
"metadata:",
|
| 204 |
+
f" name: {name}",
|
| 205 |
+
f" namespace: {namespace}",
|
| 206 |
+
"spec:",
|
| 207 |
+
f" schedule: \"{schedule}\"",
|
| 208 |
+
" jobTemplate:",
|
| 209 |
+
" spec:",
|
| 210 |
+
" template:",
|
| 211 |
+
" metadata:",
|
| 212 |
+
" labels:",
|
| 213 |
+
" app: agentctl-cronjob",
|
| 214 |
+
" spec:",
|
| 215 |
+
" restartPolicy: Never",
|
| 216 |
+
" containers:",
|
| 217 |
+
" - name: main",
|
| 218 |
+
f" image: {image}",
|
| 219 |
+
" command:",
|
| 220 |
+
" - /bin/sh",
|
| 221 |
+
" - -c",
|
| 222 |
+
f" - {command}",
|
| 223 |
+
]
|
| 224 |
+
return "\n".join(lines) + "\n"
|
| 225 |
+
|
| 226 |
+
# --------------------- Optional OpenAI LLM --------------------- #
|
| 227 |
+
|
| 228 |
+
def maybe_llm_yaml(self, text: str, kind: str, namespace: str, fallback_yaml: str) -> str:
|
| 229 |
+
"""If AGENTCTL_USE_LLM=true and OpenAI is configured, ask the LLM
|
| 230 |
+
to generate/clean YAML. Otherwise return fallback_yaml.
|
| 231 |
+
"""
|
| 232 |
+
use_llm = os.getenv("AGENTCTL_USE_LLM", "false").lower() == "true"
|
| 233 |
+
api_key = os.getenv("OPENAI_API_KEY")
|
| 234 |
+
|
| 235 |
+
if not use_llm or not api_key or OpenAI is None:
|
| 236 |
+
return fallback_yaml
|
| 237 |
+
|
| 238 |
+
client = OpenAI(api_key=api_key)
|
| 239 |
+
model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 240 |
+
|
| 241 |
+
system_msg = (
|
| 242 |
+
"You are an expert Kubernetes engineer. "
|
| 243 |
+
"Given a natural language request, output a single valid Kubernetes "
|
| 244 |
+
f"{kind} manifest in YAML. Do not include explanations or code fences."
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
user_msg = (
|
| 248 |
+
f"Namespace: {namespace}\n"
|
| 249 |
+
f"Kind: {kind}\n"
|
| 250 |
+
f"Request: {text}\n\n"
|
| 251 |
+
"Start from this template and improve it if needed:\n"
|
| 252 |
+
f"{fallback_yaml}"
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
resp = client.chat.completions.create(
|
| 256 |
+
model=model,
|
| 257 |
+
messages=[
|
| 258 |
+
{"role": "system", "content": system_msg},
|
| 259 |
+
{"role": "user", "content": user_msg},
|
| 260 |
+
],
|
| 261 |
+
temperature=0.1,
|
| 262 |
+
)
|
| 263 |
+
content = resp.choices[0].message.content or fallback_yaml
|
| 264 |
+
|
| 265 |
+
# Strip ```yaml fences if present
|
| 266 |
+
content = content.strip()
|
| 267 |
+
if content.startswith("```"):
|
| 268 |
+
content = re.sub(r"^```[a-zA-Z]*", "", content).strip()
|
| 269 |
+
if content.endswith("```"):
|
| 270 |
+
content = content[:-3].strip()
|
| 271 |
+
|
| 272 |
+
return content
|
| 273 |
+
|
| 274 |
+
# --------------------- Public entrypoint --------------------- #
|
| 275 |
+
|
| 276 |
+
def nl_to_resource_yaml(
|
| 277 |
+
self,
|
| 278 |
+
text: str,
|
| 279 |
+
namespace: str = "default",
|
| 280 |
+
kind: Optional[str] = None,
|
| 281 |
+
) -> Tuple[str, str]:
|
| 282 |
+
"""Return (kind, yaml) for a given prompt."""
|
| 283 |
+
if not kind or kind.lower() == "auto":
|
| 284 |
+
kind = self.infer_kind(text)
|
| 285 |
+
|
| 286 |
+
if kind == "Job":
|
| 287 |
+
req = self.nl_to_job_request(text, namespace)
|
| 288 |
+
base_yaml = self.job_request_to_yaml(req)
|
| 289 |
+
elif kind == "Deployment":
|
| 290 |
+
base_yaml = self.nl_to_deployment_yaml(text, namespace)
|
| 291 |
+
elif kind == "CronJob":
|
| 292 |
+
base_yaml = self.nl_to_cronjob_yaml(text, namespace)
|
| 293 |
+
else:
|
| 294 |
+
raise ValueError(f"Unsupported kind: {kind}")
|
| 295 |
+
|
| 296 |
+
final_yaml = self.maybe_llm_yaml(text, kind=kind, namespace=namespace, fallback_yaml=base_yaml)
|
| 297 |
+
return kind, final_yaml
|
hf-space/hf-space/agentctl/config.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
|
| 4 |
+
@dataclass
|
| 5 |
+
class Settings:
|
| 6 |
+
k8s_api_base_url: str = os.getenv("K8S_API_BASE_URL", "")
|
| 7 |
+
k8s_namespace: str = os.getenv("K8S_NAMESPACE", "default")
|
| 8 |
+
verify_ssl: bool = os.getenv("K8S_VERIFY_SSL", "false").lower() == "true"
|
| 9 |
+
k8s_bearer_token: str | None = os.getenv("K8S_BEARER_TOKEN") or None
|
| 10 |
+
|
| 11 |
+
settings = Settings()
|
hf-space/hf-space/agentctl/k8s_client.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal Kubernetes HTTP client using the public API exposed via ngrok."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from typing import Dict, Any, List
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
import yaml
|
| 10 |
+
|
| 11 |
+
from .config import settings
|
| 12 |
+
from .schemas import (
|
| 13 |
+
ApplyResult,
|
| 14 |
+
JobStatus,
|
| 15 |
+
PodInfo,
|
| 16 |
+
ClusterSnapshot,
|
| 17 |
+
DeploymentStatus,
|
| 18 |
+
CronJobStatus,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class K8sClient:
|
| 23 |
+
def __init__(self, base_url: str | None = None, namespace: str | None = None):
|
| 24 |
+
if not base_url:
|
| 25 |
+
base_url = settings.k8s_api_base_url
|
| 26 |
+
if not base_url:
|
| 27 |
+
raise ValueError("K8S_API_BASE_URL is not set. Configure it in env.")
|
| 28 |
+
|
| 29 |
+
self.base_url = base_url.rstrip("/")
|
| 30 |
+
self.namespace = namespace or settings.k8s_namespace
|
| 31 |
+
self.verify_ssl = settings.verify_ssl
|
| 32 |
+
self.bearer_token = settings.k8s_bearer_token
|
| 33 |
+
|
| 34 |
+
# ------------------------------------------------------------------ #
|
| 35 |
+
# Internal helpers #
|
| 36 |
+
# ------------------------------------------------------------------ #
|
| 37 |
+
|
| 38 |
+
def _headers(self) -> Dict[str, str]:
|
| 39 |
+
headers = {"Content-Type": "application/json"}
|
| 40 |
+
if self.bearer_token:
|
| 41 |
+
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
| 42 |
+
return headers
|
| 43 |
+
|
| 44 |
+
def _request(
|
| 45 |
+
self,
|
| 46 |
+
method: str,
|
| 47 |
+
path: str,
|
| 48 |
+
json_body: Dict[str, Any] | None = None,
|
| 49 |
+
) -> requests.Response:
|
| 50 |
+
url = f"{self.base_url}{path}"
|
| 51 |
+
resp = requests.request(
|
| 52 |
+
method=method,
|
| 53 |
+
url=url,
|
| 54 |
+
headers=self._headers(),
|
| 55 |
+
json=json_body,
|
| 56 |
+
verify=self.verify_ssl,
|
| 57 |
+
timeout=30,
|
| 58 |
+
)
|
| 59 |
+
return resp
|
| 60 |
+
|
| 61 |
+
# ------------------------------------------------------------------ #
|
| 62 |
+
# Apply a manifest (Job / Deployment / CronJob) #
|
| 63 |
+
# ------------------------------------------------------------------ #
|
| 64 |
+
|
| 65 |
+
def apply_manifest(self, manifest_yaml: str) -> ApplyResult:
|
| 66 |
+
"""Create a resource (Job / Deployment / CronJob) from YAML."""
|
| 67 |
+
try:
|
| 68 |
+
manifest = yaml.safe_load(manifest_yaml)
|
| 69 |
+
except yaml.YAMLError as e:
|
| 70 |
+
return ApplyResult(success=False, message=f"Invalid YAML: {e}")
|
| 71 |
+
|
| 72 |
+
if not isinstance(manifest, dict):
|
| 73 |
+
return ApplyResult(success=False, message="Manifest must be a single YAML object.")
|
| 74 |
+
|
| 75 |
+
kind = manifest.get("kind")
|
| 76 |
+
metadata = manifest.get("metadata", {})
|
| 77 |
+
name = metadata.get("name", "<unnamed>")
|
| 78 |
+
namespace = metadata.get("namespace", self.namespace)
|
| 79 |
+
|
| 80 |
+
if kind == "Job":
|
| 81 |
+
path = f"/apis/batch/v1/namespaces/{namespace}/jobs"
|
| 82 |
+
elif kind == "Deployment":
|
| 83 |
+
path = f"/apis/apps/v1/namespaces/{namespace}/deployments"
|
| 84 |
+
elif kind == "CronJob":
|
| 85 |
+
path = f"/apis/batch/v1/namespaces/{namespace}/cronjobs"
|
| 86 |
+
else:
|
| 87 |
+
return ApplyResult(success=False, message=f"Unsupported kind: {kind}")
|
| 88 |
+
|
| 89 |
+
resp = self._request("POST", path, json_body=manifest)
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
raw = resp.json()
|
| 93 |
+
except json.JSONDecodeError:
|
| 94 |
+
raw = {"raw_text": resp.text}
|
| 95 |
+
|
| 96 |
+
if resp.status_code in (200, 201):
|
| 97 |
+
return ApplyResult(
|
| 98 |
+
success=True,
|
| 99 |
+
message=f"Created {kind} '{name}' in ns '{namespace}'.",
|
| 100 |
+
raw_response=raw,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
return ApplyResult(
|
| 104 |
+
success=False,
|
| 105 |
+
message=f"K8s API error {resp.status_code}: {raw}",
|
| 106 |
+
raw_response=raw,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# ------------------------------------------------------------------ #
|
| 110 |
+
# List helpers for dashboard #
|
| 111 |
+
# ------------------------------------------------------------------ #
|
| 112 |
+
|
| 113 |
+
def list_jobs(self) -> List[JobStatus]:
|
| 114 |
+
path = f"/apis/batch/v1/namespaces/{self.namespace}/jobs"
|
| 115 |
+
resp = self._request("GET", path)
|
| 116 |
+
data = resp.json()
|
| 117 |
+
items = data.get("items", [])
|
| 118 |
+
jobs: List[JobStatus] = []
|
| 119 |
+
for item in items:
|
| 120 |
+
meta = item.get("metadata", {})
|
| 121 |
+
status = item.get("status", {})
|
| 122 |
+
jobs.append(
|
| 123 |
+
JobStatus(
|
| 124 |
+
name=meta.get("name", "<unknown>"),
|
| 125 |
+
namespace=meta.get("namespace", self.namespace),
|
| 126 |
+
succeeded=status.get("succeeded", 0) or 0,
|
| 127 |
+
failed=status.get("failed", 0) or 0,
|
| 128 |
+
active=status.get("active", 0) or 0,
|
| 129 |
+
)
|
| 130 |
+
)
|
| 131 |
+
return jobs
|
| 132 |
+
|
| 133 |
+
def list_pods(self) -> List[PodInfo]:
|
| 134 |
+
path = f"/api/v1/namespaces/{self.namespace}/pods"
|
| 135 |
+
resp = self._request("GET", path)
|
| 136 |
+
data = resp.json()
|
| 137 |
+
items = data.get("items", [])
|
| 138 |
+
pods: List[PodInfo] = []
|
| 139 |
+
for item in items:
|
| 140 |
+
meta = item.get("metadata", {})
|
| 141 |
+
status = item.get("status", {})
|
| 142 |
+
pods.append(
|
| 143 |
+
PodInfo(
|
| 144 |
+
name=meta.get("name", "<unknown>"),
|
| 145 |
+
phase=status.get("phase", "Unknown"),
|
| 146 |
+
node_name=status.get("nodeName"),
|
| 147 |
+
)
|
| 148 |
+
)
|
| 149 |
+
return pods
|
| 150 |
+
|
| 151 |
+
def list_deployments(self) -> List[DeploymentStatus]:
|
| 152 |
+
path = f"/apis/apps/v1/namespaces/{self.namespace}/deployments"
|
| 153 |
+
resp = self._request("GET", path)
|
| 154 |
+
data = resp.json()
|
| 155 |
+
items = data.get("items", [])
|
| 156 |
+
out: List[DeploymentStatus] = []
|
| 157 |
+
for item in items:
|
| 158 |
+
meta = item.get("metadata", {})
|
| 159 |
+
st = item.get("status", {})
|
| 160 |
+
out.append(
|
| 161 |
+
DeploymentStatus(
|
| 162 |
+
name=meta.get("name", "<unknown>"),
|
| 163 |
+
namespace=meta.get("namespace", self.namespace),
|
| 164 |
+
replicas=st.get("replicas", 0) or 0,
|
| 165 |
+
ready=st.get("readyReplicas", 0) or 0,
|
| 166 |
+
)
|
| 167 |
+
)
|
| 168 |
+
return out
|
| 169 |
+
|
| 170 |
+
def list_cronjobs(self) -> List[CronJobStatus]:
|
| 171 |
+
path = f"/apis/batch/v1/namespaces/{self.namespace}/cronjobs"
|
| 172 |
+
resp = self._request("GET", path)
|
| 173 |
+
data = resp.json()
|
| 174 |
+
items = data.get("items", [])
|
| 175 |
+
out: List[CronJobStatus] = []
|
| 176 |
+
for item in items:
|
| 177 |
+
meta = item.get("metadata", {})
|
| 178 |
+
st = item.get("status", {})
|
| 179 |
+
last = st.get("lastScheduleTime")
|
| 180 |
+
out.append(
|
| 181 |
+
CronJobStatus(
|
| 182 |
+
name=meta.get("name", "<unknown>"),
|
| 183 |
+
namespace=meta.get("namespace", self.namespace),
|
| 184 |
+
active=len(st.get("active", []) or []),
|
| 185 |
+
last_schedule=str(last) if last is not None else None,
|
| 186 |
+
)
|
| 187 |
+
)
|
| 188 |
+
return out
|
| 189 |
+
|
| 190 |
+
def get_pod_logs(self, pod_name: str, container: str | None = None, tail_lines: int = 100) -> str:
|
| 191 |
+
params = {"tailLines": str(tail_lines)}
|
| 192 |
+
if container:
|
| 193 |
+
params["container"] = container
|
| 194 |
+
|
| 195 |
+
path = f"/api/v1/namespaces/{self.namespace}/pods/{pod_name}/log"
|
| 196 |
+
url = f"{self.base_url}{path}"
|
| 197 |
+
resp = requests.get(url, headers=self._headers(), params=params, verify=self.verify_ssl, timeout=30)
|
| 198 |
+
if resp.status_code == 200:
|
| 199 |
+
return resp.text
|
| 200 |
+
return f"Error {resp.status_code}: {resp.text}"
|
| 201 |
+
|
| 202 |
+
def snapshot(self) -> ClusterSnapshot:
|
| 203 |
+
return ClusterSnapshot(
|
| 204 |
+
namespace=self.namespace,
|
| 205 |
+
jobs=self.list_jobs(),
|
| 206 |
+
pods=self.list_pods(),
|
| 207 |
+
deployments=self.list_deployments(),
|
| 208 |
+
cronjobs=self.list_cronjobs(),
|
| 209 |
+
)
|
hf-space/hf-space/agentctl/schemas.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Optional, List, Dict, Any
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ApplyResult(BaseModel):
|
| 6 |
+
success: bool
|
| 7 |
+
message: str
|
| 8 |
+
raw_response: Optional[Dict[str, Any]] = None
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class JobStatus(BaseModel):
|
| 12 |
+
name: str
|
| 13 |
+
namespace: str
|
| 14 |
+
succeeded: int = 0
|
| 15 |
+
failed: int = 0
|
| 16 |
+
active: int = 0
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class PodInfo(BaseModel):
|
| 20 |
+
name: str
|
| 21 |
+
phase: str
|
| 22 |
+
node_name: Optional[str] = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class DeploymentStatus(BaseModel):
|
| 26 |
+
name: str
|
| 27 |
+
namespace: str
|
| 28 |
+
replicas: int = 0
|
| 29 |
+
ready: int = 0
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class CronJobStatus(BaseModel):
|
| 33 |
+
name: str
|
| 34 |
+
namespace: str
|
| 35 |
+
active: int = 0
|
| 36 |
+
last_schedule: Optional[str] = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ClusterSnapshot(BaseModel):
|
| 40 |
+
namespace: str
|
| 41 |
+
jobs: List[JobStatus] = Field(default_factory=list)
|
| 42 |
+
pods: List[PodInfo] = Field(default_factory=list)
|
| 43 |
+
deployments: List[DeploymentStatus] = Field(default_factory=list)
|
| 44 |
+
cronjobs: List[CronJobStatus] = Field(default_factory=list)
|
hf-space/hf-space/app.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
import time
|
| 6 |
+
from typing import Tuple
|
| 7 |
+
|
| 8 |
+
import gradio as gr
|
| 9 |
+
|
| 10 |
+
from agentctl.config import settings
|
| 11 |
+
from agentctl.k8s_client import K8sClient
|
| 12 |
+
from agentctl.agent import K8sAgent
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------
|
| 16 |
+
# Core objects
|
| 17 |
+
# ---------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
agent = K8sAgent()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _get_client() -> K8sClient:
|
| 23 |
+
"""Create a Kubernetes HTTP client using env-based config."""
|
| 24 |
+
return K8sClient()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------------
|
| 28 |
+
# YAML generation + apply
|
| 29 |
+
# ---------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def generate_yaml_from_prompt(prompt: str, namespace: str, kind: str) -> str:
|
| 33 |
+
"""Convert natural language + kind selection into YAML."""
|
| 34 |
+
if not prompt.strip():
|
| 35 |
+
return "# Enter a description, e.g. 'run a python preprocessing job'"
|
| 36 |
+
|
| 37 |
+
ns = namespace.strip() or settings.k8s_namespace
|
| 38 |
+
kind_sel = None if kind == "Auto" else kind
|
| 39 |
+
|
| 40 |
+
_, yaml_text = agent.nl_to_resource_yaml(
|
| 41 |
+
prompt,
|
| 42 |
+
namespace=ns,
|
| 43 |
+
kind=kind_sel,
|
| 44 |
+
)
|
| 45 |
+
return yaml_text
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def apply_yaml_to_cluster(yaml_text: str) -> Tuple[str, str]:
|
| 49 |
+
"""Apply a YAML manifest to the cluster via K8s API."""
|
| 50 |
+
if not yaml_text.strip():
|
| 51 |
+
return "No YAML to apply.", ""
|
| 52 |
+
|
| 53 |
+
client = _get_client()
|
| 54 |
+
result = client.apply_manifest(yaml_text)
|
| 55 |
+
|
| 56 |
+
raw_str = ""
|
| 57 |
+
if result.raw_response is not None:
|
| 58 |
+
try:
|
| 59 |
+
raw_str = str(result.raw_response)
|
| 60 |
+
except Exception:
|
| 61 |
+
raw_str = "<unserialisable response>"
|
| 62 |
+
|
| 63 |
+
prefix = "✅" if result.success else "❌"
|
| 64 |
+
return f"{prefix} {result.message}", raw_str
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ---------------------------------------------------------------------
|
| 68 |
+
# Dashboard snapshot
|
| 69 |
+
# ---------------------------------------------------------------------
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def get_cluster_snapshot() -> str:
|
| 73 |
+
"""Return a human-readable snapshot of Jobs, Pods, Deployments, CronJobs."""
|
| 74 |
+
client = _get_client()
|
| 75 |
+
snap = client.snapshot()
|
| 76 |
+
|
| 77 |
+
lines: list[str] = [f"Namespace: {snap.namespace}", ""]
|
| 78 |
+
|
| 79 |
+
# Jobs
|
| 80 |
+
lines.append("Jobs:")
|
| 81 |
+
if not snap.jobs:
|
| 82 |
+
lines.append(" (no jobs)")
|
| 83 |
+
else:
|
| 84 |
+
for j in snap.jobs:
|
| 85 |
+
lines.append(
|
| 86 |
+
f" - {j.name}: succeeded={j.succeeded}, "
|
| 87 |
+
f"failed={j.failed}, active={j.active}"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Pods
|
| 91 |
+
lines.append("")
|
| 92 |
+
lines.append("Pods:")
|
| 93 |
+
if not snap.pods:
|
| 94 |
+
lines.append(" (no pods)")
|
| 95 |
+
else:
|
| 96 |
+
for p in snap.pods:
|
| 97 |
+
lines.append(f" - {p.name}: phase={p.phase}")
|
| 98 |
+
|
| 99 |
+
# Deployments
|
| 100 |
+
lines.append("")
|
| 101 |
+
lines.append("Deployments:")
|
| 102 |
+
if not snap.deployments:
|
| 103 |
+
lines.append(" (no deployments)")
|
| 104 |
+
else:
|
| 105 |
+
for d in snap.deployments:
|
| 106 |
+
lines.append(
|
| 107 |
+
f" - {d.name}: replicas={d.replicas}, ready={d.ready}"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# CronJobs
|
| 111 |
+
lines.append("")
|
| 112 |
+
lines.append("CronJobs:")
|
| 113 |
+
if not snap.cronjobs:
|
| 114 |
+
lines.append(" (no cronjobs)")
|
| 115 |
+
else:
|
| 116 |
+
for c in snap.cronjobs:
|
| 117 |
+
last = c.last_schedule or "never"
|
| 118 |
+
lines.append(
|
| 119 |
+
f" - {c.name}: active={c.active}, lastScheduleTime={last}"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
return "\n".join(lines)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ---------------------------------------------------------------------
|
| 126 |
+
# Logs helpers (colorised terminal + live follow)
|
| 127 |
+
# ---------------------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _fetch_pod_logs_raw(pod_name: str, tail_lines: int) -> str:
|
| 131 |
+
"""Internal: raw log text from Kubernetes."""
|
| 132 |
+
client = _get_client()
|
| 133 |
+
return client.get_pod_logs(pod_name.strip(), tail_lines=tail_lines)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _colorize_logs(text: str) -> str:
|
| 137 |
+
"""Convert log text into HTML with basic severity colouring."""
|
| 138 |
+
if not text:
|
| 139 |
+
return "<span style='color:#888;'>No logs</span>"
|
| 140 |
+
|
| 141 |
+
lines = text.splitlines()
|
| 142 |
+
out: list[str] = []
|
| 143 |
+
|
| 144 |
+
for line in lines:
|
| 145 |
+
if re.search(r"(error|failed|exception)", line, re.IGNORECASE):
|
| 146 |
+
colour = "#ff4b4b" # red
|
| 147 |
+
elif re.search(r"(warn)", line, re.IGNORECASE):
|
| 148 |
+
colour = "#f7c843" # yellow
|
| 149 |
+
elif re.search(r"(info|started|running|completed)", line, re.IGNORECASE):
|
| 150 |
+
colour = "#5ad55a" # green
|
| 151 |
+
else:
|
| 152 |
+
colour = "#d0d0d0" # grey
|
| 153 |
+
|
| 154 |
+
safe = line.replace("<", "<").replace(">", ">")
|
| 155 |
+
out.append(f"<span style='color:{colour};'>{safe}</span>")
|
| 156 |
+
|
| 157 |
+
return "<br>".join(out)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def get_pod_logs_once(pod_name: str, tail_lines: int) -> str:
|
| 161 |
+
"""One-shot logs fetch, used for the 'Get logs' button."""
|
| 162 |
+
pod_name = pod_name.strip()
|
| 163 |
+
if not pod_name:
|
| 164 |
+
return "<span style='color:#ff4b4b;'>Enter a pod name from the dashboard.</span>"
|
| 165 |
+
|
| 166 |
+
raw = _fetch_pod_logs_raw(pod_name, tail_lines)
|
| 167 |
+
return _colorize_logs(raw)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def follow_pod_logs(pod_name: str, tail_lines: int):
|
| 171 |
+
"""Live streaming logs using Gradio generator output."""
|
| 172 |
+
pod_name = pod_name.strip()
|
| 173 |
+
if not pod_name:
|
| 174 |
+
yield "<span style='color:#ff4b4b;'>Enter a pod name from the dashboard.</span>"
|
| 175 |
+
return
|
| 176 |
+
|
| 177 |
+
while True:
|
| 178 |
+
raw = _fetch_pod_logs_raw(pod_name, tail_lines)
|
| 179 |
+
html = _colorize_logs(raw)
|
| 180 |
+
yield html
|
| 181 |
+
time.sleep(2)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# CSS for the logs "terminal"
|
| 185 |
+
TERMINAL_CSS = """
|
| 186 |
+
#logs_terminal {
|
| 187 |
+
background-color: #111111 !important;
|
| 188 |
+
color: #d0d0d0 !important;
|
| 189 |
+
font-family: monospace !important;
|
| 190 |
+
padding: 14px;
|
| 191 |
+
border-radius: 8px;
|
| 192 |
+
border: 1px solid #444444;
|
| 193 |
+
height: 420px;
|
| 194 |
+
overflow-y: scroll;
|
| 195 |
+
white-space: pre-wrap;
|
| 196 |
+
}
|
| 197 |
+
"""
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ---------------------------------------------------------------------
|
| 201 |
+
# Gradio UI
|
| 202 |
+
# ---------------------------------------------------------------------
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def build_app() -> gr.Blocks:
|
| 206 |
+
with gr.Blocks(
|
| 207 |
+
title="AgentCTL: LLM-Driven Kubernetes Automation",
|
| 208 |
+
css=TERMINAL_CSS,
|
| 209 |
+
) as demo:
|
| 210 |
+
# Top environment hint (like your screenshot)
|
| 211 |
+
gr.Markdown(
|
| 212 |
+
"""
|
| 213 |
+
### 🔧 Environment configuration
|
| 214 |
+
|
| 215 |
+
- `K8S_API_BASE_URL` → **public K8s API URL** (ngrok / Cloudflare tunnel over `kubectl proxy`)
|
| 216 |
+
- `K8S_NAMESPACE` → **default namespace** (e.g. `default`)
|
| 217 |
+
- `K8S_VERIFY_SSL` → **true/false** (set `false` for self-signed ngrok endpoints)
|
| 218 |
+
- `AGENTCTL_USE_LLM` → **true/false** (optional; uses OpenAI if enabled in `agent.py`)
|
| 219 |
+
- `OPENAI_API_KEY` → your key (if LLM mode enabled)
|
| 220 |
+
|
| 221 |
+
---
|
| 222 |
+
"""
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
# ============================================================
|
| 226 |
+
# Create Resource tab
|
| 227 |
+
# ============================================================
|
| 228 |
+
with gr.Tab("Create Resource"):
|
| 229 |
+
with gr.Row():
|
| 230 |
+
prompt = gr.Textbox(
|
| 231 |
+
label="Describe the resource you want",
|
| 232 |
+
placeholder=(
|
| 233 |
+
"Examples:\n"
|
| 234 |
+
"- run a python job to preprocess data\n"
|
| 235 |
+
"- create an nginx deployment with 3 replicas\n"
|
| 236 |
+
"- schedule a python cleanup job every 5 minutes\n"
|
| 237 |
+
),
|
| 238 |
+
lines=5,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
with gr.Column():
|
| 242 |
+
namespace = gr.Textbox(
|
| 243 |
+
label="Kubernetes namespace",
|
| 244 |
+
value=settings.k8s_namespace,
|
| 245 |
+
lines=1,
|
| 246 |
+
)
|
| 247 |
+
kind = gr.Dropdown(
|
| 248 |
+
label="Resource kind",
|
| 249 |
+
choices=["Auto", "Job", "Deployment", "CronJob"],
|
| 250 |
+
value="Auto",
|
| 251 |
+
)
|
| 252 |
+
gr.Markdown(
|
| 253 |
+
"Tip: leave **Auto** to let the agent infer Job vs Deployment vs CronJob."
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
generate_btn = gr.Button("Generate YAML", variant="primary")
|
| 257 |
+
yaml_box = gr.Code(
|
| 258 |
+
label="Generated manifest (YAML)",
|
| 259 |
+
language="yaml",
|
| 260 |
+
lines=22,
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
apply_btn = gr.Button("Apply to cluster")
|
| 264 |
+
apply_msg = gr.Textbox(label="Status", interactive=False)
|
| 265 |
+
apply_raw = gr.Textbox(label="Raw API response (debug)")
|
| 266 |
+
|
| 267 |
+
generate_btn.click(
|
| 268 |
+
fn=generate_yaml_from_prompt,
|
| 269 |
+
inputs=[prompt, namespace, kind],
|
| 270 |
+
outputs=yaml_box,
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
apply_btn.click(
|
| 274 |
+
fn=apply_yaml_to_cluster,
|
| 275 |
+
inputs=[yaml_box],
|
| 276 |
+
outputs=[apply_msg, apply_raw],
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
# ============================================================
|
| 280 |
+
# Cluster Dashboard tab
|
| 281 |
+
# ============================================================
|
| 282 |
+
with gr.Tab("Cluster Dashboard"):
|
| 283 |
+
gr.Markdown(
|
| 284 |
+
"Snapshot of **Jobs, Pods, Deployments, CronJobs** in the target namespace."
|
| 285 |
+
)
|
| 286 |
+
snapshot_btn = gr.Button("Refresh snapshot", variant="primary")
|
| 287 |
+
snapshot_box = gr.Textbox(
|
| 288 |
+
label="Cluster overview",
|
| 289 |
+
lines=25,
|
| 290 |
+
interactive=False,
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
snapshot_btn.click(
|
| 294 |
+
fn=get_cluster_snapshot,
|
| 295 |
+
inputs=[],
|
| 296 |
+
outputs=[snapshot_box],
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
# ============================================================
|
| 300 |
+
# Pod Logs tab
|
| 301 |
+
# ============================================================
|
| 302 |
+
with gr.Tab("Pod Logs"):
|
| 303 |
+
gr.Markdown(
|
| 304 |
+
"Paste a pod name from the **Cluster Dashboard** (e.g. `agentctl-job-lnrcp`) "
|
| 305 |
+
"and fetch its logs. Use **Follow logs** for a live `kubectl logs -f` style view."
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
pod_name = gr.Textbox(
|
| 309 |
+
label="Pod name",
|
| 310 |
+
placeholder="e.g. preprocess-job-z48rz",
|
| 311 |
+
)
|
| 312 |
+
tail_lines = gr.Slider(
|
| 313 |
+
label="Tail lines",
|
| 314 |
+
minimum=10,
|
| 315 |
+
maximum=500,
|
| 316 |
+
step=10,
|
| 317 |
+
value=100,
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
with gr.Row():
|
| 321 |
+
get_logs_btn = gr.Button("Get logs once")
|
| 322 |
+
follow_logs_btn = gr.Button("Follow logs (live)", variant="primary")
|
| 323 |
+
|
| 324 |
+
logs_box = gr.HTML(label="Logs", elem_id="logs_terminal")
|
| 325 |
+
|
| 326 |
+
get_logs_btn.click(
|
| 327 |
+
fn=get_pod_logs_once,
|
| 328 |
+
inputs=[pod_name, tail_lines],
|
| 329 |
+
outputs=[logs_box],
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
follow_logs_btn.click(
|
| 333 |
+
fn=follow_pod_logs,
|
| 334 |
+
inputs=[pod_name, tail_lines],
|
| 335 |
+
outputs=logs_box,
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
return demo
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
app = build_app()
|
| 342 |
+
|
| 343 |
+
if __name__ == "__main__":
|
| 344 |
+
port = int(os.getenv("PORT", "7860"))
|
| 345 |
+
app.launch(server_name="0.0.0.0", server_port=port)
|
hf-space/hf-space/requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.0.0
|
| 2 |
+
requests>=2.31.0
|
| 3 |
+
PyYAML>=6.0
|
| 4 |
+
pydantic>=2.7.0
|
| 5 |
+
python-dotenv>=1.0.0
|
| 6 |
+
openai>=1.0.0 # for LLM-based YAML generation
|
hf-space/k8s/deployment-example.yaml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apiVersion: apps/v1
|
| 2 |
+
kind: Deployment
|
| 3 |
+
metadata:
|
| 4 |
+
name: example-nginx-deployment
|
| 5 |
+
namespace: default
|
| 6 |
+
labels:
|
| 7 |
+
app: agentctl-nginx
|
| 8 |
+
spec:
|
| 9 |
+
replicas: 1
|
| 10 |
+
selector:
|
| 11 |
+
matchLabels:
|
| 12 |
+
app: agentctl-nginx
|
| 13 |
+
template:
|
| 14 |
+
metadata:
|
| 15 |
+
labels:
|
| 16 |
+
app: agentctl-nginx
|
| 17 |
+
spec:
|
| 18 |
+
containers:
|
| 19 |
+
- name: nginx
|
| 20 |
+
image: nginx:1.27-alpine
|
| 21 |
+
ports:
|
| 22 |
+
- containerPort: 80
|
hf-space/k8s/job-example.yaml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apiVersion: batch/v1
|
| 2 |
+
kind: Job
|
| 3 |
+
metadata:
|
| 4 |
+
name: example-echo-job
|
| 5 |
+
namespace: default
|
| 6 |
+
spec:
|
| 7 |
+
template:
|
| 8 |
+
metadata:
|
| 9 |
+
labels:
|
| 10 |
+
app: agentctl-example
|
| 11 |
+
spec:
|
| 12 |
+
restartPolicy: Never
|
| 13 |
+
containers:
|
| 14 |
+
- name: main
|
| 15 |
+
image: busybox:1.36
|
| 16 |
+
command:
|
| 17 |
+
- /bin/sh
|
| 18 |
+
- -c
|
| 19 |
+
- |
|
| 20 |
+
echo "Hello from example job";
|
| 21 |
+
sleep 10;
|