start-local-runner-daemon.sh 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. BASE_URL="${BASE_URL:-http://127.0.0.1:8787}"
  4. TOKEN="${TOKEN:-dev-token}"
  5. RUNNER_ID="${RUNNER_ID:-$(hostname -s 2>/dev/null || echo local-runner)}"
  6. RUNNER_MAX_SESSIONS="${RUNNER_MAX_SESSIONS:-1}"
  7. HEARTBEAT_INTERVAL_SEC="${HEARTBEAT_INTERVAL_SEC:-20}"
  8. RUNNER_ACTIVE_SESSIONS="${RUNNER_ACTIVE_SESSIONS:-0}"
  9. SANDBOX_AGENT_URL="${SANDBOX_AGENT_URL:-http://127.0.0.1:2468}"
  10. SANDBOX_AGENT_TOKEN="${SANDBOX_AGENT_TOKEN:-}"
  11. AUTO_START_SANDBOX_AGENT="${AUTO_START_SANDBOX_AGENT:-1}"
  12. AUTO_START_CLOUDFLARED="${AUTO_START_CLOUDFLARED:-1}"
  13. # If set, this value is used directly and cloudflared is skipped.
  14. RUNNER_BASE_URL="${RUNNER_BASE_URL:-}"
  15. ACCESS_CLIENT_ID="${ACCESS_CLIENT_ID:-}"
  16. ACCESS_CLIENT_SECRET="${ACCESS_CLIENT_SECRET:-}"
  17. # Extra args for cloudflared when AUTO_START_CLOUDFLARED=1.
  18. CLOUDFLARED_ARGS="${CLOUDFLARED_ARGS:-}"
  19. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
  20. CLOUDFLARED_LOG="${CLOUDFLARED_LOG:-${SCRIPT_DIR}/.local-runner-cloudflared.log}"
  21. SANDBOX_AGENT_PID=""
  22. CLOUDFLARED_PID=""
  23. log() {
  24. printf '[local-runner-daemon] %s\n' "$*"
  25. }
  26. json_escape() {
  27. local value="${1-}"
  28. value=${value//\\/\\\\}
  29. value=${value//\"/\\\"}
  30. value=${value//$'\n'/\\n}
  31. value=${value//$'\r'/\\r}
  32. value=${value//$'\t'/\\t}
  33. printf '%s' "$value"
  34. }
  35. require_cmd() {
  36. if ! command -v "$1" >/dev/null 2>&1; then
  37. echo "Missing required command: $1" >&2
  38. exit 1
  39. fi
  40. }
  41. cleanup() {
  42. if [[ -n "${CLOUDFLARED_PID}" ]] && kill -0 "${CLOUDFLARED_PID}" >/dev/null 2>&1; then
  43. log "Stopping cloudflared (pid=${CLOUDFLARED_PID})"
  44. kill "${CLOUDFLARED_PID}" >/dev/null 2>&1 || true
  45. fi
  46. if [[ -n "${SANDBOX_AGENT_PID}" ]] && kill -0 "${SANDBOX_AGENT_PID}" >/dev/null 2>&1; then
  47. log "Stopping sandbox-agent helper (pid=${SANDBOX_AGENT_PID})"
  48. kill "${SANDBOX_AGENT_PID}" >/dev/null 2>&1 || true
  49. fi
  50. }
  51. trap cleanup EXIT INT TERM
  52. sandbox_health() {
  53. local auth=()
  54. if [[ -n "${SANDBOX_AGENT_TOKEN}" ]]; then
  55. auth=(-H "authorization: Bearer ${SANDBOX_AGENT_TOKEN}")
  56. fi
  57. curl -sS -f "${auth[@]}" "${SANDBOX_AGENT_URL}/v1/health" >/dev/null
  58. }
  59. wait_sandbox_health() {
  60. local retries="${1:-60}"
  61. local interval="${2:-1}"
  62. local i=0
  63. while [[ "${i}" -lt "${retries}" ]]; do
  64. if sandbox_health; then
  65. return 0
  66. fi
  67. sleep "${interval}"
  68. i=$((i + 1))
  69. done
  70. return 1
  71. }
  72. start_sandbox_agent_if_needed() {
  73. if sandbox_health; then
  74. log "sandbox-agent is healthy at ${SANDBOX_AGENT_URL}"
  75. return 0
  76. fi
  77. if [[ "${AUTO_START_SANDBOX_AGENT}" != "1" ]]; then
  78. echo "sandbox-agent is not healthy and AUTO_START_SANDBOX_AGENT=0" >&2
  79. return 1
  80. fi
  81. log "Starting sandbox-agent via scripts/start-local-sandbox-agent.sh"
  82. "${SCRIPT_DIR}/start-local-sandbox-agent.sh" >"${SCRIPT_DIR}/.local-runner-sandbox-agent.log" 2>&1 &
  83. SANDBOX_AGENT_PID="$!"
  84. if ! wait_sandbox_health 90 1; then
  85. echo "sandbox-agent failed to become healthy at ${SANDBOX_AGENT_URL}" >&2
  86. return 1
  87. fi
  88. log "sandbox-agent is healthy at ${SANDBOX_AGENT_URL}"
  89. }
  90. resolve_runner_base_url() {
  91. if [[ -n "${RUNNER_BASE_URL}" ]]; then
  92. log "Using RUNNER_BASE_URL=${RUNNER_BASE_URL}"
  93. return 0
  94. fi
  95. if [[ "${AUTO_START_CLOUDFLARED}" != "1" ]]; then
  96. echo "RUNNER_BASE_URL is required when AUTO_START_CLOUDFLARED=0" >&2
  97. return 1
  98. fi
  99. require_cmd cloudflared
  100. : > "${CLOUDFLARED_LOG}"
  101. log "Starting cloudflared quick tunnel to ${SANDBOX_AGENT_URL}"
  102. # shellcheck disable=SC2086
  103. cloudflared tunnel --url "${SANDBOX_AGENT_URL}" ${CLOUDFLARED_ARGS} >"${CLOUDFLARED_LOG}" 2>&1 &
  104. CLOUDFLARED_PID="$!"
  105. local i=0
  106. while [[ "${i}" -lt 60 ]]; do
  107. if ! kill -0 "${CLOUDFLARED_PID}" >/dev/null 2>&1; then
  108. echo "cloudflared exited unexpectedly. See ${CLOUDFLARED_LOG}" >&2
  109. return 1
  110. fi
  111. local detected
  112. detected="$(grep -Eo 'https://[a-zA-Z0-9.-]+trycloudflare.com' "${CLOUDFLARED_LOG}" | tail -n 1 || true)"
  113. if [[ -n "${detected}" ]]; then
  114. RUNNER_BASE_URL="${detected}"
  115. log "Detected quick tunnel URL: ${RUNNER_BASE_URL}"
  116. log "For production, prefer a named tunnel + Access policy."
  117. return 0
  118. fi
  119. sleep 1
  120. i=$((i + 1))
  121. done
  122. echo "Could not detect cloudflared public URL. See ${CLOUDFLARED_LOG}" >&2
  123. return 1
  124. }
  125. register_payload() {
  126. local payload
  127. payload='{"runner-id":"'"$(json_escape "${RUNNER_ID}")"'","base-url":"'"$(json_escape "${RUNNER_BASE_URL%/}")"'","max-sessions":'"${RUNNER_MAX_SESSIONS}"
  128. if [[ -n "${SANDBOX_AGENT_TOKEN}" ]]; then
  129. payload+=',"agent-token":"'"$(json_escape "${SANDBOX_AGENT_TOKEN}")"'"'
  130. fi
  131. if [[ -n "${ACCESS_CLIENT_ID}" ]]; then
  132. payload+=',"access-client-id":"'"$(json_escape "${ACCESS_CLIENT_ID}")"'"'
  133. fi
  134. if [[ -n "${ACCESS_CLIENT_SECRET}" ]]; then
  135. payload+=',"access-client-secret":"'"$(json_escape "${ACCESS_CLIENT_SECRET}")"'"'
  136. fi
  137. payload+='}'
  138. printf '%s' "${payload}"
  139. }
  140. register_runner() {
  141. log "Registering runner '${RUNNER_ID}' at ${RUNNER_BASE_URL}"
  142. local response
  143. response="$(curl -sS -f -X POST "${BASE_URL}/runners" \
  144. -H "authorization: Bearer ${TOKEN}" \
  145. -H "content-type: application/json" \
  146. -d "$(register_payload)")"
  147. log "Runner registered"
  148. echo "${response}" >/dev/null
  149. }
  150. heartbeat_runner() {
  151. curl -sS -f -X POST "${BASE_URL}/runners/${RUNNER_ID}/heartbeat" \
  152. -H "authorization: Bearer ${TOKEN}" \
  153. -H "content-type: application/json" \
  154. -d "{\"active-sessions\":${RUNNER_ACTIVE_SESSIONS}}" >/dev/null
  155. }
  156. heartbeat_loop() {
  157. log "Starting heartbeat loop (interval=${HEARTBEAT_INTERVAL_SEC}s)"
  158. while true; do
  159. if heartbeat_runner; then
  160. :
  161. else
  162. log "Heartbeat failed, retrying register"
  163. register_runner || true
  164. fi
  165. sleep "${HEARTBEAT_INTERVAL_SEC}"
  166. done
  167. }
  168. main() {
  169. require_cmd curl
  170. start_sandbox_agent_if_needed
  171. resolve_runner_base_url
  172. register_runner
  173. heartbeat_loop
  174. }
  175. main "$@"