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/__init__.py +3 -0
- valdtech_cli/__main__.py +4 -0
- valdtech_cli/auth.py +201 -0
- valdtech_cli/cli.py +738 -0
- valdtech_cli/config.py +196 -0
- valdtech_cli/errors.py +49 -0
- valdtech_cli/http.py +145 -0
- valdtech_cli/output.py +76 -0
- valdtech_cli-0.1.0.dist-info/METADATA +10 -0
- valdtech_cli-0.1.0.dist-info/RECORD +12 -0
- valdtech_cli-0.1.0.dist-info/WHEEL +4 -0
- valdtech_cli-0.1.0.dist-info/entry_points.txt +2 -0
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
|