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 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 • VFX Pipelines
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: 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.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # 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
 
@@ -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
- # 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("<", "&lt;").replace(">", "&gt;")
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(
@@ -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", 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(
@@ -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
- "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,
@@ -316,24 +193,14 @@ def build_app() -> gr.Blocks:
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
 
@@ -341,5 +208,4 @@ def build_app() -> gr.Blocks:
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)
 
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("<", "&lt;").replace(">", "&gt;")
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;