verulla-cli 0.0.2__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,7 @@
1
+ """Verulla CLI.
2
+
3
+ Human + agent entry point: login (OIDC device flow), dev (local Docker
4
+ emulation via verulla_manifest), deploy, secrets, logs.
5
+ """
6
+
7
+ __version__ = "0.0.2"
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+ from verulla_manifest.sketch_events import validate_sketch_event_page
7
+
8
+
9
+ class AppsError(Exception):
10
+ """Raised for failures talking to the app management API."""
11
+
12
+
13
+ def _headers(token: str) -> dict[str, str]:
14
+ return {"Authorization": f"Bearer {token}"}
15
+
16
+
17
+ def _request(api_url: str, token: str, method: str, path: str, client=None, *, extra_headers=None, params=None, json_body=None, raw=False) -> Any:
18
+ owns = client is None
19
+ http = client or httpx.Client(timeout=10)
20
+ try:
21
+ headers = {**_headers(token), **(extra_headers or {})}
22
+ request_kwargs = {}
23
+ if params is not None:
24
+ request_kwargs["params"] = params
25
+ if json_body is not None:
26
+ request_kwargs["json"] = json_body
27
+ response = http.request(method, f"{api_url}{path}", headers=headers, **request_kwargs)
28
+ response.raise_for_status()
29
+ return response.text if raw else (response.json() if response.content else None)
30
+ except httpx.HTTPStatusError as exc:
31
+ detail = exc.response.text
32
+ try:
33
+ detail = exc.response.json().get("detail", detail)
34
+ if isinstance(detail, dict):
35
+ code = detail.get("code")
36
+ code_text = str(code) if code is not None else ""
37
+ message = {
38
+ "insufficient_wallet_funds": "Not enough wallet funds. Top up your wallet, then run this command again.",
39
+ "spend_cap_exceeded": "This would exceed your spending limit. Ask the account owner to raise the limit, then try again.",
40
+ "spend_cap_required": "Set a monthly spending limit before activating Live.",
41
+ "live_activation_disabled": "Live activation is not enabled yet. Contact Verulla support.",
42
+ }.get(code_text, detail.get("message") or str(detail))
43
+ detail = f"{message} (code: {code_text})" if code_text else message
44
+ else:
45
+ detail = str(detail)
46
+ except (ValueError, AttributeError):
47
+ pass
48
+ raise AppsError(f"request failed ({exc.response.status_code}): {detail}") from exc
49
+ except httpx.HTTPError as exc:
50
+ raise AppsError(f"could not reach {api_url}: {exc}") from exc
51
+ finally:
52
+ if owns:
53
+ http.close()
54
+
55
+
56
+ def list_apps(api_url: str, token: str, client=None) -> list[dict]:
57
+ return _request(api_url, token, "GET", "/v1/apps", client)["apps"]
58
+
59
+
60
+ def get_app(api_url: str, token: str, name: str, client=None) -> dict:
61
+ return _request(api_url, token, "GET", f"/v1/apps/{name}", client)
62
+
63
+
64
+ def get_sketch_events(api_url: str, token: str, name: str, *, cursor: str | None = None,
65
+ limit: int = 100, client=None) -> dict:
66
+ from urllib.parse import quote
67
+ params: dict[str, str | int] = {"limit": limit}
68
+ if cursor is not None:
69
+ params["cursor"] = cursor
70
+ response = _request(api_url, token, "GET", f"/v1/apps/{quote(name, safe='')}/events", client, params=params)
71
+ try:
72
+ return validate_sketch_event_page(response)
73
+ except ValueError as exc:
74
+ raise AppsError(f"invalid Sketch event response: {exc}") from exc
75
+
76
+ def get_sketch_status(api_url: str, token: str, name: str, client=None) -> dict:
77
+ from urllib.parse import quote
78
+ return _request(api_url, token, "GET", f"/v1/apps/{quote(name, safe='')}/sketch", client)
79
+
80
+
81
+ def issue_owner_claim(api_url: str, token: str, owner_email: str, idempotency_key: str, client=None) -> dict:
82
+ try:
83
+ return _request(api_url, token, "POST", "/v1/owner-claims", client,
84
+ extra_headers={"Idempotency-Key": idempotency_key},
85
+ json_body={"owner_email": owner_email})
86
+ except AppsError:
87
+ # Never let an API error echo the supplied owner email into the CLI.
88
+ raise AppsError("Owner claim request failed; verify authorization and try again.") from None
89
+
90
+
91
+ def get_owner_claim_status(api_url: str, token: str, client=None) -> dict:
92
+ return _request(api_url, token, "GET", "/v1/owner-claims/current", client)
93
+
94
+
95
+ def activate_app(api_url: str, token: str, name: str, idempotency_key: str, client=None) -> dict:
96
+ return _request(api_url, token, "POST", f"/v1/apps/{name}/activate", client,
97
+ extra_headers={"Idempotency-Key": idempotency_key})
98
+
99
+
100
+ def get_spend_summary(api_url: str, token: str, client=None) -> dict:
101
+ return _request(api_url, token, "GET", "/v1/wallet/spend", client)
102
+
103
+
104
+ def get_wallet_summary(api_url: str, token: str, client=None) -> dict:
105
+ return _request(api_url, token, "GET", "/v1/wallet", client)
106
+
107
+
108
+ def get_usage_summary(api_url: str, token: str, client=None, *, month: str | None = None) -> dict:
109
+ params = {"month": month} if month is not None else None
110
+ return _request(api_url, token, "GET", "/v1/usage", client, params=params)
111
+
112
+
113
+ def get_term_usage(api_url: str, token: str, client=None, *, app: str | None = None) -> dict:
114
+ params = {"app": app} if app is not None else None
115
+ return _request(api_url, token, "GET", "/v1/usage/term", client, params=params)
116
+
117
+
118
+ def get_pricing(api_url: str, token: str, client=None) -> dict:
119
+ return _request(api_url, token, "GET", "/pricing.json", client)
120
+
121
+
122
+ def create_topup(api_url: str, token: str, amount_cents: int, currency: str = "USD", client=None) -> dict:
123
+ return _request(api_url, token, "POST", "/v1/wallet/topups", client,
124
+ json_body={"amount_cents": amount_cents, "currency": currency})
125
+
126
+
127
+ def get_app_logs(api_url: str, token: str, name: str, client=None) -> dict:
128
+ return _request(api_url, token, "GET", f"/v1/apps/{name}/logs", client)
129
+
130
+
131
+ def delete_app(api_url: str, token: str, name: str, client=None) -> dict:
132
+ return _request(api_url, token, "DELETE", f"/v1/apps/{name}", client)
133
+
134
+
135
+ def get_auth_policy(api_url: str, token: str, app: str, client=None) -> dict:
136
+ from urllib.parse import quote
137
+ return _request(api_url, token, "GET", f"/v1/apps/{quote(app, safe='')}/auth", client)
138
+
139
+
140
+ def set_auth_policy(api_url: str, token: str, app: str, version: int, policy: dict, client=None) -> dict:
141
+ from urllib.parse import quote
142
+ return _request(api_url, token, "PUT", f"/v1/apps/{quote(app, safe='')}/auth", client,
143
+ params={"version": version}, json_body={"policy": policy})
144
+
145
+
146
+ def list_customers(api_url: str, token: str, app: str, client=None, *, search: str | None = None,
147
+ limit: int = 50, offset: int = 0) -> dict:
148
+ from urllib.parse import quote
149
+ params: dict[str, int | str] = {"limit": limit, "offset": offset}
150
+ if search is not None:
151
+ params["q"] = search
152
+ return _request(api_url, token, "GET", f"/v1/apps/{quote(app, safe='')}/customers", client, params=params)
153
+
154
+
155
+ def lock_customer(api_url: str, token: str, app: str, customer_id: str, client=None) -> dict:
156
+ from urllib.parse import quote
157
+ return _request(api_url, token, "POST", f"/v1/apps/{quote(app, safe='')}/customers/{quote(customer_id, safe='')}/lock", client)
158
+
159
+
160
+ def unlock_customer(api_url: str, token: str, app: str, customer_id: str, client=None) -> dict:
161
+ from urllib.parse import quote
162
+ return _request(api_url, token, "POST", f"/v1/apps/{quote(app, safe='')}/customers/{quote(customer_id, safe='')}/unlock", client)
163
+
164
+
165
+ def delete_customer(api_url: str, token: str, app: str, customer_id: str, client=None) -> dict | None:
166
+ from urllib.parse import quote
167
+ return _request(api_url, token, "DELETE", f"/v1/apps/{quote(app, safe='')}/customers/{quote(customer_id, safe='')}", client)
168
+
169
+
170
+ def invite_customer(api_url: str, token: str, app: str, email: str, client=None) -> dict:
171
+ from urllib.parse import quote
172
+ return _request(api_url, token, "POST", f"/v1/apps/{quote(app, safe='')}/customers", client,
173
+ json_body={"email": email})
174
+
175
+
176
+ def rollback_app(api_url: str, token: str, name: str, client=None) -> dict:
177
+ return _request(api_url, token, "POST", f"/v1/apps/{name}/rollback", client)
178
+
179
+
180
+ def export_customers(api_url: str, token: str, app: str, client=None) -> str:
181
+ from urllib.parse import quote
182
+ return _request(api_url, token, "GET", f"/v1/apps/{quote(app, safe='')}/customers/export", client, raw=True)
verulla_cli/auth.py ADDED
@@ -0,0 +1,235 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import stat
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import httpx
13
+
14
+ CREDENTIALS_PATH = Path.home() / ".verulla" / "credentials.json"
15
+ DEFAULT_SCOPES = ["openid", "profile", "email"]
16
+
17
+
18
+ class DeviceLoginError(Exception):
19
+ """Raised for any failure during the device authorization login flow."""
20
+
21
+
22
+ def fetch_auth_config(api_url: str, http_client: httpx.Client) -> dict:
23
+ try:
24
+ response = http_client.get(f"{api_url}/v1/auth/config")
25
+ response.raise_for_status()
26
+ except httpx.HTTPError as exc:
27
+ raise DeviceLoginError(f"could not reach {api_url}: {exc}") from exc
28
+ return response.json()
29
+
30
+
31
+ def start_device_authorization(
32
+ device_authorization_endpoint: str,
33
+ client_id: str,
34
+ scopes: list[str],
35
+ http_client: httpx.Client,
36
+ ) -> dict:
37
+ try:
38
+ response = http_client.post(
39
+ device_authorization_endpoint,
40
+ data={"client_id": client_id, "scope": " ".join(scopes)},
41
+ )
42
+ response.raise_for_status()
43
+ except httpx.HTTPError as exc:
44
+ raise DeviceLoginError(f"device authorization request failed: {exc}") from exc
45
+ return response.json()
46
+
47
+
48
+ _MAX_POLL_FAILURES = 5 # consecutive network/non-JSON failures before giving up
49
+ _MAX_BACKOFF_SECONDS = 60
50
+
51
+
52
+ def poll_for_token(
53
+ token_endpoint: str,
54
+ client_id: str,
55
+ device_code: str,
56
+ interval: int,
57
+ expires_in: int,
58
+ http_client: httpx.Client,
59
+ sleep_fn=time.sleep,
60
+ ) -> dict:
61
+ """Poll the token endpoint per RFC 8628 §3.5 until success, denial, or timeout.
62
+
63
+ Transient transport errors and non-JSON replies (e.g. a gateway 502 page) are
64
+ retried with exponential backoff; after ``_MAX_POLL_FAILURES`` consecutive
65
+ failures the login is abandoned with a clear error. Any valid reply from the
66
+ IdP (including ``authorization_pending``) resets the failure budget.
67
+ """
68
+ elapsed = 0
69
+ failures = 0
70
+ while elapsed < expires_in:
71
+ sleep_fn(interval)
72
+ elapsed += interval
73
+ try:
74
+ response = http_client.post(
75
+ token_endpoint,
76
+ data={
77
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
78
+ "device_code": device_code,
79
+ "client_id": client_id,
80
+ },
81
+ )
82
+ body = response.json()
83
+ if not isinstance(body, dict):
84
+ raise ValueError("token endpoint returned a non-object reply")
85
+ except (httpx.HTTPError, ValueError) as exc:
86
+ failures += 1
87
+ if failures >= _MAX_POLL_FAILURES:
88
+ raise DeviceLoginError(
89
+ f"could not reach the token endpoint after {failures} attempts "
90
+ f"(network or invalid reply): {exc}"
91
+ ) from exc
92
+ backoff = min(2**failures, _MAX_BACKOFF_SECONDS)
93
+ sleep_fn(backoff)
94
+ elapsed += backoff
95
+ continue
96
+ failures = 0
97
+ if response.status_code == 200 and "access_token" in body:
98
+ return body
99
+ error = body.get("error")
100
+ if error == "authorization_pending":
101
+ continue
102
+ if error == "slow_down":
103
+ interval += 5
104
+ continue
105
+ if error == "expired_token":
106
+ raise DeviceLoginError("device code expired — run `verulla login` again")
107
+ raise DeviceLoginError(f"device authorization failed: {error or body}")
108
+ raise DeviceLoginError("device authorization timed out")
109
+
110
+
111
+ def save_credentials(
112
+ token_response: dict, issuer: str, client_id: str, path: Path = CREDENTIALS_PATH,
113
+ ) -> None:
114
+ """Persist tokens atomically with 0600 permissions — never world/group readable.
115
+
116
+ Written to a private (0600, O_EXCL) temp file in the same directory, fsynced,
117
+ then renamed over the target, so an interrupted write leaves the old file
118
+ intact and a pre-existing permissive file is replaced by a 0600 one.
119
+ """
120
+ path.parent.mkdir(parents=True, exist_ok=True)
121
+ data = {
122
+ "issuer": issuer,
123
+ "client_id": client_id,
124
+ "access_token": token_response.get("access_token"),
125
+ "id_token": token_response.get("id_token"),
126
+ "refresh_token": token_response.get("refresh_token"),
127
+ "token_type": token_response.get("token_type", "Bearer"),
128
+ }
129
+ expires_in = token_response.get("expires_in")
130
+ if isinstance(expires_in, (int, float)) and not isinstance(expires_in, bool):
131
+ data["expires_at"] = time.time() + expires_in
132
+ try:
133
+ previous = stat.S_IMODE(path.stat().st_mode)
134
+ except FileNotFoundError:
135
+ previous = None
136
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
137
+ try:
138
+ with os.fdopen(fd, "w") as f:
139
+ json.dump(data, f)
140
+ f.flush()
141
+ os.fsync(f.fileno())
142
+ os.replace(tmp_name, path)
143
+ except BaseException:
144
+ try:
145
+ os.unlink(tmp_name)
146
+ except FileNotFoundError:
147
+ pass
148
+ raise
149
+ if previous is not None and previous & 0o077:
150
+ print(
151
+ f"warning: {path} had loose permissions ({previous:04o}); tightened to 0600",
152
+ file=sys.stderr,
153
+ )
154
+
155
+
156
+ def load_credentials(path: Path = CREDENTIALS_PATH) -> Optional[dict]:
157
+ """Load credentials from the credentials file, if it exists.
158
+
159
+ Returns None if the file does not exist (not an error — the caller may
160
+ be using VERULLA_TOKEN env var instead). A corrupt file raises
161
+ DeviceLoginError telling the user to log in again.
162
+ """
163
+ if not path.is_file():
164
+ return None
165
+ try:
166
+ with path.open() as f:
167
+ creds = json.load(f)
168
+ except (OSError, ValueError) as exc:
169
+ raise DeviceLoginError(
170
+ f"credentials file {path} is unreadable or corrupt ({exc}) — run `verulla login` again"
171
+ ) from exc
172
+ if not isinstance(creds, dict) or not creds.get("access_token"):
173
+ raise DeviceLoginError(
174
+ f"credentials file {path} is corrupt (no access token) — run `verulla login` again"
175
+ )
176
+ return creds
177
+
178
+
179
+ def get_token() -> str:
180
+ """Get an authentication token, preferring VERULLA_TOKEN env var,
181
+ falling back to the stored credentials file.
182
+
183
+ Stored credentials past their recorded expiry are rejected with a clear
184
+ re-login message (the CLI does not refresh tokens itself).
185
+ """
186
+ token = os.environ.get("VERULLA_TOKEN")
187
+ if token:
188
+ return token
189
+ creds = load_credentials(CREDENTIALS_PATH)
190
+ if creds is None:
191
+ raise DeviceLoginError("not authenticated — run `verulla login` first or set VERULLA_TOKEN")
192
+ expires_at = creds.get("expires_at")
193
+ if isinstance(expires_at, (int, float)) and time.time() >= expires_at:
194
+ raise DeviceLoginError("stored credentials have expired — run `verulla login` again")
195
+ return creds["access_token"]
196
+
197
+
198
+ def run_device_login(
199
+ api_url: str,
200
+ http_client: httpx.Client | None = None,
201
+ sleep_fn=time.sleep,
202
+ credentials_path: Path = CREDENTIALS_PATH,
203
+ ) -> dict:
204
+ """Orchestrate the full device authorization grant and persist credentials.
205
+
206
+ Returns the token response dict.
207
+ """
208
+ owns_client = http_client is None
209
+ client = http_client or httpx.Client(timeout=10)
210
+ try:
211
+ config = fetch_auth_config(api_url, client)
212
+ device = start_device_authorization(
213
+ config["device_authorization_endpoint"],
214
+ config["client_id"],
215
+ config.get("scopes", DEFAULT_SCOPES),
216
+ client,
217
+ )
218
+ verification = device.get("verification_uri_complete") or device.get("verification_uri")
219
+ print(f"To continue, open: {verification}")
220
+ if "verification_uri_complete" not in device:
221
+ print(f"And enter code: {device['user_code']}")
222
+ token = poll_for_token(
223
+ config["token_endpoint"],
224
+ config["client_id"],
225
+ device["device_code"],
226
+ device.get("interval", 5),
227
+ device.get("expires_in", 600),
228
+ client,
229
+ sleep_fn=sleep_fn,
230
+ )
231
+ save_credentials(token, config["issuer"], config["client_id"], path=credentials_path)
232
+ return token
233
+ finally:
234
+ if owns_client:
235
+ client.close()
@@ -0,0 +1,107 @@
1
+ """BuildKit backend for local `verulla dev` builds."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import shlex
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ from verulla_cli.emulate import EmulationError
13
+
14
+
15
+ BUILDKIT_BUILD_TIMEOUT_SECONDS = 600
16
+
17
+
18
+ def select_builder(requested: str, docker_available: bool) -> str:
19
+ if requested == "docker":
20
+ if not docker_available:
21
+ raise EmulationError("Docker requested but unavailable; set BUILDKIT_HOST or choose --builder buildkit")
22
+ return "docker"
23
+ if requested == "buildkit":
24
+ if not os.environ.get("BUILDKIT_HOST"):
25
+ raise EmulationError("BuildKit requested but BUILDKIT_HOST is not set")
26
+ return "buildkit"
27
+ if docker_available:
28
+ return "docker"
29
+ if os.environ.get("BUILDKIT_HOST"):
30
+ return "buildkit"
31
+ raise EmulationError("No builder available: choose --builder docker|buildkit, start Docker, or set BUILDKIT_HOST")
32
+
33
+
34
+ class BuildKitRunner:
35
+ def __init__(self, address: str | None = None):
36
+ self.address = address or os.environ.get("BUILDKIT_HOST")
37
+ if not self.address:
38
+ raise EmulationError("BUILDKIT_HOST is not set")
39
+ if shutil.which("buildctl") is None:
40
+ raise EmulationError("buildctl not found on PATH (required with BUILDKIT_HOST)")
41
+
42
+ def build(self, context_dir: Path, dockerfile_name: str, *, check: bool = False, dockerfile_dir: Path | None = None) -> str:
43
+ command = [
44
+ "buildctl", "--addr", self.address, "build", "--frontend", "dockerfile.v0",
45
+ "--local", f"context={context_dir}", "--local", f"dockerfile={dockerfile_dir or context_dir}",
46
+ "--opt", f"filename={dockerfile_name}",
47
+ ]
48
+ tls_dir = os.environ.get("BUILDKIT_TLS_DIR", "/run/buildkit-client")
49
+ tls_servername = os.environ.get("BUILDKIT_TLS_SERVERNAME", "buildkitd.tools.svc.cluster.local")
50
+ if Path(tls_dir, "ca.crt").exists():
51
+ command += ["--tlsdir", tls_dir]
52
+ if tls_servername:
53
+ command += ["--tlsservername", tls_servername]
54
+ if check:
55
+ command += ["--no-cache", "--output", "type=oci,dest=/dev/null", "--progress=plain"]
56
+ try:
57
+ result = subprocess.run(
58
+ command,
59
+ text=True,
60
+ capture_output=True,
61
+ check=False,
62
+ timeout=BUILDKIT_BUILD_TIMEOUT_SECONDS,
63
+ )
64
+ except subprocess.TimeoutExpired:
65
+ raise EmulationError(
66
+ f"BuildKit build timed out after {BUILDKIT_BUILD_TIMEOUT_SECONDS} seconds"
67
+ ) from None
68
+ except OSError as exc:
69
+ raise EmulationError(f"could not execute buildctl: {exc}") from exc
70
+ output = result.stdout + result.stderr
71
+ if result.returncode and not check:
72
+ raise EmulationError(f"BuildKit build failed (exit {result.returncode}): {output.strip()}")
73
+ return output
74
+
75
+
76
+ def check_dockerfile(function, routes, output_dir: Path | None = None) -> str:
77
+ """Create a BuildKit-only check Dockerfile which starts the app and probes routes."""
78
+ plan = function.build_plan
79
+ original = plan.dockerfile_path or ".verulla.Dockerfile"
80
+ source_path = (output_dir / original) if output_dir and (output_dir / original).is_file() else (function.src_root / original)
81
+ source = source_path.read_text()
82
+ run_cmd = plan.run_cmd
83
+ if run_cmd:
84
+ start = shlex.join(run_cmd)
85
+ starter = f"({start}) >/tmp/verulla-app.log 2>&1 &"
86
+ else:
87
+ starter = "(/docker-entrypoint.sh nginx -g 'daemon off;') >/tmp/verulla-app.log 2>&1 &"
88
+ paths = [route.path for route in routes if route.function == function.name]
89
+ probe = "\n".join(
90
+ f"code=$(wget -S -O /dev/null http://127.0.0.1:{plan.port}{shlex.quote(path)} 2>&1 | awk '{{for (i=1;i<=NF;i++) if ($i ~ /^HTTP\\//) c=$(i+1)}} END {{print c+0}}'); echo ROUTE {shlex.quote(path)} $code; test $code -ge 200 -a $code -lt 400"
91
+ for path in paths
92
+ )
93
+ script = f"# check-{uuid.uuid4().hex}\n{starter}\nfor i in $(seq 1 60); do wget -q -O /dev/null http://127.0.0.1:{plan.port}/ && break; sleep 1; done\n{probe}\n"
94
+ output_dir = output_dir or function.src_root
95
+ output_dir.mkdir(parents=True, exist_ok=True)
96
+ check_name = ".verulla.Check.Dockerfile"
97
+ # Python and Node slim images lack wget; install the probe dependency only
98
+ # in this disposable check image (the nginx static image already has it).
99
+ probe_setup = (
100
+ "USER root\n"
101
+ "RUN apt-get update && apt-get install -y --no-install-recommends wget && rm -rf /var/lib/apt/lists/*\n"
102
+ "USER 10001:10001\n"
103
+ if (plan.base_image or "").startswith(("python:", "node:"))
104
+ else ""
105
+ )
106
+ (output_dir / check_name).write_text(source + "\n" + probe_setup + "RUN --network=none " + json.dumps(["/bin/sh", "-c", script]) + "\n")
107
+ return check_name
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+
6
+ class DeployError(Exception):
7
+ """Raised for any failure talking to the control plane's /v1/deployments API."""
8
+
9
+
10
+ def _error_detail(exc: httpx.HTTPStatusError) -> str:
11
+ try:
12
+ body = exc.response.json()
13
+ except ValueError:
14
+ return str(exc)
15
+ if isinstance(body, dict) and body.get("detail"):
16
+ return str(body["detail"])
17
+ return str(exc)
18
+
19
+
20
+ def create_deployment(
21
+ api_url: str,
22
+ access_token: str,
23
+ tarball: bytes,
24
+ http_client: httpx.Client | None = None,
25
+ ) -> dict:
26
+ """Upload a gzip source tarball and return the created deployment record."""
27
+ owns_client = http_client is None
28
+ client = http_client or httpx.Client(timeout=120)
29
+ try:
30
+ response = client.post(
31
+ f"{api_url}/v1/deployments",
32
+ content=tarball,
33
+ headers={
34
+ "Authorization": f"Bearer {access_token}",
35
+ "Content-Type": "application/gzip",
36
+ },
37
+ )
38
+ response.raise_for_status()
39
+ except httpx.HTTPStatusError as exc:
40
+ status = exc.response.status_code
41
+ raise DeployError(f"deploy failed ({status}): {_error_detail(exc)}") from exc
42
+ except httpx.HTTPError as exc:
43
+ raise DeployError(f"could not reach {api_url}: {exc}") from exc
44
+ finally:
45
+ if owns_client:
46
+ client.close()
47
+ return response.json()