Rewrite deployment script in Python

This commit is contained in:
2026-04-02 11:02:06 +03:00
Unverified
parent 32e186213e
commit be7da286d4
12 changed files with 1090 additions and 809 deletions
+272
View File
@@ -0,0 +1,272 @@
"""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)