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.
- mozbridge_cli/__init__.py +3 -0
- mozbridge_cli/__main__.py +4 -0
- mozbridge_cli/api.py +274 -0
- mozbridge_cli/auth.py +179 -0
- mozbridge_cli/build.py +108 -0
- mozbridge_cli/compose.py +181 -0
- mozbridge_cli/config.py +76 -0
- mozbridge_cli/link.py +90 -0
- mozbridge_cli/local_build.py +140 -0
- mozbridge_cli/main.py +821 -0
- mozbridge_cli/runtime_secrets.py +140 -0
- mozbridge_cli/session.py +156 -0
- mozbridge_cli-0.1.0.dist-info/METADATA +351 -0
- mozbridge_cli-0.1.0.dist-info/RECORD +16 -0
- mozbridge_cli-0.1.0.dist-info/WHEEL +4 -0
- mozbridge_cli-0.1.0.dist-info/entry_points.txt +2 -0
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)
|