feat(runner): tgit-runner — сервисы в песочнице на хосте, деплой после push #49

Merged
Eleboba merged 1 commits from feat/13-runner into dev 2026-10-18 23:44:53 +03:00
Owner

Closes #13 · S-5

Как это работает

бот: push ─► git archive <sha>:services/<name> ─► ssh tgit-deploy@host deploy <name> <sha>   (архив — в stdin)
                                                  │ forced command: ключ запускает только tgit-runner
host: tgit-runner ─► проверка аргументов и архива ─► docker build ─► docker run …-next (песочница)
                 ─► healthy? да — заменить старый · нет — удалить новый, старый работает, exit 3

Код до хоста довозит бот, поэтому у хоста нет доступа к репозиторию загрузок.

Что сделано

  • infra/runner/tgit-runner (bash): deploy, restart, remove, list. Аргументы из SSH_ORIGINAL_COMMAND не исполняются и не глобятся. Имя сервиса — ^[a-z][a-z0-9-]{1,30}$, sha — hex. В архиве допускаются только файлы и папки, без симлинков и .., не больше 20 МБ, обязателен main.py. health_url — только http://localhost:…, в HEALTHCHECK подставляется в exec-форме, без shell.
  • Песочница (ARCHITECTURE §4): uid 10001, read-only FS, /tmp — tmpfs 16 МБ noexec, --cap-drop ALL, no-new-privileges, 128 МБ без swap, 0.25 CPU, 64 PID, --network none. Сеть появляется, только если её просит tgit.yaml и админ внёс сервис в egress-allow на хосте. Не больше 10 сервисов на хост, хранятся 2 последних образа.
  • Без простоя и без отката «вслепую»: новая версия стартует как tgit-svc-<name>-next и заменяет старую, только когда станет healthy.
  • Бот: после push разворачивает изменённые services/<name>/. Автор получает «🚀 работает» или «⛔ не развёрнут: …» с хвостом лога, команда — алерт ⛔. Если папка сервиса удалена — remove. В «Статусе» у админа появилась кнопка «♻️ Перезапустить», только для сервисов раннера — не для бота и БД.
  • install.sh, authorized_keys.example, infra/runner/README.md. CD бота сам обновляет tgit-runner на хосте.

Как проверено

  • Живой Docker: hello — контейнер со всеми флагами песочницы (docker inspect); три деплоя подряд; сломанная версия → FAIL … previous version kept, старая работает.
  • examples/escape_attempts: запись в код и /etc, интернет, DNS, docker.sock, setuid(0), raw-сокет, 200 процессов, запуск из /tmp — все 9 BLOCKED.
  • Сервер: раннер установлен в /srv/tgit/runner, ключ в authorized_keys с command=…,restrict. Из сети бота list → OK list, а id → FAIL unknown command: forced command держит. RUNNER_* в /srv/tgit/dev/.env уже прописаны, заработает после деплоя этого PR.
  • 334 теста (38 новых): валидация и инъекции, плохие архивы, флаги песочницы, откат, лимиты, сквозной путь «клиент → фейковый ssh → настоящий tgit-runner → фейковый docker», push → деплой в боте, перезапуск. Покрытие 92%.

Ограничения

  • Сервис разворачивается сразу после push. Бот перед этим проверяет только синтаксис; ruff, bandit и тесты из CI репозитория загрузок не дожидаются — это #11, вместе с возвратом к SourceCraft.
  • HTTP-проба «Статуса» для сервисов без сети не работает (--network none); их health — это Docker HEALTHCHECK изнутри контейнера.

🤖 Generated with Claude Code

Closes #13 · S-5 ## Как это работает ``` бот: push ─► git archive <sha>:services/<name> ─► ssh tgit-deploy@host deploy <name> <sha> (архив — в stdin) │ forced command: ключ запускает только tgit-runner host: tgit-runner ─► проверка аргументов и архива ─► docker build ─► docker run …-next (песочница) ─► healthy? да — заменить старый · нет — удалить новый, старый работает, exit 3 ``` Код до хоста довозит бот, поэтому у хоста нет доступа к репозиторию загрузок. ## Что сделано - **`infra/runner/tgit-runner`** (bash): `deploy`, `restart`, `remove`, `list`. Аргументы из `SSH_ORIGINAL_COMMAND` не исполняются и не глобятся. Имя сервиса — `^[a-z][a-z0-9-]{1,30}$`, sha — hex. В архиве допускаются только файлы и папки, без симлинков и `..`, не больше 20 МБ, обязателен `main.py`. `health_url` — только `http://localhost:…`, в `HEALTHCHECK` подставляется в exec-форме, без shell. - **Песочница** (ARCHITECTURE §4): uid 10001, read-only FS, `/tmp` — tmpfs 16 МБ `noexec`, `--cap-drop ALL`, `no-new-privileges`, 128 МБ без swap, 0.25 CPU, 64 PID, `--network none`. Сеть появляется, только если её просит `tgit.yaml` **и** админ внёс сервис в `egress-allow` на хосте. Не больше 10 сервисов на хост, хранятся 2 последних образа. - **Без простоя и без отката «вслепую»:** новая версия стартует как `tgit-svc-<name>-next` и заменяет старую, только когда станет healthy. - **Бот:** после push разворачивает изменённые `services/<name>/`. Автор получает «🚀 работает» или «⛔ не развёрнут: …» с хвостом лога, команда — алерт ⛔. Если папка сервиса удалена — `remove`. В «Статусе» у админа появилась кнопка «♻️ Перезапустить», только для сервисов раннера — не для бота и БД. - `install.sh`, `authorized_keys.example`, `infra/runner/README.md`. CD бота сам обновляет `tgit-runner` на хосте. ## Как проверено - **Живой Docker:** `hello` — контейнер со всеми флагами песочницы (`docker inspect`); три деплоя подряд; сломанная версия → `FAIL … previous version kept`, старая работает. - **[`examples/escape_attempts`](examples/escape_attempts/):** запись в код и `/etc`, интернет, DNS, docker.sock, `setuid(0)`, raw-сокет, 200 процессов, запуск из `/tmp` — **все 9 BLOCKED**. - **Сервер:** раннер установлен в `/srv/tgit/runner`, ключ в `authorized_keys` с `command=…,restrict`. Из сети бота `list` → `OK list`, а `id` → `FAIL unknown command`: forced command держит. `RUNNER_*` в `/srv/tgit/dev/.env` уже прописаны, заработает после деплоя этого PR. - 334 теста (38 новых): валидация и инъекции, плохие архивы, флаги песочницы, откат, лимиты, сквозной путь «клиент → фейковый ssh → настоящий tgit-runner → фейковый docker», push → деплой в боте, перезапуск. Покрытие 92%. ## Ограничения - Сервис разворачивается сразу после push. Бот перед этим проверяет только синтаксис; ruff, bandit и тесты из CI репозитория загрузок не дожидаются — это #11, вместе с возвратом к SourceCraft. - HTTP-проба «Статуса» для сервисов без сети не работает (`--network none`); их health — это Docker `HEALTHCHECK` изнутри контейнера. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Eleboba added 1 commit 2026-10-18 15:39:29 +03:00
- 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>
Eleboba merged commit eda5694270 into dev 2026-10-18 23:44:53 +03:00
Sign in to join this conversation.