Files
tgit/app/main.py
ElebobaandClaude Opus 5.5 c443190bf4
CI / ruff · mypy · pytest (pull_request) Successful in 4m3s
feat(runner): tgit-runner — сервисы в песочнице на хосте, деплой после push (#13)
- 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>
2026-10-18 15:36:29 +03:00

152 lines
5.8 KiB
Python

"""Entry point: settings → texts → dispatcher → long polling (ADR-002)."""
import asyncio
import contextlib
import logging
from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from app import __version__
from app.access.service import AccessService
from app.agent.service import Agent
from app.alerts.broadcast import TelegramNotifier
from app.alerts.detectors import load_alerts_config
from app.alerts.monitor import AlertMonitor
from app.alerts.service import AlertService
from app.bot.fsm import make_storage
from app.bot.middlewares.access import AccessMiddleware
from app.bot.routers import alerts as alerts_router
from app.bot.routers import commit, common, logs, members, status
from app.config import Settings
from app.db.maintenance import cleanup_loop
from app.db.migrate import upgrade
from app.db.session import SessionMaker, make_engine, make_sessionmaker
from app.health import heartbeat
from app.llm.factory import make_provider
from app.logs.journal import LogJournal
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
from app.texts import Texts, load_texts
log = logging.getLogger("tgit")
def build_dispatcher(
texts: Texts,
sessions: SessionMaker,
access: AccessService,
repo: UploadsRepo,
commit_url_template: str = "",
docker: DockerClient | None = None,
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)
# Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked.
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, deployer
)
)
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))
return dp
async def run(settings: Settings) -> None:
texts = load_texts(settings.runtime.config_dir)
checks_cfg = load_checks(settings.runtime.config_dir)
alerts_cfg = load_alerts_config(settings.runtime.config_dir)
engine = make_engine(settings.database.url)
await upgrade(engine)
sessions = make_sessionmaker(engine)
access = AccessService(sessions, settings.access)
await access.bootstrap_admins()
# No DOCKER_URL (e.g. `make run` without Docker): «Статус» answers «Docker недоступен», the rest works
docker = DockerClient(settings.docker) if settings.docker.url else None
store = SnapshotStore(sessions)
checker = Checker(docker, store, checks_cfg) if docker else None
# The mock provider answers `{}` to the guard — every question would be an «attack»: no real LLM, no agent
llm = make_provider(settings.llm) if settings.llm.provider != "mock" else None
agent = Agent.from_config(llm, sessions, settings.runtime.config_dir) if llm else None
proxy = settings.telegram.proxy_url
session = AiohttpSession(proxy=proxy.get_secret_value()) if proxy else None
bot = Bot(settings.telegram.bot_token.get_secret_value(), session=session)
alerts = AlertService(
sessions,
TelegramNotifier(bot, access, sessions, texts),
alerts_cfg,
log_source=(lambda cid, n: docker.logs(cid, tail=n)) if docker else None,
)
dp = build_dispatcher(
texts,
sessions,
access,
UploadsRepo(settings.git),
settings.git.commit_url_template,
docker,
checker,
agent,
alerts,
RunnerClient(settings.runner) if settings.runner.host else None,
)
rt = settings.runtime
background = [
asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)),
asyncio.create_task(cleanup_loop(sessions)),
]
if docker:
monitor = AlertMonitor(alerts, store, alerts_cfg)
background.append(
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, 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)
finally:
for task in background:
task.cancel()
for task in background:
with contextlib.suppress(asyncio.CancelledError):
await task
await bot.session.close()
if docker:
await docker.aclose()
if checker:
await checker.aclose()
if llm:
await llm.aclose()
await engine.dispose()
log.info("tgit stopped")
def main() -> None:
settings = Settings()
setup_logging(settings.runtime.log_level)
asyncio.run(run(settings))