Initial commit
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
"""Parse docker-compose JSON and run image builds for selected components."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from deploy.paths import ProjectPaths
|
||||
import deploy.ui as ui
|
||||
from deploy.util import (
|
||||
compute_inputs_hash,
|
||||
dedupe_preserve,
|
||||
local_docker_image_tags,
|
||||
local_image_layer_fp,
|
||||
read_file_if_exists,
|
||||
sanitize_ref,
|
||||
)
|
||||
|
||||
FROMCHAT_IMAGE_SERVICES = frozenset(
|
||||
{"main", "messaging", "file_storage", "postgres", "web", "caddy", "updater"}
|
||||
)
|
||||
FROMCHAT_PREFIX = "fromchat/"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PushableService:
|
||||
service: str
|
||||
image_tag: str
|
||||
dockerfile: Path
|
||||
build_context: Path
|
||||
build_target: str
|
||||
input_hash: str
|
||||
compose_root: Path
|
||||
|
||||
|
||||
def fromchat_image(service: str, tag: str) -> str:
|
||||
return f"{FROMCHAT_PREFIX}{service}:{tag}"
|
||||
|
||||
|
||||
class ComposeBuildPhase:
|
||||
def __init__(
|
||||
self,
|
||||
paths: ProjectPaths,
|
||||
*,
|
||||
tag: str,
|
||||
platform: str,
|
||||
use_docker_build: bool,
|
||||
) -> None:
|
||||
self._paths = paths
|
||||
self._tag = tag
|
||||
self._platform = platform
|
||||
self._use_docker_build = use_docker_build
|
||||
|
||||
def load_compose_json(self, compose_root: Path) -> dict:
|
||||
env = os.environ.copy()
|
||||
env["COMPOSE_PROFILES"] = "production"
|
||||
p = subprocess.run(
|
||||
["docker", "compose", "-f", "compose.yml", "config", "--format", "json"],
|
||||
cwd=compose_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if p.returncode != 0:
|
||||
ui.error(f"docker compose config failed in {compose_root}")
|
||||
if p.stderr:
|
||||
print(p.stderr, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return json.loads(p.stdout)
|
||||
|
||||
def list_services(self, compose_root: Path) -> list[str]:
|
||||
env = os.environ.copy()
|
||||
env["COMPOSE_PROFILES"] = "production"
|
||||
p = subprocess.run(
|
||||
["docker", "compose", "-f", "compose.yml", "config", "--services"],
|
||||
cwd=compose_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if p.returncode != 0:
|
||||
return []
|
||||
return [s.strip() for s in p.stdout.splitlines() if s.strip()]
|
||||
|
||||
def _make_pushable(
|
||||
self,
|
||||
*,
|
||||
service: str,
|
||||
image_tag: str,
|
||||
dockerfile: Path,
|
||||
build_context: Path,
|
||||
build_target: str,
|
||||
compose_root: Path,
|
||||
) -> PushableService | None:
|
||||
if not dockerfile.is_file():
|
||||
ui.error(f"Could not find Dockerfile for {service} at {dockerfile}")
|
||||
sys.exit(1)
|
||||
if not self._paths.input_hash_script.is_file():
|
||||
ui.error(f"Missing {self._paths.input_hash_script}")
|
||||
sys.exit(1)
|
||||
h = compute_inputs_hash(
|
||||
build_context,
|
||||
dockerfile,
|
||||
hash_script=self._paths.input_hash_script,
|
||||
)
|
||||
if not h:
|
||||
ui.error(f"Failed to compute input hash for {service}")
|
||||
sys.exit(1)
|
||||
return PushableService(
|
||||
service=service,
|
||||
image_tag=image_tag,
|
||||
dockerfile=dockerfile,
|
||||
build_context=build_context,
|
||||
build_target=build_target,
|
||||
input_hash=h,
|
||||
compose_root=compose_root,
|
||||
)
|
||||
|
||||
def collect_pushable(
|
||||
self,
|
||||
compose: dict,
|
||||
services: list[str],
|
||||
compose_root: Path,
|
||||
) -> list[PushableService]:
|
||||
out: list[PushableService] = []
|
||||
svc_map = compose.get("services") or {}
|
||||
for service in services:
|
||||
if service not in FROMCHAT_IMAGE_SERVICES:
|
||||
continue
|
||||
spec = svc_map.get(service)
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
build = spec.get("build")
|
||||
if not isinstance(build, dict):
|
||||
continue
|
||||
image_tag = fromchat_image(service, self._tag)
|
||||
dockerfile_rel = (build.get("dockerfile") or "").strip()
|
||||
context_rel = (build.get("context") or ".").strip()
|
||||
build_target = (build.get("target") or "").strip()
|
||||
if context_rel in (".", "./", ""):
|
||||
build_context = compose_root
|
||||
elif context_rel == "..":
|
||||
build_context = compose_root.parent
|
||||
elif context_rel.startswith("/"):
|
||||
build_context = Path(context_rel)
|
||||
else:
|
||||
build_context = (compose_root / context_rel).resolve()
|
||||
if dockerfile_rel:
|
||||
if dockerfile_rel.startswith("/"):
|
||||
dockerfile = Path(dockerfile_rel)
|
||||
elif (compose_root / dockerfile_rel).is_file():
|
||||
dockerfile = compose_root / dockerfile_rel
|
||||
else:
|
||||
dockerfile = build_context / dockerfile_rel
|
||||
else:
|
||||
dockerfile = build_context / "Dockerfile"
|
||||
ps = self._make_pushable(
|
||||
service=service,
|
||||
image_tag=image_tag,
|
||||
dockerfile=dockerfile,
|
||||
build_context=build_context,
|
||||
build_target=build_target,
|
||||
compose_root=compose_root,
|
||||
)
|
||||
if ps:
|
||||
out.append(ps)
|
||||
return out
|
||||
|
||||
def collect_caddy_pushable(self) -> PushableService | None:
|
||||
caddy_dir = self._paths.caddy_build_dir
|
||||
if not caddy_dir:
|
||||
ui.error(
|
||||
"Caddy selected but backend/src/caddy not found. "
|
||||
"Set FROMCHAT_BACKEND_DIR to a backend checkout."
|
||||
)
|
||||
sys.exit(1)
|
||||
return self._make_pushable(
|
||||
service="caddy",
|
||||
image_tag=fromchat_image("caddy", self._tag),
|
||||
dockerfile=caddy_dir / "Dockerfile",
|
||||
build_context=caddy_dir,
|
||||
build_target="",
|
||||
compose_root=caddy_dir,
|
||||
)
|
||||
|
||||
def collect_updater_pushable(self) -> PushableService | None:
|
||||
updater_dir = self._paths.updater_dir
|
||||
if not updater_dir:
|
||||
ui.error(
|
||||
"Updater selected but ../updater not found. Set FROMCHAT_UPDATER_DIR."
|
||||
)
|
||||
sys.exit(1)
|
||||
dockerfile = updater_dir / "Dockerfile"
|
||||
return self._make_pushable(
|
||||
service="updater",
|
||||
image_tag=fromchat_image("updater", "latest"),
|
||||
dockerfile=dockerfile,
|
||||
build_context=updater_dir,
|
||||
build_target="",
|
||||
compose_root=updater_dir,
|
||||
)
|
||||
|
||||
def plan_builds(self, pushable: list[PushableService]) -> list[PushableService]:
|
||||
cache_root = self._paths.local_image_cache_dir
|
||||
cache_root.mkdir(parents=True, exist_ok=True)
|
||||
to_build: list[PushableService] = []
|
||||
for ps in pushable:
|
||||
key = sanitize_ref(ps.image_tag)
|
||||
cache_file = cache_root / key / "input.sha256"
|
||||
prev = read_file_if_exists(cache_file).strip()
|
||||
fp = local_image_layer_fp(ps.image_tag)
|
||||
if prev and prev == ps.input_hash and fp:
|
||||
continue
|
||||
to_build.append(ps)
|
||||
return to_build
|
||||
|
||||
def run_builds(self, to_build: list[PushableService]) -> list[str]:
|
||||
if not to_build:
|
||||
ui.success("Build skipped (no Docker inputs changed)")
|
||||
return []
|
||||
ui.step(f"Building {len(to_build)} image(s) locally")
|
||||
for ps in to_build:
|
||||
ui.substep(f"Building {ps.service} -> {ps.image_tag}...")
|
||||
if self._use_docker_build:
|
||||
args = [
|
||||
"docker",
|
||||
"build",
|
||||
"--pull",
|
||||
"--file",
|
||||
str(ps.dockerfile),
|
||||
"--tag",
|
||||
ps.image_tag,
|
||||
]
|
||||
else:
|
||||
args = [
|
||||
"docker",
|
||||
"buildx",
|
||||
"build",
|
||||
"--pull",
|
||||
"--platform",
|
||||
self._platform,
|
||||
"--file",
|
||||
str(ps.dockerfile),
|
||||
"--tag",
|
||||
ps.image_tag,
|
||||
"--output=type=docker",
|
||||
"--provenance=false",
|
||||
"--sbom=false",
|
||||
]
|
||||
if ps.build_target:
|
||||
args.extend(["--target", ps.build_target])
|
||||
args.append(str(ps.build_context))
|
||||
if subprocess.run(args, cwd=ps.compose_root).returncode != 0:
|
||||
ui.error(f"Build failed for {ps.service}")
|
||||
sys.exit(1)
|
||||
built: list[str] = []
|
||||
for ps in to_build:
|
||||
key = sanitize_ref(ps.image_tag)
|
||||
d = self._paths.local_image_cache_dir / key
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "input.sha256").write_text(ps.input_hash, encoding="utf-8")
|
||||
built.append(ps.image_tag)
|
||||
ui.success(f"Build complete! {len(built)} image(s) built")
|
||||
return built
|
||||
|
||||
|
||||
def classify_compose_images(
|
||||
compose: dict,
|
||||
service_order: list[str],
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Return (fromchat images to transfer, third-party images to transfer)."""
|
||||
services = compose.get("services") or {}
|
||||
fromchat_images: list[str] = []
|
||||
third_party: list[str] = []
|
||||
for name in service_order:
|
||||
spec = services.get(name)
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
image_from = (spec.get("image") or "").strip()
|
||||
if not image_from:
|
||||
continue
|
||||
if image_from.startswith(FROMCHAT_PREFIX):
|
||||
fromchat_images.append(image_from)
|
||||
else:
|
||||
third_party.append(image_from)
|
||||
return dedupe_preserve(fromchat_images), dedupe_preserve(third_party)
|
||||
|
||||
|
||||
def images_present_locally(images: list[str], local_tags: set[str]) -> list[str]:
|
||||
return dedupe_preserve([img for img in images if img in local_tags])
|
||||
|
||||
|
||||
def verify_fromchat_images_local(fromchat_images: list[str], local_tags: set[str], ui_mod: object) -> None:
|
||||
missing = [img for img in fromchat_images if img not in local_tags]
|
||||
if missing:
|
||||
ui_mod.error(
|
||||
"Missing locally built images: " + ", ".join(missing)
|
||||
)
|
||||
print("Build them on this machine first, then deploy.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def pull_third_party_images(images: list[str], *, platform: str | None = None) -> None:
|
||||
if not images:
|
||||
return
|
||||
import deploy.docker_local as docker_local
|
||||
|
||||
docker_local.pull_images(images, platform=platform)
|
||||
|
||||
|
||||
def generate_production_compose(
|
||||
paths: ProjectPaths,
|
||||
*,
|
||||
components: list[str],
|
||||
tag: str,
|
||||
output: Path,
|
||||
) -> None:
|
||||
ui.step("Generating production compose.yml")
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(paths.generate_compose_script),
|
||||
"--tag",
|
||||
tag,
|
||||
"--components",
|
||||
",".join(components),
|
||||
"--output",
|
||||
str(output),
|
||||
]
|
||||
if "backend" in components and paths.backend_dir:
|
||||
cmd.extend(["--backend-compose", str(paths.backend_dir / "compose.yml")])
|
||||
if "frontend" in components and paths.web_dir:
|
||||
cmd.extend(["--frontend-compose", str(paths.web_dir / "compose.yml")])
|
||||
if "caddy" in components:
|
||||
cmd.extend(["--caddy-compose", str(paths.caddy_compose)])
|
||||
if subprocess.run(cmd).returncode != 0:
|
||||
ui.error("generate-compose.py failed")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user