mozbridge-cli 0.5.0__tar.gz → 0.6.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.5.0 → mozbridge_cli-0.6.0}/PKG-INFO +1 -1
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/docs/commands.md +121 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/pyproject.toml +1 -1
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/api.py +107 -0
- mozbridge_cli-0.6.0/src/mozbridge_cli/cmd_adopt.py +179 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/main.py +11 -8
- mozbridge_cli-0.6.0/tests/test_adopt.py +320 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/.gitignore +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/README.md +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/scripts/release.sh +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/__init__.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/__main__.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/auth.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/build.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_auth.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_config.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_deploy.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_doctor.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_env.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_projects.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_publish.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_registry.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_secrets.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_shared.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/cmd_token.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/compose.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/config.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/link.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/local_build.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/runtime_secrets.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/src/mozbridge_cli/session.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/conftest.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_build.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_ci_token_auth.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_compose.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_config.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_deploy.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_deploy_preflight.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_diff.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_doctor.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_env.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_link.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_local_build.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_login.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_login_cli.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_logout.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_logs.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_publish.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_publish_local.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_publish_multi_component.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_refresh.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_registry.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_rollback.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_runtime_secrets.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_secrets.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_session_permissions.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_status.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.0}/tests/test_token.py +0 -0
- {mozbridge_cli-0.5.0 → mozbridge_cli-0.6.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.6.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
|
|
@@ -1031,6 +1031,127 @@ result never fails the command.
|
|
|
1031
1031
|
|
|
1032
1032
|
---
|
|
1033
1033
|
|
|
1034
|
+
## `mozbridge adopt` / `mozbridge scaffold`
|
|
1035
|
+
|
|
1036
|
+
Both wrap real, already-shipped backend endpoints
|
|
1037
|
+
(`POST /api/v1/projects/{project_id}/adopt`,
|
|
1038
|
+
`POST /api/v1/projects/{project_id}/scaffold` — D3/D3b in
|
|
1039
|
+
`backend/app/features/projects/router.py`, see
|
|
1040
|
+
`.llm/plans/plane-config-contract.md`) with **zero backend changes**: each
|
|
1041
|
+
renders a `docker-compose.app.yml`, validates it against the platform's D2
|
|
1042
|
+
compose contract, and — only if it passes — opens a PR on the project's
|
|
1043
|
+
own repo adding the file. This is exactly what `mozbridge doctor`'s
|
|
1044
|
+
pre-deploy checks recommend when a project has no declared compose shape
|
|
1045
|
+
(`deploy_shape` in `backend/app/services/preflight.py`) — until this
|
|
1046
|
+
slice, the CLI had no command that could act on that diagnosis.
|
|
1047
|
+
|
|
1048
|
+
Same confirmation weight as `mozbridge rollback`/`deploy`: real but
|
|
1049
|
+
reversible (opening a PR touches nothing in production and nothing merges
|
|
1050
|
+
automatically), so both print what they're about to do and prompt to
|
|
1051
|
+
continue, skippable with `--yes`. **Requires**: link + login, same as
|
|
1052
|
+
`env`/`registry`/`config`.
|
|
1053
|
+
|
|
1054
|
+
Success for either command means **a PR was opened** — printed on its own
|
|
1055
|
+
line, never buried in other output. It does **not** mean anything
|
|
1056
|
+
deployed, and it does **not** point the platform at the new file:
|
|
1057
|
+
`config.app_compose_file` is a deliberately separate, later, manual step
|
|
1058
|
+
(`mozbridge config set app_compose_file <path>`, after reviewing and
|
|
1059
|
+
merging the PR).
|
|
1060
|
+
|
|
1061
|
+
### `mozbridge adopt`
|
|
1062
|
+
|
|
1063
|
+
**Flags**: `--container` (required — name of the LIVE container to
|
|
1064
|
+
inspect on the project's bound host, e.g. `origyn-app`), `--service`
|
|
1065
|
+
(default `backend`), `--yes`.
|
|
1066
|
+
|
|
1067
|
+
**When to use it**: the project already has a container RUNNING
|
|
1068
|
+
somewhere with no declared compose shape on disk (Origyn is the concrete
|
|
1069
|
+
case this endpoint was built for: 9 real Traefik labels, an `origyn-data`
|
|
1070
|
+
volume, port 5001, none of it on disk — and its real container is named
|
|
1071
|
+
`origyn-app`, not `origyn-backend`). `--container` has **no guessed
|
|
1072
|
+
default** — a project's real container name routinely doesn't match its
|
|
1073
|
+
slug, so guessing one would silently point this at the wrong container.
|
|
1074
|
+
|
|
1075
|
+
**What it does**: `POST /api/v1/projects/{project_id}/adopt`, body
|
|
1076
|
+
`{"container": CONTAINER, "service": SERVICE}`. The backend inspects
|
|
1077
|
+
`CONTAINER` LIVE on the project's bound host (image, ports, env, volumes,
|
|
1078
|
+
Traefik labels), renders a single-service compose file from what's
|
|
1079
|
+
ACTUALLY running, validates it against D2, and opens a PR only if that
|
|
1080
|
+
passes.
|
|
1081
|
+
|
|
1082
|
+
```
|
|
1083
|
+
$ mozbridge adopt --container origyn-app
|
|
1084
|
+
This will adopt acme/origyn:
|
|
1085
|
+
container -> 'origyn-app' (inspected LIVE on the project's bound host)
|
|
1086
|
+
service -> 'backend'
|
|
1087
|
+
If the rendered compose file passes the D2 contract, a PR will be opened.
|
|
1088
|
+
Continue? [y/N]: y
|
|
1089
|
+
PR opened:
|
|
1090
|
+
https://github.com/acme/origyn/pull/42
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
**Failure modes**:
|
|
1094
|
+
- Not linked / not logged in: identical messages to every other command
|
|
1095
|
+
above, exit 1.
|
|
1096
|
+
- `--container` not found on the project's host: the backend's own 400
|
|
1097
|
+
message verbatim (`container 'X' not found on <host>: ...`), exit 1.
|
|
1098
|
+
- Host unreachable (no usable SSH credential, tunnel failure): the
|
|
1099
|
+
backend's own 400/502 message verbatim, exit 1.
|
|
1100
|
+
- Rendered compose file fails D2 validation: **no PR is opened**. Every
|
|
1101
|
+
violation prints on its own line, prefixed by the explicit statement
|
|
1102
|
+
that no PR was opened, exit 1:
|
|
1103
|
+
|
|
1104
|
+
```
|
|
1105
|
+
$ mozbridge adopt --container origyn-app --yes
|
|
1106
|
+
...
|
|
1107
|
+
Could not adopt the running container: rendered docker-compose.app.yml failed the D2 compose contract validator
|
|
1108
|
+
No PR was opened. D2 violations:
|
|
1109
|
+
- service 'backend' has no healthcheck
|
|
1110
|
+
- image tag 'latest' is not allowed
|
|
1111
|
+
```
|
|
1112
|
+
|
|
1113
|
+
### `mozbridge scaffold`
|
|
1114
|
+
|
|
1115
|
+
**Flags**: `--service` (default `backend`), `--yes`. No `--container` —
|
|
1116
|
+
scaffold never inspects a live container.
|
|
1117
|
+
|
|
1118
|
+
**When to use it**: a genuinely new project that has never run anywhere.
|
|
1119
|
+
There's no live container to inspect (that's `adopt`'s job), so this
|
|
1120
|
+
renders a starter compose file from whatever the project's OWN declared
|
|
1121
|
+
config already has (`backend_image`/`image_name`, `backend_port`,
|
|
1122
|
+
`backend_command`, `data_volumes`, `frontend_image`, `alt_domains` — set
|
|
1123
|
+
these first with `mozbridge config set`). Deliberately not run
|
|
1124
|
+
automatically at project creation: none of those config keys exist yet
|
|
1125
|
+
at that point, so an automatic call here would render a backend-only,
|
|
1126
|
+
default-port guess for every project — exactly what this endpoint's
|
|
1127
|
+
design rejects.
|
|
1128
|
+
|
|
1129
|
+
**What it does**: `POST /api/v1/projects/{project_id}/scaffold`, body
|
|
1130
|
+
`{"service": SERVICE}`. Same D2-validate-then-PR pipeline and response
|
|
1131
|
+
shape as `adopt` above, reading only `project.config` — no host, no
|
|
1132
|
+
docker, no SSH.
|
|
1133
|
+
|
|
1134
|
+
```
|
|
1135
|
+
$ mozbridge scaffold
|
|
1136
|
+
This will scaffold acme/new-app from its own declared config:
|
|
1137
|
+
service -> 'backend'
|
|
1138
|
+
If the rendered compose file passes the D2 contract, a PR will be opened.
|
|
1139
|
+
Continue? [y/N]: y
|
|
1140
|
+
PR opened:
|
|
1141
|
+
https://github.com/acme/new-app/pull/7
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
**Failure modes**:
|
|
1145
|
+
- Not linked / not logged in: exit 1, same messages as every command
|
|
1146
|
+
above.
|
|
1147
|
+
- Rendered compose file fails D2 validation: **no PR is opened**, every
|
|
1148
|
+
violation on its own line, exit 1 — identical shape to `adopt`'s
|
|
1149
|
+
example above.
|
|
1150
|
+
- Any other backend error (e.g. a 500): the raw `ApiError` message,
|
|
1151
|
+
exit 1.
|
|
1152
|
+
|
|
1153
|
+
---
|
|
1154
|
+
|
|
1034
1155
|
## A note on authorization for `publish` / `rollback`
|
|
1035
1156
|
|
|
1036
1157
|
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.
|
|
7
|
+
version = "0.6.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"
|
|
@@ -557,6 +557,113 @@ def deploy_project(
|
|
|
557
557
|
return resp.json()
|
|
558
558
|
|
|
559
559
|
|
|
560
|
+
def _raise_on_compose_validation_failure(resp: httpx.Response, action: str) -> None:
|
|
561
|
+
"""Shared 422 handler for adopt_project/scaffold_project.
|
|
562
|
+
|
|
563
|
+
Both `POST .../adopt` and `POST .../scaffold`
|
|
564
|
+
(backend/app/features/projects/router.py) render a docker-compose.app.yml
|
|
565
|
+
and, if it fails the D2 compose contract validator, respond 422 with
|
|
566
|
+
`detail={"message": "...", "violations": [...]}` — no PR is opened in
|
|
567
|
+
that case. This is a genuinely useful discovery (a live/declared spec
|
|
568
|
+
violating the platform's own guardrails), not a generic failure, so the
|
|
569
|
+
violations are folded verbatim into the raised ApiError's message rather
|
|
570
|
+
than collapsed into "422 Unprocessable Entity" — the caller (cmd_adopt.py)
|
|
571
|
+
just echoes `str(exc)` and every violation prints on its own line.
|
|
572
|
+
|
|
573
|
+
Any other non-2xx status falls through to the normal `_raise_for_status`
|
|
574
|
+
path unchanged.
|
|
575
|
+
"""
|
|
576
|
+
if resp.status_code != 422:
|
|
577
|
+
_raise_for_status(resp, action)
|
|
578
|
+
return
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
detail = resp.json().get("detail")
|
|
582
|
+
except ValueError:
|
|
583
|
+
detail = None
|
|
584
|
+
|
|
585
|
+
if not isinstance(detail, dict) or "violations" not in detail:
|
|
586
|
+
_raise_for_status(resp, action)
|
|
587
|
+
return
|
|
588
|
+
|
|
589
|
+
message = detail.get("message") or (
|
|
590
|
+
"rendered docker-compose.app.yml failed the D2 compose contract validator"
|
|
591
|
+
)
|
|
592
|
+
violations = detail.get("violations") or []
|
|
593
|
+
lines = [f"Could not {action}: {message}", "No PR was opened. D2 violations:"]
|
|
594
|
+
lines.extend(f" - {v}" for v in violations)
|
|
595
|
+
raise ApiError("\n".join(lines))
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def adopt_project(
|
|
599
|
+
client: httpx.Client,
|
|
600
|
+
access_token: str,
|
|
601
|
+
org_id: int,
|
|
602
|
+
project_id: int,
|
|
603
|
+
container: str,
|
|
604
|
+
service: str = "backend",
|
|
605
|
+
) -> dict:
|
|
606
|
+
"""POST /api/v1/projects/{project_id}/adopt, body {"container", "service"}.
|
|
607
|
+
|
|
608
|
+
D3 (`.llm/plans/plane-config-contract.md`) — wraps
|
|
609
|
+
`router.py`'s `adopt_running_container`: inspects `container` LIVE on the
|
|
610
|
+
project's bound host, renders a single-service `docker-compose.app.yml`
|
|
611
|
+
from what's ACTUALLY running, validates it against the D2 compose
|
|
612
|
+
contract, and — only if that passes — opens a PR adding the file to the
|
|
613
|
+
project's own repo. Nothing is written anywhere if validation fails (see
|
|
614
|
+
`_raise_on_compose_validation_failure`).
|
|
615
|
+
|
|
616
|
+
`container` has no safe client-side default — e.g. Origyn's real
|
|
617
|
+
container is `origyn-app`, not a guessed `{slug}-backend` — so callers
|
|
618
|
+
(cmd_adopt.py) must always pass a real, user-supplied name.
|
|
619
|
+
|
|
620
|
+
Returns {"pr_url", "pr_number", "branch", "violations": []} on success
|
|
621
|
+
(200). Raises ApiError (with violations folded into the message) on a
|
|
622
|
+
422, or the usual ApiError for any other failure — including a 400 when
|
|
623
|
+
`container` doesn't exist on the host, or a 502 when the host itself
|
|
624
|
+
can't be reached.
|
|
625
|
+
"""
|
|
626
|
+
url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/adopt"
|
|
627
|
+
payload = {"container": container, "service": service}
|
|
628
|
+
try:
|
|
629
|
+
resp = client.post(url, json=payload, headers=_headers(access_token, org_id))
|
|
630
|
+
except httpx.HTTPError as exc:
|
|
631
|
+
raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
|
|
632
|
+
_raise_on_compose_validation_failure(resp, "adopt the running container")
|
|
633
|
+
return resp.json()
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def scaffold_project(
|
|
637
|
+
client: httpx.Client,
|
|
638
|
+
access_token: str,
|
|
639
|
+
org_id: int,
|
|
640
|
+
project_id: int,
|
|
641
|
+
service: str = "backend",
|
|
642
|
+
) -> dict:
|
|
643
|
+
"""POST /api/v1/projects/{project_id}/scaffold, body {"service"}.
|
|
644
|
+
|
|
645
|
+
D3b (`.llm/plans/plane-config-contract.md`) — wraps
|
|
646
|
+
`router.py`'s `scaffold_project_compose`: renders a starter
|
|
647
|
+
`docker-compose.app.yml` from the project's OWN declared config
|
|
648
|
+
(`backend_image`/`backend_port`/`backend_command`/`data_volumes`/
|
|
649
|
+
`frontend_image`/`alt_domains` — never a live container, since a
|
|
650
|
+
brand-new project has none), same D2-validate-then-PR pipeline and
|
|
651
|
+
response shape as `adopt_project` above.
|
|
652
|
+
|
|
653
|
+
Returns {"pr_url", "pr_number", "branch", "violations": []} on success
|
|
654
|
+
(200). Raises ApiError (with violations folded into the message) on a
|
|
655
|
+
422, same handling as `adopt_project`.
|
|
656
|
+
"""
|
|
657
|
+
url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/scaffold"
|
|
658
|
+
payload = {"service": service}
|
|
659
|
+
try:
|
|
660
|
+
resp = client.post(url, json=payload, headers=_headers(access_token, org_id))
|
|
661
|
+
except httpx.HTTPError as exc:
|
|
662
|
+
raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
|
|
663
|
+
_raise_on_compose_validation_failure(resp, "scaffold the project compose file")
|
|
664
|
+
return resp.json()
|
|
665
|
+
|
|
666
|
+
|
|
560
667
|
def get_project_preflight(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
|
|
561
668
|
"""GET /api/v1/projects/{project_id}/preflight -> preflight.Preflight.as_dict().
|
|
562
669
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""`mozbridge adopt` / `mozbridge scaffold` — declare a compose shape for the
|
|
2
|
+
linked project by opening a PR, wrapping two real, already-shipped backend
|
|
3
|
+
endpoints (`POST .../adopt`, `POST .../scaffold` in
|
|
4
|
+
backend/app/features/projects/router.py — D3/D3b,
|
|
5
|
+
`.llm/plans/plane-config-contract.md`) with zero backend changes. Same
|
|
6
|
+
wrapping posture as cmd_registry.py/cmd_config.py/cmd_secrets.py.
|
|
7
|
+
|
|
8
|
+
`mozbridge doctor`'s "Pre-deploy checks" line already surfaces the gap these
|
|
9
|
+
close: when a project has no declared compose shape (`deploy_shape` in
|
|
10
|
+
backend/app/services/preflight.py), it recommends running one of these two
|
|
11
|
+
endpoints — but until now the CLI had no command that could act on that
|
|
12
|
+
diagnosis. This module is that command.
|
|
13
|
+
|
|
14
|
+
Two flat, single-verb commands (same tier as `doctor`/`login`), not a nested
|
|
15
|
+
sub-typer — neither has further subcommands, so there's nothing to group.
|
|
16
|
+
|
|
17
|
+
Confirmation weight: opening a PR is real but reversible — nothing merges
|
|
18
|
+
automatically, nothing touches production — so this matches
|
|
19
|
+
`cmd_deploy.py`'s `rollback`/`deploy` confirmation pattern (print what will
|
|
20
|
+
happen, `typer.confirm`, skippable with `--yes`), not the silent posture of
|
|
21
|
+
`env set`/`config set`.
|
|
22
|
+
|
|
23
|
+
`adopt` vs `scaffold`, when to use which:
|
|
24
|
+
- `adopt`: a project already has a container RUNNING somewhere (Origyn is
|
|
25
|
+
the canonical case — 9 real Traefik labels, a data volume, a port, none
|
|
26
|
+
of it on disk) and you want the platform to read the REAL shape off that
|
|
27
|
+
live container and declare it.
|
|
28
|
+
- `scaffold`: a genuinely new project that has never run anywhere. There is
|
|
29
|
+
no live container to inspect, so this renders a starter compose file from
|
|
30
|
+
whatever the project's OWN config already declares
|
|
31
|
+
(backend_image/backend_port/backend_command/data_volumes/etc).
|
|
32
|
+
|
|
33
|
+
Success for either command means a PR was opened — NOT that anything
|
|
34
|
+
deployed, and NOT that `config.app_compose_file` was updated to point at the
|
|
35
|
+
new file. Both are deliberately separate, later, manual steps (see the
|
|
36
|
+
backend endpoints' own docstrings).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
import httpx
|
|
44
|
+
import typer
|
|
45
|
+
|
|
46
|
+
from . import api
|
|
47
|
+
from .cmd_shared import require_link_and_session
|
|
48
|
+
|
|
49
|
+
app = typer.Typer(add_completion=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("adopt")
|
|
53
|
+
def adopt_cmd(
|
|
54
|
+
container: str = typer.Option(
|
|
55
|
+
...,
|
|
56
|
+
"--container",
|
|
57
|
+
help=(
|
|
58
|
+
"Name of the LIVE container to inspect on the project's bound host "
|
|
59
|
+
"(e.g. origyn-app). Required — there is no safe default to guess "
|
|
60
|
+
"(a project's real container name routinely doesn't match its slug)."
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
service: str = typer.Option(
|
|
64
|
+
"backend", "--service", help="Compose service name to render the adopted shape under."
|
|
65
|
+
),
|
|
66
|
+
yes: bool = typer.Option(False, "--yes", help="Skip the interactive confirmation prompt."),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Adopt a project that's already running with no declared compose shape.
|
|
69
|
+
|
|
70
|
+
Use this when a project has a container RUNNING somewhere (on its bound
|
|
71
|
+
host) but nothing declaring its shape on disk — the platform inspects
|
|
72
|
+
`--container` LIVE, renders a single-service `docker-compose.app.yml`
|
|
73
|
+
from what's ACTUALLY running (image, ports, env, volumes, Traefik
|
|
74
|
+
labels), validates it against the platform's D2 compose contract, and —
|
|
75
|
+
only if that passes — opens a PR on the project's own repo adding the
|
|
76
|
+
file. Requires `mozbridge link` and an active login session.
|
|
77
|
+
|
|
78
|
+
This is a real, consequential action (same confirmation weight as
|
|
79
|
+
`mozbridge rollback`): it inspects a live production container and opens
|
|
80
|
+
a real PR, though nothing merges automatically and nothing here touches
|
|
81
|
+
a running deployment. Confirmed unless `--yes` is passed.
|
|
82
|
+
|
|
83
|
+
Success means a PR was opened — printed on its own line, impossible to
|
|
84
|
+
miss. It does NOT mean anything was deployed, and it does NOT point the
|
|
85
|
+
platform at the new file (`config.app_compose_file` is a separate,
|
|
86
|
+
later, manual step — see `mozbridge config set`).
|
|
87
|
+
|
|
88
|
+
If the rendered compose file fails the platform's D2 compose contract
|
|
89
|
+
validator, the backend returns each violation instead of opening a PR.
|
|
90
|
+
This command prints every violation on its own line, states plainly that
|
|
91
|
+
no PR was opened, and exits 1 — there is nothing to go looking for.
|
|
92
|
+
"""
|
|
93
|
+
cwd = Path.cwd()
|
|
94
|
+
|
|
95
|
+
with httpx.Client(timeout=60.0) as client:
|
|
96
|
+
current_link, active_session = require_link_and_session(client, cwd)
|
|
97
|
+
|
|
98
|
+
project_desc = f"{current_link.org_slug}/{current_link.project_slug}"
|
|
99
|
+
typer.echo(f"This will adopt {project_desc}:")
|
|
100
|
+
typer.echo(f" container -> {container!r} (inspected LIVE on the project's bound host)")
|
|
101
|
+
typer.echo(f" service -> {service!r}")
|
|
102
|
+
typer.echo("If the rendered compose file passes the D2 contract, a PR will be opened.")
|
|
103
|
+
if not yes:
|
|
104
|
+
typer.confirm("Continue?", abort=True)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
result = api.adopt_project(
|
|
108
|
+
client,
|
|
109
|
+
active_session.access_token,
|
|
110
|
+
current_link.org_id,
|
|
111
|
+
current_link.project_id,
|
|
112
|
+
container,
|
|
113
|
+
service=service,
|
|
114
|
+
)
|
|
115
|
+
except api.ApiError as exc:
|
|
116
|
+
typer.echo(str(exc), err=True)
|
|
117
|
+
raise typer.Exit(code=1)
|
|
118
|
+
|
|
119
|
+
typer.echo("PR opened:")
|
|
120
|
+
typer.echo(f" {result.get('pr_url')}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command("scaffold")
|
|
124
|
+
def scaffold_cmd(
|
|
125
|
+
service: str = typer.Option(
|
|
126
|
+
"backend", "--service", help="Compose service name to render the scaffolded shape under."
|
|
127
|
+
),
|
|
128
|
+
yes: bool = typer.Option(False, "--yes", help="Skip the interactive confirmation prompt."),
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Scaffold a starter compose shape for a project with no running container.
|
|
131
|
+
|
|
132
|
+
Use this for a genuinely new project that has never run anywhere — there
|
|
133
|
+
is no live container to inspect (that's what `mozbridge adopt` is for).
|
|
134
|
+
Instead, this renders a starter `docker-compose.app.yml` from whatever
|
|
135
|
+
the project's OWN declared config already has
|
|
136
|
+
(backend_image/backend_port/backend_command/data_volumes/frontend_image/
|
|
137
|
+
alt_domains — see `mozbridge config set` to declare them first),
|
|
138
|
+
validates it against the platform's D2 compose contract, and — only if
|
|
139
|
+
that passes — opens a PR on the project's own repo adding the file.
|
|
140
|
+
Requires `mozbridge link` and an active login session.
|
|
141
|
+
|
|
142
|
+
Same confirmation weight as `mozbridge adopt`/`rollback`: real but
|
|
143
|
+
reversible (nothing merges automatically), confirmed unless `--yes` is
|
|
144
|
+
passed.
|
|
145
|
+
|
|
146
|
+
Success means a PR was opened — printed on its own line. It does NOT
|
|
147
|
+
mean anything was deployed, and it does NOT point the platform at the
|
|
148
|
+
new file (`config.app_compose_file` is a separate, later, manual step).
|
|
149
|
+
|
|
150
|
+
If the rendered compose file fails the platform's D2 compose contract
|
|
151
|
+
validator, this command prints every violation on its own line, states
|
|
152
|
+
plainly that no PR was opened, and exits 1.
|
|
153
|
+
"""
|
|
154
|
+
cwd = Path.cwd()
|
|
155
|
+
|
|
156
|
+
with httpx.Client(timeout=30.0) as client:
|
|
157
|
+
current_link, active_session = require_link_and_session(client, cwd)
|
|
158
|
+
|
|
159
|
+
project_desc = f"{current_link.org_slug}/{current_link.project_slug}"
|
|
160
|
+
typer.echo(f"This will scaffold {project_desc} from its own declared config:")
|
|
161
|
+
typer.echo(f" service -> {service!r}")
|
|
162
|
+
typer.echo("If the rendered compose file passes the D2 contract, a PR will be opened.")
|
|
163
|
+
if not yes:
|
|
164
|
+
typer.confirm("Continue?", abort=True)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
result = api.scaffold_project(
|
|
168
|
+
client,
|
|
169
|
+
active_session.access_token,
|
|
170
|
+
current_link.org_id,
|
|
171
|
+
current_link.project_id,
|
|
172
|
+
service=service,
|
|
173
|
+
)
|
|
174
|
+
except api.ApiError as exc:
|
|
175
|
+
typer.echo(str(exc), err=True)
|
|
176
|
+
raise typer.Exit(code=1)
|
|
177
|
+
|
|
178
|
+
typer.echo("PR opened:")
|
|
179
|
+
typer.echo(f" {result.get('pr_url')}")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Mozbridge CLI: login / whoami / logout / link / build / publish / diff / status /
|
|
2
|
-
deploy / rollback / logs / env / registry / secrets / config / token / doctor
|
|
2
|
+
deploy / rollback / logs / env / registry / secrets / config / token / doctor /
|
|
3
|
+
adopt / scaffold.
|
|
3
4
|
|
|
4
5
|
macOS-only for v1 (no Windows code paths).
|
|
5
6
|
|
|
@@ -7,16 +8,16 @@ This module is a thin composition root: the root Typer `app` is assembled
|
|
|
7
8
|
from the command modules below, each of which owns one feature area's
|
|
8
9
|
command definitions (see cmd_auth.py, cmd_projects.py, cmd_publish.py,
|
|
9
10
|
cmd_deploy.py, cmd_env.py, cmd_registry.py, cmd_secrets.py, cmd_config.py,
|
|
10
|
-
cmd_token.py, cmd_doctor.py).
|
|
11
|
+
cmd_token.py, cmd_doctor.py, cmd_adopt.py).
|
|
11
12
|
`app.add_typer(<module>.app)`, called with no explicit `name`, merges a
|
|
12
13
|
nameless sub-Typer's commands flatly into `app` (confirmed against Typer's
|
|
13
14
|
real behavior — see cmd_*.py's own module-level Typer() calls, none of
|
|
14
15
|
which pass `name=` except cmd_env's/cmd_registry's/cmd_secrets's/
|
|
15
16
|
cmd_config's/cmd_token's, which is why `env`/`registry`/`secrets`/
|
|
16
|
-
`config`/`token` stay nested command groups while `doctor
|
|
17
|
-
|
|
18
|
-
`app`). `from mozbridge_cli.main import app` continues
|
|
19
|
-
command exactly as before.
|
|
17
|
+
`config`/`token` stay nested command groups while `doctor`/`adopt`/
|
|
18
|
+
`scaffold` — single flat commands, same shape as `login`/`whoami`/`logout`
|
|
19
|
+
— merge directly into `app`). `from mozbridge_cli.main import app` continues
|
|
20
|
+
to expose every command exactly as before.
|
|
20
21
|
"""
|
|
21
22
|
|
|
22
23
|
from __future__ import annotations
|
|
@@ -24,6 +25,7 @@ from __future__ import annotations
|
|
|
24
25
|
import typer
|
|
25
26
|
|
|
26
27
|
from . import (
|
|
28
|
+
cmd_adopt,
|
|
27
29
|
cmd_auth,
|
|
28
30
|
cmd_config,
|
|
29
31
|
cmd_deploy,
|
|
@@ -40,10 +42,10 @@ app = typer.Typer(
|
|
|
40
42
|
name="mozbridge",
|
|
41
43
|
help=(
|
|
42
44
|
"Mozbridge platform CLI: login, whoami, logout, link, build, publish, diff, "
|
|
43
|
-
"status, deploy, rollback, logs, env, registry, secrets, config, token, doctor
|
|
45
|
+
"status, deploy, rollback, logs, env, registry, secrets, config, token, doctor, "
|
|
46
|
+
"adopt, scaffold."
|
|
44
47
|
),
|
|
45
48
|
no_args_is_help=True,
|
|
46
|
-
add_completion=False,
|
|
47
49
|
)
|
|
48
50
|
|
|
49
51
|
app.add_typer(cmd_auth.app)
|
|
@@ -56,6 +58,7 @@ app.add_typer(cmd_secrets.app, name="secrets")
|
|
|
56
58
|
app.add_typer(cmd_config.app, name="config")
|
|
57
59
|
app.add_typer(cmd_token.app, name="token")
|
|
58
60
|
app.add_typer(cmd_doctor.app)
|
|
61
|
+
app.add_typer(cmd_adopt.app)
|
|
59
62
|
|
|
60
63
|
|
|
61
64
|
def app_main() -> None:
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import respx
|
|
6
|
+
from typer.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from mozbridge_cli import config, session
|
|
9
|
+
from mozbridge_cli.link import Link, save_link
|
|
10
|
+
from mozbridge_cli.main import app
|
|
11
|
+
|
|
12
|
+
runner = CliRunner()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _seed_session():
|
|
16
|
+
s = session.Session(
|
|
17
|
+
access_token="AT-valid",
|
|
18
|
+
refresh_token="RT-valid",
|
|
19
|
+
id_token="IDT",
|
|
20
|
+
expires_at=time.time() + 3600,
|
|
21
|
+
)
|
|
22
|
+
session.save_session(s)
|
|
23
|
+
return s
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _seed_link(cwd):
|
|
27
|
+
save_link(cwd, Link(org_id=1, org_slug="acme", project_id=10, project_slug="origyn"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- adopt ---------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_adopt_without_link_gives_clear_error(tmp_path, monkeypatch):
|
|
34
|
+
monkeypatch.chdir(tmp_path)
|
|
35
|
+
_seed_session()
|
|
36
|
+
|
|
37
|
+
result = runner.invoke(app, ["adopt", "--container", "origyn-app", "--yes"])
|
|
38
|
+
|
|
39
|
+
assert result.exit_code == 1
|
|
40
|
+
assert "mozbridge link" in result.output
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_adopt_without_login_gives_clear_error(tmp_path, monkeypatch):
|
|
44
|
+
monkeypatch.chdir(tmp_path)
|
|
45
|
+
_seed_link(tmp_path)
|
|
46
|
+
|
|
47
|
+
result = runner.invoke(app, ["adopt", "--container", "origyn-app", "--yes"])
|
|
48
|
+
|
|
49
|
+
assert result.exit_code == 1
|
|
50
|
+
assert "mozbridge login" in result.output
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_adopt_yes_skips_prompt_and_prints_pr_url(tmp_path, monkeypatch):
|
|
54
|
+
monkeypatch.chdir(tmp_path)
|
|
55
|
+
_seed_session()
|
|
56
|
+
_seed_link(tmp_path)
|
|
57
|
+
|
|
58
|
+
with respx.mock:
|
|
59
|
+
route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
|
|
60
|
+
return_value=httpx.Response(
|
|
61
|
+
200,
|
|
62
|
+
json={
|
|
63
|
+
"pr_url": "https://github.com/acme/origyn/pull/42",
|
|
64
|
+
"pr_number": 42,
|
|
65
|
+
"branch": "mozbridge/adopt-compose",
|
|
66
|
+
"violations": [],
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
result = runner.invoke(app, ["adopt", "--container", "origyn-app", "--yes"])
|
|
72
|
+
|
|
73
|
+
assert result.exit_code == 0, result.output
|
|
74
|
+
assert route.called
|
|
75
|
+
assert "https://github.com/acme/origyn/pull/42" in result.output
|
|
76
|
+
|
|
77
|
+
request = route.calls.last.request
|
|
78
|
+
assert request.headers["authorization"] == "Bearer AT-valid"
|
|
79
|
+
assert request.headers["x-organization-id"] == "1"
|
|
80
|
+
body = json.loads(request.content)
|
|
81
|
+
assert body == {"container": "origyn-app", "service": "backend"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_adopt_custom_service_is_sent(tmp_path, monkeypatch):
|
|
85
|
+
monkeypatch.chdir(tmp_path)
|
|
86
|
+
_seed_session()
|
|
87
|
+
_seed_link(tmp_path)
|
|
88
|
+
|
|
89
|
+
with respx.mock:
|
|
90
|
+
route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
|
|
91
|
+
return_value=httpx.Response(
|
|
92
|
+
200,
|
|
93
|
+
json={
|
|
94
|
+
"pr_url": "https://github.com/acme/origyn/pull/43",
|
|
95
|
+
"pr_number": 43,
|
|
96
|
+
"branch": "mozbridge/adopt-compose",
|
|
97
|
+
"violations": [],
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
result = runner.invoke(app, ["adopt", "--container", "origyn-app", "--service", "frontend", "--yes"])
|
|
103
|
+
|
|
104
|
+
assert result.exit_code == 0, result.output
|
|
105
|
+
body = json.loads(route.calls.last.request.content)
|
|
106
|
+
assert body == {"container": "origyn-app", "service": "frontend"}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_adopt_interactive_confirm_accepted_calls_api(tmp_path, monkeypatch):
|
|
110
|
+
monkeypatch.chdir(tmp_path)
|
|
111
|
+
_seed_session()
|
|
112
|
+
_seed_link(tmp_path)
|
|
113
|
+
|
|
114
|
+
with respx.mock:
|
|
115
|
+
route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
|
|
116
|
+
return_value=httpx.Response(
|
|
117
|
+
200,
|
|
118
|
+
json={"pr_url": "https://github.com/acme/origyn/pull/44", "pr_number": 44, "violations": []},
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
result = runner.invoke(app, ["adopt", "--container", "origyn-app"], input="y\n")
|
|
123
|
+
|
|
124
|
+
assert result.exit_code == 0, result.output
|
|
125
|
+
assert route.called
|
|
126
|
+
assert "https://github.com/acme/origyn/pull/44" in result.output
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_adopt_interactive_confirm_declined_makes_no_api_call(tmp_path, monkeypatch):
|
|
130
|
+
monkeypatch.chdir(tmp_path)
|
|
131
|
+
_seed_session()
|
|
132
|
+
_seed_link(tmp_path)
|
|
133
|
+
|
|
134
|
+
with respx.mock:
|
|
135
|
+
route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
|
|
136
|
+
return_value=httpx.Response(
|
|
137
|
+
200, json={"pr_url": "https://github.com/acme/origyn/pull/45", "violations": []}
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
result = runner.invoke(app, ["adopt", "--container", "origyn-app"], input="n\n")
|
|
142
|
+
|
|
143
|
+
assert result.exit_code != 0
|
|
144
|
+
assert not route.called
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_adopt_prints_what_will_happen_before_prompting(tmp_path, monkeypatch):
|
|
148
|
+
monkeypatch.chdir(tmp_path)
|
|
149
|
+
_seed_session()
|
|
150
|
+
_seed_link(tmp_path)
|
|
151
|
+
|
|
152
|
+
with respx.mock:
|
|
153
|
+
respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
|
|
154
|
+
return_value=httpx.Response(200, json={"pr_url": "https://x/pull/1", "violations": []})
|
|
155
|
+
)
|
|
156
|
+
result = runner.invoke(app, ["adopt", "--container", "origyn-app"], input="n\n")
|
|
157
|
+
|
|
158
|
+
assert "acme/origyn" in result.output
|
|
159
|
+
assert "origyn-app" in result.output
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_adopt_d2_violation_prints_each_violation_and_no_pr_opened_claim(tmp_path, monkeypatch):
|
|
163
|
+
monkeypatch.chdir(tmp_path)
|
|
164
|
+
_seed_session()
|
|
165
|
+
_seed_link(tmp_path)
|
|
166
|
+
|
|
167
|
+
with respx.mock:
|
|
168
|
+
respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
|
|
169
|
+
return_value=httpx.Response(
|
|
170
|
+
422,
|
|
171
|
+
json={
|
|
172
|
+
"detail": {
|
|
173
|
+
"message": "rendered docker-compose.app.yml failed the D2 compose contract validator",
|
|
174
|
+
"violations": [
|
|
175
|
+
"service 'backend' has no healthcheck",
|
|
176
|
+
"image tag 'latest' is not allowed",
|
|
177
|
+
],
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
result = runner.invoke(app, ["adopt", "--container", "origyn-app", "--yes"])
|
|
184
|
+
|
|
185
|
+
assert result.exit_code == 1
|
|
186
|
+
assert "service 'backend' has no healthcheck" in result.output
|
|
187
|
+
assert "image tag 'latest' is not allowed" in result.output
|
|
188
|
+
assert "No PR was opened" in result.output
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_adopt_container_not_found_error_surfaced_clearly(tmp_path, monkeypatch):
|
|
192
|
+
monkeypatch.chdir(tmp_path)
|
|
193
|
+
_seed_session()
|
|
194
|
+
_seed_link(tmp_path)
|
|
195
|
+
|
|
196
|
+
with respx.mock:
|
|
197
|
+
respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/adopt").mock(
|
|
198
|
+
return_value=httpx.Response(
|
|
199
|
+
400, json={"detail": "container 'origyn-app' not found on origyn-host: 404 Client Error"}
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
result = runner.invoke(app, ["adopt", "--container", "origyn-app", "--yes"])
|
|
204
|
+
|
|
205
|
+
assert result.exit_code == 1
|
|
206
|
+
assert "not found on origyn-host" in result.output
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --- scaffold --------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_scaffold_without_link_gives_clear_error(tmp_path, monkeypatch):
|
|
213
|
+
monkeypatch.chdir(tmp_path)
|
|
214
|
+
_seed_session()
|
|
215
|
+
|
|
216
|
+
result = runner.invoke(app, ["scaffold", "--yes"])
|
|
217
|
+
|
|
218
|
+
assert result.exit_code == 1
|
|
219
|
+
assert "mozbridge link" in result.output
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_scaffold_without_login_gives_clear_error(tmp_path, monkeypatch):
|
|
223
|
+
monkeypatch.chdir(tmp_path)
|
|
224
|
+
_seed_link(tmp_path)
|
|
225
|
+
|
|
226
|
+
result = runner.invoke(app, ["scaffold", "--yes"])
|
|
227
|
+
|
|
228
|
+
assert result.exit_code == 1
|
|
229
|
+
assert "mozbridge login" in result.output
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_scaffold_yes_skips_prompt_and_prints_pr_url(tmp_path, monkeypatch):
|
|
233
|
+
monkeypatch.chdir(tmp_path)
|
|
234
|
+
_seed_session()
|
|
235
|
+
_seed_link(tmp_path)
|
|
236
|
+
|
|
237
|
+
with respx.mock:
|
|
238
|
+
route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/scaffold").mock(
|
|
239
|
+
return_value=httpx.Response(
|
|
240
|
+
200,
|
|
241
|
+
json={
|
|
242
|
+
"pr_url": "https://github.com/acme/origyn/pull/50",
|
|
243
|
+
"pr_number": 50,
|
|
244
|
+
"branch": "mozbridge/scaffold-compose",
|
|
245
|
+
"violations": [],
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
result = runner.invoke(app, ["scaffold", "--yes"])
|
|
251
|
+
|
|
252
|
+
assert result.exit_code == 0, result.output
|
|
253
|
+
assert route.called
|
|
254
|
+
assert "https://github.com/acme/origyn/pull/50" in result.output
|
|
255
|
+
|
|
256
|
+
request = route.calls.last.request
|
|
257
|
+
assert request.headers["authorization"] == "Bearer AT-valid"
|
|
258
|
+
assert request.headers["x-organization-id"] == "1"
|
|
259
|
+
body = json.loads(request.content)
|
|
260
|
+
assert body == {"service": "backend"}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_scaffold_custom_service_is_sent(tmp_path, monkeypatch):
|
|
264
|
+
monkeypatch.chdir(tmp_path)
|
|
265
|
+
_seed_session()
|
|
266
|
+
_seed_link(tmp_path)
|
|
267
|
+
|
|
268
|
+
with respx.mock:
|
|
269
|
+
route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/scaffold").mock(
|
|
270
|
+
return_value=httpx.Response(
|
|
271
|
+
200, json={"pr_url": "https://github.com/acme/origyn/pull/51", "violations": []}
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
result = runner.invoke(app, ["scaffold", "--service", "frontend", "--yes"])
|
|
276
|
+
|
|
277
|
+
assert result.exit_code == 0, result.output
|
|
278
|
+
body = json.loads(route.calls.last.request.content)
|
|
279
|
+
assert body == {"service": "frontend"}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_scaffold_interactive_confirm_declined_makes_no_api_call(tmp_path, monkeypatch):
|
|
283
|
+
monkeypatch.chdir(tmp_path)
|
|
284
|
+
_seed_session()
|
|
285
|
+
_seed_link(tmp_path)
|
|
286
|
+
|
|
287
|
+
with respx.mock:
|
|
288
|
+
route = respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/scaffold").mock(
|
|
289
|
+
return_value=httpx.Response(200, json={"pr_url": "https://x/pull/2", "violations": []})
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
result = runner.invoke(app, ["scaffold"], input="n\n")
|
|
293
|
+
|
|
294
|
+
assert result.exit_code != 0
|
|
295
|
+
assert not route.called
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_scaffold_d2_violation_prints_each_violation_and_no_pr_opened_claim(tmp_path, monkeypatch):
|
|
299
|
+
monkeypatch.chdir(tmp_path)
|
|
300
|
+
_seed_session()
|
|
301
|
+
_seed_link(tmp_path)
|
|
302
|
+
|
|
303
|
+
with respx.mock:
|
|
304
|
+
respx.post(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10/scaffold").mock(
|
|
305
|
+
return_value=httpx.Response(
|
|
306
|
+
422,
|
|
307
|
+
json={
|
|
308
|
+
"detail": {
|
|
309
|
+
"message": "rendered docker-compose.app.yml failed the D2 compose contract validator",
|
|
310
|
+
"violations": ["backend_port is not set"],
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
result = runner.invoke(app, ["scaffold", "--yes"])
|
|
317
|
+
|
|
318
|
+
assert result.exit_code == 1
|
|
319
|
+
assert "backend_port is not set" in result.output
|
|
320
|
+
assert "No PR was opened" in result.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|