ocm-mcp-server 0.2.2__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.
@@ -0,0 +1,10 @@
1
+ # SPDX-FileCopyrightText: 2026 Sandeep Bazar
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """ocm-mcp-server: a guardrailed MCP server for multi-cluster Kubernetes operations.
5
+
6
+ The agent never holds a kubeconfig. Every write is policy-checked, human-approved,
7
+ and traced.
8
+ """
9
+
10
+ __version__ = "0.1.0"
@@ -0,0 +1,434 @@
1
+ # SPDX-FileCopyrightText: 2026 Sandeep Bazar
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Human approval flow for destructive actions.
5
+
6
+ Design:
7
+ - A propose_* tool stores a proposal file (JSON) with a SHA-256 content hash.
8
+ - A human runs `ocm-mcp approve <id>` on a trusted terminal; the CLI signs an
9
+ approval token (Ed25519) whose claims bind the exact proposal content hash, the
10
+ intended operation (apply or rollback), and an expiry.
11
+ - apply_* only succeeds when the token's signature verifies and its claims match.
12
+
13
+ Approval is asymmetric on purpose. The signing (private) key lives in
14
+ OCM_MCP_HOME/approval_ed25519 (0600) and is used only by the `ocm-mcp` CLI. The MCP
15
+ server loads only the public key (approval_ed25519.pub), so it can verify a token
16
+ but can never mint one - even if the server, or an agent that reads the server's key
17
+ material, is compromised. Keep the signer in a separate OS account, device, or
18
+ chat-ops/ticket service for full isolation; until then, treat shell/filesystem
19
+ isolation between the agent and the CLI as mandatory.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import contextlib
26
+ import hashlib
27
+ import json
28
+ import os
29
+ import time
30
+ import uuid
31
+ from dataclasses import asdict, dataclass, field
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from cryptography.exceptions import InvalidSignature
36
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
37
+ Ed25519PrivateKey,
38
+ Ed25519PublicKey,
39
+ )
40
+ from cryptography.hazmat.primitives.serialization import (
41
+ Encoding,
42
+ NoEncryption,
43
+ PrivateFormat,
44
+ PublicFormat,
45
+ )
46
+
47
+ from .config import SETTINGS
48
+ from .filelock import locked
49
+
50
+
51
+ class ApprovalError(Exception):
52
+ """Raised when an approval token is missing, expired, or invalid."""
53
+
54
+
55
+ SCHEMA_VERSION = 1
56
+
57
+ # Legal proposal status transitions. A proposal advances forward only; it can never
58
+ # move back to pending or from a terminal state, so a stale file cannot be re-applied.
59
+ _STATUS_TRANSITIONS = {
60
+ "pending": {"applied", "rejected"},
61
+ "applied": {"rolled_back"},
62
+ "rejected": set(),
63
+ "rolled_back": set(),
64
+ }
65
+
66
+
67
+ def _valid_id(proposal_id: str) -> bool:
68
+ """A proposal id must be a 32-char uuid4 hex string. This is also what keeps a
69
+ caller-supplied id from escaping the proposals directory (path traversal)."""
70
+ return (
71
+ isinstance(proposal_id, str)
72
+ and len(proposal_id) == 32
73
+ and all(c in "0123456789abcdef" for c in proposal_id)
74
+ )
75
+
76
+
77
+ @dataclass
78
+ class Proposal:
79
+ id: str
80
+ cluster: str
81
+ name: str
82
+ summary: str
83
+ manifests: list[dict[str, Any]]
84
+ created_at: float
85
+ content_hash: str = ""
86
+ status: str = "pending" # pending | applied | rolled_back | rejected
87
+ applied_work: str = ""
88
+ applied_uid: str = "" # UID of the created ManifestWork, checked before rollback
89
+ # "manifestwork" (deploy a bundle), "action" (OCM lifecycle), or "rollback".
90
+ kind: str = "manifestwork"
91
+ action: str = ""
92
+ params: dict[str, Any] = field(default_factory=dict)
93
+ approved_by: str = "" # the human recorded in the approval token that applied this
94
+ schema_version: int = SCHEMA_VERSION
95
+
96
+ def path(self) -> Path:
97
+ if not _valid_id(self.id):
98
+ raise ApprovalError(f"Invalid proposal id '{self.id}'.")
99
+ return SETTINGS.proposals_dir / f"{self.id}.json"
100
+
101
+ def save(self) -> None:
102
+ """Write atomically (temp file + fsync + rename) under a lock, so concurrent
103
+ writers can't interleave and a crash can't leave a torn file."""
104
+ p = self.path()
105
+ with locked(p):
106
+ tmp = p.with_suffix(".json.tmp")
107
+ with tmp.open("w") as f:
108
+ f.write(json.dumps(asdict(self), indent=2, sort_keys=True))
109
+ f.flush()
110
+ os.fsync(f.fileno())
111
+ tmp.chmod(0o600)
112
+ tmp.replace(p)
113
+
114
+ def set_status(self, new_status: str) -> None:
115
+ """Advance to new_status only if the transition is legal, then persist."""
116
+ if new_status != self.status and new_status not in _STATUS_TRANSITIONS.get(
117
+ self.status, set()
118
+ ):
119
+ raise ApprovalError(
120
+ f"Illegal proposal transition {self.status} -> {new_status} for '{self.id}'."
121
+ )
122
+ self.status = new_status
123
+ self.save()
124
+
125
+
126
+ def content_hash(
127
+ cluster: str,
128
+ name: str,
129
+ manifests: list[dict[str, Any]],
130
+ kind: str = "manifestwork",
131
+ action: str = "",
132
+ params: dict[str, Any] | None = None,
133
+ ) -> str:
134
+ """A token binds to this hash. Changing any field below invalidates approval."""
135
+ canonical = json.dumps(
136
+ {
137
+ "cluster": cluster,
138
+ "name": name,
139
+ "manifests": manifests,
140
+ "kind": kind,
141
+ "action": action,
142
+ "params": params or {},
143
+ },
144
+ sort_keys=True,
145
+ separators=(",", ":"),
146
+ )
147
+ return hashlib.sha256(canonical.encode()).hexdigest()
148
+
149
+
150
+ def new_proposal(
151
+ cluster: str, name: str, summary: str, manifests: list[dict[str, Any]]
152
+ ) -> Proposal:
153
+ prop = Proposal(
154
+ id=uuid.uuid4().hex,
155
+ cluster=cluster,
156
+ name=name,
157
+ summary=summary,
158
+ manifests=manifests,
159
+ created_at=time.time(),
160
+ kind="manifestwork",
161
+ )
162
+ prop.content_hash = content_hash(cluster, name, manifests, kind="manifestwork")
163
+ prop.save()
164
+ return prop
165
+
166
+
167
+ def new_action_proposal(
168
+ cluster: str, action: str, summary: str, params: dict[str, Any]
169
+ ) -> Proposal:
170
+ """A proposed OCM lifecycle action (cordon/uncordon/set_label/accept) awaiting approval."""
171
+ prop = Proposal(
172
+ id=uuid.uuid4().hex,
173
+ cluster=cluster,
174
+ name=f"{action}-{cluster}",
175
+ summary=summary,
176
+ manifests=[],
177
+ created_at=time.time(),
178
+ kind="action",
179
+ action=action,
180
+ params=params,
181
+ )
182
+ prop.content_hash = content_hash(
183
+ cluster, prop.name, [], kind="action", action=action, params=params
184
+ )
185
+ prop.save()
186
+ return prop
187
+
188
+
189
+ _FIELDS = {f for f in Proposal.__dataclass_fields__} # type: ignore[attr-defined]
190
+
191
+
192
+ def _from_json(raw: str) -> Proposal:
193
+ """Build a Proposal from stored JSON, ignoring keys this version doesn't know
194
+ (forward compatibility) so a newer file can't crash an older reader."""
195
+ data = {k: v for k, v in json.loads(raw).items() if k in _FIELDS}
196
+ return Proposal(**data)
197
+
198
+
199
+ def load_proposal(proposal_id: str) -> Proposal:
200
+ if not _valid_id(proposal_id):
201
+ raise ApprovalError(f"Invalid proposal id '{proposal_id}'.")
202
+ path = SETTINGS.proposals_dir / f"{proposal_id}.json"
203
+ if not path.exists():
204
+ raise ApprovalError(f"No proposal with id '{proposal_id}'.")
205
+ return _from_json(path.read_text())
206
+
207
+
208
+ def list_proposals(status: str = "") -> list[Proposal]:
209
+ out = []
210
+ for path in sorted(SETTINGS.proposals_dir.glob("*.json")):
211
+ prop = _from_json(path.read_text())
212
+ if not status or prop.status == status:
213
+ out.append(prop)
214
+ return out
215
+
216
+
217
+ def proposal_lock(proposal_id: str):
218
+ """Serialize the whole apply of one proposal so two concurrent applies (even with two
219
+ separately minted valid tokens) can't both pass the pending-status check and both
220
+ write. Uses a distinct `.apply` lock file so it never nests with save()'s own lock."""
221
+ if not _valid_id(proposal_id):
222
+ raise ApprovalError(f"Invalid proposal id '{proposal_id}'.")
223
+ return locked(SETTINGS.proposals_dir / f"{proposal_id}.apply")
224
+
225
+
226
+ def new_rollback_proposal(applied: Proposal, summary: str) -> Proposal:
227
+ """A distinct, approvable proposal to roll back an already-applied ManifestWork.
228
+
229
+ It binds the exact ManifestWork name and UID recorded at apply time, so an old
230
+ apply token can never authorize a rollback and the rollback targets exactly the
231
+ object the human approved.
232
+ """
233
+ params = {
234
+ "target_work": applied.applied_work,
235
+ "target_uid": applied.applied_uid,
236
+ "origin": applied.id,
237
+ }
238
+ prop = Proposal(
239
+ id=uuid.uuid4().hex,
240
+ cluster=applied.cluster,
241
+ name=f"rollback-{applied.applied_work}",
242
+ summary=summary,
243
+ manifests=[],
244
+ created_at=time.time(),
245
+ kind="rollback",
246
+ params=params,
247
+ )
248
+ prop.content_hash = content_hash(applied.cluster, prop.name, [], kind="rollback", params=params)
249
+ prop.save()
250
+ return prop
251
+
252
+
253
+ def intended_operation(prop: Proposal) -> str:
254
+ """The operation a token for this proposal authorizes: 'rollback' or 'apply'."""
255
+ return "rollback" if prop.kind == "rollback" else "apply"
256
+
257
+
258
+ # --------------------------------------------------------------- asymmetric tokens
259
+
260
+
261
+ def _private_key() -> Ed25519PrivateKey:
262
+ """The signing key. Held by the human side (ocm-mcp CLI); the server must not call this."""
263
+ path = SETTINGS.approval_private_key_path
264
+ if path.exists():
265
+ return Ed25519PrivateKey.from_private_bytes(bytes.fromhex(path.read_text().strip()))
266
+ SETTINGS.home.mkdir(parents=True, exist_ok=True)
267
+ key = Ed25519PrivateKey.generate()
268
+ path.write_text(key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption()).hex())
269
+ path.chmod(0o600)
270
+ pub = SETTINGS.approval_public_key_path
271
+ pub.write_text(key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex())
272
+ pub.chmod(0o644)
273
+ return key
274
+
275
+
276
+ def _verifier_keys() -> list[Ed25519PublicKey]:
277
+ """The verification key(s) the server accepts. During rotation the retired public
278
+ key is kept as `.pub.prev` so tokens minted just before rotation still verify until
279
+ they expire; a hard cutover would reject valid, in-flight approvals."""
280
+ keys = []
281
+ for path in (SETTINGS.approval_public_key_path, SETTINGS.previous_public_key_path):
282
+ if path.exists():
283
+ keys.append(Ed25519PublicKey.from_public_bytes(bytes.fromhex(path.read_text().strip())))
284
+ if not keys:
285
+ raise ApprovalError("No approval key is configured; no token can be valid.")
286
+ return keys
287
+
288
+
289
+ def _b64(data: bytes) -> str:
290
+ return base64.urlsafe_b64encode(data).decode().rstrip("=")
291
+
292
+
293
+ def _unb64(s: str) -> bytes:
294
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
295
+
296
+
297
+ def _load_used(path: Path) -> list[dict[str, Any]]:
298
+ if not path.exists():
299
+ return []
300
+ out = []
301
+ with path.open() as f:
302
+ for line in f:
303
+ line = line.strip()
304
+ if line:
305
+ with contextlib.suppress(ValueError):
306
+ out.append(json.loads(line))
307
+ return out
308
+
309
+
310
+ def _token_used(jti: str) -> bool:
311
+ return any(e.get("jti") == jti for e in _load_used(SETTINGS.used_tokens_path))
312
+
313
+
314
+ # Compact the spent-token ledger once it grows past this many lines, dropping expired
315
+ # entries. The common path is an O(1) append; compaction (a full rewrite) is rare.
316
+ _LEDGER_COMPACT_AT = 2000
317
+
318
+
319
+ def _mark_token_used(claims: dict[str, Any]) -> None:
320
+ """Record a token id as spent, so it can never be replayed.
321
+
322
+ The common case appends one line (O(1) write). Only when the ledger grows past
323
+ `_LEDGER_COMPACT_AT` lines is it rewritten, dropping entries whose token has already
324
+ expired - so it stays bounded without a full rewrite on every apply. A spent id only
325
+ needs to be remembered until its token's expiry.
326
+ """
327
+ path = SETTINGS.used_tokens_path
328
+ now = int(time.time())
329
+ entry = {
330
+ "jti": claims.get("jti"),
331
+ "id": claims.get("id"),
332
+ "op": claims.get("op"),
333
+ "exp": int(claims.get("exp", now)),
334
+ "used_at": now,
335
+ }
336
+ with locked(path):
337
+ used = _load_used(path)
338
+ if any(e.get("jti") == claims.get("jti") for e in used):
339
+ raise ApprovalError("This approval token has already been used (replay refused).")
340
+ if len(used) >= _LEDGER_COMPACT_AT:
341
+ kept = [e for e in used if int(e.get("exp", now)) > now]
342
+ kept.append(entry)
343
+ tmp = path.with_suffix(".jsonl.tmp")
344
+ with tmp.open("w") as f:
345
+ for e in kept:
346
+ f.write(json.dumps(e, separators=(",", ":")) + "\n")
347
+ f.flush()
348
+ os.fsync(f.fileno())
349
+ tmp.chmod(0o600)
350
+ tmp.replace(path)
351
+ else:
352
+ with path.open("a") as f:
353
+ f.write(json.dumps(entry, separators=(",", ":")) + "\n")
354
+ f.flush()
355
+ os.fsync(f.fileno())
356
+
357
+
358
+ def mint_token(
359
+ prop: Proposal,
360
+ operation: str | None = None,
361
+ ttl_seconds: int | None = None,
362
+ approver: str = "",
363
+ ) -> str:
364
+ """Sign an approval token. Called by the CLI (a human), never by any MCP tool."""
365
+ now = int(time.time())
366
+ claims = {
367
+ "jti": uuid.uuid4().hex,
368
+ "iss": SETTINGS.issuer,
369
+ "aud": SETTINGS.audience,
370
+ "id": prop.id,
371
+ "hash": prop.content_hash,
372
+ "op": operation or intended_operation(prop),
373
+ "approver": approver or os.environ.get("USER", ""),
374
+ "iat": now,
375
+ "nbf": now,
376
+ "exp": now + (ttl_seconds or SETTINGS.approval_ttl_seconds),
377
+ }
378
+ payload = json.dumps(claims, sort_keys=True, separators=(",", ":")).encode()
379
+ return _b64(payload) + "." + _b64(_private_key().sign(payload))
380
+
381
+
382
+ def verify_token(
383
+ prop: Proposal, token: str, operation: str = "apply", consume: bool = False
384
+ ) -> dict[str, Any]:
385
+ """Raise ApprovalError unless the token is a valid signature over this exact proposal
386
+ content AND authorizes the given operation ('apply' or 'rollback'). Returns the claims.
387
+
388
+ When consume=True (the apply path), the token id is recorded as spent under a lock, so
389
+ the same approval can never be replayed; a token already spent is refused. The apply
390
+ tools consume before the cluster write, so a transient write failure spends the token
391
+ (fail-safe: never risk a replay). The proposal stays pending, so the operator simply
392
+ re-approves to mint a fresh token - a deliberate trade of convenience for safety.
393
+ """
394
+ try:
395
+ payload_b64, sig_b64 = token.strip().split(".")
396
+ payload, sig = _unb64(payload_b64), _unb64(sig_b64)
397
+ except Exception as exc: # any parse/decode failure means the token is malformed
398
+ raise ApprovalError("Malformed approval token.") from exc
399
+ if not any(_verify_sig(k, sig, payload) for k in _verifier_keys()):
400
+ raise ApprovalError("Invalid approval token signature.")
401
+ try:
402
+ claims = json.loads(payload)
403
+ except json.JSONDecodeError as exc:
404
+ raise ApprovalError("Malformed approval token.") from exc
405
+ if claims.get("iss") != SETTINGS.issuer:
406
+ raise ApprovalError("Approval token issuer does not match this deployment.")
407
+ if claims.get("aud") != SETTINGS.audience:
408
+ raise ApprovalError("Approval token audience does not match this deployment.")
409
+ if claims.get("id") != prop.id:
410
+ raise ApprovalError("Token was minted for a different proposal.")
411
+ if claims.get("hash") != prop.content_hash:
412
+ raise ApprovalError(
413
+ "Invalid approval token (content mismatch - the proposal may have changed)."
414
+ )
415
+ if claims.get("op") != operation:
416
+ raise ApprovalError(f"This token authorizes '{claims.get('op')}', not '{operation}'.")
417
+ now = time.time()
418
+ if now < claims.get("nbf", 0):
419
+ raise ApprovalError("Approval token is not yet valid (nbf in the future).")
420
+ if now > claims.get("exp", 0):
421
+ raise ApprovalError("Approval token has expired; request a fresh approval.")
422
+ if _token_used(claims.get("jti", "")):
423
+ raise ApprovalError("This approval token has already been used (replay refused).")
424
+ if consume:
425
+ _mark_token_used(claims)
426
+ return claims
427
+
428
+
429
+ def _verify_sig(key: Ed25519PublicKey, sig: bytes, payload: bytes) -> bool:
430
+ try:
431
+ key.verify(sig, payload)
432
+ return True
433
+ except InvalidSignature:
434
+ return False
ocm_mcp_server/cli.py ADDED
@@ -0,0 +1,243 @@
1
+ # SPDX-FileCopyrightText: 2026 Sandeep Bazar
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """ocm-mcp: the human side of the approval flow.
5
+
6
+ Run on a trusted terminal, never by the agent:
7
+
8
+ ocm-mcp pending list proposals waiting for approval
9
+ ocm-mcp show <id> print the full manifests of a proposal
10
+ ocm-mcp approve <id> review + mint an approval token
11
+ ocm-mcp reject <id> mark a proposal rejected
12
+ ocm-mcp audit [-n 20] tail the tool-call audit log
13
+ ocm-mcp doctor live read-path smoke test against the hub
14
+ ocm-mcp audit-verify recompute the audit-log hash chain
15
+ ocm-mcp rotate-secret new Ed25519 approval keypair (invalidates all tokens)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+
24
+ from . import approvals
25
+ from .config import SETTINGS
26
+
27
+
28
+ def cmd_pending(_args: argparse.Namespace) -> int:
29
+ pending = approvals.list_proposals(status="pending")
30
+ if not pending:
31
+ print("No pending proposals.")
32
+ return 0
33
+ for p in pending:
34
+ kind = p.action if p.kind == "action" else "manifestwork"
35
+ print(f" {p.id} cluster={p.cluster} kind={kind} name={p.name}")
36
+ print(f" {p.summary}")
37
+ return 0
38
+
39
+
40
+ def cmd_show(args: argparse.Namespace) -> int:
41
+ prop = approvals.load_proposal(args.id)
42
+ print(f"id: {prop.id}")
43
+ print(f"cluster: {prop.cluster}")
44
+ print(f"name: {prop.name}")
45
+ print(f"status: {prop.status}")
46
+ print(f"summary: {prop.summary}")
47
+ if prop.kind == "action":
48
+ print(f"action: {prop.action}")
49
+ print(f"params: {json.dumps(prop.params)}")
50
+ else:
51
+ print("manifests:")
52
+ print(json.dumps(prop.manifests, indent=2))
53
+ return 0
54
+
55
+
56
+ def cmd_approve(args: argparse.Namespace) -> int:
57
+ prop = approvals.load_proposal(args.id)
58
+ if prop.status != "pending":
59
+ print(f"Proposal {prop.id} is '{prop.status}', not pending.", file=sys.stderr)
60
+ return 1
61
+ op = approvals.intended_operation(prop)
62
+ if not args.yes:
63
+ print(f"About to approve a {op.upper()} on cluster '{prop.cluster}': {prop.summary}")
64
+ print(f"Review the full proposal first with: ocm-mcp show {prop.id}")
65
+ answer = input(f"Approve this {op}? [y/N] ").strip().lower()
66
+ if answer != "y":
67
+ print("Not approved.")
68
+ return 1
69
+ token = approvals.mint_token(prop, operation=op)
70
+ print(f"Approval token for this {op} (give this to the agent):")
71
+ print(token)
72
+ return 0
73
+
74
+
75
+ def cmd_reject(args: argparse.Namespace) -> int:
76
+ prop = approvals.load_proposal(args.id)
77
+ if prop.status != "pending":
78
+ print(f"Proposal {prop.id} is '{prop.status}', not pending.", file=sys.stderr)
79
+ return 1
80
+ prop.set_status("rejected")
81
+ print(f"Proposal {prop.id} rejected.")
82
+ return 0
83
+
84
+
85
+ def cmd_audit_verify(_args: argparse.Namespace) -> int:
86
+ from .tracing import verify_audit_chain
87
+
88
+ ok, msg = verify_audit_chain()
89
+ print(msg)
90
+ return 0 if ok else 1
91
+
92
+
93
+ def cmd_audit(args: argparse.Namespace) -> int:
94
+ path = SETTINGS.audit_log
95
+ if not path.exists():
96
+ print("No audit log yet.")
97
+ return 0
98
+ lines = path.read_text().strip().splitlines()[-args.n :]
99
+ for line in lines:
100
+ entry = json.loads(line)
101
+ print(
102
+ f"{entry.get('ts', 0):.0f} {entry.get('tool', '?'):24s} "
103
+ f"{entry.get('outcome', '?'):5s} {entry.get('duration_ms', 0):>6}ms "
104
+ f"{entry.get('error', '')[:80]}"
105
+ )
106
+ return 0
107
+
108
+
109
+ def cmd_rotate_secret(args: argparse.Namespace) -> int:
110
+ if not args.yes:
111
+ print(
112
+ "Rotating the approval key invalidates ALL previously minted approval tokens; "
113
+ "any pending proposal must be approved again."
114
+ )
115
+ if input("Rotate now? [y/N] ").strip().lower() != "y":
116
+ print("Not rotated.")
117
+ return 1
118
+ SETTINGS.rotate_approval_key()
119
+ print("Rotated. Previously minted approval tokens are now invalid.")
120
+ return 0
121
+
122
+
123
+ def cmd_doctor(_args: argparse.Namespace) -> int:
124
+ """Exercise every read tool against the live hub and print a PASS/EMPTY/SKIP/FAIL table.
125
+
126
+ Writes nothing. This is the fastest way to confirm the server can actually see
127
+ a real Open Cluster Management / ACM-MCE fleet before wiring an agent to it.
128
+ """
129
+ from . import ocm # local import: pulls in the kubernetes client only when needed
130
+
131
+ ctx = SETTINGS.hub_context or "(current kubeconfig context)"
132
+ print(f"ocm-mcp doctor - live read-path smoke test\nhub context: {ctx}\n")
133
+
134
+ counts = {"OK": 0, "EMPTY": 0, "SKIP": 0, "FAIL": 0}
135
+
136
+ def run(label: str, fn) -> object | None:
137
+ try:
138
+ result = fn()
139
+ except LookupError as exc:
140
+ # no spoke context configured, or an optional add-on / CRD not installed
141
+ print(f" [SKIP] {label:32s} {str(exc)[:70]}")
142
+ counts["SKIP"] += 1
143
+ return None
144
+ except Exception as exc: # noqa: BLE001 - doctor must never abort on one check
145
+ print(f" [FAIL] {label:32s} {type(exc).__name__}: {str(exc)[:64]}")
146
+ counts["FAIL"] += 1
147
+ return None
148
+ empty = result in ([], {}, None, "")
149
+ status, detail = ("EMPTY", "no items") if empty else ("OK", _detail(result))
150
+ counts[status] += 1
151
+ print(f" [{status:5s}] {label:32s} {detail}")
152
+ return None if empty else result
153
+
154
+ # Hub-level reads (no arguments).
155
+ run("list_clusters", ocm.list_managed_clusters)
156
+ run("list_cluster_sets", ocm.list_cluster_sets)
157
+ run("list_cluster_set_bindings", ocm.list_cluster_set_bindings)
158
+ run("list_cluster_claims", ocm.list_cluster_claims)
159
+ run("list_placements", ocm.list_placements)
160
+ run("list_manifestworkreplicasets", ocm.list_manifestworkreplicasets)
161
+ run("list_cluster_management_addons", ocm.list_cluster_management_addons)
162
+ run("get_addon_health", ocm.addon_health)
163
+ run("list_pending_csrs", ocm.list_pending_csrs)
164
+ run("list_policies", ocm.list_policies)
165
+ if hasattr(ocm, "list_hosted_clusters"):
166
+ run("list_hosted_clusters", ocm.list_hosted_clusters)
167
+ if hasattr(ocm, "list_policy_violations"):
168
+ run("list_policy_violations", ocm.list_policy_violations)
169
+
170
+ # Per-cluster reads: sample the first cluster we can see.
171
+ clusters = []
172
+ try:
173
+ clusters = ocm.list_managed_clusters()
174
+ except Exception: # noqa: BLE001 - sampling is best-effort; failures already reported above
175
+ clusters = []
176
+ if clusters:
177
+ c = clusters[0]["name"]
178
+ print(f"\n sample cluster: {c}")
179
+ run(f"get_cluster({c})", lambda: ocm.get_managed_cluster(c))
180
+ run(f"get_cluster_health({c})", lambda: ocm.cluster_health(c))
181
+ run(f"list_manifestworks({c})", lambda: ocm.list_manifestworks(c))
182
+ run(f"list_addon_placement_scores({c})", lambda: ocm.list_addon_placement_scores(c))
183
+ if hasattr(ocm, "get_cluster_info"):
184
+ run(f"get_cluster_info({c})", lambda: ocm.get_cluster_info(c))
185
+ if hasattr(ocm, "list_addons_for_cluster"):
186
+ run(f"list_addons_for_cluster({c})", lambda: ocm.list_addons_for_cluster(c))
187
+
188
+ print(
189
+ f"\nSummary: {counts['OK']} ok, {counts['EMPTY']} empty, "
190
+ f"{counts['SKIP']} skipped, {counts['FAIL']} failed."
191
+ )
192
+ if counts["FAIL"]:
193
+ print("A FAIL means the hub returned an error for that call - check RBAC and the CRD.")
194
+ return 1 if counts["FAIL"] else 0
195
+
196
+
197
+ def _detail(result: object) -> str:
198
+ if isinstance(result, list):
199
+ return f"{len(result)} item(s)"
200
+ if isinstance(result, dict):
201
+ keys = [k for k in ("name", "cluster", "available", "conditions") if k in result]
202
+ if keys:
203
+ return ", ".join(f"{k}={result[k]}" for k in keys[:2])
204
+ return f"{len(result)} field(s)"
205
+ return str(result)[:60]
206
+
207
+
208
+ def main() -> None:
209
+ parser = argparse.ArgumentParser(prog="ocm-mcp", description=__doc__)
210
+ sub = parser.add_subparsers(dest="command", required=True)
211
+
212
+ sub.add_parser("pending").set_defaults(func=cmd_pending)
213
+
214
+ p_show = sub.add_parser("show")
215
+ p_show.add_argument("id")
216
+ p_show.set_defaults(func=cmd_show)
217
+
218
+ p_approve = sub.add_parser("approve")
219
+ p_approve.add_argument("id")
220
+ p_approve.add_argument("-y", "--yes", action="store_true", help="skip confirmation prompt")
221
+ p_approve.set_defaults(func=cmd_approve)
222
+
223
+ p_reject = sub.add_parser("reject")
224
+ p_reject.add_argument("id")
225
+ p_reject.set_defaults(func=cmd_reject)
226
+
227
+ p_audit = sub.add_parser("audit")
228
+ p_audit.add_argument("-n", type=int, default=20)
229
+ p_audit.set_defaults(func=cmd_audit)
230
+
231
+ sub.add_parser("doctor").set_defaults(func=cmd_doctor)
232
+ sub.add_parser("audit-verify").set_defaults(func=cmd_audit_verify)
233
+
234
+ p_rotate = sub.add_parser("rotate-secret")
235
+ p_rotate.add_argument("-y", "--yes", action="store_true", help="skip confirmation prompt")
236
+ p_rotate.set_defaults(func=cmd_rotate_secret)
237
+
238
+ args = parser.parse_args()
239
+ raise SystemExit(args.func(args))
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()