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/plan.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from gax.envelope import make_envelope, timed_meta
|
|
11
|
+
from gax.executor import invoke
|
|
12
|
+
from gax.registry import Registry
|
|
13
|
+
|
|
14
|
+
_TEMPLATE = re.compile(r"\{\{\s*([^}]+)\s*\}\}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_plan(path: str) -> dict[str, Any]:
|
|
18
|
+
with open(path, encoding="utf-8") as f:
|
|
19
|
+
return yaml.safe_load(f) or {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_path(expr: str, ctx: dict[str, Any]) -> Any:
|
|
23
|
+
parts = expr.strip().split(".")
|
|
24
|
+
cur: Any = ctx
|
|
25
|
+
for p in parts:
|
|
26
|
+
if p.endswith("]"):
|
|
27
|
+
key, idx = p[:-1].split("[")
|
|
28
|
+
cur = cur[key][int(idx)]
|
|
29
|
+
elif "[" in p:
|
|
30
|
+
key, rest = p.split("[", 1)
|
|
31
|
+
idx = int(rest.rstrip("]"))
|
|
32
|
+
cur = cur[key][idx]
|
|
33
|
+
else:
|
|
34
|
+
cur = cur[p]
|
|
35
|
+
return cur
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_value(value: Any, ctx: dict[str, Any]) -> Any:
|
|
39
|
+
if isinstance(value, str):
|
|
40
|
+
stripped = value.strip()
|
|
41
|
+
m = re.fullmatch(r"\{\{\s*([^}]+)\s*\}\}", stripped)
|
|
42
|
+
if m:
|
|
43
|
+
return _resolve_path(m.group(1), ctx)
|
|
44
|
+
|
|
45
|
+
def repl(match: re.Match[str]) -> str:
|
|
46
|
+
resolved = _resolve_path(match.group(1), ctx)
|
|
47
|
+
return str(resolved)
|
|
48
|
+
|
|
49
|
+
return _TEMPLATE.sub(repl, value)
|
|
50
|
+
if isinstance(value, dict):
|
|
51
|
+
return {k: render_value(v, ctx) for k, v in value.items()}
|
|
52
|
+
if isinstance(value, list):
|
|
53
|
+
return [render_value(v, ctx) for v in value]
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _run_step(
|
|
58
|
+
step: dict[str, Any],
|
|
59
|
+
ctx: dict[str, Any],
|
|
60
|
+
surface: str,
|
|
61
|
+
invoke_fn: Callable[..., tuple[dict[str, Any], int]],
|
|
62
|
+
) -> tuple[str, dict[str, Any], int]:
|
|
63
|
+
step_id = step.get("id") or step.get("command")
|
|
64
|
+
command = step["command"]
|
|
65
|
+
args = render_value(step.get("args") or {}, ctx)
|
|
66
|
+
env, code = invoke_fn(command=command, args=args, surface=surface)
|
|
67
|
+
ctx["steps"][step_id] = {
|
|
68
|
+
"ok": env.get("ok"),
|
|
69
|
+
"data": env.get("data"),
|
|
70
|
+
"meta": env.get("meta"),
|
|
71
|
+
"audit_id": env.get("audit_id"),
|
|
72
|
+
}
|
|
73
|
+
summary = {
|
|
74
|
+
"id": step_id,
|
|
75
|
+
"command": command,
|
|
76
|
+
"ok": env.get("ok"),
|
|
77
|
+
"audit_id": env.get("audit_id"),
|
|
78
|
+
}
|
|
79
|
+
return step_id, summary, code
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_plan(
|
|
83
|
+
registry: Registry,
|
|
84
|
+
plan: dict[str, Any],
|
|
85
|
+
*,
|
|
86
|
+
surface: str = "model",
|
|
87
|
+
capability: str | None = None,
|
|
88
|
+
invoke_fn: Callable[..., tuple[dict[str, Any], int]] | None = None,
|
|
89
|
+
) -> tuple[dict[str, Any], int]:
|
|
90
|
+
"""Execute plan steps (sequential or parallel groups)."""
|
|
91
|
+
start = time.perf_counter()
|
|
92
|
+
steps_def = plan.get("steps") or []
|
|
93
|
+
if not steps_def:
|
|
94
|
+
return make_envelope(
|
|
95
|
+
ok=False,
|
|
96
|
+
cmd="plan@1",
|
|
97
|
+
surface=surface,
|
|
98
|
+
data={},
|
|
99
|
+
error={"kind": "invalid_plan", "message": "no steps", "retryable": False},
|
|
100
|
+
), 1
|
|
101
|
+
|
|
102
|
+
invoke_fn = invoke_fn or (lambda **kw: invoke(registry, capability=capability, **kw))
|
|
103
|
+
ctx: dict[str, Any] = {"steps": {}}
|
|
104
|
+
step_results: list[dict[str, Any]] = []
|
|
105
|
+
last_env: dict[str, Any] | None = None
|
|
106
|
+
exit_code = 0
|
|
107
|
+
|
|
108
|
+
for block in steps_def:
|
|
109
|
+
if "parallel" in block:
|
|
110
|
+
branches = block["parallel"]
|
|
111
|
+
with ThreadPoolExecutor(max_workers=min(8, len(branches))) as pool:
|
|
112
|
+
futures = {
|
|
113
|
+
pool.submit(_run_step, branch, ctx, surface, invoke_fn): branch
|
|
114
|
+
for branch in branches
|
|
115
|
+
}
|
|
116
|
+
for fut in as_completed(futures):
|
|
117
|
+
_sid, summary, code = fut.result()
|
|
118
|
+
step_results.append(summary)
|
|
119
|
+
if code != 0 or not summary.get("ok"):
|
|
120
|
+
exit_code = code or 1
|
|
121
|
+
if exit_code:
|
|
122
|
+
break
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
step_id, summary, code = _run_step(block, ctx, surface, invoke_fn)
|
|
126
|
+
step_results.append(summary)
|
|
127
|
+
last_env = ctx["steps"][step_id]
|
|
128
|
+
if code != 0 or not summary.get("ok"):
|
|
129
|
+
exit_code = code or 1
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
combined = make_envelope(
|
|
133
|
+
ok=exit_code == 0,
|
|
134
|
+
cmd=f"plan:{plan.get('name', 'unnamed')}@1",
|
|
135
|
+
surface=surface,
|
|
136
|
+
data={
|
|
137
|
+
"steps": ctx["steps"],
|
|
138
|
+
"summary": step_results,
|
|
139
|
+
"final": (
|
|
140
|
+
ctx["steps"][step_results[-1]["id"]].get("data", {})
|
|
141
|
+
if step_results and step_results[-1]["id"] in ctx["steps"]
|
|
142
|
+
else {}
|
|
143
|
+
),
|
|
144
|
+
},
|
|
145
|
+
schema="https://schemas.gax.dev/plan/result/v1",
|
|
146
|
+
meta=timed_meta(start, step_count=len(step_results)),
|
|
147
|
+
error={"kind": "plan_failed", "message": "one or more steps failed", "retryable": True}
|
|
148
|
+
if exit_code
|
|
149
|
+
else None,
|
|
150
|
+
)
|
|
151
|
+
return combined, exit_code
|
gax/policy.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from gax.caps import cap_allows_command, cap_allows_scopes
|
|
6
|
+
from gax.opa_policy import check_opa_or_yaml
|
|
7
|
+
from gax.policy_bundle import PolicyDenied
|
|
8
|
+
from gax.registry import CommandManifest
|
|
9
|
+
from gax.side_effects import capability_ceiling, exceeds_ceiling, normalize
|
|
10
|
+
|
|
11
|
+
__all__ = ["PolicyDenied", "check_invoke"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def check_invoke(
|
|
15
|
+
claims: dict[str, Any],
|
|
16
|
+
manifest: CommandManifest,
|
|
17
|
+
args: dict[str, Any] | None = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
if not cap_allows_command(claims, manifest.command):
|
|
20
|
+
raise PolicyDenied(f"capability does not allow command: {manifest.command}")
|
|
21
|
+
if not cap_allows_scopes(claims, manifest.required_scopes):
|
|
22
|
+
raise PolicyDenied(
|
|
23
|
+
f"missing scopes: {manifest.required_scopes}; held: {claims.get('scopes')}"
|
|
24
|
+
)
|
|
25
|
+
# Danger ceiling. Deliberately checked even when the command is on the
|
|
26
|
+
# capability's allowlist: an allowlist edit should not be able to silently
|
|
27
|
+
# promote a read-only token into one that can delete things.
|
|
28
|
+
if exceeds_ceiling(claims, manifest.side_effects):
|
|
29
|
+
raise PolicyDenied(
|
|
30
|
+
f"side_effects '{normalize(manifest.side_effects)}' exceeds capability "
|
|
31
|
+
f"ceiling '{capability_ceiling(claims)}': {manifest.command}"
|
|
32
|
+
)
|
|
33
|
+
check_opa_or_yaml(claims, manifest, args or {})
|
gax/policy_bundle.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from gax.paths import CONFIG_DIR
|
|
9
|
+
from gax.registry import CommandManifest
|
|
10
|
+
|
|
11
|
+
POLICY_PATH = CONFIG_DIR / "policy.yaml"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PolicyDenied(Exception):
|
|
15
|
+
def __init__(self, message: str) -> None:
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.message = message
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_policy(path: Path | None = None) -> dict[str, Any]:
|
|
21
|
+
p = path or POLICY_PATH
|
|
22
|
+
if not p.exists():
|
|
23
|
+
return {}
|
|
24
|
+
return yaml.safe_load(p.read_text()) or {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_policy_bundle(
|
|
28
|
+
claims: dict[str, Any],
|
|
29
|
+
manifest: CommandManifest,
|
|
30
|
+
args: dict[str, Any],
|
|
31
|
+
*,
|
|
32
|
+
policy: dict[str, Any] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
policy = policy or load_policy()
|
|
35
|
+
defaults = policy.get("defaults") or {}
|
|
36
|
+
tenant_id = str(claims.get("tenant_id", "default"))
|
|
37
|
+
tenant_rules = (policy.get("tenants") or {}).get(tenant_id) or (policy.get("tenants") or {}).get(
|
|
38
|
+
"default", {}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Tenant-level kill switch for destructive commands. The per-capability ceiling
|
|
42
|
+
# (gax.side_effects) is the primary control; this is the blunt org-wide override
|
|
43
|
+
# for tenants that must never run destructive commands regardless of who asks.
|
|
44
|
+
# Tenant setting wins over the global default so a tenant can opt in explicitly.
|
|
45
|
+
deny_destructive = tenant_rules.get(
|
|
46
|
+
"deny_destructive", defaults.get("deny_destructive")
|
|
47
|
+
)
|
|
48
|
+
if deny_destructive and manifest.side_effects == "destructive":
|
|
49
|
+
raise PolicyDenied(
|
|
50
|
+
f"destructive commands disabled for tenant '{tenant_id}': {manifest.command}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
denied = set(tenant_rules.get("denied_commands") or [])
|
|
54
|
+
if manifest.command in denied:
|
|
55
|
+
raise PolicyDenied(f"command denied by policy: {manifest.command}")
|
|
56
|
+
|
|
57
|
+
allowed = tenant_rules.get("allowed_commands")
|
|
58
|
+
if allowed and manifest.command not in allowed and "*" not in allowed:
|
|
59
|
+
raise PolicyDenied(f"command not in tenant allowlist: {manifest.command}")
|
|
60
|
+
|
|
61
|
+
allowlist = tenant_rules.get("repo_allowlist") or []
|
|
62
|
+
repo = args.get("repo")
|
|
63
|
+
if allowlist and repo and repo not in allowlist:
|
|
64
|
+
raise PolicyDenied(f"repo not in allowlist: {repo}")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
command: gh.issue.list
|
|
2
|
+
version: "1.0.0"
|
|
3
|
+
description: List issues in a repository
|
|
4
|
+
category: github
|
|
5
|
+
adapter: exec
|
|
6
|
+
required_scopes:
|
|
7
|
+
- github:issues:read
|
|
8
|
+
side_effects: read
|
|
9
|
+
idempotent: true
|
|
10
|
+
input_schema:
|
|
11
|
+
type: object
|
|
12
|
+
properties:
|
|
13
|
+
repo:
|
|
14
|
+
type: string
|
|
15
|
+
description: owner/name
|
|
16
|
+
limit:
|
|
17
|
+
type: integer
|
|
18
|
+
description: Max results (default 30)
|
|
19
|
+
state:
|
|
20
|
+
type: string
|
|
21
|
+
description: open | closed | all
|
|
22
|
+
required:
|
|
23
|
+
- repo
|
|
24
|
+
output_schema:
|
|
25
|
+
type: object
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
command: gh.issue.view
|
|
2
|
+
version: "1.0.0"
|
|
3
|
+
description: View a single issue
|
|
4
|
+
category: github
|
|
5
|
+
adapter: exec
|
|
6
|
+
required_scopes:
|
|
7
|
+
- github:issues:read
|
|
8
|
+
side_effects: read
|
|
9
|
+
idempotent: true
|
|
10
|
+
input_schema:
|
|
11
|
+
type: object
|
|
12
|
+
properties:
|
|
13
|
+
repo:
|
|
14
|
+
type: string
|
|
15
|
+
description: owner/name
|
|
16
|
+
number:
|
|
17
|
+
type: integer
|
|
18
|
+
description: Issue number
|
|
19
|
+
required:
|
|
20
|
+
- repo
|
|
21
|
+
- number
|
|
22
|
+
output_schema:
|
|
23
|
+
type: object
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
command: gh.pr.comment
|
|
2
|
+
version: "1.0.0"
|
|
3
|
+
description: Post a comment on a pull request
|
|
4
|
+
category: github
|
|
5
|
+
adapter: exec
|
|
6
|
+
required_scopes:
|
|
7
|
+
- github:pull_request:write
|
|
8
|
+
side_effects: write
|
|
9
|
+
idempotent: false
|
|
10
|
+
input_schema:
|
|
11
|
+
type: object
|
|
12
|
+
properties:
|
|
13
|
+
repo:
|
|
14
|
+
type: string
|
|
15
|
+
description: owner/name
|
|
16
|
+
number:
|
|
17
|
+
type: integer
|
|
18
|
+
description: PR number
|
|
19
|
+
body:
|
|
20
|
+
type: string
|
|
21
|
+
description: Comment body
|
|
22
|
+
dry_run:
|
|
23
|
+
type: boolean
|
|
24
|
+
description: Validate without posting
|
|
25
|
+
required:
|
|
26
|
+
- repo
|
|
27
|
+
- number
|
|
28
|
+
- body
|
|
29
|
+
output_schema:
|
|
30
|
+
type: object
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
command: gh.pr.merge
|
|
2
|
+
version: "1.0.0"
|
|
3
|
+
description: Merge a pull request. Irreversible on a shared branch.
|
|
4
|
+
category: github
|
|
5
|
+
adapter: exec
|
|
6
|
+
required_scopes:
|
|
7
|
+
- github:pull_request:write
|
|
8
|
+
side_effects: destructive
|
|
9
|
+
idempotent: false
|
|
10
|
+
input_schema:
|
|
11
|
+
type: object
|
|
12
|
+
properties:
|
|
13
|
+
repo:
|
|
14
|
+
type: string
|
|
15
|
+
description: owner/name
|
|
16
|
+
number:
|
|
17
|
+
type: integer
|
|
18
|
+
description: PR number
|
|
19
|
+
method:
|
|
20
|
+
type: string
|
|
21
|
+
description: merge | squash | rebase (default squash)
|
|
22
|
+
dry_run:
|
|
23
|
+
type: boolean
|
|
24
|
+
description: Validate without merging
|
|
25
|
+
required:
|
|
26
|
+
- repo
|
|
27
|
+
- number
|
|
28
|
+
output_schema:
|
|
29
|
+
type: object
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
command: gh.run.list
|
|
2
|
+
version: "1.0.0"
|
|
3
|
+
description: List recent GitHub Actions runs
|
|
4
|
+
category: github
|
|
5
|
+
adapter: exec
|
|
6
|
+
required_scopes:
|
|
7
|
+
- github:actions:read
|
|
8
|
+
side_effects: read
|
|
9
|
+
idempotent: true
|
|
10
|
+
input_schema:
|
|
11
|
+
type: object
|
|
12
|
+
properties:
|
|
13
|
+
repo:
|
|
14
|
+
type: string
|
|
15
|
+
description: owner/name
|
|
16
|
+
limit:
|
|
17
|
+
type: integer
|
|
18
|
+
description: Max results (default 20)
|
|
19
|
+
required:
|
|
20
|
+
- repo
|
|
21
|
+
output_schema:
|
|
22
|
+
type: object
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
command: k8s.deployment.list
|
|
2
|
+
version: "1.0.0"
|
|
3
|
+
description: List deployments in a namespace
|
|
4
|
+
category: kubernetes
|
|
5
|
+
adapter: k8s
|
|
6
|
+
required_scopes:
|
|
7
|
+
- k8s:deployments:read
|
|
8
|
+
side_effects: read
|
|
9
|
+
idempotent: true
|
|
10
|
+
input_schema:
|
|
11
|
+
type: object
|
|
12
|
+
properties:
|
|
13
|
+
namespace:
|
|
14
|
+
type: string
|
|
15
|
+
description: Kubernetes namespace
|
|
16
|
+
output_schema:
|
|
17
|
+
type: object
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
command: k8s.deployment.restart
|
|
2
|
+
version: "1.0.0"
|
|
3
|
+
description: Roll a deployment (restarts every pod)
|
|
4
|
+
category: kubernetes
|
|
5
|
+
adapter: k8s
|
|
6
|
+
required_scopes:
|
|
7
|
+
- k8s:deployments:write
|
|
8
|
+
side_effects: write
|
|
9
|
+
idempotent: true
|
|
10
|
+
input_schema:
|
|
11
|
+
type: object
|
|
12
|
+
properties:
|
|
13
|
+
namespace:
|
|
14
|
+
type: string
|
|
15
|
+
description: Kubernetes namespace
|
|
16
|
+
deployment:
|
|
17
|
+
type: string
|
|
18
|
+
description: Deployment name
|
|
19
|
+
dry_run:
|
|
20
|
+
type: boolean
|
|
21
|
+
description: Validate server-side without restarting
|
|
22
|
+
required:
|
|
23
|
+
- deployment
|
|
24
|
+
output_schema:
|
|
25
|
+
type: object
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
command: k8s.namespace.delete
|
|
2
|
+
version: "1.0.0"
|
|
3
|
+
description: Delete a namespace and everything in it. Irreversible.
|
|
4
|
+
category: kubernetes
|
|
5
|
+
adapter: k8s
|
|
6
|
+
required_scopes:
|
|
7
|
+
- k8s:namespaces:write
|
|
8
|
+
side_effects: destructive
|
|
9
|
+
idempotent: false
|
|
10
|
+
input_schema:
|
|
11
|
+
type: object
|
|
12
|
+
properties:
|
|
13
|
+
namespace:
|
|
14
|
+
type: string
|
|
15
|
+
description: Namespace to delete
|
|
16
|
+
dry_run:
|
|
17
|
+
type: boolean
|
|
18
|
+
description: Validate server-side without deleting
|
|
19
|
+
required:
|
|
20
|
+
- namespace
|
|
21
|
+
output_schema:
|
|
22
|
+
type: object
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
command: k8s.pod.describe
|
|
2
|
+
version: "1.0.0"
|
|
3
|
+
description: Describe a pod (status, events, conditions)
|
|
4
|
+
category: kubernetes
|
|
5
|
+
adapter: k8s
|
|
6
|
+
required_scopes:
|
|
7
|
+
- k8s:pods:read
|
|
8
|
+
side_effects: read
|
|
9
|
+
idempotent: true
|
|
10
|
+
input_schema:
|
|
11
|
+
type: object
|
|
12
|
+
properties:
|
|
13
|
+
namespace:
|
|
14
|
+
type: string
|
|
15
|
+
description: Kubernetes namespace
|
|
16
|
+
pod:
|
|
17
|
+
type: string
|
|
18
|
+
description: Pod name
|
|
19
|
+
required:
|
|
20
|
+
- pod
|
|
21
|
+
output_schema:
|
|
22
|
+
type: object
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
command: k8s.pod.logs
|
|
2
|
+
version: "1.0.0"
|
|
3
|
+
description: Fetch recent logs for a pod
|
|
4
|
+
category: kubernetes
|
|
5
|
+
adapter: k8s
|
|
6
|
+
required_scopes:
|
|
7
|
+
- k8s:pods:read
|
|
8
|
+
side_effects: read
|
|
9
|
+
idempotent: true
|
|
10
|
+
input_schema:
|
|
11
|
+
type: object
|
|
12
|
+
properties:
|
|
13
|
+
namespace:
|
|
14
|
+
type: string
|
|
15
|
+
description: Kubernetes namespace
|
|
16
|
+
pod:
|
|
17
|
+
type: string
|
|
18
|
+
description: Pod name
|
|
19
|
+
tail:
|
|
20
|
+
type: integer
|
|
21
|
+
description: Number of lines from the end (default 100)
|
|
22
|
+
container:
|
|
23
|
+
type: string
|
|
24
|
+
description: Container name when the pod has several
|
|
25
|
+
required:
|
|
26
|
+
- pod
|
|
27
|
+
output_schema:
|
|
28
|
+
type: object
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
command: k8s.service.list
|
|
2
|
+
version: "1.0.0"
|
|
3
|
+
description: List services in a namespace
|
|
4
|
+
category: kubernetes
|
|
5
|
+
adapter: k8s
|
|
6
|
+
required_scopes:
|
|
7
|
+
- k8s:services:read
|
|
8
|
+
side_effects: read
|
|
9
|
+
idempotent: true
|
|
10
|
+
input_schema:
|
|
11
|
+
type: object
|
|
12
|
+
properties:
|
|
13
|
+
namespace:
|
|
14
|
+
type: string
|
|
15
|
+
description: Kubernetes namespace
|
|
16
|
+
output_schema:
|
|
17
|
+
type: object
|
gax/profiles.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Profiles — bundled sets of ready-to-use commands.
|
|
3
|
+
|
|
4
|
+
The adoption problem this solves: GAX shipped 11 commands, mostly mocks, so a new
|
|
5
|
+
user's first task was authoring YAML for everything they actually cared about.
|
|
6
|
+
A framework that ships empty doesn't get adopted.
|
|
7
|
+
|
|
8
|
+
A profile is a directory of manifests under `gax/profiles/<name>/`. Installing one
|
|
9
|
+
copies them into `~/.gax/manifests/`, which the registry loads *after* the packaged
|
|
10
|
+
set — so profile commands survive package upgrades and can be edited in place.
|
|
11
|
+
|
|
12
|
+
Profiles deliberately span the danger ladder (`read` → `write` → `destructive`).
|
|
13
|
+
A profile of only read commands would make the side-effect ceiling untestable in
|
|
14
|
+
practice, and the ceiling is the thing worth demonstrating.
|
|
15
|
+
|
|
16
|
+
Installing a profile **grants nothing**. It registers commands; invoking them still
|
|
17
|
+
requires a capability naming the command, holding the scope, and reaching the
|
|
18
|
+
ceiling. `gax init --profile k8s` still hands out a read-only capability.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import shutil
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
from gax.paths import PROFILES_DIR, USER_MANIFESTS_DIR
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ProfileCommand:
|
|
35
|
+
command: str
|
|
36
|
+
description: str
|
|
37
|
+
side_effects: str
|
|
38
|
+
required_scopes: list[str] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Profile:
|
|
43
|
+
name: str
|
|
44
|
+
path: Path
|
|
45
|
+
commands: list[ProfileCommand]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def scopes(self) -> list[str]:
|
|
49
|
+
seen: list[str] = []
|
|
50
|
+
for c in self.commands:
|
|
51
|
+
for s in c.required_scopes:
|
|
52
|
+
if s not in seen:
|
|
53
|
+
seen.append(s)
|
|
54
|
+
return sorted(seen)
|
|
55
|
+
|
|
56
|
+
def by_level(self, level: str) -> list[ProfileCommand]:
|
|
57
|
+
return [c for c in self.commands if c.side_effects == level]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def summary(self) -> str:
|
|
61
|
+
parts = [
|
|
62
|
+
f"{len(self.by_level(lvl))} {lvl}"
|
|
63
|
+
for lvl in ("read", "write", "destructive")
|
|
64
|
+
if self.by_level(lvl)
|
|
65
|
+
]
|
|
66
|
+
return ", ".join(parts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_profile(directory: Path) -> Profile:
|
|
70
|
+
commands: list[ProfileCommand] = []
|
|
71
|
+
for path in sorted(directory.glob("*.yaml")):
|
|
72
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
73
|
+
if not data.get("command"):
|
|
74
|
+
continue
|
|
75
|
+
commands.append(
|
|
76
|
+
ProfileCommand(
|
|
77
|
+
command=str(data["command"]),
|
|
78
|
+
description=str(data.get("description", "")),
|
|
79
|
+
side_effects=str(data.get("side_effects", "destructive")),
|
|
80
|
+
required_scopes=list(data.get("required_scopes") or []),
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return Profile(name=directory.name, path=directory, commands=commands)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def available_profiles() -> dict[str, Profile]:
|
|
87
|
+
if not PROFILES_DIR.exists():
|
|
88
|
+
return {}
|
|
89
|
+
return {
|
|
90
|
+
d.name: _read_profile(d)
|
|
91
|
+
for d in sorted(PROFILES_DIR.iterdir())
|
|
92
|
+
if d.is_dir() and not d.name.startswith(("_", "."))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_profile(name: str) -> Profile | None:
|
|
97
|
+
return available_profiles().get(name)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def installed_commands() -> set[str]:
|
|
101
|
+
"""Commands currently present in the user manifests directory."""
|
|
102
|
+
out: set[str] = set()
|
|
103
|
+
if not USER_MANIFESTS_DIR.exists():
|
|
104
|
+
return out
|
|
105
|
+
for path in USER_MANIFESTS_DIR.glob("*.yaml"):
|
|
106
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
107
|
+
if data.get("command"):
|
|
108
|
+
out.add(str(data["command"]))
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def install_profile(name: str, *, force: bool = False) -> dict[str, Any]:
|
|
113
|
+
"""
|
|
114
|
+
Copy a profile's manifests into `~/.gax/manifests/`.
|
|
115
|
+
|
|
116
|
+
Existing files are skipped unless `force`, so a locally edited command is never
|
|
117
|
+
silently overwritten by a reinstall.
|
|
118
|
+
"""
|
|
119
|
+
profile = get_profile(name)
|
|
120
|
+
if profile is None:
|
|
121
|
+
raise KeyError(name)
|
|
122
|
+
|
|
123
|
+
USER_MANIFESTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
installed: list[str] = []
|
|
125
|
+
skipped: list[str] = []
|
|
126
|
+
|
|
127
|
+
for src in sorted(profile.path.glob("*.yaml")):
|
|
128
|
+
dst = USER_MANIFESTS_DIR / src.name
|
|
129
|
+
if dst.exists() and not force:
|
|
130
|
+
skipped.append(src.stem)
|
|
131
|
+
continue
|
|
132
|
+
shutil.copy2(src, dst)
|
|
133
|
+
installed.append(src.stem)
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
"profile": name,
|
|
137
|
+
"installed": installed,
|
|
138
|
+
"skipped": skipped,
|
|
139
|
+
"target": str(USER_MANIFESTS_DIR),
|
|
140
|
+
"scopes": profile.scopes,
|
|
141
|
+
"commands": [c.command for c in profile.commands],
|
|
142
|
+
"summary": profile.summary,
|
|
143
|
+
}
|