mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 11:05:05 +03:00
273 lines
9.8 KiB
Python
273 lines
9.8 KiB
Python
"""Parse docker-compose JSON and run image builds."""
|
|
|
|
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_image_layer_fp,
|
|
read_file_if_exists,
|
|
sanitize_ref,
|
|
)
|
|
|
|
|
|
def remote_project_name(server: str, deploy_path: str) -> str:
|
|
r = subprocess.run(
|
|
["ssh", server, f"dirname {deploy_path}/deployment/docker-compose.yml"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
compose_dir = r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else f"{deploy_path}/deployment"
|
|
r2 = subprocess.run(["ssh", server, f"basename {compose_dir}"], capture_output=True, text=True)
|
|
if r2.returncode == 0 and r2.stdout.strip():
|
|
return r2.stdout.strip()
|
|
return "deployment"
|
|
|
|
|
|
@dataclass
|
|
class PushableService:
|
|
service: str
|
|
image_tag: str
|
|
dockerfile: Path
|
|
build_context: Path
|
|
build_target: str
|
|
input_hash: str
|
|
|
|
|
|
class ComposeBuildPhase:
|
|
def __init__(
|
|
self,
|
|
paths: ProjectPaths,
|
|
*,
|
|
project_name: str,
|
|
platform: str,
|
|
use_docker_build: bool,
|
|
) -> None:
|
|
self._paths = paths
|
|
self._project_name = project_name
|
|
self._platform = platform
|
|
self._use_docker_build = use_docker_build
|
|
|
|
def load_compose_json(self, deployment_dir: Path) -> dict:
|
|
env = os.environ.copy()
|
|
env["COMPOSE_PROFILES"] = "production"
|
|
p = subprocess.run(
|
|
["docker", "compose", "-f", "docker-compose.yml", "config", "--format", "json"],
|
|
cwd=deployment_dir,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
if p.returncode != 0:
|
|
ui.error("docker compose config --format json failed (needs Docker Compose v2.10+)")
|
|
sys.exit(1)
|
|
return json.loads(p.stdout)
|
|
|
|
def list_services(self, deployment_dir: Path) -> list[str]:
|
|
env = os.environ.copy()
|
|
env["COMPOSE_PROFILES"] = "production"
|
|
p = subprocess.run(
|
|
["docker", "compose", "-f", "docker-compose.yml", "config", "--services"],
|
|
cwd=deployment_dir,
|
|
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 collect_pushable(self, compose: dict, services: list[str]) -> list[PushableService]:
|
|
deployment_dir = self._paths.deployment_dir
|
|
project_root = self._paths.project_root
|
|
out: list[PushableService] = []
|
|
svc_map = compose.get("services") or {}
|
|
for service in services:
|
|
spec = svc_map.get(service)
|
|
if not isinstance(spec, dict):
|
|
continue
|
|
build = spec.get("build")
|
|
if not isinstance(build, dict):
|
|
continue
|
|
image_tag = f"{self._project_name}-{service}:latest"
|
|
dockerfile_rel = (build.get("dockerfile") or "").strip()
|
|
context_rel = (build.get("context") or "").strip()
|
|
build_target = (build.get("target") or "").strip()
|
|
if not context_rel:
|
|
context_rel = ".."
|
|
if context_rel == "..":
|
|
build_context = project_root
|
|
elif context_rel.startswith("/"):
|
|
build_context = Path(context_rel)
|
|
else:
|
|
build_context = deployment_dir / context_rel
|
|
if dockerfile_rel:
|
|
if dockerfile_rel.startswith("/"):
|
|
dockerfile = Path(dockerfile_rel)
|
|
elif context_rel == ".." or build_context == project_root:
|
|
dockerfile = project_root / dockerfile_rel
|
|
else:
|
|
dockerfile = build_context / dockerfile_rel
|
|
else:
|
|
cand_a = deployment_dir / f"Dockerfile.{service}"
|
|
cand_b = deployment_dir / service / "Dockerfile"
|
|
if cand_a.is_file():
|
|
dockerfile = cand_a
|
|
elif cand_b.is_file():
|
|
dockerfile = cand_b
|
|
else:
|
|
ui.error(f"Could not determine Dockerfile for {service}")
|
|
sys.exit(1)
|
|
if not self._paths.input_hash_script.is_file():
|
|
ui.error(f"Missing {self._paths.input_hash_script} (needed for dependency hashing)")
|
|
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)
|
|
out.append(
|
|
PushableService(
|
|
service=service,
|
|
image_tag=image_tag,
|
|
dockerfile=dockerfile,
|
|
build_context=build_context,
|
|
build_target=build_target,
|
|
input_hash=h,
|
|
)
|
|
)
|
|
return out
|
|
|
|
def plan_builds(self, pushable: list[PushableService]) -> tuple[list[PushableService], list[str]]:
|
|
"""Return (to_build, built_images_after) — built_images empty until build runs."""
|
|
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)} service(s)")
|
|
deployment_dir = self._paths.deployment_dir
|
|
env = os.environ.copy()
|
|
env["COMPOSE_PROJECT_NAME"] = self._project_name
|
|
env["COMPOSE_PROFILES"] = "production"
|
|
if self._use_docker_build:
|
|
cmd = [
|
|
"docker",
|
|
"compose",
|
|
"-f",
|
|
"docker-compose.yml",
|
|
"--profile",
|
|
"production",
|
|
"build",
|
|
*[p.service for p in to_build],
|
|
]
|
|
if subprocess.run(cmd, cwd=deployment_dir, env=env).returncode != 0:
|
|
ui.error("docker compose build failed")
|
|
sys.exit(1)
|
|
else:
|
|
for ps in to_build:
|
|
ui.substep(f"Building {ps.service} -> {ps.image_tag}...")
|
|
args = [
|
|
"docker",
|
|
"buildx",
|
|
"build",
|
|
"--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).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_push_and_external(
|
|
compose: dict,
|
|
project_name: str,
|
|
local_tags: set[str],
|
|
service_order: list[str],
|
|
) -> tuple[list[str], list[str]]:
|
|
services = compose.get("services") or {}
|
|
push_images: list[str] = []
|
|
external: list[str] = []
|
|
for name in service_order:
|
|
spec = services.get(name)
|
|
if not isinstance(spec, dict):
|
|
continue
|
|
image_from = (spec.get("image") or "").strip()
|
|
build = spec.get("build")
|
|
has_build = isinstance(build, dict)
|
|
if image_from:
|
|
if not has_build:
|
|
external.append(image_from)
|
|
else:
|
|
push_images.append(image_from)
|
|
else:
|
|
tag = f"{project_name}-{name}:latest"
|
|
if tag in local_tags:
|
|
push_images.append(tag)
|
|
return dedupe_preserve(push_images), dedupe_preserve(external)
|
|
|
|
|
|
def verify_built_subset_push(built: list[str], push_images: list[str], ui: object) -> None:
|
|
matching = sum(1 for bi in built if bi in push_images)
|
|
missing = [bi for bi in built if bi not in push_images]
|
|
not_built = [di for di in push_images if di not in built]
|
|
if len(built) != matching:
|
|
ui.error(f"Mismatch between built images ({len(built)}) and detected built images ({matching}).")
|
|
if missing:
|
|
print(f" Built but not detected: {' '.join(missing)}")
|
|
if not_built:
|
|
print(f" Detected but not built (external images): {' '.join(not_built)}")
|
|
print("Aborting to avoid pushing incorrect images.")
|
|
sys.exit(1)
|
|
|
|
|
|
def images_to_push_intersection(push_images: list[str], built: list[str]) -> list[str]:
|
|
out: list[str] = []
|
|
for pi in push_images:
|
|
if pi in built:
|
|
out.append(pi)
|
|
return dedupe_preserve(out)
|