mozbridge-cli 0.2.1__tar.gz → 0.3.0__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.
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/PKG-INFO +1 -1
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/pyproject.toml +1 -1
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/scripts/release.sh +17 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/api.py +116 -0
- mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_auth.py +152 -0
- mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_deploy.py +307 -0
- mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_env.py +115 -0
- mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_projects.py +92 -0
- mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_publish.py +498 -0
- mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_registry.py +97 -0
- mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_secrets.py +165 -0
- mozbridge_cli-0.3.0/src/mozbridge_cli/cmd_shared.py +31 -0
- mozbridge_cli-0.3.0/src/mozbridge_cli/main.py +49 -0
- mozbridge_cli-0.3.0/tests/test_registry.py +196 -0
- mozbridge_cli-0.3.0/tests/test_secrets.py +294 -0
- mozbridge_cli-0.2.1/src/mozbridge_cli/main.py +0 -1142
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/.gitignore +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/README.md +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/docs/commands.md +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/__init__.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/__main__.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/auth.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/build.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/compose.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/config.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/link.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/local_build.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/runtime_secrets.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/src/mozbridge_cli/session.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/conftest.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_build.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_ci_token_auth.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_compose.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_deploy.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_diff.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_env.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_link.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_local_build.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_login.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_login_cli.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_logout.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_logs.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_publish.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_publish_local.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_publish_multi_component.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_refresh.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_rollback.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_runtime_secrets.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_session_permissions.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_status.py +0 -0
- {mozbridge_cli-0.2.1 → mozbridge_cli-0.3.0}/tests/test_whoami.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mozbridge-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Command-line client for the Mozbridge deployment platform: SSO login, build, publish, status, and rollback for any project hosted on Mozbridge.
|
|
5
5
|
Project-URL: Homepage, https://mozbridge.com
|
|
6
6
|
Project-URL: Documentation, https://github.com/jessin01/mozbridge/blob/main/cli/docs/commands.md
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mozbridge-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Command-line client for the Mozbridge deployment platform: SSO login, build, publish, status, and rollback for any project hosted on Mozbridge."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -10,8 +10,25 @@
|
|
|
10
10
|
set -euo pipefail
|
|
11
11
|
|
|
12
12
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
13
|
+
REPO_ROOT="$(cd "$ROOT/.." && pwd)"
|
|
13
14
|
PYPROJECT="$ROOT/pyproject.toml"
|
|
14
15
|
|
|
16
|
+
# Hard first check: `git fetch` updates the remote-tracking ref, not the
|
|
17
|
+
# working tree — only merge/rebase/pull do that. mozbridge-cli 0.2.0 was
|
|
18
|
+
# published missing 3 commands because this build ran between a `git
|
|
19
|
+
# fetch` and the merge that would have actually updated the checkout.
|
|
20
|
+
# Refuse to build from a checkout that's behind origin/main.
|
|
21
|
+
if command -v git >/dev/null 2>&1 && git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
|
|
22
|
+
git -C "$REPO_ROOT" fetch origin main --quiet 2>/dev/null || true
|
|
23
|
+
BEHIND="$(git -C "$REPO_ROOT" rev-list --count HEAD..origin/main 2>/dev/null || echo 0)"
|
|
24
|
+
if [ "${BEHIND:-0}" != "0" ]; then
|
|
25
|
+
echo "✗ Local main is ${BEHIND} commit(s) behind origin/main." >&2
|
|
26
|
+
echo " Run: git -C \"$REPO_ROOT\" merge --ff-only origin/main" >&2
|
|
27
|
+
echo " then re-run this script. Refusing to build a stale release." >&2
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
fi
|
|
31
|
+
|
|
15
32
|
usage() {
|
|
16
33
|
echo "Usage: $0 [--patch|--minor|--major] [--dry-run]" >&2
|
|
17
34
|
echo " Default bump: patch" >&2
|
|
@@ -308,6 +308,122 @@ def delete_project_env_var(
|
|
|
308
308
|
return resp.json()
|
|
309
309
|
|
|
310
310
|
|
|
311
|
+
def get_project_registry(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
|
|
312
|
+
"""GET /api/v1/projects/{project_id}/registry -> schemas.RegistryCredentialStatus.
|
|
313
|
+
|
|
314
|
+
{configured, registry_url, username, updated_at} — the token/secret value
|
|
315
|
+
is never returned by this route (router.py:2343-2352), by design, so
|
|
316
|
+
there is nothing here for a caller to accidentally print.
|
|
317
|
+
"""
|
|
318
|
+
url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/registry"
|
|
319
|
+
try:
|
|
320
|
+
resp = client.get(url, headers=_headers(access_token, org_id))
|
|
321
|
+
except httpx.HTTPError as exc:
|
|
322
|
+
raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
|
|
323
|
+
_raise_for_status(resp, "fetch the registry credential status")
|
|
324
|
+
return resp.json()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def set_project_registry(
|
|
328
|
+
client: httpx.Client,
|
|
329
|
+
access_token: str,
|
|
330
|
+
org_id: int,
|
|
331
|
+
project_id: int,
|
|
332
|
+
registry_url: str,
|
|
333
|
+
username: str,
|
|
334
|
+
token: str,
|
|
335
|
+
) -> dict:
|
|
336
|
+
"""PUT /api/v1/projects/{project_id}/registry, body schemas.RegistryCredentialSet.
|
|
337
|
+
|
|
338
|
+
Admin-gated (ensure_permify_can_administer) and validated against the
|
|
339
|
+
real registry BEFORE storing (router.py:2355-2384) — a bad credential
|
|
340
|
+
comes back as a 400 with a real message ("Could not validate registry
|
|
341
|
+
credential: ..."), which `_raise_for_status` already surfaces via
|
|
342
|
+
ApiError, same as every other function in this module.
|
|
343
|
+
"""
|
|
344
|
+
url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/registry"
|
|
345
|
+
payload = {"registry_url": registry_url, "username": username, "token": token}
|
|
346
|
+
try:
|
|
347
|
+
resp = client.put(url, json=payload, headers=_headers(access_token, org_id))
|
|
348
|
+
except httpx.HTTPError as exc:
|
|
349
|
+
raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
|
|
350
|
+
_raise_for_status(resp, "set the registry credential")
|
|
351
|
+
return resp.json()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _secrets_url(project_id: int, env_slug: str, component: str) -> str:
|
|
355
|
+
"""Shared path builder for the four secrets endpoints below — keeps the
|
|
356
|
+
env_slug/component path segments byte-for-byte identical across list
|
|
357
|
+
keys / get values / patch one key, rather than four separately-typed
|
|
358
|
+
f-strings that could drift apart.
|
|
359
|
+
"""
|
|
360
|
+
base = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}"
|
|
361
|
+
return f"{base}/environments/{env_slug}/secrets/{component}"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def list_env_secret_keys(
|
|
365
|
+
client: httpx.Client, access_token: str, org_id: int, project_id: int, env_slug: str, component: str
|
|
366
|
+
) -> dict:
|
|
367
|
+
"""GET .../environments/{env_slug}/secrets/{component}/keys -> {keys, count}.
|
|
368
|
+
|
|
369
|
+
Key NAMES only — no values leave the server (router.py:3422-3434). This
|
|
370
|
+
is the default listing behavior for `mozbridge secrets list`.
|
|
371
|
+
"""
|
|
372
|
+
url = f"{_secrets_url(project_id, env_slug, component)}/keys"
|
|
373
|
+
try:
|
|
374
|
+
resp = client.get(url, headers=_headers(access_token, org_id))
|
|
375
|
+
except httpx.HTTPError as exc:
|
|
376
|
+
raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
|
|
377
|
+
_raise_for_status(resp, "list secret keys")
|
|
378
|
+
return resp.json()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def get_env_secrets(
|
|
382
|
+
client: httpx.Client, access_token: str, org_id: int, project_id: int, env_slug: str, component: str
|
|
383
|
+
) -> dict:
|
|
384
|
+
"""GET .../environments/{env_slug}/secrets/{component} -> dict[key, value].
|
|
385
|
+
|
|
386
|
+
Returns actual secret VALUES (router.py:3412-3419). Only called from
|
|
387
|
+
`mozbridge secrets list --show-values`, gated behind an interactive
|
|
388
|
+
confirmation (or --yes) before this is ever invoked.
|
|
389
|
+
"""
|
|
390
|
+
url = _secrets_url(project_id, env_slug, component)
|
|
391
|
+
try:
|
|
392
|
+
resp = client.get(url, headers=_headers(access_token, org_id))
|
|
393
|
+
except httpx.HTTPError as exc:
|
|
394
|
+
raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
|
|
395
|
+
_raise_for_status(resp, "fetch secret values")
|
|
396
|
+
return resp.json()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def patch_env_secret(
|
|
400
|
+
client: httpx.Client,
|
|
401
|
+
access_token: str,
|
|
402
|
+
org_id: int,
|
|
403
|
+
project_id: int,
|
|
404
|
+
env_slug: str,
|
|
405
|
+
component: str,
|
|
406
|
+
key: str,
|
|
407
|
+
value: str,
|
|
408
|
+
) -> dict:
|
|
409
|
+
"""PATCH .../environments/{env_slug}/secrets/{component}, body schemas.SecretPatch {key, value}.
|
|
410
|
+
|
|
411
|
+
Merges ONE key server-side over a strict read (router.py:3437-3453) —
|
|
412
|
+
the safe way to set one secret without touching the rest. `mozbridge
|
|
413
|
+
secrets set` MUST use this, never the POST replace-all endpoint below.
|
|
414
|
+
|
|
415
|
+
Returns {"status": "success", "key", "created", "total_keys"} — never
|
|
416
|
+
the merged secret values.
|
|
417
|
+
"""
|
|
418
|
+
url = _secrets_url(project_id, env_slug, component)
|
|
419
|
+
try:
|
|
420
|
+
resp = client.patch(url, json={"key": key, "value": value}, headers=_headers(access_token, org_id))
|
|
421
|
+
except httpx.HTTPError as exc:
|
|
422
|
+
raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
|
|
423
|
+
_raise_for_status(resp, "set the secret")
|
|
424
|
+
return resp.json()
|
|
425
|
+
|
|
426
|
+
|
|
311
427
|
def trigger_prebuilt_build(
|
|
312
428
|
client: httpx.Client,
|
|
313
429
|
access_token: str,
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""`mozbridge login` / `whoami` / `logout` command definitions.
|
|
2
|
+
|
|
3
|
+
The underlying device-flow logic lives in auth.py (unchanged, imported
|
|
4
|
+
here, not duplicated). This module owns only the Typer command layer that
|
|
5
|
+
used to live directly in main.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from . import auth, config, session
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(add_completion=False)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command("login")
|
|
19
|
+
def login_cmd() -> None:
|
|
20
|
+
"""Sign in via Logto's device authorization flow (opens a URL for you to approve)."""
|
|
21
|
+
if session.token_from_env() is not None:
|
|
22
|
+
typer.echo(
|
|
23
|
+
f"{session.TOKEN_ENV_VAR} is set — this CLI is already authenticating as that "
|
|
24
|
+
"token for every command, so an interactive `mozbridge login` doesn't apply here. "
|
|
25
|
+
f"These are mutually exclusive auth modes; unset {session.TOKEN_ENV_VAR} first if "
|
|
26
|
+
"you want to log in interactively instead.",
|
|
27
|
+
err=True,
|
|
28
|
+
)
|
|
29
|
+
raise typer.Exit(code=1)
|
|
30
|
+
|
|
31
|
+
with httpx.Client(timeout=30.0) as client:
|
|
32
|
+
try:
|
|
33
|
+
authorization = auth.start_device_authorization(client)
|
|
34
|
+
except httpx.HTTPError as exc:
|
|
35
|
+
typer.echo(f"Could not start login: {exc}", err=True)
|
|
36
|
+
raise typer.Exit(code=1)
|
|
37
|
+
|
|
38
|
+
if authorization.verification_uri_complete:
|
|
39
|
+
typer.echo(f"Open this URL to sign in:\n\n {authorization.verification_uri_complete}\n")
|
|
40
|
+
typer.echo(f"Or go to {authorization.verification_uri} and enter code: {authorization.user_code}")
|
|
41
|
+
typer.echo("Waiting for approval...")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
token_response = auth.poll_for_token(client, authorization)
|
|
45
|
+
except auth.AccessDenied as exc:
|
|
46
|
+
typer.echo(str(exc), err=True)
|
|
47
|
+
raise typer.Exit(code=1)
|
|
48
|
+
except auth.ExpiredToken as exc:
|
|
49
|
+
typer.echo(str(exc), err=True)
|
|
50
|
+
raise typer.Exit(code=1)
|
|
51
|
+
except auth.DeviceLoginError as exc:
|
|
52
|
+
typer.echo(str(exc), err=True)
|
|
53
|
+
raise typer.Exit(code=1)
|
|
54
|
+
|
|
55
|
+
new_session = session.session_from_token_response(token_response)
|
|
56
|
+
path = session.save_session(new_session)
|
|
57
|
+
typer.echo(f"Logged in. Session saved to {path}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_and_refresh_session(client: httpx.Client) -> session.Session:
|
|
61
|
+
"""Return the bearer token to use for this command.
|
|
62
|
+
|
|
63
|
+
Single choke point for auth used by every command below (whoami, link,
|
|
64
|
+
status, rollback, diff, publish's remote AND --local paths). Checked
|
|
65
|
+
BEFORE anything session-file-related, mirroring gh/flyctl/Vercel's own
|
|
66
|
+
env-var precedence: if MOZBRIDGE_TOKEN is set, it is used directly as
|
|
67
|
+
the bearer token and load_session()/save_session()/refresh-token logic
|
|
68
|
+
are never invoked — there's no on-disk session file involved in this
|
|
69
|
+
path. Otherwise, falls back to loading the cached session, refreshing
|
|
70
|
+
the access token first if expired.
|
|
71
|
+
|
|
72
|
+
Exits the process with a clear message if there is no session, or if
|
|
73
|
+
refresh fails.
|
|
74
|
+
"""
|
|
75
|
+
env_token = session.token_from_env()
|
|
76
|
+
if env_token is not None:
|
|
77
|
+
return session.Session(
|
|
78
|
+
access_token=env_token,
|
|
79
|
+
refresh_token=None,
|
|
80
|
+
id_token=None,
|
|
81
|
+
expires_at=float("inf"),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
current = session.load_session()
|
|
85
|
+
if current is None:
|
|
86
|
+
typer.echo("Not logged in. Run `mozbridge login` first.", err=True)
|
|
87
|
+
raise typer.Exit(code=1)
|
|
88
|
+
|
|
89
|
+
if not current.is_expired():
|
|
90
|
+
return current
|
|
91
|
+
|
|
92
|
+
if not current.refresh_token:
|
|
93
|
+
typer.echo("Session expired and cannot be refreshed. Run `mozbridge login` again.", err=True)
|
|
94
|
+
raise typer.Exit(code=1)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
token_response = auth.refresh_access_token(client, current.refresh_token)
|
|
98
|
+
except auth.RefreshError as exc:
|
|
99
|
+
typer.echo(f"{exc} — run `mozbridge login` again.", err=True)
|
|
100
|
+
raise typer.Exit(code=1)
|
|
101
|
+
|
|
102
|
+
refreshed = session.session_from_token_response(token_response)
|
|
103
|
+
# Logto may omit refresh_token on rotation-less refresh — keep the old one.
|
|
104
|
+
if not refreshed.refresh_token:
|
|
105
|
+
refreshed.refresh_token = current.refresh_token
|
|
106
|
+
session.save_session(refreshed)
|
|
107
|
+
return refreshed
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command("whoami")
|
|
111
|
+
def whoami_cmd() -> None:
|
|
112
|
+
"""Show the signed-in Mozbridge identity."""
|
|
113
|
+
with httpx.Client(timeout=30.0) as client:
|
|
114
|
+
active_session = load_and_refresh_session(client)
|
|
115
|
+
|
|
116
|
+
url = f"{config.API_BASE_URL}{config.WHOAMI_PATH}"
|
|
117
|
+
try:
|
|
118
|
+
resp = client.get(url, headers={"Authorization": f"Bearer {active_session.access_token}"})
|
|
119
|
+
except httpx.HTTPError as exc:
|
|
120
|
+
typer.echo(f"Could not reach Mozbridge API: {exc}", err=True)
|
|
121
|
+
raise typer.Exit(code=1)
|
|
122
|
+
|
|
123
|
+
if resp.status_code == 401:
|
|
124
|
+
typer.echo("Session is no longer valid. Run `mozbridge login` again.", err=True)
|
|
125
|
+
raise typer.Exit(code=1)
|
|
126
|
+
if resp.status_code != 200:
|
|
127
|
+
typer.echo(f"Mozbridge API returned {resp.status_code}: {resp.text}", err=True)
|
|
128
|
+
raise typer.Exit(code=1)
|
|
129
|
+
|
|
130
|
+
data = resp.json()
|
|
131
|
+
|
|
132
|
+
email = data.get("email") or "(no email on record)"
|
|
133
|
+
username = data.get("username")
|
|
134
|
+
full_name = data.get("full_name")
|
|
135
|
+
display = full_name or username or email
|
|
136
|
+
|
|
137
|
+
typer.echo(f"Logged in as {display}")
|
|
138
|
+
typer.echo(f" email: {email}")
|
|
139
|
+
if username:
|
|
140
|
+
typer.echo(f" username: {username}")
|
|
141
|
+
typer.echo(f" user id: {data.get('id')}")
|
|
142
|
+
typer.echo(f" platform admin: {bool(data.get('is_platform_admin'))}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command("logout")
|
|
146
|
+
def logout_cmd() -> None:
|
|
147
|
+
"""Delete the cached local session. Safe to run even if already logged out."""
|
|
148
|
+
removed = session.delete_session()
|
|
149
|
+
if removed:
|
|
150
|
+
typer.echo("Logged out.")
|
|
151
|
+
else:
|
|
152
|
+
typer.echo("Already logged out.")
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""`mozbridge status` / `rollback` / `deploy` / `logs` — deploy lifecycle commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from . import api
|
|
12
|
+
from .cmd_shared import require_link_and_session
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(add_completion=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("status")
|
|
18
|
+
def status_cmd() -> None:
|
|
19
|
+
"""Show the linked project's status and its 5 most recent deployments."""
|
|
20
|
+
cwd = Path.cwd()
|
|
21
|
+
|
|
22
|
+
with httpx.Client(timeout=30.0) as client:
|
|
23
|
+
current_link, active_session = require_link_and_session(client, cwd)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
project = api.get_project(
|
|
27
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
28
|
+
)
|
|
29
|
+
except api.ApiError as exc:
|
|
30
|
+
typer.echo(str(exc), err=True)
|
|
31
|
+
raise typer.Exit(code=1)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
deployments = api.list_project_deployments(
|
|
35
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
36
|
+
)
|
|
37
|
+
except api.ApiError as exc:
|
|
38
|
+
typer.echo(str(exc), err=True)
|
|
39
|
+
raise typer.Exit(code=1)
|
|
40
|
+
|
|
41
|
+
typer.echo(f"{project.get('name')} ({project.get('slug')})")
|
|
42
|
+
typer.echo(f" status: {project.get('status')}")
|
|
43
|
+
|
|
44
|
+
if not deployments:
|
|
45
|
+
typer.echo("No deployments yet.")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
typer.echo("Recent deployments:")
|
|
49
|
+
for deployment in deployments[:5]:
|
|
50
|
+
stable_marker = " [stable]" if deployment.get("is_stable") else ""
|
|
51
|
+
typer.echo(
|
|
52
|
+
f" #{deployment.get('id')} {deployment.get('deployed_at') or '(no timestamp)'} "
|
|
53
|
+
f"{deployment.get('component')}={deployment.get('version')} "
|
|
54
|
+
f"{deployment.get('status')}{stable_marker}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("rollback")
|
|
59
|
+
def rollback_cmd(
|
|
60
|
+
to: int | None = typer.Option(
|
|
61
|
+
None, "--to", help="Deployment id to roll back to. Omit to roll back to the previous deployment."
|
|
62
|
+
),
|
|
63
|
+
yes: bool = typer.Option(False, "--yes", help="Skip the interactive confirmation prompt."),
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Roll back the linked project's deployment. A real, consequential production action."""
|
|
66
|
+
cwd = Path.cwd()
|
|
67
|
+
|
|
68
|
+
with httpx.Client(timeout=30.0) as client:
|
|
69
|
+
current_link, active_session = require_link_and_session(client, cwd)
|
|
70
|
+
|
|
71
|
+
target_desc = f"deployment #{to}" if to is not None else "the previous deployment"
|
|
72
|
+
project_desc = f"{current_link.org_slug}/{current_link.project_slug}"
|
|
73
|
+
typer.echo(f"This will roll back {project_desc} to {target_desc}.")
|
|
74
|
+
typer.echo("Target environment: prod.")
|
|
75
|
+
if not yes:
|
|
76
|
+
typer.confirm("Continue?", abort=True)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
if to is not None:
|
|
80
|
+
result = api.rollback_project_to(
|
|
81
|
+
client,
|
|
82
|
+
active_session.access_token,
|
|
83
|
+
current_link.org_id,
|
|
84
|
+
current_link.project_id,
|
|
85
|
+
to,
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
result = api.rollback_project(
|
|
89
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
90
|
+
)
|
|
91
|
+
except api.ApiError as exc:
|
|
92
|
+
typer.echo(str(exc), err=True)
|
|
93
|
+
raise typer.Exit(code=1)
|
|
94
|
+
|
|
95
|
+
task_id = result.get("task_id") or result.get("target_task")
|
|
96
|
+
typer.echo(f"Rollback triggered (task_id={task_id}).")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command("deploy")
|
|
100
|
+
def deploy_cmd(
|
|
101
|
+
frontend: str | None = typer.Option(
|
|
102
|
+
None, "--frontend", help="Frontend image tag to deploy (e.g. v1.9.319)."
|
|
103
|
+
),
|
|
104
|
+
backend: str | None = typer.Option(
|
|
105
|
+
None, "--backend", help="Backend image tag to deploy (e.g. v1.9.319)."
|
|
106
|
+
),
|
|
107
|
+
environment: str = typer.Option("prod", "--env", help="Environment to deploy to."),
|
|
108
|
+
force: bool = typer.Option(
|
|
109
|
+
False,
|
|
110
|
+
"--force",
|
|
111
|
+
help=(
|
|
112
|
+
"Override a blocking preflight check (the platform's own "
|
|
113
|
+
"known-to-fail guard). A separate, explicit decision each time — "
|
|
114
|
+
"never set automatically by this CLI."
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
yes: bool = typer.Option(False, "--yes", help="Skip the interactive confirmation prompt."),
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Deploy an already-built image tag to the linked Mozbridge project.
|
|
120
|
+
|
|
121
|
+
A real, consequential production action — same category as `mozbridge
|
|
122
|
+
rollback`: it mutates production the moment it's called, not a dry-run.
|
|
123
|
+
Requires `mozbridge link` and an active login session. At least one of
|
|
124
|
+
--frontend/--backend must be given (deploying neither makes no sense).
|
|
125
|
+
|
|
126
|
+
Only the services you actually gave a tag for are deployed — `services`
|
|
127
|
+
is built from --frontend/--backend, not left to the API's own default of
|
|
128
|
+
both, unless you passed both.
|
|
129
|
+
"""
|
|
130
|
+
if frontend is None and backend is None:
|
|
131
|
+
typer.echo(
|
|
132
|
+
"Provide at least one of --frontend/--backend — deploying neither makes no sense.",
|
|
133
|
+
err=True,
|
|
134
|
+
)
|
|
135
|
+
raise typer.Exit(code=1)
|
|
136
|
+
|
|
137
|
+
services: list[str] = []
|
|
138
|
+
if frontend is not None:
|
|
139
|
+
services.append("frontend")
|
|
140
|
+
if backend is not None:
|
|
141
|
+
services.append("backend")
|
|
142
|
+
|
|
143
|
+
cwd = Path.cwd()
|
|
144
|
+
|
|
145
|
+
with httpx.Client(timeout=30.0) as client:
|
|
146
|
+
current_link, active_session = require_link_and_session(client, cwd)
|
|
147
|
+
|
|
148
|
+
project_desc = f"{current_link.org_slug}/{current_link.project_slug}"
|
|
149
|
+
typer.echo(f"This will deploy {project_desc} ({environment}):")
|
|
150
|
+
if frontend is not None:
|
|
151
|
+
typer.echo(f" frontend -> {frontend}")
|
|
152
|
+
if backend is not None:
|
|
153
|
+
typer.echo(f" backend -> {backend}")
|
|
154
|
+
if not yes:
|
|
155
|
+
typer.confirm("Continue?", abort=True)
|
|
156
|
+
|
|
157
|
+
payload = {
|
|
158
|
+
"frontend_version": frontend,
|
|
159
|
+
"backend_version": backend,
|
|
160
|
+
"services": services,
|
|
161
|
+
"environment": environment,
|
|
162
|
+
"force": force,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
result = api.deploy_project(
|
|
167
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id, payload
|
|
168
|
+
)
|
|
169
|
+
except api.PreflightBlockedError as exc:
|
|
170
|
+
typer.echo(str(exc), err=True)
|
|
171
|
+
for check in exc.blocking:
|
|
172
|
+
title = check.get("title") or check.get("key") or "(unnamed check)"
|
|
173
|
+
detail = check.get("detail") or ""
|
|
174
|
+
typer.echo(f" - {title}: {detail}", err=True)
|
|
175
|
+
if check.get("action"):
|
|
176
|
+
typer.echo(f" action: {check['action']}", err=True)
|
|
177
|
+
typer.echo(exc.override or "Pass force=true to deploy anyway.", err=True)
|
|
178
|
+
typer.echo("Re-run with --force to override — this is not done automatically.", err=True)
|
|
179
|
+
raise typer.Exit(code=1)
|
|
180
|
+
except api.ApiError as exc:
|
|
181
|
+
typer.echo(str(exc), err=True)
|
|
182
|
+
raise typer.Exit(code=1)
|
|
183
|
+
|
|
184
|
+
task_id = result.get("task_id")
|
|
185
|
+
typer.echo(f"Deploy triggered (task_id={task_id}).")
|
|
186
|
+
typer.echo("Run `mozbridge logs` to watch it.")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _iter_sse_events(lines) -> Iterator[tuple[str, str]]:
|
|
190
|
+
"""Parse a text/event-stream line iterator into (event, data) pairs.
|
|
191
|
+
|
|
192
|
+
Standard SSE framing (see router.py's `stream_operation_logs`, which is
|
|
193
|
+
the only producer this ever reads): lines starting with "data:" accumulate
|
|
194
|
+
until a blank line, which dispatches one event; an "event:" line names
|
|
195
|
+
its type (defaults to "message" if absent, though this server always
|
|
196
|
+
sends one). A leading single space after the colon is stripped per the
|
|
197
|
+
SSE spec; any other line (comments, unknown fields) is ignored.
|
|
198
|
+
"""
|
|
199
|
+
event_type = "message"
|
|
200
|
+
data_lines: list[str] = []
|
|
201
|
+
for raw_line in lines:
|
|
202
|
+
line = raw_line.rstrip("\r")
|
|
203
|
+
if line == "":
|
|
204
|
+
if data_lines:
|
|
205
|
+
yield event_type, "\n".join(data_lines)
|
|
206
|
+
event_type = "message"
|
|
207
|
+
data_lines = []
|
|
208
|
+
continue
|
|
209
|
+
if line.startswith("event:"):
|
|
210
|
+
event_type = line[len("event:") :].strip()
|
|
211
|
+
elif line.startswith("data:"):
|
|
212
|
+
value = line[len("data:") :]
|
|
213
|
+
if value.startswith(" "):
|
|
214
|
+
value = value[1:]
|
|
215
|
+
data_lines.append(value)
|
|
216
|
+
if data_lines:
|
|
217
|
+
yield event_type, "\n".join(data_lines)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command("logs")
|
|
221
|
+
def logs_cmd(
|
|
222
|
+
task_id: str | None = typer.Argument(
|
|
223
|
+
None, help="Task id of a build/deploy operation. Omit to use the most recent one for this project."
|
|
224
|
+
),
|
|
225
|
+
follow: bool = typer.Option(
|
|
226
|
+
False, "--follow", "-f", help="Stream new log output as it arrives; exits when the run finishes."
|
|
227
|
+
),
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Show BUILD/DEPLOY OPERATION logs for the linked project.
|
|
230
|
+
|
|
231
|
+
This is the log output of a Mozbridge build or deploy task — what
|
|
232
|
+
`publish`, `deploy`, and `rollback` trigger — NOT your running
|
|
233
|
+
application's runtime/request logs. This platform does not expose
|
|
234
|
+
application runtime logs via any API today; this command is not
|
|
235
|
+
`docker logs` for your live app.
|
|
236
|
+
|
|
237
|
+
Requires `mozbridge link` and an active login session. Without --follow,
|
|
238
|
+
fetches and prints whatever log content exists right now, once. With
|
|
239
|
+
--follow, opens the platform's SSE log stream and prints new content as
|
|
240
|
+
it arrives, exiting cleanly when the server closes the stream (it does
|
|
241
|
+
so automatically once the operation reaches a terminal state).
|
|
242
|
+
"""
|
|
243
|
+
cwd = Path.cwd()
|
|
244
|
+
|
|
245
|
+
with httpx.Client(timeout=30.0) as client:
|
|
246
|
+
current_link, active_session = require_link_and_session(client, cwd)
|
|
247
|
+
|
|
248
|
+
resolved_task_id = task_id
|
|
249
|
+
if resolved_task_id is None:
|
|
250
|
+
try:
|
|
251
|
+
operations = api.list_project_operations(
|
|
252
|
+
client,
|
|
253
|
+
active_session.access_token,
|
|
254
|
+
current_link.org_id,
|
|
255
|
+
current_link.project_id,
|
|
256
|
+
limit=1,
|
|
257
|
+
)
|
|
258
|
+
except api.ApiError as exc:
|
|
259
|
+
typer.echo(str(exc), err=True)
|
|
260
|
+
raise typer.Exit(code=1)
|
|
261
|
+
if not operations or not operations[0].get("task_id"):
|
|
262
|
+
typer.echo("No operations found for this project yet.", err=True)
|
|
263
|
+
raise typer.Exit(code=1)
|
|
264
|
+
resolved_task_id = operations[0]["task_id"]
|
|
265
|
+
|
|
266
|
+
if not follow:
|
|
267
|
+
try:
|
|
268
|
+
op = api.get_operation(
|
|
269
|
+
client, active_session.access_token, current_link.org_id, resolved_task_id
|
|
270
|
+
)
|
|
271
|
+
except api.ApiError as exc:
|
|
272
|
+
typer.echo(str(exc), err=True)
|
|
273
|
+
raise typer.Exit(code=1)
|
|
274
|
+
logs = (op.get("result") or {}).get("logs")
|
|
275
|
+
if logs:
|
|
276
|
+
typer.echo(logs)
|
|
277
|
+
else:
|
|
278
|
+
typer.echo(f"No logs yet for task {resolved_task_id} (status: {op.get('status')}).")
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
org_id = current_link.org_id
|
|
282
|
+
access_token = active_session.access_token
|
|
283
|
+
|
|
284
|
+
# --follow: a separate client with no read timeout — the server holds
|
|
285
|
+
# this connection open for as long as the operation runs, closing it
|
|
286
|
+
# itself once the operation reaches a terminal state (see
|
|
287
|
+
# stream_operation_logs's docstring); we only need to survive that,
|
|
288
|
+
# not impose our own timeout on top of it.
|
|
289
|
+
stream_url = api.operation_stream_url(resolved_task_id)
|
|
290
|
+
headers = {"Authorization": f"Bearer {access_token}", "X-Organization-Id": str(org_id)}
|
|
291
|
+
try:
|
|
292
|
+
with httpx.Client(timeout=httpx.Timeout(30.0, read=None)) as stream_client:
|
|
293
|
+
with stream_client.stream("GET", stream_url, headers=headers) as resp:
|
|
294
|
+
if resp.status_code != 200:
|
|
295
|
+
resp.read()
|
|
296
|
+
typer.echo(f"Could not stream logs: HTTP {resp.status_code}: {resp.text}", err=True)
|
|
297
|
+
raise typer.Exit(code=1)
|
|
298
|
+
for event_type, data in _iter_sse_events(resp.iter_lines()):
|
|
299
|
+
if event_type == "log":
|
|
300
|
+
typer.echo(data)
|
|
301
|
+
elif event_type == "error":
|
|
302
|
+
typer.echo(f"Stream error: {data}", err=True)
|
|
303
|
+
elif event_type == "done":
|
|
304
|
+
break
|
|
305
|
+
except httpx.HTTPError as exc:
|
|
306
|
+
typer.echo(f"Log stream connection dropped: {exc}", err=True)
|
|
307
|
+
raise typer.Exit(code=1)
|