xmemcli 0.0.1__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.
- xmemcli/__init__.py +3 -0
- xmemcli/_envelope.py +24 -0
- xmemcli/_help.py +720 -0
- xmemcli/_instance_schema.py +769 -0
- xmemcli/_login.py +593 -0
- xmemcli/_org_cluster.py +70 -0
- xmemcli/_output.py +274 -0
- xmemcli/_pretty.py +373 -0
- xmemcli/_rc.py +192 -0
- xmemcli/_version.py +4 -0
- xmemcli/_xmem_bridge.py +33 -0
- xmemcli/cli.py +1826 -0
- xmemcli-0.0.1.dist-info/METADATA +22 -0
- xmemcli-0.0.1.dist-info/RECORD +16 -0
- xmemcli-0.0.1.dist-info/WHEEL +4 -0
- xmemcli-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
"""Instance lifecycle and XMD/schema workflow commands for ``xmemcli``.
|
|
2
|
+
|
|
3
|
+
Imported lazily from ``xmem._build_parser`` so ``xmem`` finishes loading before we bind helpers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
from urllib.parse import urlencode
|
|
13
|
+
|
|
14
|
+
from ._output import CliOutput, OutputKind, make_error
|
|
15
|
+
from ._xmem_bridge import curate_instance, http_to_exit, xmem_module
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import yaml as _yaml
|
|
19
|
+
except ImportError:
|
|
20
|
+
_yaml = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _yaml_dump_or_json(value: Any) -> str:
|
|
24
|
+
if _yaml is not None:
|
|
25
|
+
return _yaml.safe_dump(value, sort_keys=False, default_flow_style=False)
|
|
26
|
+
return json.dumps(value, indent=2) + "\n"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _infer_schema_type(path: str | None, explicit: str | None) -> str:
|
|
30
|
+
if explicit:
|
|
31
|
+
return explicit
|
|
32
|
+
if path and Path(path).suffix.lower() == ".json":
|
|
33
|
+
return "json"
|
|
34
|
+
return "yaml"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_instance_schema_payload(schema_text: str, schema_type: str) -> dict[str, Any]:
|
|
38
|
+
if schema_type == "json":
|
|
39
|
+
return {"json_schema": {"value": schema_text}}
|
|
40
|
+
return {"yml": {"value": schema_text}}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _extract_limit_message(body: str) -> tuple[str, str] | None:
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(body)
|
|
46
|
+
err = (data.get("errors") or [{}])[0]
|
|
47
|
+
if err.get("console_url"):
|
|
48
|
+
return (err.get("message") or "instance limit reached"), err["console_url"]
|
|
49
|
+
except (json.JSONDecodeError, KeyError, IndexError, TypeError):
|
|
50
|
+
# Envelope shape varies across API versions; ignore malformed bodies.
|
|
51
|
+
pass
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_instance_id(args: argparse.Namespace) -> str | None:
|
|
56
|
+
m = xmem_module()
|
|
57
|
+
explicit = getattr(args, "instance_id_arg", None)
|
|
58
|
+
if explicit:
|
|
59
|
+
return explicit
|
|
60
|
+
if args.instance_id:
|
|
61
|
+
return args.instance_id
|
|
62
|
+
return os.environ.get(m.ENV_INSTANCE_ID)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _require_instance_id(args: argparse.Namespace) -> tuple[str | None, tuple[CliOutput, int] | None]:
|
|
66
|
+
m = xmem_module()
|
|
67
|
+
instance_id = _resolve_instance_id(args)
|
|
68
|
+
if not instance_id:
|
|
69
|
+
return (
|
|
70
|
+
None,
|
|
71
|
+
(
|
|
72
|
+
make_error(
|
|
73
|
+
f"instance id required (positional arg or ${m.ENV_INSTANCE_ID})",
|
|
74
|
+
stage="usage",
|
|
75
|
+
),
|
|
76
|
+
m.EXIT_USAGE,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
return instance_id, None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_schema_mutation_payload(
|
|
83
|
+
args: argparse.Namespace,
|
|
84
|
+
) -> tuple[dict[str, Any] | None, tuple[CliOutput, int] | None]:
|
|
85
|
+
if not args.schema_file:
|
|
86
|
+
return None, (make_error("--schema-file is required", stage="usage"), xmem_module().EXIT_USAGE)
|
|
87
|
+
try:
|
|
88
|
+
schema_text = Path(args.schema_file).read_text(encoding="utf-8")
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
return None, (make_error(f"cannot read {args.schema_file}: {exc}", stage="usage"), xmem_module().EXIT_USAGE)
|
|
91
|
+
schema_type = _infer_schema_type(args.schema_file, getattr(args, "schema_type", None))
|
|
92
|
+
payload: dict[str, Any] = {
|
|
93
|
+
"instance_schema": _build_instance_schema_payload(schema_text, schema_type),
|
|
94
|
+
"confirm_destructive": bool(getattr(args, "confirm_destructive", False)),
|
|
95
|
+
}
|
|
96
|
+
migration_plan = getattr(args, "migration_plan", None)
|
|
97
|
+
if migration_plan:
|
|
98
|
+
try:
|
|
99
|
+
payload["migration_plan"] = json.loads(Path(migration_plan).read_text(encoding="utf-8"))
|
|
100
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
101
|
+
return None, (make_error(f"cannot read migration_plan: {exc}", stage="usage"), xmem_module().EXIT_USAGE)
|
|
102
|
+
return payload, None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_schema_mutation_error_body(body: str) -> tuple[str, str, dict[str, Any]]:
|
|
106
|
+
"""Return (error_type, message, details) from a schema apply/dry-run 422 body."""
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(body)
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
return "", body, {}
|
|
111
|
+
|
|
112
|
+
if isinstance(data, dict) and data.get("error_type"):
|
|
113
|
+
raw_details = data.get("details")
|
|
114
|
+
details: dict[str, Any] = raw_details if isinstance(raw_details, dict) else {}
|
|
115
|
+
return (
|
|
116
|
+
str(data.get("error_type") or ""),
|
|
117
|
+
str(data.get("error_message") or data.get("message") or body),
|
|
118
|
+
details,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
err = (data.get("errors") or [{}])[0] if isinstance(data, dict) else {}
|
|
122
|
+
if isinstance(err, dict):
|
|
123
|
+
raw_details = err.get("details")
|
|
124
|
+
details = raw_details if isinstance(raw_details, dict) else {}
|
|
125
|
+
return (
|
|
126
|
+
str(err.get("code") or err.get("error_type") or ""),
|
|
127
|
+
str(err.get("message") or err.get("error_message") or body),
|
|
128
|
+
details,
|
|
129
|
+
)
|
|
130
|
+
return "", body, {}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class _SchemaApiError(Protocol):
|
|
134
|
+
status: int
|
|
135
|
+
body: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _handle_schema_mutation_error(e: _SchemaApiError, *, instance_id: str) -> tuple[CliOutput, int]:
|
|
139
|
+
m = xmem_module()
|
|
140
|
+
if e.status == 422:
|
|
141
|
+
error_type, msg, details = _parse_schema_mutation_error_body(e.body)
|
|
142
|
+
code = error_type.lower()
|
|
143
|
+
|
|
144
|
+
if code == "non_additive_change_requires_plan":
|
|
145
|
+
return (
|
|
146
|
+
make_error(
|
|
147
|
+
f"non-additive change requires --migration-plan "
|
|
148
|
+
f"(produce one with `{m.CLI_NAME} xmd enhance --plan-output`)",
|
|
149
|
+
status=e.status,
|
|
150
|
+
stage="plan_required",
|
|
151
|
+
),
|
|
152
|
+
m.EXIT_HTTP_ERROR,
|
|
153
|
+
)
|
|
154
|
+
if code == "plan_validation_failed":
|
|
155
|
+
detail_bits: list[str] = []
|
|
156
|
+
for key in ("uncovered_non_additive", "uncovered_additive_ops", "missing_additive_ops"):
|
|
157
|
+
items = details.get(key)
|
|
158
|
+
if isinstance(items, list) and items:
|
|
159
|
+
detail_bits.append(f"{key}={len(items)}")
|
|
160
|
+
suffix = f" ({', '.join(detail_bits)})" if detail_bits else ""
|
|
161
|
+
return (
|
|
162
|
+
make_error(f"{msg}{suffix}", status=e.status, stage="validate"),
|
|
163
|
+
m.EXIT_HTTP_ERROR,
|
|
164
|
+
)
|
|
165
|
+
if code == "destructive_confirmation_required" or (
|
|
166
|
+
"destructive" in code and "confirmation" in code
|
|
167
|
+
):
|
|
168
|
+
return (
|
|
169
|
+
make_error(
|
|
170
|
+
"destructive change requires --confirm-destructive",
|
|
171
|
+
status=e.status,
|
|
172
|
+
stage="destructive",
|
|
173
|
+
),
|
|
174
|
+
m.EXIT_HTTP_ERROR,
|
|
175
|
+
)
|
|
176
|
+
# Legacy envelopes: only map explicit plan-required codes, not substrings in details.
|
|
177
|
+
if code in ("non_additive", "non_additive_change") or (
|
|
178
|
+
not error_type and "non_additive_change_requires" in msg.lower()
|
|
179
|
+
):
|
|
180
|
+
return (
|
|
181
|
+
make_error(
|
|
182
|
+
f"non-additive change requires --migration-plan "
|
|
183
|
+
f"(produce one with `{m.CLI_NAME} xmd enhance --plan-output`)",
|
|
184
|
+
status=e.status,
|
|
185
|
+
stage="plan_required",
|
|
186
|
+
),
|
|
187
|
+
m.EXIT_HTTP_ERROR,
|
|
188
|
+
)
|
|
189
|
+
if "destructive" in code or (not error_type and "destructive" in msg.lower()):
|
|
190
|
+
return (
|
|
191
|
+
make_error(
|
|
192
|
+
"destructive change requires --confirm-destructive",
|
|
193
|
+
status=e.status,
|
|
194
|
+
stage="destructive",
|
|
195
|
+
),
|
|
196
|
+
m.EXIT_HTTP_ERROR,
|
|
197
|
+
)
|
|
198
|
+
return (make_error(msg, status=e.status, stage="validate"), m.EXIT_HTTP_ERROR)
|
|
199
|
+
if e.status == 404:
|
|
200
|
+
return (
|
|
201
|
+
make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
|
|
202
|
+
m.EXIT_HTTP_ERROR,
|
|
203
|
+
)
|
|
204
|
+
if e.status in (401, 403):
|
|
205
|
+
return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
|
|
206
|
+
return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _get_with_query(url: str, api_key: str, params: dict[str, Any], *, timeout_s: float = 30.0) -> dict[str, Any]:
|
|
210
|
+
m = xmem_module()
|
|
211
|
+
query = urlencode({k: v for k, v in params.items() if v is not None})
|
|
212
|
+
full_url = f"{url}?{query}" if query else url
|
|
213
|
+
return m._get(full_url, api_key, timeout_s=timeout_s)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cmd_instance_get(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
217
|
+
m = xmem_module()
|
|
218
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
219
|
+
base = m._api_url(args)
|
|
220
|
+
instance_id = args.instance_id_arg
|
|
221
|
+
|
|
222
|
+
explicit_cluster = getattr(args, "cluster", None)
|
|
223
|
+
if explicit_cluster:
|
|
224
|
+
cluster_ids = [explicit_cluster]
|
|
225
|
+
else:
|
|
226
|
+
try:
|
|
227
|
+
clusters_env = m._get(f"{base}/clusters?verbose=true", api_key)
|
|
228
|
+
except m.ApiError as e:
|
|
229
|
+
return (make_error(f"/clusters failed: {e}", status=e.status), http_to_exit(e.status))
|
|
230
|
+
cluster_ids = [c["id"] for c in (clusters_env.get("items") or []) if isinstance(c, dict) and c.get("id")]
|
|
231
|
+
|
|
232
|
+
last_auth_error: m.ApiError | None = None
|
|
233
|
+
for cid in cluster_ids:
|
|
234
|
+
try:
|
|
235
|
+
envelope = m._get(f"{base}/clusters/{cid}/instances/{instance_id}", api_key)
|
|
236
|
+
except m.ApiError as e:
|
|
237
|
+
if e.status == 404:
|
|
238
|
+
continue
|
|
239
|
+
if e.status in (401, 403):
|
|
240
|
+
last_auth_error = e
|
|
241
|
+
continue
|
|
242
|
+
return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
|
|
243
|
+
item = m._unwrap_items(envelope)
|
|
244
|
+
return (CliOutput(OutputKind.INSTANCE_GET, curate_instance(item, args.verbose)), m.EXIT_OK)
|
|
245
|
+
|
|
246
|
+
if last_auth_error is not None:
|
|
247
|
+
return (
|
|
248
|
+
make_error(
|
|
249
|
+
f"instance {instance_id} exists in a cluster this key cannot access",
|
|
250
|
+
status=last_auth_error.status,
|
|
251
|
+
stage="auth",
|
|
252
|
+
),
|
|
253
|
+
m.EXIT_AUTH_ERROR,
|
|
254
|
+
)
|
|
255
|
+
return (
|
|
256
|
+
make_error(
|
|
257
|
+
f"instance {instance_id} not found in any cluster visible to this key",
|
|
258
|
+
status=404,
|
|
259
|
+
stage="request",
|
|
260
|
+
),
|
|
261
|
+
m.EXIT_HTTP_ERROR,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _require_interactive_yes(prompt: str) -> tuple[bool, str | None]:
|
|
266
|
+
"""Return (True, None) when the user typed ``yes`` on a real TTY."""
|
|
267
|
+
if not sys.stdin.isatty():
|
|
268
|
+
return False, "instance delete requires an interactive terminal; type yes when prompted (do not pipe yes)"
|
|
269
|
+
try:
|
|
270
|
+
# DELIBERATELY print: confirmation prompt must not pollute stdout in machine mode.
|
|
271
|
+
print(prompt, end="", file=sys.stderr, flush=True)
|
|
272
|
+
answer = input().strip().lower()
|
|
273
|
+
except EOFError:
|
|
274
|
+
return False, "instance delete aborted (confirmation required; type yes)"
|
|
275
|
+
if answer != "yes":
|
|
276
|
+
return False, "instance delete aborted (confirmation required; type yes)"
|
|
277
|
+
return True, None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def cmd_instance_delete(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
281
|
+
"""Delete an instance after interactive confirmation.
|
|
282
|
+
|
|
283
|
+
Partial by design: human operators at a real TTY must type ``yes``; piped or
|
|
284
|
+
non-interactive stdin is refused. Agents, harnesses, and automation must use
|
|
285
|
+
``DELETE /instances/{id}`` over HTTP instead — see ``cli/HARNESS-FIRST-TEST.md``
|
|
286
|
+
step 41.
|
|
287
|
+
"""
|
|
288
|
+
m = xmem_module()
|
|
289
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
290
|
+
base = m._api_url(args)
|
|
291
|
+
|
|
292
|
+
explicit_id = getattr(args, "instance_id_arg", None)
|
|
293
|
+
if explicit_id:
|
|
294
|
+
instance_id = str(explicit_id)
|
|
295
|
+
else:
|
|
296
|
+
_, resolved = m._require(args, need_instance=True)
|
|
297
|
+
instance_id = str(resolved)
|
|
298
|
+
|
|
299
|
+
name = instance_id
|
|
300
|
+
try:
|
|
301
|
+
lookup = m._get(f"{base.rstrip('/')}/instances/{instance_id}", api_key)
|
|
302
|
+
item = m._unwrap_items(lookup)
|
|
303
|
+
if isinstance(item, dict) and item.get("name"):
|
|
304
|
+
name = str(item["name"])
|
|
305
|
+
except m.ApiError:
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
ok, err = _require_interactive_yes(
|
|
309
|
+
f"Delete instance {instance_id} ({name})? Type yes to confirm: "
|
|
310
|
+
)
|
|
311
|
+
if not ok:
|
|
312
|
+
return (make_error(err or "instance delete aborted", stage="usage"), m.EXIT_USAGE)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
envelope = m._delete(f"{base.rstrip('/')}/instances/{instance_id}", api_key)
|
|
316
|
+
except m.ApiError as e:
|
|
317
|
+
if e.status in (401, 403):
|
|
318
|
+
return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
|
|
319
|
+
if e.status == 404:
|
|
320
|
+
return (
|
|
321
|
+
make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
|
|
322
|
+
m.EXIT_HTTP_ERROR,
|
|
323
|
+
)
|
|
324
|
+
return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
|
|
325
|
+
|
|
326
|
+
deleted_id = instance_id
|
|
327
|
+
if isinstance(envelope, dict):
|
|
328
|
+
items = envelope.get("items")
|
|
329
|
+
if isinstance(items, list) and items and isinstance(items[0], dict) and items[0].get("id"):
|
|
330
|
+
deleted_id = str(items[0]["id"])
|
|
331
|
+
elif envelope.get("id"):
|
|
332
|
+
deleted_id = str(envelope["id"])
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
CliOutput(
|
|
336
|
+
OutputKind.INSTANCE_DELETE,
|
|
337
|
+
{"instance_id": deleted_id, "name": name, "status": "deleted"},
|
|
338
|
+
),
|
|
339
|
+
m.EXIT_OK,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def cmd_instance_create(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
344
|
+
m = xmem_module()
|
|
345
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
346
|
+
base = m._api_url(args)
|
|
347
|
+
cluster_id, cluster_err = m._resolve_cluster_id(args, base, api_key)
|
|
348
|
+
if cluster_err is not None:
|
|
349
|
+
return cluster_err
|
|
350
|
+
if not cluster_id:
|
|
351
|
+
return (make_error("could not resolve cluster_id", stage="usage"), m.EXIT_USAGE)
|
|
352
|
+
|
|
353
|
+
if args.schema_stdin and args.schema_file:
|
|
354
|
+
return (
|
|
355
|
+
make_error("pass either --schema-file or --schema-stdin, not both", stage="usage"),
|
|
356
|
+
m.EXIT_USAGE,
|
|
357
|
+
)
|
|
358
|
+
if args.schema_stdin:
|
|
359
|
+
schema_text = sys.stdin.read()
|
|
360
|
+
schema_type = _infer_schema_type(None, args.schema_type)
|
|
361
|
+
elif args.schema_file:
|
|
362
|
+
try:
|
|
363
|
+
schema_text = Path(args.schema_file).read_text(encoding="utf-8")
|
|
364
|
+
except OSError as exc:
|
|
365
|
+
return (make_error(f"cannot read {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
|
|
366
|
+
schema_type = _infer_schema_type(args.schema_file, args.schema_type)
|
|
367
|
+
else:
|
|
368
|
+
return (make_error("--schema-file or --schema-stdin is required", stage="usage"), m.EXIT_USAGE)
|
|
369
|
+
|
|
370
|
+
payload: dict[str, Any] = {
|
|
371
|
+
"name": args.name,
|
|
372
|
+
"instance_schema": _build_instance_schema_payload(schema_text, schema_type),
|
|
373
|
+
}
|
|
374
|
+
if args.description:
|
|
375
|
+
payload["description"] = args.description
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
envelope = m._post(
|
|
379
|
+
f"{base}/clusters/{cluster_id}/instances",
|
|
380
|
+
payload,
|
|
381
|
+
api_key,
|
|
382
|
+
timeout_s=180.0,
|
|
383
|
+
)
|
|
384
|
+
except m.ApiError as e:
|
|
385
|
+
if e.status == 403:
|
|
386
|
+
limit = _extract_limit_message(e.body)
|
|
387
|
+
if limit is not None:
|
|
388
|
+
msg, console_url = limit
|
|
389
|
+
return (
|
|
390
|
+
make_error(f"{msg} (see {console_url})", status=e.status, stage="limit"),
|
|
391
|
+
m.EXIT_HTTP_ERROR,
|
|
392
|
+
)
|
|
393
|
+
return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
|
|
394
|
+
if e.status == 401:
|
|
395
|
+
return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
|
|
396
|
+
return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
|
|
397
|
+
|
|
398
|
+
item = m._unwrap_items(envelope)
|
|
399
|
+
curated: dict[str, Any] = {
|
|
400
|
+
"instance_id": item.get("id"),
|
|
401
|
+
"name": item.get("name"),
|
|
402
|
+
"console_url": item.get("console_url"),
|
|
403
|
+
}
|
|
404
|
+
if args.verbose:
|
|
405
|
+
for key in ("trace_id", "data_schema", "cluster_id"):
|
|
406
|
+
if key in item and item[key] is not None:
|
|
407
|
+
curated[key] = item[key]
|
|
408
|
+
return (CliOutput(OutputKind.INSTANCE_CREATE, curated), m.EXIT_OK)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def cmd_xmd_generate(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
412
|
+
m = xmem_module()
|
|
413
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
414
|
+
base = m._api_url(args)
|
|
415
|
+
cluster_id, cluster_err = m._resolve_cluster_id(args, base, api_key)
|
|
416
|
+
if cluster_err is not None:
|
|
417
|
+
return cluster_err
|
|
418
|
+
if not cluster_id:
|
|
419
|
+
return (make_error("could not resolve cluster_id", stage="usage"), m.EXIT_USAGE)
|
|
420
|
+
payload = {"schema_description": args.description}
|
|
421
|
+
try:
|
|
422
|
+
envelope = m._post(
|
|
423
|
+
f"{base}/clusters/{cluster_id}/instances/generate_schema",
|
|
424
|
+
payload,
|
|
425
|
+
api_key,
|
|
426
|
+
timeout_s=120.0,
|
|
427
|
+
)
|
|
428
|
+
except m.ApiError as e:
|
|
429
|
+
if e.status in (401, 403):
|
|
430
|
+
return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
|
|
431
|
+
return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
|
|
432
|
+
item = m._unwrap_items(envelope)
|
|
433
|
+
schema = item.get("data_schema") if isinstance(item, dict) else None
|
|
434
|
+
if args.output and schema is not None:
|
|
435
|
+
Path(args.output).write_text(_yaml_dump_or_json(schema), encoding="utf-8")
|
|
436
|
+
return (CliOutput(OutputKind.XMD_SCHEMA, item), m.EXIT_OK)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def cmd_xmd_enhance(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
440
|
+
m = xmem_module()
|
|
441
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
442
|
+
base = m._api_url(args)
|
|
443
|
+
cluster_id, cluster_err = m._resolve_cluster_id(args, base, api_key)
|
|
444
|
+
if cluster_err is not None:
|
|
445
|
+
return cluster_err
|
|
446
|
+
if not cluster_id:
|
|
447
|
+
return (make_error("could not resolve cluster_id", stage="usage"), m.EXIT_USAGE)
|
|
448
|
+
|
|
449
|
+
from_instance = bool(getattr(args, "from_instance", False))
|
|
450
|
+
schema_file = getattr(args, "schema_file", None)
|
|
451
|
+
if from_instance and schema_file:
|
|
452
|
+
return (
|
|
453
|
+
make_error("pass either --from-instance or a schema_file, not both", stage="usage"),
|
|
454
|
+
m.EXIT_USAGE,
|
|
455
|
+
)
|
|
456
|
+
if from_instance:
|
|
457
|
+
instance_id, id_err = _require_instance_id(args)
|
|
458
|
+
if id_err is not None:
|
|
459
|
+
return id_err
|
|
460
|
+
if instance_id is None:
|
|
461
|
+
return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
|
|
462
|
+
try:
|
|
463
|
+
envelope = m._get(f"{base}/instances/{instance_id}/schema", api_key)
|
|
464
|
+
except m.ApiError as e:
|
|
465
|
+
if e.status == 404:
|
|
466
|
+
return (
|
|
467
|
+
make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
|
|
468
|
+
m.EXIT_HTTP_ERROR,
|
|
469
|
+
)
|
|
470
|
+
if e.status in (401, 403):
|
|
471
|
+
return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
|
|
472
|
+
return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
|
|
473
|
+
item = m._unwrap_items(envelope)
|
|
474
|
+
schema = item.get("data_schema") if isinstance(item, dict) else None
|
|
475
|
+
if schema is None:
|
|
476
|
+
return (make_error("instance has no live schema to enhance", stage="usage"), m.EXIT_USAGE)
|
|
477
|
+
current_yaml = _yaml_dump_or_json(schema)
|
|
478
|
+
elif schema_file:
|
|
479
|
+
try:
|
|
480
|
+
current_yaml = Path(schema_file).read_text(encoding="utf-8")
|
|
481
|
+
except OSError as exc:
|
|
482
|
+
return (make_error(f"cannot read {schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
|
|
483
|
+
else:
|
|
484
|
+
return (
|
|
485
|
+
make_error(
|
|
486
|
+
"schema_file or --from-instance is required "
|
|
487
|
+
"(MCP enhance_schema uses the bound instance; pass --from-instance with an instance id)",
|
|
488
|
+
stage="usage",
|
|
489
|
+
),
|
|
490
|
+
m.EXIT_USAGE,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
payload = {"schema_description": args.description, "current_yml_schema": current_yaml}
|
|
494
|
+
try:
|
|
495
|
+
envelope = m._post(
|
|
496
|
+
f"{base}/clusters/{cluster_id}/instances/generate_schema",
|
|
497
|
+
payload,
|
|
498
|
+
api_key,
|
|
499
|
+
timeout_s=120.0,
|
|
500
|
+
)
|
|
501
|
+
except m.ApiError as e:
|
|
502
|
+
if e.status in (401, 403):
|
|
503
|
+
return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
|
|
504
|
+
return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
|
|
505
|
+
item = m._unwrap_items(envelope)
|
|
506
|
+
migration_plan = item.get("migration_plan") if isinstance(item, dict) else None
|
|
507
|
+
if args.output and isinstance(item, dict) and item.get("data_schema") is not None:
|
|
508
|
+
Path(args.output).write_text(_yaml_dump_or_json(item["data_schema"]), encoding="utf-8")
|
|
509
|
+
if args.plan_output and isinstance(item, dict):
|
|
510
|
+
Path(args.plan_output).write_text(json.dumps(item.get("migration_plan") or {}, indent=2), encoding="utf-8")
|
|
511
|
+
return (CliOutput(OutputKind.XMD_ENHANCE, item), m.EXIT_OK)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _xmd_validate_impl(
|
|
515
|
+
args: argparse.Namespace,
|
|
516
|
+
*,
|
|
517
|
+
format_hint: str | None,
|
|
518
|
+
) -> tuple[CliOutput, int]:
|
|
519
|
+
m = xmem_module()
|
|
520
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
521
|
+
base = m._api_url(args)
|
|
522
|
+
cluster_id, cluster_err = m._resolve_cluster_id(args, base, api_key)
|
|
523
|
+
if cluster_err is not None:
|
|
524
|
+
return cluster_err
|
|
525
|
+
if not cluster_id:
|
|
526
|
+
return (make_error("could not resolve cluster_id", stage="usage"), m.EXIT_USAGE)
|
|
527
|
+
try:
|
|
528
|
+
text = Path(args.schema_file).read_text(encoding="utf-8")
|
|
529
|
+
except OSError as exc:
|
|
530
|
+
return (make_error(f"cannot read {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
|
|
531
|
+
|
|
532
|
+
use_json = format_hint == "json" or (
|
|
533
|
+
format_hint is None and Path(args.schema_file).suffix.lower() == ".json"
|
|
534
|
+
)
|
|
535
|
+
if use_json:
|
|
536
|
+
try:
|
|
537
|
+
js = json.loads(text)
|
|
538
|
+
except json.JSONDecodeError as exc:
|
|
539
|
+
return (make_error(f"invalid JSON in {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
|
|
540
|
+
url_path = f"/clusters/{cluster_id}/instances/validate_json_schema"
|
|
541
|
+
body: dict[str, Any] = {"json_schema": js}
|
|
542
|
+
else:
|
|
543
|
+
url_path = f"/clusters/{cluster_id}/instances/validate_yaml_schema"
|
|
544
|
+
body = {"yml_schema": text}
|
|
545
|
+
try:
|
|
546
|
+
envelope = m._post(f"{base}{url_path}", body, api_key, timeout_s=30.0)
|
|
547
|
+
except m.ApiError as e:
|
|
548
|
+
if e.status in (401, 403):
|
|
549
|
+
return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
|
|
550
|
+
return (make_error(str(e), status=e.status, stage="validate"), m.EXIT_HTTP_ERROR)
|
|
551
|
+
item = m._unwrap_items(envelope)
|
|
552
|
+
return (CliOutput(OutputKind.XMD_SCHEMA, item), m.EXIT_OK)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def cmd_xmd_validate(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
556
|
+
return _xmd_validate_impl(args, format_hint=None)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def cmd_xmd_validate_yaml(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
560
|
+
return _xmd_validate_impl(args, format_hint="yaml")
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def cmd_xmd_validate_json(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
564
|
+
return _xmd_validate_impl(args, format_hint="json")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def cmd_xmd_convert(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
568
|
+
"""Convert JSON Schema to XMD YAML via ``POST …/validate_json_schema``."""
|
|
569
|
+
m = xmem_module()
|
|
570
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
571
|
+
base = m._api_url(args)
|
|
572
|
+
cluster_id, cluster_err = m._resolve_cluster_id(args, base, api_key)
|
|
573
|
+
if cluster_err is not None:
|
|
574
|
+
return cluster_err
|
|
575
|
+
if not cluster_id:
|
|
576
|
+
return (make_error("could not resolve cluster_id", stage="usage"), m.EXIT_USAGE)
|
|
577
|
+
|
|
578
|
+
input_file = Path(args.schema_file)
|
|
579
|
+
if not input_file.is_file():
|
|
580
|
+
return (make_error(f"cannot read {args.schema_file}: file not found", stage="usage"), m.EXIT_USAGE)
|
|
581
|
+
try:
|
|
582
|
+
json_schema = json.loads(input_file.read_text(encoding="utf-8"))
|
|
583
|
+
except json.JSONDecodeError as exc:
|
|
584
|
+
return (make_error(f"invalid JSON in {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
|
|
585
|
+
except OSError as exc:
|
|
586
|
+
return (make_error(f"cannot read {args.schema_file}: {exc}", stage="usage"), m.EXIT_USAGE)
|
|
587
|
+
|
|
588
|
+
root_object = getattr(args, "root_object", None) or "MainObject"
|
|
589
|
+
body: dict[str, Any] = {"json_schema": json_schema, "root_object_name": root_object}
|
|
590
|
+
try:
|
|
591
|
+
envelope = m._post(
|
|
592
|
+
f"{base}/clusters/{cluster_id}/instances/validate_json_schema",
|
|
593
|
+
body,
|
|
594
|
+
api_key,
|
|
595
|
+
timeout_s=30.0,
|
|
596
|
+
)
|
|
597
|
+
except m.ApiError as e:
|
|
598
|
+
if e.status in (401, 403):
|
|
599
|
+
return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
|
|
600
|
+
return (make_error(str(e), status=e.status, stage="convert"), m.EXIT_HTTP_ERROR)
|
|
601
|
+
|
|
602
|
+
item = m._unwrap_items(envelope)
|
|
603
|
+
data_schema = item.get("data_schema") if isinstance(item, dict) else None
|
|
604
|
+
if not isinstance(data_schema, dict):
|
|
605
|
+
return (make_error("conversion returned no data_schema", stage="convert"), m.EXIT_HTTP_ERROR)
|
|
606
|
+
|
|
607
|
+
output_file = Path(args.output) if args.output else input_file.with_suffix(".yml")
|
|
608
|
+
try:
|
|
609
|
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
610
|
+
output_file.write_text(_yaml_dump_or_json(data_schema), encoding="utf-8")
|
|
611
|
+
except OSError as exc:
|
|
612
|
+
return (make_error(f"cannot write {output_file}: {exc}", stage="convert"), m.EXIT_HTTP_ERROR)
|
|
613
|
+
|
|
614
|
+
message = f"Successfully converted '{args.schema_file}' to '{output_file}'\n"
|
|
615
|
+
return (CliOutput(OutputKind.XMD_CONVERT, {"message": message}), m.EXIT_OK)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def cmd_schema_get(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
619
|
+
m = xmem_module()
|
|
620
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
621
|
+
instance_id, id_err = _require_instance_id(args)
|
|
622
|
+
if id_err is not None:
|
|
623
|
+
return id_err
|
|
624
|
+
if instance_id is None:
|
|
625
|
+
return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
|
|
626
|
+
base = m._api_url(args)
|
|
627
|
+
try:
|
|
628
|
+
envelope = m._get(f"{base}/instances/{instance_id}/schema", api_key)
|
|
629
|
+
except m.ApiError as e:
|
|
630
|
+
if e.status == 404:
|
|
631
|
+
return (
|
|
632
|
+
make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
|
|
633
|
+
m.EXIT_HTTP_ERROR,
|
|
634
|
+
)
|
|
635
|
+
if e.status in (401, 403):
|
|
636
|
+
return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
|
|
637
|
+
return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
|
|
638
|
+
item = m._unwrap_items(envelope)
|
|
639
|
+
schema = item.get("data_schema") if isinstance(item, dict) else None
|
|
640
|
+
if args.output and schema is not None:
|
|
641
|
+
Path(args.output).write_text(_yaml_dump_or_json(schema), encoding="utf-8")
|
|
642
|
+
return (CliOutput(OutputKind.XMD_SCHEMA, item), m.EXIT_OK)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def cmd_schema_update(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
646
|
+
m = xmem_module()
|
|
647
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
648
|
+
instance_id, id_err = _require_instance_id(args)
|
|
649
|
+
if id_err is not None:
|
|
650
|
+
return id_err
|
|
651
|
+
if instance_id is None:
|
|
652
|
+
return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
|
|
653
|
+
payload, payload_err = _read_schema_mutation_payload(args)
|
|
654
|
+
if payload_err is not None:
|
|
655
|
+
return payload_err
|
|
656
|
+
if payload is None:
|
|
657
|
+
return (make_error("--schema-file is required", stage="usage"), m.EXIT_USAGE)
|
|
658
|
+
base = m._api_url(args)
|
|
659
|
+
try:
|
|
660
|
+
envelope = m._put(
|
|
661
|
+
f"{base}/instances/{instance_id}/schema",
|
|
662
|
+
payload,
|
|
663
|
+
api_key,
|
|
664
|
+
timeout_s=300.0,
|
|
665
|
+
)
|
|
666
|
+
except m.ApiError as e:
|
|
667
|
+
return _handle_schema_mutation_error(e, instance_id=instance_id)
|
|
668
|
+
item = m._unwrap_items(envelope)
|
|
669
|
+
return (CliOutput(OutputKind.SCHEMA_UPDATE, item), m.EXIT_OK)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def cmd_schema_dry_run(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
673
|
+
m = xmem_module()
|
|
674
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
675
|
+
instance_id, id_err = _require_instance_id(args)
|
|
676
|
+
if id_err is not None:
|
|
677
|
+
return id_err
|
|
678
|
+
if instance_id is None:
|
|
679
|
+
return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
|
|
680
|
+
payload, payload_err = _read_schema_mutation_payload(args)
|
|
681
|
+
if payload_err is not None:
|
|
682
|
+
return payload_err
|
|
683
|
+
if payload is None:
|
|
684
|
+
return (make_error("--schema-file is required", stage="usage"), m.EXIT_USAGE)
|
|
685
|
+
base = m._api_url(args)
|
|
686
|
+
try:
|
|
687
|
+
envelope = m._post(
|
|
688
|
+
f"{base}/instances/{instance_id}/migrations/dry_run",
|
|
689
|
+
payload,
|
|
690
|
+
api_key,
|
|
691
|
+
timeout_s=120.0,
|
|
692
|
+
)
|
|
693
|
+
except m.ApiError as e:
|
|
694
|
+
return _handle_schema_mutation_error(e, instance_id=instance_id)
|
|
695
|
+
item = m._unwrap_items(envelope)
|
|
696
|
+
return (CliOutput(OutputKind.SCHEMA_DRY_RUN, item), m.EXIT_OK)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def cmd_schema_migrations_list(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
700
|
+
m = xmem_module()
|
|
701
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
702
|
+
instance_id, id_err = _require_instance_id(args)
|
|
703
|
+
if id_err is not None:
|
|
704
|
+
return id_err
|
|
705
|
+
if instance_id is None:
|
|
706
|
+
return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
|
|
707
|
+
base = m._api_url(args)
|
|
708
|
+
params: dict[str, Any] = {
|
|
709
|
+
"limit": getattr(args, "limit", 50),
|
|
710
|
+
"include_yaml": str(bool(getattr(args, "include_yaml", False))).lower(),
|
|
711
|
+
}
|
|
712
|
+
before_id = getattr(args, "before_id", None)
|
|
713
|
+
if before_id:
|
|
714
|
+
params["before_id"] = before_id
|
|
715
|
+
try:
|
|
716
|
+
envelope = _get_with_query(
|
|
717
|
+
f"{base}/instances/{instance_id}/migrations",
|
|
718
|
+
api_key,
|
|
719
|
+
params,
|
|
720
|
+
)
|
|
721
|
+
except m.ApiError as e:
|
|
722
|
+
if e.status == 404:
|
|
723
|
+
return (
|
|
724
|
+
make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
|
|
725
|
+
m.EXIT_HTTP_ERROR,
|
|
726
|
+
)
|
|
727
|
+
if e.status in (401, 403):
|
|
728
|
+
return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
|
|
729
|
+
return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
|
|
730
|
+
item = m._unwrap_items(envelope)
|
|
731
|
+
return (CliOutput(OutputKind.SCHEMA_MIGRATIONS, item), m.EXIT_OK)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def cmd_schema_migrations_get(args: argparse.Namespace) -> tuple[CliOutput, int]:
|
|
735
|
+
m = xmem_module()
|
|
736
|
+
api_key, _ = m._require(args, need_instance=False)
|
|
737
|
+
instance_id, id_err = _require_instance_id(args)
|
|
738
|
+
if id_err is not None:
|
|
739
|
+
return id_err
|
|
740
|
+
if instance_id is None:
|
|
741
|
+
return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
|
|
742
|
+
migration_id = getattr(args, "migration_id_arg", None)
|
|
743
|
+
if not migration_id:
|
|
744
|
+
return (make_error("migration_id is required", stage="usage"), m.EXIT_USAGE)
|
|
745
|
+
base = m._api_url(args)
|
|
746
|
+
params: dict[str, Any] = {
|
|
747
|
+
"include_yaml": str(bool(getattr(args, "include_yaml", False))).lower(),
|
|
748
|
+
}
|
|
749
|
+
try:
|
|
750
|
+
envelope = _get_with_query(
|
|
751
|
+
f"{base}/instances/{instance_id}/migrations/{migration_id}",
|
|
752
|
+
api_key,
|
|
753
|
+
params,
|
|
754
|
+
)
|
|
755
|
+
except m.ApiError as e:
|
|
756
|
+
if e.status == 404:
|
|
757
|
+
return (
|
|
758
|
+
make_error(
|
|
759
|
+
f"migration {migration_id} not found for instance {instance_id}",
|
|
760
|
+
status=e.status,
|
|
761
|
+
stage="request",
|
|
762
|
+
),
|
|
763
|
+
m.EXIT_HTTP_ERROR,
|
|
764
|
+
)
|
|
765
|
+
if e.status in (401, 403):
|
|
766
|
+
return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
|
|
767
|
+
return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
|
|
768
|
+
item = m._unwrap_items(envelope)
|
|
769
|
+
return (CliOutput(OutputKind.SCHEMA_MIGRATIONS, item), m.EXIT_OK)
|