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/main.py
ADDED
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
"""Mozbridge CLI: login / whoami / logout / link / build / publish / diff / status / rollback.
|
|
2
|
+
|
|
3
|
+
macOS-only for v1 (no Windows code paths).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from . import api, auth, config, session
|
|
15
|
+
from . import build as buildmod
|
|
16
|
+
from . import compose as composemod
|
|
17
|
+
from . import link as linkmod
|
|
18
|
+
from . import local_build as local_buildmod
|
|
19
|
+
from . import runtime_secrets as runtime_secretsmod
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="mozbridge",
|
|
23
|
+
help="Mozbridge platform CLI: login, whoami, logout, link, build, publish, diff, status, rollback.",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
add_completion=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.command("login")
|
|
30
|
+
def login_cmd() -> None:
|
|
31
|
+
"""Sign in via Logto's device authorization flow (opens a URL for you to approve)."""
|
|
32
|
+
if session.token_from_env() is not None:
|
|
33
|
+
typer.echo(
|
|
34
|
+
f"{session.TOKEN_ENV_VAR} is set — this CLI is already authenticating as that "
|
|
35
|
+
"token for every command, so an interactive `mozbridge login` doesn't apply here. "
|
|
36
|
+
f"These are mutually exclusive auth modes; unset {session.TOKEN_ENV_VAR} first if "
|
|
37
|
+
"you want to log in interactively instead.",
|
|
38
|
+
err=True,
|
|
39
|
+
)
|
|
40
|
+
raise typer.Exit(code=1)
|
|
41
|
+
|
|
42
|
+
with httpx.Client(timeout=30.0) as client:
|
|
43
|
+
try:
|
|
44
|
+
authorization = auth.start_device_authorization(client)
|
|
45
|
+
except httpx.HTTPError as exc:
|
|
46
|
+
typer.echo(f"Could not start login: {exc}", err=True)
|
|
47
|
+
raise typer.Exit(code=1)
|
|
48
|
+
|
|
49
|
+
if authorization.verification_uri_complete:
|
|
50
|
+
typer.echo(f"Open this URL to sign in:\n\n {authorization.verification_uri_complete}\n")
|
|
51
|
+
typer.echo(f"Or go to {authorization.verification_uri} and enter code: {authorization.user_code}")
|
|
52
|
+
typer.echo("Waiting for approval...")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
token_response = auth.poll_for_token(client, authorization)
|
|
56
|
+
except auth.AccessDenied as exc:
|
|
57
|
+
typer.echo(str(exc), err=True)
|
|
58
|
+
raise typer.Exit(code=1)
|
|
59
|
+
except auth.ExpiredToken as exc:
|
|
60
|
+
typer.echo(str(exc), err=True)
|
|
61
|
+
raise typer.Exit(code=1)
|
|
62
|
+
except auth.DeviceLoginError as exc:
|
|
63
|
+
typer.echo(str(exc), err=True)
|
|
64
|
+
raise typer.Exit(code=1)
|
|
65
|
+
|
|
66
|
+
new_session = session.session_from_token_response(token_response)
|
|
67
|
+
path = session.save_session(new_session)
|
|
68
|
+
typer.echo(f"Logged in. Session saved to {path}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_and_refresh_session(client: httpx.Client) -> session.Session:
|
|
72
|
+
"""Return the bearer token to use for this command.
|
|
73
|
+
|
|
74
|
+
Single choke point for auth used by every command below (whoami, link,
|
|
75
|
+
status, rollback, diff, publish's remote AND --local paths). Checked
|
|
76
|
+
BEFORE anything session-file-related, mirroring gh/flyctl/Vercel's own
|
|
77
|
+
env-var precedence: if MOZBRIDGE_TOKEN is set, it is used directly as
|
|
78
|
+
the bearer token and load_session()/save_session()/refresh-token logic
|
|
79
|
+
are never invoked — there's no on-disk session file involved in this
|
|
80
|
+
path. Otherwise, falls back to loading the cached session, refreshing
|
|
81
|
+
the access token first if expired.
|
|
82
|
+
|
|
83
|
+
Exits the process with a clear message if there is no session, or if
|
|
84
|
+
refresh fails.
|
|
85
|
+
"""
|
|
86
|
+
env_token = session.token_from_env()
|
|
87
|
+
if env_token is not None:
|
|
88
|
+
return session.Session(
|
|
89
|
+
access_token=env_token,
|
|
90
|
+
refresh_token=None,
|
|
91
|
+
id_token=None,
|
|
92
|
+
expires_at=float("inf"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
current = session.load_session()
|
|
96
|
+
if current is None:
|
|
97
|
+
typer.echo("Not logged in. Run `mozbridge login` first.", err=True)
|
|
98
|
+
raise typer.Exit(code=1)
|
|
99
|
+
|
|
100
|
+
if not current.is_expired():
|
|
101
|
+
return current
|
|
102
|
+
|
|
103
|
+
if not current.refresh_token:
|
|
104
|
+
typer.echo("Session expired and cannot be refreshed. Run `mozbridge login` again.", err=True)
|
|
105
|
+
raise typer.Exit(code=1)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
token_response = auth.refresh_access_token(client, current.refresh_token)
|
|
109
|
+
except auth.RefreshError as exc:
|
|
110
|
+
typer.echo(f"{exc} — run `mozbridge login` again.", err=True)
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
refreshed = session.session_from_token_response(token_response)
|
|
114
|
+
# Logto may omit refresh_token on rotation-less refresh — keep the old one.
|
|
115
|
+
if not refreshed.refresh_token:
|
|
116
|
+
refreshed.refresh_token = current.refresh_token
|
|
117
|
+
session.save_session(refreshed)
|
|
118
|
+
return refreshed
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command("whoami")
|
|
122
|
+
def whoami_cmd() -> None:
|
|
123
|
+
"""Show the signed-in Mozbridge identity."""
|
|
124
|
+
with httpx.Client(timeout=30.0) as client:
|
|
125
|
+
active_session = _load_and_refresh_session(client)
|
|
126
|
+
|
|
127
|
+
url = f"{config.API_BASE_URL}{config.WHOAMI_PATH}"
|
|
128
|
+
try:
|
|
129
|
+
resp = client.get(url, headers={"Authorization": f"Bearer {active_session.access_token}"})
|
|
130
|
+
except httpx.HTTPError as exc:
|
|
131
|
+
typer.echo(f"Could not reach Mozbridge API: {exc}", err=True)
|
|
132
|
+
raise typer.Exit(code=1)
|
|
133
|
+
|
|
134
|
+
if resp.status_code == 401:
|
|
135
|
+
typer.echo("Session is no longer valid. Run `mozbridge login` again.", err=True)
|
|
136
|
+
raise typer.Exit(code=1)
|
|
137
|
+
if resp.status_code != 200:
|
|
138
|
+
typer.echo(f"Mozbridge API returned {resp.status_code}: {resp.text}", err=True)
|
|
139
|
+
raise typer.Exit(code=1)
|
|
140
|
+
|
|
141
|
+
data = resp.json()
|
|
142
|
+
|
|
143
|
+
email = data.get("email") or "(no email on record)"
|
|
144
|
+
username = data.get("username")
|
|
145
|
+
full_name = data.get("full_name")
|
|
146
|
+
display = full_name or username or email
|
|
147
|
+
|
|
148
|
+
typer.echo(f"Logged in as {display}")
|
|
149
|
+
typer.echo(f" email: {email}")
|
|
150
|
+
if username:
|
|
151
|
+
typer.echo(f" username: {username}")
|
|
152
|
+
typer.echo(f" user id: {data.get('id')}")
|
|
153
|
+
typer.echo(f" platform admin: {bool(data.get('is_platform_admin'))}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.command("logout")
|
|
157
|
+
def logout_cmd() -> None:
|
|
158
|
+
"""Delete the cached local session. Safe to run even if already logged out."""
|
|
159
|
+
removed = session.delete_session()
|
|
160
|
+
if removed:
|
|
161
|
+
typer.echo("Logged out.")
|
|
162
|
+
else:
|
|
163
|
+
typer.echo("Already logged out.")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _pick_one(items: list[dict], *, label: str) -> dict:
|
|
167
|
+
"""Numbered-list interactive picker. No fancy TUI — a prompt is enough for v1."""
|
|
168
|
+
typer.echo(f"Select a {label}:")
|
|
169
|
+
for i, item in enumerate(items, start=1):
|
|
170
|
+
name = item.get("name") or item.get("slug")
|
|
171
|
+
typer.echo(f" {i}. {name} ({item.get('slug')})")
|
|
172
|
+
raw_choice = typer.prompt(f"Enter a number (1-{len(items)})")
|
|
173
|
+
try:
|
|
174
|
+
index = int(raw_choice)
|
|
175
|
+
except ValueError:
|
|
176
|
+
typer.echo(f"'{raw_choice}' is not a number.", err=True)
|
|
177
|
+
raise typer.Exit(code=1)
|
|
178
|
+
if not (1 <= index <= len(items)):
|
|
179
|
+
typer.echo(f"'{raw_choice}' is not between 1 and {len(items)}.", err=True)
|
|
180
|
+
raise typer.Exit(code=1)
|
|
181
|
+
return items[index - 1]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _resolve_by_slug_or_prompt(items: list[dict], slug: str | None, *, label: str) -> dict:
|
|
185
|
+
if slug is None:
|
|
186
|
+
if not items:
|
|
187
|
+
typer.echo(f"No {label}s found for this account.", err=True)
|
|
188
|
+
raise typer.Exit(code=1)
|
|
189
|
+
return _pick_one(items, label=label)
|
|
190
|
+
|
|
191
|
+
for item in items:
|
|
192
|
+
if item.get("slug") == slug:
|
|
193
|
+
return item
|
|
194
|
+
available = ", ".join(sorted(i.get("slug", "") for i in items)) or "(none)"
|
|
195
|
+
typer.echo(
|
|
196
|
+
f"{label.capitalize()} '{slug}' not found or not visible to this account. Available: {available}",
|
|
197
|
+
err=True,
|
|
198
|
+
)
|
|
199
|
+
raise typer.Exit(code=1)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command("link")
|
|
203
|
+
def link_cmd(
|
|
204
|
+
org: str | None = typer.Option(None, "--org", help="Organization slug to link (skips the picker)."),
|
|
205
|
+
project: str | None = typer.Option(None, "--project", help="Project slug to link (skips the picker)."),
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Link the current directory to a Mozbridge org + project.
|
|
208
|
+
|
|
209
|
+
Writes .mozbridge/link.json in the current directory. Used by `build`
|
|
210
|
+
and `publish` to know where to ship.
|
|
211
|
+
"""
|
|
212
|
+
cwd = Path.cwd()
|
|
213
|
+
|
|
214
|
+
with httpx.Client(timeout=30.0) as client:
|
|
215
|
+
active_session = _load_and_refresh_session(client)
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
orgs = api.list_organizations(client, active_session.access_token)
|
|
219
|
+
except api.ApiError as exc:
|
|
220
|
+
typer.echo(str(exc), err=True)
|
|
221
|
+
raise typer.Exit(code=1)
|
|
222
|
+
|
|
223
|
+
chosen_org = _resolve_by_slug_or_prompt(orgs, org, label="organization")
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
projects = api.list_projects(client, active_session.access_token, chosen_org["id"])
|
|
227
|
+
except api.ApiError as exc:
|
|
228
|
+
typer.echo(str(exc), err=True)
|
|
229
|
+
raise typer.Exit(code=1)
|
|
230
|
+
|
|
231
|
+
chosen_project = _resolve_by_slug_or_prompt(projects, project, label="project")
|
|
232
|
+
|
|
233
|
+
new_link = linkmod.Link(
|
|
234
|
+
org_id=chosen_org["id"],
|
|
235
|
+
org_slug=chosen_org["slug"],
|
|
236
|
+
project_id=chosen_project["id"],
|
|
237
|
+
project_slug=chosen_project["slug"],
|
|
238
|
+
)
|
|
239
|
+
path = linkmod.save_link(cwd, new_link)
|
|
240
|
+
typer.echo(f"Linked {chosen_org['slug']}/{chosen_project['slug']} -> {path}")
|
|
241
|
+
typer.echo(linkmod.ensure_gitignored(cwd))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.command("build")
|
|
245
|
+
def build_cmd() -> None:
|
|
246
|
+
"""Zip the current directory into a build artifact. Does not upload.
|
|
247
|
+
|
|
248
|
+
A dry-run-adjacent primitive: prints the path and size of what a
|
|
249
|
+
`publish` would ship.
|
|
250
|
+
"""
|
|
251
|
+
result = buildmod.build_zip(Path.cwd())
|
|
252
|
+
size_kb = result.size_bytes / 1024
|
|
253
|
+
typer.echo(f"Built {result.zip_path} ({size_kb:.1f} KB, {result.file_count} files)")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _upload_context(
|
|
257
|
+
client: httpx.Client,
|
|
258
|
+
active_session: session.Session,
|
|
259
|
+
current_link: linkmod.Link,
|
|
260
|
+
context_dir: Path,
|
|
261
|
+
) -> str:
|
|
262
|
+
"""Zip `context_dir`, create a source-upload session, PUT the bytes, return upload_id.
|
|
263
|
+
|
|
264
|
+
Shared by both the single-component path (context_dir == cwd) and each
|
|
265
|
+
distinct build-context group in the multi-component path. Raises
|
|
266
|
+
`typer.Exit` (already having echoed a clear message) on any failure —
|
|
267
|
+
callers do not need their own try/except around this.
|
|
268
|
+
"""
|
|
269
|
+
result = buildmod.build_zip(context_dir)
|
|
270
|
+
typer.echo(f"Built {result.zip_path} ({result.size_bytes / 1024:.1f} KB, {result.file_count} files)")
|
|
271
|
+
try:
|
|
272
|
+
try:
|
|
273
|
+
upload_session = api.create_source_upload(
|
|
274
|
+
client,
|
|
275
|
+
active_session.access_token,
|
|
276
|
+
current_link.org_id,
|
|
277
|
+
current_link.project_id,
|
|
278
|
+
)
|
|
279
|
+
except api.ApiError as exc:
|
|
280
|
+
typer.echo(str(exc), err=True)
|
|
281
|
+
raise typer.Exit(code=1)
|
|
282
|
+
|
|
283
|
+
zip_bytes = result.zip_path.read_bytes()
|
|
284
|
+
try:
|
|
285
|
+
api.put_source_upload_bytes(
|
|
286
|
+
client,
|
|
287
|
+
active_session.access_token,
|
|
288
|
+
current_link.org_id,
|
|
289
|
+
upload_session["put_url"],
|
|
290
|
+
zip_bytes,
|
|
291
|
+
)
|
|
292
|
+
except api.ApiError as exc:
|
|
293
|
+
typer.echo(str(exc), err=True)
|
|
294
|
+
raise typer.Exit(code=1)
|
|
295
|
+
finally:
|
|
296
|
+
result.zip_path.unlink(missing_ok=True)
|
|
297
|
+
|
|
298
|
+
return upload_session["upload_id"]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _publish_single_component(
|
|
302
|
+
client: httpx.Client, active_session: session.Session, current_link: linkmod.Link, cwd: Path
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Today's original publish flow, unchanged: one zip of the whole directory,
|
|
305
|
+
one upload, one trigger_build call — no image_name/context_path/
|
|
306
|
+
dockerfile_path override, so the backend applies its own defaults exactly
|
|
307
|
+
as it always has.
|
|
308
|
+
"""
|
|
309
|
+
upload_id = _upload_context(client, active_session, current_link, cwd)
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
build_response = api.trigger_build(
|
|
313
|
+
client,
|
|
314
|
+
active_session.access_token,
|
|
315
|
+
current_link.org_id,
|
|
316
|
+
current_link.project_id,
|
|
317
|
+
upload_id,
|
|
318
|
+
)
|
|
319
|
+
except api.ApiError as exc:
|
|
320
|
+
typer.echo(str(exc), err=True)
|
|
321
|
+
raise typer.Exit(code=1)
|
|
322
|
+
|
|
323
|
+
task_id = build_response.get("task_id")
|
|
324
|
+
typer.echo(f"Build triggered (task_id={task_id}).")
|
|
325
|
+
typer.echo("Run `mozbridge status` to check progress, or poll the Mozbridge dashboard.")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _publish_multi_component(
|
|
329
|
+
client: httpx.Client,
|
|
330
|
+
active_session: session.Session,
|
|
331
|
+
current_link: linkmod.Link,
|
|
332
|
+
cwd: Path,
|
|
333
|
+
groups: dict[Path, list[composemod.ResolvedComponent]],
|
|
334
|
+
) -> None:
|
|
335
|
+
"""One trigger_build call per compose service with a `build:` key.
|
|
336
|
+
|
|
337
|
+
Services that share an identical resolved build context are zipped and
|
|
338
|
+
uploaded exactly once (`groups` is already de-duplicated by
|
|
339
|
+
compose.group_by_context) and reuse that one upload_id across their
|
|
340
|
+
individual trigger_build calls. Every component is attempted even if an
|
|
341
|
+
earlier one fails, so one broken service never hides the outcome of the
|
|
342
|
+
others; failures are collected and reported clearly at the end.
|
|
343
|
+
"""
|
|
344
|
+
total = sum(len(components) for components in groups.values())
|
|
345
|
+
typer.echo(
|
|
346
|
+
f"Found {total} component(s) to publish from compose ({len(groups)} distinct build context(s))."
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
succeeded: list[tuple[str, int | str | None]] = []
|
|
350
|
+
failed: list[tuple[str, str]] = []
|
|
351
|
+
|
|
352
|
+
for context_dir, components in groups.items():
|
|
353
|
+
service_names = ", ".join(c.service_name for c in components)
|
|
354
|
+
if not context_dir.is_dir():
|
|
355
|
+
typer.echo(f"[{service_names}] build context not found: {context_dir}", err=True)
|
|
356
|
+
for component in components:
|
|
357
|
+
failed.append((component.service_name, f"build context not found: {context_dir}"))
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
typer.echo(f"--- Context {context_dir} ({service_names}) ---")
|
|
361
|
+
try:
|
|
362
|
+
upload_id = _upload_context(client, active_session, current_link, context_dir)
|
|
363
|
+
except typer.Exit:
|
|
364
|
+
# _upload_context already echoed the specific error; every
|
|
365
|
+
# component sharing this context failed with it.
|
|
366
|
+
for component in components:
|
|
367
|
+
failed.append((component.service_name, "upload failed — see error above"))
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
for component in components:
|
|
371
|
+
typer.echo(f"[{component.service_name}] Triggering build (image={component.image_name})...")
|
|
372
|
+
try:
|
|
373
|
+
build_response = api.trigger_build(
|
|
374
|
+
client,
|
|
375
|
+
active_session.access_token,
|
|
376
|
+
current_link.org_id,
|
|
377
|
+
current_link.project_id,
|
|
378
|
+
upload_id,
|
|
379
|
+
image_name=component.image_name,
|
|
380
|
+
context_path=component.context_path,
|
|
381
|
+
dockerfile_path=component.dockerfile_path,
|
|
382
|
+
)
|
|
383
|
+
except api.ApiError as exc:
|
|
384
|
+
typer.echo(f"[{component.service_name}] FAILED: {exc}", err=True)
|
|
385
|
+
failed.append((component.service_name, str(exc)))
|
|
386
|
+
continue
|
|
387
|
+
task_id = build_response.get("task_id")
|
|
388
|
+
typer.echo(f"[{component.service_name}] Build triggered (task_id={task_id}).")
|
|
389
|
+
succeeded.append((component.service_name, task_id))
|
|
390
|
+
|
|
391
|
+
typer.echo("")
|
|
392
|
+
typer.echo(f"Publish summary: {len(succeeded)} succeeded, {len(failed)} failed (of {total}).")
|
|
393
|
+
for service_name, task_id in succeeded:
|
|
394
|
+
typer.echo(f" OK {service_name} (task_id={task_id})")
|
|
395
|
+
for service_name, reason in failed:
|
|
396
|
+
typer.echo(f" FAIL {service_name}: {reason}", err=True)
|
|
397
|
+
|
|
398
|
+
if failed:
|
|
399
|
+
raise typer.Exit(code=1)
|
|
400
|
+
|
|
401
|
+
typer.echo("Run `mozbridge status` to check progress, or poll the Mozbridge dashboard.")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@app.command("publish")
|
|
405
|
+
def publish_cmd(
|
|
406
|
+
local: bool = typer.Option(
|
|
407
|
+
False,
|
|
408
|
+
"--local",
|
|
409
|
+
help=(
|
|
410
|
+
"Build and push the image(s) on THIS machine, using the project's own "
|
|
411
|
+
"registry credential, instead of uploading source for the platform to "
|
|
412
|
+
"build — then register the resulting digest(s) for deploy. Requires "
|
|
413
|
+
f"{runtime_secretsmod.BUILD_TOKEN_ENV_VAR} (see the error message for how "
|
|
414
|
+
"to get one)."
|
|
415
|
+
),
|
|
416
|
+
),
|
|
417
|
+
) -> None:
|
|
418
|
+
"""Build the current directory and publish it to the linked Mozbridge project.
|
|
419
|
+
|
|
420
|
+
Requires `mozbridge link` to have been run here, and an active login
|
|
421
|
+
session.
|
|
422
|
+
|
|
423
|
+
If a compose file is present (docker-compose.yml, compose.yaml, or
|
|
424
|
+
docker-compose.yaml — Docker's own discovery order) and at least one of
|
|
425
|
+
its services has a `build:` key, each such service is published as its
|
|
426
|
+
own component/image: one create_source_upload -> PUT -> trigger_build
|
|
427
|
+
per component, with contexts shared across services de-duplicated to a
|
|
428
|
+
single upload. Otherwise (no compose file, or none of its services
|
|
429
|
+
build) this falls back unchanged to the original single-component flow:
|
|
430
|
+
zip the whole directory, one upload, one trigger_build call.
|
|
431
|
+
|
|
432
|
+
`--local` replaces the upload+remote-build step entirely: components are
|
|
433
|
+
resolved the same way, but each is built and pushed with the developer's
|
|
434
|
+
own local `docker`, using registry credentials fetched with a SEPARATE
|
|
435
|
+
build-scoped token (MOZBRIDGE_BUILD_TOKEN), then registered via the same
|
|
436
|
+
trigger_build endpoint using the regular login session. See
|
|
437
|
+
`_publish_local` below.
|
|
438
|
+
"""
|
|
439
|
+
cwd = Path.cwd()
|
|
440
|
+
current_link = linkmod.load_link(cwd)
|
|
441
|
+
if current_link is None:
|
|
442
|
+
typer.echo(
|
|
443
|
+
"This directory is not linked to a Mozbridge project. Run `mozbridge link` first.", err=True
|
|
444
|
+
)
|
|
445
|
+
raise typer.Exit(code=1)
|
|
446
|
+
|
|
447
|
+
if local:
|
|
448
|
+
_publish_local(cwd, current_link)
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
components: list[composemod.Component] = []
|
|
452
|
+
compose_path = composemod.find_compose_file(cwd)
|
|
453
|
+
if compose_path is not None:
|
|
454
|
+
components = composemod.parse_components(compose_path)
|
|
455
|
+
|
|
456
|
+
with httpx.Client(timeout=120.0) as client:
|
|
457
|
+
active_session = _load_and_refresh_session(client)
|
|
458
|
+
|
|
459
|
+
if components:
|
|
460
|
+
typer.echo(f"Using {compose_path.name} for multi-component publish.")
|
|
461
|
+
groups = composemod.resolve_components(
|
|
462
|
+
cwd, current_link.org_slug, current_link.project_slug, components
|
|
463
|
+
)
|
|
464
|
+
_publish_multi_component(client, active_session, current_link, cwd, groups)
|
|
465
|
+
else:
|
|
466
|
+
_publish_single_component(client, active_session, current_link, cwd)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _publish_local(cwd: Path, current_link: linkmod.Link) -> None:
|
|
470
|
+
"""`mozbridge publish --local`: build+push image(s) on THIS machine using
|
|
471
|
+
the project's own registry credentials (fetched with a SEPARATE
|
|
472
|
+
build-scoped token), then register each resulting digest via the same
|
|
473
|
+
trigger_build endpoint `publish` already calls — using the regular LOGIN
|
|
474
|
+
session, never the build token. See runtime_secrets.py's module
|
|
475
|
+
docstring for why these two credentials must never cross.
|
|
476
|
+
|
|
477
|
+
Order of checks is deliberate: cheap, local, no-network checks first
|
|
478
|
+
(MOZBRIDGE_BUILD_TOKEN present, docker installed/daemon reachable) so a
|
|
479
|
+
misconfigured environment fails fast with zero docker or HTTP calls,
|
|
480
|
+
before login-session refresh or any network call is attempted.
|
|
481
|
+
"""
|
|
482
|
+
build_token = os.environ.get(runtime_secretsmod.BUILD_TOKEN_ENV_VAR)
|
|
483
|
+
if not build_token:
|
|
484
|
+
typer.echo(
|
|
485
|
+
f"{runtime_secretsmod.BUILD_TOKEN_ENV_VAR} is not set.\n\n"
|
|
486
|
+
"`mozbridge publish --local` builds and pushes images on this machine, "
|
|
487
|
+
"so it needs a SEPARATE credential from your `mozbridge login` session: "
|
|
488
|
+
"a project-scoped service token with the 'build' scope (registry/build "
|
|
489
|
+
"credentials only — it can never reach a deploy host key). Create one "
|
|
490
|
+
"via the Mozbridge dashboard or API (Project Settings -> Service Tokens "
|
|
491
|
+
"-> New, scope 'build') — this CLI does not create or mint tokens "
|
|
492
|
+
"itself. Then:\n\n"
|
|
493
|
+
f" export {runtime_secretsmod.BUILD_TOKEN_ENV_VAR}=<token>\n",
|
|
494
|
+
err=True,
|
|
495
|
+
)
|
|
496
|
+
raise typer.Exit(code=1)
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
local_buildmod.check_docker_available()
|
|
500
|
+
except local_buildmod.DockerUnavailableError as exc:
|
|
501
|
+
typer.echo(str(exc), err=True)
|
|
502
|
+
raise typer.Exit(code=1)
|
|
503
|
+
|
|
504
|
+
components: list[composemod.Component] = []
|
|
505
|
+
compose_path = composemod.find_compose_file(cwd)
|
|
506
|
+
if compose_path is not None:
|
|
507
|
+
components = composemod.parse_components(compose_path)
|
|
508
|
+
|
|
509
|
+
with httpx.Client(timeout=180.0) as client:
|
|
510
|
+
active_session = _load_and_refresh_session(client)
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
secrets = runtime_secretsmod.fetch_runtime_secrets(client, build_token)
|
|
514
|
+
except runtime_secretsmod.RuntimeSecretsError as exc:
|
|
515
|
+
typer.echo(str(exc), err=True)
|
|
516
|
+
raise typer.Exit(code=1)
|
|
517
|
+
|
|
518
|
+
registry_user = secrets.get("GHCR_USER")
|
|
519
|
+
registry_password = secrets.get("GHCR_TOKEN")
|
|
520
|
+
if not registry_password or not registry_user:
|
|
521
|
+
missing = "GHCR_TOKEN" if not registry_password else "GHCR_USER"
|
|
522
|
+
typer.echo(
|
|
523
|
+
f"The runtime-secrets response did not include {missing}. This project "
|
|
524
|
+
"may not have a registry credential configured yet — set one via "
|
|
525
|
+
"PUT /projects/{project_id}/registry (Project Settings -> Registry in "
|
|
526
|
+
"the Mozbridge dashboard).",
|
|
527
|
+
err=True,
|
|
528
|
+
)
|
|
529
|
+
raise typer.Exit(code=1)
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
local_buildmod.docker_login(local_buildmod.REGISTRY, registry_user, registry_password)
|
|
533
|
+
except local_buildmod.DockerError as exc:
|
|
534
|
+
typer.echo(f"docker login to {local_buildmod.REGISTRY} failed: {exc}", err=True)
|
|
535
|
+
raise typer.Exit(code=1)
|
|
536
|
+
|
|
537
|
+
if components:
|
|
538
|
+
typer.echo(f"Using {compose_path.name} for local multi-component publish.")
|
|
539
|
+
groups = composemod.resolve_components(
|
|
540
|
+
cwd, current_link.org_slug, current_link.project_slug, components
|
|
541
|
+
)
|
|
542
|
+
else:
|
|
543
|
+
image_name = composemod.default_image_name(current_link.org_slug, current_link.project_slug)
|
|
544
|
+
single = composemod.ResolvedComponent(
|
|
545
|
+
service_name="app",
|
|
546
|
+
image_name=image_name,
|
|
547
|
+
context_dir=cwd,
|
|
548
|
+
context_path=".",
|
|
549
|
+
dockerfile_path="Dockerfile",
|
|
550
|
+
)
|
|
551
|
+
groups = {cwd: [single]}
|
|
552
|
+
|
|
553
|
+
_local_build_and_register(client, active_session, current_link, groups)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _local_build_and_register(
|
|
557
|
+
client: httpx.Client,
|
|
558
|
+
active_session: session.Session,
|
|
559
|
+
current_link: linkmod.Link,
|
|
560
|
+
groups: dict[Path, list[composemod.ResolvedComponent]],
|
|
561
|
+
) -> None:
|
|
562
|
+
"""docker build -> docker push -> trigger_build(source_type='prebuilt') for
|
|
563
|
+
every component in `groups`. Mirrors `_publish_multi_component`'s
|
|
564
|
+
per-component OK/FAIL reporting and partial-failure semantics: every
|
|
565
|
+
component is attempted even if an earlier one fails.
|
|
566
|
+
"""
|
|
567
|
+
total = sum(len(components) for components in groups.values())
|
|
568
|
+
typer.echo(f"Building {total} component(s) locally with docker...")
|
|
569
|
+
|
|
570
|
+
succeeded: list[tuple[str, str]] = []
|
|
571
|
+
failed: list[tuple[str, str]] = []
|
|
572
|
+
|
|
573
|
+
for context_dir, components in groups.items():
|
|
574
|
+
if not context_dir.is_dir():
|
|
575
|
+
for component in components:
|
|
576
|
+
typer.echo(f"[{component.service_name}] build context not found: {context_dir}", err=True)
|
|
577
|
+
failed.append((component.service_name, f"build context not found: {context_dir}"))
|
|
578
|
+
continue
|
|
579
|
+
|
|
580
|
+
for component in components:
|
|
581
|
+
tag = f"{component.image_name}:{local_buildmod.local_tag()}"
|
|
582
|
+
typer.echo(f"[{component.service_name}] docker build -> {tag} ...")
|
|
583
|
+
try:
|
|
584
|
+
local_buildmod.docker_build(context_dir, component.dockerfile_path, tag)
|
|
585
|
+
except local_buildmod.DockerError as exc:
|
|
586
|
+
typer.echo(f"[{component.service_name}] FAILED (docker build): {exc}", err=True)
|
|
587
|
+
failed.append((component.service_name, f"docker build failed: {exc}"))
|
|
588
|
+
continue
|
|
589
|
+
|
|
590
|
+
typer.echo(f"[{component.service_name}] docker push {tag} ...")
|
|
591
|
+
try:
|
|
592
|
+
digest = local_buildmod.docker_push(tag)
|
|
593
|
+
except local_buildmod.DockerError as exc:
|
|
594
|
+
typer.echo(f"[{component.service_name}] FAILED (docker push): {exc}", err=True)
|
|
595
|
+
failed.append((component.service_name, f"docker push failed: {exc}"))
|
|
596
|
+
continue
|
|
597
|
+
|
|
598
|
+
typer.echo(f"[{component.service_name}] Registering {component.image_name}@{digest} ...")
|
|
599
|
+
try:
|
|
600
|
+
api.trigger_prebuilt_build(
|
|
601
|
+
client,
|
|
602
|
+
active_session.access_token,
|
|
603
|
+
current_link.org_id,
|
|
604
|
+
current_link.project_id,
|
|
605
|
+
component.image_name,
|
|
606
|
+
digest,
|
|
607
|
+
)
|
|
608
|
+
except api.ApiError as exc:
|
|
609
|
+
typer.echo(f"[{component.service_name}] FAILED (register): {exc}", err=True)
|
|
610
|
+
failed.append((component.service_name, str(exc)))
|
|
611
|
+
continue
|
|
612
|
+
|
|
613
|
+
typer.echo(f"[{component.service_name}] Registered.")
|
|
614
|
+
succeeded.append((component.service_name, digest))
|
|
615
|
+
|
|
616
|
+
typer.echo("")
|
|
617
|
+
typer.echo(f"Local publish summary: {len(succeeded)} succeeded, {len(failed)} failed (of {total}).")
|
|
618
|
+
for service_name, digest in succeeded:
|
|
619
|
+
typer.echo(f" OK {service_name} ({digest})")
|
|
620
|
+
for service_name, reason in failed:
|
|
621
|
+
typer.echo(f" FAIL {service_name}: {reason}", err=True)
|
|
622
|
+
|
|
623
|
+
if failed:
|
|
624
|
+
raise typer.Exit(code=1)
|
|
625
|
+
|
|
626
|
+
typer.echo("Run `mozbridge status` to check progress, or poll the Mozbridge dashboard.")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
@app.command("diff")
|
|
630
|
+
def diff_cmd() -> None:
|
|
631
|
+
"""Preview what `mozbridge publish` would do right now. Does not publish anything.
|
|
632
|
+
|
|
633
|
+
Requires `mozbridge link` and an active login session, same as `publish`.
|
|
634
|
+
Makes no mutating API calls: it only GETs the linked project's most
|
|
635
|
+
recent deployment (the same call `mozbridge status` makes) for context,
|
|
636
|
+
plus local filesystem reads to work out what `publish` would build. No
|
|
637
|
+
upload, no trigger_build.
|
|
638
|
+
|
|
639
|
+
This compares local components against the most recent deployment
|
|
640
|
+
RECORD only — the platform does not expose the actually-deployed
|
|
641
|
+
compose content anywhere, so this is not a full semantic compose diff,
|
|
642
|
+
just "here's what's live" next to "here's what publish would build".
|
|
643
|
+
"""
|
|
644
|
+
cwd = Path.cwd()
|
|
645
|
+
|
|
646
|
+
with httpx.Client(timeout=30.0) as client:
|
|
647
|
+
current_link, active_session = _require_link_and_session(client, cwd)
|
|
648
|
+
|
|
649
|
+
try:
|
|
650
|
+
deployments = api.list_project_deployments(
|
|
651
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
652
|
+
)
|
|
653
|
+
except api.ApiError as exc:
|
|
654
|
+
typer.echo(str(exc), err=True)
|
|
655
|
+
raise typer.Exit(code=1)
|
|
656
|
+
|
|
657
|
+
typer.echo(
|
|
658
|
+
f"Diff for {current_link.org_slug}/{current_link.project_slug} "
|
|
659
|
+
"(preview only — mozbridge publish has NOT been run)."
|
|
660
|
+
)
|
|
661
|
+
typer.echo("")
|
|
662
|
+
|
|
663
|
+
typer.echo("Currently deployed (most recent deployment record):")
|
|
664
|
+
if deployments:
|
|
665
|
+
latest = deployments[0]
|
|
666
|
+
stable_marker = " [stable]" if latest.get("is_stable") else ""
|
|
667
|
+
typer.echo(
|
|
668
|
+
f" #{latest.get('id')} {latest.get('deployed_at') or '(no timestamp)'} "
|
|
669
|
+
f"{latest.get('component')}={latest.get('version')} "
|
|
670
|
+
f"{latest.get('status')}{stable_marker}"
|
|
671
|
+
)
|
|
672
|
+
else:
|
|
673
|
+
typer.echo(" No deployments yet.")
|
|
674
|
+
typer.echo("")
|
|
675
|
+
|
|
676
|
+
components: list[composemod.Component] = []
|
|
677
|
+
compose_path = composemod.find_compose_file(cwd)
|
|
678
|
+
if compose_path is not None:
|
|
679
|
+
components = composemod.parse_components(compose_path)
|
|
680
|
+
|
|
681
|
+
if not components:
|
|
682
|
+
if compose_path is not None:
|
|
683
|
+
typer.echo(
|
|
684
|
+
f"{compose_path.name} was found but has no services with a `build:` key. "
|
|
685
|
+
"`mozbridge publish` would fall back to the single-component path: "
|
|
686
|
+
"zip the whole current directory and publish it as one component."
|
|
687
|
+
)
|
|
688
|
+
else:
|
|
689
|
+
typer.echo(
|
|
690
|
+
"No compose file found (docker-compose.yml, compose.yaml, or "
|
|
691
|
+
"docker-compose.yaml). `mozbridge publish` would use the "
|
|
692
|
+
"single-component path: zip the whole current directory and "
|
|
693
|
+
"publish it as one component."
|
|
694
|
+
)
|
|
695
|
+
return
|
|
696
|
+
|
|
697
|
+
groups = composemod.resolve_components(cwd, current_link.org_slug, current_link.project_slug, components)
|
|
698
|
+
total = sum(len(resolved) for resolved in groups.values())
|
|
699
|
+
typer.echo(
|
|
700
|
+
f"Using {compose_path.name}. `mozbridge publish` would publish {total} component(s) "
|
|
701
|
+
f"from {len(groups)} distinct build context(s):"
|
|
702
|
+
)
|
|
703
|
+
for context_dir, resolved in groups.items():
|
|
704
|
+
service_names = ", ".join(c.service_name for c in resolved)
|
|
705
|
+
missing_note = " (WARNING: build context not found on disk)" if not context_dir.is_dir() else ""
|
|
706
|
+
typer.echo(f"--- Context {context_dir} ({service_names}){missing_note} ---")
|
|
707
|
+
for component in resolved:
|
|
708
|
+
typer.echo(f" [{component.service_name}]")
|
|
709
|
+
typer.echo(f" image_name: {component.image_name}")
|
|
710
|
+
typer.echo(f" context_path: {component.context_path}")
|
|
711
|
+
typer.echo(f" dockerfile_path: {component.dockerfile_path}")
|
|
712
|
+
|
|
713
|
+
typer.echo("")
|
|
714
|
+
typer.echo(
|
|
715
|
+
"Note: this diff compares local components against the most recent "
|
|
716
|
+
"deployment record only, not the actually-deployed compose content "
|
|
717
|
+
"(the platform doesn't expose that) — it is not a full semantic "
|
|
718
|
+
"compose diff."
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _require_link_and_session(client: httpx.Client, cwd: Path) -> tuple[linkmod.Link, session.Session]:
|
|
723
|
+
"""Shared guard for `status`, `rollback`, and `diff`: same clear-error pattern as `publish`."""
|
|
724
|
+
current_link = linkmod.load_link(cwd)
|
|
725
|
+
if current_link is None:
|
|
726
|
+
typer.echo(
|
|
727
|
+
"This directory is not linked to a Mozbridge project. Run `mozbridge link` first.", err=True
|
|
728
|
+
)
|
|
729
|
+
raise typer.Exit(code=1)
|
|
730
|
+
active_session = _load_and_refresh_session(client)
|
|
731
|
+
return current_link, active_session
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
@app.command("status")
|
|
735
|
+
def status_cmd() -> None:
|
|
736
|
+
"""Show the linked project's status and its 5 most recent deployments."""
|
|
737
|
+
cwd = Path.cwd()
|
|
738
|
+
|
|
739
|
+
with httpx.Client(timeout=30.0) as client:
|
|
740
|
+
current_link, active_session = _require_link_and_session(client, cwd)
|
|
741
|
+
|
|
742
|
+
try:
|
|
743
|
+
project = api.get_project(
|
|
744
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
745
|
+
)
|
|
746
|
+
except api.ApiError as exc:
|
|
747
|
+
typer.echo(str(exc), err=True)
|
|
748
|
+
raise typer.Exit(code=1)
|
|
749
|
+
|
|
750
|
+
try:
|
|
751
|
+
deployments = api.list_project_deployments(
|
|
752
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
753
|
+
)
|
|
754
|
+
except api.ApiError as exc:
|
|
755
|
+
typer.echo(str(exc), err=True)
|
|
756
|
+
raise typer.Exit(code=1)
|
|
757
|
+
|
|
758
|
+
typer.echo(f"{project.get('name')} ({project.get('slug')})")
|
|
759
|
+
typer.echo(f" status: {project.get('status')}")
|
|
760
|
+
|
|
761
|
+
if not deployments:
|
|
762
|
+
typer.echo("No deployments yet.")
|
|
763
|
+
return
|
|
764
|
+
|
|
765
|
+
typer.echo("Recent deployments:")
|
|
766
|
+
for deployment in deployments[:5]:
|
|
767
|
+
stable_marker = " [stable]" if deployment.get("is_stable") else ""
|
|
768
|
+
typer.echo(
|
|
769
|
+
f" #{deployment.get('id')} {deployment.get('deployed_at') or '(no timestamp)'} "
|
|
770
|
+
f"{deployment.get('component')}={deployment.get('version')} "
|
|
771
|
+
f"{deployment.get('status')}{stable_marker}"
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@app.command("rollback")
|
|
776
|
+
def rollback_cmd(
|
|
777
|
+
to: int | None = typer.Option(
|
|
778
|
+
None, "--to", help="Deployment id to roll back to. Omit to roll back to the previous deployment."
|
|
779
|
+
),
|
|
780
|
+
yes: bool = typer.Option(False, "--yes", help="Skip the interactive confirmation prompt."),
|
|
781
|
+
) -> None:
|
|
782
|
+
"""Roll back the linked project's deployment. A real, consequential production action."""
|
|
783
|
+
cwd = Path.cwd()
|
|
784
|
+
|
|
785
|
+
with httpx.Client(timeout=30.0) as client:
|
|
786
|
+
current_link, active_session = _require_link_and_session(client, cwd)
|
|
787
|
+
|
|
788
|
+
target_desc = f"deployment #{to}" if to is not None else "the previous deployment"
|
|
789
|
+
project_desc = f"{current_link.org_slug}/{current_link.project_slug}"
|
|
790
|
+
typer.echo(f"This will roll back {project_desc} to {target_desc}.")
|
|
791
|
+
typer.echo("Target environment: prod.")
|
|
792
|
+
if not yes:
|
|
793
|
+
typer.confirm("Continue?", abort=True)
|
|
794
|
+
|
|
795
|
+
try:
|
|
796
|
+
if to is not None:
|
|
797
|
+
result = api.rollback_project_to(
|
|
798
|
+
client,
|
|
799
|
+
active_session.access_token,
|
|
800
|
+
current_link.org_id,
|
|
801
|
+
current_link.project_id,
|
|
802
|
+
to,
|
|
803
|
+
)
|
|
804
|
+
else:
|
|
805
|
+
result = api.rollback_project(
|
|
806
|
+
client, active_session.access_token, current_link.org_id, current_link.project_id
|
|
807
|
+
)
|
|
808
|
+
except api.ApiError as exc:
|
|
809
|
+
typer.echo(str(exc), err=True)
|
|
810
|
+
raise typer.Exit(code=1)
|
|
811
|
+
|
|
812
|
+
task_id = result.get("task_id") or result.get("target_task")
|
|
813
|
+
typer.echo(f"Rollback triggered (task_id={task_id}).")
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def app_main() -> None:
|
|
817
|
+
app()
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
if __name__ == "__main__":
|
|
821
|
+
app_main()
|