mozbridge-cli 0.2.1__tar.gz → 0.4.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.
Files changed (56) hide show
  1. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/PKG-INFO +8 -6
  2. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/README.md +7 -5
  3. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/docs/commands.md +214 -6
  4. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/pyproject.toml +1 -1
  5. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/scripts/release.sh +17 -0
  6. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/src/mozbridge_cli/api.py +232 -0
  7. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_auth.py +152 -0
  8. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_deploy.py +363 -0
  9. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_doctor.py +229 -0
  10. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_env.py +115 -0
  11. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_projects.py +92 -0
  12. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_publish.py +498 -0
  13. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_registry.py +134 -0
  14. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_secrets.py +165 -0
  15. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_shared.py +31 -0
  16. mozbridge_cli-0.4.0/src/mozbridge_cli/cmd_token.py +180 -0
  17. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/src/mozbridge_cli/config.py +7 -0
  18. mozbridge_cli-0.4.0/src/mozbridge_cli/main.py +64 -0
  19. mozbridge_cli-0.4.0/tests/test_deploy_preflight.py +259 -0
  20. mozbridge_cli-0.4.0/tests/test_doctor.py +407 -0
  21. mozbridge_cli-0.4.0/tests/test_registry.py +293 -0
  22. mozbridge_cli-0.4.0/tests/test_secrets.py +294 -0
  23. mozbridge_cli-0.4.0/tests/test_token.py +275 -0
  24. mozbridge_cli-0.2.1/src/mozbridge_cli/main.py +0 -1142
  25. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/.gitignore +0 -0
  26. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/src/mozbridge_cli/__init__.py +0 -0
  27. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/src/mozbridge_cli/__main__.py +0 -0
  28. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/src/mozbridge_cli/auth.py +0 -0
  29. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/src/mozbridge_cli/build.py +0 -0
  30. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/src/mozbridge_cli/compose.py +0 -0
  31. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/src/mozbridge_cli/link.py +0 -0
  32. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/src/mozbridge_cli/local_build.py +0 -0
  33. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/src/mozbridge_cli/runtime_secrets.py +0 -0
  34. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/src/mozbridge_cli/session.py +0 -0
  35. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/conftest.py +0 -0
  36. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_build.py +0 -0
  37. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_ci_token_auth.py +0 -0
  38. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_compose.py +0 -0
  39. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_deploy.py +0 -0
  40. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_diff.py +0 -0
  41. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_env.py +0 -0
  42. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_link.py +0 -0
  43. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_local_build.py +0 -0
  44. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_login.py +0 -0
  45. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_login_cli.py +0 -0
  46. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_logout.py +0 -0
  47. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_logs.py +0 -0
  48. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_publish.py +0 -0
  49. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_publish_local.py +0 -0
  50. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_publish_multi_component.py +0 -0
  51. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_refresh.py +0 -0
  52. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_rollback.py +0 -0
  53. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_runtime_secrets.py +0 -0
  54. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_session_permissions.py +0 -0
  55. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.0}/tests/test_status.py +0 -0
  56. {mozbridge_cli-0.2.1 → mozbridge_cli-0.4.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.2.1
3
+ Version: 0.4.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
@@ -250,11 +250,13 @@ written, or permission-checked in this path at all. Because of that,
250
250
  means "don't attempt an interactive login") — unset it first if you actually
251
251
  want to run the device flow.
252
252
 
253
- **Where the token comes from**: a project-scoped Mozbridge `ServiceToken`,
254
- created out of band via the Mozbridge dashboard/API
255
- (`create_service_token`) — the CLI never creates or mints one itself. Set it
256
- as your CI platform's secret store, never as a `--token` flag (it would leak
257
- into shell history and process listings) and never committed to the repo.
253
+ **Where the token comes from**: a project- or org-scoped Mozbridge
254
+ `ServiceToken`. Mint one directly from this CLI — `mozbridge token create
255
+ --scope deploy` (requires an active `mozbridge login` session; see
256
+ `mozbridge token create --help`) — or out of band via the dashboard/API
257
+ (`create_service_token`). Set it as your CI platform's secret store, never
258
+ as a `--token` flag (it would leak into shell history and process
259
+ listings) and never committed to the repo.
258
260
 
259
261
  **`MOZBRIDGE_TOKEN` vs `MOZBRIDGE_BUILD_TOKEN` — do not confuse these**:
260
262
 
@@ -222,11 +222,13 @@ written, or permission-checked in this path at all. Because of that,
222
222
  means "don't attempt an interactive login") — unset it first if you actually
223
223
  want to run the device flow.
224
224
 
225
- **Where the token comes from**: a project-scoped Mozbridge `ServiceToken`,
226
- created out of band via the Mozbridge dashboard/API
227
- (`create_service_token`) — the CLI never creates or mints one itself. Set it
228
- as your CI platform's secret store, never as a `--token` flag (it would leak
229
- into shell history and process listings) and never committed to the repo.
225
+ **Where the token comes from**: a project- or org-scoped Mozbridge
226
+ `ServiceToken`. Mint one directly from this CLI — `mozbridge token create
227
+ --scope deploy` (requires an active `mozbridge login` session; see
228
+ `mozbridge token create --help`) — or out of band via the dashboard/API
229
+ (`create_service_token`). Set it as your CI platform's secret store, never
230
+ as a `--token` flag (it would leak into shell history and process
231
+ listings) and never committed to the repo.
230
232
 
231
233
  **`MOZBRIDGE_TOKEN` vs `MOZBRIDGE_BUILD_TOKEN` — do not confuse these**:
232
234
 
@@ -575,12 +575,25 @@ calls `POST /api/v1/projects/{project_id}/deploy` with
575
575
  gave a tag** — not the API's own default of deploying both — so the CLI only
576
576
  ever asks the platform to deploy what you specified.
577
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.
578
+ Before the confirmation prompt, `deploy` also proactively calls `GET
579
+ /projects/{id}/preflight` (the platform's own pre-deploy checks) and
580
+ prints anything at `warn`/`fail` level — title, detail, and suggested
581
+ action — so a likely-to-fail condition is visible before you commit, not
582
+ just after a 409. When every check is `pass`, nothing extra is printed —
583
+ the happy path stays exactly as quiet as before this existed. A failed
584
+ lookup here (network error, etc.) is swallowed silently and never blocks
585
+ `deploy` — this is informational only, matching the backend's own
586
+ fail-open design; the real gate is still the backend's own check at
587
+ enqueue time. These checks are platform-level (registry credentials, DNS,
588
+ disk, Vault role, and similar) — they do **not** detect a project's own
589
+ compose/template misconfiguration.
590
+
591
+ The backend also runs its own preflight check before enqueuing the
592
+ deploy. A 409 response means it found a real, known-to-fail condition and
593
+ refused — each blocking check is printed with its title, detail, and
594
+ suggested action, followed by a hint to re-run with `--force` if you want
595
+ to override it. **The CLI never retries with `--force` automatically** —
596
+ that is always a separate, explicit re-invocation.
584
597
 
585
598
  **Example (`--yes`, single service)**:
586
599
  ```
@@ -737,6 +750,201 @@ Removed DATABASE_URL.
737
750
 
738
751
  ---
739
752
 
753
+ ## `mozbridge registry status` / `mozbridge registry set`
754
+
755
+ Manages the linked project's own container-registry credential, used by
756
+ `publish --local` builds and by platform-side deploys when no
757
+ platform-provisioned registry applies. GET never returns a token/secret
758
+ value — `schemas.RegistryCredentialStatus` has no such field — so there is
759
+ no `registry get` that prints one.
760
+
761
+ ### `mozbridge registry status`
762
+
763
+ **What it does**: `GET /api/v1/projects/{project_id}/registry`. If the
764
+ project has its own credential configured, prints it (`registry_url`,
765
+ `username`, `updated_at`) and stops — a project-level credential always
766
+ takes precedence. Only when the project has none does it also call `GET
767
+ /api/v1/organizations/{org_id}/registry` and report the org-level default
768
+ instead, since that's what a build/deploy will actually fall back to.
769
+ This mirrors the real resolution order
770
+ (`app/services/registry_credentials.resolve_registry_credential`: project
771
+ override → org default → none) rather than only ever showing the
772
+ project's own (possibly misleading) empty state.
773
+
774
+ ```
775
+ $ mozbridge registry status
776
+ No project-level registry credential configured.
777
+ Builds/deploys will use the ORG-level default:
778
+ registry_url: ghcr.io
779
+ username: acme-bot
780
+ updated_at: 2026-09-01T12:00:00Z
781
+ ```
782
+
783
+ If neither is configured:
784
+
785
+ ```
786
+ $ mozbridge registry status
787
+ No registry credential configured at the project or org level.
788
+ `--local` builds will fail without one — run `mozbridge registry set`.
789
+ ```
790
+
791
+ ### `mozbridge registry set`
792
+
793
+ **Flags**: `--url` (default `ghcr.io`), `--username` (required), `--token`
794
+ (required — the registry password/token, not a Mozbridge service token).
795
+
796
+ **What it does**: `PUT /api/v1/projects/{project_id}/registry`. Always
797
+ writes the **project-level** credential — there is no `--org` flag on this
798
+ command, so an org-level default is configured from the dashboard
799
+ (**Organization Settings → Container Registry**), not from here. The
800
+ backend validates the credential against the real registry before storing
801
+ it; a bad credential is rejected with the registry's own error, not
802
+ silently saved.
803
+
804
+ ```
805
+ $ mozbridge registry set --url ghcr.io --username acme-bot --token ghp_xxx
806
+ Registry credential set (ghcr.io, username=acme-bot).
807
+ ```
808
+
809
+ **Failure modes**: not linked / not logged in, exit 1. Registry rejects
810
+ the credential (bad token, wrong URL): the raw validation error, exit 1.
811
+
812
+ ---
813
+
814
+ ## `mozbridge secrets list` / `mozbridge secrets set`
815
+
816
+ Manages the linked project's Vault-backed, per-environment/per-component
817
+ secrets (`backend/app/features/projects/router.py`'s
818
+ `/environments/{env}/secrets/{component}` routes) — distinct from the
819
+ plain env-vars `env` wraps. The underlying replace-all POST endpoint
820
+ deletes any key not included in the request; this CLI deliberately never
821
+ calls it — `secrets set` uses PATCH (merge one key) only, and `api.py` has
822
+ no wrapper for the replace-all route at all, so there is nothing here
823
+ that could call it by accident.
824
+
825
+ ### `mozbridge secrets list`
826
+
827
+ **Flags**: `--component` (`backend` default, or `frontend`/`infra`),
828
+ `--env` (`prod` default), `--show-values` (fetch and print actual VALUES
829
+ instead of just key names — prompts for confirmation unless `--yes` is
830
+ also passed).
831
+
832
+ **What it does**: defaults to the keys-only endpoint — no secret value
833
+ ever leaves the server for a plain `mozbridge secrets list`. With
834
+ `--show-values`, confirms (real secret material is about to print to your
835
+ terminal and end up in scrollback/history — same gating as `deploy`) then
836
+ fetches and prints actual values.
837
+
838
+ ```
839
+ $ mozbridge secrets list
840
+ backend/prod: 2 secret(s)
841
+ DATABASE_URL
842
+ STRIPE_SECRET_KEY
843
+ ```
844
+
845
+ ### `mozbridge secrets set KEY VALUE`
846
+
847
+ **Flags**: `--component` (`backend` default), `--env` (`prod` default).
848
+
849
+ **What it does**: `PATCH .../secrets/{component}` — merges this one key
850
+ server-side over a strict read of what's already there; never touches any
851
+ other key. There is no `secrets rm` — the backend has no single-key
852
+ delete endpoint, only PATCH-to-add/update or the wholesale-replace POST
853
+ this CLI avoids on purpose. Removing a key today means the dashboard, or
854
+ accepting the replace-all risk directly.
855
+
856
+ ```
857
+ $ mozbridge secrets set STRIPE_SECRET_KEY sk_live_xxx
858
+ Set STRIPE_SECRET_KEY (backend/prod).
859
+ ```
860
+
861
+ **Failure modes (both commands)**: not linked / not logged in, exit 1.
862
+ Any other API error: the raw `ApiError` message, exit 1.
863
+
864
+ ---
865
+
866
+ ## `mozbridge token create`
867
+
868
+ Mints a new Mozbridge `ServiceToken` without leaving the CLI — wraps `POST
869
+ /api/v1/tokens` (`token_router.create_service_token`), authenticated by
870
+ the CLI's existing human login session. A service token cannot call this
871
+ endpoint to mint further tokens (enforced server-side), which is exactly
872
+ why this command requires `mozbridge login`, not `MOZBRIDGE_TOKEN`.
873
+
874
+ **Flags**:
875
+ | Flag | Meaning |
876
+ |---|---|
877
+ | `--scope` | Repeatable. Required at least once — no silent default. Real scopes: `read`, `write`, `deploy`, `admin`, `build`. |
878
+ | `--name` | Token name. Defaults to a generated `mozbridge-cli-<slug>-<random>` name. |
879
+ | `--org` | Mint an org-scoped token (bound to the linked org, no single project) instead of the default project-scoped one. |
880
+ | `--project` | Mint a project-scoped token — already the default; pass only for explicitness. Mutually exclusive with `--org`. |
881
+ | `--expires` | A single duration: `45m`, `24h`, `30d`, `2w`. Omit for a token that never expires. |
882
+
883
+ **What it does**: calls `create_service_token` with the given scopes,
884
+ bound to the linked project (default) or org (`--org`). The raw token
885
+ value is printed once, in full — the one place in this CLI where printing
886
+ a real secret to the terminal is correct, since there is no `mozbridge
887
+ token show` to retrieve it again.
888
+
889
+ ```
890
+ $ mozbridge token create --scope build --expires 30d
891
+ Token created: mozbridge-cli-acme-web-app-3f9a2c1e (project-scoped, scopes=build)
892
+ expires_at: 2026-10-14T12:00:00Z
893
+
894
+ ======================================================================
895
+ SAVE THIS TOKEN NOW — it will never be shown again:
896
+
897
+ mbt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
898
+
899
+ Store it in a password manager or your CI provider's secret store
900
+ immediately. There is no `mozbridge token show` — this is the only time
901
+ the raw value is ever returned.
902
+ ======================================================================
903
+ ```
904
+
905
+ **Failure modes**: no `--scope` given: clear error, exit 1, no API call.
906
+ Both `--org` and `--project` given: clear error, exit 1. Invalid
907
+ `--expires` format: clear error naming the expected shape, exit 1. Not
908
+ linked / not logged in: identical messages to `publish` above, exit 1.
909
+ Any other API error: the raw `ApiError` message, exit 1.
910
+
911
+ ---
912
+
913
+ ## `mozbridge doctor`
914
+
915
+ A self-diagnostic checklist for this CLI's setup — same motivation as
916
+ sibling package `cruxhive-mcp`'s own `cruxhive-doctor`: enough moving
917
+ parts (cached session, per-repo link file, registry credential, optional
918
+ build token, local docker daemon) that "is my setup actually working"
919
+ deserves one command instead of five.
920
+
921
+ **What it checks, in order**: logged in, linked (verified with a real
922
+ `GET /projects/{id}` call, not just the presence of
923
+ `.mozbridge/link.json`), project-level registry credential configured,
924
+ the platform's own pre-deploy checks (the same `GET .../preflight` call
925
+ `deploy` now runs proactively), whether `MOZBRIDGE_BUILD_TOKEN` is set,
926
+ and whether Docker is available locally. Each check is independent — one
927
+ erroring API call is reported as "could not check (error)" on its own
928
+ line and never prevents the rest from running.
929
+
930
+ ```
931
+ $ mozbridge doctor
932
+ ✓ Logged in: session valid
933
+ ✓ Linked: acme/web-app (.mozbridge/link.json present) — resolved via API
934
+ ○ Registry configured: no project-level registry credential configured (normal, not an error — ...)
935
+ ✓ Pre-deploy checks: all clear (runs the platform's own pre-deploy checks)
936
+ ○ MOZBRIDGE_BUILD_TOKEN set: not set — only required for `mozbridge publish --local`
937
+ ✓ Docker available: docker CLI + daemon reachable
938
+ ```
939
+
940
+ **Exit code**: non-zero only when a MEANINGFUL gate failed — not logged
941
+ in, or not linked. Registry, pre-deploy checks, `MOZBRIDGE_BUILD_TOKEN`,
942
+ and Docker are informational only: none of them are required for every
943
+ workflow this CLI supports, so a "not configured"/"not set"/"not found"
944
+ result never fails the command.
945
+
946
+ ---
947
+
740
948
  ## A note on authorization for `publish` / `rollback`
741
949
 
742
950
  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.1"
7
+ version = "0.4.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,149 @@ 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 get_org_registry(client: httpx.Client, access_token: str, org_id: int) -> dict:
328
+ """GET /api/v1/organizations/{org_id}/registry -> schemas.RegistryCredentialStatus.
329
+
330
+ Same response shape as get_project_registry above — {configured,
331
+ registry_url, username, updated_at}, token never included
332
+ (backend/app/features/identity/router.py:521-537's get_org_registry
333
+ mirrors the project-level route's own never-return-the-token design).
334
+ This is the org's default registry credential, used as a fallback for
335
+ any project that has no project-level override — see
336
+ app/services/registry_credentials.resolve_registry_credential, whose
337
+ real resolution order is project override -> org default -> none.
338
+
339
+ No X-Organization-Id header: get_org_registry is gated by
340
+ ensure_permify_org_access(user, org_id, db), which takes org_id
341
+ straight from the path, not from that header — same as
342
+ list_organizations above, the only other /organizations route this
343
+ module calls.
344
+ """
345
+ url = f"{config.API_BASE_URL}{config.ORGANIZATIONS_PATH}/{org_id}/registry"
346
+ try:
347
+ resp = client.get(url, headers=_headers(access_token))
348
+ except httpx.HTTPError as exc:
349
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
350
+ _raise_for_status(resp, "fetch the org registry credential status")
351
+ return resp.json()
352
+
353
+
354
+ def set_project_registry(
355
+ client: httpx.Client,
356
+ access_token: str,
357
+ org_id: int,
358
+ project_id: int,
359
+ registry_url: str,
360
+ username: str,
361
+ token: str,
362
+ ) -> dict:
363
+ """PUT /api/v1/projects/{project_id}/registry, body schemas.RegistryCredentialSet.
364
+
365
+ Admin-gated (ensure_permify_can_administer) and validated against the
366
+ real registry BEFORE storing (router.py:2355-2384) — a bad credential
367
+ comes back as a 400 with a real message ("Could not validate registry
368
+ credential: ..."), which `_raise_for_status` already surfaces via
369
+ ApiError, same as every other function in this module.
370
+ """
371
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/registry"
372
+ payload = {"registry_url": registry_url, "username": username, "token": token}
373
+ try:
374
+ resp = client.put(url, json=payload, 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, "set the registry credential")
378
+ return resp.json()
379
+
380
+
381
+ def _secrets_url(project_id: int, env_slug: str, component: str) -> str:
382
+ """Shared path builder for the four secrets endpoints below — keeps the
383
+ env_slug/component path segments byte-for-byte identical across list
384
+ keys / get values / patch one key, rather than four separately-typed
385
+ f-strings that could drift apart.
386
+ """
387
+ base = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}"
388
+ return f"{base}/environments/{env_slug}/secrets/{component}"
389
+
390
+
391
+ def list_env_secret_keys(
392
+ client: httpx.Client, access_token: str, org_id: int, project_id: int, env_slug: str, component: str
393
+ ) -> dict:
394
+ """GET .../environments/{env_slug}/secrets/{component}/keys -> {keys, count}.
395
+
396
+ Key NAMES only — no values leave the server (router.py:3422-3434). This
397
+ is the default listing behavior for `mozbridge secrets list`.
398
+ """
399
+ url = f"{_secrets_url(project_id, env_slug, component)}/keys"
400
+ try:
401
+ resp = client.get(url, headers=_headers(access_token, org_id))
402
+ except httpx.HTTPError as exc:
403
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
404
+ _raise_for_status(resp, "list secret keys")
405
+ return resp.json()
406
+
407
+
408
+ def get_env_secrets(
409
+ client: httpx.Client, access_token: str, org_id: int, project_id: int, env_slug: str, component: str
410
+ ) -> dict:
411
+ """GET .../environments/{env_slug}/secrets/{component} -> dict[key, value].
412
+
413
+ Returns actual secret VALUES (router.py:3412-3419). Only called from
414
+ `mozbridge secrets list --show-values`, gated behind an interactive
415
+ confirmation (or --yes) before this is ever invoked.
416
+ """
417
+ url = _secrets_url(project_id, env_slug, component)
418
+ try:
419
+ resp = client.get(url, headers=_headers(access_token, org_id))
420
+ except httpx.HTTPError as exc:
421
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
422
+ _raise_for_status(resp, "fetch secret values")
423
+ return resp.json()
424
+
425
+
426
+ def patch_env_secret(
427
+ client: httpx.Client,
428
+ access_token: str,
429
+ org_id: int,
430
+ project_id: int,
431
+ env_slug: str,
432
+ component: str,
433
+ key: str,
434
+ value: str,
435
+ ) -> dict:
436
+ """PATCH .../environments/{env_slug}/secrets/{component}, body schemas.SecretPatch {key, value}.
437
+
438
+ Merges ONE key server-side over a strict read (router.py:3437-3453) —
439
+ the safe way to set one secret without touching the rest. `mozbridge
440
+ secrets set` MUST use this, never the POST replace-all endpoint below.
441
+
442
+ Returns {"status": "success", "key", "created", "total_keys"} — never
443
+ the merged secret values.
444
+ """
445
+ url = _secrets_url(project_id, env_slug, component)
446
+ try:
447
+ resp = client.patch(url, json={"key": key, "value": value}, headers=_headers(access_token, org_id))
448
+ except httpx.HTTPError as exc:
449
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
450
+ _raise_for_status(resp, "set the secret")
451
+ return resp.json()
452
+
453
+
311
454
  def trigger_prebuilt_build(
312
455
  client: httpx.Client,
313
456
  access_token: str,
@@ -387,6 +530,47 @@ def deploy_project(
387
530
  return resp.json()
388
531
 
389
532
 
533
+ def get_project_preflight(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
534
+ """GET /api/v1/projects/{project_id}/preflight -> preflight.Preflight.as_dict().
535
+
536
+ Read-only (backend/app/features/projects/router.py:1534's
537
+ project_preflight docstring: "nothing here provisions, binds or
538
+ writes"). Returns:
539
+
540
+ {project_id, project_slug, host, can_deploy,
541
+ blocking: [key, ...], warnings: [key, ...],
542
+ checks: [{key, level, title, detail, action}, ...]}
543
+
544
+ (see backend/app/services/preflight.py's Preflight.as_dict; `level` is
545
+ one of "pass"/"warn"/"fail", `action` is None when there's nothing to
546
+ do). `can_deploy` is `not blocking` — exactly the 10 checks
547
+ run_preflight assembles (placement, host_ready, architecture, dns,
548
+ registry, disk, vault_role, site_placement, secrets, observability).
549
+ This is the SAME gate `deploy_project` above already hits reactively as
550
+ a 409 (`_assert_preflight_clear` calls this same `run_preflight`); this
551
+ wrapper lets a caller run it proactively, before committing to a
552
+ deploy, not instead of that reactive path.
553
+
554
+ Does not detect a project's compose/template mismatch or any other
555
+ app-specific misconfiguration — none of the 10 checks cover that class
556
+ of problem. Callers must not describe this as catching it.
557
+
558
+ The backend's own gate fails OPEN: if preflight itself cannot be
559
+ computed (vault down, host lookup error, etc.), the real deploy is not
560
+ blocked. This read-only endpoint has no gate to fail open around, but a
561
+ caller of this wrapper should apply the same philosophy on its own
562
+ side: treat an ApiError here as "could not check", not as a reason to
563
+ block anything itself.
564
+ """
565
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/preflight"
566
+ try:
567
+ resp = client.get(url, headers=_headers(access_token, org_id))
568
+ except httpx.HTTPError as exc:
569
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
570
+ _raise_for_status(resp, "run pre-deploy checks")
571
+ return resp.json()
572
+
573
+
390
574
  def list_project_operations(
391
575
  client: httpx.Client, access_token: str, org_id: int, project_id: int, *, limit: int = 1
392
576
  ) -> list[dict]:
@@ -425,6 +609,54 @@ def get_operation(client: httpx.Client, access_token: str, org_id: int, task_id:
425
609
  return resp.json()
426
610
 
427
611
 
612
+ def create_service_token(
613
+ client: httpx.Client,
614
+ access_token: str,
615
+ org_id: int,
616
+ *,
617
+ name: str,
618
+ token_type: str,
619
+ scopes: list[str],
620
+ project_id: int | None = None,
621
+ expires_at: str | None = None,
622
+ ) -> dict:
623
+ """POST /api/v1/tokens, body identity.token_router.TokenCreate.
624
+
625
+ Authenticated by the caller's regular LOGIN session access_token, never
626
+ a service token — create_service_token (token_router.py:146) is gated
627
+ by get_current_user and explicitly rejects a service-token caller (403
628
+ "Service tokens cannot mint new tokens"), a deliberate anti-escalation
629
+ design. `org_id` is sent as the X-Organization-Id header for
630
+ consistency with every other call in this module, though this route
631
+ does not itself depend on get_current_org: `org_id` in the JSON body
632
+ only matters for an org-scoped token (the backend 400s without it), and
633
+ is ignored/overwritten server-side for a project-scoped one
634
+ (project.organization_id wins over anything the client sends).
635
+
636
+ `project_id`/`expires_at` are omitted from the body entirely when None,
637
+ same "let the backend's own default apply" convention as trigger_build
638
+ above — expires_at omitted means the token never expires (schemas
639
+ default), project_id omitted means an org-scoped token.
640
+
641
+ Returns TokenCreated: the full token record plus `token`, the raw value
642
+ — shown exactly once here, never retrievable from the API again.
643
+ """
644
+ payload: dict = {"name": name, "type": token_type, "scopes": scopes}
645
+ if project_id is not None:
646
+ payload["project_id"] = project_id
647
+ if token_type == "org":
648
+ payload["org_id"] = org_id
649
+ if expires_at is not None:
650
+ payload["expires_at"] = expires_at
651
+ url = f"{config.API_BASE_URL}{config.TOKENS_PATH}"
652
+ try:
653
+ resp = client.post(url, json=payload, headers=_headers(access_token, org_id))
654
+ except httpx.HTTPError as exc:
655
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
656
+ _raise_for_status(resp, "create the service token")
657
+ return resp.json()
658
+
659
+
428
660
  def operation_stream_url(task_id: str) -> str:
429
661
  """URL for GET /api/v1/projects/operations/{task_id}/stream (SSE).
430
662