#!/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 stdin: tar of services// (files at the archive root) # restart # remove # 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 ». 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:/" 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