valdtech-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
valdtech_cli/cli.py ADDED
@@ -0,0 +1,738 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from dataclasses import replace
5
+ import hashlib
6
+ import ipaddress
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import stat
11
+ import sys
12
+ import tempfile
13
+ from typing import Any
14
+ from urllib.parse import urlsplit
15
+ import uuid
16
+
17
+ from . import __version__
18
+ from .auth import device_login, workload_login
19
+ from .config import ConfigStore, Context, build_context
20
+ from .errors import CliError, ExitCode
21
+ from .http import HttpClient, redact
22
+ from .output import emit, emit_error
23
+
24
+
25
+ PROVISIONING_PATH = "api/provisioning/v1"
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> int:
29
+ parser = _parser()
30
+ args = parser.parse_args(argv)
31
+ command = getattr(args, "command", "valdtechctl")
32
+ try:
33
+ outcome = _run(args)
34
+ result, raw_text = outcome[:2]
35
+ exit_code = outcome[2] if len(outcome) == 3 else ExitCode.SUCCESS
36
+ emit(command, result, args.output, raw_text=raw_text)
37
+ return int(exit_code)
38
+ except CliError as error:
39
+ emit_error(command, error.code, error.message, error.details, args.output)
40
+ return int(error.exit_code)
41
+ except KeyboardInterrupt:
42
+ emit_error(command, "cancelled", "The operation was cancelled.", None, args.output)
43
+ return int(ExitCode.CANCELLED)
44
+
45
+
46
+ def _run(
47
+ args: argparse.Namespace,
48
+ ) -> tuple[Any, str | None] | tuple[Any, str | None, ExitCode]:
49
+ store = ConfigStore()
50
+ if args.command == "bootstrap":
51
+ return _bootstrap(args), None
52
+ if args.command == "context":
53
+ return _context(args, store), None
54
+ context = _effective_context(store.current_context(args.context), args.api_url)
55
+ if args.command == "login":
56
+ if args.workload_private_key:
57
+ password = os.environ.get(args.private_key_password_env) if args.private_key_password_env else None
58
+ result = workload_login(
59
+ context,
60
+ store,
61
+ args.timeout,
62
+ args.workload_private_key,
63
+ args.key_id,
64
+ password,
65
+ )
66
+ return {
67
+ "context": context.name,
68
+ "authentication": "private_key_jwt",
69
+ "expiresAt": result.token.expires_at.isoformat(),
70
+ "scope": result.granted_scope,
71
+ }, None
72
+ if args.non_interactive:
73
+ raise CliError("Device login requires an interactive user.", ExitCode.AUTHENTICATION, "interactive-login-required")
74
+ result = device_login(context, store, args.timeout, open_browser=args.open_browser)
75
+ return {
76
+ "context": context.name,
77
+ "expiresAt": result.token.expires_at.isoformat(),
78
+ "scope": result.granted_scope,
79
+ }, None
80
+
81
+ token = store.load_token(context.name)
82
+ access_token = token.value if token and not token.expired else None
83
+ client = HttpClient(context.api_url, args.timeout, access_token)
84
+ if args.command == "validate":
85
+ manifest, manifest_format = _manifest(args.file, args.manifest_format)
86
+ response = client.post_json(
87
+ f"{PROVISIONING_PATH}/validate",
88
+ {"manifest": manifest, "format": manifest_format},
89
+ ).json()
90
+ if not isinstance(response, dict) or response.get("isValid") is not True:
91
+ diagnostics = response.get("diagnostics") if isinstance(response, dict) else None
92
+ raise CliError("The manifest is invalid.", ExitCode.VALIDATION, "manifest-invalid", diagnostics)
93
+ return response, None
94
+ if args.command == "plan":
95
+ manifest, manifest_format = _manifest(args.file, args.manifest_format)
96
+ response = _plan(client, manifest, manifest_format, _inputs(args))
97
+ _raise_for_plan_status(response)
98
+ return response, None
99
+ if args.command == "drift":
100
+ return _drift(args, client)
101
+ if args.command == "apply":
102
+ return _apply(args, client), None
103
+ if args.command == "operation":
104
+ if args.operation_command == "resume":
105
+ result = _resume_operation(args, client)
106
+ exit_code = (
107
+ ExitCode.PARTIAL_EXTERNAL_EFFECT
108
+ if result.get("status") == 1
109
+ else ExitCode.SUCCESS
110
+ )
111
+ return result, None, exit_code
112
+ return _get_operation(args, client), None
113
+ if args.command == "export":
114
+ return _export_configuration(args, client)
115
+ if args.command == "schema":
116
+ response = client.get(f"{PROVISIONING_PATH}/schema")
117
+ try:
118
+ schema_text = response.body.decode("utf-8")
119
+ schema = json.loads(schema_text)
120
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
121
+ raise CliError("The server returned an invalid schema.", ExitCode.SERVER_FAILURE, "invalid-schema") from error
122
+ digest = hashlib.sha256(response.body).hexdigest()
123
+ if args.file:
124
+ _write_new_or_replace(Path(args.file), response.body)
125
+ result = {
126
+ "apiVersion": _schema_api_version(schema),
127
+ "sha256": digest,
128
+ "mediaType": response.content_type,
129
+ "file": str(Path(args.file).resolve()) if args.file else None,
130
+ "schema": None if args.file else schema,
131
+ }
132
+ return result, schema_text if args.output == "text" and not args.file else None
133
+ if args.command == "doctor":
134
+ result = _doctor(context, token, client)
135
+ if not result["healthy"]:
136
+ exit_code = ExitCode.AUTHENTICATION if not result["checks"][1]["ok"] else ExitCode.SERVER_FAILURE
137
+ raise CliError("One or more doctor checks failed.", exit_code, "doctor-unhealthy", result)
138
+ return result, None
139
+ raise CliError("Unknown command.", ExitCode.USAGE, "unknown-command")
140
+
141
+
142
+ def _bootstrap(args: argparse.Namespace) -> dict[str, Any]:
143
+ api_url = args.api_url.rstrip("/")
144
+ parsed = urlsplit(api_url)
145
+ try:
146
+ loopback = parsed.hostname == "localhost" or (
147
+ parsed.hostname is not None and ipaddress.ip_address(parsed.hostname).is_loopback
148
+ )
149
+ except ValueError:
150
+ loopback = False
151
+ if parsed.scheme not in {"http", "https"} or not loopback or parsed.username or parsed.password:
152
+ raise CliError(
153
+ "Bootstrap requires an explicit loopback API URL. Remote bootstrap requires an operator-managed mTLS client.",
154
+ ExitCode.VALIDATION,
155
+ "bootstrap-loopback-required",
156
+ )
157
+
158
+ token_path = Path(args.token_file)
159
+ try:
160
+ file_status = token_path.lstat()
161
+ if not stat.S_ISREG(file_status.st_mode) or token_path.is_symlink():
162
+ raise OSError("not a regular file")
163
+ if os.name != "nt" and stat.S_IMODE(file_status.st_mode) & 0o077:
164
+ raise OSError("permissions are not owner-only")
165
+ token = token_path.read_text(encoding="utf-8").strip()
166
+ except (OSError, UnicodeError) as error:
167
+ raise CliError(
168
+ "The bootstrap token must be a readable regular file accessible only to its owner.",
169
+ ExitCode.VALIDATION,
170
+ "unsafe-bootstrap-token-file",
171
+ ) from error
172
+ if len(token) < 32 or len(token) > 512:
173
+ raise CliError(
174
+ "The bootstrap token file has an invalid value.",
175
+ ExitCode.VALIDATION,
176
+ "invalid-bootstrap-token",
177
+ )
178
+
179
+ client = HttpClient(api_url, args.timeout)
180
+ response = client.post_json(
181
+ f"{PROVISIONING_PATH}/bootstrap",
182
+ {
183
+ "tenantKey": args.tenant_key,
184
+ "displayName": args.display_name,
185
+ "administratorIdentifier": args.administrator,
186
+ },
187
+ headers={"X-Valdtech-Bootstrap-Token": token},
188
+ authenticated=False,
189
+ sensitive_values=(token,),
190
+ ).json()
191
+ if not isinstance(response, dict) or response.get("status") != 0:
192
+ raise CliError(
193
+ "The bootstrap ceremony did not complete.",
194
+ ExitCode.SERVER_FAILURE,
195
+ "invalid-bootstrap-response",
196
+ )
197
+ return {
198
+ "status": response["status"],
199
+ "tenantKey": args.tenant_key,
200
+ "administratorIdentifier": args.administrator,
201
+ }
202
+
203
+
204
+ def _context(args: argparse.Namespace, store: ConfigStore) -> Any:
205
+ if args.context_command == "use":
206
+ provided = any((args.api_url, args.issuer, args.client_id, args.scope))
207
+ context = None
208
+ if provided:
209
+ if not all((args.api_url, args.issuer, args.client_id, args.scope)):
210
+ raise CliError(
211
+ "A new context requires --api-url, --issuer, --client-id, and --scope.",
212
+ ExitCode.VALIDATION,
213
+ "incomplete-context",
214
+ )
215
+ context = build_context(args.name, args.api_url, args.issuer, args.client_id, args.scope)
216
+ return store.use_context(context, args.name).public_dict()
217
+ if args.context_command == "show":
218
+ return store.current_context(args.name).public_dict()
219
+ current, contexts = store.list_contexts()
220
+ return {"currentContext": current, "contexts": [item.public_dict() for item in contexts]}
221
+
222
+
223
+ def _apply(args: argparse.Namespace, client: HttpClient) -> Any:
224
+ non_secret_inputs = _inputs(args)
225
+ plan: dict[str, Any] | None = None
226
+ if args.plan_id:
227
+ try:
228
+ plan_id = str(uuid.UUID(args.plan_id))
229
+ except (ValueError, AttributeError) as error:
230
+ raise CliError("--plan-id must be a valid UUID.", ExitCode.VALIDATION, "invalid-plan-id") from error
231
+ approval = plan_id
232
+ else:
233
+ manifest, manifest_format = _manifest(args.file, args.manifest_format)
234
+ plan = _plan(client, manifest, manifest_format, non_secret_inputs)
235
+ _raise_for_plan_status(plan)
236
+ plan_id = _required_response_string(plan, "planId")
237
+ approval = _required_response_string(plan, "planDigest")
238
+ print(_plan_summary(plan), file=sys.stderr)
239
+
240
+ if args.non_interactive:
241
+ if args.approve != approval:
242
+ raise CliError(
243
+ "Non-interactive apply requires --approve with the exact reviewed plan digest or plan id.",
244
+ ExitCode.VALIDATION,
245
+ "approval-required",
246
+ )
247
+ elif args.approve != approval:
248
+ answer = input(f"Apply reviewed plan {approval}? [y/N] ")
249
+ if answer.strip().lower() not in {"y", "yes"}:
250
+ raise CliError("Apply was not confirmed.", ExitCode.CANCELLED, "apply-not-confirmed")
251
+
252
+ secret_inputs = _secret_inputs(args)
253
+ required = {
254
+ item["key"]
255
+ for item in (plan or {}).get("requiredSecretInputs", [])
256
+ if isinstance(item, dict) and isinstance(item.get("key"), str)
257
+ }
258
+ if plan is not None and set(secret_inputs) != required:
259
+ missing = sorted(required - set(secret_inputs))
260
+ unexpected = sorted(set(secret_inputs) - required)
261
+ raise CliError(
262
+ "Secret inputs do not match the reviewed plan.",
263
+ ExitCode.VALIDATION,
264
+ "secret-input-mismatch",
265
+ {"missingKeys": missing, "unexpectedKeys": unexpected},
266
+ )
267
+ idempotency_key = args.idempotency_key or str(uuid.uuid4())
268
+ sensitive_values = tuple(secret_inputs.values())
269
+ response = client.post_json(
270
+ f"{PROVISIONING_PATH}/plans/{plan_id}/apply",
271
+ {"nonSecretInputs": non_secret_inputs, "secretInputs": secret_inputs},
272
+ headers={"Idempotency-Key": idempotency_key},
273
+ sensitive_values=sensitive_values,
274
+ ).json()
275
+ response = redact(response, sensitive_values)
276
+ _raise_for_apply_status(response)
277
+ return {"plan": plan, "operation": response, "idempotencyKey": idempotency_key}
278
+
279
+
280
+ def _get_operation(args: argparse.Namespace, client: HttpClient) -> dict[str, Any]:
281
+ operation_id = _canonical_uuid(args.operation_id, "OPERATION_ID", "invalid-operation-id")
282
+ response = client.get(f"{PROVISIONING_PATH}/operations/{operation_id}").json()
283
+ if not isinstance(response, dict) or response.get("status") != 0:
284
+ raise CliError(
285
+ "The server returned an invalid operation response.",
286
+ ExitCode.SERVER_FAILURE,
287
+ "invalid-operation-response",
288
+ )
289
+ operation = response.get("operation")
290
+ if not isinstance(operation, dict):
291
+ raise CliError(
292
+ "The server returned an invalid operation response.",
293
+ ExitCode.SERVER_FAILURE,
294
+ "invalid-operation-response",
295
+ )
296
+ returned_id = operation.get("operationId")
297
+ try:
298
+ returned_operation_id = str(uuid.UUID(returned_id))
299
+ except (ValueError, TypeError, AttributeError) as error:
300
+ raise CliError(
301
+ "The server returned an invalid operation response.",
302
+ ExitCode.SERVER_FAILURE,
303
+ "invalid-operation-response",
304
+ ) from error
305
+ if returned_operation_id != operation_id:
306
+ raise CliError(
307
+ "The server returned a different operation than requested.",
308
+ ExitCode.SERVER_FAILURE,
309
+ "operation-response-mismatch",
310
+ )
311
+ return response
312
+
313
+
314
+ def _resume_operation(args: argparse.Namespace, client: HttpClient) -> dict[str, Any]:
315
+ operation_id = _canonical_uuid(args.operation_id, "OPERATION_ID", "invalid-operation-id")
316
+ response = client.post_json(
317
+ f"{PROVISIONING_PATH}/operations/{operation_id}/resume",
318
+ {},
319
+ ).json()
320
+ if not isinstance(response, dict) or response.get("status") not in {0, 1}:
321
+ raise CliError(
322
+ "The server returned an invalid operation-resume response.",
323
+ ExitCode.SERVER_FAILURE,
324
+ "invalid-operation-resume-response",
325
+ )
326
+ returned_id = response.get("operationId")
327
+ try:
328
+ returned_operation_id = str(uuid.UUID(returned_id))
329
+ except (ValueError, TypeError, AttributeError) as error:
330
+ raise CliError(
331
+ "The server returned an invalid operation-resume response.",
332
+ ExitCode.SERVER_FAILURE,
333
+ "invalid-operation-resume-response",
334
+ ) from error
335
+ if returned_operation_id != operation_id:
336
+ raise CliError(
337
+ "The server resumed a different operation than requested.",
338
+ ExitCode.SERVER_FAILURE,
339
+ "operation-resume-response-mismatch",
340
+ )
341
+ for field in ("completedEffects", "pendingEffects"):
342
+ if not isinstance(response.get(field), int) or response[field] < 0:
343
+ raise CliError(
344
+ "The server returned an invalid operation-resume response.",
345
+ ExitCode.SERVER_FAILURE,
346
+ "invalid-operation-resume-response",
347
+ )
348
+ return response
349
+
350
+
351
+ def _export_configuration(
352
+ args: argparse.Namespace,
353
+ client: HttpClient,
354
+ ) -> tuple[dict[str, Any], str | None]:
355
+ response = client.get(f"{PROVISIONING_PATH}/export").json()
356
+ if not isinstance(response, dict):
357
+ raise CliError(
358
+ "The server returned an invalid export response.",
359
+ ExitCode.SERVER_FAILURE,
360
+ "invalid-export-response",
361
+ )
362
+ _raise_for_export_status(response)
363
+ envelope = response.get("envelope")
364
+ if not isinstance(envelope, dict):
365
+ raise CliError(
366
+ "The server export response has no manifest envelope.",
367
+ ExitCode.SERVER_FAILURE,
368
+ "invalid-export-response",
369
+ )
370
+ manifest = envelope.get("manifest")
371
+ manifest_format = envelope.get("format")
372
+ if not isinstance(manifest, str) or not manifest or manifest_format not in {0, 1}:
373
+ raise CliError(
374
+ "The server export response has an invalid manifest envelope.",
375
+ ExitCode.SERVER_FAILURE,
376
+ "invalid-export-response",
377
+ )
378
+
379
+ if args.file:
380
+ destination = Path(args.file)
381
+ _write_new_or_replace(destination, manifest.encode("utf-8"))
382
+ result = {
383
+ "status": response.get("status"),
384
+ "manifestDigest": response.get("manifestDigest"),
385
+ "omittedWriteOnlyPaths": response.get("omittedWriteOnlyPaths"),
386
+ "diagnostics": response.get("diagnostics"),
387
+ "format": manifest_format,
388
+ "file": str(destination.resolve()),
389
+ }
390
+ return result, None
391
+ return response, manifest if args.output == "text" else None
392
+
393
+
394
+ def _plan(client: HttpClient, manifest: str, manifest_format: int, inputs: dict[str, str]) -> dict[str, Any]:
395
+ response = client.post_json(
396
+ f"{PROVISIONING_PATH}/plans",
397
+ {"envelope": {"manifest": manifest, "format": manifest_format}, "nonSecretInputs": inputs},
398
+ ).json()
399
+ if not isinstance(response, dict):
400
+ raise CliError("The plan response is invalid.", ExitCode.SERVER_FAILURE, "invalid-plan-response")
401
+ return response
402
+
403
+
404
+ def _drift(
405
+ args: argparse.Namespace,
406
+ client: HttpClient,
407
+ ) -> tuple[dict[str, Any], None, ExitCode]:
408
+ manifest, manifest_format = _manifest(args.file, args.manifest_format)
409
+ response = client.post_json(
410
+ f"{PROVISIONING_PATH}/drift",
411
+ {
412
+ "envelope": {"manifest": manifest, "format": manifest_format},
413
+ "nonSecretInputs": _inputs(args),
414
+ },
415
+ ).json()
416
+ if not isinstance(response, dict):
417
+ raise CliError(
418
+ "The drift response is invalid.",
419
+ ExitCode.SERVER_FAILURE,
420
+ "invalid-drift-response",
421
+ )
422
+ _raise_for_drift_status(response)
423
+ has_drift = response.get("hasDrift")
424
+ if not isinstance(has_drift, bool) or not isinstance(response.get("operations"), list):
425
+ raise CliError(
426
+ "The drift response is incomplete.",
427
+ ExitCode.SERVER_FAILURE,
428
+ "invalid-drift-response",
429
+ )
430
+ return response, None, ExitCode.CONFLICT if has_drift else ExitCode.SUCCESS
431
+
432
+
433
+ def _doctor(context: Context, token, client: HttpClient) -> dict[str, Any]: # noqa: ANN001
434
+ checks: list[dict[str, Any]] = [
435
+ {"name": "context", "ok": True, "detail": context.name},
436
+ {"name": "token", "ok": bool(token and not token.expired), "detail": "available" if token and not token.expired else "missing-or-expired"},
437
+ ]
438
+ try:
439
+ discovery = HttpClient(context.issuer, client.timeout).get(
440
+ f"{context.issuer}/.well-known/openid-configuration",
441
+ authenticated=False,
442
+ ).json()
443
+ checks.append({"name": "oidc-discovery", "ok": isinstance(discovery, dict), "detail": context.issuer})
444
+ except CliError as error:
445
+ checks.append({"name": "oidc-discovery", "ok": False, "detail": error.code})
446
+ try:
447
+ schema = client.get(f"{PROVISIONING_PATH}/schema")
448
+ parsed = json.loads(schema.body.decode("utf-8"))
449
+ checks.append({"name": "provisioning-api", "ok": isinstance(parsed, dict), "detail": _schema_api_version(parsed)})
450
+ except (CliError, UnicodeDecodeError, json.JSONDecodeError) as error:
451
+ checks.append({"name": "provisioning-api", "ok": False, "detail": getattr(error, "code", "invalid-schema")})
452
+ return {"healthy": all(item["ok"] for item in checks), "checks": checks}
453
+
454
+
455
+ def _manifest(filename: str, manifest_format: str | None) -> tuple[str, int]:
456
+ if filename == "-":
457
+ raw = sys.stdin.buffer.read()
458
+ if not manifest_format:
459
+ raise CliError("--format is required when reading a manifest from stdin.", ExitCode.VALIDATION, "manifest-format-required")
460
+ else:
461
+ path = Path(filename)
462
+ try:
463
+ raw = path.read_bytes()
464
+ except OSError as error:
465
+ raise CliError(f"Manifest '{filename}' could not be read.", ExitCode.VALIDATION, "manifest-read-failed") from error
466
+ if not manifest_format:
467
+ suffix = path.suffix.lower()
468
+ manifest_format = "json" if suffix == ".json" else "yaml" if suffix in {".yaml", ".yml"} else None
469
+ if not manifest_format:
470
+ raise CliError("Manifest format could not be inferred; use --format.", ExitCode.VALIDATION, "manifest-format-required")
471
+ try:
472
+ return raw.decode("utf-8"), 0 if manifest_format == "json" else 1
473
+ except UnicodeDecodeError as error:
474
+ raise CliError("The manifest must be UTF-8.", ExitCode.VALIDATION, "manifest-encoding-invalid") from error
475
+
476
+
477
+ def _inputs(args: argparse.Namespace) -> dict[str, str]:
478
+ result = _pairs(getattr(args, "input", []), "--input")
479
+ for key, environment_name in _pairs(getattr(args, "input_env", []), "--input-env").items():
480
+ if environment_name not in os.environ:
481
+ raise CliError(f"Environment variable '{environment_name}' is missing.", ExitCode.VALIDATION, "input-environment-missing")
482
+ result[key] = os.environ[environment_name]
483
+ return result
484
+
485
+
486
+ def _secret_inputs(args: argparse.Namespace) -> dict[str, str]:
487
+ result: dict[str, str] = {}
488
+ mappings = _pairs(args.secret_env, "--secret-env")
489
+ for key, environment_name in mappings.items():
490
+ if environment_name not in os.environ:
491
+ raise CliError(f"Secret environment variable for key '{key}' is missing.", ExitCode.VALIDATION, "secret-environment-missing")
492
+ result[key] = os.environ[environment_name]
493
+ return result
494
+
495
+
496
+ def _pairs(values: list[str], option: str) -> dict[str, str]:
497
+ result: dict[str, str] = {}
498
+ for value in values:
499
+ key, separator, item = value.partition("=")
500
+ if not separator or not key or len(key) > 128 or len(item) > 8192:
501
+ raise CliError(f"{option} expects bounded KEY=VALUE entries.", ExitCode.VALIDATION, "invalid-input")
502
+ if key in result:
503
+ raise CliError(f"Duplicate input key '{key}'.", ExitCode.VALIDATION, "duplicate-input")
504
+ result[key] = item
505
+ return result
506
+
507
+
508
+ def _raise_for_plan_status(response: dict[str, Any]) -> None:
509
+ status = response.get("status")
510
+ if status == 0:
511
+ return
512
+ code = ExitCode.AUTHENTICATION if status == 2 else ExitCode.CONFLICT if status == 3 else ExitCode.VALIDATION
513
+ raise CliError("Provisioning plan was not accepted.", code, "plan-rejected", response.get("diagnostics"))
514
+
515
+
516
+ def _raise_for_drift_status(response: dict[str, Any]) -> None:
517
+ status = response.get("status")
518
+ if status == 0:
519
+ return
520
+ if status == 2:
521
+ exit_code = ExitCode.AUTHENTICATION
522
+ elif status == 3:
523
+ exit_code = ExitCode.CONFLICT
524
+ elif status == 1:
525
+ exit_code = ExitCode.VALIDATION
526
+ else:
527
+ exit_code = ExitCode.SERVER_FAILURE
528
+ raise CliError(
529
+ "Provisioning drift could not be evaluated.",
530
+ exit_code,
531
+ "drift-evaluation-rejected",
532
+ response.get("diagnostics"),
533
+ )
534
+
535
+
536
+ def _raise_for_apply_status(response: Any) -> None:
537
+ if not isinstance(response, dict):
538
+ raise CliError("The apply response is invalid.", ExitCode.SERVER_FAILURE, "invalid-apply-response")
539
+ status = response.get("status")
540
+ if status == 0:
541
+ return
542
+ if status == 4:
543
+ exit_code = ExitCode.AUTHENTICATION
544
+ elif status in {2, 3, 5, 6, 7}:
545
+ exit_code = ExitCode.CONFLICT
546
+ else:
547
+ exit_code = ExitCode.VALIDATION
548
+ raise CliError("Provisioning apply was not accepted.", exit_code, "apply-rejected", response.get("diagnostics"))
549
+
550
+
551
+ def _raise_for_export_status(response: dict[str, Any]) -> None:
552
+ status = response.get("status")
553
+ if status == 0:
554
+ return
555
+ if status == 1:
556
+ exit_code = ExitCode.AUTHENTICATION
557
+ elif status == 2:
558
+ exit_code = ExitCode.VALIDATION
559
+ else:
560
+ exit_code = ExitCode.SERVER_FAILURE
561
+ raise CliError(
562
+ "Provisioning configuration could not be exported.",
563
+ exit_code,
564
+ "export-rejected",
565
+ response.get("diagnostics"),
566
+ )
567
+
568
+
569
+ def _plan_summary(plan: dict[str, Any]) -> str:
570
+ operations = plan.get("operations") if isinstance(plan.get("operations"), list) else []
571
+ required = plan.get("requiredSecretInputs") if isinstance(plan.get("requiredSecretInputs"), list) else []
572
+ return f"Reviewed plan {plan.get('planDigest')}: {len(operations)} operation(s), {len(required)} secret input(s)."
573
+
574
+
575
+ def _required_response_string(value: dict[str, Any], key: str) -> str:
576
+ item = value.get(key)
577
+ if not isinstance(item, str) or not item:
578
+ raise CliError("The server plan response is incomplete.", ExitCode.SERVER_FAILURE, "invalid-plan-response")
579
+ return item
580
+
581
+
582
+ def _canonical_uuid(value: Any, field: str, code: str) -> str:
583
+ try:
584
+ return str(uuid.UUID(value))
585
+ except (ValueError, TypeError, AttributeError) as error:
586
+ raise CliError(
587
+ f"{field} must be a valid UUID.",
588
+ ExitCode.VALIDATION,
589
+ code,
590
+ ) from error
591
+
592
+
593
+ def _effective_context(context: Context, api_url: str | None) -> Context:
594
+ if not api_url:
595
+ return context
596
+ override = build_context(context.name, api_url, context.issuer, context.client_id, list(context.scopes))
597
+ return replace(context, api_url=override.api_url)
598
+
599
+
600
+ def _schema_api_version(schema: Any) -> str | None:
601
+ try:
602
+ value = schema["properties"]["apiVersion"]["const"]
603
+ return value if isinstance(value, str) else None
604
+ except (KeyError, TypeError):
605
+ return None
606
+
607
+
608
+ def _write_new_or_replace(path: Path, content: bytes) -> None:
609
+ path.parent.mkdir(parents=True, exist_ok=True)
610
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
611
+ try:
612
+ with os.fdopen(descriptor, "wb") as output:
613
+ output.write(content)
614
+ output.flush()
615
+ os.fsync(output.fileno())
616
+ os.replace(temporary_name, path)
617
+ except BaseException:
618
+ try:
619
+ os.unlink(temporary_name)
620
+ except FileNotFoundError:
621
+ pass
622
+ raise
623
+
624
+
625
+ def _parser() -> argparse.ArgumentParser:
626
+ parser = argparse.ArgumentParser(prog="valdtechctl", description="Valdtech Provisioning API client")
627
+ parser.add_argument("--version", action="version", version=f"valdtechctl {__version__}")
628
+ commands = parser.add_subparsers(dest="command", required=True)
629
+
630
+ login = commands.add_parser("login", help="Authenticate using OAuth Device Authorization")
631
+ _common_arguments(login)
632
+ login.add_argument("--open-browser", action="store_true", help="Open the verification URI in the default browser")
633
+ login.add_argument("--workload-private-key", metavar="PEM_FILE", help="Authenticate a workload with private_key_jwt")
634
+ login.add_argument("--key-id", help="Registered JWK key id for private_key_jwt")
635
+ login.add_argument("--private-key-password-env", metavar="ENV_VAR", help="Environment variable containing the PEM password")
636
+
637
+ bootstrap = commands.add_parser("bootstrap", help="Run the one-time local instance bootstrap ceremony")
638
+ _common_arguments(bootstrap, connection=False)
639
+ bootstrap.add_argument("--api-url", required=True, help="Explicit loopback Administration API URL")
640
+ bootstrap.add_argument("--token-file", required=True, help="Owner-only file containing the one-time bootstrap token")
641
+ bootstrap.add_argument("--tenant-key", required=True)
642
+ bootstrap.add_argument("--display-name", required=True)
643
+ bootstrap.add_argument("--administrator", required=True, help="Initial administrator email or stable identifier")
644
+
645
+ context = commands.add_parser("context", help="Manage named server contexts")
646
+ context_commands = context.add_subparsers(dest="context_command", required=True)
647
+ use = context_commands.add_parser("use", help="Create/select a context")
648
+ _common_arguments(use, connection=False)
649
+ use.add_argument("name")
650
+ use.add_argument("--api-url")
651
+ use.add_argument("--issuer")
652
+ use.add_argument("--client-id")
653
+ use.add_argument("--scope", action="append", default=[])
654
+ show = context_commands.add_parser("show", help="Show a context")
655
+ _common_arguments(show, connection=False)
656
+ show.add_argument("name", nargs="?")
657
+ context_list = context_commands.add_parser("list", help="List contexts")
658
+ _common_arguments(context_list, connection=False)
659
+
660
+ validate = commands.add_parser("validate", help="Validate a manifest on the server")
661
+ _common_arguments(validate)
662
+ _manifest_arguments(validate)
663
+
664
+ plan = commands.add_parser("plan", help="Create a persisted, secret-free plan")
665
+ _common_arguments(plan)
666
+ _manifest_arguments(plan)
667
+ _input_arguments(plan)
668
+
669
+ drift = commands.add_parser("drift", help="Compare a manifest with current observable state without creating a plan")
670
+ _common_arguments(drift)
671
+ _manifest_arguments(drift)
672
+ _input_arguments(drift)
673
+
674
+ apply = commands.add_parser("apply", help="Apply a reviewed plan")
675
+ _common_arguments(apply)
676
+ source = apply.add_mutually_exclusive_group(required=True)
677
+ source.add_argument("-f", "--file", help="Manifest file, or '-' for stdin")
678
+ source.add_argument("--plan-id", help="Existing reviewed plan id")
679
+ apply.add_argument("--format", dest="manifest_format", choices=("json", "yaml"))
680
+ _input_arguments(apply)
681
+ apply.add_argument("--secret-env", action="append", default=[], metavar="KEY=ENV_VAR")
682
+ apply.add_argument("--approve", help="Exact reviewed plan digest (or plan id with --plan-id)")
683
+ apply.add_argument("--idempotency-key", help="Stable visible-ASCII retry key")
684
+
685
+ operation = commands.add_parser("operation", help="Inspect provisioning operations")
686
+ operation_commands = operation.add_subparsers(dest="operation_command", required=True)
687
+ operation_get = operation_commands.add_parser("get", help="Get an operation owned by the current actor and tenant")
688
+ _common_arguments(operation_get)
689
+ operation_get.add_argument("operation_id", metavar="OPERATION_ID")
690
+ operation_resume = operation_commands.add_parser(
691
+ "resume",
692
+ help="Retry pending external effects for an operation owned by the current actor and tenant",
693
+ )
694
+ _common_arguments(operation_resume)
695
+ operation_resume.add_argument("operation_id", metavar="OPERATION_ID")
696
+
697
+ export = commands.add_parser("export", help="Export authorized configuration without secrets")
698
+ _common_arguments(export)
699
+ export.add_argument("-f", "--file", help="Write the server-produced manifest to this file")
700
+
701
+ schema = commands.add_parser("schema", help="Download the active server schema")
702
+ _common_arguments(schema)
703
+ schema.add_argument("-f", "--file", help="Write the schema to this file")
704
+ doctor = commands.add_parser("doctor", help="Check context, authentication, discovery, and API compatibility")
705
+ _common_arguments(doctor)
706
+ return parser
707
+
708
+
709
+ def _common_arguments(parser: argparse.ArgumentParser, *, connection: bool = True) -> None:
710
+ if connection:
711
+ parser.add_argument("--context", help="Context name (defaults to the current context)")
712
+ parser.add_argument("--api-url", help="Temporary API URL override")
713
+ parser.add_argument("--timeout", type=_positive_timeout, default=30.0, help="HTTP timeout in seconds")
714
+ parser.add_argument("--non-interactive", action="store_true")
715
+ parser.add_argument("--wait", action="store_true", help="Wait for a synchronous operation result")
716
+ else:
717
+ parser.set_defaults(context=None, api_url=None, timeout=30.0, non_interactive=False, wait=False)
718
+ parser.add_argument("--output", choices=("text", "json", "yaml"), default="text")
719
+
720
+
721
+ def _manifest_arguments(parser: argparse.ArgumentParser) -> None:
722
+ parser.add_argument("-f", "--file", required=True, help="Manifest file, or '-' for stdin")
723
+ parser.add_argument("--format", dest="manifest_format", choices=("json", "yaml"))
724
+
725
+
726
+ def _input_arguments(parser: argparse.ArgumentParser) -> None:
727
+ parser.add_argument("--input", action="append", default=[], metavar="KEY=VALUE")
728
+ parser.add_argument("--input-env", action="append", default=[], metavar="KEY=ENV_VAR")
729
+
730
+
731
+ def _positive_timeout(value: str) -> float:
732
+ try:
733
+ parsed = float(value)
734
+ except ValueError as error:
735
+ raise argparse.ArgumentTypeError("timeout must be a number") from error
736
+ if parsed <= 0 or parsed > 600:
737
+ raise argparse.ArgumentTypeError("timeout must be between 0 and 600 seconds")
738
+ return parsed