feat(runner): tgit-runner — сервисы в песочнице на хосте, деплой после push (#13)
CI / ruff · mypy · pytest (pull_request) Successful in 4m3s

- infra/runner/tgit-runner: deploy/restart/remove/list по SSH forced command;
  аргументы не исполняются и не глобятся; архив — только файлы и папки,
  без «..», ≤ 20 МБ, есть main.py; health_url — только localhost
- песочница: uid 10001, read-only, tmpfs noexec, cap-drop ALL,
  no-new-privileges, 128 МБ без swap, 0.25 CPU, 64 PID, --network none
  (сеть — только по egress-allow админа), лимит 10 сервисов, 2 образа
- новая версия стартует рядом (-next) и заменяет старую, только став
  healthy; иначе удаляется, старая работает, exit 3
- бот: после push git archive изменённых services/<name>/ → runner;
  автору — «🚀 работает» или «⛔ не развёрнут» с логом, команде — алерт ⛔;
  удалённая папка → remove; «♻️ Перезапустить» в «Статусе» для админа (S-5)
- examples/escape_attempts: все попытки выбраться — BLOCKED на живом Docker
- install.sh, authorized_keys.example; CD бота обновляет runner на хосте

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 15:36:29 +03:00
co-authored by Claude Opus 5.5
parent 93dd7ee0a3
commit c443190bf4
27 changed files with 1249 additions and 20 deletions
+30 -1
View File
@@ -4,6 +4,7 @@ import base64
import html
import io
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
@@ -33,6 +34,8 @@ from app.repo.validate import (
python_syntax,
strip_fence,
)
from app.runner.deployer import Deployer
from app.runner.deployer import Outcome as DeployOutcome
from app.texts import CommitTexts, Texts
log = logging.getLogger(__name__)
@@ -212,7 +215,12 @@ def to_dir_keyboard(texts: Texts) -> InlineKeyboardMarkup:
def build_router(
texts: Texts, repo: UploadsRepo, drafts: DraftService, publisher: Publisher, commit_url_template: str = ""
texts: Texts,
repo: UploadsRepo,
drafts: DraftService,
publisher: Publisher,
commit_url_template: str = "",
deployer: Deployer | None = None,
) -> Router:
router = Router(name="commit")
t = texts.commit
@@ -275,6 +283,21 @@ def build_router(
ops = {ChangeOp.ADD: t.op_add, ChangeOp.MODIFY: t.op_modify, ChangeOp.DELETE: t.op_delete}
return "\n".join(f"{ops[c.op]} /{c.path}" for c in draft.changes)
def deploy_report(msg: Message, sha: str) -> Callable[[DeployOutcome], Awaitable[None]]:
async def report(o: DeployOutcome) -> None:
name = html.escape(o.service)
if o.result.ok and o.removed:
text = t.removed.format(service=name)
elif o.result.ok:
text = t.deployed.format(service=name, sha=sha[:7])
else:
text = t.deploy_failed.format(service=name, reason=html.escape(o.result.summary))
if o.result.tail:
text += f"\n<pre>{html.escape(o.result.tail[-3000:])}</pre>"
await msg.answer(text, parse_mode=ParseMode.HTML)
return report
async def do_push(msg: Message, state: FSMContext, member: User, *, overwrite: bool) -> None:
draft = await drafts.get(member.tg_id)
if draft is None or not draft.changes or not draft.message:
@@ -291,6 +314,12 @@ def build_router(
if commit_url_template:
lines.append(commit_url_template.format(sha=result.sha))
lines.append(t.ci_pending)
if deployer is not None:
services = deployer.schedule(
result.sha, [c.path for c in draft.changes], deploy_report(msg, result.sha)
)
if services:
lines.append(t.deploying.format(services=", ".join(services)))
await msg.edit_text("\n".join(lines))
await publisher.record(member, result, draft.message, draft, msg.chat.id, msg.message_id)
await drafts.clear(member.tg_id)
+38 -8
View File
@@ -13,7 +13,8 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from app.bot.menu import Nav, NavAction
from app.bot.routers.commit import human_size
from app.db.models import utcnow
from app.db.models import Role, User, utcnow
from app.runner.client import RunnerClient
from app.runtime.checks import Checker, CheckName, ContainerReport
from app.runtime.docker import Container, Details, DockerClient, DockerError, Stats
from app.texts import StatusTexts, Texts
@@ -27,6 +28,7 @@ class StatusAct(StrEnum):
LIST = "l"
CARD = "c"
CHECK = "k"
RESTART = "r"
class StatusCb(CallbackData, prefix="s"):
@@ -75,7 +77,10 @@ def render_list(texts: Texts, containers: list[Container], now: datetime) -> tup
return _clip("\n".join(lines)), kb.as_markup()
def render_card(texts: Texts, d: Details, stats: Stats | None, now: datetime) -> tuple[str, InlineKeyboardMarkup]:
def render_card(
texts: Texts, d: Details, stats: Stats | None, now: datetime, can_restart: bool = False
) -> tuple[str, InlineKeyboardMarkup]:
"""can_restart: an admin looking at a service deployed by tgit-runner (S-5)."""
t = texts.status
running = d.state == "running"
uptime = duration(t, (now - d.started_at).total_seconds()) if running and d.started_at else t.none
@@ -105,11 +110,18 @@ def render_card(texts: Texts, d: Details, stats: Stats | None, now: datetime) ->
kb = InlineKeyboardBuilder()
kb.button(text=t.check_button, callback_data=StatusCb(a=StatusAct.CHECK, id=d.id[:12]))
kb.button(text=t.refresh, callback_data=StatusCb(a=StatusAct.CARD, id=d.id[:12]))
if can_restart and runner_service(d):
kb.button(text=t.restart, callback_data=StatusCb(a=StatusAct.RESTART, id=d.id[:12]))
kb.button(text=t.to_list, callback_data=StatusCb(a=StatusAct.LIST))
kb.adjust(2, 1)
kb.adjust(2, 1, 1)
return text, kb.as_markup()
def runner_service(d: Details) -> str | None:
"""Only services tgit-runner deployed can be restarted through it — not the bot, its DB or the proxy."""
return d.labels.get("tgit.service") or None
def render_report(
texts: Texts, reports: list[ContainerReport], now: datetime, back_id: str = ""
) -> tuple[str, InlineKeyboardMarkup]:
@@ -141,7 +153,9 @@ def render_report(
return _clip("\n".join(lines)), kb.as_markup()
def build_router(texts: Texts, docker: DockerClient | None, checker: Checker | None) -> Router:
def build_router(
texts: Texts, docker: DockerClient | None, checker: Checker | None, runner: RunnerClient | None = None
) -> Router:
router = Router(name="status")
t = texts.status
@@ -150,7 +164,7 @@ def build_router(texts: Texts, docker: DockerClient | None, checker: Checker | N
raise DockerError("docker is not configured (DOCKER_URL)")
return render_list(texts, await docker.containers(), utcnow())
async def card_screen(short_id: str) -> tuple[str, InlineKeyboardMarkup]:
async def card_screen(short_id: str, is_admin: bool = False) -> tuple[str, InlineKeyboardMarkup]:
if docker is None:
raise DockerError("docker is not configured (DOCKER_URL)")
cid = await docker.resolve(short_id)
@@ -161,7 +175,7 @@ def build_router(texts: Texts, docker: DockerClient | None, checker: Checker | N
stats = await docker.stats(cid)
except DockerError:
log.warning("stats %s unavailable", short_id)
return render_card(texts, details, stats, utcnow())
return render_card(texts, details, stats, utcnow(), can_restart=is_admin and runner is not None)
@router.message(F.text == texts.menu.status)
async def open_status(message: Message, state: FSMContext) -> None:
@@ -175,7 +189,7 @@ def build_router(texts: Texts, docker: DockerClient | None, checker: Checker | N
await message.answer(text, reply_markup=kb)
@router.callback_query(StatusCb.filter())
async def on_action(query: CallbackQuery, callback_data: StatusCb) -> None:
async def on_action(query: CallbackQuery, callback_data: StatusCb, member: User) -> None:
if not isinstance(query.message, Message):
await query.answer()
return
@@ -185,7 +199,23 @@ def build_router(texts: Texts, docker: DockerClient | None, checker: Checker | N
case StatusAct.LIST:
text, kb = await list_screen()
case StatusAct.CARD:
text, kb = await card_screen(callback_data.id)
text, kb = await card_screen(callback_data.id, member.role is Role.ADMIN)
case StatusAct.RESTART:
if docker is None or runner is None or member.role is not Role.ADMIN:
await query.answer()
return
service = runner_service(await docker.inspect(await docker.resolve(callback_data.id)))
if service is None:
await query.answer()
return
result = await runner.restart(service)
if not result.ok:
await query.answer(t.restart_failed.format(reason=result.summary)[:200], show_alert=True)
return
await query.answer(t.restarted)
text, kb = await card_screen(callback_data.id, True)
await _edit(msg, text, kb)
return
case StatusAct.CHECK:
if docker is None or checker is None:
raise DockerError("docker is not configured (DOCKER_URL)")
+13
View File
@@ -98,6 +98,18 @@ class DockerSettings(BaseSettings):
max_log_lines: int = Field(default=50_000, gt=0)
class RunnerSettings(BaseSettings):
model_config = SettingsConfigDict(**_ENV, env_prefix="RUNNER_")
# SSH to tgit-runner on the host (ADR-006); empty host — services are not deployed from the bot
host: str = ""
port: int = 22
user: str = "tgit-deploy"
# The bot's runner key, base64; authorized_keys pins it to `command="tgit-runner"` (infra/runner/)
ssh_key_b64: SecretStr | None = None
timeout: float = Field(default=300.0, gt=0)
class RuntimeSettings(BaseSettings):
model_config = _ENV
@@ -120,4 +132,5 @@ class Settings(BaseSettings):
sourcecraft: SourceCraftSettings = Field(default_factory=SourceCraftSettings)
git: GitSettings = Field(default_factory=GitSettings)
docker: DockerSettings = Field(default_factory=DockerSettings)
runner: RunnerSettings = Field(default_factory=RunnerSettings)
runtime: RuntimeSettings = Field(default_factory=RuntimeSettings)
+11 -3
View File
@@ -29,6 +29,8 @@ from app.logs_setup import setup_logging
from app.repo.drafts import DraftService
from app.repo.git import UploadsRepo
from app.repo.publisher import Publisher
from app.runner.client import RunnerClient
from app.runner.deployer import Deployer
from app.runtime.checks import Checker, load_checks
from app.runtime.docker import DockerClient
from app.runtime.snapshots import SnapshotStore, snapshot_loop
@@ -47,6 +49,7 @@ def build_dispatcher(
checker: Checker | None = None,
agent: Agent | None = None,
alerts: AlertService | None = None,
runner: RunnerClient | None = None,
) -> Dispatcher:
dp = Dispatcher(storage=make_storage(sessions))
gate = AccessMiddleware(access, texts)
@@ -54,10 +57,13 @@ def build_dispatcher(
dp.message.outer_middleware(gate)
dp.callback_query.outer_middleware(gate)
dp.include_router(members.build_router(texts, access))
deployer = Deployer(repo, runner, alerts) if runner else None
dp.include_router(
commit.build_router(texts, repo, DraftService(sessions), Publisher(repo, sessions), commit_url_template)
commit.build_router(
texts, repo, DraftService(sessions), Publisher(repo, sessions), commit_url_template, deployer
)
)
dp.include_router(status.build_router(texts, docker, checker))
dp.include_router(status.build_router(texts, docker, checker, runner))
dp.include_router(logs.build_router(texts, docker, LogJournal(sessions), agent, alerts))
dp.include_router(alerts_router.build_router(texts, alerts, docker))
dp.include_router(common.build_router(texts, access))
@@ -99,6 +105,7 @@ async def run(settings: Settings) -> None:
checker,
agent,
alerts,
RunnerClient(settings.runner) if settings.runner.host else None,
)
rt = settings.runtime
background = [
@@ -111,12 +118,13 @@ async def run(settings: Settings) -> None:
asyncio.create_task(snapshot_loop(docker, store, settings.docker.snapshot_interval, monitor.on_round))
)
log.info(
"tgit %s starting, %d bootstrap admin(s), telegram %s, docker %s, agent %s",
"tgit %s starting, %d bootstrap admin(s), telegram %s, docker %s, agent %s, runner %s",
__version__,
len(settings.access.admin_ids),
"via proxy" if proxy else "direct",
"on" if docker else "off",
settings.llm.provider if agent else "off",
settings.runner.host or "off",
)
try:
await dp.start_polling(bot, handle_signals=True)
+1
View File
@@ -0,0 +1 @@
"""Deploying services of the uploads repo: the bot ships `git archive` to `tgit-runner` on the host (#13)."""
+85
View File
@@ -0,0 +1,85 @@
"""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)
+85
View File
@@ -0,0 +1,85 @@
"""After a push: the services it touched are (re)deployed through tgit-runner, the pusher hears how it went.
A service = `services/<name>/`. 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))
+7
View File
@@ -119,6 +119,10 @@ class CommitTexts(_Strict):
push_empty: str
push_limit: str
push_failed: str
deploying: str
deployed: str
deploy_failed: str
removed: str
class StatusTexts(_Strict):
@@ -148,6 +152,9 @@ class StatusTexts(_Strict):
hours: str
minutes: str
seconds: str
restart: str
restarted: str
restart_failed: str
class LogsTexts(_Strict):