"""SSH to `tgit-runner`: the key can run nothing else (forced command), so the «command» is just its arguments.""" import asyncio import base64 import logging import os import re import tempfile from dataclasses import dataclass from pathlib import Path from app.config import RunnerSettings log = logging.getLogger(__name__) SERVICE = re.compile(r"^[a-z][a-z0-9-]{1,30}$") # the runner checks the same — refuse early, with a clear reason SHA = re.compile(r"^[0-9a-f]{7,40}$") @dataclass(frozen=True) class RunnerResult: ok: bool code: int # 0 ok · 2 bad request · 3 not healthy · 4 limit · 5 build failed · 255 ssh · -1 timeout summary: str # the runner's last line without OK/FAIL output: str # everything, for logs and the user's log tail @property def tail(self) -> str: lines = self.output.strip().splitlines()[:-1] return "\n".join(lines[-20:]) class RunnerClient: def __init__(self, settings: RunnerSettings) -> None: self._s = settings self._key = self._materialize_key() def _materialize_key(self) -> Path | None: if self._s.ssh_key_b64 is None: return None key = base64.b64decode(self._s.ssh_key_b64.get_secret_value()) fd, name = tempfile.mkstemp(prefix="tgit-runner-") with os.fdopen(fd, "wb") as f: f.write(key if key.endswith(b"\n") else key + b"\n") return Path(name) def _ssh(self) -> list[str]: cmd = ["ssh", "-F", "/dev/null", "-p", str(self._s.port), "-o", "BatchMode=yes", "-o", "ConnectTimeout=20"] cmd += ["-o", "StrictHostKeyChecking=accept-new", "-o", f"UserKnownHostsFile={tempfile.gettempdir()}/tgit-kh"] if self._key is not None: cmd += ["-i", str(self._key), "-o", "IdentitiesOnly=yes"] return [*cmd, f"{self._s.user}@{self._s.host}"] async def run(self, *args: str, stdin: bytes = b"") -> RunnerResult: proc = await asyncio.create_subprocess_exec( *self._ssh(), *args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) try: out, _ = await asyncio.wait_for(proc.communicate(stdin), timeout=self._s.timeout) except TimeoutError: proc.kill() await proc.wait() return RunnerResult(False, -1, "timeout", "") text = out.decode(errors="replace") last = text.strip().splitlines()[-1] if text.strip() else "" code = proc.returncode if proc.returncode is not None else -1 ok = code == 0 and last.startswith("OK") summary = last.removeprefix("OK ").removeprefix("FAIL ") if last.startswith(("OK", "FAIL")) else last[:200] if not ok: log.warning("runner %s: exit %s: %s", args[0] if args else "", code, summary) return RunnerResult(ok, code, summary, text) async def deploy(self, service: str, sha: str, archive: bytes) -> RunnerResult: if not SERVICE.match(service) or not SHA.match(sha): return RunnerResult(False, 2, "bad service name or sha", "") return await self.run("deploy", service, sha, stdin=archive) async def restart(self, service: str) -> RunnerResult: if not SERVICE.match(service): return RunnerResult(False, 2, "bad service name", "") return await self.run("restart", service)