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)")