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/compose.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Discover and parse Docker Compose build components for `mozbridge publish`.
|
|
2
|
+
|
|
3
|
+
Most real Mozbridge tenants (orphimuse: backend+frontend+2 celery; pratios:
|
|
4
|
+
backend+celery+frontend+redis+alloy) are multi-service, not the single
|
|
5
|
+
whole-directory image `publish` has always built. This module reads
|
|
6
|
+
whatever compose file the user already has and turns each buildable
|
|
7
|
+
service into a `Component` — `publish` then runs the existing
|
|
8
|
+
single-component upload+trigger_build flow once per component (see
|
|
9
|
+
main.py), never once per compose file.
|
|
10
|
+
|
|
11
|
+
No backend changes and no cli/ -> backend/ import: this is a small,
|
|
12
|
+
independent, best-effort YAML read, not a Compose-spec validator. Server
|
|
13
|
+
side pre-flight (app_compose_file, compose_contract.py) is the real
|
|
14
|
+
enforcement point for a project that opts into it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from . import config
|
|
25
|
+
|
|
26
|
+
# Docker Compose's own file discovery order:
|
|
27
|
+
# https://docs.docker.com/compose/compose-file/ ("Compose file names").
|
|
28
|
+
# Do not invent a new default name here — this reads a file the user
|
|
29
|
+
# already has, it doesn't declare a new platform-owned one.
|
|
30
|
+
COMPOSE_FILENAMES = ("docker-compose.yml", "compose.yaml", "docker-compose.yaml")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Component:
|
|
35
|
+
"""One compose service with a `build:` key, ready to publish as an image."""
|
|
36
|
+
|
|
37
|
+
service_name: str
|
|
38
|
+
# As written in compose (bare string or build.context), relative to the compose file's dir.
|
|
39
|
+
context_path: str
|
|
40
|
+
# As written in compose (build.dockerfile), relative to context_path per Compose convention.
|
|
41
|
+
dockerfile_path: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_compose_file(cwd: Path) -> Path | None:
|
|
45
|
+
"""Return the first compose file present in `cwd`, in Docker's own discovery order."""
|
|
46
|
+
for name in COMPOSE_FILENAMES:
|
|
47
|
+
candidate = cwd / name
|
|
48
|
+
if candidate.is_file():
|
|
49
|
+
return candidate
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_components(compose_path: Path) -> list[Component]:
|
|
54
|
+
"""Return one Component per top-level service that has a `build:` key.
|
|
55
|
+
|
|
56
|
+
Best-effort: an unreadable, malformed, or structurally unexpected
|
|
57
|
+
compose file yields an empty list rather than raising. `publish`
|
|
58
|
+
treats an empty list exactly like "no compose file found" and falls
|
|
59
|
+
back to the single-component path — never a hard failure just because
|
|
60
|
+
this best-effort read couldn't make sense of the file.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
raw = compose_path.read_text()
|
|
64
|
+
except OSError:
|
|
65
|
+
return []
|
|
66
|
+
try:
|
|
67
|
+
data = yaml.safe_load(raw)
|
|
68
|
+
except yaml.YAMLError:
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
if not isinstance(data, dict):
|
|
72
|
+
return []
|
|
73
|
+
services = data.get("services")
|
|
74
|
+
if not isinstance(services, dict):
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
components: list[Component] = []
|
|
78
|
+
for service_name, service_def in services.items():
|
|
79
|
+
if not isinstance(service_def, dict):
|
|
80
|
+
continue
|
|
81
|
+
build = service_def.get("build")
|
|
82
|
+
if isinstance(build, str):
|
|
83
|
+
context_path = build or "."
|
|
84
|
+
dockerfile_path = "Dockerfile"
|
|
85
|
+
elif isinstance(build, dict):
|
|
86
|
+
context_path = str(build.get("context") or ".")
|
|
87
|
+
dockerfile_path = str(build.get("dockerfile") or "Dockerfile")
|
|
88
|
+
else:
|
|
89
|
+
# No `build:` key (image: only), or a shape we don't recognize.
|
|
90
|
+
continue
|
|
91
|
+
components.append(
|
|
92
|
+
Component(
|
|
93
|
+
service_name=str(service_name),
|
|
94
|
+
context_path=context_path,
|
|
95
|
+
dockerfile_path=dockerfile_path,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return components
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def group_by_context(cwd: Path, components: list[Component]) -> dict[Path, list[Component]]:
|
|
102
|
+
"""Group components by resolved build-context directory.
|
|
103
|
+
|
|
104
|
+
Two services whose compose `build.context` spells the same directory
|
|
105
|
+
differently (`.` vs `./`, `./backend` vs `backend`) land in the same
|
|
106
|
+
group, keyed by the resolved absolute path — so `publish` zips and
|
|
107
|
+
uploads that context exactly once and reuses the same upload_id for
|
|
108
|
+
every component in the group, instead of re-uploading identical bytes.
|
|
109
|
+
Dict order follows first-seen component order (dicts are
|
|
110
|
+
insertion-ordered), so publish progress prints in a stable order.
|
|
111
|
+
"""
|
|
112
|
+
groups: dict[Path, list[Component]] = {}
|
|
113
|
+
for component in components:
|
|
114
|
+
resolved = (cwd / component.context_path).resolve()
|
|
115
|
+
groups.setdefault(resolved, []).append(component)
|
|
116
|
+
return groups
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def default_image_name(org_slug: str, project_slug: str) -> str:
|
|
120
|
+
"""The SAME base image name the backend would compute when image_name is
|
|
121
|
+
omitted (see config.GHCR_ORG's docstring for why this is derived
|
|
122
|
+
client-side): f"ghcr.io/{ghcr_org}/mozbridge-tenants/{org_slug}/{project_slug}".
|
|
123
|
+
|
|
124
|
+
Used directly by the single-component fallback path (no compose file /
|
|
125
|
+
no buildable services) of both regular `publish` (implicitly, via the
|
|
126
|
+
backend's own default) and `publish --local` (explicitly, since a
|
|
127
|
+
prebuilt registration always requires a concrete image_name — there is
|
|
128
|
+
no server-side default to fall back on when the CLI itself pushed the
|
|
129
|
+
image).
|
|
130
|
+
"""
|
|
131
|
+
return f"ghcr.io/{config.GHCR_ORG}/mozbridge-tenants/{org_slug}/{project_slug}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def image_name_for(org_slug: str, project_slug: str, service_name: str) -> str:
|
|
135
|
+
"""`default_image_name`, suffixed with the component's service name so N
|
|
136
|
+
components never collide on one image.
|
|
137
|
+
"""
|
|
138
|
+
return f"{default_image_name(org_slug, project_slug)}-{service_name}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class ResolvedComponent:
|
|
143
|
+
"""A `Component` with every value `publish` actually sends to
|
|
144
|
+
`trigger_build` filled in — the single source of truth `publish` and
|
|
145
|
+
`diff` both read, so they can't silently diverge.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
service_name: str
|
|
149
|
+
image_name: str
|
|
150
|
+
# Local, resolved build-context directory (what gets zipped and uploaded).
|
|
151
|
+
context_dir: Path
|
|
152
|
+
# Always "." — components are zipped and uploaded per-context, so the
|
|
153
|
+
# context is the root of that upload, exactly as `publish` sends it.
|
|
154
|
+
context_path: str
|
|
155
|
+
dockerfile_path: str
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def resolve_components(
|
|
159
|
+
cwd: Path, org_slug: str, project_slug: str, components: list[Component]
|
|
160
|
+
) -> dict[Path, list[ResolvedComponent]]:
|
|
161
|
+
"""Group `components` by resolved build context and compute exactly the
|
|
162
|
+
image_name/context_path/dockerfile_path each would be published with.
|
|
163
|
+
|
|
164
|
+
This is the shared computation behind both `mozbridge publish` (which
|
|
165
|
+
sends these values to `trigger_build`) and `mozbridge diff` (which only
|
|
166
|
+
prints them) — refactored out so the two can never silently drift apart.
|
|
167
|
+
"""
|
|
168
|
+
groups = group_by_context(cwd, components)
|
|
169
|
+
resolved: dict[Path, list[ResolvedComponent]] = {}
|
|
170
|
+
for context_dir, group_components in groups.items():
|
|
171
|
+
resolved[context_dir] = [
|
|
172
|
+
ResolvedComponent(
|
|
173
|
+
service_name=component.service_name,
|
|
174
|
+
image_name=image_name_for(org_slug, project_slug, component.service_name),
|
|
175
|
+
context_dir=context_dir,
|
|
176
|
+
context_path=".",
|
|
177
|
+
dockerfile_path=component.dockerfile_path,
|
|
178
|
+
)
|
|
179
|
+
for component in group_components
|
|
180
|
+
]
|
|
181
|
+
return resolved
|
mozbridge_cli/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Static configuration and environment-var overrides for the Mozbridge CLI.
|
|
2
|
+
|
|
3
|
+
All values here have real, working defaults (see cli/README.md) so the CLI
|
|
4
|
+
works out of the box for a human running `mozbridge login`. The env vars
|
|
5
|
+
exist for testing and for pointing at a non-production Mozbridge instance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# Logto tenant serving Mozbridge's real, provisioned Native (public) client.
|
|
14
|
+
LOGTO_ENDPOINT = os.environ.get("MOZBRIDGE_LOGTO_ENDPOINT", "https://auth.mozbridge.com").rstrip("/")
|
|
15
|
+
LOGTO_CLIENT_ID = os.environ.get("MOZBRIDGE_LOGTO_CLIENT_ID", "swdtsuiz6dgzack1g75x3")
|
|
16
|
+
|
|
17
|
+
DEVICE_AUTH_URL = f"{LOGTO_ENDPOINT}/oidc/device/auth"
|
|
18
|
+
TOKEN_URL = f"{LOGTO_ENDPOINT}/oidc/token"
|
|
19
|
+
|
|
20
|
+
DEVICE_SCOPE = "openid offline_access profile"
|
|
21
|
+
|
|
22
|
+
# Mozbridge API base — same env var convention already used across the
|
|
23
|
+
# backend (see backend/app/features/projects/router.py).
|
|
24
|
+
API_BASE_URL = (
|
|
25
|
+
os.environ.get("MOZBRIDGE_PUBLIC_API_URL")
|
|
26
|
+
or os.environ.get("PUBLIC_API_URL")
|
|
27
|
+
or "https://api.mozbridge.com"
|
|
28
|
+
).rstrip("/")
|
|
29
|
+
|
|
30
|
+
# GET /api/v1/auth/me: gated by the generic get_current_user dependency (not
|
|
31
|
+
# a service-token-only endpoint like /api/v1/tokens/me), returns the human
|
|
32
|
+
# user's profile (id, email, username, full_name, is_superuser,
|
|
33
|
+
# is_platform_admin). This is the right whoami target for a Logto human
|
|
34
|
+
# bearer token.
|
|
35
|
+
WHOAMI_PATH = "/api/v1/auth/me"
|
|
36
|
+
|
|
37
|
+
# GET /api/v1/organizations: lists orgs the caller is a member of (see
|
|
38
|
+
# backend/app/features/identity/router.py:list_my_organizations). Gated by
|
|
39
|
+
# the generic get_current_user dependency — no X-Organization-Id needed.
|
|
40
|
+
ORGANIZATIONS_PATH = "/api/v1/organizations"
|
|
41
|
+
|
|
42
|
+
# /api/v1/projects[...]: see backend/app/features/projects/router.py. Every
|
|
43
|
+
# route under this prefix that mutates or lists project-scoped state also
|
|
44
|
+
# depends on get_current_org, which requires an X-Organization-Id header.
|
|
45
|
+
PROJECTS_PATH = "/api/v1/projects"
|
|
46
|
+
|
|
47
|
+
# Mirrors backend/app/config.py:Settings.ghcr_org — the platform's own GHCR
|
|
48
|
+
# account, used to build the SAME default image-name base the backend would
|
|
49
|
+
# compute (BuildService._trigger_build_from_upload) when image_name is
|
|
50
|
+
# omitted: f"ghcr.io/{ghcr_org}/mozbridge-tenants/{org_slug}/{project_slug}".
|
|
51
|
+
# No API response returns this value back to the client (create_source_upload
|
|
52
|
+
# and trigger_build's response bodies both omit it — see api.py), so a
|
|
53
|
+
# multi-component `publish`, which must send distinct explicit image_name
|
|
54
|
+
# values per component to avoid all components colliding on one image, needs
|
|
55
|
+
# it client-side to build a name that actually matches where the backend's
|
|
56
|
+
# own default would have pushed a single-component build. Same pattern as
|
|
57
|
+
# every other platform constant in this file (LOGTO_CLIENT_ID, API_BASE_URL):
|
|
58
|
+
# a real, working default plus an env var escape hatch for pointing at a
|
|
59
|
+
# non-default backend deployment.
|
|
60
|
+
GHCR_ORG = os.environ.get("MOZBRIDGE_GHCR_ORG", "jessin01")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def config_dir() -> Path:
|
|
64
|
+
"""Directory holding the cached session file.
|
|
65
|
+
|
|
66
|
+
Overridable via MOZBRIDGE_CONFIG_DIR (used by tests to isolate a fake
|
|
67
|
+
HOME instead of touching the real one).
|
|
68
|
+
"""
|
|
69
|
+
override = os.environ.get("MOZBRIDGE_CONFIG_DIR")
|
|
70
|
+
if override:
|
|
71
|
+
return Path(override)
|
|
72
|
+
return Path.home() / ".config" / "mozbridge"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def session_path() -> Path:
|
|
76
|
+
return config_dir() / "session.json"
|
mozbridge_cli/link.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Per-project-repo link state: `.mozbridge/link.json` in the CURRENT directory.
|
|
2
|
+
|
|
3
|
+
Deliberately distinct from the CLI's own config dir (~/.config/mozbridge,
|
|
4
|
+
see config.py) — this records which Mozbridge org/project a given repo
|
|
5
|
+
checkout is linked to, so it is per-repo working-tree state, not per-user
|
|
6
|
+
CLI state. It should be gitignored, not committed (see `ensure_gitignored`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
LINK_DIR_NAME = ".mozbridge"
|
|
16
|
+
LINK_FILE_NAME = "link.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Link:
|
|
21
|
+
org_id: int
|
|
22
|
+
org_slug: str
|
|
23
|
+
project_id: int
|
|
24
|
+
project_slug: str
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict:
|
|
27
|
+
return asdict(self)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def link_dir(cwd: Path) -> Path:
|
|
31
|
+
return cwd / LINK_DIR_NAME
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def link_path(cwd: Path) -> Path:
|
|
35
|
+
return link_dir(cwd) / LINK_FILE_NAME
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_link(cwd: Path) -> Link | None:
|
|
39
|
+
"""Return the link for `cwd`, or None if absent/unreadable/malformed."""
|
|
40
|
+
path = link_path(cwd)
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(path.read_text())
|
|
45
|
+
return Link(
|
|
46
|
+
org_id=int(data["org_id"]),
|
|
47
|
+
org_slug=str(data["org_slug"]),
|
|
48
|
+
project_id=int(data["project_id"]),
|
|
49
|
+
project_slug=str(data["project_slug"]),
|
|
50
|
+
)
|
|
51
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def save_link(cwd: Path, link: Link) -> Path:
|
|
56
|
+
d = link_dir(cwd)
|
|
57
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
path = link_path(cwd)
|
|
59
|
+
path.write_text(json.dumps(link.to_dict(), indent=2) + "\n")
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ensure_gitignored(cwd: Path) -> str:
|
|
64
|
+
"""Best-effort: append `.mozbridge/` to an existing .gitignore in `cwd`.
|
|
65
|
+
|
|
66
|
+
Never creates a .gitignore that didn't already exist — `cwd` may not be
|
|
67
|
+
a repo this tool owns, and conjuring one into existence for an arbitrary
|
|
68
|
+
directory is out of scope. Returns a human-readable status line.
|
|
69
|
+
"""
|
|
70
|
+
gitignore = cwd / ".gitignore"
|
|
71
|
+
entry = f"{LINK_DIR_NAME}/"
|
|
72
|
+
if not gitignore.exists():
|
|
73
|
+
return f"Note: no .gitignore found here — add `{entry}` to one yourself."
|
|
74
|
+
try:
|
|
75
|
+
content = gitignore.read_text()
|
|
76
|
+
except OSError:
|
|
77
|
+
return f"Note: could not read .gitignore — add `{entry}` to it yourself."
|
|
78
|
+
|
|
79
|
+
existing = {line.strip() for line in content.splitlines()}
|
|
80
|
+
if entry in existing or LINK_DIR_NAME in existing:
|
|
81
|
+
return ".gitignore already excludes .mozbridge/."
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
with gitignore.open("a") as f:
|
|
85
|
+
if content and not content.endswith("\n"):
|
|
86
|
+
f.write("\n")
|
|
87
|
+
f.write(f"{entry}\n")
|
|
88
|
+
except OSError:
|
|
89
|
+
return f"Note: could not update .gitignore — add `{entry}` to it yourself."
|
|
90
|
+
return "Added .mozbridge/ to .gitignore."
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Local docker build/login/push for `mozbridge publish --local`.
|
|
2
|
+
|
|
3
|
+
Every function here shells out to the real `docker` binary on the
|
|
4
|
+
developer's own machine — the one part of this CLI that is NOT a thin HTTP
|
|
5
|
+
wrapper. macOS-only (see main.py's module docstring): no Windows-specific
|
|
6
|
+
code paths here (no cmd.exe quoting, no Docker Desktop-for-Windows named
|
|
7
|
+
pipe handling).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# Every image name this CLI computes (compose.default_image_name /
|
|
18
|
+
# compose.image_name_for) is rooted at ghcr.io/<GHCR_ORG>/... — matching
|
|
19
|
+
# backend Settings.ghcr_org. The runtime-secrets response
|
|
20
|
+
# (backend/app/services/cicd_runtime_secrets.py) never emits a separate
|
|
21
|
+
# registry-host key: GHCR_TOKEN/GHCR_USER are always ghcr.io credentials,
|
|
22
|
+
# so there is nothing to parse a registry URL out of.
|
|
23
|
+
REGISTRY = "ghcr.io"
|
|
24
|
+
|
|
25
|
+
# A real `docker push` success ends with a line shaped like:
|
|
26
|
+
# latest: digest: sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae size: 1787
|
|
27
|
+
_DIGEST_RE = re.compile(r"digest:\s*(sha256:[0-9a-f]{64})")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DockerError(Exception):
|
|
31
|
+
"""Base for every docker-subprocess failure this module raises."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DockerUnavailableError(DockerError):
|
|
35
|
+
"""`docker` isn't installed, or its daemon isn't reachable."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DockerBuildError(DockerError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DockerLoginError(DockerError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DockerPushError(DockerError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def local_tag() -> str:
|
|
51
|
+
"""A short, collision-resistant tag for one locally-built image."""
|
|
52
|
+
return f"local-{uuid.uuid4().hex[:12]}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_docker_available() -> None:
|
|
56
|
+
"""Raise DockerUnavailableError with a clear, actionable message if
|
|
57
|
+
`docker` isn't on PATH or its daemon can't be reached.
|
|
58
|
+
|
|
59
|
+
Checked once, up front, before any build is attempted — never inferred
|
|
60
|
+
after the fact by pattern-matching a build's own cryptic failure text.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["docker", "version", "--format", "{{.Server.Version}}"],
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
timeout=15,
|
|
68
|
+
)
|
|
69
|
+
except FileNotFoundError as exc:
|
|
70
|
+
raise DockerUnavailableError(
|
|
71
|
+
"`docker` is not installed (or not on PATH). Install Docker Desktop "
|
|
72
|
+
"or the docker CLI, then try `mozbridge publish --local` again."
|
|
73
|
+
) from exc
|
|
74
|
+
except subprocess.TimeoutExpired as exc:
|
|
75
|
+
raise DockerUnavailableError(
|
|
76
|
+
"Timed out waiting for `docker version` to respond — is the Docker "
|
|
77
|
+
"daemon starting up or stuck? Try again once `docker ps` works."
|
|
78
|
+
) from exc
|
|
79
|
+
|
|
80
|
+
if result.returncode != 0:
|
|
81
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
82
|
+
raise DockerUnavailableError(
|
|
83
|
+
f"Docker daemon is not reachable: {detail or 'docker version failed'}. "
|
|
84
|
+
"Start Docker Desktop (or the docker daemon) and try again."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def docker_build(context_dir: Path, dockerfile_path: str, tag: str) -> None:
|
|
89
|
+
"""`docker build -t <tag> -f <context_dir>/<dockerfile_path> <context_dir>`."""
|
|
90
|
+
dockerfile = context_dir / dockerfile_path
|
|
91
|
+
cmd = ["docker", "build", "-t", tag, "-f", str(dockerfile), str(context_dir)]
|
|
92
|
+
try:
|
|
93
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
94
|
+
except FileNotFoundError as exc:
|
|
95
|
+
raise DockerUnavailableError("`docker` is not installed (or not on PATH).") from exc
|
|
96
|
+
if result.returncode != 0:
|
|
97
|
+
detail = (result.stderr or result.stdout or "docker build failed").strip()
|
|
98
|
+
raise DockerBuildError(detail)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def docker_login(registry: str, username: str, password: str) -> None:
|
|
102
|
+
"""`docker login <registry> -u <username> --password-stdin`, password piped
|
|
103
|
+
via stdin so it never appears in the process list or shell history.
|
|
104
|
+
"""
|
|
105
|
+
cmd = ["docker", "login", registry, "-u", username, "--password-stdin"]
|
|
106
|
+
try:
|
|
107
|
+
result = subprocess.run(cmd, input=password, capture_output=True, text=True)
|
|
108
|
+
except FileNotFoundError as exc:
|
|
109
|
+
raise DockerUnavailableError("`docker` is not installed (or not on PATH).") from exc
|
|
110
|
+
if result.returncode != 0:
|
|
111
|
+
detail = (result.stderr or result.stdout or "docker login failed").strip()
|
|
112
|
+
raise DockerLoginError(detail)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def docker_push(tag: str) -> str:
|
|
116
|
+
"""`docker push <tag>`, returning the real pushed digest (sha256:...).
|
|
117
|
+
|
|
118
|
+
Parsed from `docker push`'s own final "<ref>: digest: sha256:... size:
|
|
119
|
+
N" stdout line — the authoritative source that is always present on a
|
|
120
|
+
successful push — rather than a follow-up `docker inspect
|
|
121
|
+
--format='{{index .RepoDigests 0}}'` against the local image: RepoDigests
|
|
122
|
+
is only reliably populated by some local image-store configurations
|
|
123
|
+
after a push, so trusting the push command's own output avoids a
|
|
124
|
+
second, environment-dependent point of failure.
|
|
125
|
+
"""
|
|
126
|
+
cmd = ["docker", "push", tag]
|
|
127
|
+
try:
|
|
128
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
129
|
+
except FileNotFoundError as exc:
|
|
130
|
+
raise DockerUnavailableError("`docker` is not installed (or not on PATH).") from exc
|
|
131
|
+
if result.returncode != 0:
|
|
132
|
+
detail = (result.stderr or result.stdout or "docker push failed").strip()
|
|
133
|
+
raise DockerPushError(detail)
|
|
134
|
+
|
|
135
|
+
match = _DIGEST_RE.search(result.stdout)
|
|
136
|
+
if not match:
|
|
137
|
+
raise DockerPushError(
|
|
138
|
+
f"docker push succeeded but no digest could be parsed from its output:\n{result.stdout.strip()}"
|
|
139
|
+
)
|
|
140
|
+
return match.group(1)
|