mozbridge-cli 0.4.0__tar.gz → 0.5.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.4.0 → mozbridge_cli-0.5.0}/PKG-INFO +1 -1
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/docs/commands.md +86 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/pyproject.toml +1 -1
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/api.py +27 -0
- mozbridge_cli-0.5.0/src/mozbridge_cli/cmd_config.py +110 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/main.py +11 -9
- mozbridge_cli-0.5.0/tests/test_config.py +244 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/.gitignore +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/README.md +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/scripts/release.sh +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/__init__.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/__main__.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/auth.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/build.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_auth.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_deploy.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_doctor.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_env.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_projects.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_publish.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_registry.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_secrets.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_shared.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/cmd_token.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/compose.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/config.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/link.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/local_build.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/runtime_secrets.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/src/mozbridge_cli/session.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/conftest.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_build.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_ci_token_auth.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_compose.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_deploy.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_deploy_preflight.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_diff.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_doctor.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_env.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_link.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_local_build.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_login.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_login_cli.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_logout.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_logs.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_publish.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_publish_local.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_publish_multi_component.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_refresh.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_registry.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_rollback.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_runtime_secrets.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_secrets.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_session_permissions.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_status.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.0}/tests/test_token.py +0 -0
- {mozbridge_cli-0.4.0 → mozbridge_cli-0.5.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.5.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
|
|
@@ -811,6 +811,92 @@ the credential (bad token, wrong URL): the raw validation error, exit 1.
|
|
|
811
811
|
|
|
812
812
|
---
|
|
813
813
|
|
|
814
|
+
## `mozbridge config set` / `mozbridge config get`
|
|
815
|
+
|
|
816
|
+
Manages one key on the linked project's `config`
|
|
817
|
+
(`PUT /api/v1/projects/{project_id}`, body `{"config": {KEY: VALUE}}`) —
|
|
818
|
+
the same allowlisted settings the platform template reads
|
|
819
|
+
(`app_compose_file`, `backend_port`, `data_volumes`, and anything else on
|
|
820
|
+
`schemas.PROJECT_CONFIG_ALLOWLIST` in
|
|
821
|
+
`backend/app/features/projects/schemas.py`). This wraps an existing
|
|
822
|
+
endpoint with zero backend changes — the same one `env`/`registry`/
|
|
823
|
+
`secrets` wrap, using the normal logged-in session (`get_valid_project`),
|
|
824
|
+
no special scope.
|
|
825
|
+
|
|
826
|
+
**Requires**: link + login, same as `env`/`registry`.
|
|
827
|
+
|
|
828
|
+
### `mozbridge config set KEY VALUE`
|
|
829
|
+
|
|
830
|
+
**What it does**: `PUT /api/v1/projects/{project_id}` with body
|
|
831
|
+
`{"config": {KEY: VALUE}}`. **This merges into the existing config, it
|
|
832
|
+
does not replace it** — read directly off
|
|
833
|
+
`backend/app/features/projects/router.py`'s `update_project`:
|
|
834
|
+
`current_config.update(incoming)`, so setting one key never touches any
|
|
835
|
+
other key already set.
|
|
836
|
+
|
|
837
|
+
`KEY` is checked against `PROJECT_CONFIG_ALLOWLIST` **server-side only**
|
|
838
|
+
— there is no client-side copy of that list in this CLI, and none is
|
|
839
|
+
maintained here. A `KEY` not on the allowlist gets the whole request
|
|
840
|
+
rejected with a 400:
|
|
841
|
+
|
|
842
|
+
```
|
|
843
|
+
$ mozbridge config set totally_made_up_key x
|
|
844
|
+
Could not set the project config: Unknown or privileged config keys rejected: totally_made_up_key
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
On success, the confirmation is read back off the response body's
|
|
848
|
+
`config` field — not echoed from what was typed — so it reflects what
|
|
849
|
+
the backend actually stored:
|
|
850
|
+
|
|
851
|
+
```
|
|
852
|
+
$ mozbridge config set app_compose_file compose.custom.yml
|
|
853
|
+
Set app_compose_file = compose.custom.yml
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
An empty-string `VALUE` is passed through unchanged, same posture as
|
|
857
|
+
`env set` — this command does not second-guess values.
|
|
858
|
+
|
|
859
|
+
### `mozbridge config get KEY`
|
|
860
|
+
|
|
861
|
+
**What it does**: `GET /api/v1/projects/{project_id}` (the same call
|
|
862
|
+
`status`/`doctor` already make), prints the current value of `KEY` from
|
|
863
|
+
the response's `config` field:
|
|
864
|
+
|
|
865
|
+
```
|
|
866
|
+
$ mozbridge config get app_compose_file
|
|
867
|
+
app_compose_file = compose.custom.yml
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
If `KEY` isn't present in the project's config, prints a clear message
|
|
871
|
+
instead of a raw `None`/`null`:
|
|
872
|
+
|
|
873
|
+
```
|
|
874
|
+
$ mozbridge config get backend_port
|
|
875
|
+
backend_port is not set.
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
**This command does not validate `KEY` against any allowlist** — it
|
|
879
|
+
prints whatever is actually stored, including a key that isn't (or is no
|
|
880
|
+
longer) on `PROJECT_CONFIG_ALLOWLIST`, e.g. a leftover from before it was
|
|
881
|
+
removed. It mirrors reality; it is not a gatekeeper.
|
|
882
|
+
|
|
883
|
+
**No `config list` / `config unset`**: unlike `env`, which has a real
|
|
884
|
+
`DELETE /env-vars/{key}` route, the backend has no operation to delete a
|
|
885
|
+
single arbitrary config key — only `set` (merge) and reading the whole
|
|
886
|
+
project. Inventing client-side behavior for something the backend can't
|
|
887
|
+
actually do would be misleading, so neither subcommand exists.
|
|
888
|
+
|
|
889
|
+
**Failure modes**:
|
|
890
|
+
- Not linked / not logged in: identical messages to `publish` above,
|
|
891
|
+
exit 1.
|
|
892
|
+
- `set` against a key not on the backend's allowlist: the raw `ApiError`
|
|
893
|
+
message (`Could not set the project config: Unknown or privileged
|
|
894
|
+
config keys rejected: <key>`), exit 1.
|
|
895
|
+
- Any other API error (e.g. a 500 from the database): the raw `ApiError`
|
|
896
|
+
message, exit 1.
|
|
897
|
+
|
|
898
|
+
---
|
|
899
|
+
|
|
814
900
|
## `mozbridge secrets list` / `mozbridge secrets set`
|
|
815
901
|
|
|
816
902
|
Manages the linked project's Vault-backed, per-environment/per-component
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mozbridge-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.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"
|
|
@@ -378,6 +378,33 @@ def set_project_registry(
|
|
|
378
378
|
return resp.json()
|
|
379
379
|
|
|
380
380
|
|
|
381
|
+
def set_project_config(
|
|
382
|
+
client: httpx.Client, access_token: str, org_id: int, project_id: int, key: str, value: str
|
|
383
|
+
) -> dict:
|
|
384
|
+
"""PUT /api/v1/projects/{project_id}, body schemas.ProjectUpdate {"config": {key: value}}.
|
|
385
|
+
|
|
386
|
+
router.py's update_project merges `incoming` into the project's existing
|
|
387
|
+
config (`current_config.update(incoming)`) — it does NOT replace the
|
|
388
|
+
whole config, so this never touches any other key. Unknown/privileged
|
|
389
|
+
keys (anything not on schemas.PROJECT_CONFIG_ALLOWLIST) come back as a
|
|
390
|
+
400 with `"Unknown or privileged config keys rejected: <key>"`, which
|
|
391
|
+
`_raise_for_status` already surfaces verbatim via ApiError, same as
|
|
392
|
+
every other function in this module.
|
|
393
|
+
|
|
394
|
+
Returns the full updated schemas.Project — the caller reads the
|
|
395
|
+
confirmed value back off its `config` field rather than trusting what
|
|
396
|
+
was sent, in case the backend did anything to it.
|
|
397
|
+
"""
|
|
398
|
+
url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}"
|
|
399
|
+
payload = {"config": {key: value}}
|
|
400
|
+
try:
|
|
401
|
+
resp = client.put(url, json=payload, 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, "set the project config")
|
|
405
|
+
return resp.json()
|
|
406
|
+
|
|
407
|
+
|
|
381
408
|
def _secrets_url(project_id: int, env_slug: str, component: str) -> str:
|
|
382
409
|
"""Shared path builder for the four secrets endpoints below — keeps the
|
|
383
410
|
env_slug/component path segments byte-for-byte identical across list
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""`mozbridge config` sub-typer — set / get one key on the linked project's config.
|
|
2
|
+
|
|
3
|
+
Same wrapping pattern as cmd_env.py/cmd_registry.py: this is a thin CLI layer
|
|
4
|
+
over a real, already-shipped backend feature — `PUT /api/v1/projects/{id}`
|
|
5
|
+
(backend/app/features/projects/router.py's `update_project`), which merges
|
|
6
|
+
`{"config": {key: value}}` into the project's existing config rather than
|
|
7
|
+
replacing it, and rejects any key not on schemas.PROJECT_CONFIG_ALLOWLIST
|
|
8
|
+
with a 400.
|
|
9
|
+
|
|
10
|
+
Deliberately does NOT duplicate that allowlist here. The backend is the
|
|
11
|
+
single source of truth for what's valid; this CLI just surfaces whatever
|
|
12
|
+
400 it returns, same posture as the rest of this CLI not re-validating
|
|
13
|
+
what a real endpoint already validates server-side.
|
|
14
|
+
|
|
15
|
+
Deliberately no `config list` / `config unset`: the backend has no
|
|
16
|
+
delete-a-single-config-key operation for arbitrary config (unlike `env`,
|
|
17
|
+
which has a real DELETE route), so there is nothing for either to wrap.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
import typer
|
|
26
|
+
|
|
27
|
+
from . import api
|
|
28
|
+
from .cmd_shared import require_link_and_session
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="config",
|
|
32
|
+
help="Manage the linked project's config (set, get).",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
add_completion=False,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command("set")
|
|
39
|
+
def config_set_cmd(
|
|
40
|
+
key: str = typer.Argument(..., help="Config key (must be on the backend's allowlist)."),
|
|
41
|
+
value: str = typer.Argument(..., help="Config value."),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Set one key on the linked project's config.
|
|
44
|
+
|
|
45
|
+
Requires `mozbridge link` and an active login session, same as `env
|
|
46
|
+
set`. The real endpoint (PUT .../projects/{id}) merges this key into
|
|
47
|
+
the project's existing config — it never touches any other key — and
|
|
48
|
+
rejects a key that isn't on the backend's config allowlist with a 400
|
|
49
|
+
surfaced here verbatim (e.g. "Unknown or privileged config keys
|
|
50
|
+
rejected: <key>").
|
|
51
|
+
|
|
52
|
+
The confirmation reads the value back off the response's `config` dict
|
|
53
|
+
rather than echoing what was typed, so it reflects what the backend
|
|
54
|
+
actually stored.
|
|
55
|
+
"""
|
|
56
|
+
cwd = Path.cwd()
|
|
57
|
+
|
|
58
|
+
with httpx.Client(timeout=30.0) as client:
|
|
59
|
+
current_link, active_session = require_link_and_session(client, cwd)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
project = api.set_project_config(
|
|
63
|
+
client,
|
|
64
|
+
active_session.access_token,
|
|
65
|
+
current_link.org_id,
|
|
66
|
+
current_link.project_id,
|
|
67
|
+
key,
|
|
68
|
+
value,
|
|
69
|
+
)
|
|
70
|
+
except api.ApiError as exc:
|
|
71
|
+
typer.echo(str(exc), err=True)
|
|
72
|
+
raise typer.Exit(code=1)
|
|
73
|
+
|
|
74
|
+
stored_value = (project.get("config") or {}).get(key)
|
|
75
|
+
typer.echo(f"Set {key} = {stored_value}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command("get")
|
|
79
|
+
def config_get_cmd(
|
|
80
|
+
key: str = typer.Argument(..., help="Config key to read."),
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Print the current value of one key on the linked project's config.
|
|
83
|
+
|
|
84
|
+
Requires `mozbridge link` and an active login session. This is a
|
|
85
|
+
mirror of reality, not a gatekeeper: it does NOT validate `key`
|
|
86
|
+
against the backend's config allowlist, so it will happily print a
|
|
87
|
+
key that isn't (or is no longer) allowlisted, e.g. a value left over
|
|
88
|
+
from before a key was removed from it. A key that is absent from the
|
|
89
|
+
project's config prints a clear "not set" message, never a raw
|
|
90
|
+
`None`/`null`.
|
|
91
|
+
"""
|
|
92
|
+
cwd = Path.cwd()
|
|
93
|
+
|
|
94
|
+
with httpx.Client(timeout=30.0) as client:
|
|
95
|
+
current_link, active_session = require_link_and_session(client, cwd)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
project = api.get_project(
|
|
99
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
100
|
+
)
|
|
101
|
+
except api.ApiError as exc:
|
|
102
|
+
typer.echo(str(exc), err=True)
|
|
103
|
+
raise typer.Exit(code=1)
|
|
104
|
+
|
|
105
|
+
project_config = project.get("config") or {}
|
|
106
|
+
if key not in project_config:
|
|
107
|
+
typer.echo(f"{key} is not set.")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
typer.echo(f"{key} = {project_config[key]}")
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
"""Mozbridge CLI: login / whoami / logout / link / build / publish / diff / status /
|
|
2
|
-
deploy / rollback / logs / env / registry / secrets / token / doctor.
|
|
2
|
+
deploy / rollback / logs / env / registry / secrets / config / token / doctor.
|
|
3
3
|
|
|
4
4
|
macOS-only for v1 (no Windows code paths).
|
|
5
5
|
|
|
6
6
|
This module is a thin composition root: the root Typer `app` is assembled
|
|
7
7
|
from the command modules below, each of which owns one feature area's
|
|
8
8
|
command definitions (see cmd_auth.py, cmd_projects.py, cmd_publish.py,
|
|
9
|
-
cmd_deploy.py, cmd_env.py, cmd_registry.py, cmd_secrets.py,
|
|
10
|
-
cmd_doctor.py).
|
|
9
|
+
cmd_deploy.py, cmd_env.py, cmd_registry.py, cmd_secrets.py, cmd_config.py,
|
|
10
|
+
cmd_token.py, cmd_doctor.py).
|
|
11
11
|
`app.add_typer(<module>.app)`, called with no explicit `name`, merges a
|
|
12
12
|
nameless sub-Typer's commands flatly into `app` (confirmed against Typer's
|
|
13
13
|
real behavior — see cmd_*.py's own module-level Typer() calls, none of
|
|
14
14
|
which pass `name=` except cmd_env's/cmd_registry's/cmd_secrets's/
|
|
15
|
-
cmd_token's, which is why `env`/`registry`/`secrets
|
|
16
|
-
command groups while `doctor` — a single flat
|
|
17
|
-
`login`/`whoami`/`logout` — merges directly into
|
|
18
|
-
mozbridge_cli.main import app` continues to expose every
|
|
19
|
-
before.
|
|
15
|
+
cmd_config's/cmd_token's, which is why `env`/`registry`/`secrets`/
|
|
16
|
+
`config`/`token` stay nested command groups while `doctor` — a single flat
|
|
17
|
+
command, same shape as `login`/`whoami`/`logout` — merges directly into
|
|
18
|
+
`app`). `from mozbridge_cli.main import app` continues to expose every
|
|
19
|
+
command exactly as before.
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
@@ -25,6 +25,7 @@ import typer
|
|
|
25
25
|
|
|
26
26
|
from . import (
|
|
27
27
|
cmd_auth,
|
|
28
|
+
cmd_config,
|
|
28
29
|
cmd_deploy,
|
|
29
30
|
cmd_doctor,
|
|
30
31
|
cmd_env,
|
|
@@ -39,7 +40,7 @@ app = typer.Typer(
|
|
|
39
40
|
name="mozbridge",
|
|
40
41
|
help=(
|
|
41
42
|
"Mozbridge platform CLI: login, whoami, logout, link, build, publish, diff, "
|
|
42
|
-
"status, deploy, rollback, logs, env, registry, secrets, token, doctor."
|
|
43
|
+
"status, deploy, rollback, logs, env, registry, secrets, config, token, doctor."
|
|
43
44
|
),
|
|
44
45
|
no_args_is_help=True,
|
|
45
46
|
add_completion=False,
|
|
@@ -52,6 +53,7 @@ app.add_typer(cmd_deploy.app)
|
|
|
52
53
|
app.add_typer(cmd_env.app, name="env")
|
|
53
54
|
app.add_typer(cmd_registry.app, name="registry")
|
|
54
55
|
app.add_typer(cmd_secrets.app, name="secrets")
|
|
56
|
+
app.add_typer(cmd_config.app, name="config")
|
|
55
57
|
app.add_typer(cmd_token.app, name="token")
|
|
56
58
|
app.add_typer(cmd_doctor.app)
|
|
57
59
|
|
|
@@ -0,0 +1,244 @@
|
|
|
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="web-app"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- config set -------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_config_set_without_link_gives_clear_error(tmp_path, monkeypatch):
|
|
34
|
+
monkeypatch.chdir(tmp_path)
|
|
35
|
+
_seed_session()
|
|
36
|
+
|
|
37
|
+
result = runner.invoke(app, ["config", "set", "app_compose_file", "compose.custom.yml"])
|
|
38
|
+
|
|
39
|
+
assert result.exit_code == 1
|
|
40
|
+
assert "mozbridge link" in result.output
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_config_set_without_login_gives_clear_error(tmp_path, monkeypatch):
|
|
44
|
+
monkeypatch.chdir(tmp_path)
|
|
45
|
+
_seed_link(tmp_path)
|
|
46
|
+
|
|
47
|
+
result = runner.invoke(app, ["config", "set", "app_compose_file", "compose.custom.yml"])
|
|
48
|
+
|
|
49
|
+
assert result.exit_code == 1
|
|
50
|
+
assert "mozbridge login" in result.output
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_config_set_happy_path_puts_correct_body_and_prints_confirmed_value(tmp_path, monkeypatch):
|
|
54
|
+
monkeypatch.chdir(tmp_path)
|
|
55
|
+
_seed_session()
|
|
56
|
+
_seed_link(tmp_path)
|
|
57
|
+
|
|
58
|
+
with respx.mock:
|
|
59
|
+
route = respx.put(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
|
|
60
|
+
return_value=httpx.Response(
|
|
61
|
+
200,
|
|
62
|
+
json={
|
|
63
|
+
"id": 10,
|
|
64
|
+
"name": "web-app",
|
|
65
|
+
"slug": "web-app",
|
|
66
|
+
"status": "active",
|
|
67
|
+
"config": {"app_compose_file": "compose.custom.yml"},
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
result = runner.invoke(app, ["config", "set", "app_compose_file", "compose.custom.yml"])
|
|
73
|
+
|
|
74
|
+
assert result.exit_code == 0, result.output
|
|
75
|
+
assert route.called
|
|
76
|
+
assert route.calls.last.request.headers["authorization"] == "Bearer AT-valid"
|
|
77
|
+
assert route.calls.last.request.headers["x-organization-id"] == "1"
|
|
78
|
+
body = json.loads(route.calls.last.request.content)
|
|
79
|
+
assert body == {"config": {"app_compose_file": "compose.custom.yml"}}
|
|
80
|
+
|
|
81
|
+
assert "app_compose_file" in result.output
|
|
82
|
+
assert "compose.custom.yml" in result.output
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_config_set_reads_confirmation_back_from_response_not_input(tmp_path, monkeypatch):
|
|
86
|
+
"""If the backend does anything to the value (e.g. trims it), the
|
|
87
|
+
confirmation must reflect what's actually stored, not what was typed."""
|
|
88
|
+
monkeypatch.chdir(tmp_path)
|
|
89
|
+
_seed_session()
|
|
90
|
+
_seed_link(tmp_path)
|
|
91
|
+
|
|
92
|
+
with respx.mock:
|
|
93
|
+
respx.put(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
|
|
94
|
+
return_value=httpx.Response(
|
|
95
|
+
200,
|
|
96
|
+
json={"id": 10, "config": {"backend_port": "8000"}},
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
result = runner.invoke(app, ["config", "set", "backend_port", " 8000 "])
|
|
101
|
+
|
|
102
|
+
assert result.exit_code == 0, result.output
|
|
103
|
+
assert "8000" in result.output
|
|
104
|
+
assert " 8000 " not in result.output
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_config_set_unknown_key_surfaces_backend_detail_message(tmp_path, monkeypatch):
|
|
108
|
+
"""The real PUT endpoint rejects the WHOLE request with a 400 for any key
|
|
109
|
+
not on schemas.PROJECT_CONFIG_ALLOWLIST — this exact message is the
|
|
110
|
+
actual useful information and must reach the terminal verbatim."""
|
|
111
|
+
monkeypatch.chdir(tmp_path)
|
|
112
|
+
_seed_session()
|
|
113
|
+
_seed_link(tmp_path)
|
|
114
|
+
|
|
115
|
+
with respx.mock:
|
|
116
|
+
respx.put(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
|
|
117
|
+
return_value=httpx.Response(
|
|
118
|
+
400, json={"detail": "Unknown or privileged config keys rejected: totally_made_up_key"}
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
result = runner.invoke(app, ["config", "set", "totally_made_up_key", "x"])
|
|
123
|
+
|
|
124
|
+
assert result.exit_code == 1
|
|
125
|
+
assert "Unknown or privileged config keys rejected: totally_made_up_key" in result.output
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_config_set_empty_string_value_is_passed_through(tmp_path, monkeypatch):
|
|
129
|
+
"""No client-side special-casing of an empty-string value — matches
|
|
130
|
+
env set's own posture of not second-guessing values."""
|
|
131
|
+
monkeypatch.chdir(tmp_path)
|
|
132
|
+
_seed_session()
|
|
133
|
+
_seed_link(tmp_path)
|
|
134
|
+
|
|
135
|
+
with respx.mock:
|
|
136
|
+
route = respx.put(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
|
|
137
|
+
return_value=httpx.Response(200, json={"id": 10, "config": {"backend_command": ""}})
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
result = runner.invoke(app, ["config", "set", "backend_command", ""])
|
|
141
|
+
|
|
142
|
+
assert result.exit_code == 0, result.output
|
|
143
|
+
body = json.loads(route.calls.last.request.content)
|
|
144
|
+
assert body == {"config": {"backend_command": ""}}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --- config get -------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_config_get_without_link_gives_clear_error(tmp_path, monkeypatch):
|
|
151
|
+
monkeypatch.chdir(tmp_path)
|
|
152
|
+
_seed_session()
|
|
153
|
+
|
|
154
|
+
result = runner.invoke(app, ["config", "get", "app_compose_file"])
|
|
155
|
+
|
|
156
|
+
assert result.exit_code == 1
|
|
157
|
+
assert "mozbridge link" in result.output
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_config_get_without_login_gives_clear_error(tmp_path, monkeypatch):
|
|
161
|
+
monkeypatch.chdir(tmp_path)
|
|
162
|
+
_seed_link(tmp_path)
|
|
163
|
+
|
|
164
|
+
result = runner.invoke(app, ["config", "get", "app_compose_file"])
|
|
165
|
+
|
|
166
|
+
assert result.exit_code == 1
|
|
167
|
+
assert "mozbridge login" in result.output
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_config_get_set_key_prints_its_value(tmp_path, monkeypatch):
|
|
171
|
+
monkeypatch.chdir(tmp_path)
|
|
172
|
+
_seed_session()
|
|
173
|
+
_seed_link(tmp_path)
|
|
174
|
+
|
|
175
|
+
with respx.mock:
|
|
176
|
+
route = respx.get(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
|
|
177
|
+
return_value=httpx.Response(
|
|
178
|
+
200,
|
|
179
|
+
json={"id": 10, "config": {"app_compose_file": "compose.custom.yml"}},
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
result = runner.invoke(app, ["config", "get", "app_compose_file"])
|
|
184
|
+
|
|
185
|
+
assert result.exit_code == 0, result.output
|
|
186
|
+
assert route.called
|
|
187
|
+
assert route.calls.last.request.headers["authorization"] == "Bearer AT-valid"
|
|
188
|
+
assert route.calls.last.request.headers["x-organization-id"] == "1"
|
|
189
|
+
assert "compose.custom.yml" in result.output
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_config_get_absent_key_prints_not_set_not_none(tmp_path, monkeypatch):
|
|
193
|
+
monkeypatch.chdir(tmp_path)
|
|
194
|
+
_seed_session()
|
|
195
|
+
_seed_link(tmp_path)
|
|
196
|
+
|
|
197
|
+
with respx.mock:
|
|
198
|
+
respx.get(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
|
|
199
|
+
return_value=httpx.Response(200, json={"id": 10, "config": {}})
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
result = runner.invoke(app, ["config", "get", "backend_port"])
|
|
203
|
+
|
|
204
|
+
assert result.exit_code == 0, result.output
|
|
205
|
+
assert "not set" in result.output
|
|
206
|
+
assert "None" not in result.output
|
|
207
|
+
assert "null" not in result.output
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_config_get_does_not_validate_key_against_any_allowlist(tmp_path, monkeypatch):
|
|
211
|
+
"""A key that's actually present but not (or no longer) on the backend's
|
|
212
|
+
allowlist is still read and printed — this command mirrors reality, it
|
|
213
|
+
is not a client-side gatekeeper."""
|
|
214
|
+
monkeypatch.chdir(tmp_path)
|
|
215
|
+
_seed_session()
|
|
216
|
+
_seed_link(tmp_path)
|
|
217
|
+
|
|
218
|
+
with respx.mock:
|
|
219
|
+
respx.get(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
|
|
220
|
+
return_value=httpx.Response(
|
|
221
|
+
200, json={"id": 10, "config": {"leftover_unlisted_key": "still-here"}}
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
result = runner.invoke(app, ["config", "get", "leftover_unlisted_key"])
|
|
226
|
+
|
|
227
|
+
assert result.exit_code == 0, result.output
|
|
228
|
+
assert "still-here" in result.output
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_config_get_api_failure_surfaces_clear_error(tmp_path, monkeypatch):
|
|
232
|
+
monkeypatch.chdir(tmp_path)
|
|
233
|
+
_seed_session()
|
|
234
|
+
_seed_link(tmp_path)
|
|
235
|
+
|
|
236
|
+
with respx.mock:
|
|
237
|
+
respx.get(f"{config.API_BASE_URL}{config.PROJECTS_PATH}/10").mock(
|
|
238
|
+
return_value=httpx.Response(500, json={"detail": "Database temporarily unreachable"})
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
result = runner.invoke(app, ["config", "get", "app_compose_file"])
|
|
242
|
+
|
|
243
|
+
assert result.exit_code == 1
|
|
244
|
+
assert "Database temporarily unreachable" 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
|