mozbridge-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """Mozbridge CLI — login / whoami / logout against the real Mozbridge platform."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .main import app_main
2
+
3
+ if __name__ == "__main__":
4
+ app_main()
mozbridge_cli/api.py ADDED
@@ -0,0 +1,274 @@
1
+ """Thin wrappers around the real, already-shipped Mozbridge REST API.
2
+
3
+ Used by `link`, `build`, and `publish`. No new backend endpoints — every
4
+ function here calls a route that already exists in
5
+ backend/app/features/{identity,projects}/router.py.
6
+
7
+ Auth note (resolved for this slice): `require_deploy_scope` —
8
+ `backend/app/features/identity/token_scopes.py` — is built from
9
+ `require_token_scopes(...)`, whose check body is:
10
+
11
+ def _check(user: User = Depends(get_current_user)) -> None:
12
+ if not is_service_identity(user):
13
+ return
14
+ ...
15
+
16
+ `is_service_identity` is only true for `mz_`-prefixed ServiceTokens (it
17
+ checks `_token_payload.is_service_token` / the synthetic `svc:system` user).
18
+ A plain human Logto access token — exactly what `mozbridge login` already
19
+ caches — makes `is_service_identity` False, so `require_deploy_scope` is a
20
+ complete no-op for it. The only real gates a human token hits are
21
+ `get_current_org` (needs `X-Organization-Id` + org membership via Permify)
22
+ and `get_valid_project` (project must belong to that org). Both are ordinary
23
+ membership checks, not a service-token requirement. So: yes, the cached
24
+ human session token works against create_source_upload/trigger_build today,
25
+ with no ServiceToken minting needed.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import httpx
31
+
32
+ from . import config
33
+
34
+
35
+ class ApiError(Exception):
36
+ """Raised when the Mozbridge API returns a response we can't recover from."""
37
+
38
+
39
+ def _headers(access_token: str, org_id: int | None = None) -> dict[str, str]:
40
+ headers = {"Authorization": f"Bearer {access_token}"}
41
+ if org_id is not None:
42
+ headers["X-Organization-Id"] = str(org_id)
43
+ return headers
44
+
45
+
46
+ def _raise_for_status(resp: httpx.Response, action: str) -> None:
47
+ if 200 <= resp.status_code < 300:
48
+ return
49
+ try:
50
+ detail = resp.json().get("detail")
51
+ except ValueError:
52
+ detail = None
53
+ detail = detail or resp.text or f"HTTP {resp.status_code}"
54
+ raise ApiError(f"Could not {action}: {detail}")
55
+
56
+
57
+ def list_organizations(client: httpx.Client, access_token: str) -> list[dict]:
58
+ """GET /api/v1/organizations — orgs the current human session can see."""
59
+ url = f"{config.API_BASE_URL}{config.ORGANIZATIONS_PATH}"
60
+ try:
61
+ resp = client.get(url, headers=_headers(access_token))
62
+ except httpx.HTTPError as exc:
63
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
64
+ _raise_for_status(resp, "list organizations")
65
+ return resp.json()
66
+
67
+
68
+ def list_projects(client: httpx.Client, access_token: str, org_id: int) -> list[dict]:
69
+ """GET /api/v1/projects?organization_id=... — projects visible in org_id."""
70
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}"
71
+ try:
72
+ resp = client.get(
73
+ url,
74
+ params={"organization_id": org_id},
75
+ headers=_headers(access_token, org_id),
76
+ )
77
+ except httpx.HTTPError as exc:
78
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
79
+ _raise_for_status(resp, "list projects")
80
+ return resp.json()
81
+
82
+
83
+ def create_source_upload(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
84
+ """POST /api/v1/projects/{project_id}/source-uploads.
85
+
86
+ Returns {upload_id, put_url, expires_at, max_bytes, instructions}.
87
+ """
88
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/source-uploads"
89
+ try:
90
+ resp = client.post(url, headers=_headers(access_token, org_id))
91
+ except httpx.HTTPError as exc:
92
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
93
+ _raise_for_status(resp, "create a source upload session")
94
+ return resp.json()
95
+
96
+
97
+ def put_source_upload_bytes(
98
+ client: httpx.Client,
99
+ access_token: str,
100
+ org_id: int,
101
+ put_url: str,
102
+ data: bytes,
103
+ ) -> None:
104
+ """PUT the zip bytes to the put_url returned by create_source_upload.
105
+
106
+ Per the endpoint's own `instructions` string: Authorization: Bearer
107
+ <token> and X-Organization-Id: <org_id> are both required.
108
+ """
109
+ try:
110
+ resp = client.put(
111
+ put_url,
112
+ content=data,
113
+ headers={**_headers(access_token, org_id), "Content-Type": "application/zip"},
114
+ )
115
+ except httpx.HTTPError as exc:
116
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
117
+ _raise_for_status(resp, "upload the build source")
118
+
119
+
120
+ def get_project(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
121
+ """GET /api/v1/projects/{project_id} — schemas.Project (name/slug/status/config/...)."""
122
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}"
123
+ try:
124
+ resp = client.get(url, headers=_headers(access_token, org_id))
125
+ except httpx.HTTPError as exc:
126
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
127
+ _raise_for_status(resp, "fetch the project")
128
+ return resp.json()
129
+
130
+
131
+ def list_project_deployments(
132
+ client: httpx.Client, access_token: str, org_id: int, project_id: int
133
+ ) -> list[dict]:
134
+ """GET /api/v1/projects/{project_id}/deployments — list[schemas.ProjectDeployment].
135
+
136
+ Backend orders these newest-first (ProjectService.list_project_deployments
137
+ sorts by deployed_at.desc()), so the caller can just slice the front of
138
+ the list for "N most recent" without re-sorting client-side.
139
+ """
140
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/deployments"
141
+ try:
142
+ resp = client.get(url, headers=_headers(access_token, org_id))
143
+ except httpx.HTTPError as exc:
144
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
145
+ _raise_for_status(resp, "list deployments")
146
+ return resp.json()
147
+
148
+
149
+ def rollback_project(client: httpx.Client, access_token: str, org_id: int, project_id: int) -> dict:
150
+ """POST /api/v1/projects/{project_id}/rollback — roll back to the previous deployment.
151
+
152
+ No request body is sent: router.py:694 (rollback_project_or_site) takes
153
+ an optional schemas.SiteRollbackRequest whose own defaults
154
+ (to="previous", environment="prod") are exactly what an un-targeted
155
+ `mozbridge rollback` should ask for, and it has no `confirm` field to
156
+ satisfy — confirmation gating for this route is CLI/MCP-side only.
157
+ Omitting the body here keeps this the same request an empty-body POST
158
+ has always been, same pattern as trigger_build's optional overrides in
159
+ this module.
160
+
161
+ Returns {"status": "enqueued", "action": "rollback", "task_id": ...} for
162
+ app-shaped projects, or a SitePublishResponse-shaped body (task_id,
163
+ revision, status, preview_url) for site-shaped ones — both carry the
164
+ operation id under "task_id".
165
+ """
166
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/rollback"
167
+ try:
168
+ resp = client.post(url, headers=_headers(access_token, org_id))
169
+ except httpx.HTTPError as exc:
170
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
171
+ _raise_for_status(resp, "roll back the project")
172
+ return resp.json()
173
+
174
+
175
+ def rollback_project_to(
176
+ client: httpx.Client, access_token: str, org_id: int, project_id: int, deployment_id: int
177
+ ) -> dict:
178
+ """POST /api/v1/projects/{project_id}/rollback/{deployment_id} — roll back to one specific deployment.
179
+
180
+ router.py:2964 (rollback_to_specific) takes only the deployment_id path
181
+ param — no request body, no `confirm` field server-side either.
182
+
183
+ Returns {"status": "enqueued", "action": "rollback", "target_task": ...}
184
+ — note the operation id key here is "target_task", not "task_id" like
185
+ every other trigger-an-operation response in this module.
186
+ """
187
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/rollback/{deployment_id}"
188
+ try:
189
+ resp = client.post(url, headers=_headers(access_token, org_id))
190
+ except httpx.HTTPError as exc:
191
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
192
+ _raise_for_status(resp, "roll back the project")
193
+ return resp.json()
194
+
195
+
196
+ def trigger_build(
197
+ client: httpx.Client,
198
+ access_token: str,
199
+ org_id: int,
200
+ project_id: int,
201
+ upload_id: str,
202
+ *,
203
+ image_name: str | None = None,
204
+ context_path: str | None = None,
205
+ dockerfile_path: str | None = None,
206
+ ) -> dict:
207
+ """POST /api/v1/projects/{project_id}/build with source_type='upload'.
208
+
209
+ image_name/context_path/dockerfile_path are optional per-call
210
+ overrides — backend/app/features/projects/schemas.py:BuildRequest and
211
+ BuildService._trigger_build_from_upload already accept all three
212
+ independently, no backend changes needed. Omitted fields are left out
213
+ of the request body entirely (rather than sent as explicit nulls) so
214
+ the backend's own defaults apply unchanged — this keeps the
215
+ single-component call byte-for-byte the same request as before these
216
+ kwargs existed.
217
+
218
+ Returns {"status": "enqueued", "action": "build", "task_id": ...}.
219
+ """
220
+ payload: dict = {"source_type": "upload", "upload_id": upload_id}
221
+ if image_name is not None:
222
+ payload["image_name"] = image_name
223
+ if context_path is not None:
224
+ payload["context_path"] = context_path
225
+ if dockerfile_path is not None:
226
+ payload["dockerfile_path"] = dockerfile_path
227
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/build"
228
+ try:
229
+ resp = client.post(
230
+ url,
231
+ json=payload,
232
+ headers=_headers(access_token, org_id),
233
+ )
234
+ except httpx.HTTPError as exc:
235
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
236
+ _raise_for_status(resp, "trigger the build")
237
+ return resp.json()
238
+
239
+
240
+ def trigger_prebuilt_build(
241
+ client: httpx.Client,
242
+ access_token: str,
243
+ org_id: int,
244
+ project_id: int,
245
+ image_name: str,
246
+ image_digest: str,
247
+ ) -> dict:
248
+ """POST /api/v1/projects/{project_id}/build with source_type='prebuilt'.
249
+
250
+ Used by `mozbridge publish --local`: the image was already built and
251
+ pushed by the CLI itself (see local_build.py), so this call carries no
252
+ upload_id — only the image_name/image_digest for the backend to verify
253
+ against the real registry and register
254
+ (BuildService._trigger_build_from_prebuilt). Uses the caller's regular
255
+ LOGIN session access_token, exactly like `trigger_build` above — never
256
+ the separate MOZBRIDGE_BUILD_TOKEN used only for
257
+ runtime_secrets.fetch_runtime_secrets.
258
+
259
+ Returns {"status": "enqueued", "action": "build", "task_id": ...} same
260
+ shape as trigger_build, though task_id is always null here — a prebuilt
261
+ registration has no Celery task to dispatch, it's already done.
262
+ """
263
+ payload = {"source_type": "prebuilt", "image_name": image_name, "image_digest": image_digest}
264
+ url = f"{config.API_BASE_URL}{config.PROJECTS_PATH}/{project_id}/build"
265
+ try:
266
+ resp = client.post(
267
+ url,
268
+ json=payload,
269
+ headers=_headers(access_token, org_id),
270
+ )
271
+ except httpx.HTTPError as exc:
272
+ raise ApiError(f"Could not reach Mozbridge API: {exc}") from exc
273
+ _raise_for_status(resp, "register the prebuilt image")
274
+ return resp.json()
mozbridge_cli/auth.py ADDED
@@ -0,0 +1,179 @@
1
+ """RFC 8628 OAuth 2.0 Device Authorization Grant against Logto.
2
+
3
+ Endpoints and behavior confirmed against Logto's own docs / RFC 8628:
4
+
5
+ POST {LOGTO_ENDPOINT}/oidc/device/auth
6
+ body: client_id=<id>&scope=openid offline_access profile
7
+ -> device_code, user_code, verification_uri, verification_uri_complete,
8
+ expires_in, interval (default 5s if absent)
9
+
10
+ POST {LOGTO_ENDPOINT}/oidc/token
11
+ body: client_id=<id>
12
+ &grant_type=urn:ietf:params:oauth:grant-type:device_code
13
+ &device_code=<code>
14
+ -> access_token, id_token, refresh_token, token_type, expires_in, scope
15
+
16
+ Polling errors (standard OAuth device-flow codes):
17
+ authorization_pending -> keep polling at the current interval
18
+ slow_down -> increase the interval and keep polling
19
+ expired_token -> give up, tell the user to re-run login
20
+ access_denied -> user declined, give up
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import time
26
+ from collections.abc import Callable
27
+ from dataclasses import dataclass
28
+
29
+ import httpx
30
+
31
+ from . import config
32
+
33
+ GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
34
+ DEFAULT_POLL_INTERVAL = 5
35
+ SLOW_DOWN_INCREMENT = 5
36
+
37
+
38
+ class DeviceLoginError(Exception):
39
+ """Raised when the device flow cannot succeed and the user must re-run login."""
40
+
41
+
42
+ class AccessDenied(DeviceLoginError):
43
+ pass
44
+
45
+
46
+ class ExpiredToken(DeviceLoginError):
47
+ pass
48
+
49
+
50
+ class RefreshError(Exception):
51
+ """Raised when a refresh_token exchange fails — session is fully expired."""
52
+
53
+
54
+ @dataclass
55
+ class DeviceAuthorization:
56
+ device_code: str
57
+ user_code: str
58
+ verification_uri: str
59
+ verification_uri_complete: str | None
60
+ expires_in: int
61
+ interval: int
62
+
63
+
64
+ def start_device_authorization(client: httpx.Client) -> DeviceAuthorization:
65
+ resp = client.post(
66
+ config.DEVICE_AUTH_URL,
67
+ data={"client_id": config.LOGTO_CLIENT_ID, "scope": config.DEVICE_SCOPE},
68
+ )
69
+ resp.raise_for_status()
70
+ data = resp.json()
71
+ return DeviceAuthorization(
72
+ device_code=data["device_code"],
73
+ user_code=data["user_code"],
74
+ verification_uri=data["verification_uri"],
75
+ verification_uri_complete=data.get("verification_uri_complete"),
76
+ expires_in=int(data.get("expires_in", 600)),
77
+ interval=int(data.get("interval") or DEFAULT_POLL_INTERVAL),
78
+ )
79
+
80
+
81
+ def _poll_once(client: httpx.Client, device_code: str) -> dict | None:
82
+ """One poll of the token endpoint. Returns the token response on success,
83
+ None to keep polling (caller decides how long to wait), or raises on a
84
+ terminal error.
85
+ """
86
+ resp = client.post(
87
+ config.TOKEN_URL,
88
+ data={
89
+ "client_id": config.LOGTO_CLIENT_ID,
90
+ "grant_type": GRANT_TYPE_DEVICE_CODE,
91
+ "device_code": device_code,
92
+ },
93
+ )
94
+ if resp.status_code == 200:
95
+ return resp.json()
96
+
97
+ try:
98
+ body = resp.json()
99
+ except ValueError:
100
+ resp.raise_for_status()
101
+ raise DeviceLoginError(f"Unexpected response from token endpoint: {resp.status_code}")
102
+
103
+ error = body.get("error")
104
+ if error == "authorization_pending":
105
+ return None
106
+ if error == "slow_down":
107
+ raise _SlowDown()
108
+ if error == "expired_token":
109
+ raise ExpiredToken("The login request expired. Run `mozbridge login` again.")
110
+ if error == "access_denied":
111
+ raise AccessDenied("Login was declined.")
112
+
113
+ # Any other error is treated as terminal — surface it plainly.
114
+ detail = body.get("error_description") or error or f"HTTP {resp.status_code}"
115
+ raise DeviceLoginError(f"Login failed: {detail}")
116
+
117
+
118
+ class _SlowDown(Exception):
119
+ """Internal signal: increase the poll interval and keep polling."""
120
+
121
+
122
+ def poll_for_token(
123
+ client: httpx.Client,
124
+ authorization: DeviceAuthorization,
125
+ *,
126
+ sleep: Callable[[float], None] | None = None,
127
+ now: Callable[[], float] | None = None,
128
+ ) -> dict:
129
+ """Poll the token endpoint per RFC 8628 until success or a terminal error.
130
+
131
+ Raises ExpiredToken, AccessDenied, or DeviceLoginError on failure.
132
+
133
+ `sleep`/`now` are resolved from the `time` module at call time (rather
134
+ than bound as default-argument values) so tests can monkeypatch
135
+ `time.sleep` / `time.time` globally instead of having to pass fakes
136
+ through every caller, including the CLI's own `login` command.
137
+ """
138
+ do_sleep = sleep or time.sleep
139
+ do_now = now or time.time
140
+
141
+ interval = authorization.interval or DEFAULT_POLL_INTERVAL
142
+ deadline = do_now() + authorization.expires_in
143
+
144
+ while True:
145
+ if do_now() >= deadline:
146
+ raise ExpiredToken("The login request expired. Run `mozbridge login` again.")
147
+
148
+ do_sleep(interval)
149
+
150
+ try:
151
+ result = _poll_once(client, authorization.device_code)
152
+ except _SlowDown:
153
+ interval += SLOW_DOWN_INCREMENT
154
+ continue
155
+
156
+ if result is not None:
157
+ return result
158
+ # authorization_pending: loop and poll again after `interval`.
159
+
160
+
161
+ def refresh_access_token(client: httpx.Client, refresh_token: str) -> dict:
162
+ """Exchange a refresh_token for a new token set. Raises RefreshError on failure."""
163
+ resp = client.post(
164
+ config.TOKEN_URL,
165
+ data={
166
+ "grant_type": "refresh_token",
167
+ "refresh_token": refresh_token,
168
+ "client_id": config.LOGTO_CLIENT_ID,
169
+ },
170
+ )
171
+ if resp.status_code == 200:
172
+ return resp.json()
173
+
174
+ try:
175
+ body = resp.json()
176
+ detail = body.get("error_description") or body.get("error") or f"HTTP {resp.status_code}"
177
+ except ValueError:
178
+ detail = f"HTTP {resp.status_code}"
179
+ raise RefreshError(f"Session refresh failed: {detail}")
mozbridge_cli/build.py ADDED
@@ -0,0 +1,108 @@
1
+ """Zip the current directory into a build artifact (`mozbridge build`).
2
+
3
+ This is a dry-run-adjacent primitive: it produces the zip a `publish` would
4
+ upload, without uploading it, so a later `mozbridge diff`/dry-run command
5
+ can build on it. It does not talk to the network.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import os
12
+ import tempfile
13
+ import zipfile
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ # Always excluded, regardless of what .gitignore says.
18
+ DEFAULT_IGNORE_DIRS = {
19
+ ".git",
20
+ ".mozbridge",
21
+ "__pycache__",
22
+ "node_modules",
23
+ ".venv",
24
+ "venv",
25
+ ".pytest_cache",
26
+ ".mypy_cache",
27
+ ".ruff_cache",
28
+ "dist",
29
+ "build",
30
+ ".next",
31
+ ".turbo",
32
+ }
33
+ DEFAULT_IGNORE_FILE_PATTERNS = (
34
+ "*.pyc",
35
+ "*.pyo",
36
+ ".DS_Store",
37
+ )
38
+
39
+
40
+ @dataclass
41
+ class BuildResult:
42
+ zip_path: Path
43
+ size_bytes: int
44
+ file_count: int
45
+
46
+
47
+ def _load_gitignore_patterns(cwd: Path) -> list[str]:
48
+ """Best-effort read of `cwd`'s own .gitignore, one glob pattern per line.
49
+
50
+ This is a simple glob matcher, not a full git-compatible parser (no
51
+ negation, no anchoring semantics) — good enough to keep obvious noise
52
+ (build output, coverage files, editor droppings) out of the zip.
53
+ """
54
+ gitignore = cwd / ".gitignore"
55
+ if not gitignore.exists():
56
+ return []
57
+ try:
58
+ lines = gitignore.read_text().splitlines()
59
+ except OSError:
60
+ return []
61
+ return [line.strip() for line in lines if line.strip() and not line.strip().startswith("#")]
62
+
63
+
64
+ def _matches_gitignore(rel_path: str, name: str, patterns: list[str]) -> bool:
65
+ for pattern in patterns:
66
+ p = pattern.rstrip("/")
67
+ if fnmatch.fnmatch(name, p) or fnmatch.fnmatch(rel_path, p):
68
+ return True
69
+ return False
70
+
71
+
72
+ def build_zip(cwd: Path, *, dest: Path | None = None) -> BuildResult:
73
+ """Zip `cwd` into `dest` (a fresh temp file if not given).
74
+
75
+ Excludes DEFAULT_IGNORE_DIRS/DEFAULT_IGNORE_FILE_PATTERNS unconditionally,
76
+ plus anything `cwd`'s own .gitignore matches, best effort. The caller
77
+ owns cleanup of the returned zip_path.
78
+ """
79
+ if dest is None:
80
+ fd, tmp_name = tempfile.mkstemp(prefix="mozbridge-build-", suffix=".zip")
81
+ os.close(fd)
82
+ dest = Path(tmp_name)
83
+
84
+ gitignore_patterns = _load_gitignore_patterns(cwd)
85
+ file_count = 0
86
+
87
+ with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as zf:
88
+ for root, dirs, files in os.walk(cwd):
89
+ root_path = Path(root)
90
+ dirs[:] = [
91
+ d
92
+ for d in dirs
93
+ if d not in DEFAULT_IGNORE_DIRS
94
+ and not _matches_gitignore(str((root_path / d).relative_to(cwd)), d, gitignore_patterns)
95
+ ]
96
+ for name in files:
97
+ file_path = root_path / name
98
+ if file_path == dest:
99
+ continue
100
+ if any(fnmatch.fnmatch(name, pat) for pat in DEFAULT_IGNORE_FILE_PATTERNS):
101
+ continue
102
+ rel_str = str(file_path.relative_to(cwd))
103
+ if _matches_gitignore(rel_str, name, gitignore_patterns):
104
+ continue
105
+ zf.write(file_path, arcname=rel_str)
106
+ file_count += 1
107
+
108
+ return BuildResult(zip_path=dest, size_bytes=dest.stat().st_size, file_count=file_count)