apizit-cli 0.2.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,745 @@
1
+ """Command orchestration; authorization and product decisions stay on the server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ import uuid
13
+ import webbrowser
14
+ from pathlib import Path
15
+ from urllib.parse import urlsplit
16
+
17
+ import requests
18
+ import yaml
19
+
20
+ from apizit_cli import __version__
21
+ from apizit_cli.authentication import system_vault
22
+ from apizit_cli.commands import COMMANDS
23
+ from apizit_cli.configuration import (
24
+ CONFIG_FILES,
25
+ DeploymentConfiguration,
26
+ load_configuration,
27
+ _StrictLoader,
28
+ )
29
+ from apizit_cli.context import ClientContext
30
+ from apizit_cli.errors import CliError, EXIT_CONFLICT, EXIT_NETWORK, EXIT_TIMEOUT, EXIT_USAGE
31
+ from apizit_cli.http import PlatformClient, identifier
32
+
33
+
34
+ def launch_overrides(args):
35
+ result = {}
36
+ if getattr(args, "region", None) is not None:
37
+ result["region"] = args.region
38
+ if getattr(args, "format", None) is not None:
39
+ result["launch"] = {"format": args.format}
40
+ if getattr(args, "name", None) is not None:
41
+ result["project"] = {"name": args.name}
42
+ if getattr(args, "api_key_required", False) and getattr(args, "no_api_key", False):
43
+ raise CliError("Choose --api-key-required or --no-api-key.", exit_code=EXIT_USAGE)
44
+ if getattr(args, "api_key_required", False):
45
+ result["api"] = {"api_key_required": True}
46
+ elif getattr(args, "no_api_key", False):
47
+ result["api"] = {"api_key_required": False}
48
+ return result
49
+
50
+
51
+ class CommandRunner:
52
+ def __init__(self, args, *, client=None):
53
+ self.args = args
54
+ source = getattr(args, "directory", None) or getattr(args, "source", None)
55
+ root = Path(source) if source and Path(source).is_dir() else None
56
+ self.context = (
57
+ client.context
58
+ if client
59
+ else ClientContext(
60
+ workspace_id=getattr(args, "workspace", None),
61
+ endpoint=getattr(args, "endpoint", None),
62
+ root=root,
63
+ )
64
+ )
65
+ self.client = client or PlatformClient(self.context)
66
+ self.tracking = {}
67
+ self.non_interactive = bool(
68
+ getattr(args, "non_interactive", False)
69
+ or getattr(args, "json", False)
70
+ or not sys.stdin.isatty()
71
+ )
72
+ if getattr(args, "token_stdin", False):
73
+ if getattr(args, "stdin", False):
74
+ raise CliError(
75
+ "stdin cannot carry both a token and a secret.", exit_code=EXIT_USAGE
76
+ )
77
+ if sys.stdin.isatty():
78
+ raise CliError("Pipe a token to --token-stdin.", exit_code=EXIT_USAGE)
79
+ token = sys.stdin.read(8193).strip()
80
+ if not token or len(token) > 8192:
81
+ raise CliError("Expected a bounded CI token on stdin.", exit_code=EXIT_USAGE)
82
+ self.client.auth.injected_token = token
83
+ if getattr(args, "wait", False) and getattr(args, "no_wait", False):
84
+ raise CliError("Choose --wait or --no-wait.", exit_code=EXIT_USAGE)
85
+
86
+ def get(self, path, **kwargs):
87
+ return self.client.request("GET", path, **kwargs)
88
+
89
+ def operation_key(self):
90
+ key = getattr(self.args, "idempotency_key", None)
91
+ if key is None:
92
+ key = self.tracking.get("idempotency_key") or str(uuid.uuid4())
93
+ if not 8 <= len(key) <= 128 or not all(
94
+ c.isascii() and (c.isalnum() or c in "._:-") for c in key
95
+ ):
96
+ raise CliError(
97
+ "An idempotency key must be 8–128 ASCII letters, digits or ._:-.",
98
+ exit_code=EXIT_USAGE,
99
+ )
100
+ self.tracking["idempotency_key"] = key
101
+ return key
102
+
103
+ def confirm(self, message):
104
+ if getattr(self.args, "yes", False):
105
+ return
106
+ if self.non_interactive:
107
+ raise CliError(
108
+ "This action requires --yes in scripts or JSON mode.",
109
+ code="CONFIRMATION_REQUIRED",
110
+ exit_code=EXIT_USAGE,
111
+ )
112
+
113
+ print(message + ' Type "yes" to continue: ', end="", file=sys.stderr, flush=True)
114
+ if sys.stdin.readline().strip() != "yes":
115
+ raise CliError(
116
+ "No operation was submitted.", code="CONFIRMATION_DECLINED", exit_code=EXIT_USAGE
117
+ )
118
+
119
+ def phase_key(self, phase):
120
+ return hashlib.sha256((self.operation_key() + "\n" + phase).encode()).hexdigest()
121
+
122
+ def api_id(self, value=None):
123
+ result = value or getattr(self.args, "api", None) or self.context.project.get("api_id")
124
+ if not result:
125
+ raise CliError(
126
+ "Provide an API ID or link this project with deploy.",
127
+ code="API_REQUIRED",
128
+ exit_code=EXIT_USAGE,
129
+ )
130
+ return identifier(result)
131
+
132
+ def should_wait(self):
133
+ return bool(
134
+ getattr(self.args, "wait", False)
135
+ or (not getattr(self.args, "no_wait", False) and not self.non_interactive)
136
+ )
137
+
138
+ def submitted(self, result, *, scan=False):
139
+ for key in ("scan_id", "job_id", "api_id", "deployment_id", "source_snapshot_id"):
140
+ if result.get(key):
141
+ self.tracking[key] = result[key]
142
+ if self.should_wait():
143
+ resource_id = result.get("scan_id" if scan else "job_id")
144
+ if resource_id:
145
+ result = {**result, "operation": self.wait(resource_id, scan=scan)}
146
+ return result
147
+
148
+ def wait(self, resource_id, *, scan=False):
149
+ kind = "scan" if scan else "job"
150
+ self.tracking[kind + "_id"] = resource_id
151
+ deadline = time.monotonic() + (getattr(self.args, "timeout", None) or 600)
152
+ previous = None
153
+ while True:
154
+ remaining = deadline - time.monotonic()
155
+ if remaining <= 0:
156
+ raise CliError(
157
+ f"Local wait expired. Resume with apizit {kind} wait {resource_id}.",
158
+ code="WAIT_TIMEOUT",
159
+ exit_code=EXIT_TIMEOUT,
160
+ details=dict(self.tracking),
161
+ )
162
+ result = self.get(f"/{kind}s/{identifier(resource_id)}", timeout=min(30, remaining))
163
+ status = result.get("status")
164
+ if not isinstance(status, str):
165
+ raise CliError(
166
+ "Operation status is missing.", code="INVALID_RESPONSE", exit_code=EXIT_NETWORK
167
+ )
168
+ if status != previous:
169
+ print(
170
+ json.dumps({"operation": kind, "id": resource_id, "status": status}),
171
+ file=sys.stderr,
172
+ flush=True,
173
+ )
174
+ previous = status
175
+ if status == "SUCCEEDED":
176
+ return result
177
+ if status in {"FAILED", "CANCELLED", "RECONCILIATION_REQUIRED", "DEAD_LETTERED"}:
178
+ raise CliError(
179
+ "The remote operation finished without success; inspect its result.",
180
+ code="OPERATION_" + status,
181
+ details={**self.tracking, "operation": result},
182
+ )
183
+ time.sleep(min(2, max(0, deadline - time.monotonic())))
184
+
185
+ def prepare_source(self, source):
186
+ if source.startswith("https://"):
187
+ result = self.client.request(
188
+ "POST",
189
+ "/sources",
190
+ body={
191
+ "kind": "git",
192
+ "repository_url": source,
193
+ **({"ref": self.args.ref} if getattr(self.args, "ref", None) else {}),
194
+ },
195
+ idempotency_key=self.phase_key("source"),
196
+ )
197
+ else:
198
+ if getattr(self.args, "ref", None):
199
+ raise CliError(
200
+ "--ref applies to Git URLs, not local folders.", exit_code=EXIT_USAGE
201
+ )
202
+ from apizit_cli.sources import pack_local_source
203
+
204
+ root = Path(source).resolve(strict=True)
205
+ if not root.is_dir():
206
+ raise CliError("Source must be a folder or HTTPS Git URL.", exit_code=EXIT_USAGE)
207
+ # Validate before upload; the server validates the exact uploaded bytes again.
208
+ load_configuration(root)
209
+ with tempfile.TemporaryDirectory(prefix="apizit-source-") as temporary:
210
+ bundle = pack_local_source(root, Path(temporary) / "source.tar.gz")
211
+ manifest = bundle.path.with_suffix(
212
+ bundle.path.suffix + ".manifest.json"
213
+ ).read_bytes()
214
+ result = self.client.request(
215
+ "POST",
216
+ "/sources",
217
+ body={
218
+ "kind": "local",
219
+ "digest_sha256": bundle.digest_sha256,
220
+ "size_bytes": bundle.size_bytes,
221
+ "manifest_sha256": hashlib.sha256(manifest).hexdigest(),
222
+ "source_commit_sha": bundle.source_commit_sha,
223
+ "working_tree_dirty": bundle.working_tree_dirty,
224
+ },
225
+ idempotency_key=self.phase_key("source"),
226
+ )
227
+ self.tracking["source_snapshot_id"] = result["source_snapshot_id"]
228
+ if result.get("status") == "UPLOADING":
229
+ upload = result["upload"]
230
+ _trusted_delivery_url(upload["url"])
231
+ transport = requests.Session()
232
+ transport.trust_env = False
233
+ try:
234
+ with bundle.path.open("rb") as stream:
235
+ response = transport.post(
236
+ upload["url"],
237
+ data=upload["fields"],
238
+ files={"file": ("source.tar.gz", stream, "application/gzip")},
239
+ timeout=120,
240
+ allow_redirects=False,
241
+ )
242
+ if response.status_code not in {200, 201, 204}:
243
+ raise requests.RequestException()
244
+ except requests.RequestException:
245
+ raise CliError(
246
+ "Source upload was not confirmed. Retry with the same idempotency key.",
247
+ code="SOURCE_UPLOAD_FAILED",
248
+ exit_code=EXIT_NETWORK,
249
+ ) from None
250
+ finally:
251
+ transport.close()
252
+ result = self.client.request(
253
+ "POST",
254
+ f"/sources/{identifier(result['source_snapshot_id'])}/finalize",
255
+ body={},
256
+ idempotency_key=self.phase_key("finalize"),
257
+ )
258
+ self.tracking["source_snapshot_id"] = result["source_snapshot_id"]
259
+ deadline = time.monotonic() + (getattr(self.args, "timeout", None) or 600)
260
+ previous = None
261
+ while result.get("status") != "READY":
262
+ status = result.get("status")
263
+ if status in {"FAILED", "EXPIRED"}:
264
+ raise CliError(
265
+ "Source preparation failed; inspect the source and configuration.",
266
+ code=result.get("failure_code") or "SOURCE_PREPARATION_FAILED",
267
+ )
268
+ if status != previous:
269
+ print(
270
+ json.dumps(
271
+ {"source_snapshot_id": result["source_snapshot_id"], "status": status}
272
+ ),
273
+ file=sys.stderr,
274
+ flush=True,
275
+ )
276
+ previous = status
277
+ remaining = deadline - time.monotonic()
278
+ if remaining <= 0:
279
+ raise CliError(
280
+ "Source preparation is still running. Retry with the same idempotency key.",
281
+ code="WAIT_TIMEOUT",
282
+ exit_code=EXIT_TIMEOUT,
283
+ )
284
+ time.sleep(min(1, remaining))
285
+ result = self.get(
286
+ f"/sources/{identifier(result['source_snapshot_id'])}", timeout=min(30, remaining)
287
+ )
288
+ return result
289
+
290
+ def run(self):
291
+ args, command = self.args, self.args.command_path
292
+ if command == "login":
293
+ return self.client.auth.login(
294
+ timeout=getattr(args, "timeout", None) or 180,
295
+ non_interactive=self.non_interactive,
296
+ )
297
+ if command == "logout":
298
+ return self.client.auth.logout()
299
+ if command == "whoami":
300
+ return self.get("/auth/me", workspace=False)
301
+ if command == "workspace list":
302
+ return self.get("/workspaces", workspace=False)
303
+ if command == "workspace use":
304
+ workspaces = self.get("/workspaces", workspace=False).get("workspaces", [])
305
+ selected = next(
306
+ (item for item in workspaces if item.get("workspace_id") == args.id), None
307
+ )
308
+ if selected is None:
309
+ raise CliError(
310
+ "Workspace is not accessible.", code="WORKSPACE_NOT_FOUND", exit_code=4
311
+ )
312
+ if (
313
+ self.context.project.get("api_id")
314
+ and self.context.project.get("workspace_id") != args.id
315
+ ):
316
+ raise CliError(
317
+ "This project's API belongs to another workspace.",
318
+ code="PROJECT_WORKSPACE_MISMATCH",
319
+ exit_code=EXIT_USAGE,
320
+ )
321
+ self.context.select_workspace(args.id)
322
+ return {"workspace": selected}
323
+ if command == "workspace show":
324
+ return self.get("/workspace/current")
325
+ if command == "workspace create":
326
+ return self.client.request(
327
+ "POST", "/workspaces", body={"name": args.name}, workspace=False
328
+ )
329
+ if command.startswith("config ") or command == "init":
330
+ return self.configuration()
331
+ if command == "scan":
332
+ if args.source in {"show", "wait"}:
333
+ if not args.id or any(
334
+ getattr(args, key, None)
335
+ for key in ("ref", "format", "region", "idempotency_key")
336
+ ):
337
+ raise CliError(
338
+ "Use scan show/wait <id> without source options.", exit_code=EXIT_USAGE
339
+ )
340
+ return (
341
+ self.wait(args.id, scan=True)
342
+ if args.source == "wait"
343
+ else self.get(f"/scans/{identifier(args.id)}")
344
+ )
345
+ if args.id:
346
+ raise CliError("Provide exactly one source.", exit_code=EXIT_USAGE)
347
+ source = self.prepare_source(args.source)
348
+ result = self.client.request(
349
+ "POST",
350
+ "/scans",
351
+ body={
352
+ "source_snapshot_id": source["source_snapshot_id"],
353
+ "configuration_overrides": launch_overrides(args),
354
+ },
355
+ idempotency_key=self.operation_key(),
356
+ )
357
+ return self.submitted(result, scan=True)
358
+ if command == "deploy":
359
+ return self.deploy()
360
+ if command.startswith("api ") or command.startswith("release "):
361
+ return self.api_command()
362
+ if command in {"job show", "job wait"}:
363
+ return (
364
+ self.wait(args.id)
365
+ if command.endswith("wait")
366
+ else self.get(f"/jobs/{identifier(args.id)}")
367
+ )
368
+ if command == "job cancel":
369
+ job = self.get(f"/jobs/{identifier(args.id)}")
370
+ if job.get("target", {}).get("type") != "deployment" or (
371
+ not job.get("can_cancel") and not args.idempotency_key
372
+ ):
373
+ raise CliError(
374
+ "This operation cannot currently be cancelled.",
375
+ code="JOB_NOT_CANCELLABLE",
376
+ exit_code=EXIT_CONFLICT,
377
+ )
378
+ self.confirm("Request cancellation of this operation?")
379
+ target = identifier(job["target"]["id"])
380
+ result = self.client.request(
381
+ "POST",
382
+ f"/deployments/{target}/jobs/{identifier(args.id)}/cancel",
383
+ body={},
384
+ idempotency_key=self.operation_key(),
385
+ )
386
+ return self.submitted(result)
387
+ if command == "regions":
388
+ # This translation expresses product names, never plan limits.
389
+ formats = {
390
+ "automatic": ("auto", "automatic"),
391
+ "standard": ("lambda_zip", "automatic"),
392
+ "expanded": ("lambda_ecr", "automatic"),
393
+ "max_power": ("lambda_ecr", "max_power"),
394
+ }
395
+ launch_type, performance = formats[args.format or "automatic"]
396
+ query = {"deployment_type": launch_type, "performance_profile": performance}
397
+ if args.api:
398
+ query["api_id"] = args.api
399
+ return self.get("/deployments/regions", query=query)
400
+ if command == "plans":
401
+ return self.get("/subscriptions/plans", workspace=False, authenticated=False)
402
+ if command == "usage":
403
+ return self.get("/deployments/usage")
404
+ if command == "capabilities":
405
+ return self.get("/capabilities")
406
+ if command.startswith("token ") or command.startswith("secret "):
407
+ return self.access_command()
408
+ if command == "docs":
409
+ configuration = self.client.auth.configuration()
410
+ url = configuration["website_url"].rstrip("/") + "/cli/"
411
+ if args.topic:
412
+ topic = args.topic.strip().lower()
413
+ if topic in {item.path for item in COMMANDS}:
414
+ url += "commands/#" + topic.replace(" ", "-")
415
+ elif topic in {
416
+ "installation",
417
+ "commands",
418
+ "configuration",
419
+ "sources",
420
+ "workspaces",
421
+ "releases",
422
+ "observability",
423
+ "access",
424
+ "agents",
425
+ "ci",
426
+ "troubleshooting",
427
+ }:
428
+ url += topic + "/"
429
+ elif topic != "quickstart":
430
+ raise CliError(
431
+ "Unknown documentation topic or command.",
432
+ code="DOCS_TOPIC_INVALID",
433
+ exit_code=EXIT_USAGE,
434
+ )
435
+ if args.open:
436
+ if self.non_interactive:
437
+ raise CliError("--open requires an interactive terminal.", exit_code=EXIT_USAGE)
438
+ webbrowser.open(url)
439
+ return {"url": url, "version": __version__, "status": "beta"}
440
+ if command == "doctor":
441
+ checks = {
442
+ "client_version": __version__,
443
+ "python": sys.version.split()[0],
444
+ "endpoint": self.context.origin,
445
+ }
446
+ try:
447
+ checks["connection"] = {
448
+ "ok": True,
449
+ "configuration_version": self.client.auth.configuration()["version"],
450
+ }
451
+ except CliError as error:
452
+ checks["connection"] = {"ok": False, "code": error.code}
453
+ if self.client.auth.injected_token:
454
+ checks["credential_vault"] = {"ok": True, "required": False}
455
+ else:
456
+ try:
457
+ system_vault()
458
+ checks["credential_vault"] = {"ok": True, "required": True}
459
+ except CliError as error:
460
+ checks["credential_vault"] = {"ok": False, "code": error.code}
461
+ if any(
462
+ isinstance(check, dict) and check.get("ok") is False for check in checks.values()
463
+ ):
464
+ raise CliError(
465
+ "A required client check failed.", code="DOCTOR_CHECK_FAILED", details=checks
466
+ )
467
+ return checks
468
+ raise CliError("Unknown command.", exit_code=EXIT_USAGE)
469
+
470
+ def configuration(self):
471
+ args = self.args
472
+ if args.command_path == "config schema":
473
+ return DeploymentConfiguration.model_json_schema()
474
+ root = Path(args.directory or ".").resolve(strict=True)
475
+ if args.command_path == "init":
476
+ if any((root / name).exists() for name in CONFIG_FILES):
477
+ raise CliError(
478
+ "A deployment configuration already exists.",
479
+ code="CONFIG_EXISTS",
480
+ exit_code=EXIT_CONFLICT,
481
+ )
482
+ name = args.name or root.name
483
+ from apizit_cli.configuration import parse_configuration
484
+
485
+ value = {"version": 2, "project": {"name": name}, "launch": {"format": "automatic"}}
486
+ parse_configuration(json.dumps(value), json_format=True)
487
+ import yaml
488
+
489
+ path = root / CONFIG_FILES[0]
490
+ with path.open("x", encoding="utf-8", newline="\n") as stream:
491
+ yaml.safe_dump(value, stream, sort_keys=False, allow_unicode=True)
492
+ return {"path": str(path), "version": 2}
493
+ config, path = load_configuration(root)
494
+ if args.command_path == "config validate":
495
+ return {"valid": True, "path": str(path) if path else None, "version": 2}
496
+ body = {"configuration": config.explicit_options(), "overrides": launch_overrides(args)}
497
+ api = getattr(args, "api", None) or self.context.project.get("api_id")
498
+ if api:
499
+ body["api_id"] = api
500
+ return self.client.request("POST", "/configuration/resolve", body=body)
501
+
502
+ def deploy(self):
503
+ args = self.args
504
+ api = args.api or self.context.project.get("api_id")
505
+ if args.from_scan and (args.source or args.ref):
506
+ raise CliError(
507
+ "--from-scan cannot be combined with a source or --ref.", exit_code=EXIT_USAGE
508
+ )
509
+ if args.strategy and not api:
510
+ raise CliError(
511
+ "--strategy applies to a release of an existing API.", exit_code=EXIT_USAGE
512
+ )
513
+ if api and (args.strategy or "direct") == "direct":
514
+ self.confirm("Launch a release that replaces this API's live traffic?")
515
+ body = {"configuration_overrides": launch_overrides(args)}
516
+ if args.from_scan:
517
+ body["from_scan_id"] = args.from_scan
518
+ else:
519
+ source = self.prepare_source(args.source or ".")
520
+ body["source_snapshot_id"] = source["source_snapshot_id"]
521
+ if args.strategy:
522
+ body["deployment_strategy"] = args.strategy
523
+ route = f"/apis/{identifier(api)}/deploy" if api else "/deployments"
524
+ result = self.client.request("POST", route, body=body, idempotency_key=self.operation_key())
525
+ self.tracking.update(
526
+ {key: result[key] for key in ("api_id", "job_id", "deployment_id") if result.get(key)}
527
+ )
528
+ if (
529
+ result.get("api_id")
530
+ and not args.from_scan
531
+ and not (args.source or ".").startswith("https://")
532
+ ):
533
+ self.context.bind_api(result["api_id"])
534
+ result = self.submitted(result)
535
+ if self.should_wait() and result.get("api_id"):
536
+ result["api"] = self.get(f"/apis/{identifier(result['api_id'])}")
537
+ return result
538
+
539
+ def api_command(self):
540
+ args, command = self.args, self.args.command_path
541
+ if command == "api list":
542
+ return self.get("/apis")
543
+ if command == "release show":
544
+ return self.get(f"/deployments/{identifier(args.id)}")
545
+ if command == "release discard":
546
+ self.confirm("Delete this retained release?")
547
+ return self.submitted(
548
+ self.client.request(
549
+ "DELETE",
550
+ f"/deployments/{identifier(args.id)}",
551
+ idempotency_key=self.operation_key(),
552
+ )
553
+ )
554
+ api = self.api_id(getattr(args, "id", None) if command.startswith("api ") else None)
555
+ if command == "api show":
556
+ return self.get(f"/apis/{api}")
557
+ if command == "api logs":
558
+ fields = {
559
+ "from": "from",
560
+ "to": "to",
561
+ "level": "level",
562
+ "query": "q",
563
+ "limit": "limit",
564
+ "cursor": "cursor",
565
+ "release": "deployment_id",
566
+ }
567
+ return self.get(
568
+ f"/apis/{api}/logs",
569
+ query={
570
+ target: getattr(args, source)
571
+ for source, target in fields.items()
572
+ if getattr(args, source, None) is not None
573
+ },
574
+ )
575
+ if command == "api usage":
576
+ return self.get(f"/apis/{api}/dashboard").get("usage", {})
577
+ if command == "api key":
578
+ release = args.release
579
+ if not release:
580
+ release = (self.get(f"/apis/{api}").get("current_deployment") or {}).get(
581
+ "deployment_id"
582
+ )
583
+ if not release:
584
+ raise CliError("This API has no active release.", code="RELEASE_NOT_FOUND")
585
+ return self.get(f"/apis/{api}/deployments/{identifier(release)}/credentials")
586
+ if command == "api openapi":
587
+ delivery = self.get(f"/apis/{api}/artifacts/openapi/export")
588
+ document = _download_openapi(delivery["url"])
589
+ if args.output:
590
+ output = Path(args.output)
591
+ with output.open("x", encoding="utf-8") as stream:
592
+ json.dump(document, stream, indent=2)
593
+ stream.write("\n")
594
+ return {"path": str(output.resolve()), "api_id": api}
595
+ return {"api_id": api, "openapi": document}
596
+ if command == "api delete":
597
+ self.confirm("Delete this API and its releases?")
598
+ return self.submitted(
599
+ self.client.request("DELETE", f"/apis/{api}", idempotency_key=self.operation_key())
600
+ )
601
+ detail = self.get(f"/apis/{api}")
602
+ workflow = detail.get("release_workflow", {})
603
+ if command == "release list":
604
+ return {"api_id": api, "releases": detail.get("deployments", []), "workflow": workflow}
605
+ promoting = command == "release promote"
606
+ capability = "can_promote" if promoting else "can_rollback"
607
+ if not workflow.get(capability) and not args.idempotency_key:
608
+ raise CliError(
609
+ "This traffic change is not currently allowed.",
610
+ code=workflow.get("promotion_blocker" if promoting else "rollback_blocker")
611
+ or "TRAFFIC_CHANGE_BLOCKED",
612
+ exit_code=EXIT_CONFLICT,
613
+ )
614
+ body = {"expected_active_deployment_id": workflow["active_deployment_id"]}
615
+ if promoting:
616
+ if workflow.get("candidate_deployment_id") != args.id and not args.idempotency_key:
617
+ raise CliError(
618
+ "The selected release is not the current candidate.",
619
+ code="CANDIDATE_CHANGED",
620
+ exit_code=EXIT_CONFLICT,
621
+ )
622
+ path = f"/apis/{api}/deployments/{identifier(args.id)}/promote"
623
+ else:
624
+ body["expected_rollback_target_id"] = (
625
+ workflow.get("rollback_target_id") or workflow["active_deployment_id"]
626
+ )
627
+ path = f"/apis/{api}/rollback"
628
+ self.confirm("Change this API's live traffic to the selected existing release?")
629
+ # The server freezes the first preconditions and replays the recorded result.
630
+ return self.submitted(
631
+ self.client.request("POST", path, body=body, idempotency_key=self.operation_key())
632
+ )
633
+
634
+ def access_command(self):
635
+ args, command = self.args, self.args.command_path
636
+ if command == "token create":
637
+ body = {"name": args.name}
638
+ if args.scope is not None:
639
+ body["scopes"] = args.scope
640
+ if args.expires_days is not None:
641
+ body["expires_in_days"] = args.expires_days
642
+ return self.client.request("POST", "/workspace/tokens", body=body)
643
+ if command == "token list":
644
+ return self.get("/workspace/tokens")
645
+ if command == "token revoke":
646
+ self.confirm("Revoke this automation token?")
647
+ return self.client.request("DELETE", f"/workspace/tokens/{identifier(args.id)}")
648
+ if command == "secret list":
649
+ return self.get("/workspace/secrets")
650
+ if command == "secret delete":
651
+ self.confirm("Delete this secret if no retained release needs it?")
652
+ return self.client.request(
653
+ "DELETE",
654
+ f"/workspace/secrets/{identifier(args.name)}",
655
+ idempotency_key=self.operation_key(),
656
+ )
657
+ if args.stdin and args.from_env:
658
+ raise CliError("Choose --stdin or --from-env.", exit_code=EXIT_USAGE)
659
+ if args.stdin:
660
+ if sys.stdin.isatty():
661
+ raise CliError("Pipe a secret value to --stdin.", exit_code=EXIT_USAGE)
662
+ value = sys.stdin.read(2049)
663
+ elif args.from_env:
664
+ if args.from_env not in os.environ:
665
+ raise CliError(
666
+ "The local secret environment variable is missing.",
667
+ code="SECRET_INPUT_MISSING",
668
+ exit_code=EXIT_USAGE,
669
+ )
670
+ value = os.environ[args.from_env]
671
+ else:
672
+ if self.non_interactive:
673
+ raise CliError(
674
+ "Use --stdin or --from-env for non-interactive secret input.",
675
+ exit_code=EXIT_USAGE,
676
+ )
677
+ value = getpass.getpass("Secret value (hidden): ", stream=sys.stderr)
678
+ if not value or len(value.encode("utf-8")) > 2048 or "\x00" in value:
679
+ raise CliError(
680
+ "Secret value must contain 1–2048 UTF-8 bytes without NUL.",
681
+ code="INVALID_SECRET_VALUE",
682
+ exit_code=EXIT_USAGE,
683
+ )
684
+ try:
685
+ return self.client.request(
686
+ "PUT",
687
+ f"/workspace/secrets/{identifier(args.name)}",
688
+ body={"value": value},
689
+ idempotency_key=self.operation_key(),
690
+ )
691
+ finally:
692
+ value = None
693
+
694
+
695
+ def _trusted_delivery_url(url):
696
+ parsed = urlsplit(url)
697
+ if (
698
+ parsed.scheme != "https"
699
+ or not parsed.hostname
700
+ or parsed.username
701
+ or parsed.password
702
+ or parsed.port not in (None, 443)
703
+ or parsed.fragment
704
+ or not parsed.hostname.endswith(".amazonaws.com")
705
+ or any(ord(char) < 33 for char in url)
706
+ or "\\" in url
707
+ ):
708
+ raise CliError(
709
+ "Invalid APIZIT artifact delivery endpoint.",
710
+ code="INVALID_DELIVERY_URL",
711
+ exit_code=EXIT_NETWORK,
712
+ )
713
+
714
+
715
+ def _download_openapi(url):
716
+ _trusted_delivery_url(url)
717
+ session = requests.Session()
718
+ session.trust_env = False
719
+ try:
720
+ with session.get(url, timeout=30, stream=True, allow_redirects=False) as response:
721
+ if response.status_code != 200:
722
+ raise ValueError()
723
+ content = bytearray()
724
+ for chunk in response.iter_content(64 * 1024):
725
+ content.extend(chunk)
726
+ if len(content) > 16 * 1024 * 1024:
727
+ raise ValueError()
728
+ text = content.decode("utf-8-sig")
729
+ if any(
730
+ isinstance(token, (yaml.AliasToken, yaml.AnchorToken)) for token in yaml.scan(text)
731
+ ):
732
+ raise ValueError()
733
+ document = yaml.load(text, Loader=_StrictLoader)
734
+ if not isinstance(document, dict):
735
+ raise ValueError()
736
+ json.dumps(document, allow_nan=False)
737
+ return document
738
+ except (ValueError, TypeError, yaml.YAMLError, RecursionError, requests.RequestException):
739
+ raise CliError(
740
+ "The OpenAPI document could not be downloaded.",
741
+ code="ARTIFACT_DOWNLOAD_FAILED",
742
+ exit_code=EXIT_NETWORK,
743
+ ) from None
744
+ finally:
745
+ session.close()