#!/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
