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,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
@@ -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)