mozbridge-cli 0.8.0__tar.gz → 0.9.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.8.0 → mozbridge_cli-0.9.0}/PKG-INFO +1 -1
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/docs/commands.md +139 -10
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/pyproject.toml +1 -1
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/api.py +100 -1
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_deploy.py +40 -2
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_publish.py +160 -21
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_shared.py +14 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_deploy.py +26 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_deploy_preflight.py +22 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_diff.py +32 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_publish.py +21 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_publish_local.py +22 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_publish_multi_component.py +20 -0
- mozbridge_cli-0.9.0/tests/test_publish_site.py +320 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/.gitignore +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/README.md +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/scripts/release.sh +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/__init__.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/__main__.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/auth.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/build.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_adopt.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_auth.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_config.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_doctor.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_env.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_projects.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_registry.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_secrets.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/cmd_token.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/compose.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/config.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/link.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/local_build.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/main.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/runtime_secrets.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/src/mozbridge_cli/session.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/conftest.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_adopt.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_build.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_ci_token_auth.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_compose.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_config.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_doctor.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_env.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_link.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_local_build.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_login.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_login_cli.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_logout.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_logs.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_refresh.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_registry.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_rollback.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_runtime_secrets.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_secrets.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_session_permissions.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_status.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.0}/tests/test_token.py +0 -0
- {mozbridge_cli-0.8.0 → mozbridge_cli-0.9.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.9.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
|
|
@@ -382,6 +382,96 @@ modes):
|
|
|
382
382
|
|
|
383
383
|
---
|
|
384
384
|
|
|
385
|
+
## `mozbridge publish` — Launch sites (site-shaped projects)
|
|
386
|
+
|
|
387
|
+
Everything under `## \`mozbridge publish\`` above describes the **app-build**
|
|
388
|
+
path. A project can instead be **site-shaped** (a Launch site — static
|
|
389
|
+
hosting only, `config.shape == "site"` on the project, set by `mozbridge
|
|
390
|
+
create-site`/the dashboard's "Launch site" project type) — `publish`
|
|
391
|
+
resolves this automatically and takes a structurally different path for it.
|
|
392
|
+
This section is the site-shaped counterpart to everything above.
|
|
393
|
+
|
|
394
|
+
**Flags**: `--local` — see below, it's rejected outright for a site.
|
|
395
|
+
|
|
396
|
+
**Requires**: same as `publish` (link + login). No compose file / component
|
|
397
|
+
discovery ever runs for a site — a Launch site has no Docker image.
|
|
398
|
+
|
|
399
|
+
**What it does**:
|
|
400
|
+
1. One extra call at the very start of `publish` (for *every* project, app or
|
|
401
|
+
site): `GET /api/v1/projects/{project_id}` to resolve
|
|
402
|
+
`config.shape` — the same one discriminator the backend itself uses
|
|
403
|
+
everywhere (`is_site_project`). This is the only new cost `publish` pays
|
|
404
|
+
for an app-shaped project — one cheap GET before it does exactly what it
|
|
405
|
+
always did.
|
|
406
|
+
2. If `shape == "site"`: zips the current directory — the *exact same*
|
|
407
|
+
`build.build_zip` primitive `mozbridge build` and the app-build path use,
|
|
408
|
+
no separate site-zipping logic — then:
|
|
409
|
+
- `POST /api/v1/projects/{project_id}/publish-uploads` — creates a
|
|
410
|
+
short-lived upload session, returns `{upload_id, put_url, expires_at,
|
|
411
|
+
max_bytes, instructions}` (the site counterpart of `source-uploads`,
|
|
412
|
+
same response shape, different endpoint).
|
|
413
|
+
- `PUT {put_url}` — the raw zip bytes, `Authorization` + `X-Organization-Id`
|
|
414
|
+
+ `Content-Type: application/zip`, same wire contract as the app-build
|
|
415
|
+
upload.
|
|
416
|
+
- `POST /api/v1/projects/{project_id}/publish-from-upload`, body
|
|
417
|
+
`{upload_id, immediate: true}` — consumes the upload and publishes it
|
|
418
|
+
right away. `immediate=true` is what this CLI always sends, for parity
|
|
419
|
+
with how `publish` already behaves for an app-shaped project (no
|
|
420
|
+
separate stage-then-confirm step).
|
|
421
|
+
3. Prints the resulting task id/revision, then — since the
|
|
422
|
+
`publish-from-upload` response itself never carries the site's live URL —
|
|
423
|
+
the project's own `domain` (already known from step 1's `get_project`
|
|
424
|
+
call) as a clearly labeled `Site: https://...` line. This is the actual
|
|
425
|
+
deliverable of a site publish.
|
|
426
|
+
|
|
427
|
+
**Example**:
|
|
428
|
+
```
|
|
429
|
+
$ mozbridge publish
|
|
430
|
+
Built /tmp/mozbridge-build-a1b2.zip (12.4 KB, 4 files)
|
|
431
|
+
Site publish triggered (task_id=abc123, revision=deadbeefcafe).
|
|
432
|
+
Site: https://launchpage.mozbridge.com
|
|
433
|
+
Run `mozbridge status` to check progress, or poll the Mozbridge dashboard.
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
**`--local` against a site fails immediately**: `--local` means "build and
|
|
437
|
+
push a Docker image on this machine" — meaningless for a site, which has no
|
|
438
|
+
Docker image at all. `_publish_local` checks the project's shape right after
|
|
439
|
+
resolving the login session (still before `MOZBRIDGE_BUILD_TOKEN`'s
|
|
440
|
+
runtime-secrets fetch, `docker login`, or any build/push) and, if the
|
|
441
|
+
project is a site, fails immediately:
|
|
442
|
+
```
|
|
443
|
+
$ mozbridge publish --local
|
|
444
|
+
--local is for app builds; sites don't use Docker — run `mozbridge publish`
|
|
445
|
+
without --local.
|
|
446
|
+
```
|
|
447
|
+
No docker command and no HTTP call beyond the shape check itself is ever
|
|
448
|
+
attempted.
|
|
449
|
+
|
|
450
|
+
**Failure modes** (in addition to `publish`'s own not-linked/not-logged-in
|
|
451
|
+
modes):
|
|
452
|
+
- The shape-check `get_project` call itself fails: the raw `ApiError`
|
|
453
|
+
message (same pattern as every other command), exit 1 — nothing else is
|
|
454
|
+
attempted.
|
|
455
|
+
- `create_publish_upload` fails: `Could not create a publish upload
|
|
456
|
+
session: <backend detail>`, exit 1.
|
|
457
|
+
- The `PUT` upload fails: `Could not upload the site content: <backend
|
|
458
|
+
detail>`, exit 1 — `publish-from-upload` is never called if the upload
|
|
459
|
+
failed.
|
|
460
|
+
- `publish-from-upload` itself fails: `Could not publish the site: <backend
|
|
461
|
+
detail>`, exit 1.
|
|
462
|
+
- `--local` against a site: the clear message above, exit 1, no docker or
|
|
463
|
+
HTTP call beyond the shape check.
|
|
464
|
+
|
|
465
|
+
**Known gap**: `mozbridge status`'s "recent deployments" list stays empty
|
|
466
|
+
for a site even after real publishes — see `## \`mozbridge status\`` below.
|
|
467
|
+
`mozbridge diff` describes the site-publish path in plain language instead
|
|
468
|
+
of the compose/component analysis (see `## \`mozbridge diff\`` below).
|
|
469
|
+
`mozbridge deploy` refuses outright for a site (see `## \`mozbridge
|
|
470
|
+
deploy\`` below) — `mozbridge rollback` is unaffected, it already works
|
|
471
|
+
identically for both shapes via one unified backend endpoint.
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
385
475
|
## `mozbridge diff`
|
|
386
476
|
|
|
387
477
|
A genuine dry-run/preview: shows what `mozbridge publish` would do right
|
|
@@ -393,15 +483,23 @@ now, without doing anything. Makes **no** mutating API call — GET-only
|
|
|
393
483
|
**Requires**: link + login, same as `publish` and `status`.
|
|
394
484
|
|
|
395
485
|
**What it does**:
|
|
396
|
-
1. `GET /api/v1/projects/{project_id}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
`
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
486
|
+
1. `GET /api/v1/projects/{project_id}` to resolve the project (and its
|
|
487
|
+
shape — see the site-shaped behavior below), and `GET
|
|
488
|
+
/api/v1/projects/{project_id}/deployments` — same call `status` makes —
|
|
489
|
+
and prints the single most recent deployment record for context (id,
|
|
490
|
+
timestamp, `component=version`, status, `[stable]`).
|
|
491
|
+
2. **Site-shaped project**: stops here and prints, in plain language, what
|
|
492
|
+
`mozbridge publish` would actually do for a site (zip the directory,
|
|
493
|
+
create a publish-upload session, PUT the bytes, publish-from-upload with
|
|
494
|
+
`immediate=true`) — none of the compose/component analysis below applies,
|
|
495
|
+
a Launch site has no Docker image to build. See `## \`mozbridge publish\`
|
|
496
|
+
— Launch sites` above for the real flow this describes.
|
|
497
|
+
3. **App-shaped project** (unchanged): discovers a compose file exactly as
|
|
498
|
+
`publish` does (reusing `compose.py`). If none is found, or one is found
|
|
499
|
+
but none of its services have a `build:` key, it says plainly that
|
|
500
|
+
`publish` would fall back to the single-component path (zip the whole
|
|
501
|
+
directory) — this is reported, not treated as an error.
|
|
502
|
+
4. If components are found, prints each one's resolved
|
|
405
503
|
`image_name`/`context_path`/`dockerfile_path` — computed by the exact
|
|
406
504
|
same `compose.resolve_components` function `publish` calls to build its
|
|
407
505
|
`trigger_build` request bodies, so `diff`'s output can never silently
|
|
@@ -487,6 +585,14 @@ only when `is_stable` is true on that deployment. If there are no
|
|
|
487
585
|
deployments at all, the project line still prints, followed by `No
|
|
488
586
|
deployments yet.`
|
|
489
587
|
|
|
588
|
+
**KNOWN GAP — Launch sites**: `/deployments` only ever returns rows written
|
|
589
|
+
by the app-build deploy task; a site's own publish path never writes one.
|
|
590
|
+
For a site-shaped project this command currently prints `No deployments
|
|
591
|
+
yet.` even after several real publishes, even though the project's own
|
|
592
|
+
config tracks its actual revision history. Not yet fixed — see the CLI
|
|
593
|
+
site-project-support notes rather than assuming this is a bug specific to
|
|
594
|
+
your project.
|
|
595
|
+
|
|
490
596
|
**Failure modes**:
|
|
491
597
|
- Not linked / not logged in: identical messages to `publish` above,
|
|
492
598
|
exit 1.
|
|
@@ -500,6 +606,11 @@ deployments yet.`
|
|
|
500
606
|
|
|
501
607
|
Rolls back the linked project's deployment. A real, consequential
|
|
502
608
|
production action — prompts for confirmation unless `--yes` is given.
|
|
609
|
+
Unlike `publish`/`deploy`, this command needs **no shape check** and no
|
|
610
|
+
change for this feature: the backend already unifies app- and site-shaped
|
|
611
|
+
rollback behind these same two endpoints
|
|
612
|
+
(`rollback_project_or_site`/`rollback_to_specific`), so it already works
|
|
613
|
+
identically for both.
|
|
503
614
|
|
|
504
615
|
**Flags**:
|
|
505
616
|
| Flag | Meaning |
|
|
@@ -567,7 +678,21 @@ confirmation unless `--yes` is given.
|
|
|
567
678
|
one of `--frontend`/`--backend` must be given — deploying neither makes no
|
|
568
679
|
sense.
|
|
569
680
|
|
|
570
|
-
**
|
|
681
|
+
**Not applicable to a Launch site**: a site has no image tag to deploy at
|
|
682
|
+
all. Right after resolving the link/session — before the preflight check,
|
|
683
|
+
before the confirmation prompt, before any deploy request — `deploy` checks
|
|
684
|
+
the linked project's shape (`GET /api/v1/projects/{project_id}`) and, if
|
|
685
|
+
it's a site, fails immediately:
|
|
686
|
+
```
|
|
687
|
+
$ mozbridge deploy --backend v1.9.319 --yes
|
|
688
|
+
This project is a Launch site — sites deploy via `mozbridge publish`, not
|
|
689
|
+
`mozbridge deploy` (there is no image tag to deploy for a site). Run
|
|
690
|
+
`mozbridge publish` instead.
|
|
691
|
+
```
|
|
692
|
+
No preflight call and no deploy request is ever sent for a site — this
|
|
693
|
+
never becomes a confusing raw 400 from the backend.
|
|
694
|
+
|
|
695
|
+
**What it does** (app-shaped project): prints what it's about to do (project, which service(s),
|
|
571
696
|
which tag(s), which environment), then either aborts on a declined prompt or
|
|
572
697
|
calls `POST /api/v1/projects/{project_id}/deploy` with
|
|
573
698
|
`{frontend_version, backend_version, services, environment, force}`.
|
|
@@ -620,6 +745,10 @@ Re-run with --force to override — this is not done automatically.
|
|
|
620
745
|
- Neither `--frontend` nor `--backend` given: clear error, exit 1, no API
|
|
621
746
|
call is made.
|
|
622
747
|
- Not linked / not logged in: identical messages to `publish` above, exit 1.
|
|
748
|
+
- The shape-check `get_project` call itself fails: the raw `ApiError`
|
|
749
|
+
message, exit 1 — nothing else is attempted.
|
|
750
|
+
- Linked project is a Launch site: the clear message above, exit 1, no
|
|
751
|
+
preflight/deploy call ever sent.
|
|
623
752
|
- Confirmation prompt declined (`--yes` not passed, answer is not `y`):
|
|
624
753
|
Typer's own `Abort` handling — the command exits non-zero without calling
|
|
625
754
|
the API.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mozbridge-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.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"
|
|
@@ -134,7 +134,16 @@ def put_source_upload_bytes(
|
|
|
134
134
|
|
|
135
135
|
|
|
136
136
|
def get_project(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
|
|
137
|
-
"""GET /api/v1/projects/{project_id} — schemas.Project (name/slug/status/config/...).
|
|
137
|
+
"""GET /api/v1/projects/{project_id} — schemas.Project (name/slug/status/config/...).
|
|
138
|
+
|
|
139
|
+
`config` (a plain dict, see schemas.Project) is what carries the project's
|
|
140
|
+
"shape" — `(project.get("config") or {}).get("shape") == "site"` for a
|
|
141
|
+
Launch site, absent/anything else for an app build. This is the SAME
|
|
142
|
+
discriminator the backend itself uses everywhere
|
|
143
|
+
(`app/services/site_service.py:is_site_project`), so callers needing to
|
|
144
|
+
branch on shape (see cmd_shared.is_site_project) should call this rather
|
|
145
|
+
than adding a second, parallel "get the shape" endpoint.
|
|
146
|
+
"""
|
|
138
147
|
url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}"
|
|
139
148
|
try:
|
|
140
149
|
resp = client.get(url, headers=_headers(access_token, org_id))
|
|
@@ -144,6 +153,96 @@ def get_project(client: httpx.Client, access_token: str, org_id: int, project_id
|
|
|
144
153
|
return resp.json()
|
|
145
154
|
|
|
146
155
|
|
|
156
|
+
def create_publish_upload(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
|
|
157
|
+
"""POST /api/v1/projects/{project_id}/publish-uploads — Launch-site counterpart to
|
|
158
|
+
`create_source_upload` above.
|
|
159
|
+
|
|
160
|
+
Sites don't get a Docker build — they get a static-file publish
|
|
161
|
+
(`backend/app/features/projects/router.py`'s `create_publish_upload`,
|
|
162
|
+
~line 464), 400s if the project isn't `shape=site` (`is_site_project`).
|
|
163
|
+
|
|
164
|
+
Returns the SAME shape as `create_source_upload`
|
|
165
|
+
({upload_id, put_url, expires_at, max_bytes, instructions}) — the backend
|
|
166
|
+
reuses `schemas.SitePublishUploadSession` for both, so this is a distinct
|
|
167
|
+
function (not an alias) only because it hits a distinct, site-only route.
|
|
168
|
+
"""
|
|
169
|
+
url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/publish-uploads"
|
|
170
|
+
try:
|
|
171
|
+
resp = client.post(url, headers=_headers(access_token, org_id))
|
|
172
|
+
except httpx.HTTPError as exc:
|
|
173
|
+
raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
|
|
174
|
+
_raise_for_status(resp, "create a publish upload session")
|
|
175
|
+
return resp.json()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def put_publish_upload_bytes(
|
|
179
|
+
client: httpx.Client,
|
|
180
|
+
access_token: str,
|
|
181
|
+
org_id: int,
|
|
182
|
+
put_url: str,
|
|
183
|
+
data: bytes,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""PUT the zip bytes to the put_url returned by create_publish_upload.
|
|
186
|
+
|
|
187
|
+
Same wire contract as `put_source_upload_bytes` above (Authorization +
|
|
188
|
+
X-Organization-Id + `application/zip` body) — the backend's
|
|
189
|
+
`put_publish_upload` route (router.py ~line 504) accepts the raw body the
|
|
190
|
+
same way `put_source_upload` does. Kept as a separate function (rather
|
|
191
|
+
than reusing `put_source_upload_bytes` directly) purely so a failure here
|
|
192
|
+
reads as "upload the site content", not "upload the build source" — the
|
|
193
|
+
two flows are conceptually distinct even though the bytes-on-the-wire
|
|
194
|
+
shape is identical.
|
|
195
|
+
"""
|
|
196
|
+
try:
|
|
197
|
+
resp = client.put(
|
|
198
|
+
put_url,
|
|
199
|
+
content=data,
|
|
200
|
+
headers={**_headers(access_token, org_id), "Content-Type": "application/zip"},
|
|
201
|
+
)
|
|
202
|
+
except httpx.HTTPError as exc:
|
|
203
|
+
raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
|
|
204
|
+
_raise_for_status(resp, "upload the site content")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def publish_from_upload(
|
|
208
|
+
client: httpx.Client,
|
|
209
|
+
access_token: str,
|
|
210
|
+
org_id: int,
|
|
211
|
+
project_id: int,
|
|
212
|
+
upload_id: str,
|
|
213
|
+
*,
|
|
214
|
+
immediate: bool = True,
|
|
215
|
+
) -> dict:
|
|
216
|
+
"""POST /api/v1/projects/{project_id}/publish-from-upload, body schemas.SitePublishFromUpload.
|
|
217
|
+
|
|
218
|
+
Consumes a ready `create_publish_upload` session and ingests it exactly
|
|
219
|
+
like the multipart publish endpoint (router.py's `publish_site_from_upload`,
|
|
220
|
+
~line 612) — 400s if the project isn't `shape=site`.
|
|
221
|
+
|
|
222
|
+
`immediate` defaults True: this CLI's `publish` command always calls with
|
|
223
|
+
immediate=True, matching how `publish` already behaves for app-shaped
|
|
224
|
+
projects (no separate stage-then-confirm step) — see cmd_publish.py's
|
|
225
|
+
`_publish_site`. With immediate=True the revision is deployed right away
|
|
226
|
+
and the response's `status` comes back "queued" with `preview_url` unset;
|
|
227
|
+
with immediate=False (not currently used by this CLI, kept as a real
|
|
228
|
+
parameter rather than hardcoding True so a future `--stage` flag doesn't
|
|
229
|
+
need a new function) the revision is only staged, `status` comes back
|
|
230
|
+
"staged" and `preview_url` is populated instead.
|
|
231
|
+
|
|
232
|
+
Returns schemas.SitePublishResponse: {task_id, revision, status,
|
|
233
|
+
preview_url}. Note this response never carries the site's live URL —
|
|
234
|
+
that's `project.domain` (from `get_project`), not anything in this body.
|
|
235
|
+
"""
|
|
236
|
+
url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/publish-from-upload"
|
|
237
|
+
payload = {"upload_id": upload_id, "immediate": immediate}
|
|
238
|
+
try:
|
|
239
|
+
resp = client.post(url, json=payload, headers=_headers(access_token, org_id))
|
|
240
|
+
except httpx.HTTPError as exc:
|
|
241
|
+
raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
|
|
242
|
+
_raise_for_status(resp, "publish the site")
|
|
243
|
+
return resp.json()
|
|
244
|
+
|
|
245
|
+
|
|
147
246
|
def list_project_deployments(
|
|
148
247
|
client: httpx.Client, access_token: str, org_id: int, project_id: int
|
|
149
248
|
) -> list[dict]:
|
|
@@ -9,7 +9,7 @@ import httpx
|
|
|
9
9
|
import typer
|
|
10
10
|
|
|
11
11
|
from . import api
|
|
12
|
-
from .cmd_shared import require_link_and_session
|
|
12
|
+
from .cmd_shared import is_site_project, require_link_and_session
|
|
13
13
|
|
|
14
14
|
app = typer.Typer(add_completion=False)
|
|
15
15
|
|
|
@@ -43,7 +43,20 @@ def _print_preflight_notice(preflight: dict) -> None:
|
|
|
43
43
|
|
|
44
44
|
@app.command("status")
|
|
45
45
|
def status_cmd() -> None:
|
|
46
|
-
"""Show the linked project's status and its 5 most recent deployments.
|
|
46
|
+
"""Show the linked project's status and its 5 most recent deployments.
|
|
47
|
+
|
|
48
|
+
KNOWN GAP for a site-shaped (Launch site) project: `list_project_deployments`
|
|
49
|
+
only ever returns rows written by the app-build deploy task
|
|
50
|
+
(`models.ProjectDeployment`, created in `app/tasks.py`'s `deploy_project`
|
|
51
|
+
task) — a site's own publish path (`SiteService.ingest_zip` /
|
|
52
|
+
`_enqueue_deploy`, driven by `publish_static_site`) never writes one. So
|
|
53
|
+
for a site this command will print "No deployments yet." even after
|
|
54
|
+
several real publishes, even though the project's `config` (last_revision/
|
|
55
|
+
staged_revision/stable_revision) shows otherwise. Left unfixed
|
|
56
|
+
deliberately in this slice — see the CLI site-project-support docs for the
|
|
57
|
+
full reasoning — rather than guessed at without a confirmed design for
|
|
58
|
+
what a site's own "recent activity" list should look like.
|
|
59
|
+
"""
|
|
47
60
|
cwd = Path.cwd()
|
|
48
61
|
|
|
49
62
|
with httpx.Client(timeout=30.0) as client:
|
|
@@ -171,6 +184,14 @@ def deploy_cmd(
|
|
|
171
184
|
still proceeds to the normal confirmation flow — matching the
|
|
172
185
|
backend's own fail-open design for this gate rather than being
|
|
173
186
|
stricter than the platform itself is.
|
|
187
|
+
|
|
188
|
+
Not applicable to a site-shaped (Launch site) project: a site has no
|
|
189
|
+
image tag to deploy at all — `--frontend`/`--backend` are meaningless for
|
|
190
|
+
it. If the linked project turns out to be a site, this command fails
|
|
191
|
+
immediately (right after resolving the link/session, before the preflight
|
|
192
|
+
check or any deploy request) with a clear error pointing at `mozbridge
|
|
193
|
+
publish` instead — it never sends the nonsensical deploy request to the
|
|
194
|
+
backend and lets a raw API error stand in for that explanation.
|
|
174
195
|
"""
|
|
175
196
|
if frontend is None and backend is None:
|
|
176
197
|
typer.echo(
|
|
@@ -190,6 +211,23 @@ def deploy_cmd(
|
|
|
190
211
|
with httpx.Client(timeout=30.0) as client:
|
|
191
212
|
current_link, active_session = require_link_and_session(client, cwd)
|
|
192
213
|
|
|
214
|
+
try:
|
|
215
|
+
project = api.get_project(
|
|
216
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
217
|
+
)
|
|
218
|
+
except api.ApiError as exc:
|
|
219
|
+
typer.echo(str(exc), err=True)
|
|
220
|
+
raise typer.Exit(code=1)
|
|
221
|
+
|
|
222
|
+
if is_site_project(project):
|
|
223
|
+
typer.echo(
|
|
224
|
+
"This project is a Launch site — sites deploy via `mozbridge publish`, "
|
|
225
|
+
"not `mozbridge deploy` (there is no image tag to deploy for a site). "
|
|
226
|
+
"Run `mozbridge publish` instead.",
|
|
227
|
+
err=True,
|
|
228
|
+
)
|
|
229
|
+
raise typer.Exit(code=1)
|
|
230
|
+
|
|
193
231
|
try:
|
|
194
232
|
preflight = api.get_project_preflight(
|
|
195
233
|
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
@@ -15,7 +15,7 @@ from . import link as linkmod
|
|
|
15
15
|
from . import local_build as local_buildmod
|
|
16
16
|
from . import runtime_secrets as runtime_secretsmod
|
|
17
17
|
from .cmd_auth import load_and_refresh_session
|
|
18
|
-
from .cmd_shared import require_link_and_session
|
|
18
|
+
from .cmd_shared import is_site_project, require_link_and_session
|
|
19
19
|
|
|
20
20
|
app = typer.Typer(add_completion=False)
|
|
21
21
|
|
|
@@ -104,6 +104,80 @@ def _publish_single_component(
|
|
|
104
104
|
typer.echo("Run `mozbridge status` to check progress, or poll the Mozbridge dashboard.")
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
def _publish_site(
|
|
108
|
+
client: httpx.Client,
|
|
109
|
+
active_session: session.Session,
|
|
110
|
+
current_link: linkmod.Link,
|
|
111
|
+
cwd: Path,
|
|
112
|
+
project: dict,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""`mozbridge publish` for a Launch site (shape=site): the site-shaped
|
|
115
|
+
counterpart of `_publish_single_component` above — same shape (zip -> create
|
|
116
|
+
an upload session -> PUT the bytes -> tell the backend to consume it), but
|
|
117
|
+
against the site-only endpoints, and it always publishes immediately (no
|
|
118
|
+
build to enqueue, no task to poll for a docker image; the platform serves
|
|
119
|
+
the zip's contents directly).
|
|
120
|
+
|
|
121
|
+
Uses the EXACT SAME zip (`buildmod.build_zip`) as `mozbridge build` and the
|
|
122
|
+
app-build path above — no separate zipping logic for sites.
|
|
123
|
+
"""
|
|
124
|
+
result = buildmod.build_zip(cwd)
|
|
125
|
+
typer.echo(f"Built {result.zip_path} ({result.size_bytes / 1024:.1f} KB, {result.file_count} files)")
|
|
126
|
+
try:
|
|
127
|
+
try:
|
|
128
|
+
upload_session = api.create_publish_upload(
|
|
129
|
+
client,
|
|
130
|
+
active_session.access_token,
|
|
131
|
+
current_link.org_id,
|
|
132
|
+
current_link.project_id,
|
|
133
|
+
)
|
|
134
|
+
except api.ApiError as exc:
|
|
135
|
+
typer.echo(str(exc), err=True)
|
|
136
|
+
raise typer.Exit(code=1)
|
|
137
|
+
|
|
138
|
+
zip_bytes = result.zip_path.read_bytes()
|
|
139
|
+
try:
|
|
140
|
+
api.put_publish_upload_bytes(
|
|
141
|
+
client,
|
|
142
|
+
active_session.access_token,
|
|
143
|
+
current_link.org_id,
|
|
144
|
+
upload_session["put_url"],
|
|
145
|
+
zip_bytes,
|
|
146
|
+
)
|
|
147
|
+
except api.ApiError as exc:
|
|
148
|
+
typer.echo(str(exc), err=True)
|
|
149
|
+
raise typer.Exit(code=1)
|
|
150
|
+
finally:
|
|
151
|
+
result.zip_path.unlink(missing_ok=True)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
publish_response = api.publish_from_upload(
|
|
155
|
+
client,
|
|
156
|
+
active_session.access_token,
|
|
157
|
+
current_link.org_id,
|
|
158
|
+
current_link.project_id,
|
|
159
|
+
upload_session["upload_id"],
|
|
160
|
+
immediate=True,
|
|
161
|
+
)
|
|
162
|
+
except api.ApiError as exc:
|
|
163
|
+
typer.echo(str(exc), err=True)
|
|
164
|
+
raise typer.Exit(code=1)
|
|
165
|
+
|
|
166
|
+
task_id = publish_response.get("task_id")
|
|
167
|
+
revision = publish_response.get("revision") or ""
|
|
168
|
+
typer.echo(f"Site publish triggered (task_id={task_id}, revision={revision[:12]}).")
|
|
169
|
+
|
|
170
|
+
# The publish response itself never carries the site's live URL (see
|
|
171
|
+
# api.publish_from_upload's docstring) — it's the project's own domain,
|
|
172
|
+
# which we already have from the get_project call publish_cmd made to
|
|
173
|
+
# resolve the project's shape in the first place. This is the actual
|
|
174
|
+
# deliverable of a site publish, so it gets its own clearly-labeled line.
|
|
175
|
+
domain = project.get("domain")
|
|
176
|
+
if domain:
|
|
177
|
+
typer.echo(f"Site: https://{domain}")
|
|
178
|
+
typer.echo("Run `mozbridge status` to check progress, or poll the Mozbridge dashboard.")
|
|
179
|
+
|
|
180
|
+
|
|
107
181
|
def _publish_multi_component(
|
|
108
182
|
client: httpx.Client,
|
|
109
183
|
active_session: session.Session,
|
|
@@ -199,21 +273,32 @@ def publish_cmd(
|
|
|
199
273
|
Requires `mozbridge link` to have been run here, and an active login
|
|
200
274
|
session.
|
|
201
275
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
276
|
+
First resolves the linked project's shape (one cheap `get_project` call)
|
|
277
|
+
and branches:
|
|
278
|
+
|
|
279
|
+
- **Site-shaped** (Launch site, `config.shape == "site"`): zips the whole
|
|
280
|
+
directory and publishes it as a static site — see `_publish_site`
|
|
281
|
+
below. None of the compose/component logic below applies; a site has
|
|
282
|
+
no Docker image to build.
|
|
283
|
+
- **App-shaped** (everything else, the original behavior, unchanged): if
|
|
284
|
+
a compose file is present (docker-compose.yml, compose.yaml, or
|
|
285
|
+
docker-compose.yaml — Docker's own discovery order) and at least one of
|
|
286
|
+
its services has a `build:` key, each such service is published as its
|
|
287
|
+
own component/image: one create_source_upload -> PUT -> trigger_build
|
|
288
|
+
per component, with contexts shared across services de-duplicated to a
|
|
289
|
+
single upload. Otherwise (no compose file, or none of its services
|
|
290
|
+
build) this falls back unchanged to the original single-component flow:
|
|
291
|
+
zip the whole directory, one upload, one trigger_build call.
|
|
210
292
|
|
|
211
293
|
`--local` replaces the upload+remote-build step entirely: components are
|
|
212
294
|
resolved the same way, but each is built and pushed with the developer's
|
|
213
295
|
own local `docker`, using registry credentials fetched with a SEPARATE
|
|
214
296
|
build-scoped token (MOZBRIDGE_BUILD_TOKEN), then registered via the same
|
|
215
297
|
trigger_build endpoint using the regular login session. See
|
|
216
|
-
`_publish_local` below.
|
|
298
|
+
`_publish_local` below. `--local` has no meaning for a site-shaped
|
|
299
|
+
project (sites don't build a Docker image at all) — `_publish_local`
|
|
300
|
+
checks this itself and fails immediately, before any docker or upload
|
|
301
|
+
call, if the linked project turns out to be a site.
|
|
217
302
|
"""
|
|
218
303
|
cwd = Path.cwd()
|
|
219
304
|
current_link = linkmod.load_link(cwd)
|
|
@@ -227,14 +312,26 @@ def publish_cmd(
|
|
|
227
312
|
_publish_local(cwd, current_link)
|
|
228
313
|
return
|
|
229
314
|
|
|
230
|
-
components: list[composemod.Component] = []
|
|
231
|
-
compose_path = composemod.find_compose_file(cwd)
|
|
232
|
-
if compose_path is not None:
|
|
233
|
-
components = composemod.parse_components(compose_path)
|
|
234
|
-
|
|
235
315
|
with httpx.Client(timeout=120.0) as client:
|
|
236
316
|
active_session = load_and_refresh_session(client)
|
|
237
317
|
|
|
318
|
+
try:
|
|
319
|
+
project = api.get_project(
|
|
320
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
321
|
+
)
|
|
322
|
+
except api.ApiError as exc:
|
|
323
|
+
typer.echo(str(exc), err=True)
|
|
324
|
+
raise typer.Exit(code=1)
|
|
325
|
+
|
|
326
|
+
if is_site_project(project):
|
|
327
|
+
_publish_site(client, active_session, current_link, cwd, project)
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
components: list[composemod.Component] = []
|
|
331
|
+
compose_path = composemod.find_compose_file(cwd)
|
|
332
|
+
if compose_path is not None:
|
|
333
|
+
components = composemod.parse_components(compose_path)
|
|
334
|
+
|
|
238
335
|
if components:
|
|
239
336
|
typer.echo(f"Using {compose_path.name} for multi-component publish.")
|
|
240
337
|
groups = composemod.resolve_components(
|
|
@@ -256,7 +353,11 @@ def _publish_local(cwd: Path, current_link: linkmod.Link) -> None:
|
|
|
256
353
|
Order of checks is deliberate: cheap, local, no-network checks first
|
|
257
354
|
(MOZBRIDGE_BUILD_TOKEN present, docker installed/daemon reachable) so a
|
|
258
355
|
misconfigured environment fails fast with zero docker or HTTP calls,
|
|
259
|
-
before login-session refresh or any network call is attempted.
|
|
356
|
+
before login-session refresh or any network call is attempted. The one
|
|
357
|
+
network-shaped check that can't be done locally — is this project even a
|
|
358
|
+
site, which makes `--local` meaningless — runs right after session
|
|
359
|
+
refresh, still before runtime-secrets/docker-login/build/push, so a site
|
|
360
|
+
project fails clearly without ever touching docker or a registry.
|
|
260
361
|
"""
|
|
261
362
|
build_token = os.environ.get(runtime_secretsmod.BUILD_TOKEN_ENV_VAR)
|
|
262
363
|
if not build_token:
|
|
@@ -288,6 +389,22 @@ def _publish_local(cwd: Path, current_link: linkmod.Link) -> None:
|
|
|
288
389
|
with httpx.Client(timeout=180.0) as client:
|
|
289
390
|
active_session = load_and_refresh_session(client)
|
|
290
391
|
|
|
392
|
+
try:
|
|
393
|
+
project = api.get_project(
|
|
394
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
395
|
+
)
|
|
396
|
+
except api.ApiError as exc:
|
|
397
|
+
typer.echo(str(exc), err=True)
|
|
398
|
+
raise typer.Exit(code=1)
|
|
399
|
+
|
|
400
|
+
if is_site_project(project):
|
|
401
|
+
typer.echo(
|
|
402
|
+
"--local is for app builds; sites don't use Docker — run `mozbridge publish` "
|
|
403
|
+
"without --local.",
|
|
404
|
+
err=True,
|
|
405
|
+
)
|
|
406
|
+
raise typer.Exit(code=1)
|
|
407
|
+
|
|
291
408
|
try:
|
|
292
409
|
secrets = runtime_secretsmod.fetch_runtime_secrets(client, build_token)
|
|
293
410
|
except runtime_secretsmod.RuntimeSecretsError as exc:
|
|
@@ -410,21 +527,34 @@ def diff_cmd() -> None:
|
|
|
410
527
|
"""Preview what `mozbridge publish` would do right now. Does not publish anything.
|
|
411
528
|
|
|
412
529
|
Requires `mozbridge link` and an active login session, same as `publish`.
|
|
413
|
-
Makes no mutating API calls: it only GETs the linked project
|
|
414
|
-
recent deployment (the same call
|
|
415
|
-
plus local filesystem reads to
|
|
416
|
-
upload, no trigger_build.
|
|
530
|
+
Makes no mutating API calls: it only GETs the linked project itself (to
|
|
531
|
+
resolve its shape) and its most recent deployment (the same call
|
|
532
|
+
`mozbridge status` makes) for context, plus local filesystem reads to
|
|
533
|
+
work out what `publish` would build. No upload, no trigger_build.
|
|
417
534
|
|
|
418
535
|
This compares local components against the most recent deployment
|
|
419
536
|
RECORD only — the platform does not expose the actually-deployed
|
|
420
537
|
compose content anywhere, so this is not a full semantic compose diff,
|
|
421
|
-
just "here's what's live" next to "here's what publish would build".
|
|
538
|
+
just "here's what's live" next to "here's what publish would build". For
|
|
539
|
+
a site-shaped project, the deployment-record list is normally empty even
|
|
540
|
+
after real publishes — see `mozbridge status`'s own known gap, the same
|
|
541
|
+
limitation applies here — and the compose/component analysis below is
|
|
542
|
+
skipped entirely in favor of a plain statement of what `publish` would
|
|
543
|
+
actually do for a site.
|
|
422
544
|
"""
|
|
423
545
|
cwd = Path.cwd()
|
|
424
546
|
|
|
425
547
|
with httpx.Client(timeout=30.0) as client:
|
|
426
548
|
current_link, active_session = require_link_and_session(client, cwd)
|
|
427
549
|
|
|
550
|
+
try:
|
|
551
|
+
project = api.get_project(
|
|
552
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
553
|
+
)
|
|
554
|
+
except api.ApiError as exc:
|
|
555
|
+
typer.echo(str(exc), err=True)
|
|
556
|
+
raise typer.Exit(code=1)
|
|
557
|
+
|
|
428
558
|
try:
|
|
429
559
|
deployments = api.list_project_deployments(
|
|
430
560
|
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
@@ -452,6 +582,15 @@ def diff_cmd() -> None:
|
|
|
452
582
|
typer.echo(" No deployments yet.")
|
|
453
583
|
typer.echo("")
|
|
454
584
|
|
|
585
|
+
if is_site_project(project):
|
|
586
|
+
typer.echo(
|
|
587
|
+
"This project is a Launch site — `mozbridge publish` would zip the current "
|
|
588
|
+
"directory and publish it as a static site (create a publish-upload session, "
|
|
589
|
+
"PUT the zip, then publish-from-upload with immediate=true), not build a Docker "
|
|
590
|
+
"image. The compose/component analysis below does not apply to Launch sites."
|
|
591
|
+
)
|
|
592
|
+
return
|
|
593
|
+
|
|
455
594
|
components: list[composemod.Component] = []
|
|
456
595
|
compose_path = composemod.find_compose_file(cwd)
|
|
457
596
|
if compose_path is not None:
|
|
@@ -29,3 +29,17 @@ def require_link_and_session(client: httpx.Client, cwd: Path) -> tuple[linkmod.L
|
|
|
29
29
|
raise typer.Exit(code=1)
|
|
30
30
|
active_session = load_and_refresh_session(client)
|
|
31
31
|
return current_link, active_session
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_site_project(project: dict) -> bool:
|
|
35
|
+
"""True when `project` (an api.get_project response) is a Launch site
|
|
36
|
+
(`config.shape == "site"`), not an app build.
|
|
37
|
+
|
|
38
|
+
Mirrors `backend/app/services/site_service.py`'s own `is_site_project` —
|
|
39
|
+
the ONE discriminator the backend uses everywhere to route between the
|
|
40
|
+
app-build and static-site code paths. Shape-aware commands
|
|
41
|
+
(`publish`, `publish --local`, `deploy`, `diff`) all call this against
|
|
42
|
+
whatever `api.get_project` already returned, rather than adding a second,
|
|
43
|
+
parallel "get the shape" endpoint.
|
|
44
|
+
"""
|
|
45
|
+
return (project.get("config") or {}).get("shape") == "site"
|