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.
- ocm_mcp_server/__init__.py +10 -0
- ocm_mcp_server/approvals.py +434 -0
- ocm_mcp_server/cli.py +243 -0
- ocm_mcp_server/config.py +332 -0
- ocm_mcp_server/filelock.py +40 -0
- ocm_mcp_server/guardrails.py +307 -0
- ocm_mcp_server/k8s.py +82 -0
- ocm_mcp_server/metrics.py +84 -0
- ocm_mcp_server/ocm.py +1095 -0
- ocm_mcp_server/server.py +975 -0
- ocm_mcp_server/tracing.py +231 -0
- ocm_mcp_server-0.2.2.dist-info/METADATA +647 -0
- ocm_mcp_server-0.2.2.dist-info/RECORD +16 -0
- ocm_mcp_server-0.2.2.dist-info/WHEEL +4 -0
- ocm_mcp_server-0.2.2.dist-info/entry_points.txt +3 -0
- ocm_mcp_server-0.2.2.dist-info/licenses/LICENSE +202 -0
|
@@ -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()
|