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