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.
- verulla_cli/__init__.py +7 -0
- verulla_cli/apps_client.py +182 -0
- verulla_cli/auth.py +235 -0
- verulla_cli/buildkit.py +107 -0
- verulla_cli/deploy_client.py +47 -0
- verulla_cli/emulate.py +124 -0
- verulla_cli/main.py +1000 -0
- verulla_cli/packaging.py +106 -0
- verulla_cli/secrets_client.py +79 -0
- verulla_cli-0.0.2.dist-info/METADATA +8 -0
- verulla_cli-0.0.2.dist-info/RECORD +13 -0
- verulla_cli-0.0.2.dist-info/WHEEL +4 -0
- verulla_cli-0.0.2.dist-info/entry_points.txt +2 -0
verulla_cli/__init__.py
ADDED
|
@@ -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()
|
verulla_cli/buildkit.py
ADDED
|
@@ -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()
|