"""After a push: the services it touched are (re)deployed through tgit-runner, the pusher hears how it went. A service = `services//`. Gone from the commit — removed on the host. A refused deploy keeps the old version running (the runner switches only to a healthy one) and raises ⛔ for the whole team. """ import asyncio import logging from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass from app.alerts.detectors import Condition from app.alerts.service import AlertService from app.db.models import AlertKind from app.repo.git import GitError, UploadsRepo from app.runner.client import SERVICE, RunnerClient, RunnerResult log = logging.getLogger(__name__) def changed_services(paths: Iterable[str]) -> list[str]: found = set() for path in paths: parts = path.split("/") if len(parts) >= 3 and parts[0] == "services" and SERVICE.match(parts[1]): found.add(parts[1]) return sorted(found) @dataclass(frozen=True) class Outcome: service: str result: RunnerResult removed: bool = False Report = Callable[[Outcome], Awaitable[None]] class Deployer: def __init__(self, repo: UploadsRepo, runner: RunnerClient, alerts: AlertService | None = None) -> None: self._repo = repo self._runner = runner self._alerts = alerts self._lock = asyncio.Lock() # one deploy at a time: builds are heavy for the shared host self._tasks: set[asyncio.Task[None]] = set() def schedule(self, sha: str, paths: Iterable[str], report: Report) -> list[str]: """Start in the background; returns the services that will be deployed.""" services = changed_services(paths) if services: task = asyncio.create_task(self.deploy(sha, services, report)) self._tasks.add(task) task.add_done_callback(self._tasks.discard) return services async def deploy(self, sha: str, services: list[str], report: Report) -> None: async with self._lock: for service in services: try: outcome = await self._one(sha, service) except Exception: # one broken service must not stop the others log.exception("deploy %s @ %s", service, sha[:7]) outcome = Outcome(service, RunnerResult(False, -1, "internal error", "")) if not outcome.result.ok and self._alerts is not None: await self._alerts.event( Condition( AlertKind.DEPLOY_REFUSED, f"tgit-svc-{service}", {"detail": outcome.result.summary, "sha": sha[:7], "log": outcome.result.tail}, ) ) try: await report(outcome) except Exception: log.exception("deploy report for %s", service) async def _one(self, sha: str, service: str) -> Outcome: tree = f"{sha}:services/{service}" try: await self._repo.git("cat-file", "-e", tree) except GitError: return Outcome(service, await self._runner.run("remove", service), removed=True) archive = await self._repo.git("archive", "--format=tar", tree) return Outcome(service, await self._runner.deploy(service, sha, archive))