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
+1 -1
View File
@@ -3,5 +3,5 @@
| Каталог | Что будет | Задача |
|---|---|---|
| [`deploy/`](deploy/) | `deploy.sh` + `remote.sh` — деплой бота на сервер (CD и руками), см. [`docs/dev/DEPLOY.md`](../docs/dev/DEPLOY.md) | #12 |
| `runner/` | `tgit-runner` — песочница и запуск сервисов на хосте | #13 |
| [`runner/`](runner/) | `tgit-runner` — песочница и запуск сервисов на хосте, установка | #13 |
| [`uploads-template/`](uploads-template/) | шаблон [`tgit-uploads`](https://sourcecraft.dev/rogachevegorushka/tgit-uploads): CI/CD, служебные скрипты `.tgit/`, `ruff.toml`, демо-сервис `hello` — правки вносить здесь и переносить в репозиторий загрузок | #7 |
+4
View File
@@ -35,5 +35,9 @@ if [ "$status" != healthy ]; then
exit 1
fi
echo "$SHA" > ../REVISION
# Keep tgit-runner in step with the bot (installed once by infra/runner/install.sh; absent — nothing to update)
if [ -d /srv/tgit/runner ] && [ -w /srv/tgit/runner ]; then
install -m 755 infra/runner/tgit-runner /srv/tgit/runner/tgit-runner
fi
docker image prune -f >/dev/null
echo "bot healthy @ $SHA ($ENV)"
+38
View File
@@ -0,0 +1,38 @@
# infra/runner — `tgit-runner`
Запуск сервисов из репозитория загрузок (`services/<name>/`) на хосте в песочнице — ADR-005, ADR-006,
[ARCHITECTURE §4](../../docs/ARCHITECTURE.md).
```
бот: 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 за 60 с? ─► да: заменить старый контейнер · нет: удалить новый, старый работает, exit 3
```
| Команда | Что делает |
|---|---|
| `deploy <service> <sha>` | собрать образ `tgit-svc-<service>:<sha>` и запустить, старый заменяется только здоровым новым |
| `restart <service>` | перезапуск (кнопка «♻️ Перезапустить» в «Статусе», только админ) |
| `remove <service>` | сервис удалён из репозитория — остановить и удалить образы |
| `list` | контейнеры сервисов |
**Песочница:** uid 10001, read-only FS, `/tmp` — tmpfs 16 МБ с `noexec`, `--cap-drop ALL`, `no-new-privileges`,
128 МБ без swap, 0.25 CPU, 64 процесса, `--network none` (интернет — только если `network: true` в `tgit.yaml`
**и** имя сервиса есть в `/srv/tgit/runner/egress-allow`), без Docker-сокета, `--restart on-failure:3`, ротация логов.
Не больше 10 сервисов на хост; хранятся два последних образа сервиса.
**Проверено** [`examples/escape_attempts`](../../examples/escape_attempts/): запись в код и `/etc`, сеть, DNS,
docker.sock, `setuid(0)`, raw-сокет, 200 процессов, запуск из `/tmp` — всё `BLOCKED`.
**Установка** (один раз, root на хосте):
```bash
ssh-keygen -t ed25519 -N '' -C tgit-runner -f tgit-runner-key # ключ бота для раннера
sudo infra/runner/install.sh tgit-runner-key.pub tgit-deploy
# в .env бота на сервере:
# RUNNER_HOST=host.docker.internal RUNNER_SSH_KEY_B64=$(base64 -w0 tgit-runner-key)
```
Дальше скрипт обновляется сам: деплой бота (`infra/deploy/remote.sh`) копирует свежий `tgit-runner`, если раннер
установлен. Тесты — `tests/runner/` (фейковые `docker` и `ssh`, сам скрипт настоящий).
+6
View File
@@ -0,0 +1,6 @@
# ~tgit-deploy/.ssh/authorized_keys on the host. `restrict` = no-pty, no port/agent/X11 forwarding;
# command= makes the key run tgit-runner and nothing else, the requested command arrives as SSH_ORIGINAL_COMMAND.
command="/srv/tgit/runner/tgit-runner",restrict ssh-ed25519 AAAA…bot-runner-key… tgit-runner
# The CD key of the bot itself stays unrestricted (it deploys the bot; Gitea secret DEPLOY_SSH_KEY):
ssh-ed25519 AAAA…cd-key… tgit-cd@gitea
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# One-time setup of tgit-runner on the host (as root). Safe to re-run.
# sudo infra/runner/install.sh /path/to/tgit-runner-key.pub [deploy-user]
# The key is the bot's runner key (RUNNER_SSH_KEY_B64 holds its private half); it is pinned to the runner only.
set -euo pipefail
PUB=${1:?usage: install.sh <runner-key.pub> [user]}
USER_NAME=${2:-tgit-deploy}
DIR=/srv/tgit/runner
HERE=$(cd "$(dirname "$0")" && pwd)
id "$USER_NAME" >/dev/null
install -d -o "$USER_NAME" -g "$USER_NAME" -m 700 "$DIR"
install -o "$USER_NAME" -g "$USER_NAME" -m 755 "$HERE/tgit-runner" "$DIR/tgit-runner"
touch "$DIR/egress-allow" && chown "$USER_NAME:" "$DIR/egress-allow" && chmod 600 "$DIR/egress-allow"
HOME_DIR=$(getent passwd "$USER_NAME" | cut -d: -f6)
install -d -o "$USER_NAME" -g "$USER_NAME" -m 700 "$HOME_DIR/.ssh"
AK=$HOME_DIR/.ssh/authorized_keys
touch "$AK" && chown "$USER_NAME:" "$AK" && chmod 600 "$AK"
KEY=$(awk '{print $1" "$2}' "$PUB")
LINE="command=\"$DIR/tgit-runner\",restrict $KEY tgit-runner"
if grep -qF "$KEY" "$AK"; then
echo "key already in $AK — leaving it"
else
echo "$LINE" >>"$AK"
echo "added the runner key to $AK (forced command)"
fi
echo "runner installed in $DIR; allow internet for a service: echo <service> >> $DIR/egress-allow"
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env bash
# tgit-runner — the only command the bot's SSH key may run on the host (ADR-006, ARCHITECTURE §4).
# authorized_keys: command="/srv/tgit/runner/tgit-runner",restrict ssh-ed25519 AAAA… tgit-runner
#
# deploy <service> <sha> stdin: tar of services/<service>/ (files at the archive root)
# restart <service>
# remove <service>
# list
#
# Exit codes: 0 ok · 2 bad request · 3 not healthy (old container kept) · 4 limit · 5 build failed.
# The last line of output is machine-readable: «OK …» or «FAIL <reason>».
set -euo pipefail
umask 077
export LC_ALL=C
STATE=${TGIT_RUNNER_STATE:-/srv/tgit/runner}
MAX_SERVICES=${TGIT_MAX_SERVICES:-10}
HEALTH_TIMEOUT=${TGIT_HEALTH_TIMEOUT:-60}
SETTLE=${TGIT_SETTLE:-10} # no healthcheck: must stay up this long without restarts
MAX_TAR=${TGIT_MAX_TAR:-$((20 * 1024 * 1024))}
BASE_IMAGE=${TGIT_BASE_IMAGE:-python:3.12-slim}
EGRESS_NET=tgit-svc-egress
NAME_RE='^[a-z][a-z0-9-]{1,30}$'
SHA_RE='^[0-9a-f]{7,40}$'
HEALTH_RE='^http://(localhost|127\.0\.0\.1):[0-9]{2,5}(/[A-Za-z0-9._/-]*)?$'
fail() { echo "FAIL $2"; exit "$1"; }
# Called through SSH the arguments come in SSH_ORIGINAL_COMMAND: split on spaces, never evaluated, never globbed
if [ -n "${SSH_ORIGINAL_COMMAND+x}" ]; then
set -f
read -r -a ARGS <<<"$SSH_ORIGINAL_COMMAND"
set +f
else
ARGS=("$@")
fi
[ "${#ARGS[@]}" -ge 1 ] || fail 2 "usage: deploy|restart|remove|list"
CMD=${ARGS[0]}
need_service() {
[ "${#ARGS[@]}" -eq "$1" ] || fail 2 "wrong number of arguments for $CMD"
SVC=${ARGS[1]}
[[ $SVC =~ $NAME_RE ]] || fail 2 "bad service name"
NAME=tgit-svc-$SVC
}
exists() { docker container inspect "$1" >/dev/null 2>&1; }
# tgit.yaml is read with grep, not executed or parsed by anything clever
yaml_value() { grep -E "^$1:" "$2" 2>/dev/null | head -1 | sed -E "s/^$1:[[:space:]]*//; s/[[:space:]]+#.*$//; s/^[\"']//; s/[\"']$//" || true; }
wait_ready() { # $1 container, $2 has healthcheck (yes/no)
local deadline=$((SECONDS + HEALTH_TIMEOUT)) status restarts
if [ "$2" = yes ]; then
while [ $SECONDS -lt $deadline ]; do
status=$(docker inspect -f '{{.State.Health.Status}}' "$1" 2>/dev/null || echo gone)
case $status in healthy) return 0 ;; unhealthy | gone) return 1 ;; esac
sleep 2
done
return 1
fi
sleep "$SETTLE"
status=$(docker inspect -f '{{.State.Status}}' "$1" 2>/dev/null || echo gone)
restarts=$(docker inspect -f '{{.RestartCount}}' "$1" 2>/dev/null || echo 1)
[ "$status" = running ] && [ "$restarts" = 0 ]
}
deploy() {
need_service 3
SHA=${ARGS[2]}
[[ $SHA =~ $SHA_RE ]] || fail 2 "bad sha"
if ! exists "$NAME"; then
local count
count=$(docker ps -a --filter label=tgit.service --format '{{.Names}}' | grep -cv -- '-next$' || true)
[ "$count" -lt "$MAX_SERVICES" ] || fail 4 "service limit $MAX_SERVICES reached"
fi
mkdir -p "$STATE"
WORK=$(mktemp -d "$STATE/build.XXXXXX")
trap 'rm -rf "$WORK"' EXIT
head -c $((MAX_TAR + 1)) >"$WORK/src.tar"
[ "$(stat -c %s "$WORK/src.tar")" -le "$MAX_TAR" ] || fail 2 "archive larger than $MAX_TAR bytes"
# Only plain files and directories, relative paths without "..": no symlinks, devices or hard links
tar -tvf "$WORK/src.tar" >"$WORK/list" 2>/dev/null || fail 2 "not a tar archive"
if grep -qvE '^[-d]' "$WORK/list"; then fail 2 "archive may contain only files and directories"; fi
if tar -tf "$WORK/src.tar" | grep -qE '(^/|(^|/)\.\.(/|$))'; then fail 2 "unsafe path in archive"; fi
mkdir -p "$WORK/ctx/app"
tar -xf "$WORK/src.tar" -C "$WORK/ctx/app" --no-same-owner --no-same-permissions
[ -f "$WORK/ctx/app/main.py" ] || fail 2 "services/$SVC/main.py is missing"
# umask 077 above protects the build dir; inside the image the code must be readable (not writable) by uid 10001
chmod -R u=rwX,go=rX "$WORK/ctx"
local health network healthcheck=no net=none
health=$(yaml_value health_url "$WORK/ctx/app/tgit.yaml")
network=$(yaml_value network "$WORK/ctx/app/tgit.yaml")
if [ -n "$health" ]; then
[[ $health =~ $HEALTH_RE ]] || fail 2 "health_url must be http://localhost:<port>/<path>"
healthcheck=yes
fi
# Internet only if tgit.yaml asks AND the admin allowed this service on the host
if [ "$network" = true ] && grep -qxF "$SVC" "$STATE/egress-allow" 2>/dev/null; then
docker network inspect "$EGRESS_NET" >/dev/null 2>&1 || docker network create "$EGRESS_NET" >/dev/null
net=$EGRESS_NET
fi
{
echo "FROM $BASE_IMAGE"
echo "RUN useradd --uid 10001 --no-create-home --home-dir /tmp svc"
echo "WORKDIR /app"
echo "COPY app/ /app/"
echo "RUN if [ -f requirements.txt ]; then pip install --no-cache-dir --disable-pip-version-check -r requirements.txt; fi"
echo "ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 HOME=/tmp"
if [ "$healthcheck" = yes ]; then
# exec form, the URL is an argument — nothing here goes through a shell
printf 'HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=3 CMD ["python", "-c", "import sys, urllib.request; urllib.request.urlopen(sys.argv[1], timeout=2)", "%s"]\n' "$health"
fi
echo "USER 10001:10001"
echo 'CMD ["python", "main.py"]'
} >"$WORK/ctx/Dockerfile"
IMAGE=$NAME:$SHA
echo "building $IMAGE"
if ! docker build -q -t "$IMAGE" --label tgit.service="$SVC" "$WORK/ctx" >"$WORK/build.log" 2>&1; then
tail -20 "$WORK/build.log"
fail 5 "build failed"
fi
NEXT=$NAME-next
docker rm -f "$NEXT" >/dev/null 2>&1 || true
docker run -d --name "$NEXT" \
--label tgit.managed=true --label tgit.service="$SVC" \
--label tgit.sha="$SHA" --label tgit.deployed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--user 10001:10001 --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \
--cap-drop ALL --security-opt no-new-privileges \
--memory 128m --memory-swap 128m --cpus 0.25 --pids-limit 64 \
--network "$net" --restart on-failure:3 \
--log-opt max-size=5m --log-opt max-file=2 \
"$IMAGE" >/dev/null
if ! wait_ready "$NEXT" "$healthcheck"; then
echo "--- last log lines of the new version ---"
docker logs --tail 20 "$NEXT" 2>&1 || true
docker rm -f "$NEXT" >/dev/null 2>&1 || true
if exists "$NAME"; then fail 3 "not healthy within ${HEALTH_TIMEOUT}s, previous version kept"; fi
fail 3 "not healthy within ${HEALTH_TIMEOUT}s"
fi
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker rename "$NEXT" "$NAME"
# Keep the current image and one previous; older ones go
{ docker image ls "$NAME" --format '{{.Tag}}' | grep -vxF "$SHA" || true; } | tail -n +2 | while read -r old; do
docker image rm "$NAME:$old" >/dev/null 2>&1 || true
done
echo "OK deployed $SVC $SHA network=$net healthcheck=$healthcheck"
}
restart() {
need_service 2
exists "$NAME" || fail 2 "no such service"
docker restart -t 10 "$NAME" >/dev/null
echo "OK restarted $SVC"
}
remove() {
need_service 2
docker rm -f "$NAME" "$NAME-next" >/dev/null 2>&1 || true
docker image ls "$NAME" --format '{{.Repository}}:{{.Tag}}' | while read -r img; do
docker image rm "$img" >/dev/null 2>&1 || true
done
echo "OK removed $SVC"
}
case $CMD in
deploy) deploy ;;
restart) restart ;;
remove) remove ;;
list)
[ "${#ARGS[@]}" -eq 1 ] || fail 2 "list takes no arguments"
docker ps -a --filter label=tgit.service --format '{{.Names}} {{.Status}} {{.Label "tgit.sha"}}'
echo "OK list"
;;
*) fail 2 "unknown command" ;;
esac
+3 -2
View File
@@ -47,5 +47,6 @@ workflows:
script:
- python .tgit/changed_services.py > changed.txt
- cat changed.txt
# Запуск через tgit-runner по SSH — #13. До этого — только список сервисов к развёртыванию.
- 'echo "deploy stub: $(wc -l < changed.txt) service(s) to deploy"'
# Разворачивает бот сразу после push (tgit-runner, #13); здесь — только список для лога.
# Когда CI переедет сюда целиком (#11), бот будет ждать зелёного check перед деплоем.
- 'echo "$(wc -l < changed.txt) service(s) changed — deployed by the bot via tgit-runner"'