verulla-cli 0.0.2__tar.gz → 0.0.3__tar.gz
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-0.0.2 → verulla_cli-0.0.3}/PKG-INFO +1 -1
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/pyproject.toml +1 -1
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_apps_client.py +36 -1
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_main.py +134 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/__init__.py +1 -1
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/apps_client.py +41 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/main.py +159 -2
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/.gitignore +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_admin_cmd.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_auth.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_buildkit.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_buildkit_real.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_cli_import.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_deploy_client.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_dev_check.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_emulate.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_owner_commands.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_packaging.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_secrets_client.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/tests/test_sketch_status.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/auth.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/buildkit.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/deploy_client.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/emulate.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/packaging.py +0 -0
- {verulla_cli-0.0.2 → verulla_cli-0.0.3}/verulla_cli/secrets_client.py +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import httpx
|
|
4
5
|
import pytest
|
|
5
6
|
|
|
6
7
|
from verulla_cli.apps_client import (
|
|
7
|
-
AppsError, activate_app, delete_app, delete_customer, export_customers, get_app_logs, get_auth_policy,
|
|
8
|
+
AppsError, activate_app, create_signup, delete_app, delete_customer, export_customers, get_app_logs, get_auth_policy,
|
|
8
9
|
get_spend_summary, invite_customer, list_apps, list_customers, lock_customer,
|
|
9
10
|
get_sketch_events, rollback_app, set_auth_policy, unlock_customer,
|
|
10
11
|
)
|
|
@@ -25,6 +26,30 @@ def response(status, body):
|
|
|
25
26
|
return httpx.Response(status, json=body, request=req)
|
|
26
27
|
|
|
27
28
|
|
|
29
|
+
def test_create_signup_solves_pow_and_sends_idempotency_header():
|
|
30
|
+
class Client:
|
|
31
|
+
calls = []
|
|
32
|
+
|
|
33
|
+
def get(self, url):
|
|
34
|
+
self.calls.append(("GET", url, None, None))
|
|
35
|
+
return response(200, {"prefix": "challenge-prefix", "difficulty": 2, "challenge_id": "c-1"})
|
|
36
|
+
|
|
37
|
+
def post(self, url, *, headers, json):
|
|
38
|
+
self.calls.append(("POST", url, headers, json))
|
|
39
|
+
return response(201, {"tenant": {"slug": "demo"}})
|
|
40
|
+
|
|
41
|
+
client = Client()
|
|
42
|
+
result = create_signup("http://api.test", "demo", "Demo", "owner@example.test", "idempotency-key-123456", client)
|
|
43
|
+
assert result == {"tenant": {"slug": "demo"}}
|
|
44
|
+
assert client.calls[0][:2] == ("GET", "http://api.test/v1/signup/challenge")
|
|
45
|
+
method, url, headers, body = client.calls[1]
|
|
46
|
+
assert (method, url) == ("POST", "http://api.test/v1/signup")
|
|
47
|
+
assert headers == {"Idempotency-Key": "idempotency-key-123456"}
|
|
48
|
+
assert hashlib.sha256(f"challenge-prefix{body['nonce']}".encode()).hexdigest().startswith("00")
|
|
49
|
+
assert body == {"slug": "demo", "display_name": "Demo", "owner_email": "owner@example.test",
|
|
50
|
+
"challenge_id": "c-1", "nonce": body["nonce"]}
|
|
51
|
+
|
|
52
|
+
|
|
28
53
|
def test_list_apps_uses_authenticated_apps_endpoint():
|
|
29
54
|
client = FakeClient(response(200, {"apps": [{"app": "demo"}]}))
|
|
30
55
|
assert list_apps("http://api.test", "token", client) == [{"app": "demo"}]
|
|
@@ -32,6 +57,16 @@ def test_list_apps_uses_authenticated_apps_endpoint():
|
|
|
32
57
|
|
|
33
58
|
|
|
34
59
|
|
|
60
|
+
def test_get_deployment_uses_id_scoped_endpoint():
|
|
61
|
+
from verulla_cli.apps_client import get_deployment
|
|
62
|
+
|
|
63
|
+
client = FakeClient(response(200, {"id": "dep-1", "status": "building"}))
|
|
64
|
+
assert get_deployment("http://api.test", "token", "dep-1", client)["status"] == "building"
|
|
65
|
+
assert client.calls[0][:3] == (
|
|
66
|
+
"GET", "http://api.test/v1/deployments/dep-1", {"Authorization": "Bearer token"}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
35
70
|
def test_sketch_events_passes_cursor_and_limit_without_changing_contract():
|
|
36
71
|
body = {
|
|
37
72
|
"events": [{
|
|
@@ -14,6 +14,7 @@ def test_auth_and_customer_commands_are_registered() -> None:
|
|
|
14
14
|
result = runner.invoke(app, ["--help"])
|
|
15
15
|
assert result.exit_code == 0
|
|
16
16
|
assert "auth" in result.output and "customers" in result.output
|
|
17
|
+
assert "signup" in result.output
|
|
17
18
|
for command in ("wallet", "spend", "usage", "pricing", "topup"):
|
|
18
19
|
assert command in result.output
|
|
19
20
|
for command in ("show", "set"):
|
|
@@ -22,6 +23,81 @@ def test_auth_and_customer_commands_are_registered() -> None:
|
|
|
22
23
|
assert command in runner.invoke(app, ["customers", "--help"]).output
|
|
23
24
|
|
|
24
25
|
|
|
26
|
+
def test_signup_stores_key_privately_and_redacts_output(tmp_path, monkeypatch) -> None:
|
|
27
|
+
import stat
|
|
28
|
+
|
|
29
|
+
path = tmp_path / ".verulla" / "credentials.json"
|
|
30
|
+
monkeypatch.setattr("verulla_cli.main.CREDENTIALS_PATH", path)
|
|
31
|
+
pending = tmp_path / ".verulla" / "signup-pending.json"
|
|
32
|
+
monkeypatch.setattr("verulla_cli.main._SIGNUP_PENDING_PATH", pending)
|
|
33
|
+
monkeypatch.setattr("verulla_cli.main.create_signup", lambda *args: {
|
|
34
|
+
"tenant": {"slug": "safe-demo", "display_name": "Demo", "schema_name": "tenant_safe_demo"},
|
|
35
|
+
"api_key": {"token": "vrl_secret-key", "scopes": ["tenant:read"]},
|
|
36
|
+
"owner_claim": {"claim_url": "https://account.verulla.com/claim#claim-secret", "expires_at": "expires"},
|
|
37
|
+
})
|
|
38
|
+
result = runner.invoke(app, ["signup", "--api-url", "https://api.example", "--owner-email", "owner@example.test",
|
|
39
|
+
"--display-name", "Demo", "--slug", "safe-demo"])
|
|
40
|
+
assert result.exit_code == 0, result.output
|
|
41
|
+
assert path.exists() and stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
42
|
+
assert "vrl_secret-key" not in result.output
|
|
43
|
+
assert "owner@example.test" not in result.output
|
|
44
|
+
assert "0123456789abcdef" * 4 not in result.output
|
|
45
|
+
assert "claim-secret" in result.output
|
|
46
|
+
assert not pending.exists()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_signup_recovers_interrupted_request_with_same_idempotency_key(tmp_path, monkeypatch) -> None:
|
|
50
|
+
import stat
|
|
51
|
+
from verulla_cli.apps_client import AppsError
|
|
52
|
+
|
|
53
|
+
credentials = tmp_path / ".verulla" / "credentials.json"
|
|
54
|
+
pending = tmp_path / ".verulla" / "signup-pending.json"
|
|
55
|
+
monkeypatch.setattr("verulla_cli.main.CREDENTIALS_PATH", credentials)
|
|
56
|
+
monkeypatch.setattr("verulla_cli.main._SIGNUP_PENDING_PATH", pending)
|
|
57
|
+
attempts = []
|
|
58
|
+
|
|
59
|
+
def fake_signup(*args):
|
|
60
|
+
attempts.append(args)
|
|
61
|
+
if len(attempts) == 1:
|
|
62
|
+
raise AppsError("network interruption")
|
|
63
|
+
return {
|
|
64
|
+
"tenant": {"slug": "safe-demo", "display_name": "Demo"},
|
|
65
|
+
"api_key": {"token": "vrl_secret-key"},
|
|
66
|
+
"owner_claim": {"claim_url": "https://account.verulla.com/claim#claim-secret", "expires_at": "expires"},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
monkeypatch.setattr("verulla_cli.main.create_signup", fake_signup)
|
|
70
|
+
initial = runner.invoke(app, ["signup", "--api-url", "https://api.example", "--owner-email", "owner@example.test",
|
|
71
|
+
"--display-name", "Demo", "--slug", "safe-demo"])
|
|
72
|
+
assert initial.exit_code == 1
|
|
73
|
+
assert pending.exists() and stat.S_IMODE(pending.stat().st_mode) == 0o600
|
|
74
|
+
saved_pending = json.loads(pending.read_text())
|
|
75
|
+
generated_key = attempts[0][-1]
|
|
76
|
+
assert saved_pending["idempotency_key"] == generated_key
|
|
77
|
+
assert len(bytes.fromhex(generated_key)) == 32
|
|
78
|
+
assert len(set(bytes.fromhex(generated_key))) > 16
|
|
79
|
+
wrong_server = runner.invoke(app, ["signup", "--recover", "--api-url", "https://other.example"])
|
|
80
|
+
assert wrong_server.exit_code == 1 and len(attempts) == 1 and pending.exists()
|
|
81
|
+
recovered = runner.invoke(app, ["signup", "--recover", "--api-url", "https://api.example", "--json"])
|
|
82
|
+
assert recovered.exit_code == 0, recovered.output
|
|
83
|
+
assert attempts[0] == attempts[1]
|
|
84
|
+
assert not pending.exists()
|
|
85
|
+
assert "vrl_secret-key" not in recovered.output
|
|
86
|
+
assert "owner@example.test" not in recovered.output
|
|
87
|
+
assert "123456789abcdef0" * 4 not in recovered.output
|
|
88
|
+
assert json.loads(recovered.output)["tenant"]["slug"] == "safe-demo"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_signup_rejects_user_supplied_repeating_recovery_key() -> None:
|
|
92
|
+
repeated_key = "0001020304050607" * 4
|
|
93
|
+
result = runner.invoke(app, [
|
|
94
|
+
"signup", "--api-url", "https://api.example", "--owner-email", "owner@example.test",
|
|
95
|
+
"--display-name", "Demo", "--idempotency-key", repeated_key,
|
|
96
|
+
])
|
|
97
|
+
assert result.exit_code == 2
|
|
98
|
+
assert repeated_key not in result.output
|
|
99
|
+
|
|
100
|
+
|
|
25
101
|
def test_usage_accepts_optional_app_and_prints_term_contract(monkeypatch) -> None:
|
|
26
102
|
monkeypatch.setattr("verulla_cli.main.get_token", lambda: "api-token")
|
|
27
103
|
monkeypatch.setattr("verulla_cli.auth.load_credentials", lambda: None)
|
|
@@ -240,6 +316,64 @@ def test_deploy_success_prints_deployment(tmp_path, monkeypatch) -> None:
|
|
|
240
316
|
assert recorded["tarball"].startswith(b"\x1f\x8b")
|
|
241
317
|
|
|
242
318
|
|
|
319
|
+
def test_deploy_wait_success_prints_sketch_handoff(tmp_path, monkeypatch) -> None:
|
|
320
|
+
monkeypatch.setattr("verulla_cli.main.get_token", lambda: "tok")
|
|
321
|
+
monkeypatch.setattr("verulla_cli.main.create_deployment", lambda *args: {
|
|
322
|
+
"id": "d-wait", "app": "demo-app", "url": "https://demo.verulla.app", "status": "queued",
|
|
323
|
+
})
|
|
324
|
+
states = iter([
|
|
325
|
+
{"status": "building", "build_log_tail": ""},
|
|
326
|
+
{"status": "deployed", "build_log_tail": ""},
|
|
327
|
+
])
|
|
328
|
+
monkeypatch.setattr("verulla_cli.main.get_deployment", lambda *args: next(states))
|
|
329
|
+
monkeypatch.setattr("verulla_cli.main.get_sketch_status", lambda *args: {
|
|
330
|
+
"app": "demo-app", "status": "deployed", "url": "https://demo.verulla.app",
|
|
331
|
+
"expires_at": "2026-11-06T12:00:00Z", "claim_status": "pending",
|
|
332
|
+
"limits_url": "https://verulla.com/#pricing", "console_url": "https://account.verulla.com/",
|
|
333
|
+
})
|
|
334
|
+
monkeypatch.setattr("verulla_cli.main.time.sleep", lambda _: None)
|
|
335
|
+
result = runner.invoke(app, ["deploy", str(_valid_project(tmp_path)), "--api-url", "http://api.test", "--wait"])
|
|
336
|
+
assert result.exit_code == 0, result.output
|
|
337
|
+
assert "https://demo.verulla.app" in result.output
|
|
338
|
+
assert "2026-11-06T12:00:00Z" in result.output
|
|
339
|
+
assert "72 hours" in result.output
|
|
340
|
+
assert "custom domain" in result.output.lower()
|
|
341
|
+
assert "verulla sketch status demo-app" in result.output
|
|
342
|
+
assert "owner claim status: pending" in result.output.lower()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_deploy_wait_failure_exits_nonzero_and_shows_build_log_tail(tmp_path, monkeypatch) -> None:
|
|
346
|
+
monkeypatch.setattr("verulla_cli.main.get_token", lambda: "tok")
|
|
347
|
+
monkeypatch.setattr("verulla_cli.main.create_deployment", lambda *args: {
|
|
348
|
+
"id": "d-fail", "app": "demo-app", "status": "queued",
|
|
349
|
+
})
|
|
350
|
+
monkeypatch.setattr("verulla_cli.main.get_deployment", lambda *args: {
|
|
351
|
+
"id": "d-fail", "status": "apply_failed", "error": "apply failed",
|
|
352
|
+
})
|
|
353
|
+
monkeypatch.setattr("verulla_cli.main.get_app_logs", lambda *args: {
|
|
354
|
+
"deployment_id": "d-fail", "build_log_tail": "build output tail",
|
|
355
|
+
})
|
|
356
|
+
result = runner.invoke(app, ["deploy", str(_valid_project(tmp_path)), "--api-url", "http://api.test", "--wait"])
|
|
357
|
+
assert result.exit_code == 1
|
|
358
|
+
assert "verulla logs demo-app" in result.output
|
|
359
|
+
assert "build output tail" in result.output
|
|
360
|
+
assert "Runtime/pod logs are not available yet" in result.output
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_deploy_wait_timeout_exits_nonzero_with_inspection_action(tmp_path, monkeypatch) -> None:
|
|
364
|
+
import verulla_cli.main as cli_main
|
|
365
|
+
|
|
366
|
+
monkeypatch.setattr(cli_main, "get_token", lambda: "tok")
|
|
367
|
+
monkeypatch.setattr(cli_main, "create_deployment", lambda *args: {"id": "d-slow", "app": "demo-app", "status": "queued"})
|
|
368
|
+
monkeypatch.setattr(cli_main, "get_deployment", lambda *args: {"status": "building"})
|
|
369
|
+
monkeypatch.setattr(cli_main.time, "monotonic", iter([0.0, 0.0, 2.0]).__next__)
|
|
370
|
+
monkeypatch.setattr(cli_main.time, "sleep", lambda _: None)
|
|
371
|
+
result = runner.invoke(app, ["deploy", str(_valid_project(tmp_path)), "--api-url", "http://api.test", "--wait", "--wait-timeout", "1"])
|
|
372
|
+
assert result.exit_code == 1
|
|
373
|
+
assert "Timed out after 1s" in result.output
|
|
374
|
+
assert "verulla logs demo-app" in result.output
|
|
375
|
+
|
|
376
|
+
|
|
243
377
|
def test_deploy_logged_in_unreachable_api_fails_cleanly(tmp_path, monkeypatch) -> None:
|
|
244
378
|
monkeypatch.setattr(
|
|
245
379
|
"verulla_cli.main.get_token", lambda: "tok"
|
|
@@ -53,10 +53,51 @@ def _request(api_url: str, token: str, method: str, path: str, client=None, *, e
|
|
|
53
53
|
http.close()
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def create_signup(api_url: str, slug: str, display_name: str, owner_email: str,
|
|
57
|
+
idempotency_key: str, client=None) -> dict:
|
|
58
|
+
"""Submit signup with a recovery key from 32 CSPRNG-generated bytes.
|
|
59
|
+
|
|
60
|
+
Encode the key as 64 lowercase hex characters. It authorizes recovery of
|
|
61
|
+
the bootstrap response and must never be derived from user input.
|
|
62
|
+
"""
|
|
63
|
+
import hashlib
|
|
64
|
+
|
|
65
|
+
owns = client is None
|
|
66
|
+
http = client or httpx.Client(timeout=20)
|
|
67
|
+
try:
|
|
68
|
+
challenge_response = http.get(f"{api_url}/v1/signup/challenge")
|
|
69
|
+
challenge_response.raise_for_status()
|
|
70
|
+
challenge = challenge_response.json()
|
|
71
|
+
prefix, difficulty = challenge["prefix"], challenge["difficulty"]
|
|
72
|
+
nonce = 0
|
|
73
|
+
while not hashlib.sha256(f"{prefix}{nonce}".encode()).hexdigest().startswith("0" * difficulty):
|
|
74
|
+
nonce += 1
|
|
75
|
+
response = http.post(
|
|
76
|
+
f"{api_url}/v1/signup",
|
|
77
|
+
headers={"Idempotency-Key": idempotency_key},
|
|
78
|
+
json={"slug": slug, "display_name": display_name, "owner_email": owner_email,
|
|
79
|
+
"challenge_id": challenge["challenge_id"], "nonce": str(nonce)},
|
|
80
|
+
)
|
|
81
|
+
response.raise_for_status()
|
|
82
|
+
return response.json()
|
|
83
|
+
except httpx.HTTPStatusError as exc:
|
|
84
|
+
raise AppsError(f"signup failed ({exc.response.status_code}); check the request and retry safely") from None
|
|
85
|
+
except (httpx.HTTPError, KeyError, TypeError, ValueError) as exc:
|
|
86
|
+
raise AppsError(f"signup request could not be completed ({type(exc).__name__}); retry with the same idempotency key") from None
|
|
87
|
+
finally:
|
|
88
|
+
if owns:
|
|
89
|
+
http.close()
|
|
90
|
+
|
|
91
|
+
|
|
56
92
|
def list_apps(api_url: str, token: str, client=None) -> list[dict]:
|
|
57
93
|
return _request(api_url, token, "GET", "/v1/apps", client)["apps"]
|
|
58
94
|
|
|
59
95
|
|
|
96
|
+
def get_deployment(api_url: str, token: str, deployment_id: str, client=None) -> dict:
|
|
97
|
+
from urllib.parse import quote
|
|
98
|
+
return _request(api_url, token, "GET", f"/v1/deployments/{quote(deployment_id, safe='')}", client)
|
|
99
|
+
|
|
100
|
+
|
|
60
101
|
def get_app(api_url: str, token: str, name: str, client=None) -> dict:
|
|
61
102
|
return _request(api_url, token, "GET", f"/v1/apps/{name}", client)
|
|
62
103
|
|
|
@@ -4,20 +4,23 @@ from pathlib import Path
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
|
-
import
|
|
7
|
+
import secrets
|
|
8
8
|
import tempfile
|
|
9
|
+
import time
|
|
9
10
|
import uuid
|
|
10
11
|
|
|
11
12
|
import typer
|
|
12
13
|
|
|
13
|
-
from verulla_cli.auth import DeviceLoginError, get_token, run_device_login
|
|
14
|
+
from verulla_cli.auth import CREDENTIALS_PATH, DeviceLoginError, get_token, run_device_login, save_credentials
|
|
14
15
|
from verulla_manifest import load_manifest, translate
|
|
15
16
|
from verulla_cli.apps_client import (
|
|
16
17
|
AppsError,
|
|
18
|
+
create_signup,
|
|
17
19
|
activate_app,
|
|
18
20
|
delete_app as api_delete_app,
|
|
19
21
|
delete_customer as api_delete_customer,
|
|
20
22
|
export_customers as api_export_customers,
|
|
23
|
+
get_deployment,
|
|
21
24
|
get_app,
|
|
22
25
|
get_app_logs,
|
|
23
26
|
|
|
@@ -92,6 +95,109 @@ def login(
|
|
|
92
95
|
typer.echo("Logged in.")
|
|
93
96
|
|
|
94
97
|
|
|
98
|
+
_SIGNUP_PENDING_PATH = Path.home() / ".verulla" / "signup-pending.json"
|
|
99
|
+
_SIGNUP_IDEMPOTENCY_KEY = re.compile(r"[0-9a-f]{64}\Z")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _valid_signup_idempotency_key(value: str) -> bool:
|
|
103
|
+
if not _SIGNUP_IDEMPOTENCY_KEY.fullmatch(value):
|
|
104
|
+
return False
|
|
105
|
+
key = bytes.fromhex(value)
|
|
106
|
+
return not any(all(key[index] == key[index % period] for index in range(period, len(key)))
|
|
107
|
+
for period in range(1, 17))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _write_signup_pending(path: Path, payload: dict) -> None:
|
|
111
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
113
|
+
try:
|
|
114
|
+
os.fchmod(descriptor, 0o600)
|
|
115
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
116
|
+
json.dump(payload, stream)
|
|
117
|
+
stream.flush()
|
|
118
|
+
os.fsync(stream.fileno())
|
|
119
|
+
os.replace(temporary, path)
|
|
120
|
+
except BaseException:
|
|
121
|
+
try:
|
|
122
|
+
os.unlink(temporary)
|
|
123
|
+
except FileNotFoundError:
|
|
124
|
+
pass
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.command()
|
|
129
|
+
def signup(
|
|
130
|
+
owner_email: str | None = typer.Option(None, "--owner-email", help="Verified owner email to bind the claim to."),
|
|
131
|
+
display_name: str | None = typer.Option(None, "--display-name", help="Tenant display name."),
|
|
132
|
+
slug: str | None = typer.Option(None, "--slug", help="Safe tenant slug; generated when omitted."),
|
|
133
|
+
recover: bool = typer.Option(False, "--recover", help="Retry the saved interrupted signup request."),
|
|
134
|
+
api_url: str = typer.Option(..., "--api-url", envvar="VERULLA_API_URL", help="Verulla control plane URL."),
|
|
135
|
+
json_output: bool = typer.Option(False, "--json", help="Print a redacted JSON receipt."),
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Create a tenant and owner hand-off without a browser or interactive prompts."""
|
|
138
|
+
pending_path = _SIGNUP_PENDING_PATH
|
|
139
|
+
if recover:
|
|
140
|
+
if any((owner_email, display_name, slug)):
|
|
141
|
+
typer.echo("Do not pass new signup values with --recover.", err=True)
|
|
142
|
+
raise typer.Exit(code=2)
|
|
143
|
+
try:
|
|
144
|
+
pending = json.loads(pending_path.read_text(encoding="utf-8"))
|
|
145
|
+
if not isinstance(pending, dict) or not all(pending.get(key) for key in
|
|
146
|
+
("owner_email", "display_name", "slug", "idempotency_key", "api_url")):
|
|
147
|
+
raise ValueError("invalid pending request")
|
|
148
|
+
if not isinstance(pending.get("idempotency_key"), str) or not _valid_signup_idempotency_key(pending["idempotency_key"]):
|
|
149
|
+
raise ValueError("invalid pending idempotency key")
|
|
150
|
+
if pending["api_url"].rstrip("/") != api_url.rstrip("/"):
|
|
151
|
+
raise ValueError("API URL does not match the pending signup")
|
|
152
|
+
except (OSError, ValueError) as exc:
|
|
153
|
+
typer.echo("No valid interrupted signup is available to recover.", err=True)
|
|
154
|
+
raise typer.Exit(code=1) from exc
|
|
155
|
+
else:
|
|
156
|
+
if not owner_email or not display_name:
|
|
157
|
+
typer.echo("--owner-email and --display-name are required unless --recover is used.", err=True)
|
|
158
|
+
raise typer.Exit(code=2)
|
|
159
|
+
if pending_path.exists():
|
|
160
|
+
typer.echo("An interrupted signup is pending; use `verulla signup --recover` first.", err=True)
|
|
161
|
+
raise typer.Exit(code=1)
|
|
162
|
+
pending = {
|
|
163
|
+
"owner_email": owner_email, "display_name": display_name,
|
|
164
|
+
"slug": slug or f"agent-{secrets.token_hex(4)}",
|
|
165
|
+
"idempotency_key": secrets.token_hex(32),
|
|
166
|
+
"api_url": api_url.rstrip("/"),
|
|
167
|
+
}
|
|
168
|
+
try:
|
|
169
|
+
_write_signup_pending(pending_path, pending)
|
|
170
|
+
except OSError as exc:
|
|
171
|
+
typer.echo(f"Could not securely save signup recovery state: {exc}", err=True)
|
|
172
|
+
raise typer.Exit(code=1) from exc
|
|
173
|
+
|
|
174
|
+
api_url = api_url.rstrip("/")
|
|
175
|
+
try:
|
|
176
|
+
result = create_signup(api_url, pending["slug"], pending["display_name"],
|
|
177
|
+
pending["owner_email"], pending["idempotency_key"])
|
|
178
|
+
api_key = result["api_key"]["token"]
|
|
179
|
+
save_credentials({"access_token": api_key, "expires_in": 7 * 24 * 60 * 60},
|
|
180
|
+
api_url, "signup", path=CREDENTIALS_PATH)
|
|
181
|
+
pending_path.unlink(missing_ok=True)
|
|
182
|
+
except (AppsError, KeyError, ValueError, OSError) as exc:
|
|
183
|
+
typer.echo(f"Signup failed; retry with `verulla signup --recover`. ({type(exc).__name__})", err=True)
|
|
184
|
+
raise typer.Exit(code=1) from None
|
|
185
|
+
|
|
186
|
+
receipt = {
|
|
187
|
+
"tenant": result.get("tenant", {}),
|
|
188
|
+
"owner_claim": result.get("owner_claim", {}),
|
|
189
|
+
}
|
|
190
|
+
if json_output:
|
|
191
|
+
typer.echo(json.dumps(receipt))
|
|
192
|
+
else:
|
|
193
|
+
tenant = receipt["tenant"]
|
|
194
|
+
claim = receipt["owner_claim"]
|
|
195
|
+
typer.echo(f"Tenant: {tenant.get('display_name')} ({tenant.get('slug')})")
|
|
196
|
+
typer.echo(f"Owner claim: {claim.get('claim_url')}")
|
|
197
|
+
typer.echo(f"Claim expires: {claim.get('expires_at')}")
|
|
198
|
+
typer.echo("Bootstrap API key saved to your private Verulla credentials file.")
|
|
199
|
+
|
|
200
|
+
|
|
95
201
|
@app.command()
|
|
96
202
|
def dev(
|
|
97
203
|
path: str = typer.Argument(".", help="Path to the directory containing verulla.yml"),
|
|
@@ -232,6 +338,9 @@ def deploy(
|
|
|
232
338
|
envvar="VERULLA_API_URL",
|
|
233
339
|
help="Verulla control plane base URL.",
|
|
234
340
|
),
|
|
341
|
+
wait: bool = typer.Option(False, "--wait", help="Wait for build/apply to finish, then print the Sketch hand-off."),
|
|
342
|
+
wait_timeout: int = typer.Option(600, "--wait-timeout", min=1, max=3600,
|
|
343
|
+
help="Maximum seconds to wait (1-3600; default 600)."),
|
|
235
344
|
) -> None:
|
|
236
345
|
"""Deploy the app described by verulla.yml to your Verulla tenant."""
|
|
237
346
|
base_dir = Path(path).resolve()
|
|
@@ -267,6 +376,54 @@ def deploy(
|
|
|
267
376
|
typer.echo(deployment["url"])
|
|
268
377
|
for fn in deployment.get("functions", []):
|
|
269
378
|
typer.echo(f" {fn['name']} ({fn['adapter']})")
|
|
379
|
+
if wait:
|
|
380
|
+
terminal = {"deployed", "build_failed", "apply_failed", "failed", "deleted", "delete_failed", "superseded", "suspended"}
|
|
381
|
+
deadline = time.monotonic() + wait_timeout
|
|
382
|
+
while True:
|
|
383
|
+
try:
|
|
384
|
+
latest = get_deployment(api_url.rstrip("/"), token, deployment["id"])
|
|
385
|
+
except AppsError as exc:
|
|
386
|
+
typer.echo(f"Error checking deployment status: {exc}", err=True)
|
|
387
|
+
raise typer.Exit(code=1) from exc
|
|
388
|
+
status = latest.get("status")
|
|
389
|
+
if status in terminal:
|
|
390
|
+
break
|
|
391
|
+
remaining = deadline - time.monotonic()
|
|
392
|
+
if remaining <= 0:
|
|
393
|
+
typer.echo(
|
|
394
|
+
f"Timed out after {wait_timeout}s waiting for deployment {deployment['id']} (last status: {status}). "
|
|
395
|
+
f"Inspect with: verulla logs {deployment['app']}", err=True,
|
|
396
|
+
)
|
|
397
|
+
raise typer.Exit(code=1)
|
|
398
|
+
time.sleep(min(2, remaining))
|
|
399
|
+
if status != "deployed":
|
|
400
|
+
typer.echo(f"Deployment {deployment['id']} ended with status {status}.", err=True)
|
|
401
|
+
if latest.get("error"):
|
|
402
|
+
typer.echo(f"Error: {latest['error']}", err=True)
|
|
403
|
+
try:
|
|
404
|
+
logs = get_app_logs(api_url.rstrip("/"), token, deployment["app"])
|
|
405
|
+
except AppsError:
|
|
406
|
+
logs = {}
|
|
407
|
+
log_url = f"{api_url.rstrip('/')}/v1/apps/{deployment['app']}/logs"
|
|
408
|
+
typer.echo(f"Build logs: {log_url} (authenticated); inspect with: verulla logs {deployment['app']}", err=True)
|
|
409
|
+
typer.echo("build_log_tail:", err=True)
|
|
410
|
+
tail = logs.get("build_log_tail") if logs.get("deployment_id") == deployment["id"] else None
|
|
411
|
+
typer.echo(tail or "(no matching build log available)", err=True)
|
|
412
|
+
typer.echo("Runtime/pod logs are not available yet.", err=True)
|
|
413
|
+
raise typer.Exit(code=1)
|
|
414
|
+
try:
|
|
415
|
+
receipt = _safe_sketch_receipt(get_sketch_status(api_url.rstrip("/"), token, deployment["app"]))
|
|
416
|
+
except AppsError as exc:
|
|
417
|
+
typer.echo(f"Deployment succeeded, but the Sketch receipt could not be loaded: {exc}", err=True)
|
|
418
|
+
raise typer.Exit(code=1) from exc
|
|
419
|
+
typer.echo("\nSketch hand-off")
|
|
420
|
+
typer.echo(f"Public URL: {receipt.get('url') or deployment.get('url') or '(not provided)'}")
|
|
421
|
+
typer.echo(f"Expires at: {receipt.get('expires_at') or '(expiry not provided by API)'}")
|
|
422
|
+
typer.echo(f"Owner claim status: {receipt.get('claim_status') or '(not provided)'}")
|
|
423
|
+
typer.echo("Trial: free for 72 hours from first successful deployment; no custom domain.")
|
|
424
|
+
if receipt.get("limits_url"):
|
|
425
|
+
typer.echo(f"Limits: {receipt['limits_url']}")
|
|
426
|
+
typer.echo(f"Next: share the public URL with the verified owner, then run `verulla sketch status {deployment['app']}` before expiry.")
|
|
270
427
|
|
|
271
428
|
|
|
272
429
|
secrets_app = typer.Typer(help="Manage per-tenant secrets and environment variables.")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|