#!/bin/dash # Reconcile loop for gitea-runner-controller. # Owns: TTL sweep, orphan-VM sweep, deferred-job retry, stale-runner dereg, # startup convergence. Runs forever under systemd; webhook service is separate. gcr_record_age_sec() { created_at="$(gcr_record_field "$1" created_at)" now="$(gcr_now_epoch)" case "$created_at" in ''|*[!0-9]*) printf '%s' 999999 ;; *) printf '%s' "$((now - created_at))" ;; esac } gcr_sweep_ttl() { for f in $(gcr_active_records); do rec="$(cat "$f")" status="$(gcr_record_field "$rec" status)" job_id="$(gcr_record_field "$rec" job_id)" attempt="$(gcr_record_field "$rec" run_attempt)" if [ "$status" = "idle_vm" ]; then gcr_lock_acquire idle-pool || continue rec="$(gcr_record_get "$job_id" "$attempt")" if [ -z "$rec" ] || [ "$(gcr_record_field "$rec" status)" != "idle_vm" ]; then gcr_lock_release idle-pool continue fi if gcr_idle_record_unexpired "$rec"; then gcr_lock_release idle-pool continue fi vm_id="$(gcr_record_field "$rec" vm_id)" if gcr_record_vm_owned_elsewhere "$vm_id" "$job_id" "$attempt"; then gcr_log warn --ns=sweep "removing superseded idle record job=$job_id vm=$vm_id" gcr_record_del "$job_id" "$attempt" gcr_lock_release idle-pool continue fi gcr_log info --ns=sweep "idle slot expired job=$job_id vm=$vm_id" if [ -n "$vm_id" ] && [ "$vm_id" != "null" ] && [ "$vm_id" != "0" ]; then if gcr_vm_cleanup_start "$job_id" "$attempt" "$rec" idle-expired false; then gcr_event "vm-destroyed" "$job_id" \ "{\"vm_id\":$vm_id,\"reason\":\"idle-expired\"}" else gcr_event "vm-cleanup-pending" "$job_id" \ "{\"vm_id\":$vm_id,\"reason\":\"idle-expired\"}" fi else gcr_record_del "$job_id" "$attempt" fi gcr_lock_release idle-pool continue fi [ "$status" = "vm_active" ] || [ "$status" = "pending_vm" ] || continue ttl_min="$(gcr_record_field "$rec" ttl_min)" case "$ttl_min" in ''|*[!0-9]*) continue ;; esac max_sec="$((ttl_min * 60))" age="$(gcr_record_age_sec "$rec")" if [ "$age" -ge "$max_sec" ]; then key="$(gcr_alloc_key "$job_id" "$attempt")" gcr_lock_acquire "$key" || continue rec="$(gcr_record_get "$job_id" "$attempt")" case "$(gcr_record_field "$rec" status)" in pending_vm|vm_active) ;; *) gcr_lock_release "$key"; continue ;; esac ttl_min="$(gcr_record_field "$rec" ttl_min)" case "$ttl_min" in ''|*[!0-9]*) gcr_lock_release "$key"; continue ;; esac max_sec="$((ttl_min * 60))" age="$(gcr_record_age_sec "$rec")" if [ "$age" -lt "$max_sec" ]; then gcr_lock_release "$key" continue fi vm_id="$(gcr_record_field "$rec" vm_id)" gcr_log warn --ns=sweep "TTL exceeded job=$job_id age=${age}s max=${max_sec}s" if [ -n "$vm_id" ] && [ "$vm_id" != "null" ] && [ "$vm_id" != "0" ]; then ip="$(gcr_vm_public_ip "$vm_id" || true)" gcr_vm_collect_diagnostics "$vm_id" "$ip" "$job_id" ttl || true if gcr_vm_cleanup_start "$job_id" "$attempt" "$rec" ttl false; then gcr_event "vm-destroyed" "$job_id" \ "{\"vm_id\":$vm_id,\"reason\":\"ttl\"}" else gcr_event "vm-cleanup-pending" "$job_id" \ "{\"vm_id\":$vm_id,\"reason\":\"ttl\"}" fi else gcr_record_del "$job_id" "$attempt" fi gcr_event "job-ttl-expired" "$job_id" "{\"age\":$age}" gcr_lock_release "$key" fi done } gcr_sweep_orphan_vms() { gcr_lock_acquire admission || return 0 vms_json="$(gcr_vm_list_managed)" || { gcr_lock_release admission return 0 } if ! gcr_lock_acquire idle-pool; then gcr_lock_release admission return 0 fi count="$(printf '%s' "$vms_json" | jq 'length')" i=0 while [ "$i" -lt "$count" ]; do vm="$(printf '%s' "$vms_json" | jq -c ".[$i]")" vm_id="$(printf '%s' "$vm" | jq -r '.id')" jid="$(printf '%s' "$vm" | jq -r '.labels["gcr.job-id"] // ""')" att="$(printf '%s' "$vm" | jq -r '.labels["gcr.run-attempt"] // ""')" if ! gcr_record_exists_for_vm_id "$vm_id"; then gcr_log warn --ns=sweep "orphan VM $vm_id job=$jid attempt=$att -> destroy" cleanup_job="${jid:-orphan-$vm_id}" cleanup_attempt="${att:-0}" cleanup_rec="$(jq -n --arg j "$cleanup_job" --arg a "$cleanup_attempt" \ --arg v "$vm_id" --arg vn "$(printf '%s' "$vm" | jq -r '.name // ""')" \ '{job_id:$j, run_attempt:$a, repo:"", label:"", created_at:"0", ttl_min:0, vm_id:($v|tonumber), vm_name:$vn, bootstrapped:false, status:"cleanup_pending"}')" if gcr_vm_cleanup_start "$cleanup_job" "$cleanup_attempt" \ "$cleanup_rec" orphan false; then gcr_event "orphan-vm-destroyed" "$cleanup_job" "{\"vm_id\":$vm_id}" else gcr_event "vm-cleanup-pending" "$cleanup_job" \ "{\"vm_id\":$vm_id,\"reason\":\"orphan\"}" fi fi i=$((i + 1)) done gcr_lock_release idle-pool gcr_lock_release admission } gcr_alloc_deferred() { job_id="$1"; attempt="$2" key="$(gcr_alloc_key "$job_id" "$attempt")" gcr_lock_acquire "$key" || return 0 rec="$(gcr_record_get "$job_id" "$attempt")" if [ -z "$rec" ] || [ "$(gcr_record_field "$rec" status)" != "deferred" ]; then gcr_lock_release "$key" return 0 fi repo="$(gcr_record_field "$rec" repo)" label="$(gcr_record_field "$rec" label)" state="$(gcr_gitea_job_state "$repo" "$job_id")" || { gcr_lock_release "$key" return 0 } case "$state" in completed:*) gcr_log info --ns=alloc "deferred job=$job_id already terminal ($state), dropping record" gcr_record_del "$job_id" "$attempt" gcr_lock_release "$key" return 0 ;; esac profile="$(gcr_label_profile "$label")" || { gcr_lock_release "$key" return 0 } set -- $profile server_type="$1"; ttl_min="$2" gcr_lock_acquire admission || { gcr_lock_release "$key" return 0 } active="$(gcr_count_active)" repo_active="$(gcr_count_active_repo "$repo")" if [ "$active" -ge "${GCR_CONCURRENCY_CAP:-2}" ] \ || [ "$repo_active" -ge "${GCR_PER_REPO_CAP:-1}" ]; then gcr_lock_release admission gcr_lock_release "$key" return 0 fi claim_status=0 gcr_claim_idle "$job_id" "$attempt" "$repo" "$label" || claim_status="$?" if [ "$claim_status" -eq 0 ]; then reused="$(gcr_record_get "$job_id" "$attempt")" vm_id="$(gcr_record_field "$reused" vm_id)" if gcr_vm_runner_service "$vm_id" start \ && gcr_vm_runner_service "$vm_id" health \ && gcr_gitea_runner_disabled "$repo" "$(gcr_record_field "$reused" vm_name)" false; then reused="$(gcr_record_get "$job_id" "$attempt")" reused="$(printf '%s' "$reused" | jq -c '.bootstrapped = true | del(.reused_vm)')" gcr_record_put "$job_id" "$attempt" "$reused" else gcr_gitea_runner_disabled "$repo" "$(gcr_record_field "$reused" vm_name)" true || true gcr_event "vm-reuse-start-failed" "$job_id" "{\"vm_id\":$vm_id,\"via\":\"deferred-retry\"}" fi gcr_lock_release admission gcr_lock_release "$key" gcr_event "vm-reused" "$job_id" "{\"vm_id\":$vm_id,\"label\":\"$label\",\"via\":\"deferred-retry\"}" gcr_log info --ns=alloc "deferred job=$job_id reused vm=$vm_id" return 0 fi if [ "$claim_status" -eq 2 ]; then gcr_lock_release admission gcr_lock_release "$key" return 0 fi reg_token="$(gcr_gitea_registration_token "$repo")" || { gcr_lock_release admission gcr_lock_release "$key" return 0 } vm_name="gcr-${job_id}-${attempt}" created_at="$(gcr_now_epoch)" create_status=0 created="$(gcr_vm_create "$vm_name" "$label" "$server_type" "$ttl_min" \ "$reg_token" "$job_id" "$attempt" "$repo")" || create_status="$?" if [ "$create_status" -ne 0 ] || [ -z "$created" ]; then gcr_lock_release admission gcr_lock_release "$key" return 0 fi set -- $created vm_id="$1"; actual_server_type="$2"; actual_rate="$3" if ! gcr_vm_record_created "$job_id" "$attempt" "$repo" "$label" \ "$created_at" "$vm_id" "$vm_name" "$ttl_min" \ "$actual_server_type" "$actual_rate"; then gcr_lock_release admission gcr_lock_release "$key" return 0 fi gcr_lock_release admission gcr_lock_release "$key" gcr_event "vm-created" "$job_id" "{\"vm_id\":$vm_id,\"label\":\"$label\",\"ttl_min\":$ttl_min,\"via\":\"deferred-retry\"}" gcr_log info --ns=alloc "deferred job=$job_id allocated vm=$vm_id" } gcr_sweep_cleanup_pending() { for f in $(gcr_active_records); do rec="$(cat "$f")" [ "$(gcr_record_field "$rec" status)" = "cleanup_pending" ] || continue job_id="$(gcr_record_field "$rec" job_id)" attempt="$(gcr_record_field "$rec" run_attempt)" key="$(gcr_alloc_key "$job_id" "$attempt")" gcr_lock_acquire "$key" || continue if ! gcr_lock_acquire admission; then gcr_lock_release "$key" continue fi rec="$(gcr_record_get "$job_id" "$attempt")" if [ "$(gcr_record_field "$rec" status)" = "cleanup_pending" ]; then gcr_vm_cleanup_pending "$job_id" "$attempt" "$rec" || true fi gcr_lock_release admission gcr_lock_release "$key" done } gcr_sweep_create_ambiguous() { for f in $(gcr_active_records); do rec="$(cat "$f")" [ "$(gcr_record_field "$rec" status)" = "create_ambiguous" ] || continue job_id="$(gcr_record_field "$rec" job_id)" attempt="$(gcr_record_field "$rec" run_attempt)" key="$(gcr_alloc_key "$job_id" "$attempt")" gcr_lock_acquire "$key" || continue if ! gcr_lock_acquire admission; then gcr_lock_release "$key" continue fi rec="$(gcr_record_get "$job_id" "$attempt")" if [ "$(gcr_record_field "$rec" status)" != "create_ambiguous" ]; then gcr_lock_release admission gcr_lock_release "$key" continue fi find_status=0 found_vm_id="$(gcr_vm_find_created \ "$(gcr_record_field "$rec" vm_name)" "$job_id" "$attempt" \ "$(gcr_record_field "$rec" label)" \ "$(gcr_record_field "$rec" server_type)" \ "$(gcr_record_field "$rec" candidate_location)" \ "$(gcr_record_field "$rec" candidate_arch)")" || find_status="$?" case "$find_status" in 0) rec="$(printf '%s' "$rec" | jq -c --arg vm "$found_vm_id" \ '.vm_id = ($vm | tonumber) | .status = "pending_vm" | .bootstrapped = false | del(.create_http, .create_curl_status, .candidate_location, .candidate_arch)')" if gcr_record_put "$job_id" "$attempt" "$rec"; then gcr_event "vm-create-recovered" "$job_id" \ "{\"vm_id\":$found_vm_id,\"label\":\"$(gcr_record_field "$rec" label)\"}" fi ;; 1) rec="$(printf '%s' "$rec" | jq -c \ '.status = "cleanup_pending" | .cleanup_reason = "ambiguous-create-absent" | .cleanup_refund_budget = true | .cleanup_vm_destroyed = true | .cleanup_budget_released = false')" if gcr_record_put "$job_id" "$attempt" "$rec"; then gcr_vm_cleanup_pending "$job_id" "$attempt" "$rec" || true fi ;; 2) ;; esac gcr_lock_release admission gcr_lock_release "$key" done } gcr_retry_deferred() { for f in $(gcr_active_records); do rec="$(cat "$f")" [ "$(gcr_record_field "$rec" status)" = "deferred" ] || continue gcr_alloc_deferred \ "$(gcr_record_field "$rec" job_id)" \ "$(gcr_record_field "$rec" run_attempt)" done } gcr_sweep_stale_runners() { gcr_lock_acquire idle-pool || return 0 oldIFS="$IFS" IFS=, for allowed_repo in ${GCR_ALLOWED_REPOS:-}; do IFS="$oldIFS" case "$allowed_repo" in */\*) owner="${allowed_repo%/*}" repos="$(gcr_gitea_list_org_repos "$owner")" || { IFS=, continue } ;; */*) repos="$allowed_repo" ;; *) continue ;; esac while read -r repo; do [ -n "${repo:-}" ] || continue gcr_repo_allowed "$repo" || continue runners="$(gcr_gitea_list_runners "$repo")" || continue # Here-doc instead of pipe: dash runs pipe tails in a subshell, which # would strand gcr_event/audit writes from the caller's perspective. while read -r rid rname; do [ -n "${rid:-}" ] || continue case "$rname" in gcr-*) ;; *) continue ;; esac # Runner name stays tied to original VM across later job assignments. rest="${rname#gcr-}" jid="${rest%-*}" if ! gcr_record_exists_for_vm_name "$rname"; then gcr_log warn --ns=sweep "stale repo=$repo registration id=$rid name=$rname -> delete" if gcr_gitea_delete_runner "$repo" "$rid"; then gcr_event "stale-runner-deleted" "${jid:-unknown}" "{\"repo\":\"$repo\",\"runner_id\":$rid,\"name\":\"$rname\"}" else gcr_log error --ns=sweep "failed deleting repo=$repo runner id=$rid" fi fi done <