GitHub Actions commited on
Commit
267fc17
·
1 Parent(s): f346c47

Sync from GitHub on master (Wed Nov 19 12:57:50 UTC 2025)

Browse files
Files changed (34) hide show
  1. app.py +100 -189
  2. backend/main.py +11 -9
  3. backend/routers/apply.py +67 -16
  4. backend/routers/logs.py +15 -6
  5. backend/routers/snapshot.py +45 -10
  6. backend/routers/yaml_gen.py +24 -8
  7. hf-space/backend/__init__.py +0 -0
  8. hf-space/backend/main.py +30 -0
  9. hf-space/backend/routers/__init__.py +0 -0
  10. hf-space/backend/routers/apply.py +37 -0
  11. hf-space/backend/routers/logs.py +11 -0
  12. hf-space/backend/routers/snapshot.py +19 -0
  13. hf-space/backend/routers/yaml_gen.py +23 -0
  14. hf-space/backend/services/__init__.py +0 -0
  15. hf-space/backend/services/agent_service.py +29 -0
  16. hf-space/backend/services/k8s_service.py +28 -0
  17. hf-space/hf-space/app.py +1 -8
  18. hf-space/hf-space/hf-space/hf-space/app.py +211 -101
  19. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/.github/workflows/sync-to-hf.yml +39 -0
  20. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/README.md +216 -7
  21. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/app.py +49 -183
  22. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/.gitignore +97 -0
  23. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/README.md +16 -0
  24. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/agentctl/__init__.py +1 -0
  25. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/agentctl/agent.py +297 -0
  26. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/agentctl/config.py +11 -0
  27. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/agentctl/k8s_client.py +209 -0
  28. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/agentctl/schemas.py +44 -0
  29. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/app.py +345 -0
  30. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/requirements.txt +6 -0
  31. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/k8s/deployment-example.yaml +22 -0
  32. hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/k8s/job-example.yaml +21 -0
  33. hf-space/hf-space/hf-space/hf-space/hf-space/requirements.txt +7 -6
  34. hf-space/requirements.txt +2 -0
app.py CHANGED
@@ -1,156 +1,42 @@
1
  #!/usr/bin/env python3
2
 
3
  import os
4
- import re
5
  import time
6
- import uuid
7
- import yaml
8
- from typing import Tuple
9
-
10
  import gradio as gr
11
 
12
- from agentctl.config import settings
13
- from agentctl.k8s_client import K8sClient
14
- from agentctl.agent import K8sAgent
15
-
16
-
17
- # ---------------------------------------------------------------------
18
- # Core - Agent + Client
19
- # ---------------------------------------------------------------------
20
-
21
- agent = K8sAgent()
22
-
23
- def _get_client() -> K8sClient:
24
- return K8sClient()
25
-
26
-
27
- # ---------------------------------------------------------------------
28
- # Unique Name Injection
29
- # ---------------------------------------------------------------------
30
-
31
- def _inject_unique_name(yaml_text: str) -> str:
32
- """
33
- Add unique suffix to metadata.name for Job, Deployment, CronJob.
34
- Prevents 409 AlreadyExists errors.
35
- """
36
- try:
37
- data = yaml.safe_load(yaml_text)
38
- if not isinstance(data, dict):
39
- return yaml_text
40
-
41
- kind = data.get("kind")
42
- if kind not in {"Job", "CronJob", "Deployment"}:
43
- return yaml_text
44
-
45
- base = data["metadata"]["name"]
46
- suffix = str(uuid.uuid4())[:5]
47
- new_name = f"{base}-{suffix}"
48
- data["metadata"]["name"] = new_name
49
-
50
- # Patch CronJob template labels
51
- if kind == "CronJob":
52
- jt = data["spec"]["jobTemplate"]["spec"]["template"]
53
- if "metadata" in jt:
54
- if "labels" in jt["metadata"]:
55
- jt["metadata"]["labels"]["app"] = new_name
56
- else:
57
- jt["metadata"]["labels"] = {"app": new_name}
58
-
59
- return yaml.safe_dump(data)
60
-
61
- except Exception:
62
- return yaml_text
63
-
64
-
65
- # ---------------------------------------------------------------------
66
- # YAML Generation + Apply
67
- # ---------------------------------------------------------------------
68
-
69
- def generate_yaml_from_prompt(prompt: str, namespace: str, kind: str) -> str:
70
- if not prompt.strip():
71
- return "# Enter a description, e.g. 'run a python preprocessing job'"
72
-
73
- ns = namespace.strip() or settings.k8s_namespace
74
- kind_sel = None if kind == "Auto" else kind
75
-
76
- _, yaml_text = agent.nl_to_resource_yaml(prompt, namespace=ns, kind=kind_sel)
77
- return yaml_text
78
-
79
-
80
- def apply_yaml_to_cluster(yaml_text: str) -> Tuple[str, str]:
81
- if not yaml_text.strip():
82
- return "No YAML to apply.", ""
83
-
84
- yaml_text = _inject_unique_name(yaml_text)
85
-
86
- client = _get_client()
87
- result = client.apply_manifest(yaml_text)
88
-
89
- try:
90
- raw = str(result.raw_response)
91
- except:
92
- raw = "<unserialisable>"
93
-
94
- prefix = "✅" if result.success else "❌"
95
- return f"{prefix} {result.message}", raw
96
-
97
 
98
  # ---------------------------------------------------------------------
99
- # Cluster Snapshot
100
  # ---------------------------------------------------------------------
 
101
 
102
- def get_cluster_snapshot() -> str:
103
- client = _get_client()
104
- snap = client.snapshot()
105
 
106
- out = [f"Namespace: {snap.namespace}", ""]
 
 
 
 
 
107
 
108
- out.append("Jobs:")
109
- if not snap.jobs:
110
- out.append(" (no jobs)")
111
- else:
112
- for j in snap.jobs:
113
- out.append(
114
- f" - {j.name}: succeeded={j.succeeded}, failed={j.failed}, active={j.active}"
115
- )
116
 
117
- out.append("")
118
- out.append("Pods:")
119
- if not snap.pods:
120
- out.append(" (no pods)")
121
- else:
122
- for p in snap.pods:
123
- out.append(f" - {p.name}: phase={p.phase}")
124
-
125
- out.append("")
126
- out.append("Deployments:")
127
- if not snap.deployments:
128
- out.append(" (no deployments)")
129
- else:
130
- for d in snap.deployments:
131
- out.append(f" - {d.name}: replicas={d.replicas}, ready={d.ready}")
132
-
133
- out.append("")
134
- out.append("CronJobs:")
135
- if not snap.cronjobs:
136
- out.append(" (no cronjobs)")
137
- else:
138
- for c in snap.cronjobs:
139
- last = c.last_schedule or "never"
140
- out.append(f" - {c.name}: active={c.active}, lastScheduleTime={last}")
141
-
142
- return "\n".join(out)
143
 
144
 
145
  # ---------------------------------------------------------------------
146
- # Logs System
147
  # ---------------------------------------------------------------------
148
-
149
  def _colorize_logs(text: str) -> str:
150
  if not text:
151
  return "<span style='color:#888;'>No logs</span>"
152
 
153
- html_lines = []
154
  for line in text.splitlines():
155
  if re.search(r"(error|failed|exception)", line, re.IGNORECASE):
156
  color = "#ff4b4b"
@@ -162,38 +48,61 @@ def _colorize_logs(text: str) -> str:
162
  color = "#d0d0d0"
163
 
164
  safe = line.replace("<", "&lt;").replace(">", "&gt;")
165
- html_lines.append(f"<span style='color:{color};'>{safe}</span>")
166
 
167
- return "<br>".join(html_lines)
168
 
169
 
170
- def get_pod_logs_once(pod_name: str, tail_lines: int) -> str:
171
- pod_name = pod_name.strip()
172
- if not pod_name:
173
- return "<span style='color:#ff4b4b;'>Enter a pod name.</span>"
 
 
 
 
 
 
 
 
174
 
175
- client = _get_client()
176
- raw = client.get_pod_logs(pod_name, tail_lines)
177
- return _colorize_logs(raw)
178
 
 
 
 
 
179
 
180
- def follow_pod_logs(pod_name: str, tail_lines: int):
181
- pod_name = pod_name.strip()
182
- if not pod_name:
183
- yield "<span style='color:#ff4b4b;'>Enter a pod name.</span>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  return
185
 
186
- client = _get_client()
187
  while True:
188
- raw = client.get_pod_logs(pod_name, tail_lines)
189
- yield _colorize_logs(raw)
 
190
  time.sleep(2)
191
 
192
 
193
  # ---------------------------------------------------------------------
194
- # Logs CSS
195
  # ---------------------------------------------------------------------
196
-
197
  TERMINAL_CSS = """
198
  #logs_terminal {
199
  background-color: #111;
@@ -210,41 +119,43 @@ TERMINAL_CSS = """
210
 
211
 
212
  # ---------------------------------------------------------------------
213
- # UI
214
  # ---------------------------------------------------------------------
215
 
216
  def build_app() -> gr.Blocks:
217
  with gr.Blocks(
218
  title="AgentCTL: LLM-Driven Kubernetes Automation",
219
- css=TERMINAL_CSS,
220
  ) as demo:
221
 
222
- gr.Markdown("""
223
  ### 🔧 Environment
224
- - `K8S_API_BASE_URL`
 
 
 
225
  - `K8S_NAMESPACE`
226
- - `K8S_VERIFY_SSL`
227
- - `OPENAI_API_KEY` (optional)
228
  ---
229
  """)
230
 
231
- # Create Resource
232
  with gr.Tab("Create Resource"):
233
  with gr.Row():
234
  prompt = gr.Textbox(
235
- label="Describe the resource",
 
236
  placeholder=(
237
  "- run a python job\n"
238
- "- create an nginx deployment with 3 replicas\n"
239
- "- schedule a cleanup script every 5 minutes\n"
240
  ),
241
- lines=5,
242
  )
243
 
244
  with gr.Column():
245
  namespace = gr.Textbox(
246
  label="Namespace",
247
- value=settings.k8s_namespace,
248
  )
249
  kind = gr.Dropdown(
250
  label="Resource kind",
@@ -252,56 +163,56 @@ def build_app() -> gr.Blocks:
252
  value="Auto",
253
  )
254
 
255
- generate_btn = gr.Button("Generate YAML", variant="primary")
256
  yaml_box = gr.Code(language="yaml", lines=22, label="Generated YAML")
257
 
258
  apply_btn = gr.Button("Apply to cluster")
259
  apply_msg = gr.Textbox(label="Status")
260
- apply_debug = gr.Textbox(label="Raw API Response")
261
 
262
- generate_btn.click(
263
- generate_yaml_from_prompt,
264
  inputs=[prompt, namespace, kind],
265
- outputs=yaml_box,
266
  )
 
267
  apply_btn.click(
268
- apply_yaml_to_cluster,
269
  inputs=[yaml_box],
270
- outputs=[apply_msg, apply_debug],
271
  )
272
 
273
- # Dashboard
274
  with gr.Tab("Cluster Dashboard"):
275
- snapshot_btn = gr.Button("Refresh snapshot", variant="primary")
276
- snapshot_box = gr.Textbox(lines=25, label="Cluster Overview")
277
 
278
- snapshot_btn.click(
279
- get_cluster_snapshot,
280
  inputs=[],
281
- outputs=snapshot_box,
282
  )
283
 
284
- # Logs
285
  with gr.Tab("Pod Logs"):
286
- pod_name = gr.Textbox(label="Pod name")
287
- tail_lines = gr.Slider(10, 500, step=10, value=100, label="Tail lines")
288
 
289
- get_logs_btn = gr.Button("Get logs once")
290
- follow_logs_btn = gr.Button("Follow logs (live)", variant="primary")
291
 
292
  logs_box = gr.HTML(elem_id="logs_terminal", label="Logs")
293
 
294
- get_logs_btn.click(
295
- get_pod_logs_once,
296
- inputs=[pod_name, tail_lines],
297
- outputs=logs_box,
298
  )
299
 
300
- # FIXED: removed `stream=True`
301
- follow_logs_btn.click(
302
- follow_pod_logs,
303
- inputs=[pod_name, tail_lines],
304
- outputs=logs_box,
305
  )
306
 
307
  return demo
 
1
  #!/usr/bin/env python3
2
 
3
  import os
 
4
  import time
5
+ import re
6
+ import requests
 
 
7
  import gradio as gr
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  # ---------------------------------------------------------------------
11
+ # Backend URL (FastAPI)
12
  # ---------------------------------------------------------------------
13
+ BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:9000")
14
 
 
 
 
15
 
16
+ def _post(path: str, payload: dict):
17
+ """POST helper"""
18
+ url = f"{BACKEND_URL}{path}"
19
+ resp = requests.post(url, json=payload)
20
+ resp.raise_for_status()
21
+ return resp.json()
22
 
 
 
 
 
 
 
 
 
23
 
24
+ def _get(path: str):
25
+ """GET helper"""
26
+ url = f"{BACKEND_URL}{path}"
27
+ resp = requests.get(url)
28
+ resp.raise_for_status()
29
+ return resp.json()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
 
32
  # ---------------------------------------------------------------------
33
+ # Colorize Logs (same as before)
34
  # ---------------------------------------------------------------------
 
35
  def _colorize_logs(text: str) -> str:
36
  if not text:
37
  return "<span style='color:#888;'>No logs</span>"
38
 
39
+ html = []
40
  for line in text.splitlines():
41
  if re.search(r"(error|failed|exception)", line, re.IGNORECASE):
42
  color = "#ff4b4b"
 
48
  color = "#d0d0d0"
49
 
50
  safe = line.replace("<", "&lt;").replace(">", "&gt;")
51
+ html.append(f"<span style='color:{color};'>{safe}</span>")
52
 
53
+ return "<br>".join(html)
54
 
55
 
56
+ # ---------------------------------------------------------------------
57
+ # UI Connected Functions (call backend)
58
+ # ---------------------------------------------------------------------
59
+
60
+ def generate_yaml(prompt: str, namespace: str, kind: str) -> str:
61
+ payload = {
62
+ "prompt": prompt,
63
+ "namespace": namespace,
64
+ "kind": kind
65
+ }
66
+ result = _post("/api/generate-yaml", payload)
67
+ return result["yaml"]
68
 
 
 
 
69
 
70
+ def apply_yaml(yaml_text: str):
71
+ payload = {"yaml": yaml_text}
72
+ result = _post("/api/apply", payload)
73
+ return result["status"], result["raw"]
74
 
75
+
76
+ def get_snapshot() -> str:
77
+ result = _get("/api/snapshot")
78
+ return result["snapshot"]
79
+
80
+
81
+ def get_logs_once(pod_name: str, tail: int) -> str:
82
+ if not pod_name.strip():
83
+ return "<span style='color:#ff4b4b;'>Enter a pod name</span>"
84
+
85
+ result = _get(f"/api/logs?pod={pod_name}&tail={tail}")
86
+ logs = result.get("logs", "")
87
+ return _colorize_logs(logs)
88
+
89
+
90
+ def follow_logs(pod_name: str, tail: int):
91
+ """Stream logs from backend."""
92
+ if not pod_name.strip():
93
+ yield "<span style='color:#ff4b4b;'>Enter a pod name</span>"
94
  return
95
 
 
96
  while True:
97
+ result = _get(f"/api/logs?pod={pod_name}&tail={tail}")
98
+ logs = result.get("logs", "")
99
+ yield _colorize_logs(logs)
100
  time.sleep(2)
101
 
102
 
103
  # ---------------------------------------------------------------------
104
+ # CSS (same as before)
105
  # ---------------------------------------------------------------------
 
106
  TERMINAL_CSS = """
107
  #logs_terminal {
108
  background-color: #111;
 
119
 
120
 
121
  # ---------------------------------------------------------------------
122
+ # UI (identical to original UI)
123
  # ---------------------------------------------------------------------
124
 
125
  def build_app() -> gr.Blocks:
126
  with gr.Blocks(
127
  title="AgentCTL: LLM-Driven Kubernetes Automation",
128
+ css=TERMINAL_CSS
129
  ) as demo:
130
 
131
+ gr.Markdown(f"""
132
  ### 🔧 Environment
133
+
134
+ Connected to backend: **{BACKEND_URL}**
135
+
136
+ - `K8S_API_BASE_URL` (backend)
137
  - `K8S_NAMESPACE`
138
+ - K8s proxy + FastAPI → ngrok → HuggingFace
 
139
  ---
140
  """)
141
 
142
+ # CREATE RESOURCE TAB
143
  with gr.Tab("Create Resource"):
144
  with gr.Row():
145
  prompt = gr.Textbox(
146
+ label="Describe resource",
147
+ lines=5,
148
  placeholder=(
149
  "- run a python job\n"
150
+ "- 3-replica nginx deployment\n"
151
+ "- cronjob every 5 minutes"
152
  ),
 
153
  )
154
 
155
  with gr.Column():
156
  namespace = gr.Textbox(
157
  label="Namespace",
158
+ value="default"
159
  )
160
  kind = gr.Dropdown(
161
  label="Resource kind",
 
163
  value="Auto",
164
  )
165
 
166
+ gen_btn = gr.Button("Generate YAML", variant="primary")
167
  yaml_box = gr.Code(language="yaml", lines=22, label="Generated YAML")
168
 
169
  apply_btn = gr.Button("Apply to cluster")
170
  apply_msg = gr.Textbox(label="Status")
171
+ apply_raw = gr.Textbox(label="API Raw Response")
172
 
173
+ gen_btn.click(
174
+ generate_yaml,
175
  inputs=[prompt, namespace, kind],
176
+ outputs=yaml_box
177
  )
178
+
179
  apply_btn.click(
180
+ apply_yaml,
181
  inputs=[yaml_box],
182
+ outputs=[apply_msg, apply_raw]
183
  )
184
 
185
+ # DASHBOARD
186
  with gr.Tab("Cluster Dashboard"):
187
+ snap_btn = gr.Button("Refresh snapshot", variant="primary")
188
+ snap_box = gr.Textbox(lines=25, label="Cluster Overview")
189
 
190
+ snap_btn.click(
191
+ get_snapshot,
192
  inputs=[],
193
+ outputs=snap_box
194
  )
195
 
196
+ # LOGS TAB
197
  with gr.Tab("Pod Logs"):
198
+ pod_name = gr.Textbox(label="Pod Name")
199
+ tail = gr.Slider(10, 500, value=100, step=10, label="Tail lines")
200
 
201
+ logs_btn = gr.Button("Get logs once")
202
+ follow_btn = gr.Button("Follow logs (live)", variant="primary")
203
 
204
  logs_box = gr.HTML(elem_id="logs_terminal", label="Logs")
205
 
206
+ logs_btn.click(
207
+ get_logs_once,
208
+ inputs=[pod_name, tail],
209
+ outputs=logs_box
210
  )
211
 
212
+ follow_btn.click(
213
+ follow_logs,
214
+ inputs=[pod_name, tail],
215
+ outputs=logs_box
 
216
  )
217
 
218
  return demo
backend/main.py CHANGED
@@ -9,22 +9,24 @@ app = FastAPI(
9
  description="Backend API for Kubernetes AgentCTL automation",
10
  )
11
 
12
- # CORS (UI → API)
13
  app.add_middleware(
14
  CORSMiddleware,
15
- allow_origins=["*"], # tighten later if needed
16
  allow_credentials=True,
17
  allow_methods=["*"],
18
  allow_headers=["*"],
19
  )
20
 
21
- # Routers
22
- app.include_router(yaml_gen.router)
23
- app.include_router(apply.router)
24
- app.include_router(snapshot.router)
25
- app.include_router(logs.router)
26
 
27
-
28
- @app.get("/")
29
  def root():
 
30
  return {"status": "AgentCTL Backend Running"}
 
 
 
 
 
 
 
 
9
  description="Backend API for Kubernetes AgentCTL automation",
10
  )
11
 
12
+ # CORS (for Gradio UI, HF Space, other tools)
13
  app.add_middleware(
14
  CORSMiddleware,
15
+ allow_origins=["*"], # tighten later for prod
16
  allow_credentials=True,
17
  allow_methods=["*"],
18
  allow_headers=["*"],
19
  )
20
 
 
 
 
 
 
21
 
22
+ @app.get("/", tags=["Status"])
 
23
  def root():
24
+ """Simple health endpoint."""
25
  return {"status": "AgentCTL Backend Running"}
26
+
27
+
28
+ # Attach routers under /api/*
29
+ app.include_router(yaml_gen.router, prefix="/api")
30
+ app.include_router(apply.router, prefix="/api")
31
+ app.include_router(snapshot.router, prefix="/api")
32
+ app.include_router(logs.router, prefix="/api")
backend/routers/apply.py CHANGED
@@ -1,37 +1,88 @@
 
 
1
  from fastapi import APIRouter
2
  from pydantic import BaseModel
3
- import uuid, yaml
 
4
 
5
  from agentctl.k8s_client import K8sClient
6
 
7
- router = APIRouter(prefix="/api", tags=["Apply"])
8
 
9
- client = K8sClient()
10
 
11
  class ApplyRequest(BaseModel):
12
  yaml: str
13
 
 
14
  class ApplyResponse(BaseModel):
15
  success: bool
16
  message: str
17
- raw: str
 
18
 
19
- def inject_unique(yaml_text: str) -> str:
 
 
 
 
20
  try:
21
- obj = yaml.safe_load(yaml_text)
22
- kind = obj.get("kind")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- if kind in ["Job", "CronJob", "Deployment"]:
25
- suffix = str(uuid.uuid4())[:5]
26
- obj["metadata"]["name"] += f"-{suffix}"
 
 
 
 
 
 
 
 
 
 
 
27
 
28
- return yaml.safe_dump(obj)
29
- except:
 
30
  return yaml_text
31
 
 
32
  @router.post("/apply", response_model=ApplyResponse)
33
- def apply_resource(req: ApplyRequest):
34
- final_yaml = inject_unique(req.yaml)
 
 
 
 
 
 
35
  result = client.apply_manifest(final_yaml)
36
- raw = str(result.raw_response)
37
- return ApplyResponse(success=result.success, message=result.message, raw=raw)
 
 
 
 
 
 
 
 
 
 
1
+ # backend/routers/apply.py
2
+
3
  from fastapi import APIRouter
4
  from pydantic import BaseModel
5
+ import uuid
6
+ import yaml
7
 
8
  from agentctl.k8s_client import K8sClient
9
 
10
+ router = APIRouter(tags=["Apply Manifest"])
11
 
 
12
 
13
  class ApplyRequest(BaseModel):
14
  yaml: str
15
 
16
+
17
  class ApplyResponse(BaseModel):
18
  success: bool
19
  message: str
20
+ raw: str | None = None
21
+
22
 
23
+ def _inject_unique_name(yaml_text: str) -> str:
24
+ """
25
+ Add a short UUID suffix to metadata.name for Job/CronJob/Deployment
26
+ to avoid 409 AlreadyExists errors when users re-apply the same YAML.
27
+ """
28
  try:
29
+ data = yaml.safe_load(yaml_text)
30
+ if not isinstance(data, dict):
31
+ return yaml_text
32
+
33
+ kind = data.get("kind")
34
+ if kind not in {"Job", "CronJob", "Deployment"}:
35
+ return yaml_text
36
+
37
+ meta = data.get("metadata") or {}
38
+ base_name = meta.get("name")
39
+ if not base_name:
40
+ return yaml_text
41
+
42
+ suffix = str(uuid.uuid4())[:5]
43
+ new_name = f"{base_name}-{suffix}"
44
+ meta["name"] = new_name
45
+ data["metadata"] = meta
46
 
47
+ # For CronJob make sure template labels are consistent
48
+ if kind == "CronJob":
49
+ tmpl = (
50
+ data.get("spec", {})
51
+ .get("jobTemplate", {})
52
+ .get("spec", {})
53
+ .get("template", {})
54
+ )
55
+ if isinstance(tmpl, dict):
56
+ md = tmpl.get("metadata") or {}
57
+ labels = md.get("labels") or {}
58
+ labels["app"] = new_name
59
+ md["labels"] = labels
60
+ tmpl["metadata"] = md
61
 
62
+ return yaml.safe_dump(data)
63
+ except Exception:
64
+ # on failure, just return original yaml
65
  return yaml_text
66
 
67
+
68
  @router.post("/apply", response_model=ApplyResponse)
69
+ def apply_manifest(req: ApplyRequest):
70
+ """
71
+ Apply a YAML manifest to the Kubernetes cluster via K8sClient.
72
+ """
73
+ # Lazy K8sClient instantiation so missing env vars don't crash import
74
+ client = K8sClient()
75
+
76
+ final_yaml = _inject_unique_name(req.yaml)
77
  result = client.apply_manifest(final_yaml)
78
+
79
+ try:
80
+ raw_str = str(result.raw_response)
81
+ except Exception:
82
+ raw_str = None
83
+
84
+ return ApplyResponse(
85
+ success=result.success,
86
+ message=result.message,
87
+ raw=raw_str,
88
+ )
backend/routers/logs.py CHANGED
@@ -1,11 +1,20 @@
1
- from fastapi import APIRouter
 
 
 
2
  from agentctl.k8s_client import K8sClient
3
 
4
- router = APIRouter(prefix="/api", tags=["Logs"])
5
 
6
- client = K8sClient()
7
 
8
  @router.get("/logs")
9
- def get_logs(pod_name: str, tail: int = 100):
10
- logs = client.get_pod_logs(pod_name, tail)
11
- return {"pod": pod_name, "tail": tail, "logs": logs}
 
 
 
 
 
 
 
 
1
+ # backend/routers/logs.py
2
+
3
+ from fastapi import APIRouter, Query
4
+
5
  from agentctl.k8s_client import K8sClient
6
 
7
+ router = APIRouter(tags=["Logs"])
8
 
 
9
 
10
  @router.get("/logs")
11
+ def get_logs(
12
+ pod_name: str = Query(..., description="Pod name from snapshot"),
13
+ tail: int = Query(100, ge=1, le=1000, description="Tail lines"),
14
+ ):
15
+ """
16
+ Return raw logs for a given pod (no HTML, just plain text).
17
+ """
18
+ client = K8sClient()
19
+ text = client.get_pod_logs(pod_name.strip(), tail_lines=tail)
20
+ return {"pod_name": pod_name, "tail": tail, "logs": text}
backend/routers/snapshot.py CHANGED
@@ -1,19 +1,54 @@
 
 
1
  from fastapi import APIRouter
 
2
  from agentctl.k8s_client import K8sClient
3
 
4
- router = APIRouter(prefix="/api", tags=["Snapshot"])
5
 
6
- client = K8sClient()
7
 
8
  @router.get("/snapshot")
9
- def snapshot():
 
 
 
 
10
  snap = client.snapshot()
11
 
12
- data = {
13
- "namespace": snap.namespace,
14
- "jobs": [vars(j) for j in snap.jobs],
15
- "pods": [vars(p) for p in snap.pods],
16
- "deployments": [vars(d) for d in snap.deployments],
17
- "cronjobs": [vars(c) for c in snap.cronjobs],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  }
19
- return data
 
1
+ # backend/routers/snapshot.py
2
+
3
  from fastapi import APIRouter
4
+
5
  from agentctl.k8s_client import K8sClient
6
 
7
+ router = APIRouter(tags=["Cluster Snapshot"])
8
 
 
9
 
10
  @router.get("/snapshot")
11
+ def get_snapshot():
12
+ """
13
+ Return a JSON snapshot of Jobs, Pods, Deployments, CronJobs in the namespace.
14
+ """
15
+ client = K8sClient()
16
  snap = client.snapshot()
17
 
18
+ def job_to_dict(j):
19
+ return {
20
+ "name": getattr(j, "name", None),
21
+ "succeeded": getattr(j, "succeeded", None),
22
+ "failed": getattr(j, "failed", None),
23
+ "active": getattr(j, "active", None),
24
+ }
25
+
26
+ def pod_to_dict(p):
27
+ return {
28
+ "name": getattr(p, "name", None),
29
+ "phase": getattr(p, "phase", None),
30
+ }
31
+
32
+ def deploy_to_dict(d):
33
+ return {
34
+ "name": getattr(d, "name", None),
35
+ "replicas": getattr(d, "replicas", None),
36
+ "ready": getattr(d, "ready", None),
37
+ }
38
+
39
+ def cron_to_dict(c):
40
+ return {
41
+ "name": getattr(c, "name", None),
42
+ "active": getattr(c, "active", None),
43
+ # your schema likely calls this last_schedule or lastScheduleTime
44
+ "last_schedule": getattr(c, "last_schedule", None)
45
+ or getattr(c, "lastScheduleTime", None),
46
+ }
47
+
48
+ return {
49
+ "namespace": getattr(snap, "namespace", None),
50
+ "jobs": [job_to_dict(j) for j in snap.jobs],
51
+ "pods": [pod_to_dict(p) for p in snap.pods],
52
+ "deployments": [deploy_to_dict(d) for d in snap.deployments],
53
+ "cronjobs": [cron_to_dict(c) for c in snap.cronjobs],
54
  }
 
backend/routers/yaml_gen.py CHANGED
@@ -1,23 +1,39 @@
 
 
1
  from fastapi import APIRouter
2
  from pydantic import BaseModel
 
3
  from agentctl.agent import K8sAgent
4
  from agentctl.config import settings
5
 
6
- router = APIRouter(prefix="/api", tags=["YAML Generation"])
7
 
8
  agent = K8sAgent()
9
 
10
- class GenerateRequest(BaseModel):
 
11
  prompt: str
12
  namespace: str | None = None
13
  kind: str | None = "Auto"
14
 
15
- class GenerateResponse(BaseModel):
 
16
  yaml: str
17
 
18
- @router.post("/generate-yaml", response_model=GenerateResponse)
19
- def generate_yaml(req: GenerateRequest):
 
 
 
 
20
  ns = req.namespace or settings.k8s_namespace
21
- kind = None if req.kind == "Auto" else req.kind
22
- _, yaml_text = agent.nl_to_resource_yaml(req.prompt, ns, kind)
23
- return GenerateResponse(yaml=yaml_text)
 
 
 
 
 
 
 
 
1
+ # backend/routers/yaml_gen.py
2
+
3
  from fastapi import APIRouter
4
  from pydantic import BaseModel
5
+
6
  from agentctl.agent import K8sAgent
7
  from agentctl.config import settings
8
 
9
+ router = APIRouter(tags=["YAML Generation"])
10
 
11
  agent = K8sAgent()
12
 
13
+
14
+ class GenerateYAMLRequest(BaseModel):
15
  prompt: str
16
  namespace: str | None = None
17
  kind: str | None = "Auto"
18
 
19
+
20
+ class GenerateYAMLResponse(BaseModel):
21
  yaml: str
22
 
23
+
24
+ @router.post("/generate-yaml", response_model=GenerateYAMLResponse)
25
+ def generate_yaml(req: GenerateYAMLRequest):
26
+ """
27
+ Natural language → Kubernetes YAML using your K8sAgent.
28
+ """
29
  ns = req.namespace or settings.k8s_namespace
30
+ kind_sel = None if (req.kind is None or req.kind == "Auto") else req.kind
31
+
32
+ # your existing agent logic returns (explanation, yaml_text)
33
+ _, yaml_text = agent.nl_to_resource_yaml(
34
+ req.prompt,
35
+ namespace=ns,
36
+ kind=kind_sel,
37
+ )
38
+
39
+ return GenerateYAMLResponse(yaml=yaml_text)
hf-space/backend/__init__.py ADDED
File without changes
hf-space/backend/main.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+
4
+ from backend.routers import yaml_gen, apply, snapshot, logs
5
+
6
+ app = FastAPI(
7
+ title="AgentCTL Backend API",
8
+ version="1.0.0",
9
+ description="Backend API for Kubernetes AgentCTL automation",
10
+ )
11
+
12
+ # CORS (UI → API)
13
+ app.add_middleware(
14
+ CORSMiddleware,
15
+ allow_origins=["*"], # tighten later if needed
16
+ allow_credentials=True,
17
+ allow_methods=["*"],
18
+ allow_headers=["*"],
19
+ )
20
+
21
+ # Routers
22
+ app.include_router(yaml_gen.router)
23
+ app.include_router(apply.router)
24
+ app.include_router(snapshot.router)
25
+ app.include_router(logs.router)
26
+
27
+
28
+ @app.get("/")
29
+ def root():
30
+ return {"status": "AgentCTL Backend Running"}
hf-space/backend/routers/__init__.py ADDED
File without changes
hf-space/backend/routers/apply.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from pydantic import BaseModel
3
+ import uuid, yaml
4
+
5
+ from agentctl.k8s_client import K8sClient
6
+
7
+ router = APIRouter(prefix="/api", tags=["Apply"])
8
+
9
+ client = K8sClient()
10
+
11
+ class ApplyRequest(BaseModel):
12
+ yaml: str
13
+
14
+ class ApplyResponse(BaseModel):
15
+ success: bool
16
+ message: str
17
+ raw: str
18
+
19
+ def inject_unique(yaml_text: str) -> str:
20
+ try:
21
+ obj = yaml.safe_load(yaml_text)
22
+ kind = obj.get("kind")
23
+
24
+ if kind in ["Job", "CronJob", "Deployment"]:
25
+ suffix = str(uuid.uuid4())[:5]
26
+ obj["metadata"]["name"] += f"-{suffix}"
27
+
28
+ return yaml.safe_dump(obj)
29
+ except:
30
+ return yaml_text
31
+
32
+ @router.post("/apply", response_model=ApplyResponse)
33
+ def apply_resource(req: ApplyRequest):
34
+ final_yaml = inject_unique(req.yaml)
35
+ result = client.apply_manifest(final_yaml)
36
+ raw = str(result.raw_response)
37
+ return ApplyResponse(success=result.success, message=result.message, raw=raw)
hf-space/backend/routers/logs.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from agentctl.k8s_client import K8sClient
3
+
4
+ router = APIRouter(prefix="/api", tags=["Logs"])
5
+
6
+ client = K8sClient()
7
+
8
+ @router.get("/logs")
9
+ def get_logs(pod_name: str, tail: int = 100):
10
+ logs = client.get_pod_logs(pod_name, tail)
11
+ return {"pod": pod_name, "tail": tail, "logs": logs}
hf-space/backend/routers/snapshot.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from agentctl.k8s_client import K8sClient
3
+
4
+ router = APIRouter(prefix="/api", tags=["Snapshot"])
5
+
6
+ client = K8sClient()
7
+
8
+ @router.get("/snapshot")
9
+ def snapshot():
10
+ snap = client.snapshot()
11
+
12
+ data = {
13
+ "namespace": snap.namespace,
14
+ "jobs": [vars(j) for j in snap.jobs],
15
+ "pods": [vars(p) for p in snap.pods],
16
+ "deployments": [vars(d) for d in snap.deployments],
17
+ "cronjobs": [vars(c) for c in snap.cronjobs],
18
+ }
19
+ return data
hf-space/backend/routers/yaml_gen.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from pydantic import BaseModel
3
+ from agentctl.agent import K8sAgent
4
+ from agentctl.config import settings
5
+
6
+ router = APIRouter(prefix="/api", tags=["YAML Generation"])
7
+
8
+ agent = K8sAgent()
9
+
10
+ class GenerateRequest(BaseModel):
11
+ prompt: str
12
+ namespace: str | None = None
13
+ kind: str | None = "Auto"
14
+
15
+ class GenerateResponse(BaseModel):
16
+ yaml: str
17
+
18
+ @router.post("/generate-yaml", response_model=GenerateResponse)
19
+ def generate_yaml(req: GenerateRequest):
20
+ ns = req.namespace or settings.k8s_namespace
21
+ kind = None if req.kind == "Auto" else req.kind
22
+ _, yaml_text = agent.nl_to_resource_yaml(req.prompt, ns, kind)
23
+ return GenerateResponse(yaml=yaml_text)
hf-space/backend/services/__init__.py ADDED
File without changes
hf-space/backend/services/agent_service.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agentctl.agent import K8sAgent
2
+ from agentctl.config import settings
3
+
4
+
5
+ class AgentService:
6
+ """
7
+ Wrapper for the LLM → YAML agent.
8
+ """
9
+
10
+ def __init__(self):
11
+ self.agent = K8sAgent()
12
+
13
+ def generate_yaml(self, prompt: str, namespace: str | None, kind: str | None):
14
+ # fallback to default namespace from config
15
+ ns = namespace or settings.k8s_namespace
16
+ kind_sel = None if (kind is None or kind == "Auto") else kind
17
+
18
+ # returns: (explanation, yaml_text)
19
+ _, yaml_text = self.agent.nl_to_resource_yaml(
20
+ prompt,
21
+ namespace=ns,
22
+ kind=kind_sel,
23
+ )
24
+
25
+ return yaml_text
26
+
27
+
28
+ # Shared singleton
29
+ agent_service = AgentService()
hf-space/backend/services/k8s_service.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agentctl.k8s_client import K8sClient
2
+
3
+
4
+ class K8sService:
5
+ """
6
+ Simple wrapper around your existing K8sClient.
7
+ This allows FastAPI routes to call Kubernetes operations
8
+ without importing K8sClient directly in every router.
9
+ """
10
+
11
+ def __init__(self):
12
+ self.client = K8sClient()
13
+
14
+ # ---- Apply YAML ----
15
+ def apply_manifest(self, yaml_text: str):
16
+ return self.client.apply_manifest(yaml_text)
17
+
18
+ # ---- Snapshot ----
19
+ def snapshot(self):
20
+ return self.client.snapshot()
21
+
22
+ # ---- Logs ----
23
+ def get_pod_logs(self, pod_name: str, tail_lines: int = 100):
24
+ return self.client.get_pod_logs(pod_name, tail_lines=tail_lines)
25
+
26
+
27
+ # Shared singleton instance
28
+ k8s_service = K8sService()
hf-space/hf-space/app.py CHANGED
@@ -81,7 +81,6 @@ def apply_yaml_to_cluster(yaml_text: str) -> Tuple[str, str]:
81
  if not yaml_text.strip():
82
  return "No YAML to apply.", ""
83
 
84
- # Add unique suffix before applying
85
  yaml_text = _inject_unique_name(yaml_text)
86
 
87
  client = _get_client()
@@ -229,9 +228,7 @@ def build_app() -> gr.Blocks:
229
  ---
230
  """)
231
 
232
- # -----------------------------------------------------------------
233
  # Create Resource
234
- # -----------------------------------------------------------------
235
  with gr.Tab("Create Resource"):
236
  with gr.Row():
237
  prompt = gr.Textbox(
@@ -273,9 +270,7 @@ def build_app() -> gr.Blocks:
273
  outputs=[apply_msg, apply_debug],
274
  )
275
 
276
- # -----------------------------------------------------------------
277
  # Dashboard
278
- # -----------------------------------------------------------------
279
  with gr.Tab("Cluster Dashboard"):
280
  snapshot_btn = gr.Button("Refresh snapshot", variant="primary")
281
  snapshot_box = gr.Textbox(lines=25, label="Cluster Overview")
@@ -286,9 +281,7 @@ def build_app() -> gr.Blocks:
286
  outputs=snapshot_box,
287
  )
288
 
289
- # -----------------------------------------------------------------
290
  # Logs
291
- # -----------------------------------------------------------------
292
  with gr.Tab("Pod Logs"):
293
  pod_name = gr.Textbox(label="Pod name")
294
  tail_lines = gr.Slider(10, 500, step=10, value=100, label="Tail lines")
@@ -304,11 +297,11 @@ def build_app() -> gr.Blocks:
304
  outputs=logs_box,
305
  )
306
 
 
307
  follow_logs_btn.click(
308
  follow_pod_logs,
309
  inputs=[pod_name, tail_lines],
310
  outputs=logs_box,
311
- stream=True,
312
  )
313
 
314
  return demo
 
81
  if not yaml_text.strip():
82
  return "No YAML to apply.", ""
83
 
 
84
  yaml_text = _inject_unique_name(yaml_text)
85
 
86
  client = _get_client()
 
228
  ---
229
  """)
230
 
 
231
  # Create Resource
 
232
  with gr.Tab("Create Resource"):
233
  with gr.Row():
234
  prompt = gr.Textbox(
 
270
  outputs=[apply_msg, apply_debug],
271
  )
272
 
 
273
  # Dashboard
 
274
  with gr.Tab("Cluster Dashboard"):
275
  snapshot_btn = gr.Button("Refresh snapshot", variant="primary")
276
  snapshot_box = gr.Textbox(lines=25, label="Cluster Overview")
 
281
  outputs=snapshot_box,
282
  )
283
 
 
284
  # Logs
 
285
  with gr.Tab("Pod Logs"):
286
  pod_name = gr.Textbox(label="Pod name")
287
  tail_lines = gr.Slider(10, 500, step=10, value=100, label="Tail lines")
 
297
  outputs=logs_box,
298
  )
299
 
300
+ # FIXED: removed `stream=True`
301
  follow_logs_btn.click(
302
  follow_pod_logs,
303
  inputs=[pod_name, tail_lines],
304
  outputs=logs_box,
 
305
  )
306
 
307
  return demo
hf-space/hf-space/hf-space/hf-space/app.py CHANGED
@@ -1,6 +1,10 @@
1
  #!/usr/bin/env python3
2
 
3
  import os
 
 
 
 
4
  from typing import Tuple
5
 
6
  import gradio as gr
@@ -10,26 +14,66 @@ from agentctl.k8s_client import K8sClient
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
 
@@ -37,118 +81,173 @@ 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
 
53
- lines: list[str] = [f"Namespace: {snap.namespace}", ""]
54
 
55
- # Jobs
56
- lines.append("Jobs:")
57
  if not snap.jobs:
58
- lines.append(" (no jobs)")
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
66
- lines.append("")
67
- lines.append("Pods:")
68
  if not snap.pods:
69
- lines.append(" (no pods)")
70
  else:
71
  for p in snap.pods:
72
- lines.append(f" - {p.name}: phase={p.phase}")
73
 
74
- # Deployments
75
- lines.append("")
76
- lines.append("Deployments:")
77
  if not snap.deployments:
78
- lines.append(" (no deployments)")
79
  else:
80
  for d in snap.deployments:
81
- lines.append(
82
- f" - {d.name}: replicas={d.replicas}, ready={d.ready}"
83
- )
84
 
85
- # CronJobs
86
- lines.append("")
87
- lines.append("CronJobs:")
88
  if not snap.cronjobs:
89
- lines.append(" (no cronjobs)")
90
  else:
91
  for c in snap.cronjobs:
92
  last = c.last_schedule or "never"
93
- lines.append(
94
- f" - {c.name}: active={c.active}, lastScheduleTime={last}"
95
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
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(
138
- label="Describe the resource you want",
139
  placeholder=(
140
- "Examples:\n"
141
- "- run a python job to preprocess data\n"
142
  "- create an nginx deployment with 3 replicas\n"
143
- "- schedule a python cleanup job every 5 minutes\n"
144
  ),
145
  lines=5,
146
  )
 
147
  with gr.Column():
148
  namespace = gr.Textbox(
149
- label="Kubernetes namespace",
150
  value=settings.k8s_namespace,
151
- lines=1,
152
  )
153
  kind = gr.Dropdown(
154
  label="Resource kind",
@@ -156,50 +255,60 @@ def build_app() -> gr.Blocks:
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(
173
- fn=apply_yaml_to_cluster,
174
  inputs=[yaml_box],
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,
192
- maximum=500,
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
@@ -208,4 +317,5 @@ def build_app() -> gr.Blocks:
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)))
 
 
1
  #!/usr/bin/env python3
2
 
3
  import os
4
+ import re
5
+ import time
6
+ import uuid
7
+ import yaml
8
  from typing import Tuple
9
 
10
  import gradio as gr
 
14
  from agentctl.agent import K8sAgent
15
 
16
 
17
+ # ---------------------------------------------------------------------
18
+ # Core - Agent + Client
19
+ # ---------------------------------------------------------------------
20
 
21
+ agent = K8sAgent()
22
 
23
  def _get_client() -> K8sClient:
24
  return K8sClient()
25
 
26
 
27
+ # ---------------------------------------------------------------------
28
+ # Unique Name Injection
29
+ # ---------------------------------------------------------------------
30
+
31
+ def _inject_unique_name(yaml_text: str) -> str:
32
+ """
33
+ Add unique suffix to metadata.name for Job, Deployment, CronJob.
34
+ Prevents 409 AlreadyExists errors.
35
+ """
36
+ try:
37
+ data = yaml.safe_load(yaml_text)
38
+ if not isinstance(data, dict):
39
+ return yaml_text
40
+
41
+ kind = data.get("kind")
42
+ if kind not in {"Job", "CronJob", "Deployment"}:
43
+ return yaml_text
44
+
45
+ base = data["metadata"]["name"]
46
+ suffix = str(uuid.uuid4())[:5]
47
+ new_name = f"{base}-{suffix}"
48
+ data["metadata"]["name"] = new_name
49
+
50
+ # Patch CronJob template labels
51
+ if kind == "CronJob":
52
+ jt = data["spec"]["jobTemplate"]["spec"]["template"]
53
+ if "metadata" in jt:
54
+ if "labels" in jt["metadata"]:
55
+ jt["metadata"]["labels"]["app"] = new_name
56
+ else:
57
+ jt["metadata"]["labels"] = {"app": new_name}
58
+
59
+ return yaml.safe_dump(data)
60
+
61
+ except Exception:
62
+ return yaml_text
63
 
64
 
65
+ # ---------------------------------------------------------------------
66
+ # YAML Generation + Apply
67
+ # ---------------------------------------------------------------------
68
+
69
  def generate_yaml_from_prompt(prompt: str, namespace: str, kind: str) -> str:
70
  if not prompt.strip():
71
  return "# Enter a description, e.g. 'run a python preprocessing job'"
72
+
73
+ ns = namespace.strip() or settings.k8s_namespace
74
+ kind_sel = None if kind == "Auto" else kind
75
+
76
+ _, yaml_text = agent.nl_to_resource_yaml(prompt, namespace=ns, kind=kind_sel)
77
  return yaml_text
78
 
79
 
 
81
  if not yaml_text.strip():
82
  return "No YAML to apply.", ""
83
 
84
+ # Add unique suffix before applying
85
+ yaml_text = _inject_unique_name(yaml_text)
86
+
87
  client = _get_client()
88
  result = client.apply_manifest(yaml_text)
89
 
90
+ try:
91
+ raw = str(result.raw_response)
92
+ except:
93
+ raw = "<unserialisable>"
94
+
95
+ prefix = "✅" if result.success else "❌"
96
+ return f"{prefix} {result.message}", raw
97
 
98
 
99
+ # ---------------------------------------------------------------------
100
+ # Cluster Snapshot
101
+ # ---------------------------------------------------------------------
102
+
103
  def get_cluster_snapshot() -> str:
104
  client = _get_client()
105
  snap = client.snapshot()
106
 
107
+ out = [f"Namespace: {snap.namespace}", ""]
108
 
109
+ out.append("Jobs:")
 
110
  if not snap.jobs:
111
+ out.append(" (no jobs)")
112
  else:
113
  for j in snap.jobs:
114
+ out.append(
115
  f" - {j.name}: succeeded={j.succeeded}, failed={j.failed}, active={j.active}"
116
  )
117
 
118
+ out.append("")
119
+ out.append("Pods:")
 
120
  if not snap.pods:
121
+ out.append(" (no pods)")
122
  else:
123
  for p in snap.pods:
124
+ out.append(f" - {p.name}: phase={p.phase}")
125
 
126
+ out.append("")
127
+ out.append("Deployments:")
 
128
  if not snap.deployments:
129
+ out.append(" (no deployments)")
130
  else:
131
  for d in snap.deployments:
132
+ out.append(f" - {d.name}: replicas={d.replicas}, ready={d.ready}")
 
 
133
 
134
+ out.append("")
135
+ out.append("CronJobs:")
 
136
  if not snap.cronjobs:
137
+ out.append(" (no cronjobs)")
138
  else:
139
  for c in snap.cronjobs:
140
  last = c.last_schedule or "never"
141
+ out.append(f" - {c.name}: active={c.active}, lastScheduleTime={last}")
142
+
143
+ return "\n".join(out)
144
+
145
+
146
+ # ---------------------------------------------------------------------
147
+ # Logs System
148
+ # ---------------------------------------------------------------------
149
+
150
+ def _colorize_logs(text: str) -> str:
151
+ if not text:
152
+ return "<span style='color:#888;'>No logs</span>"
153
+
154
+ html_lines = []
155
+ for line in text.splitlines():
156
+ if re.search(r"(error|failed|exception)", line, re.IGNORECASE):
157
+ color = "#ff4b4b"
158
+ elif re.search(r"warn", line, re.IGNORECASE):
159
+ color = "#f7c843"
160
+ elif re.search(r"(info|started|running|completed)", line, re.IGNORECASE):
161
+ color = "#5ad55a"
162
+ else:
163
+ color = "#d0d0d0"
164
+
165
+ safe = line.replace("<", "&lt;").replace(">", "&gt;")
166
+ html_lines.append(f"<span style='color:{color};'>{safe}</span>")
167
+
168
+ return "<br>".join(html_lines)
169
 
 
170
 
171
+ def get_pod_logs_once(pod_name: str, tail_lines: int) -> str:
172
+ pod_name = pod_name.strip()
173
+ if not pod_name:
174
+ return "<span style='color:#ff4b4b;'>Enter a pod name.</span>"
175
 
 
 
 
176
  client = _get_client()
177
+ raw = client.get_pod_logs(pod_name, tail_lines)
178
+ return _colorize_logs(raw)
179
 
180
 
181
+ def follow_pod_logs(pod_name: str, tail_lines: int):
182
+ pod_name = pod_name.strip()
183
+ if not pod_name:
184
+ yield "<span style='color:#ff4b4b;'>Enter a pod name.</span>"
185
+ return
186
 
187
+ client = _get_client()
188
+ while True:
189
+ raw = client.get_pod_logs(pod_name, tail_lines)
190
+ yield _colorize_logs(raw)
191
+ time.sleep(2)
192
+
193
+
194
+ # ---------------------------------------------------------------------
195
+ # Logs CSS
196
+ # ---------------------------------------------------------------------
197
+
198
+ TERMINAL_CSS = """
199
+ #logs_terminal {
200
+ background-color: #111;
201
+ color: #ddd;
202
+ font-family: monospace;
203
+ padding: 14px;
204
+ border-radius: 8px;
205
+ border: 1px solid #444;
206
+ height: 420px;
207
+ overflow-y: scroll;
208
+ white-space: pre-wrap;
209
+ }
210
+ """
211
+
212
+
213
+ # ---------------------------------------------------------------------
214
+ # UI
215
+ # ---------------------------------------------------------------------
216
 
217
  def build_app() -> gr.Blocks:
218
+ with gr.Blocks(
219
+ title="AgentCTL: LLM-Driven Kubernetes Automation",
220
+ css=TERMINAL_CSS,
221
+ ) as demo:
222
+
223
+ gr.Markdown("""
224
+ ### 🔧 Environment
225
+ - `K8S_API_BASE_URL`
226
+ - `K8S_NAMESPACE`
227
+ - `K8S_VERIFY_SSL`
228
+ - `OPENAI_API_KEY` (optional)
229
+ ---
230
+ """)
231
+
232
+ # -----------------------------------------------------------------
233
+ # Create Resource
234
+ # -----------------------------------------------------------------
 
 
 
 
235
  with gr.Tab("Create Resource"):
236
  with gr.Row():
237
  prompt = gr.Textbox(
238
+ label="Describe the resource",
239
  placeholder=(
240
+ "- run a python job\n"
 
241
  "- create an nginx deployment with 3 replicas\n"
242
+ "- schedule a cleanup script every 5 minutes\n"
243
  ),
244
  lines=5,
245
  )
246
+
247
  with gr.Column():
248
  namespace = gr.Textbox(
249
+ label="Namespace",
250
  value=settings.k8s_namespace,
 
251
  )
252
  kind = gr.Dropdown(
253
  label="Resource kind",
 
255
  value="Auto",
256
  )
257
 
258
+ generate_btn = gr.Button("Generate YAML", variant="primary")
259
+ yaml_box = gr.Code(language="yaml", lines=22, label="Generated YAML")
260
 
261
+ apply_btn = gr.Button("Apply to cluster")
262
+ apply_msg = gr.Textbox(label="Status")
263
+ apply_debug = gr.Textbox(label="Raw API Response")
264
 
265
  generate_btn.click(
266
+ generate_yaml_from_prompt,
267
  inputs=[prompt, namespace, kind],
268
+ outputs=yaml_box,
269
  )
 
270
  apply_btn.click(
271
+ apply_yaml_to_cluster,
272
  inputs=[yaml_box],
273
+ outputs=[apply_msg, apply_debug],
274
  )
275
 
276
+ # -----------------------------------------------------------------
277
+ # Dashboard
278
+ # -----------------------------------------------------------------
279
  with gr.Tab("Cluster Dashboard"):
280
+ snapshot_btn = gr.Button("Refresh snapshot", variant="primary")
281
+ snapshot_box = gr.Textbox(lines=25, label="Cluster Overview")
 
282
 
283
+ snapshot_btn.click(
284
+ get_cluster_snapshot,
285
+ inputs=[],
286
+ outputs=snapshot_box,
287
+ )
288
 
289
+ # -----------------------------------------------------------------
290
+ # Logs
291
+ # -----------------------------------------------------------------
292
  with gr.Tab("Pod Logs"):
293
  pod_name = gr.Textbox(label="Pod name")
294
+ tail_lines = gr.Slider(10, 500, step=10, value=100, label="Tail lines")
295
+
296
+ get_logs_btn = gr.Button("Get logs once")
297
+ follow_logs_btn = gr.Button("Follow logs (live)", variant="primary")
298
+
299
+ logs_box = gr.HTML(elem_id="logs_terminal", label="Logs")
300
+
301
+ get_logs_btn.click(
302
+ get_pod_logs_once,
303
+ inputs=[pod_name, tail_lines],
304
+ outputs=logs_box,
305
  )
 
 
306
 
307
+ follow_logs_btn.click(
308
+ follow_pod_logs,
309
  inputs=[pod_name, tail_lines],
310
+ outputs=logs_box,
311
+ stream=True,
312
  )
313
 
314
  return demo
 
317
  app = build_app()
318
 
319
  if __name__ == "__main__":
320
+ port = int(os.getenv("PORT", "7860"))
321
+ app.launch(server_name="0.0.0.0", server_port=port)
hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/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/hf-space/hf-space/hf-space/hf-space/hf-space/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/hf-space/hf-space/hf-space/hf-space/hf-space/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/hf-space/hf-space/hf-space/hf-space/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/hf-space/hf-space/hf-space/hf-space/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/hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/agentctl/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __all__ = ["config", "k8s_client", "agent", "schemas"]
hf-space/hf-space/hf-space/hf-space/hf-space/hf-space/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/hf-space/hf-space/hf-space/hf-space/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/hf-space/hf-space/hf-space/hf-space/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/hf-space/hf-space/hf-space/hf-space/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/hf-space/hf-space/hf-space/hf-space/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/hf-space/hf-space/hf-space/hf-space/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/hf-space/hf-space/hf-space/hf-space/hf-space/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/hf-space/hf-space/hf-space/hf-space/hf-space/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;
hf-space/hf-space/hf-space/hf-space/hf-space/requirements.txt CHANGED
@@ -1,6 +1,7 @@
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
 
 
1
+ gradio==4.26.0
2
+ huggingface_hub==0.19.4
3
+ requests==2.31.0
4
+ PyYAML==6.0
5
+ pydantic==2.7.0
6
+ python-dotenv==1.0.0
7
+ openai==1.0.0
hf-space/requirements.txt CHANGED
@@ -5,3 +5,5 @@ PyYAML==6.0
5
  pydantic==2.7.0
6
  python-dotenv==1.0.0
7
  openai==1.0.0
 
 
 
5
  pydantic==2.7.0
6
  python-dotenv==1.0.0
7
  openai==1.0.0
8
+ fastapi
9
+ uvicorn