feat(runner): tgit-runner — сервисы в песочнице на хосте, деплой после push (#13)
CI / ruff · mypy · pytest (pull_request) Successful in 4m3s
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:
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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`, сам скрипт настоящий).
|
||||
@@ -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
|
||||
Executable
+28
@@ -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"
|
||||
Executable
+184
@@ -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
|
||||
@@ -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"'
|
||||
|
||||
Reference in New Issue
Block a user