Files
cli/sunbeam/images.py

926 lines
32 KiB
Python
Raw Normal View History

"""Image building, mirroring, and pushing to Gitea registry."""
import base64
import json
import os
import shutil
import socket
import subprocess
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from sunbeam.config import get_repo_root as _get_repo_root
from sunbeam.kube import kube, kube_out, get_lima_ip
from sunbeam.output import step, ok, warn, die
LIMA_VM = "sunbeam"
GITEA_ADMIN_USER = "gitea_admin"
MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "matrix", "media", "ory",
"storage", "vault-secrets-operator"]
AMD64_ONLY_IMAGES = [
("docker.io/lasuite/people-backend:latest", "studio", "people-backend", "latest"),
("docker.io/lasuite/people-frontend:latest", "studio", "people-frontend", "latest"),
("docker.io/lasuite/impress-backend:latest", "studio", "impress-backend", "latest"),
("docker.io/lasuite/impress-frontend:latest", "studio", "impress-frontend", "latest"),
("docker.io/lasuite/impress-y-provider:latest","studio", "impress-y-provider","latest"),
]
_MIRROR_SCRIPT_BODY = r'''
import json, hashlib, io, tarfile, os, subprocess, urllib.request
CONTENT_STORE = (
"/var/lib/rancher/k3s/agent/containerd"
"/io.containerd.content.v1.content/blobs/sha256"
)
def blob_path(h):
return os.path.join(CONTENT_STORE, h)
def blob_exists(h):
return os.path.exists(blob_path(h))
def read_blob(h):
with open(blob_path(h), "rb") as f:
return f.read()
def add_tar_entry(tar, name, data):
info = tarfile.TarInfo(name=name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
def get_image_digest(ref):
r = subprocess.run(
["ctr", "-n", "k8s.io", "images", "ls", "name==" + ref],
capture_output=True, text=True,
)
for line in r.stdout.splitlines():
if ref in line:
for part in line.split():
if part.startswith("sha256:"):
return part[7:]
return None
def fetch_index_from_registry(repo, tag):
url = (
"https://auth.docker.io/token"
f"?service=registry.docker.io&scope=repository:{repo}:pull"
)
with urllib.request.urlopen(url) as resp:
token = json.loads(resp.read())["token"]
accept = ",".join([
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
])
req = urllib.request.Request(
f"https://registry-1.docker.io/v2/{repo}/manifests/{tag}",
headers={"Authorization": f"Bearer {token}", "Accept": accept},
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
def make_oci_tar(ref, new_index_bytes, amd64_manifest_bytes):
ix_hex = hashlib.sha256(new_index_bytes).hexdigest()
amd64_hex = json.loads(new_index_bytes)["manifests"][0]["digest"].replace("sha256:", "")
layout = json.dumps({"imageLayoutVersion": "1.0.0"}).encode()
top = json.dumps({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [{
"mediaType": "application/vnd.oci.image.index.v1+json",
"digest": f"sha256:{ix_hex}",
"size": len(new_index_bytes),
"annotations": {"org.opencontainers.image.ref.name": ref},
}],
}, separators=(",", ":")).encode()
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:") as tar:
add_tar_entry(tar, "oci-layout", layout)
add_tar_entry(tar, "index.json", top)
add_tar_entry(tar, f"blobs/sha256/{ix_hex}", new_index_bytes)
add_tar_entry(tar, f"blobs/sha256/{amd64_hex}", amd64_manifest_bytes)
return buf.getvalue()
def import_ref(ref, tar_bytes):
subprocess.run(["ctr", "-n", "k8s.io", "images", "rm", ref], capture_output=True)
r = subprocess.run(
["ctr", "-n", "k8s.io", "images", "import", "--all-platforms", "-"],
input=tar_bytes, capture_output=True,
)
if r.returncode:
print(f" import failed: {r.stderr.decode()}")
return False
subprocess.run(
["ctr", "-n", "k8s.io", "images", "label", ref, "io.cri-containerd.image=managed"],
capture_output=True,
)
return True
def process(src, tgt, user, pwd):
print(f" {src}")
# Pull by tag — may fail on arm64-only images but still puts the index blob in the store
subprocess.run(["ctr", "-n", "k8s.io", "images", "pull", src], capture_output=True)
ix_hex = get_image_digest(src)
if ix_hex and blob_exists(ix_hex):
index = json.loads(read_blob(ix_hex))
else:
print(" index not in content store — fetching from docker.io...")
no_prefix = src.replace("docker.io/", "")
parts = no_prefix.split(":", 1)
repo, tag = parts[0], (parts[1] if len(parts) > 1 else "latest")
index = fetch_index_from_registry(repo, tag)
amd64 = next(
(m for m in index.get("manifests", [])
if m.get("platform", {}).get("architecture") == "amd64"
and m.get("platform", {}).get("os") == "linux"),
None,
)
if not amd64:
print(" skip: no linux/amd64 entry in index")
return
amd64_hex = amd64["digest"].replace("sha256:", "")
# Always pull by digest with --platform linux/amd64 to ensure all layer
# blobs are downloaded to the content store (the index pull in step 1 only
# fetches the manifest blob, not the layers, on an arm64 host).
print(" pulling amd64 manifest + layers by digest...")
repo_base = src.rsplit(":", 1)[0]
subprocess.run(
["ctr", "-n", "k8s.io", "images", "pull",
"--platform", "linux/amd64",
f"{repo_base}@sha256:{amd64_hex}"],
capture_output=True,
)
if not blob_exists(amd64_hex):
print(" failed: amd64 manifest blob missing after pull")
return
amd64_bytes = read_blob(amd64_hex)
# Patched index: keep amd64 + add arm64 alias pointing at same manifest
arm64 = {
"mediaType": amd64["mediaType"],
"digest": amd64["digest"],
"size": amd64["size"],
"platform": {"architecture": "arm64", "os": "linux"},
}
new_index = dict(index)
new_index["manifests"] = [amd64, arm64]
new_index_bytes = json.dumps(new_index, separators=(",", ":")).encode()
# Import with Gitea target name
if not import_ref(tgt, make_oci_tar(tgt, new_index_bytes, amd64_bytes)):
return
# Also patch the original source ref so pods still using docker.io name work
import_ref(src, make_oci_tar(src, new_index_bytes, amd64_bytes))
# Push to Gitea registry
print(f" pushing to registry...")
r = subprocess.run(
["ctr", "-n", "k8s.io", "images", "push",
"--user", f"{user}:{pwd}", tgt],
capture_output=True, text=True,
)
status = "OK" if r.returncode == 0 else f"PUSH FAILED: {r.stderr.strip()}"
print(f" {status}")
for _src, _tgt in TARGETS:
process(_src, _tgt, USER, PASS)
'''
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _capture_out(cmd, *, default=""):
r = subprocess.run(cmd, capture_output=True, text=True)
return r.stdout.strip() if r.returncode == 0 else default
def _run(cmd, *, check=True, input=None, capture=False, cwd=None):
text = not isinstance(input, bytes)
return subprocess.run(cmd, check=check, text=text, input=input,
capture_output=capture, cwd=cwd)
# ---------------------------------------------------------------------------
# Build environment & generic builder
# ---------------------------------------------------------------------------
@dataclass
class BuildEnv:
"""Resolved build environment — production (remote k8s) or local (Lima)."""
is_prod: bool
domain: str
registry: str
admin_pass: str
platform: str
ssh_host: str | None = None
def _get_build_env() -> BuildEnv:
"""Detect prod vs local and resolve registry credentials."""
from sunbeam import kube as _kube
is_prod = bool(_kube._ssh_host)
if is_prod:
domain = os.environ.get("SUNBEAM_DOMAIN", "sunbeam.pt")
else:
ip = get_lima_ip()
domain = f"{ip}.sslip.io"
b64 = kube_out("-n", "devtools", "get", "secret",
"gitea-admin-credentials", "-o=jsonpath={.data.password}")
if not b64:
die("gitea-admin-credentials secret not found -- run seed first.")
admin_pass = base64.b64decode(b64).decode()
return BuildEnv(
is_prod=is_prod,
domain=domain,
registry=f"src.{domain}",
admin_pass=admin_pass,
platform="linux/amd64" if is_prod else "linux/arm64",
ssh_host=_kube._ssh_host if is_prod else None,
)
def _buildctl_build_and_push(
env: BuildEnv,
image: str,
dockerfile: Path,
context_dir: Path,
*,
target: str | None = None,
build_args: dict[str, str] | None = None,
) -> None:
"""Build and push an image via buildkitd running in k3s.
Port-forwards to the buildkitd service in the `build` namespace,
runs `buildctl build`, and pushes the image directly to the Gitea
registry from inside the cluster. No local Docker daemon needed.
Works for both production and local Lima k3s.
"""
from sunbeam import kube as _kube
from sunbeam.tools import ensure_tool
buildctl = ensure_tool("buildctl")
kubectl = ensure_tool("kubectl")
with socket.socket() as s:
s.bind(("", 0))
local_port = s.getsockname()[1]
ctx_args = [_kube.context_arg()]
auth_token = base64.b64encode(
f"{GITEA_ADMIN_USER}:{env.admin_pass}".encode()
).decode()
docker_cfg = {"auths": {env.registry: {"auth": auth_token}}}
with tempfile.TemporaryDirectory() as tmpdir:
cfg_path = Path(tmpdir) / "config.json"
cfg_path.write_text(json.dumps(docker_cfg))
pf = subprocess.Popen(
[str(kubectl), *ctx_args,
"port-forward", "-n", "build", "svc/buildkitd",
f"{local_port}:1234"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
deadline = time.time() + 15
while time.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", local_port), timeout=1):
break
except OSError:
time.sleep(0.3)
else:
pf.terminate()
raise RuntimeError(
f"buildkitd port-forward on :{local_port} did not become ready within 15s"
)
try:
cmd = [
str(buildctl), "build",
"--frontend", "dockerfile.v0",
"--local", f"context={context_dir}",
"--local", f"dockerfile={dockerfile.parent}",
"--opt", f"filename={dockerfile.name}",
"--opt", f"platform={env.platform}",
"--output", f"type=image,name={image},push=true",
]
if target:
cmd += ["--opt", f"target={target}"]
if build_args:
for k, v in build_args.items():
cmd += ["--opt", f"build-arg:{k}={v}"]
run_env = {
**os.environ,
"BUILDKIT_HOST": f"tcp://127.0.0.1:{local_port}",
"DOCKER_CONFIG": tmpdir,
}
subprocess.run(cmd, env=run_env, check=True)
finally:
pf.terminate()
pf.wait()
def _build_image(
env: BuildEnv,
image: str,
dockerfile: Path,
context_dir: Path,
*,
target: str | None = None,
build_args: dict[str, str] | None = None,
push: bool = False,
cleanup_paths: list[Path] | None = None,
) -> None:
"""Build a container image via buildkitd and push to the Gitea registry.
Both production and local builds use the in-cluster buildkitd. The image
is built for the environment's platform and pushed directly to the registry.
"""
ok(f"Building image ({env.platform}{f', {target} target' if target else ''})...")
if not push:
warn("Builds require --push (buildkitd pushes directly to registry); skipping.")
return
try:
_buildctl_build_and_push(
env=env,
image=image,
dockerfile=dockerfile,
context_dir=context_dir,
target=target,
build_args=build_args,
)
finally:
for p in (cleanup_paths or []):
if p.exists():
if p.is_dir():
shutil.rmtree(str(p), ignore_errors=True)
else:
p.unlink(missing_ok=True)
def _get_node_addresses() -> list[str]:
"""Return one SSH-reachable IP per node in the cluster.
Each node may report both IPv4 and IPv6 InternalIPs. We pick one per
node name, preferring IPv4 (more likely to have SSH reachable).
"""
# Get "nodeName ip" pairs
raw = kube_out(
"get", "nodes",
"-o", "jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}"
"{range .status.addresses[?(@.type==\"InternalIP\")]}{.address}{\" \"}{end}{\"\\n\"}{end}",
)
lines = [l.strip() for l in raw.strip().split("\n") if l.strip()]
seen_nodes: dict[str, str] = {}
# Lines alternate: node name, then space-separated IPs
i = 0
while i < len(lines) - 1:
node_name = lines[i]
addrs = lines[i + 1].split()
i += 2
if node_name in seen_nodes:
continue
# Prefer IPv4 (no colons)
ipv4 = [a for a in addrs if ":" not in a]
seen_nodes[node_name] = ipv4[0] if ipv4 else addrs[0]
return list(seen_nodes.values())
def _ctr_pull_on_nodes(env: BuildEnv, images: list[str]):
"""SSH to each k3s node and pull images into containerd.
For k3s with imagePullPolicy: IfNotPresent, the image must be present
in containerd *before* the rollout restart. buildkitd pushes to the
Gitea registry; we SSH to each node and ctr-pull so containerd has the
fresh layers.
"""
if not images:
return
nodes = _get_node_addresses()
if not nodes:
warn("Could not detect node addresses; skipping ctr pull.")
return
ssh_user = env.ssh_host.split("@")[0] if env.ssh_host and "@" in env.ssh_host else "root"
for node_ip in nodes:
for img in images:
ok(f"Pulling {img} into containerd on {node_ip}...")
r = subprocess.run(
["ssh", "-p", "2222",
"-o", "StrictHostKeyChecking=no", f"{ssh_user}@{node_ip}",
f"sudo ctr -n k8s.io images pull {img}"],
capture_output=True, text=True,
)
if r.returncode != 0:
die(f"ctr pull failed on {node_ip}: {r.stderr.strip()}")
ok(f"Pulled {img} on {node_ip}")
def _deploy_rollout(env: BuildEnv, deployments: list[str], namespace: str,
timeout: str = "180s", images: list[str] | None = None):
"""Apply manifests for the target namespace and rolling-restart the given deployments.
For single-node k3s (env.ssh_host is set), pulls *images* into containerd
on the node via SSH before restarting, so imagePullPolicy: IfNotPresent
picks up the new layers.
"""
from sunbeam.manifests import cmd_apply
cmd_apply(env="production" if env.is_prod else "local", domain=env.domain,
namespace=namespace)
# Pull fresh images into containerd on every node before rollout
if images:
_ctr_pull_on_nodes(env, images)
for dep in deployments:
ok(f"Rolling {dep}...")
kube("rollout", "restart", f"deployment/{dep}", "-n", namespace)
for dep in deployments:
kube("rollout", "status", f"deployment/{dep}", "-n", namespace,
f"--timeout={timeout}")
ok("Redeployed.")
# ---------------------------------------------------------------------------
# Mirroring
# ---------------------------------------------------------------------------
def cmd_mirror(domain: str = "", gitea_admin_pass: str = ""):
"""Patch amd64-only images with an arm64 alias and push to Gitea registry."""
if not domain:
ip = get_lima_ip()
domain = f"{ip}.sslip.io"
if not gitea_admin_pass:
b64 = kube_out("-n", "devtools", "get", "secret",
"gitea-admin-credentials", "-o=jsonpath={.data.password}")
if b64:
gitea_admin_pass = base64.b64decode(b64).decode()
step("Mirroring amd64-only images to Gitea registry...")
registry = f"src.{domain}"
targets = [
(src, f"{registry}/{org}/{repo}:{tag}")
for src, org, repo, tag in AMD64_ONLY_IMAGES
]
header = (
f"TARGETS = {repr(targets)}\n"
f"USER = {repr(GITEA_ADMIN_USER)}\n"
f"PASS = {repr(gitea_admin_pass)}\n"
)
script = header + _MIRROR_SCRIPT_BODY
_run(["limactl", "shell", LIMA_VM, "sudo", "python3", "-c", script])
# Delete any pods stuck in image-pull error states
ok("Clearing image-pull-error pods...")
error_reasons = {"ImagePullBackOff", "ErrImagePull", "ErrImageNeverPull"}
for ns in MANAGED_NS:
pods_raw = kube_out(
"-n", ns, "get", "pods",
"-o=jsonpath={range .items[*]}"
"{.metadata.name}:{.status.containerStatuses[0].state.waiting.reason}\\n"
"{end}",
)
for line in pods_raw.splitlines():
if not line:
continue
parts = line.split(":", 1)
if len(parts) == 2 and parts[1] in error_reasons:
kube("delete", "pod", parts[0], "-n", ns,
"--ignore-not-found", check=False)
ok("Done.")
# ---------------------------------------------------------------------------
# Build dispatch
# ---------------------------------------------------------------------------
def cmd_build(what: str, push: bool = False, deploy: bool = False):
"""Build an image. Pass push=True to push, deploy=True to also apply + rollout."""
try:
_cmd_build(what, push=push, deploy=deploy)
except subprocess.CalledProcessError as exc:
cmd_str = " ".join(str(a) for a in exc.cmd)
die(f"Build step failed (exit {exc.returncode}): {cmd_str}")
def _cmd_build(what: str, push: bool = False, deploy: bool = False):
if what == "proxy":
_build_proxy(push=push, deploy=deploy)
elif what == "integration":
_build_integration(push=push, deploy=deploy)
elif what == "kratos-admin":
_build_kratos_admin(push=push, deploy=deploy)
elif what == "meet":
_build_meet(push=push, deploy=deploy)
elif what == "docs-frontend":
_build_la_suite_frontend(
app="docs-frontend",
repo_dir=_get_repo_root() / "docs",
workspace_rel="src/frontend",
app_rel="src/frontend/apps/impress",
dockerfile_rel="src/frontend/Dockerfile",
image_name="impress-frontend",
deployment="docs-frontend",
namespace="lasuite",
push=push,
deploy=deploy,
)
elif what in ("people", "people-frontend"):
_build_people(push=push, deploy=deploy)
elif what in ("messages", "messages-backend", "messages-frontend",
"messages-mta-in", "messages-mta-out", "messages-mpa",
"messages-socks-proxy"):
_build_messages(what, push=push, deploy=deploy)
elif what == "tuwunel":
_build_tuwunel(push=push, deploy=deploy)
else:
die(f"Unknown build target: {what}")
# ---------------------------------------------------------------------------
# Per-service build functions
# ---------------------------------------------------------------------------
def _build_proxy(push: bool = False, deploy: bool = False):
env = _get_build_env()
proxy_dir = _get_repo_root() / "proxy"
if not proxy_dir.is_dir():
die(f"Proxy source not found at {proxy_dir}")
image = f"{env.registry}/studio/proxy:latest"
step(f"Building sunbeam-proxy -> {image} ...")
# Both local and production use the same Dockerfile and build via
# the in-cluster buildkitd. The buildkitd on each environment
# compiles natively for its own architecture (arm64 on Lima,
# amd64 on Scaleway).
_build_image(env, image, proxy_dir / "Dockerfile", proxy_dir, push=push)
if deploy:
_deploy_rollout(env, ["pingora"], "ingress", timeout="120s",
images=[image])
def _build_tuwunel(push: bool = False, deploy: bool = False):
"""Build tuwunel Matrix homeserver image from source."""
env = _get_build_env()
tuwunel_dir = _get_repo_root() / "tuwunel"
if not tuwunel_dir.is_dir():
die(f"Tuwunel source not found at {tuwunel_dir}")
image = f"{env.registry}/studio/tuwunel:latest"
step(f"Building tuwunel -> {image} ...")
# buildkitd runs on the x86_64 server — builds natively, no cross-compilation.
_build_image(env, image, tuwunel_dir / "Dockerfile", tuwunel_dir, push=push)
if deploy:
_deploy_rollout(env, ["tuwunel"], "matrix", timeout="180s",
images=[image])
def _build_integration(push: bool = False, deploy: bool = False):
env = _get_build_env()
sunbeam_dir = _get_repo_root()
integration_service_dir = sunbeam_dir / "integration-service"
dockerfile = integration_service_dir / "Dockerfile"
dockerignore = integration_service_dir / ".dockerignore"
if not dockerfile.exists():
die(f"integration-service Dockerfile not found at {dockerfile}")
if not (sunbeam_dir / "integration" / "packages" / "widgets").is_dir():
die(f"integration repo not found at {sunbeam_dir / 'integration'} -- "
"run: cd sunbeam && git clone https://github.com/suitenumerique/integration.git")
image = f"{env.registry}/studio/integration:latest"
step(f"Building integration -> {image} ...")
# .dockerignore needs to be at context root (sunbeam/)
root_ignore = sunbeam_dir / ".dockerignore"
copied_ignore = False
if not root_ignore.exists() and dockerignore.exists():
shutil.copy(str(dockerignore), str(root_ignore))
copied_ignore = True
try:
_build_image(env, image, dockerfile, sunbeam_dir, push=push)
finally:
if copied_ignore and root_ignore.exists():
root_ignore.unlink()
if deploy:
_deploy_rollout(env, ["integration"], "lasuite", timeout="120s")
def _build_kratos_admin(push: bool = False, deploy: bool = False):
env = _get_build_env()
kratos_admin_dir = _get_repo_root() / "kratos-admin"
if not kratos_admin_dir.is_dir():
die(f"kratos-admin source not found at {kratos_admin_dir}")
image = f"{env.registry}/studio/kratos-admin-ui:latest"
step(f"Building kratos-admin-ui -> {image} ...")
_build_image(
env, image,
kratos_admin_dir / "Dockerfile", kratos_admin_dir,
push=push,
)
if deploy:
_deploy_rollout(env, ["kratos-admin-ui"], "ory", timeout="120s")
def _build_meet(push: bool = False, deploy: bool = False):
"""Build meet-backend and meet-frontend images from source."""
env = _get_build_env()
meet_dir = _get_repo_root() / "meet"
if not meet_dir.is_dir():
die(f"meet source not found at {meet_dir}")
backend_image = f"{env.registry}/studio/meet-backend:latest"
frontend_image = f"{env.registry}/studio/meet-frontend:latest"
step(f"Building meet-backend -> {backend_image} ...")
_build_image(
env, backend_image,
meet_dir / "Dockerfile", meet_dir,
target="backend-production",
push=push,
)
step(f"Building meet-frontend -> {frontend_image} ...")
frontend_dockerfile = meet_dir / "src" / "frontend" / "Dockerfile"
if not frontend_dockerfile.exists():
die(f"meet frontend Dockerfile not found at {frontend_dockerfile}")
_build_image(
env, frontend_image,
frontend_dockerfile, meet_dir,
target="frontend-production",
build_args={"VITE_API_BASE_URL": ""},
push=push,
)
if deploy:
_deploy_rollout(
env,
["meet-backend", "meet-celery-worker", "meet-frontend"],
"lasuite",
)
def _build_people(push: bool = False, deploy: bool = False):
"""Build people-frontend from source."""
env = _get_build_env()
people_dir = _get_repo_root() / "people"
if not people_dir.is_dir():
die(f"people source not found at {people_dir}")
if not shutil.which("yarn"):
die("yarn not found on PATH -- install Node.js + yarn first (nvm use 22).")
workspace_dir = people_dir / "src" / "frontend"
app_dir = people_dir / "src" / "frontend" / "apps" / "desk"
dockerfile = people_dir / "src" / "frontend" / "Dockerfile"
if not dockerfile.exists():
die(f"Dockerfile not found at {dockerfile}")
image = f"{env.registry}/studio/people-frontend:latest"
step(f"Building people-frontend -> {image} ...")
ok("Updating yarn.lock (yarn install in workspace)...")
_run(["yarn", "install", "--ignore-engines"], cwd=str(workspace_dir))
ok("Regenerating cunningham design tokens (cunningham -g css,ts)...")
cunningham_bin = workspace_dir / "node_modules" / ".bin" / "cunningham"
_run([str(cunningham_bin), "-g", "css,ts", "-o", "src/cunningham", "--utility-classes"],
cwd=str(app_dir))
_build_image(
env, image,
dockerfile, people_dir,
target="frontend-production",
build_args={"DOCKER_USER": "101"},
push=push,
)
if deploy:
_deploy_rollout(env, ["people-frontend"], "lasuite")
def _build_messages(what: str, push: bool = False, deploy: bool = False):
"""Build one or all messages images from source."""
env = _get_build_env()
messages_dir = _get_repo_root() / "messages"
if not messages_dir.is_dir():
die(f"messages source not found at {messages_dir}")
all_components = [
("messages-backend", "messages-backend", "src/backend/Dockerfile", "runtime-distroless-prod"),
("messages-frontend", "messages-frontend", "src/frontend/Dockerfile", "runtime-prod"),
("messages-mta-in", "messages-mta-in", "src/mta-in/Dockerfile", None),
("messages-mta-out", "messages-mta-out", "src/mta-out/Dockerfile", None),
("messages-mpa", "messages-mpa", "src/mpa/rspamd/Dockerfile", None),
("messages-socks-proxy", "messages-socks-proxy", "src/socks-proxy/Dockerfile", None),
]
components = all_components if what == "messages" else [
c for c in all_components if c[0] == what
]
built_images = []
for component, image_name, dockerfile_rel, target in components:
dockerfile = messages_dir / dockerfile_rel
if not dockerfile.exists():
warn(f"Dockerfile not found at {dockerfile} -- skipping {component}")
continue
image = f"{env.registry}/studio/{image_name}:latest"
context_dir = dockerfile.parent
step(f"Building {component} -> {image} ...")
# Patch ghcr.io/astral-sh/uv COPY for messages-backend on local builds
cleanup_paths: list[Path] = []
actual_dockerfile = dockerfile
if not env.is_prod and image_name == "messages-backend":
actual_dockerfile, cleanup_paths = _patch_dockerfile_uv(
dockerfile, context_dir, env.platform
)
_build_image(
env, image,
actual_dockerfile, context_dir,
target=target,
push=push,
cleanup_paths=cleanup_paths,
)
built_images.append(image)
if deploy and built_images:
_deploy_rollout(
env,
["messages-backend", "messages-worker", "messages-frontend",
"messages-mta-in", "messages-mta-out", "messages-mpa",
"messages-socks-proxy"],
"lasuite",
)
def _build_la_suite_frontend(
app: str,
repo_dir: Path,
workspace_rel: str,
app_rel: str,
dockerfile_rel: str,
image_name: str,
deployment: str,
namespace: str,
push: bool = False,
deploy: bool = False,
):
"""Build a La Suite frontend image from source and push to the Gitea registry."""
env = _get_build_env()
if not shutil.which("yarn"):
die("yarn not found on PATH — install Node.js + yarn first (nvm use 22).")
workspace_dir = repo_dir / workspace_rel
app_dir = repo_dir / app_rel
dockerfile = repo_dir / dockerfile_rel
if not repo_dir.is_dir():
die(f"{app} source not found at {repo_dir}")
if not dockerfile.exists():
die(f"Dockerfile not found at {dockerfile}")
image = f"{env.registry}/studio/{image_name}:latest"
step(f"Building {app} -> {image} ...")
ok("Updating yarn.lock (yarn install in workspace)...")
_run(["yarn", "install", "--ignore-engines"], cwd=str(workspace_dir))
ok("Regenerating cunningham design tokens (yarn build-theme)...")
_run(["yarn", "build-theme"], cwd=str(app_dir))
_build_image(
env, image,
dockerfile, repo_dir,
target="frontend-production",
build_args={"DOCKER_USER": "101"},
push=push,
)
if deploy:
_deploy_rollout(env, [deployment], namespace)
def _patch_dockerfile_uv(
dockerfile_path: Path,
messages_dir: Path,
platform: str,
) -> tuple[Path, list[Path]]:
"""Download uv from GitHub releases and return a patched Dockerfile path.
The docker-container buildkit driver cannot access the host Docker daemon's
local image cache, so --build-context docker-image:// silently falls through
to docker.io. oci-layout:// is the only local-context type that works, but
it requires producing an OCI tar and extracting it.
The simplest reliable approach: stage the downloaded binaries inside the
build context directory and patch the Dockerfile to use a plain COPY instead
of COPY --from=ghcr.io/... The patched Dockerfile is written next to the
original; both it and the staging dir are cleaned up by the caller.
Returns (patched_dockerfile_path, [paths_to_cleanup]).
"""
import re as _re
import tarfile as _tf
import urllib.request as _url
content = dockerfile_path.read_text()
copy_match = _re.search(
r'(COPY\s+--from=ghcr\.io/astral-sh/uv@sha256:[a-f0-9]+\s+/uv\s+/uvx\s+/bin/)',
content,
)
if not copy_match:
return (dockerfile_path, [])
original_copy = copy_match.group(1)
version_match = _re.search(r'oci://ghcr\.io/astral-sh/uv:(\S+)', content)
if not version_match:
warn("Could not find uv version comment in Dockerfile; ghcr.io pull may fail.")
return (dockerfile_path, [])
version = version_match.group(1)
arch = "x86_64" if "amd64" in platform else "aarch64"
url = (
f"https://github.com/astral-sh/uv/releases/download/{version}/"
f"uv-{arch}-unknown-linux-gnu.tar.gz"
)
stage_dir = messages_dir / "_sunbeam_uv_stage"
patched_df = dockerfile_path.parent / "Dockerfile._sunbeam_patched"
cleanup = [stage_dir, patched_df]
ok(f"Downloading uv {version} ({arch}) from GitHub releases to bypass ghcr.io...")
try:
stage_dir.mkdir(exist_ok=True)
tarball = stage_dir / "uv.tar.gz"
_url.urlretrieve(url, str(tarball))
with _tf.open(str(tarball), "r:gz") as tf:
for member in tf.getmembers():
name = os.path.basename(member.name)
if name in ("uv", "uvx") and member.isfile():
member.name = name
tf.extract(member, str(stage_dir))
tarball.unlink()
uv_path = stage_dir / "uv"
uvx_path = stage_dir / "uvx"
if not uv_path.exists():
warn("uv binary not found in release tarball; build may fail.")
return (dockerfile_path, cleanup)
uv_path.chmod(0o755)
if uvx_path.exists():
uvx_path.chmod(0o755)
patched = content.replace(
original_copy,
"COPY _sunbeam_uv_stage/uv _sunbeam_uv_stage/uvx /bin/",
)
patched_df.write_text(patched)
ok(f" uv {version} staged; using patched Dockerfile.")
return (patched_df, cleanup)
except Exception as exc:
warn(f"Failed to stage uv binaries: {exc}")
return (dockerfile_path, cleanup)