gax-cli 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. gax/__init__.py +3 -0
  2. gax/adapters/__init__.py +3 -0
  3. gax/adapters/base.py +25 -0
  4. gax/adapters/exec_adapter.py +261 -0
  5. gax/adapters/http_adapter.py +33 -0
  6. gax/adapters/k8s_adapter.py +338 -0
  7. gax/adapters/mcp_bridge.py +108 -0
  8. gax/adapters/mock_adapter.py +49 -0
  9. gax/audit.py +54 -0
  10. gax/caps.py +91 -0
  11. gax/cli.py +722 -0
  12. gax/client.py +58 -0
  13. gax/compliance.py +71 -0
  14. gax/config/oauth_providers.yaml +21 -0
  15. gax/config/policy.rego +20 -0
  16. gax/config/policy.yaml +59 -0
  17. gax/daemon.py +187 -0
  18. gax/envelope.py +64 -0
  19. gax/executor.py +151 -0
  20. gax/macaroons_cap.py +80 -0
  21. gax/manifests/aws.s3.list.yaml +20 -0
  22. gax/manifests/demo.echo.yaml +20 -0
  23. gax/manifests/gh.pr.list.yaml +41 -0
  24. gax/manifests/gh.pr.view.yaml +30 -0
  25. gax/manifests/jira.issue.get.yaml +18 -0
  26. gax/manifests/k8s.deployment.scale.yaml +35 -0
  27. gax/manifests/k8s.pod.delete.yaml +30 -0
  28. gax/manifests/kubectl.get.pods.yaml +20 -0
  29. gax/manifests/mcp.github.list_pulls.yaml +29 -0
  30. gax/manifests/pet_findpetsbystatus.yaml +19 -0
  31. gax/manifests/pet_getpetbyid.yaml +19 -0
  32. gax/mcp_client.py +110 -0
  33. gax/mcp_server.py +299 -0
  34. gax/oauth.py +184 -0
  35. gax/onboarding.py +397 -0
  36. gax/opa_policy.py +46 -0
  37. gax/openapi_gen.py +96 -0
  38. gax/otel_audit.py +50 -0
  39. gax/paths.py +46 -0
  40. gax/plan.py +151 -0
  41. gax/policy.py +33 -0
  42. gax/policy_bundle.py +64 -0
  43. gax/profiles/github/gh.issue.list.yaml +25 -0
  44. gax/profiles/github/gh.issue.view.yaml +23 -0
  45. gax/profiles/github/gh.pr.comment.yaml +30 -0
  46. gax/profiles/github/gh.pr.merge.yaml +29 -0
  47. gax/profiles/github/gh.run.list.yaml +22 -0
  48. gax/profiles/k8s/k8s.deployment.list.yaml +17 -0
  49. gax/profiles/k8s/k8s.deployment.restart.yaml +25 -0
  50. gax/profiles/k8s/k8s.namespace.delete.yaml +22 -0
  51. gax/profiles/k8s/k8s.pod.describe.yaml +22 -0
  52. gax/profiles/k8s/k8s.pod.logs.yaml +28 -0
  53. gax/profiles/k8s/k8s.service.list.yaml +17 -0
  54. gax/profiles.py +143 -0
  55. gax/projection.py +42 -0
  56. gax/registry.py +130 -0
  57. gax/schemas/envelope.v1.json +46 -0
  58. gax/secrets.py +49 -0
  59. gax/side_effects.py +67 -0
  60. gax/spiffe.py +32 -0
  61. gax/vault.py +69 -0
  62. gax_cli-0.4.0.dist-info/METADATA +164 -0
  63. gax_cli-0.4.0.dist-info/RECORD +66 -0
  64. gax_cli-0.4.0.dist-info/WHEEL +5 -0
  65. gax_cli-0.4.0.dist-info/entry_points.txt +4 -0
  66. gax_cli-0.4.0.dist-info/top_level.txt +1 -0
gax/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """GAX — Governed Agent eXecution."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from gax.adapters.base import run_adapter
2
+
3
+ __all__ = ["run_adapter"]
gax/adapters/base.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from gax.adapters import exec_adapter, http_adapter, k8s_adapter, mock_adapter, mcp_bridge
6
+ from gax.registry import CommandManifest
7
+
8
+
9
+ def run_adapter(
10
+ manifest: CommandManifest,
11
+ args: dict[str, Any],
12
+ *,
13
+ tenant_id: str | None = None,
14
+ ) -> dict[str, Any]:
15
+ if manifest.adapter == "k8s":
16
+ return k8s_adapter.run(manifest, args, tenant_id=tenant_id)
17
+ if manifest.adapter == "exec":
18
+ return exec_adapter.run(manifest, args, tenant_id=tenant_id)
19
+ if manifest.adapter == "mock":
20
+ return mock_adapter.run(manifest, args)
21
+ if manifest.adapter == "mcp":
22
+ return mcp_bridge.run(manifest, args, tenant_id=tenant_id)
23
+ if manifest.adapter == "http":
24
+ return http_adapter.run(manifest, args, tenant_id=tenant_id)
25
+ raise RuntimeError(f"unknown adapter: {manifest.adapter}")
@@ -0,0 +1,261 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from typing import Any
9
+
10
+ from gax.adapters import mock_adapter
11
+ from gax.oauth import load_tokens
12
+ from gax.registry import CommandManifest
13
+
14
+
15
+ def _gh_available() -> bool:
16
+ return shutil.which("gh") is not None
17
+
18
+
19
+ def _gh_env(tenant_id: str | None) -> dict[str, str]:
20
+ env = os.environ.copy()
21
+ if tenant_id:
22
+ tokens = load_tokens(tenant_id, "github")
23
+ if tokens and tokens.get("access_token"):
24
+ env["GH_TOKEN"] = tokens["access_token"]
25
+ return env
26
+
27
+
28
+ def run(
29
+ manifest: CommandManifest,
30
+ args: dict[str, Any],
31
+ *,
32
+ tenant_id: str | None = None,
33
+ ) -> dict[str, Any]:
34
+ if not _gh_available():
35
+ return mock_adapter.run(manifest, args)
36
+
37
+ name = _HANDLERS.get(manifest.command)
38
+ if name is None:
39
+ raise RuntimeError(f"exec adapter has no handler for {manifest.command}")
40
+ # Resolve the handler by name at call time rather than capturing the function
41
+ # object in the table. Binding at import makes monkeypatching a handler
42
+ # silently ineffective — the table would keep calling the original.
43
+ handler = globals()[name]
44
+ if manifest.command in _MUTATING:
45
+ return handler(args, tenant_id=tenant_id, dry_run=bool(args.get("dry_run")))
46
+ return handler(args, tenant_id=tenant_id)
47
+
48
+
49
+ def _gh_pr_list(args: dict[str, Any], *, tenant_id: str | None = None) -> dict[str, Any]:
50
+ repo = args["repo"]
51
+ limit = int(args.get("limit", 30))
52
+ state = args.get("state", "open")
53
+ cmd = [
54
+ "gh",
55
+ "pr",
56
+ "list",
57
+ "--repo",
58
+ repo,
59
+ "--limit",
60
+ str(limit),
61
+ "--state",
62
+ state,
63
+ "--json",
64
+ "number,title,state,url,author,isDraft",
65
+ ]
66
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=_gh_env(tenant_id))
67
+ if proc.returncode != 0:
68
+ raise RuntimeError(proc.stderr.strip() or "gh pr list failed")
69
+ raw = json.loads(proc.stdout or "[]")
70
+ items = []
71
+ for row in raw:
72
+ author = row.get("author") or {}
73
+ items.append(
74
+ {
75
+ "number": row["number"],
76
+ "title": row["title"],
77
+ "state": row["state"],
78
+ "url": row["url"],
79
+ "author": author.get("login", "unknown"),
80
+ "draft": row.get("isDraft", False),
81
+ }
82
+ )
83
+ return {"items": items}
84
+
85
+
86
+ def _gh_pr_view(args: dict[str, Any], *, tenant_id: str | None = None) -> dict[str, Any]:
87
+ repo = args["repo"]
88
+ number = int(args["number"])
89
+ cmd = [
90
+ "gh",
91
+ "pr",
92
+ "view",
93
+ str(number),
94
+ "--repo",
95
+ repo,
96
+ "--json",
97
+ "number,title,body,state,url,author",
98
+ ]
99
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=_gh_env(tenant_id))
100
+ if proc.returncode != 0:
101
+ raise RuntimeError(proc.stderr.strip() or "gh pr view failed")
102
+ row = json.loads(proc.stdout or "{}")
103
+ author = row.get("author") or {}
104
+ return {
105
+ "number": row["number"],
106
+ "title": row["title"],
107
+ "body": (row.get("body") or "")[:2000],
108
+ "state": row["state"],
109
+ "url": row["url"],
110
+ "author": author.get("login", "unknown"),
111
+ }
112
+
113
+
114
+ def _repo(args: dict[str, Any]) -> str:
115
+ """Validate owner/name before it reaches the gh subprocess."""
116
+ repo = str(args.get("repo") or "").strip()
117
+ if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo):
118
+ raise ValueError(f"invalid repo {repo!r}; expected owner/name")
119
+ return repo
120
+
121
+
122
+ def _gh(argv: list[str], tenant_id: str | None) -> str:
123
+ proc = subprocess.run(
124
+ ["gh", *argv], capture_output=True, text=True, timeout=60, env=_gh_env(tenant_id)
125
+ )
126
+ if proc.returncode != 0:
127
+ raise RuntimeError(proc.stderr.strip() or f"gh {' '.join(argv)} failed")
128
+ return proc.stdout
129
+
130
+
131
+ def _gh_issue_list(args: dict[str, Any], *, tenant_id: str | None = None) -> dict[str, Any]:
132
+ repo = _repo(args)
133
+ argv = [
134
+ "issue", "list", "--repo", repo,
135
+ "--limit", str(int(args.get("limit", 30))),
136
+ "--state", str(args.get("state", "open")),
137
+ "--json", "number,title,state,url,author",
138
+ ]
139
+ rows = json.loads(_gh(argv, tenant_id) or "[]")
140
+ return {
141
+ "items": [
142
+ {
143
+ "number": r["number"],
144
+ "title": r["title"],
145
+ "state": r["state"],
146
+ "url": r["url"],
147
+ "author": (r.get("author") or {}).get("login", "unknown"),
148
+ }
149
+ for r in rows
150
+ ]
151
+ }
152
+
153
+
154
+ def _gh_issue_view(args: dict[str, Any], *, tenant_id: str | None = None) -> dict[str, Any]:
155
+ repo = _repo(args)
156
+ number = int(args["number"])
157
+ row = json.loads(
158
+ _gh(
159
+ ["issue", "view", str(number), "--repo", repo,
160
+ "--json", "number,title,body,state,url,author"],
161
+ tenant_id,
162
+ )
163
+ or "{}"
164
+ )
165
+ return {
166
+ "number": row.get("number"),
167
+ "title": row.get("title"),
168
+ "body": (row.get("body") or "")[:2000],
169
+ "state": row.get("state"),
170
+ "url": row.get("url"),
171
+ "author": (row.get("author") or {}).get("login", "unknown"),
172
+ }
173
+
174
+
175
+ def _gh_run_list(args: dict[str, Any], *, tenant_id: str | None = None) -> dict[str, Any]:
176
+ repo = _repo(args)
177
+ rows = json.loads(
178
+ _gh(
179
+ ["run", "list", "--repo", repo,
180
+ "--limit", str(int(args.get("limit", 20))),
181
+ "--json", "databaseId,displayTitle,status,conclusion,workflowName"],
182
+ tenant_id,
183
+ )
184
+ or "[]"
185
+ )
186
+ return {
187
+ "items": [
188
+ {
189
+ "id": r.get("databaseId"),
190
+ "title": r.get("displayTitle"),
191
+ "workflow": r.get("workflowName"),
192
+ "status": r.get("status"),
193
+ "conclusion": r.get("conclusion"),
194
+ }
195
+ for r in rows
196
+ ]
197
+ }
198
+
199
+
200
+ def _gh_pr_comment(
201
+ args: dict[str, Any], *, tenant_id: str | None = None, dry_run: bool = False
202
+ ) -> dict[str, Any]:
203
+ repo = _repo(args)
204
+ number = int(args["number"])
205
+ body = str(args.get("body") or "").strip()
206
+ if not body:
207
+ raise ValueError("body is required")
208
+ if dry_run:
209
+ return {"posted": False, "repo": repo, "number": number, "dry_run": True}
210
+ out = _gh(["pr", "comment", str(number), "--repo", repo, "--body", body], tenant_id)
211
+ return {
212
+ "posted": True,
213
+ "repo": repo,
214
+ "number": number,
215
+ "dry_run": False,
216
+ "url": out.strip(),
217
+ }
218
+
219
+
220
+ def _gh_pr_merge(
221
+ args: dict[str, Any], *, tenant_id: str | None = None, dry_run: bool = False
222
+ ) -> dict[str, Any]:
223
+ repo = _repo(args)
224
+ number = int(args["number"])
225
+ method = str(args.get("method") or "squash").lower()
226
+ if method not in {"merge", "squash", "rebase"}:
227
+ raise ValueError(f"invalid merge method {method!r}; expected merge|squash|rebase")
228
+ if dry_run:
229
+ return {
230
+ "merged": False,
231
+ "repo": repo,
232
+ "number": number,
233
+ "method": method,
234
+ "dry_run": True,
235
+ }
236
+ out = _gh(["pr", "merge", str(number), "--repo", repo, f"--{method}"], tenant_id)
237
+ return {
238
+ "merged": True,
239
+ "repo": repo,
240
+ "number": number,
241
+ "method": method,
242
+ "dry_run": False,
243
+ "output": out.strip(),
244
+ }
245
+
246
+
247
+ # Table-driven so a new gh manifest needs one entry, not an if-branch.
248
+ # Values are function *names*, resolved at call time in run() — see the comment
249
+ # there. Mutating commands receive dry_run; read-only ones cannot accept one
250
+ # they would silently ignore.
251
+ _HANDLERS = {
252
+ "gh.pr.list": "_gh_pr_list",
253
+ "gh.pr.view": "_gh_pr_view",
254
+ "gh.issue.list": "_gh_issue_list",
255
+ "gh.issue.view": "_gh_issue_view",
256
+ "gh.run.list": "_gh_run_list",
257
+ "gh.pr.comment": "_gh_pr_comment",
258
+ "gh.pr.merge": "_gh_pr_merge",
259
+ }
260
+
261
+ _MUTATING = {"gh.pr.comment", "gh.pr.merge"}
@@ -0,0 +1,33 @@
1
+ """Generic HTTP adapter for OpenAPI-generated manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from gax.registry import CommandManifest
10
+
11
+
12
+ def run(manifest: CommandManifest, args: dict[str, Any], **_: Any) -> dict[str, Any]:
13
+ http = (manifest.raw or {}).get("http") or {}
14
+ method = str(http.get("method", "GET")).upper()
15
+ url_template = str(http.get("url", ""))
16
+ if not url_template:
17
+ raise RuntimeError(f"http.url missing for {manifest.command}")
18
+
19
+ url = url_template
20
+ path_params = http.get("path_params") or []
21
+ query_params = http.get("query_params") or []
22
+ for p in path_params:
23
+ url = url.replace("{" + p + "}", str(args[p]))
24
+ query = {p: args[p] for p in query_params if p in args}
25
+
26
+ headers = dict(http.get("headers") or {})
27
+ with httpx.Client(timeout=60.0) as client:
28
+ resp = client.request(method, url, params=query, headers=headers)
29
+ resp.raise_for_status()
30
+ try:
31
+ return resp.json()
32
+ except Exception:
33
+ return {"text": resp.text}
@@ -0,0 +1,338 @@
1
+ """
2
+ Kubernetes exec adapter — the first commands in GAX with real blast radius.
3
+
4
+ Until now every registered command was `side_effects: read`, so the destructive
5
+ path through policy had never executed. These commands exist to make that path
6
+ real: `k8s.pod.delete` and `k8s.deployment.scale` can genuinely break a running
7
+ system, which is what makes the enforcement guarantee demonstrable rather than
8
+ architectural.
9
+
10
+ Three safety properties, in order of importance:
11
+
12
+ 1. **`--dry-run` is honored server-side**, not by the caller. A dry run maps to
13
+ `kubectl --dry-run=server` where kubectl supports it, so the API server
14
+ validates without mutating.
15
+ 2. **Args are passed as an argv list**, never through a shell. There is no string
16
+ interpolation into a command line anywhere in this module, so a namespace of
17
+ `; rm -rf /` is an invalid k8s name rather than an injection.
18
+ 3. **Identifiers are validated** against the DNS-1123 subset kubectl accepts,
19
+ before the subprocess is spawned.
20
+
21
+ `kubectl` absent → mock response, so tests and demos run without a cluster.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import re
29
+ import shutil
30
+ import subprocess
31
+ from typing import Any
32
+
33
+ from gax.registry import CommandManifest
34
+
35
+ # DNS-1123: lowercase alphanumerics and '-', must start/end alphanumeric.
36
+ # Deliberately strict — anything outside this is rejected before exec.
37
+ _NAME_RE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
38
+ _MAX_NAME = 253
39
+
40
+ TIMEOUT_S = 60
41
+
42
+
43
+ class K8sArgError(ValueError):
44
+ """Invalid Kubernetes identifier — raised before any subprocess runs."""
45
+
46
+
47
+ def _validate_name(value: Any, field: str) -> str:
48
+ name = str(value or "").strip()
49
+ if not name:
50
+ raise K8sArgError(f"{field} is required")
51
+ if len(name) > _MAX_NAME:
52
+ raise K8sArgError(f"{field} exceeds {_MAX_NAME} characters")
53
+ if not _NAME_RE.match(name):
54
+ raise K8sArgError(
55
+ f"invalid {field}: {name!r} (must match DNS-1123: lowercase "
56
+ "alphanumeric and '-', starting and ending alphanumeric)"
57
+ )
58
+ return name
59
+
60
+
61
+ def _kubectl_available() -> bool:
62
+ """
63
+ Whether to drive real kubectl.
64
+
65
+ `kubectl` being on PATH is not enough — it is commonly installed with no
66
+ reachable cluster (CI, laptops), where every call fails with a connection
67
+ error. `GAX_K8S_MOCK=1` forces the mock path so tests and demos are
68
+ deterministic regardless of local kube context.
69
+ """
70
+ if os.environ.get("GAX_K8S_MOCK") == "1":
71
+ return False
72
+ return shutil.which("kubectl") is not None
73
+
74
+
75
+ def _run_kubectl(argv: list[str]) -> str:
76
+ proc = subprocess.run(
77
+ ["kubectl", *argv],
78
+ capture_output=True,
79
+ text=True,
80
+ timeout=TIMEOUT_S,
81
+ )
82
+ if proc.returncode != 0:
83
+ raise RuntimeError(proc.stderr.strip() or f"kubectl {' '.join(argv)} failed")
84
+ return proc.stdout
85
+
86
+
87
+ def run(
88
+ manifest: CommandManifest,
89
+ args: dict[str, Any],
90
+ *,
91
+ tenant_id: str | None = None,
92
+ ) -> dict[str, Any]:
93
+ dry_run = bool(args.get("dry_run"))
94
+
95
+ name = _HANDLERS.get(manifest.command)
96
+ if name is None:
97
+ raise RuntimeError(f"k8s adapter has no handler for {manifest.command}")
98
+ # Resolved by name at call time so monkeypatching a handler actually takes
99
+ # effect; a table of function objects binds at import and silently ignores it.
100
+ handler = globals()[name]
101
+ if manifest.command in _MUTATING:
102
+ return handler(args, dry_run=dry_run)
103
+ return handler(args)
104
+
105
+
106
+ def _pod_list(args: dict[str, Any]) -> dict[str, Any]:
107
+ namespace = _validate_name(args.get("namespace", "default"), "namespace")
108
+ if not _kubectl_available():
109
+ return {
110
+ "items": [{"name": "mock-pod-1", "namespace": namespace, "status": "Running"}],
111
+ "_mock": True,
112
+ }
113
+ out = _run_kubectl(["get", "pods", "-n", namespace, "-o", "json"])
114
+ payload = json.loads(out or "{}")
115
+ return {
116
+ "items": [
117
+ {
118
+ "name": (i.get("metadata") or {}).get("name"),
119
+ "namespace": namespace,
120
+ "status": (i.get("status") or {}).get("phase"),
121
+ }
122
+ for i in payload.get("items", [])
123
+ ]
124
+ }
125
+
126
+
127
+ def _pod_delete(args: dict[str, Any], *, dry_run: bool) -> dict[str, Any]:
128
+ namespace = _validate_name(args.get("namespace", "default"), "namespace")
129
+ pod = _validate_name(args.get("pod"), "pod")
130
+
131
+ if not _kubectl_available():
132
+ return {
133
+ "deleted": not dry_run,
134
+ "pod": pod,
135
+ "namespace": namespace,
136
+ "dry_run": dry_run,
137
+ "_mock": True,
138
+ }
139
+
140
+ argv = ["delete", "pod", pod, "-n", namespace]
141
+ if dry_run:
142
+ argv.append("--dry-run=server")
143
+ out = _run_kubectl(argv)
144
+ return {
145
+ "deleted": not dry_run,
146
+ "pod": pod,
147
+ "namespace": namespace,
148
+ "dry_run": dry_run,
149
+ "output": out.strip(),
150
+ }
151
+
152
+
153
+ def _resource_list(kind: str, args: dict[str, Any]) -> dict[str, Any]:
154
+ namespace = _validate_name(args.get("namespace", "default"), "namespace")
155
+ if not _kubectl_available():
156
+ return {
157
+ "items": [{"name": f"mock-{kind}-1", "namespace": namespace}],
158
+ "_mock": True,
159
+ }
160
+ payload = json.loads(_run_kubectl(["get", kind, "-n", namespace, "-o", "json"]) or "{}")
161
+ return {
162
+ "items": [
163
+ {
164
+ "name": (i.get("metadata") or {}).get("name"),
165
+ "namespace": namespace,
166
+ }
167
+ for i in payload.get("items", [])
168
+ ]
169
+ }
170
+
171
+
172
+ def _deployment_list(args: dict[str, Any]) -> dict[str, Any]:
173
+ return _resource_list("deployments", args)
174
+
175
+
176
+ def _service_list(args: dict[str, Any]) -> dict[str, Any]:
177
+ return _resource_list("services", args)
178
+
179
+
180
+ def _pod_logs(args: dict[str, Any]) -> dict[str, Any]:
181
+ namespace = _validate_name(args.get("namespace", "default"), "namespace")
182
+ pod = _validate_name(args.get("pod"), "pod")
183
+ tail = int(args.get("tail") or 100)
184
+ if tail < 1:
185
+ raise K8sArgError("tail must be >= 1")
186
+ container = args.get("container")
187
+ if container:
188
+ container = _validate_name(container, "container")
189
+
190
+ if not _kubectl_available():
191
+ return {
192
+ "pod": pod,
193
+ "namespace": namespace,
194
+ "lines": [f"[mock] log line for {pod}"],
195
+ "_mock": True,
196
+ }
197
+ argv = ["logs", pod, "-n", namespace, f"--tail={tail}"]
198
+ if container:
199
+ argv += ["-c", container]
200
+ out = _run_kubectl(argv)
201
+ return {
202
+ "pod": pod,
203
+ "namespace": namespace,
204
+ "lines": out.splitlines(),
205
+ }
206
+
207
+
208
+ def _pod_describe(args: dict[str, Any]) -> dict[str, Any]:
209
+ namespace = _validate_name(args.get("namespace", "default"), "namespace")
210
+ pod = _validate_name(args.get("pod"), "pod")
211
+ if not _kubectl_available():
212
+ return {"pod": pod, "namespace": namespace, "status": "Running", "_mock": True}
213
+ payload = json.loads(_run_kubectl(["get", "pod", pod, "-n", namespace, "-o", "json"]) or "{}")
214
+ status = payload.get("status") or {}
215
+ return {
216
+ "pod": pod,
217
+ "namespace": namespace,
218
+ "status": status.get("phase"),
219
+ "conditions": [
220
+ {"type": c.get("type"), "status": c.get("status")}
221
+ for c in status.get("conditions", [])
222
+ ],
223
+ "containers": [
224
+ {"name": c.get("name"), "ready": c.get("ready"), "restarts": c.get("restartCount")}
225
+ for c in status.get("containerStatuses", [])
226
+ ],
227
+ }
228
+
229
+
230
+ def _deployment_restart(args: dict[str, Any], *, dry_run: bool) -> dict[str, Any]:
231
+ namespace = _validate_name(args.get("namespace", "default"), "namespace")
232
+ deployment = _validate_name(args.get("deployment"), "deployment")
233
+ if not _kubectl_available():
234
+ return {
235
+ "restarted": not dry_run,
236
+ "deployment": deployment,
237
+ "namespace": namespace,
238
+ "dry_run": dry_run,
239
+ "_mock": True,
240
+ }
241
+ argv = ["rollout", "restart", f"deployment/{deployment}", "-n", namespace]
242
+ if dry_run:
243
+ argv.append("--dry-run=server")
244
+ out = _run_kubectl(argv)
245
+ return {
246
+ "restarted": not dry_run,
247
+ "deployment": deployment,
248
+ "namespace": namespace,
249
+ "dry_run": dry_run,
250
+ "output": out.strip(),
251
+ }
252
+
253
+
254
+ def _namespace_delete(args: dict[str, Any], *, dry_run: bool) -> dict[str, Any]:
255
+ namespace = _validate_name(args.get("namespace"), "namespace")
256
+ if not _kubectl_available():
257
+ return {
258
+ "deleted": not dry_run,
259
+ "namespace": namespace,
260
+ "dry_run": dry_run,
261
+ "_mock": True,
262
+ }
263
+ argv = ["delete", "namespace", namespace]
264
+ if dry_run:
265
+ argv.append("--dry-run=server")
266
+ out = _run_kubectl(argv)
267
+ return {
268
+ "deleted": not dry_run,
269
+ "namespace": namespace,
270
+ "dry_run": dry_run,
271
+ "output": out.strip(),
272
+ }
273
+
274
+
275
+ def _deployment_scale(args: dict[str, Any], *, dry_run: bool) -> dict[str, Any]:
276
+ namespace = _validate_name(args.get("namespace", "default"), "namespace")
277
+ deployment = _validate_name(args.get("deployment"), "deployment")
278
+
279
+ raw = args.get("replicas")
280
+ if raw is None:
281
+ raise K8sArgError("replicas is required")
282
+ try:
283
+ replicas = int(raw)
284
+ except (TypeError, ValueError):
285
+ raise K8sArgError(f"replicas must be an integer, got {raw!r}") from None
286
+ if replicas < 0:
287
+ raise K8sArgError("replicas must be >= 0")
288
+
289
+ if not _kubectl_available():
290
+ return {
291
+ "scaled": not dry_run,
292
+ "deployment": deployment,
293
+ "namespace": namespace,
294
+ "replicas": replicas,
295
+ "dry_run": dry_run,
296
+ "_mock": True,
297
+ }
298
+
299
+ argv = [
300
+ "scale",
301
+ f"deployment/{deployment}",
302
+ f"--replicas={replicas}",
303
+ "-n",
304
+ namespace,
305
+ ]
306
+ if dry_run:
307
+ argv.append("--dry-run=server")
308
+ out = _run_kubectl(argv)
309
+ return {
310
+ "scaled": not dry_run,
311
+ "deployment": deployment,
312
+ "namespace": namespace,
313
+ "replicas": replicas,
314
+ "dry_run": dry_run,
315
+ "output": out.strip(),
316
+ }
317
+
318
+
319
+ # Dispatch table. Mutating commands receive `dry_run`; read-only ones do not —
320
+ # so a read command can never silently accept a dry_run it would ignore.
321
+ _HANDLERS = {
322
+ "k8s.pod.list": "_pod_list",
323
+ "k8s.pod.logs": "_pod_logs",
324
+ "k8s.pod.describe": "_pod_describe",
325
+ "k8s.deployment.list": "_deployment_list",
326
+ "k8s.service.list": "_service_list",
327
+ "k8s.pod.delete": "_pod_delete",
328
+ "k8s.deployment.scale": "_deployment_scale",
329
+ "k8s.deployment.restart": "_deployment_restart",
330
+ "k8s.namespace.delete": "_namespace_delete",
331
+ }
332
+
333
+ _MUTATING = {
334
+ "k8s.pod.delete",
335
+ "k8s.deployment.scale",
336
+ "k8s.deployment.restart",
337
+ "k8s.namespace.delete",
338
+ }