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.
- gax/__init__.py +3 -0
- gax/adapters/__init__.py +3 -0
- gax/adapters/base.py +25 -0
- gax/adapters/exec_adapter.py +261 -0
- gax/adapters/http_adapter.py +33 -0
- gax/adapters/k8s_adapter.py +338 -0
- gax/adapters/mcp_bridge.py +108 -0
- gax/adapters/mock_adapter.py +49 -0
- gax/audit.py +54 -0
- gax/caps.py +91 -0
- gax/cli.py +722 -0
- gax/client.py +58 -0
- gax/compliance.py +71 -0
- gax/config/oauth_providers.yaml +21 -0
- gax/config/policy.rego +20 -0
- gax/config/policy.yaml +59 -0
- gax/daemon.py +187 -0
- gax/envelope.py +64 -0
- gax/executor.py +151 -0
- gax/macaroons_cap.py +80 -0
- gax/manifests/aws.s3.list.yaml +20 -0
- gax/manifests/demo.echo.yaml +20 -0
- gax/manifests/gh.pr.list.yaml +41 -0
- gax/manifests/gh.pr.view.yaml +30 -0
- gax/manifests/jira.issue.get.yaml +18 -0
- gax/manifests/k8s.deployment.scale.yaml +35 -0
- gax/manifests/k8s.pod.delete.yaml +30 -0
- gax/manifests/kubectl.get.pods.yaml +20 -0
- gax/manifests/mcp.github.list_pulls.yaml +29 -0
- gax/manifests/pet_findpetsbystatus.yaml +19 -0
- gax/manifests/pet_getpetbyid.yaml +19 -0
- gax/mcp_client.py +110 -0
- gax/mcp_server.py +299 -0
- gax/oauth.py +184 -0
- gax/onboarding.py +397 -0
- gax/opa_policy.py +46 -0
- gax/openapi_gen.py +96 -0
- gax/otel_audit.py +50 -0
- gax/paths.py +46 -0
- gax/plan.py +151 -0
- gax/policy.py +33 -0
- gax/policy_bundle.py +64 -0
- gax/profiles/github/gh.issue.list.yaml +25 -0
- gax/profiles/github/gh.issue.view.yaml +23 -0
- gax/profiles/github/gh.pr.comment.yaml +30 -0
- gax/profiles/github/gh.pr.merge.yaml +29 -0
- gax/profiles/github/gh.run.list.yaml +22 -0
- gax/profiles/k8s/k8s.deployment.list.yaml +17 -0
- gax/profiles/k8s/k8s.deployment.restart.yaml +25 -0
- gax/profiles/k8s/k8s.namespace.delete.yaml +22 -0
- gax/profiles/k8s/k8s.pod.describe.yaml +22 -0
- gax/profiles/k8s/k8s.pod.logs.yaml +28 -0
- gax/profiles/k8s/k8s.service.list.yaml +17 -0
- gax/profiles.py +143 -0
- gax/projection.py +42 -0
- gax/registry.py +130 -0
- gax/schemas/envelope.v1.json +46 -0
- gax/secrets.py +49 -0
- gax/side_effects.py +67 -0
- gax/spiffe.py +32 -0
- gax/vault.py +69 -0
- gax_cli-0.4.0.dist-info/METADATA +164 -0
- gax_cli-0.4.0.dist-info/RECORD +66 -0
- gax_cli-0.4.0.dist-info/WHEEL +5 -0
- gax_cli-0.4.0.dist-info/entry_points.txt +4 -0
- gax_cli-0.4.0.dist-info/top_level.txt +1 -0
gax/__init__.py
ADDED
gax/adapters/__init__.py
ADDED
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
|
+
}
|