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:
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
|
||||
Reference in New Issue
Block a user