mozbridge-cli 0.2.0__tar.gz → 0.2.1__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.
Files changed (40) hide show
  1. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/PKG-INFO +1 -1
  2. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/docs/commands.md +190 -0
  3. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/pyproject.toml +1 -1
  4. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/src/mozbridge_cli/api.py +162 -0
  5. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/src/mozbridge_cli/main.py +323 -2
  6. mozbridge_cli-0.2.1/tests/test_deploy.py +268 -0
  7. mozbridge_cli-0.2.1/tests/test_env.py +242 -0
  8. mozbridge_cli-0.2.1/tests/test_logs.py +252 -0
  9. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/.gitignore +0 -0
  10. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/README.md +0 -0
  11. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/scripts/release.sh +0 -0
  12. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/src/mozbridge_cli/__init__.py +0 -0
  13. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/src/mozbridge_cli/__main__.py +0 -0
  14. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/src/mozbridge_cli/auth.py +0 -0
  15. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/src/mozbridge_cli/build.py +0 -0
  16. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/src/mozbridge_cli/compose.py +0 -0
  17. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/src/mozbridge_cli/config.py +0 -0
  18. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/src/mozbridge_cli/link.py +0 -0
  19. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/src/mozbridge_cli/local_build.py +0 -0
  20. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/src/mozbridge_cli/runtime_secrets.py +0 -0
  21. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/src/mozbridge_cli/session.py +0 -0
  22. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/conftest.py +0 -0
  23. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_build.py +0 -0
  24. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_ci_token_auth.py +0 -0
  25. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_compose.py +0 -0
  26. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_diff.py +0 -0
  27. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_link.py +0 -0
  28. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_local_build.py +0 -0
  29. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_login.py +0 -0
  30. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_login_cli.py +0 -0
  31. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_logout.py +0 -0
  32. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_publish.py +0 -0
  33. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_publish_local.py +0 -0
  34. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_publish_multi_component.py +0 -0
  35. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_refresh.py +0 -0
  36. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_rollback.py +0 -0
  37. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_runtime_secrets.py +0 -0
  38. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_session_permissions.py +0 -0
  39. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_status.py +0 -0
  40. {mozbridge_cli-0.2.0 → mozbridge_cli-0.2.1}/tests/test_whoami.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mozbridge-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
@@ -547,6 +547,196 @@ Rollback triggered (task_id=777).
547
547
 
548
548
  ---
549
549
 
550
+ ## `mozbridge deploy`
551
+
552
+ Deploys an already-built image tag to the linked Mozbridge project. A real,
553
+ consequential production action — same category as `mozbridge rollback`: it
554
+ mutates production the moment it's called, **not a dry-run**. Prompts for
555
+ confirmation unless `--yes` is given.
556
+
557
+ **Flags**:
558
+ | Flag | Meaning |
559
+ |---|---|
560
+ | `--frontend TAG` | Frontend image tag to deploy. |
561
+ | `--backend TAG` | Backend image tag to deploy. |
562
+ | `--env NAME` | Environment to deploy to. Default `prod`. |
563
+ | `--force` | Override a blocking preflight check. Never set automatically — a separate, explicit decision each time. |
564
+ | `--yes` | Skip the interactive confirmation prompt. |
565
+
566
+ **Requires**: link + login, same as `publish`/`status`/`rollback`. At least
567
+ one of `--frontend`/`--backend` must be given — deploying neither makes no
568
+ sense.
569
+
570
+ **What it does**: prints what it's about to do (project, which service(s),
571
+ which tag(s), which environment), then either aborts on a declined prompt or
572
+ calls `POST /api/v1/projects/{project_id}/deploy` with
573
+ `{frontend_version, backend_version, services, environment, force}`.
574
+ **`services` is built from which of `--frontend`/`--backend` you actually
575
+ gave a tag** — not the API's own default of deploying both — so the CLI only
576
+ ever asks the platform to deploy what you specified.
577
+
578
+ The backend runs a preflight check before enqueuing the deploy. A 409
579
+ response means it found a real, known-to-fail condition and refused —
580
+ each blocking check is printed with its title, detail, and suggested
581
+ action, followed by a hint to re-run with `--force` if you want to override
582
+ it. **The CLI never retries with `--force` automatically** — that is always
583
+ a separate, explicit re-invocation.
584
+
585
+ **Example (`--yes`, single service)**:
586
+ ```
587
+ $ mozbridge deploy --backend v1.9.319 --yes
588
+ This will deploy acme/web-app (prod):
589
+ backend -> v1.9.319
590
+ Deploy triggered (task_id=888).
591
+ Run `mozbridge logs` to watch it.
592
+ ```
593
+
594
+ **Example (preflight blocked)**:
595
+ ```
596
+ $ mozbridge deploy --backend v1.9.319 --yes
597
+ This will deploy acme/web-app (prod):
598
+ backend -> v1.9.319
599
+ Preflight failed — this deploy is known to fail.
600
+ - No registry credential: GHCR_TOKEN is not configured for this project.
601
+ action: Set a registry credential in Project Settings.
602
+ Pass force=true to deploy anyway.
603
+ Re-run with --force to override — this is not done automatically.
604
+ ```
605
+
606
+ **Failure modes**:
607
+ - Neither `--frontend` nor `--backend` given: clear error, exit 1, no API
608
+ call is made.
609
+ - Not linked / not logged in: identical messages to `publish` above, exit 1.
610
+ - Confirmation prompt declined (`--yes` not passed, answer is not `y`):
611
+ Typer's own `Abort` handling — the command exits non-zero without calling
612
+ the API.
613
+ - Preflight-blocked (409, structured body): every blocking check printed
614
+ clearly plus a `--force` hint, exit 1 — no automatic retry.
615
+ - Any other API error: the raw `ApiError` message, exit 1.
616
+
617
+ ---
618
+
619
+ ## `mozbridge logs`
620
+
621
+ Shows **BUILD/DEPLOY OPERATION logs** for the linked project — the log
622
+ output of a Mozbridge build or deploy task (what `publish`, `deploy`, and
623
+ `rollback` trigger). **This is not your running application's
624
+ runtime/request logs** — the platform does not expose those via any API
625
+ today, and this command is not `docker logs` for your live app.
626
+
627
+ **Flags**:
628
+ | Flag | Meaning |
629
+ |---|---|
630
+ | `TASK_ID` (argument, optional) | Task id of a build/deploy operation. Omit to use the most recent one for this project. |
631
+ | `--follow` / `-f` | Stream new log output as it arrives; exits when the run finishes. |
632
+
633
+ **Requires**: link + login, same as `status`/`diff`/`rollback`.
634
+
635
+ **What it does**:
636
+ - If `TASK_ID` is omitted: `GET /api/v1/projects/{project_id}/operations?limit=1`
637
+ (newest-first, same ordering `status`/`diff` already rely on for
638
+ deployments) to resolve the most recent operation's `task_id`.
639
+ - Without `--follow`: one `GET /api/v1/projects/operations/{task_id}` call,
640
+ printing whatever is in `result.logs` right now — a single snapshot, no
641
+ long-lived connection.
642
+ - With `--follow`: opens `GET /api/v1/projects/operations/{task_id}/stream`
643
+ (SSE) and prints each `log` event's content as it's parsed. The server
644
+ closes the stream itself once the operation reaches a terminal state
645
+ (success/failed/error) — the CLI exits cleanly at that point. A dropped
646
+ connection surfaces a clear error instead of hanging.
647
+
648
+ **Example (snapshot)**:
649
+ ```
650
+ $ mozbridge logs task-123
651
+ Step 1/5 : FROM python:3.12
652
+ Step 2/5 : COPY . /app
653
+ ```
654
+
655
+ **Example (follow)**:
656
+ ```
657
+ $ mozbridge logs task-123 --follow
658
+ Step 1/5 : FROM python:3.12
659
+ Step 2/5 : COPY . /app
660
+ Successfully built abc123
661
+ ```
662
+
663
+ **Failure modes**:
664
+ - Not linked / not logged in: identical messages to `publish` above, exit 1.
665
+ - No `TASK_ID` given and the project has no operations yet: `No operations
666
+ found for this project yet.`, exit 1.
667
+ - The operation lookup or stream request fails: the raw `ApiError` message
668
+ (snapshot mode) or `Log stream connection dropped: <error>` (`--follow`),
669
+ exit 1.
670
+
671
+ ---
672
+
673
+ ## `mozbridge env list` / `mozbridge env set` / `mozbridge env rm`
674
+
675
+ Manages the linked project's environment variables
676
+ (`GET`/`POST`/`DELETE /api/v1/projects/{project_id}/env-vars[...]`).
677
+ `EnvVar` is a plain `{key, value}` pair — distinct from the project's
678
+ separate Vault-backed secrets blocks — and nothing in the platform's own
679
+ dashboard UI masks these values, so `env list` prints them in plain text.
680
+
681
+ **Requires**: link + login, same as `status`/`diff`/`rollback`.
682
+
683
+ ### `mozbridge env list`
684
+
685
+ **Flags**: none.
686
+
687
+ **What it does**: `GET /api/v1/projects/{project_id}/env-vars`, prints every
688
+ key/value pair sorted by key. Prints `No environment variables set.` if
689
+ there are none.
690
+
691
+ ```
692
+ $ mozbridge env list
693
+ DATABASE_URL postgres://user:pass@host/db
694
+ DEBUG false
695
+ ```
696
+
697
+ ### `mozbridge env set KEY VALUE`
698
+
699
+ **What it does**: `POST /api/v1/projects/{project_id}/env-vars` with body
700
+ `{"key": KEY, "value": VALUE}`. **This is an upsert, not create-only** —
701
+ read directly off `backend/app/security.py:store_project_env_var`: the
702
+ handler loads the existing key->value dict out of Vault, does
703
+ `existing[key] = value`, and writes the whole dict back. Setting an
704
+ already-existing `KEY` silently overwrites it; there is no 409/400 and no
705
+ separate create-vs-update codepath.
706
+
707
+ The confirmation deliberately prints only the key, never the value, so a
708
+ secret-shaped `VALUE` isn't echoed a second time into your terminal beyond
709
+ the command invocation itself:
710
+
711
+ ```
712
+ $ mozbridge env set DATABASE_URL postgres://user:pass@host/db
713
+ Set DATABASE_URL.
714
+ ```
715
+
716
+ ### `mozbridge env rm KEY`
717
+
718
+ **What it does**: `DELETE /api/v1/projects/{project_id}/env-vars/{KEY}`.
719
+ **This is idempotent, not a strict "must exist" delete** — read directly
720
+ off `backend/app/security.py:delete_project_env_var`: it deletes `KEY`
721
+ `if key in existing`, and otherwise falls straight through to `return
722
+ True` — either way the route responds 200. Removing a `KEY` that was never
723
+ set still reports success; there is no 404 for a missing key, and this
724
+ command does not invent one.
725
+
726
+ ```
727
+ $ mozbridge env rm DATABASE_URL
728
+ Removed DATABASE_URL.
729
+ ```
730
+
731
+ **Failure modes (all three)**:
732
+ - Not linked / not logged in: identical messages to `publish` above, exit 1.
733
+ - Any other API error (e.g. a 500 from a Vault write/delete failure): the
734
+ raw `ApiError` message (`Could not list env vars: <detail>` / `Could not
735
+ set the env var: <detail>` / `Could not remove the env var: <detail>`),
736
+ exit 1.
737
+
738
+ ---
739
+
550
740
  ## A note on authorization for `publish` / `rollback`
551
741
 
552
742
  Both commands work today because a plain human Logto session token passes
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mozbridge-cli"
7
- version = "0.2.0"
7
+ version = "0.2.1"
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"
@@ -36,6 +36,22 @@ class ApiError(Exception):
36
36
  """Raised when the Mozbridge API returns a response we can't recover from."""
37
37
 
38
38
 
39
+ class PreflightBlockedError(ApiError):
40
+ """POST .../deploy returned 409 with the structured preflight-blocking body.
41
+
42
+ backend/app/features/projects/router.py's `_assert_preflight_clear` shapes
43
+ this as {message, blocking: [{key, title, detail, action}, ...], override}
44
+ — distinct enough from every other ApiError (which just carries a flat
45
+ string `detail`) that callers need the pieces separately to print each
46
+ blocking check on its own line, not a flattened string.
47
+ """
48
+
49
+ def __init__(self, message: str, blocking: list[dict], override: str | None) -> None:
50
+ super().__init__(message)
51
+ self.blocking = blocking
52
+ self.override = override
53
+
54
+
39
55
  def _headers(access_token: str, org_id: int | None = None) -> dict[str, str]:
40
56
  headers = {"Authorization": f"Bearer {access_token}"}
41
57
  if org_id is not None:
@@ -237,6 +253,61 @@ def trigger_build(
237
253
  return resp.json()
238
254
 
239
255
 
256
+ def list_project_env_vars(
257
+ client: httpx.Client, access_token: str, org_id: int, project_id: int
258
+ ) -> list[dict]:
259
+ """GET /api/v1/projects/{project_id}/env-vars -> list[schemas.EnvVar] ({key, value})."""
260
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/env-vars"
261
+ try:
262
+ resp = client.get(url, headers=_headers(access_token, org_id))
263
+ except httpx.HTTPError as exc:
264
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
265
+ _raise_for_status(resp, "list env vars")
266
+ return resp.json()
267
+
268
+
269
+ def set_project_env_var(
270
+ client: httpx.Client, access_token: str, org_id: int, project_id: int, key: str, value: str
271
+ ) -> dict:
272
+ """POST /api/v1/projects/{project_id}/env-vars, body schemas.EnvVarCreate {key, value}.
273
+
274
+ This is an upsert, not create-only: router.py's update_project_env_var
275
+ calls security.store_project_env_var, which reads the existing
276
+ key->value dict out of Vault, does `existing[key] = value`, and writes
277
+ the whole dict back. Setting an already-existing key silently
278
+ overwrites it — there is no separate create-vs-update codepath and no
279
+ 409/400 for an existing key.
280
+ """
281
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/env-vars"
282
+ try:
283
+ resp = client.post(url, json={"key": key, "value": value}, headers=_headers(access_token, org_id))
284
+ except httpx.HTTPError as exc:
285
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
286
+ _raise_for_status(resp, "set the env var")
287
+ return resp.json()
288
+
289
+
290
+ def delete_project_env_var(
291
+ client: httpx.Client, access_token: str, org_id: int, project_id: int, key: str
292
+ ) -> dict:
293
+ """DELETE /api/v1/projects/{project_id}/env-vars/{key}.
294
+
295
+ Idempotent, not a strict "must exist" delete: router.py's
296
+ delete_project_env_var calls security.delete_project_env_var, which
297
+ only deletes `key` from the existing dict `if key in existing` and
298
+ otherwise falls straight through to `return True` — either way the
299
+ route responds 200 {"status": "success", "message": "Variable <key>
300
+ removed"}. There is no 404 for a key that was never set.
301
+ """
302
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/env-vars/{key}"
303
+ try:
304
+ resp = client.delete(url, headers=_headers(access_token, org_id))
305
+ except httpx.HTTPError as exc:
306
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
307
+ _raise_for_status(resp, "remove the env var")
308
+ return resp.json()
309
+
310
+
240
311
  def trigger_prebuilt_build(
241
312
  client: httpx.Client,
242
313
  access_token: str,
@@ -272,3 +343,94 @@ def trigger_prebuilt_build(
272
343
  raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
273
344
  _raise_for_status(resp, "register the prebuilt image")
274
345
  return resp.json()
346
+
347
+
348
+ def deploy_project(
349
+ client: httpx.Client, access_token: str, org_id: int, project_id: int, payload: dict
350
+ ) -> dict:
351
+ """POST /api/v1/projects/{project_id}/deploy, body schemas.DeployRequest.
352
+
353
+ `payload` is sent as-is — the caller (main.py's `deploy` command) builds
354
+ the exact DeployRequest-shaped dict, since it's the one that knows which
355
+ fields the human actually asked for.
356
+
357
+ A 409 here means the backend's preflight gate (`_assert_preflight_clear`)
358
+ found real, known-to-fail conditions and refused to enqueue the deploy —
359
+ handled as `PreflightBlockedError`, not folded into the generic ApiError
360
+ path below, so the caller can print each blocking check individually.
361
+ Any other non-2xx (network error, plain validation error, etc.) falls
362
+ through to the same `_raise_for_status` -> `ApiError` path every other
363
+ function in this module uses.
364
+
365
+ Returns {"status": "enqueued", "action": "deploy", "task_id": ..., ...deploy_kwargs}.
366
+ """
367
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/deploy"
368
+ try:
369
+ resp = client.post(url, json=payload, headers=_headers(access_token, org_id))
370
+ except httpx.HTTPError as exc:
371
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
372
+
373
+ if resp.status_code == 409:
374
+ try:
375
+ body = resp.json()
376
+ except ValueError:
377
+ body = None
378
+ detail = body.get("detail") if isinstance(body, dict) else None
379
+ if isinstance(detail, dict) and "blocking" in detail:
380
+ raise PreflightBlockedError(
381
+ detail.get("message") or "Preflight failed — this deploy is known to fail.",
382
+ detail.get("blocking") or [],
383
+ detail.get("override"),
384
+ )
385
+
386
+ _raise_for_status(resp, "deploy the project")
387
+ return resp.json()
388
+
389
+
390
+ def list_project_operations(
391
+ client: httpx.Client, access_token: str, org_id: int, project_id: int, *, limit: int = 1
392
+ ) -> list[dict]:
393
+ """GET /api/v1/projects/{project_id}/operations?limit=... -> list[schemas.ProjectOperation].
394
+
395
+ Backend orders these newest-first (ProjectService.get_operations_with_logs
396
+ sorts by created_at.desc(), same pattern as list_project_deployments), so
397
+ `limit=1` is enough to resolve "the most recent operation for this
398
+ project" for `mozbridge logs` when no task id is given.
399
+ """
400
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/operations"
401
+ try:
402
+ resp = client.get(url, params={"limit": limit}, headers=_headers(access_token, org_id))
403
+ except httpx.HTTPError as exc:
404
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
405
+ _raise_for_status(resp, "list operations")
406
+ return resp.json()
407
+
408
+
409
+ def get_operation(client: httpx.Client, access_token: str, org_id: int, task_id: str) -> dict:
410
+ """GET /api/v1/projects/operations/{task_id} -> schemas.ProjectOperation.
411
+
412
+ Note this route lives directly under /operations/, not
413
+ /{project_id}/operations/{task_id} — router.py's `get_task_operation`
414
+ resolves the project purely from the ProjectOperation row itself (scoped
415
+ by org via X-Organization-Id), so no project_id is needed in the path.
416
+ Used by `mozbridge logs` (without --follow) for a one-shot snapshot of
417
+ whatever's in `result.logs` so far.
418
+ """
419
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/operations/{task_id}"
420
+ try:
421
+ resp = client.get(url, headers=_headers(access_token, org_id))
422
+ except httpx.HTTPError as exc:
423
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
424
+ _raise_for_status(resp, "fetch the operation")
425
+ return resp.json()
426
+
427
+
428
+ def operation_stream_url(task_id: str) -> str:
429
+ """URL for GET /api/v1/projects/operations/{task_id}/stream (SSE).
430
+
431
+ A plain string builder, not a request function — `mozbridge logs
432
+ --follow` needs the URL to open its own long-lived `client.stream(...)`
433
+ call directly (see main.py's `_iter_sse_events`), rather than going
434
+ through a request/response helper shaped for ordinary JSON calls.
435
+ """
436
+ return f"{config.API_BASE_URL}{config.PROJECTS_PATH}/operations/{task_id}/stream"