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.
- apizit_cli/__init__.py +3 -0
- apizit_cli/authentication.py +396 -0
- apizit_cli/commands.py +337 -0
- apizit_cli/configuration.py +246 -0
- apizit_cli/context.py +155 -0
- apizit_cli/credential_store.py +170 -0
- apizit_cli/errors.py +19 -0
- apizit_cli/http.py +162 -0
- apizit_cli/main.py +164 -0
- apizit_cli/operations.py +745 -0
- apizit_cli/sources.py +259 -0
- apizit_cli-0.2.0b1.dist-info/METADATA +44 -0
- apizit_cli-0.2.0b1.dist-info/RECORD +16 -0
- apizit_cli-0.2.0b1.dist-info/WHEEL +5 -0
- apizit_cli-0.2.0b1.dist-info/entry_points.txt +2 -0
- apizit_cli-0.2.0b1.dist-info/top_level.txt +1 -0
apizit_cli/operations.py
ADDED
|
@@ -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()
|