Files
deployment/scripts/generate-compose.py
2026-07-14 12:10:22 +03:00

198 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""
Merge backend + frontend (+ optional caddy) compose files for production deploy.
Replaces build: stanzas with image: fromchat/<service>:<tag>.
"""
from __future__ import annotations
import argparse
import copy
import sys
from pathlib import Path
from typing import Any
import yaml
# Services that map to fromchat/* images when they use build:
FROMCHAT_IMAGE_SERVICES = frozenset(
{"main", "messaging", "file_storage", "postgres", "web", "caddy"}
)
BACKEND_SERVICES = frozenset(
{"main", "messaging", "file_storage", "livekit", "postgres"}
)
FRONTEND_SERVICES = frozenset({"web"})
CADDY_SERVICES = frozenset({"caddy"})
STRIP_KEYS = ("develop",)
def load_yaml(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
if not isinstance(data, dict):
raise ValueError(f"{path}: root must be a mapping")
return data
def dump_yaml(data: dict[str, Any], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as fh:
yaml.dump(
data,
fh,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
)
def deep_merge(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
out = copy.deepcopy(base)
for key, value in extra.items():
if key in out and isinstance(out[key], dict) and isinstance(value, dict):
out[key] = deep_merge(out[key], value)
else:
out[key] = copy.deepcopy(value)
return out
def strip_dev_keys(service: dict[str, Any]) -> None:
for key in STRIP_KEYS:
service.pop(key, None)
def replace_build_with_image(
service_name: str,
service: dict[str, Any],
tag: str,
) -> None:
if "build" not in service:
return
if service_name not in FROMCHAT_IMAGE_SERVICES:
del service["build"]
return
del service["build"]
service["image"] = f"fromchat/{service_name}:{tag}"
def patch_livekit_volume(service: dict[str, Any]) -> None:
volumes = service.get("volumes")
if not volumes:
return
patched: list[Any] = []
for entry in volumes:
if isinstance(entry, str) and "src/livekit/compose.yaml" in entry:
patched.append("./config/livekit.yaml:/etc/livekit.yaml:ro")
else:
patched.append(entry)
service["volumes"] = patched
def filter_services(
services: dict[str, Any],
enabled: set[str],
) -> dict[str, Any]:
return {name: cfg for name, cfg in services.items() if name in enabled}
def generate(
*,
backend_compose: Path | None,
frontend_compose: Path | None,
caddy_compose: Path | None,
tag: str,
components: set[str],
output: Path,
) -> None:
merged: dict[str, Any] = {"services": {}, "networks": {}, "volumes": {}}
enabled: set[str] = set()
if "backend" in components:
enabled |= BACKEND_SERVICES
if "frontend" in components:
enabled |= FRONTEND_SERVICES
if "caddy" in components:
enabled |= CADDY_SERVICES
sources: list[Path] = []
if backend_compose and backend_compose.is_file():
sources.append(backend_compose)
if frontend_compose and frontend_compose.is_file():
sources.append(frontend_compose)
if caddy_compose and caddy_compose.is_file() and "caddy" in components:
sources.append(caddy_compose)
if not sources:
raise SystemExit("No compose inputs found.")
for src in sources:
doc = load_yaml(src)
merged = deep_merge(merged, doc)
services: dict[str, Any] = merged.get("services") or {}
services = filter_services(services, enabled)
for name, cfg in services.items():
strip_dev_keys(cfg)
replace_build_with_image(name, cfg, tag)
if name == "livekit":
patch_livekit_volume(cfg)
if name == "caddy":
cfg.setdefault("volumes", [])
vols = cfg["volumes"]
if isinstance(vols, list) and not any("Caddyfile" in str(v) for v in vols):
vols.append("./Caddyfile:/etc/caddy/Caddyfile:ro")
cfg.setdefault("networks", ["public"])
cfg.setdefault("depends_on", ["web"])
cfg.setdefault("restart", "unless-stopped")
cfg.setdefault("ports", ["80:80", "443:443"])
if "caddy" in components and "web" in services:
web = services["web"]
ports = web.get("ports")
if isinstance(ports, list):
web["ports"] = [p for p in ports if not str(p).startswith("8301:")]
merged["services"] = services
if not merged.get("networks"):
merged.pop("networks", None)
if not merged.get("volumes"):
merged.pop("volumes", None)
dump_yaml(merged, output)
print(f"Wrote {output} ({len(services)} services, tag {tag})", file=sys.stderr)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate FromChat production compose.yml")
parser.add_argument("--tag", required=True, help="Image tag, e.g. v1.0.0")
parser.add_argument(
"--components",
required=True,
help="Comma-separated: backend,frontend,caddy",
)
parser.add_argument("--backend-compose", type=Path, default=None)
parser.add_argument("--frontend-compose", type=Path, default=None)
parser.add_argument("--caddy-compose", type=Path, default=None)
parser.add_argument("--output", type=Path, default=Path("compose.yml"))
return parser.parse_args()
def main() -> None:
args = parse_args()
components = {c.strip().lower() for c in args.components.split(",") if c.strip()}
generate(
backend_compose=args.backend_compose,
frontend_compose=args.frontend_compose,
caddy_compose=args.caddy_compose,
tag=args.tag,
components=components,
output=args.output,
)
if __name__ == "__main__":
main()