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/context.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Non-secret machine and project preferences; credentials live in the OS vault."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from apizit_cli.errors import CliError, EXIT_USAGE
|
|
12
|
+
|
|
13
|
+
DEFAULT_API_ORIGIN = "https://jxo6vbfwy0.execute-api.eu-central-1.amazonaws.com/v1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize_origin(value: str) -> str:
|
|
17
|
+
parsed = urlsplit(value)
|
|
18
|
+
if (
|
|
19
|
+
not parsed.hostname
|
|
20
|
+
or parsed.username
|
|
21
|
+
or parsed.password
|
|
22
|
+
or parsed.query
|
|
23
|
+
or parsed.fragment
|
|
24
|
+
or "\\" in value
|
|
25
|
+
or any(ord(char) < 33 for char in value)
|
|
26
|
+
or (
|
|
27
|
+
parsed.scheme != "https"
|
|
28
|
+
and not (
|
|
29
|
+
parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
):
|
|
33
|
+
raise CliError(
|
|
34
|
+
"APIZIT endpoint must be HTTPS (HTTP is allowed only on loopback).",
|
|
35
|
+
exit_code=EXIT_USAGE,
|
|
36
|
+
)
|
|
37
|
+
try:
|
|
38
|
+
parsed.port
|
|
39
|
+
except ValueError:
|
|
40
|
+
raise CliError("Invalid APIZIT endpoint port.", exit_code=EXIT_USAGE) from None
|
|
41
|
+
return value.rstrip("/")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def home_directory() -> Path:
|
|
45
|
+
override = os.environ.get("APIZIT_CONFIG_HOME")
|
|
46
|
+
return Path(override).expanduser().resolve() if override else Path.home() / ".apizit"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_json(path: Path) -> dict:
|
|
50
|
+
assert_safe_path(path)
|
|
51
|
+
if not path.exists():
|
|
52
|
+
return {}
|
|
53
|
+
try:
|
|
54
|
+
if path.is_symlink() or path.stat().st_size > 64 * 1024:
|
|
55
|
+
raise ValueError()
|
|
56
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
57
|
+
if not isinstance(value, dict):
|
|
58
|
+
raise ValueError()
|
|
59
|
+
return value
|
|
60
|
+
except (OSError, ValueError):
|
|
61
|
+
raise CliError(
|
|
62
|
+
f"Cannot read APIZIT context at {path}.", code="CONTEXT_INVALID", exit_code=EXIT_USAGE
|
|
63
|
+
) from None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_json(path: Path, value: dict) -> None:
|
|
67
|
+
assert_safe_path(path)
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
if path.is_symlink():
|
|
70
|
+
raise CliError("Refusing to replace a symbolic context file.", exit_code=EXIT_USAGE)
|
|
71
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".apizit-", dir=path.parent)
|
|
72
|
+
try:
|
|
73
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
|
74
|
+
json.dump(value, stream, indent=2)
|
|
75
|
+
stream.write("\n")
|
|
76
|
+
os.replace(temporary, path)
|
|
77
|
+
finally:
|
|
78
|
+
if os.path.exists(temporary):
|
|
79
|
+
os.unlink(temporary)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def assert_safe_path(path: Path) -> None:
|
|
83
|
+
for part in (path, *path.parents):
|
|
84
|
+
if part.is_symlink() or part.is_junction():
|
|
85
|
+
raise CliError(
|
|
86
|
+
"APIZIT context paths must not contain symbolic links or junctions.",
|
|
87
|
+
code="CONTEXT_INVALID",
|
|
88
|
+
exit_code=EXIT_USAGE,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ClientContext:
|
|
93
|
+
def __init__(self, *, workspace_id=None, root=None, endpoint=None):
|
|
94
|
+
self.root = Path(root or Path.cwd()).resolve()
|
|
95
|
+
self.path = home_directory() / "context.json"
|
|
96
|
+
self.project_path = self.root / ".apizit" / "project.json"
|
|
97
|
+
self.preferences = read_json(self.path)
|
|
98
|
+
self.project = read_json(self.project_path)
|
|
99
|
+
self.origin = normalize_origin(
|
|
100
|
+
endpoint or os.getenv("APIZIT_API_URL") or DEFAULT_API_ORIGIN
|
|
101
|
+
)
|
|
102
|
+
saved = self.preferences.get(self.origin, {})
|
|
103
|
+
if not isinstance(saved, dict):
|
|
104
|
+
raise CliError("Invalid saved endpoint context.", exit_code=EXIT_USAGE)
|
|
105
|
+
self.workspace_id = (
|
|
106
|
+
workspace_id
|
|
107
|
+
or os.getenv("APIZIT_WORKSPACE_ID")
|
|
108
|
+
or self.project.get("workspace_id")
|
|
109
|
+
or saved.get("workspace_id")
|
|
110
|
+
)
|
|
111
|
+
if self.project and self.project.get("origin") != self.origin:
|
|
112
|
+
raise CliError(
|
|
113
|
+
"This project is linked to another APIZIT endpoint.",
|
|
114
|
+
code="PROJECT_ENDPOINT_MISMATCH",
|
|
115
|
+
exit_code=EXIT_USAGE,
|
|
116
|
+
)
|
|
117
|
+
if self.project.get("api_id") and self.project.get("workspace_id") != self.workspace_id:
|
|
118
|
+
raise CliError(
|
|
119
|
+
"The linked API belongs to another workspace. Use an unlinked project context.",
|
|
120
|
+
code="PROJECT_WORKSPACE_MISMATCH",
|
|
121
|
+
exit_code=EXIT_USAGE,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def require_workspace(self):
|
|
125
|
+
import re
|
|
126
|
+
|
|
127
|
+
if not isinstance(self.workspace_id, str) or not re.fullmatch(
|
|
128
|
+
r"ws_[A-Za-z0-9_-]{1,128}", self.workspace_id
|
|
129
|
+
):
|
|
130
|
+
raise CliError(
|
|
131
|
+
"Choose a workspace with apizit workspace use <id> or --workspace.",
|
|
132
|
+
code="WORKSPACE_REQUIRED",
|
|
133
|
+
exit_code=EXIT_USAGE,
|
|
134
|
+
)
|
|
135
|
+
return self.workspace_id
|
|
136
|
+
|
|
137
|
+
def select_workspace(self, workspace_id):
|
|
138
|
+
self.preferences[self.origin] = {"workspace_id": workspace_id}
|
|
139
|
+
write_json(self.path, self.preferences)
|
|
140
|
+
self.workspace_id = workspace_id
|
|
141
|
+
|
|
142
|
+
def bind_api(self, api_id):
|
|
143
|
+
ignore = self.root / ".gitignore"
|
|
144
|
+
assert_safe_path(ignore)
|
|
145
|
+
assert_safe_path(self.project_path)
|
|
146
|
+
previous = ignore.read_text(encoding="utf-8") if ignore.exists() else ""
|
|
147
|
+
write_json(
|
|
148
|
+
self.project_path,
|
|
149
|
+
{"origin": self.origin, "workspace_id": self.require_workspace(), "api_id": api_id},
|
|
150
|
+
)
|
|
151
|
+
if "/.apizit/" not in previous.splitlines():
|
|
152
|
+
with ignore.open("a", encoding="utf-8") as stream:
|
|
153
|
+
stream.write(
|
|
154
|
+
("\n" if previous and not previous.endswith("\n") else "") + "/.apizit/\n"
|
|
155
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Bounded OS-vault entries for providers with long renewal credentials."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import secrets
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
|
|
13
|
+
from apizit_cli.context import assert_safe_path
|
|
14
|
+
from apizit_cli.errors import CliError, EXIT_AUTH
|
|
15
|
+
|
|
16
|
+
PART_SIZE = 1000 # Windows generic credentials allow 2560 bytes, stored as UTF-16 by keyring.
|
|
17
|
+
MAX_PARTS = 32
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@contextmanager
|
|
21
|
+
def session_lock(service, *, directory=None, timeout=30):
|
|
22
|
+
"""Serialize renewal/revocation across processes; the lock contains no credentials."""
|
|
23
|
+
directory = directory or Path.home() / ".apizit" / "session-locks"
|
|
24
|
+
assert_safe_path(directory)
|
|
25
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
path = directory / (hashlib.sha256(service.encode()).hexdigest() + ".lock")
|
|
27
|
+
assert_safe_path(path)
|
|
28
|
+
with path.open("a+b") as stream:
|
|
29
|
+
if stream.tell() == 0:
|
|
30
|
+
stream.write(b"0")
|
|
31
|
+
stream.flush()
|
|
32
|
+
deadline = time.monotonic() + timeout
|
|
33
|
+
while True:
|
|
34
|
+
stream.seek(0)
|
|
35
|
+
try:
|
|
36
|
+
if os.name == "nt":
|
|
37
|
+
import msvcrt
|
|
38
|
+
|
|
39
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
|
|
40
|
+
else:
|
|
41
|
+
import fcntl
|
|
42
|
+
|
|
43
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
44
|
+
break
|
|
45
|
+
except OSError:
|
|
46
|
+
if time.monotonic() >= deadline:
|
|
47
|
+
raise CliError(
|
|
48
|
+
"Another APIZIT process is renewing or clearing the session. Retry shortly.",
|
|
49
|
+
code="SESSION_BUSY",
|
|
50
|
+
exit_code=EXIT_AUTH,
|
|
51
|
+
) from None
|
|
52
|
+
time.sleep(0.05)
|
|
53
|
+
try:
|
|
54
|
+
yield
|
|
55
|
+
finally:
|
|
56
|
+
stream.seek(0)
|
|
57
|
+
if os.name == "nt":
|
|
58
|
+
msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
|
|
59
|
+
else:
|
|
60
|
+
fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CredentialStore:
|
|
64
|
+
def __init__(self, vault, service):
|
|
65
|
+
self.vault = vault
|
|
66
|
+
self.service = service
|
|
67
|
+
|
|
68
|
+
def _parts(self, value):
|
|
69
|
+
if not value:
|
|
70
|
+
return []
|
|
71
|
+
record = json.loads(value)
|
|
72
|
+
if "vault_format" not in record:
|
|
73
|
+
return []
|
|
74
|
+
if (
|
|
75
|
+
record.get("vault_format") != 1
|
|
76
|
+
or not re.fullmatch(r"[0-9a-f]{32}", record.get("generation", ""))
|
|
77
|
+
or type(record.get("parts")) is not int
|
|
78
|
+
or not 1 <= record["parts"] <= MAX_PARTS
|
|
79
|
+
or not re.fullmatch(r"[0-9a-f]{64}", record.get("sha256", ""))
|
|
80
|
+
):
|
|
81
|
+
raise ValueError("Invalid secure session manifest")
|
|
82
|
+
return [f"{self.service}:part:{record['generation']}:{i}" for i in range(record["parts"])]
|
|
83
|
+
|
|
84
|
+
def read(self):
|
|
85
|
+
self._recover()
|
|
86
|
+
for _ in range(3):
|
|
87
|
+
value = self.vault.get_password(self.service, "session")
|
|
88
|
+
parts = self._parts(value)
|
|
89
|
+
if not parts:
|
|
90
|
+
return value
|
|
91
|
+
fragments = [self.vault.get_password(part, "session") for part in parts]
|
|
92
|
+
if any(not isinstance(part, str) or len(part) > PART_SIZE for part in fragments):
|
|
93
|
+
if self.vault.get_password(self.service, "session") != value:
|
|
94
|
+
continue # A concurrent renewal replaced and cleaned the old generation.
|
|
95
|
+
raise ValueError("Incomplete secure session")
|
|
96
|
+
result = "".join(fragments)
|
|
97
|
+
if not hmac.compare_digest(
|
|
98
|
+
hashlib.sha256(result.encode()).hexdigest(), json.loads(value)["sha256"]
|
|
99
|
+
):
|
|
100
|
+
raise ValueError("Invalid secure session integrity")
|
|
101
|
+
return result
|
|
102
|
+
raise ValueError("Secure session changed while reading")
|
|
103
|
+
|
|
104
|
+
def _clear_parts(self, parts):
|
|
105
|
+
for part in parts:
|
|
106
|
+
if self.vault.get_password(part, "session") is not None:
|
|
107
|
+
self.vault.delete_password(part, "session")
|
|
108
|
+
|
|
109
|
+
def _recover(self):
|
|
110
|
+
pending = self.vault.get_password(self.service + ":pending", "session")
|
|
111
|
+
if pending is None:
|
|
112
|
+
return
|
|
113
|
+
journal = json.loads(pending)
|
|
114
|
+
if not re.fullmatch(r"[0-9a-f]{64}", journal.get("target_sha256", "")):
|
|
115
|
+
raise ValueError("Invalid secure session recovery record")
|
|
116
|
+
previous = self._parts(journal.get("previous"))
|
|
117
|
+
next_parts = self._parts(journal.get("next"))
|
|
118
|
+
current = self.vault.get_password(self.service, "session") or ""
|
|
119
|
+
committed = hashlib.sha256(current.encode()).hexdigest() == journal["target_sha256"]
|
|
120
|
+
self._clear_parts(previous if committed else next_parts)
|
|
121
|
+
self.vault.delete_password(self.service + ":pending", "session")
|
|
122
|
+
|
|
123
|
+
def write(self, value):
|
|
124
|
+
if not value.isascii() or len(value) > PART_SIZE * MAX_PARTS:
|
|
125
|
+
raise ValueError("Secure session exceeds its storage limit")
|
|
126
|
+
self._recover()
|
|
127
|
+
previous = self.vault.get_password(self.service, "session")
|
|
128
|
+
old_parts = self._parts(previous)
|
|
129
|
+
new_parts = []
|
|
130
|
+
manifest = value
|
|
131
|
+
if len(value) > PART_SIZE:
|
|
132
|
+
record = {
|
|
133
|
+
"vault_format": 1,
|
|
134
|
+
"generation": secrets.token_hex(16),
|
|
135
|
+
"parts": (len(value) + PART_SIZE - 1) // PART_SIZE,
|
|
136
|
+
"sha256": hashlib.sha256(value.encode()).hexdigest(),
|
|
137
|
+
}
|
|
138
|
+
manifest = json.dumps(record)
|
|
139
|
+
new_parts = self._parts(manifest)
|
|
140
|
+
if new_parts or old_parts:
|
|
141
|
+
# A metadata-only journal in the same vault permits cleanup after process death.
|
|
142
|
+
self.vault.set_password(
|
|
143
|
+
self.service + ":pending",
|
|
144
|
+
"session",
|
|
145
|
+
json.dumps(
|
|
146
|
+
{
|
|
147
|
+
"previous": previous if old_parts else None,
|
|
148
|
+
"next": manifest if new_parts else None,
|
|
149
|
+
"target_sha256": hashlib.sha256(manifest.encode()).hexdigest(),
|
|
150
|
+
}
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
try:
|
|
154
|
+
for index, part in enumerate(new_parts):
|
|
155
|
+
self.vault.set_password(
|
|
156
|
+
part, "session", value[index * PART_SIZE : (index + 1) * PART_SIZE]
|
|
157
|
+
)
|
|
158
|
+
# Publish the complete generation only after every fragment is in the OS vault.
|
|
159
|
+
self.vault.set_password(self.service, "session", manifest)
|
|
160
|
+
except Exception:
|
|
161
|
+
self._recover()
|
|
162
|
+
raise
|
|
163
|
+
self._recover()
|
|
164
|
+
|
|
165
|
+
def delete(self):
|
|
166
|
+
self._recover()
|
|
167
|
+
value = self.vault.get_password(self.service, "session")
|
|
168
|
+
self._clear_parts(self._parts(value))
|
|
169
|
+
if value is not None:
|
|
170
|
+
self.vault.delete_password(self.service, "session")
|
apizit_cli/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Stable CLI exit and error contract."""
|
|
2
|
+
|
|
3
|
+
EXIT_OK = 0
|
|
4
|
+
EXIT_FAILED = 1
|
|
5
|
+
EXIT_USAGE = 2
|
|
6
|
+
EXIT_AUTH = 3
|
|
7
|
+
EXIT_DENIED = 4
|
|
8
|
+
EXIT_CONFLICT = 5
|
|
9
|
+
EXIT_NETWORK = 6
|
|
10
|
+
EXIT_TIMEOUT = 7
|
|
11
|
+
EXIT_INTERRUPTED = 130
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CliError(Exception):
|
|
15
|
+
def __init__(self, message, *, code="CLI_ERROR", exit_code=EXIT_FAILED, details=None):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.code = code
|
|
18
|
+
self.exit_code = exit_code
|
|
19
|
+
self.details = details or {}
|
apizit_cli/http.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""One workspace-fenced HTTP transport for every CLI operation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from apizit_cli.authentication import NativeSession
|
|
11
|
+
from apizit_cli.errors import (
|
|
12
|
+
CliError,
|
|
13
|
+
EXIT_AUTH,
|
|
14
|
+
EXIT_CONFLICT,
|
|
15
|
+
EXIT_DENIED,
|
|
16
|
+
EXIT_NETWORK,
|
|
17
|
+
EXIT_TIMEOUT,
|
|
18
|
+
EXIT_USAGE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def identifier(value):
|
|
23
|
+
if (
|
|
24
|
+
not isinstance(value, str)
|
|
25
|
+
or not value
|
|
26
|
+
or len(value) > 256
|
|
27
|
+
or any(ord(c) < 33 for c in value)
|
|
28
|
+
):
|
|
29
|
+
raise CliError("Invalid resource identifier.", code="INVALID_IDENTIFIER", exit_code=2)
|
|
30
|
+
return quote(value, safe="")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PlatformClient:
|
|
34
|
+
def __init__(self, context, *, session=None, authentication=None):
|
|
35
|
+
self.context = context
|
|
36
|
+
self.http = session or requests.Session()
|
|
37
|
+
self.http.trust_env = False
|
|
38
|
+
self.auth = authentication or NativeSession(context.origin, http=self.http)
|
|
39
|
+
self.request_id = None
|
|
40
|
+
|
|
41
|
+
def request(
|
|
42
|
+
self,
|
|
43
|
+
method,
|
|
44
|
+
path,
|
|
45
|
+
*,
|
|
46
|
+
body=None,
|
|
47
|
+
query=None,
|
|
48
|
+
workspace=True,
|
|
49
|
+
authenticated=True,
|
|
50
|
+
idempotency_key=None,
|
|
51
|
+
timeout=30,
|
|
52
|
+
):
|
|
53
|
+
if not path.startswith("/") or path.startswith("//") or "?" in path or "#" in path:
|
|
54
|
+
raise ValueError("Expected an API-relative path without a query.")
|
|
55
|
+
headers = {"Accept": "application/json"}
|
|
56
|
+
deadline = time.monotonic() + timeout
|
|
57
|
+
if workspace:
|
|
58
|
+
headers["X-Workspace-Id"] = self.context.require_workspace()
|
|
59
|
+
if authenticated:
|
|
60
|
+
headers["Authorization"] = "Bearer " + self.auth.token()
|
|
61
|
+
if idempotency_key:
|
|
62
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
63
|
+
attempts = 3 if method == "GET" or (method == "POST" and idempotency_key) else 1
|
|
64
|
+
renewed = False
|
|
65
|
+
for attempt in range(attempts + 1):
|
|
66
|
+
remaining = deadline - time.monotonic()
|
|
67
|
+
if remaining <= 0:
|
|
68
|
+
raise CliError(
|
|
69
|
+
"Local request time expired. A remote operation may still be running.",
|
|
70
|
+
code="REQUEST_TIMEOUT",
|
|
71
|
+
exit_code=EXIT_TIMEOUT,
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
response = self.http.request(
|
|
75
|
+
method,
|
|
76
|
+
self.context.origin + path,
|
|
77
|
+
headers=headers,
|
|
78
|
+
json=body,
|
|
79
|
+
params=query,
|
|
80
|
+
timeout=(min(5, remaining / 2), remaining / 2),
|
|
81
|
+
allow_redirects=False,
|
|
82
|
+
)
|
|
83
|
+
except (requests.ConnectionError, requests.Timeout):
|
|
84
|
+
if attempt + 1 < attempts:
|
|
85
|
+
time.sleep(min(0.2 * (attempt + 1), max(0, deadline - time.monotonic())))
|
|
86
|
+
continue
|
|
87
|
+
raise CliError(
|
|
88
|
+
"Could not reach APIZIT. A submitted operation may still be running; reuse its idempotency key.",
|
|
89
|
+
code="NETWORK_UNAVAILABLE",
|
|
90
|
+
exit_code=EXIT_NETWORK,
|
|
91
|
+
) from None
|
|
92
|
+
except requests.RequestException:
|
|
93
|
+
raise CliError(
|
|
94
|
+
"Could not reach APIZIT.", code="NETWORK_UNAVAILABLE", exit_code=EXIT_NETWORK
|
|
95
|
+
) from None
|
|
96
|
+
self.request_id = response.headers.get("X-Request-Id")
|
|
97
|
+
if (
|
|
98
|
+
response.status_code == 401
|
|
99
|
+
and authenticated
|
|
100
|
+
and not renewed
|
|
101
|
+
and not self.auth.injected_token
|
|
102
|
+
):
|
|
103
|
+
headers["Authorization"] = "Bearer " + self.auth.token(force_refresh=True)
|
|
104
|
+
renewed = True
|
|
105
|
+
continue
|
|
106
|
+
if response.status_code in {502, 503, 504} and attempt + 1 < attempts:
|
|
107
|
+
time.sleep(min(0.2 * (attempt + 1), max(0, deadline - time.monotonic())))
|
|
108
|
+
continue
|
|
109
|
+
if response.status_code == 204:
|
|
110
|
+
return {}
|
|
111
|
+
try:
|
|
112
|
+
payload = response.json()
|
|
113
|
+
if not isinstance(payload, dict):
|
|
114
|
+
raise ValueError()
|
|
115
|
+
except (ValueError, TypeError):
|
|
116
|
+
raise CliError(
|
|
117
|
+
"APIZIT returned an invalid response.",
|
|
118
|
+
code="INVALID_RESPONSE",
|
|
119
|
+
exit_code=EXIT_NETWORK,
|
|
120
|
+
details={"http_status": response.status_code, "request_id": self.request_id},
|
|
121
|
+
) from None
|
|
122
|
+
if not 200 <= response.status_code < 300:
|
|
123
|
+
message = payload.get("error")
|
|
124
|
+
exit_code = {
|
|
125
|
+
400: EXIT_USAGE,
|
|
126
|
+
401: EXIT_AUTH,
|
|
127
|
+
402: EXIT_DENIED,
|
|
128
|
+
403: EXIT_DENIED,
|
|
129
|
+
409: EXIT_CONFLICT,
|
|
130
|
+
423: EXIT_DENIED,
|
|
131
|
+
422: EXIT_USAGE,
|
|
132
|
+
429: EXIT_DENIED,
|
|
133
|
+
428: EXIT_USAGE,
|
|
134
|
+
}.get(response.status_code, EXIT_NETWORK if response.status_code >= 500 else 1)
|
|
135
|
+
raise CliError(
|
|
136
|
+
message if isinstance(message, str) else "APIZIT rejected the request.",
|
|
137
|
+
code=payload.get("code") or "HTTP_ERROR",
|
|
138
|
+
exit_code=exit_code,
|
|
139
|
+
details={
|
|
140
|
+
"http_status": response.status_code,
|
|
141
|
+
"request_id": self.request_id,
|
|
142
|
+
"retry_after": response.headers.get("Retry-After"),
|
|
143
|
+
**{
|
|
144
|
+
key: payload[key]
|
|
145
|
+
for key in (
|
|
146
|
+
"profile_required",
|
|
147
|
+
"required_fields",
|
|
148
|
+
"action",
|
|
149
|
+
"job_id",
|
|
150
|
+
"scan_id",
|
|
151
|
+
"deployment_id",
|
|
152
|
+
)
|
|
153
|
+
if key in payload
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
return payload
|
|
158
|
+
raise CliError(
|
|
159
|
+
"APIZIT authentication could not be renewed.",
|
|
160
|
+
code="LOGIN_REQUIRED",
|
|
161
|
+
exit_code=EXIT_AUTH,
|
|
162
|
+
)
|
apizit_cli/main.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Public executable: HTTP client only, with a stable machine output contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from apizit_cli import __version__
|
|
11
|
+
from apizit_cli.commands import COMMANDS, GLOBAL_OPTIONS
|
|
12
|
+
from apizit_cli.configuration import ConfigurationError
|
|
13
|
+
from apizit_cli.errors import CliError, EXIT_INTERRUPTED, EXIT_USAGE
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Parser(argparse.ArgumentParser):
|
|
17
|
+
def error(self, message):
|
|
18
|
+
# argparse can include argument values in its diagnostics. Keep those out of logs.
|
|
19
|
+
raise CliError(
|
|
20
|
+
"Invalid or missing command arguments. Run apizit <command> --help.",
|
|
21
|
+
code="INVALID_ARGUMENTS",
|
|
22
|
+
exit_code=EXIT_USAGE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def positive_float(value):
|
|
27
|
+
number = float(value)
|
|
28
|
+
if not math.isfinite(number) or number <= 0:
|
|
29
|
+
raise ValueError("Expected a positive finite number.")
|
|
30
|
+
return number
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def add_options(parser, options, *, global_options=False):
|
|
34
|
+
for option in options:
|
|
35
|
+
kwargs = {"help": option.help}
|
|
36
|
+
if option.action:
|
|
37
|
+
kwargs["action"] = option.action
|
|
38
|
+
elif option.value_type != "str":
|
|
39
|
+
kwargs["type"] = {"int": int, "positive_float": positive_float}[option.value_type]
|
|
40
|
+
if option.choices:
|
|
41
|
+
kwargs["choices"] = option.choices
|
|
42
|
+
if option.nargs:
|
|
43
|
+
kwargs["nargs"] = option.nargs
|
|
44
|
+
if option.flags[0].startswith("-"):
|
|
45
|
+
kwargs["default"] = argparse.SUPPRESS if global_options else option.default
|
|
46
|
+
parser.add_argument(*option.flags, **kwargs)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_parser():
|
|
50
|
+
common = Parser(add_help=False)
|
|
51
|
+
add_options(common, GLOBAL_OPTIONS, global_options=True)
|
|
52
|
+
parser = Parser(
|
|
53
|
+
prog="apizit",
|
|
54
|
+
description="Launch Python as a managed public API. APIZIT development beta.",
|
|
55
|
+
parents=[common],
|
|
56
|
+
allow_abbrev=False,
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument("--version", action="version", version=f"apizit {__version__}")
|
|
59
|
+
root = parser.add_subparsers(dest="command", required=True)
|
|
60
|
+
groups = {}
|
|
61
|
+
for command in COMMANDS:
|
|
62
|
+
words = command.path.split()
|
|
63
|
+
parent = root
|
|
64
|
+
if len(words) == 2:
|
|
65
|
+
if words[0] not in groups:
|
|
66
|
+
group = root.add_parser(
|
|
67
|
+
words[0],
|
|
68
|
+
parents=[common],
|
|
69
|
+
allow_abbrev=False,
|
|
70
|
+
help=f"Manage {words[0]} resources.",
|
|
71
|
+
)
|
|
72
|
+
groups[words[0]] = group.add_subparsers(dest="action", required=True)
|
|
73
|
+
parent = groups[words[0]]
|
|
74
|
+
leaf = parent.add_parser(
|
|
75
|
+
words[-1],
|
|
76
|
+
parents=[common],
|
|
77
|
+
allow_abbrev=False,
|
|
78
|
+
help=command.summary,
|
|
79
|
+
description=command.summary,
|
|
80
|
+
)
|
|
81
|
+
leaf.set_defaults(command_path=command.path)
|
|
82
|
+
add_options(leaf, command.options)
|
|
83
|
+
return parser
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def envelope(*, command, data=None, error=None, runner=None):
|
|
87
|
+
return {
|
|
88
|
+
"schema_version": 1,
|
|
89
|
+
"ok": error is None,
|
|
90
|
+
"command": command,
|
|
91
|
+
"data": data,
|
|
92
|
+
"error": (
|
|
93
|
+
{"code": error.code, "message": str(error), "details": error.details} if error else None
|
|
94
|
+
),
|
|
95
|
+
"meta": {
|
|
96
|
+
"cli_version": __version__,
|
|
97
|
+
"workspace_id": runner.context.workspace_id if runner else None,
|
|
98
|
+
"request_id": runner.client.request_id if runner else None,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main(argv=None):
|
|
104
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
105
|
+
json_mode = "--json" in argv
|
|
106
|
+
runner = None
|
|
107
|
+
command = None
|
|
108
|
+
exit_code = 0
|
|
109
|
+
try:
|
|
110
|
+
args = build_parser().parse_args(argv)
|
|
111
|
+
json_mode = bool(getattr(args, "json", False))
|
|
112
|
+
command = args.command_path
|
|
113
|
+
from apizit_cli.operations import CommandRunner
|
|
114
|
+
|
|
115
|
+
runner = CommandRunner(args)
|
|
116
|
+
data = runner.run()
|
|
117
|
+
result = envelope(command=command, data=data, runner=runner)
|
|
118
|
+
except KeyboardInterrupt:
|
|
119
|
+
exit_code = EXIT_INTERRUPTED
|
|
120
|
+
error = CliError(
|
|
121
|
+
"Local observation interrupted. A remote operation may still be running.",
|
|
122
|
+
code="LOCAL_INTERRUPTED",
|
|
123
|
+
exit_code=EXIT_INTERRUPTED,
|
|
124
|
+
details=runner.tracking if runner else {},
|
|
125
|
+
)
|
|
126
|
+
result = envelope(command=command, error=error, runner=runner)
|
|
127
|
+
except CliError as error:
|
|
128
|
+
exit_code = error.exit_code
|
|
129
|
+
if runner:
|
|
130
|
+
error.details = {**runner.tracking, **error.details}
|
|
131
|
+
result = envelope(command=command, error=error, runner=runner)
|
|
132
|
+
except ConfigurationError as cause:
|
|
133
|
+
exit_code = EXIT_USAGE
|
|
134
|
+
error = CliError(str(cause), code="CONFIGURATION_INVALID", exit_code=EXIT_USAGE)
|
|
135
|
+
result = envelope(command=command, error=error, runner=runner)
|
|
136
|
+
except (OSError, UnicodeError):
|
|
137
|
+
exit_code = EXIT_USAGE
|
|
138
|
+
error = CliError(
|
|
139
|
+
"A local file or device could not be read or written.",
|
|
140
|
+
code="LOCAL_IO_ERROR",
|
|
141
|
+
exit_code=EXIT_USAGE,
|
|
142
|
+
details=runner.tracking if runner else {},
|
|
143
|
+
)
|
|
144
|
+
result = envelope(command=command, error=error, runner=runner)
|
|
145
|
+
except Exception:
|
|
146
|
+
exit_code = 1
|
|
147
|
+
error = CliError(
|
|
148
|
+
"The client could not complete this command. Use the request ID and client version when reporting it.",
|
|
149
|
+
code="CLIENT_UNEXPECTED_ERROR",
|
|
150
|
+
details=runner.tracking if runner else {},
|
|
151
|
+
)
|
|
152
|
+
result = envelope(command=command, error=error, runner=runner)
|
|
153
|
+
if json_mode:
|
|
154
|
+
print(json.dumps(result, ensure_ascii=True, separators=(",", ":")))
|
|
155
|
+
elif result["ok"]:
|
|
156
|
+
# JSON quoting prevents untrusted API names or logs from executing terminal escapes.
|
|
157
|
+
print(json.dumps(result["data"], ensure_ascii=True, indent=2))
|
|
158
|
+
else:
|
|
159
|
+
print(json.dumps(result["error"], ensure_ascii=True, indent=2), file=sys.stderr)
|
|
160
|
+
return exit_code
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
raise SystemExit(main())
|