Files
deployment/scripts/deploy/main.py
T
2026-07-14 12:10:22 +03:00

224 lines
7.3 KiB
Python

"""CLI entry: build locally, transfer images via pussh, rsync config — no registry pulls."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from deploy.compose_build import ( # noqa: E402
ComposeBuildPhase,
classify_compose_images,
fromchat_image,
generate_production_compose,
pull_third_party_images,
verify_fromchat_images_local,
)
from deploy.config import load_settings # noqa: E402
import deploy.docker_local as docker_local # noqa: E402
from deploy.paths import ProjectPaths # noqa: E402
from deploy.ssh_auth import SshAuth # noqa: E402
from deploy.transfer import DeployTransfer # noqa: E402
from deploy.updater import setup_updater_remote # noqa: E402
import deploy.ui as ui # noqa: E402
from deploy.util import dedupe_preserve, local_docker_image_tags # noqa: E402
UPDATER_IMAGE = fromchat_image("updater", "latest")
def _sync_caddy_build_files(paths: ProjectPaths, compose_components: list[str]) -> None:
if "caddy" not in compose_components or not paths.caddy_build_dir:
return
if paths.caddyfile_template.is_file():
shutil.copy2(paths.caddyfile_template, paths.caddy_build_dir / "Caddyfile")
def _prepare_staging(paths: ProjectPaths, compose_components: list[str], tag: str) -> Path:
staging = paths.staging_dir
if staging.exists():
shutil.rmtree(staging)
staging.mkdir(parents=True)
(staging / "config").mkdir()
if compose_components:
generate_production_compose(
paths, components=compose_components, tag=tag, output=staging / "compose.yml"
)
(staging / ".fromchat-version").write_text(tag + "\n", encoding="utf-8")
if paths.livekit_config.is_file():
shutil.copy2(paths.livekit_config, staging / "config" / "livekit.yaml")
if "caddy" in compose_components:
caddyfile = staging / "Caddyfile"
if paths.caddyfile_template.is_file():
shutil.copy2(paths.caddyfile_template, caddyfile)
if paths.systemd_unit_template.is_file():
unit = paths.systemd_unit_template.read_text(encoding="utf-8")
if "WorkingDirectory=" not in unit:
unit += "\nWorkingDirectory=/opt/fromchat-server\n"
(staging / "fromchat.service").write_text(unit, encoding="utf-8")
return staging
def _load_generated_compose(staging: Path) -> dict:
compose_file = staging / "compose.yml"
if not compose_file.is_file():
return {}
env = os.environ.copy()
p = subprocess.run(
["docker", "compose", "-f", "compose.yml", "config", "--format", "json"],
cwd=staging,
capture_output=True,
text=True,
env=env,
)
if p.returncode != 0:
ui.warning("docker compose config on staging failed; using raw YAML structure")
import yaml
return yaml.safe_load(compose_file.read_text(encoding="utf-8")) or {}
return json.loads(p.stdout)
def _collect_all_pushable(
build_phase: ComposeBuildPhase,
paths: ProjectPaths,
stack: list[str],
include_updater: bool,
) -> list:
pushable = []
ui.step("Detecting buildable services")
for component in stack:
if component == "caddy":
ps = build_phase.collect_caddy_pushable()
if ps:
pushable.append(ps)
ui.substep(f"caddy: {ps.service}")
continue
compose_root = paths.project_root_for(component)
if not compose_root:
continue
services = build_phase.list_services(compose_root)
if not services:
ui.warning(f"No services in {compose_root}/compose.yml")
continue
compose_json = build_phase.load_compose_json(compose_root)
found = build_phase.collect_pushable(compose_json, services, compose_root)
ui.substep(f"{component}: {', '.join(p.service for p in found) or '(none)'}")
pushable.extend(found)
if include_updater:
ps = build_phase.collect_updater_pushable()
if ps:
pushable.append(ps)
ui.substep(f"updater: {ps.service}")
return pushable
def main() -> None:
paths = ProjectPaths.from_deploy_package()
settings = load_settings(paths, sys.argv)
ui.banner()
ui.info("Build locally, pull remote images on this PC, transfer via pussh")
ui.info(f"Components: {', '.join(settings.components)}")
ui.info(f"Image tag: {settings.tag}")
if paths.backend_dir:
ui.info(f"Backend: {paths.backend_dir}")
if paths.web_dir:
ui.info(f"Web: {paths.web_dir}")
creds = SshAuth(settings.server).authenticate()
transfer = DeployTransfer(paths)
deploy_resolved = transfer.resolve_deploy_path_on_server(settings.server, settings.deploy_path)
stack = settings.compose_components
include_updater = "updater" in settings.components
docker_local.ensure_daemon()
docker_local.ensure_buildx(settings.use_docker_build)
build_phase = ComposeBuildPhase(
paths,
tag=settings.tag,
platform=settings.platform,
use_docker_build=settings.use_docker_build,
)
all_pushable = _collect_all_pushable(
build_phase, paths, stack, include_updater=include_updater
)
if not all_pushable:
if stack or include_updater:
ui.error("No buildable services found for selected components")
raise SystemExit(1)
_sync_caddy_build_files(paths, stack)
ui.build_banner()
to_build = build_phase.plan_builds(all_pushable)
build_phase.run_builds(to_build)
staging = _prepare_staging(paths, stack, settings.tag)
ui.deploy_banner(settings.server)
transfer.ensure_pussh()
images_to_transfer: list[str] = []
if stack:
generated = _load_generated_compose(staging)
services = list((generated.get("services") or {}).keys())
local_tags = local_docker_image_tags()
fromchat_images, third_party = classify_compose_images(generated, services)
verify_fromchat_images_local(fromchat_images, local_tags, ui)
pull_third_party_images(third_party, platform=settings.platform)
images_to_transfer.extend(fromchat_images)
images_to_transfer.extend(third_party)
if include_updater:
local_tags = local_docker_image_tags()
verify_fromchat_images_local([UPDATER_IMAGE], local_tags, ui)
images_to_transfer.append(UPDATER_IMAGE)
images_to_transfer = dedupe_preserve(images_to_transfer)
transfer.pussh_images(creds, images_to_transfer)
if stack:
transfer.rsync_staging(creds, settings.deploy_path, staging)
transfer.copy_env_prod(creds, settings.deploy_path)
if "backend" in stack:
deploy_resolved = transfer.sync_firebase_cert(creds, settings.deploy_path)
transfer.run_remote_systemd(creds, deploy_resolved)
if include_updater:
setup_updater_remote(
creds,
settings.deploy_path,
deploy_resolved,
components=settings.components,
paths=paths,
git_token=settings.git_token,
sudo_password=creds.sudo_password,
)
print()
ui.success("Deployment complete!")
if __name__ == "__main__":
main()