feat: zero-idle gite runners infra
This commit is contained in:
@@ -144,6 +144,7 @@ in {
|
||||
support-bot = pkgs.callPackage ./support-bot {};
|
||||
gitea-heatmap = pkgs.callPackage ./gitea {};
|
||||
gitea-runner-nix-image = pkgs.callPackage ./gitea-runner-nix-image {};
|
||||
gitea-runner-controller = pkgs.callPackage ./gitea-runner-controller {};
|
||||
nix-derivation-hash = pkgs.callPackage ./nix-derivation-hash {};
|
||||
"sentinèlla" = pkgs.callPackage (./. + "/sentinèlla") {};
|
||||
deploy = pkgs.callPackage ./deploy { inherit inputs; };
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/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_ttl_grace_sec() {
|
||||
printf '%s' "$((10 * 60))"
|
||||
}
|
||||
|
||||
gcr_record_age_sec() {
|
||||
created_at="$(gcr_record_field "$1" created_at)"
|
||||
now="$(date -u '+%s')"
|
||||
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)"
|
||||
[ "$status" = "vm_active" ] || [ "$status" = "pending_vm" ] || continue
|
||||
|
||||
job_id="$(gcr_record_field "$rec" job_id)"
|
||||
attempt="$(gcr_record_field "$rec" run_attempt)"
|
||||
ttl_min="$(gcr_record_field "$rec" ttl_min)"
|
||||
case "$ttl_min" in ''|*[!0-9]*) continue ;; esac
|
||||
|
||||
max_sec="$((ttl_min * 60 + $(gcr_ttl_grace_sec)))"
|
||||
age="$(gcr_record_age_sec "$rec")"
|
||||
if [ "$age" -gt "$max_sec" ]; then
|
||||
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
|
||||
gcr_vm_destroy "$vm_id" || true
|
||||
gcr_event "vm-destroyed" "$job_id" "{\"vm_id\":$vm_id,\"reason\":\"ttl\"}"
|
||||
fi
|
||||
gcr_event "job-ttl-expired" "$job_id" "{\"age\":$age}"
|
||||
gcr_record_del "$job_id" "$attempt"
|
||||
gcr_lock_release "$(gcr_alloc_key "$job_id" "$attempt")"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
gcr_sweep_orphan_vms() {
|
||||
vms_json="$(gcr_vm_list_managed)" || return 0
|
||||
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"] // ""')"
|
||||
|
||||
known=""
|
||||
if [ -n "$jid" ] && [ -n "$att" ]; then
|
||||
rec="$(gcr_record_get "$jid" "$att")"
|
||||
[ -n "$rec" ] && known=1
|
||||
fi
|
||||
|
||||
if [ -z "$known" ]; then
|
||||
gcr_log warn --ns=sweep "orphan VM $vm_id job=$jid attempt=$att -> destroy"
|
||||
gcr_vm_destroy "$vm_id" || true
|
||||
gcr_event "orphan-vm-destroyed" "${jid:-unknown}" "{\"vm_id\":$vm_id}"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
}
|
||||
|
||||
gcr_alloc_deferred() {
|
||||
job_id="$1"; attempt="$2"
|
||||
|
||||
rec="$(gcr_record_get "$job_id" "$attempt")"
|
||||
[ -n "$rec" ] || return 0
|
||||
[ "$(gcr_record_field "$rec" status)" = "deferred" ] || return 0
|
||||
|
||||
repo="$(gcr_record_field "$rec" repo)"
|
||||
label="$(gcr_record_field "$rec" label)"
|
||||
|
||||
profile="$(gcr_label_profile "$label")" || return 0
|
||||
set -- $profile
|
||||
server_type="$1"; ttl_min="$2"; rate="$3"
|
||||
|
||||
active="$(gcr_count_active)"
|
||||
repo_active="$(gcr_count_active_repo "$repo")"
|
||||
[ "$active" -ge "${GCR_CONCURRENCY_CAP:-2}" ] && return 0
|
||||
[ "$repo_active" -ge "${GCR_PER_REPO_CAP:-1}" ] && return 0
|
||||
|
||||
gcr_budget_add "$rate" "$ttl_min" || return 0
|
||||
reg_token="$(gcr_gitea_registration_token)" || return 0
|
||||
|
||||
key="$(gcr_alloc_key "$job_id" "$attempt")"
|
||||
gcr_lock_acquire "$key" || return 0
|
||||
|
||||
vm_name="gcr-${job_id}-${attempt}"
|
||||
vm_id="$(gcr_vm_create "$vm_name" "$label" "$server_type" "$ttl_min" \
|
||||
"$reg_token" "$job_id" "$attempt" "$repo")" && [ -n "$vm_id" ] || {
|
||||
gcr_lock_release "$key"
|
||||
return 0
|
||||
}
|
||||
|
||||
rec="$(jq -n --arg j "$job_id" --arg a "$attempt" --arg r "$repo" \
|
||||
--arg l "$label" --arg t "$(date -u '+%s')" --arg v "$vm_id" \
|
||||
--arg vn "$vm_name" --arg ttl "$ttl_min" \
|
||||
'{job_id:$j, run_attempt:$a, repo:$r, label:$l,
|
||||
created_at:$t, ttl_min:($ttl|tonumber), vm_id:($v|tonumber),
|
||||
vm_name:$vn, bootstrapped:false, status:"pending_vm"}')"
|
||||
gcr_record_put "$job_id" "$attempt" "$rec"
|
||||
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_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() {
|
||||
runners="$(gcr_gitea_list_runners)" || return 0
|
||||
# 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
|
||||
|
||||
# gcr-<job>-<attempt>: alive iff a matching active/pending record exists.
|
||||
rest="${rname#gcr-}"
|
||||
jid="${rest%-*}"
|
||||
att="${rest##*-}"
|
||||
rec=""
|
||||
case "$jid" in *[!0-9]*|"") rec="" ;;
|
||||
*) case "$att" in *[!0-9]*|"") rec="" ;;
|
||||
*) rec="$(gcr_record_get "$jid" "$att")" ;;
|
||||
esac ;;
|
||||
esac
|
||||
|
||||
if [ -z "$rec" ]; then
|
||||
gcr_log warn --ns=sweep "stale runner registration id=$rid name=$rname -> delete"
|
||||
if gcr_gitea_delete_runner "$rid"; then
|
||||
gcr_event "stale-runner-deleted" "${jid:-unknown}" "{\"runner_id\":$rid,\"name\":\"$rname\"}"
|
||||
else
|
||||
gcr_log error --ns=sweep "failed deleting runner id=$rid"
|
||||
fi
|
||||
fi
|
||||
done <<EOF
|
||||
$runners
|
||||
EOF
|
||||
}
|
||||
|
||||
gcr_vm_public_ip() {
|
||||
# gcr_vm_public_ip SERVER_ID -> ipv4 or empty
|
||||
if gcr_hcloud_req GET "/servers/$1"; then
|
||||
jq -r '.server.public_net.ipv4.ip // ""' "$GCR_LAST_BODY"
|
||||
fi
|
||||
}
|
||||
|
||||
# Runs SSH-push bootstrap for VMs that were created but not yet provisioned.
|
||||
# Registration token is fetched fresh per attempt (short-lived usefulness).
|
||||
gcr_bootstrap_pending() {
|
||||
for f in $(gcr_active_records); do
|
||||
rec="$(cat "$f")"
|
||||
[ "$(gcr_record_field "$rec" status)" = "pending_vm" ] || continue
|
||||
[ "$(gcr_record_field "$rec" bootstrapped)" = "true" ] && continue
|
||||
|
||||
job_id="$(gcr_record_field "$rec" job_id)"
|
||||
attempt="$(gcr_record_field "$rec" run_attempt)"
|
||||
label="$(gcr_record_field "$rec" label)"
|
||||
vm_id="$(gcr_record_field "$rec" vm_id)"
|
||||
runner_name="$(gcr_record_field "$rec" vm_name)"
|
||||
|
||||
ip="$(gcr_vm_public_ip "$vm_id")"
|
||||
[ -n "$ip" ] || continue
|
||||
|
||||
reg_token="$(gcr_gitea_registration_token)" || continue
|
||||
ttl_min="$(gcr_record_field "$rec" ttl_min)"
|
||||
|
||||
gcr_log info --ns=alloc "bootstrapping vm=$vm_id ip=$ip job=$job_id"
|
||||
if gcr_vm_bootstrap_ssh "$ip" "$label" "$reg_token" "$runner_name"; then
|
||||
rec="$(printf '%s' "$rec" | jq -c '.bootstrapped = true | .ip = $ip' --arg ip "$ip")"
|
||||
gcr_record_put "$job_id" "$attempt" "$rec"
|
||||
gcr_event "vm-bootstrapped" "$job_id" "{\"vm_id\":$vm_id,\"ip\":\"$ip\"}"
|
||||
else
|
||||
gcr_log warn --ns=alloc "bootstrap failed vm=$vm_id (retry next tick)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
gcr_tick() {
|
||||
gcr_sweep_ttl
|
||||
gcr_sweep_orphan_vms
|
||||
gcr_retry_deferred
|
||||
gcr_bootstrap_pending
|
||||
gcr_sweep_stale_runners
|
||||
}
|
||||
|
||||
gcr_main() {
|
||||
: "${GCR_RECONCILE_INTERVAL_SEC:=60}"
|
||||
gcr_state_init
|
||||
|
||||
gcr_log info --ns=core "controller starting, interval=${GCR_RECONCILE_INTERVAL_SEC}s state=$GCR_STATE_DIR"
|
||||
gcr_tick
|
||||
|
||||
while :; do
|
||||
sleep "$GCR_RECONCILE_INTERVAL_SEC"
|
||||
if ! gcr_tick; then
|
||||
gcr_log error --ns=core "tick failed, retrying next interval"
|
||||
fi
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/bin/dash
|
||||
# Allocation decision for gitea-runner-controller.
|
||||
# Fail-closed: anything not explicitly allowed here is refused.
|
||||
#
|
||||
# gcr_decide LABEL REPO -> prints "<server_type> <ttl_min> <rate_eur_h>" and
|
||||
# returns 0 when allowed; returns 1 with reason on stderr otherwise.
|
||||
|
||||
gcr_label_profile() {
|
||||
case "$1" in
|
||||
nix) printf 'cx33 180 0.008' ;;
|
||||
ubuntu-latest) printf 'cx33 60 0.008' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
gcr_repo_allowed() {
|
||||
repo="$1"
|
||||
oldIFS="$IFS"
|
||||
IFS=,
|
||||
for allowed in ${GCR_ALLOWED_REPOS:-}; do
|
||||
if [ "$allowed" = "$repo" ]; then
|
||||
IFS="$oldIFS"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
IFS="$oldIFS"
|
||||
return 1
|
||||
}
|
||||
|
||||
gcr_decide() {
|
||||
label="$1"; repo="$2"
|
||||
|
||||
if ! gcr_repo_allowed "$repo"; then
|
||||
gcr_log warn --ns=decide "repo not allowed: $repo"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Multi-label jobs are out of MVP scope: ambiguous VM profile mapping.
|
||||
if ! profile="$(gcr_label_profile "$label")"; then
|
||||
gcr_log warn --ns=decide "unknown or unsupported label: $label"
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$profile"
|
||||
}
|
||||
|
||||
gcr_count_active() {
|
||||
count=0
|
||||
for f in $(gcr_active_records); do
|
||||
status="$(gcr_record_field "$(cat "$f")" status)"
|
||||
case "$status" in
|
||||
pending_vm|vm_active) count=$((count + 1)) ;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "$count"
|
||||
}
|
||||
|
||||
gcr_count_active_repo() {
|
||||
repo="$1"
|
||||
count=0
|
||||
for f in $(gcr_active_records); do
|
||||
rec="$(cat "$f")"
|
||||
case "$(gcr_record_field "$rec" status)" in
|
||||
pending_vm|vm_active) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
[ "$(gcr_record_field "$rec" repo)" = "$repo" ] && count=$((count + 1))
|
||||
done
|
||||
printf '%s' "$count"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
symlinkJoin,
|
||||
hectic,
|
||||
dash,
|
||||
socat,
|
||||
curl,
|
||||
jq,
|
||||
coreutils,
|
||||
gawk,
|
||||
gnugrep,
|
||||
gnused,
|
||||
openssl,
|
||||
}:
|
||||
let
|
||||
shell = "${dash}/bin/dash";
|
||||
bashOptions = [
|
||||
"errexit"
|
||||
"nounset"
|
||||
];
|
||||
# SC2329: lib units are sourced wholesale into both binaries; the reconciler
|
||||
# and webhook each leave a few wrapper functions unreferenced by design.
|
||||
excludeShellChecks = [
|
||||
"SC2086" # word splitting on purpose: env lists and profile triples
|
||||
"SC2046" # same, command substitution into set --
|
||||
"SC2329"
|
||||
];
|
||||
|
||||
runtimeDeps = [
|
||||
curl
|
||||
jq
|
||||
coreutils
|
||||
gawk
|
||||
gnugrep
|
||||
gnused
|
||||
openssl
|
||||
];
|
||||
|
||||
lib = ''
|
||||
${builtins.readFile ./log.sh}
|
||||
${builtins.readFile ./state.sh}
|
||||
${builtins.readFile ./decide.sh}
|
||||
${builtins.readFile ./hcloud.sh}
|
||||
${builtins.readFile ./gitea.sh}
|
||||
'';
|
||||
|
||||
handler = hectic.writeShellApplication {
|
||||
inherit shell bashOptions;
|
||||
inherit excludeShellChecks;
|
||||
name = "gcr-webhook-handler";
|
||||
runtimeInputs = [ socat ] ++ runtimeDeps;
|
||||
text = ''
|
||||
${lib}
|
||||
${builtins.readFile ./webhook.sh}
|
||||
gcr_state_init
|
||||
gcr_handle_webhook || gcr_respond 500 "internal error"
|
||||
exit 0
|
||||
'';
|
||||
};
|
||||
|
||||
webhook = hectic.writeShellApplication {
|
||||
inherit shell bashOptions;
|
||||
inherit excludeShellChecks;
|
||||
name = "gitea-runner-webhook";
|
||||
runtimeInputs = [ socat ];
|
||||
text = ''
|
||||
: "''${GCR_LISTEN_ADDR:=127.0.0.1}"
|
||||
: "''${GCR_LISTEN_PORT:=8787}"
|
||||
exec ${socat}/bin/socat -T5 -t5 \
|
||||
"TCP-LISTEN:$GCR_LISTEN_PORT,bind=$GCR_LISTEN_ADDR,reuseaddr,fork" \
|
||||
EXEC:"${handler}/bin/gcr-webhook-handler",pipes
|
||||
'';
|
||||
};
|
||||
|
||||
controller = hectic.writeShellApplication {
|
||||
inherit shell bashOptions;
|
||||
inherit excludeShellChecks;
|
||||
name = "gitea-runner-controller";
|
||||
runtimeInputs = runtimeDeps;
|
||||
text = ''
|
||||
${lib}
|
||||
${builtins.readFile ./controller.sh}
|
||||
gcr_main
|
||||
'';
|
||||
};
|
||||
in
|
||||
symlinkJoin {
|
||||
name = "gitea-runner-controller";
|
||||
paths = [
|
||||
controller
|
||||
webhook
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/dash
|
||||
# Gitea API wrappers for gitea-runner-controller.
|
||||
# Requires: GCR_GITEA_URL, GITEA_REGISTRATION_TOKEN_FILE, GITEA_ADMIN_TOKEN_FILE
|
||||
|
||||
gcr_gitea_registration_token() {
|
||||
token="$(gcr_gitea_admin_token)" || return 1
|
||||
curl -fsS -X POST -H "Authorization: token $token" \
|
||||
"$GCR_GITEA_URL/api/v1/orgs/hectic-lab/actions/runners/registration-token" \
|
||||
| jq -r '.token'
|
||||
}
|
||||
|
||||
gcr_gitea_admin_token() {
|
||||
test -r "${GITEA_ADMIN_TOKEN_FILE:-}" || {
|
||||
gcr_log error --ns=gitea "GITEA_ADMIN_TOKEN_FILE missing"
|
||||
return 1
|
||||
}
|
||||
tr -d '\n' < "$GITEA_ADMIN_TOKEN_FILE"
|
||||
}
|
||||
|
||||
# gcr_gitea_list_runners — prints "id name" lines for org hectic-lab.
|
||||
gcr_gitea_list_runners() {
|
||||
token="$(gcr_gitea_admin_token)" || return 1
|
||||
curl -fsS -H "Authorization: token $token" \
|
||||
"$GCR_GITEA_URL/api/v1/orgs/hectic-lab/actions/runners?per_page=50" \
|
||||
| jq -r '.entries[] | "\(.id) \(.name)"'
|
||||
}
|
||||
|
||||
gcr_gitea_delete_runner() {
|
||||
id="$1"
|
||||
token="$(gcr_gitea_admin_token)" || return 1
|
||||
curl -fsS -X DELETE -H "Authorization: token $token" \
|
||||
"$GCR_GITEA_URL/api/v1/orgs/hectic-lab/actions/runners/$id"
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/bin/dash
|
||||
# Hetzner Cloud API wrappers for gitea-runner-controller.
|
||||
# Requires: HCLOUD_TOKEN_FILE, GCR_HETZNER_LOCATION, GCR_IMAGE_ID,
|
||||
# GCR_ACT_RUNNER_VERSION, GCR_ACT_RUNNER_SHA256, GCR_NIX_VERSION,
|
||||
# GCR_NIX_TARBALL_SHA256, GCR_GITEA_URL
|
||||
# All VMs carry the tag pair gitea-runner-controller=managed plus gcr.* metadata.
|
||||
|
||||
GCR_API="https://api.hetzner.cloud/v1"
|
||||
|
||||
gcr_hcloud_token() {
|
||||
test -n "${HCLOUD_TOKEN_FILE:-}" && test -r "$HCLOUD_TOKEN_FILE" || {
|
||||
gcr_log error --ns=hcloud "HCLOUD_TOKEN_FILE missing or unreadable"
|
||||
return 1
|
||||
}
|
||||
tr -d '\n' < "$HCLOUD_TOKEN_FILE"
|
||||
}
|
||||
|
||||
# All request state flows through files/exit codes, never command substitution
|
||||
# ($( ) runs in a subshell and would strand GCR_REQ_FAILED/GCR_LAST_HTTP).
|
||||
gcr_hcloud_req() {
|
||||
# gcr_hcloud_req METHOD PATH [JSON_BODY]
|
||||
# Body written to $GCR_LAST_BODY; exit 0 only on HTTP 2xx.
|
||||
method="$1"; path="$2"; body="${3:-}"
|
||||
token="$(gcr_hcloud_token)" || return 1
|
||||
GCR_LAST_BODY="$(mktemp "${TMPDIR:-/tmp}/gcr-resp.XXXXXX")"
|
||||
if [ -n "$body" ]; then
|
||||
code="$(printf '%s' "$body" | curl -sS -X "$method" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @- \
|
||||
-o "$GCR_LAST_BODY" \
|
||||
-w '%{http_code}' \
|
||||
"$GCR_API$path")"
|
||||
else
|
||||
code="$(curl -sS -X "$method" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-o "$GCR_LAST_BODY" \
|
||||
-w '%{http_code}' \
|
||||
"$GCR_API$path")"
|
||||
fi
|
||||
case "$code" in 2??) return 0 ;; esac
|
||||
gcr_log warn --ns=hcloud "request failed path=$path http=$code body=$(head -c 200 "$GCR_LAST_BODY" | gcr_redact)"
|
||||
return 1
|
||||
}
|
||||
|
||||
gcr_vm_list_managed() {
|
||||
if gcr_hcloud_req GET "/servers?label_selector=gitea-runner-controller%3Dmanaged&per_page=50"; then
|
||||
jq -S '.servers' "$GCR_LAST_BODY"
|
||||
fi
|
||||
}
|
||||
|
||||
gcr_vm_build_userdata() {
|
||||
vm_name="$1"; label="$2"; reg_token="$3"
|
||||
|
||||
nix_conf='accept-flake-config = true
|
||||
experimental-features = nix-command flakes
|
||||
substituters = https://cache.nixos.org https://cache.hectic-lab.com/hectic
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gW4x6l1xP+GxgH0r7u+f6p1VFlr0= hectic:KMQsKow4SoA9K2vOJlOljmx7/Zpf91Yy+5qEtxDDCzA=
|
||||
sandbox = false'
|
||||
|
||||
runner_config="log:
|
||||
level: info
|
||||
runner:
|
||||
file: /var/lib/gitea-runner/.runner
|
||||
capacity: 1
|
||||
timeout: $(printf '%s' "$(gcr_label_profile "$label")" | awk '{print $2}')m
|
||||
insecure: false
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
labels:
|
||||
- \"$label:host\""
|
||||
|
||||
ssh_key_block=""
|
||||
if [ -n "${GCR_DEBUG_SSH_PUBKEY:-}" ]; then
|
||||
ssh_key_block=" - path: /root/.ssh/authorized_keys
|
||||
permissions: '0600'
|
||||
content: |
|
||||
$GCR_DEBUG_SSH_PUBKEY"
|
||||
fi
|
||||
|
||||
# NOTE(yukkop): token reaches only this VM's Hetzner metadata service;
|
||||
# ephemeral registration makes it useless after the single job exits.
|
||||
printf '%s' "#cloud-config
|
||||
write_files:
|
||||
$ssh_key_block
|
||||
- path: /etc/ssh/sshd_config.d/99-gcr-root.conf
|
||||
permissions: '0644'
|
||||
content: |
|
||||
PermitRootLogin prohibit-password
|
||||
PubkeyAuthentication yes
|
||||
- path: /etc/nix/nix.conf
|
||||
content: |
|
||||
$(printf '%s\n' "$nix_conf" | sed 's/^/ /')
|
||||
- path: /etc/gitea-runner/config.yaml
|
||||
content: |
|
||||
$(printf '%s\n' "$runner_config" | sed 's/^/ /')
|
||||
- path: /etc/systemd/system/gitea-runner.service
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Gitea ephemeral Actions runner
|
||||
After=network-online.target gcr-bootstrap.service
|
||||
Requires=gcr-bootstrap.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=GITEA_INSTANCE_URL=$GCR_GITEA_URL
|
||||
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=$reg_token
|
||||
ExecStart=/usr/local/bin/act_runner daemon --ephemeral --config /etc/gitea-runner/config.yaml
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
- path: /etc/systemd/system/gcr-bootstrap.service
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Bootstrap Nix + act_runner for ephemeral CI job
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Before=gitea-runner.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=true
|
||||
ExecStart=/usr/local/sbin/gcr-bootstrap
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
- path: /usr/local/sbin/gcr-bootstrap
|
||||
permissions: '0700'
|
||||
content: |
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
exec > /var/log/gcr-bootstrap.log 2>&1
|
||||
curl -fsSL \"https://nixos.org/releases/nix/$GCR_NIX_VERSION/nix-$GCR_NIX_VERSION-x86_64-linux.tar.xz\" -o /tmp/nix.tar.xz
|
||||
printf '%s /tmp/nix.tar.xz\n' \"$GCR_NIX_TARBALL_SHA256\" | sha256sum -c -
|
||||
tar -xJf /tmp/nix.tar.xz -C /tmp
|
||||
/tmp/nix-$GCR_NIX_VERSION-x86_64-linux/install --no-daemon
|
||||
rm -rf /tmp/nix*
|
||||
curl -fsSL \"https://gitea.com/gitea/act_runner/releases/download/$GCR_ACT_RUNNER_VERSION/act_runner-\$(printf '%s' \"$GCR_ACT_RUNNER_VERSION\" | sed 's/^v//')-linux-amd64\" -o /usr/local/bin/act_runner
|
||||
printf '%s /usr/local/bin/act_runner\n' \"$GCR_ACT_RUNNER_SHA256\" | sha256sum -c -
|
||||
chmod 0755 /usr/local/bin/act_runner
|
||||
mkdir -p /var/lib/gitea-runner
|
||||
runcmd:
|
||||
- [ sh, -c, 'systemctl enable --now sshd.service 2>/dev/null || systemctl enable --now ssh 2>/dev/null || true' ]
|
||||
- [ sh, -c, 'systemctl restart sshd.service 2>/dev/null || systemctl restart ssh 2>/dev/null || true' ]
|
||||
- [ systemctl, enable, --now, gitea-runner.service ]
|
||||
"
|
||||
}
|
||||
|
||||
# gcr_vm_create NAME LABEL SERVER_TYPE TTL_MIN REG_TOKEN JOB_ID ATTEMPT REPO
|
||||
# Prints new server id.
|
||||
gcr_vm_create() {
|
||||
vm_name="$1"; label="$2"; server_type="$3"; ttl_min="$4"
|
||||
reg_token="$5"; job_id="$6"; attempt="$7"; repo="$8"
|
||||
|
||||
test -n "${GCR_IMAGE_ID:-}" || {
|
||||
gcr_log error --ns=hcloud "GCR_IMAGE_ID not set; refusing VM creation"
|
||||
return 1
|
||||
}
|
||||
|
||||
userdata="$(gcr_vm_build_userdata "$vm_name" "$label" "$reg_token")"
|
||||
payload="$(jq -n \
|
||||
--arg name "$vm_name" \
|
||||
--arg stype "$server_type" \
|
||||
--arg image "$GCR_IMAGE_ID" \
|
||||
--arg loc "${GCR_HETZNER_LOCATION:-nbg1}" \
|
||||
--arg udata "$userdata" \
|
||||
--arg jid "$job_id" \
|
||||
--arg att "$attempt" \
|
||||
--arg repo "$repo" \
|
||||
--arg label "$label" \
|
||||
--arg ts "$(date -u '+%s')" \
|
||||
--arg ttl "$ttl_min" \
|
||||
--arg repo_safe "$(printf '%s' "$repo" | tr '/:' '--')" \
|
||||
'{name:$name, server_type:$stype, image:$image, location:$loc,
|
||||
start_after_create:true,
|
||||
labels:{
|
||||
"gitea-runner-controller":"managed",
|
||||
"gcr.job-id":$jid, "gcr.run-attempt":$att,
|
||||
"gcr.repo":$repo_safe, "gcr.label":$label,
|
||||
"gcr.created-at":$ts, "gcr.ttl-min":$ttl}}')"
|
||||
|
||||
# Hetzner placement is occasionally transient (resource_unavailable);
|
||||
# retry a few times before giving up. NOTE: userdata/cloud-init is NOT
|
||||
# used — bootstrap happens over SSH from the controller (see
|
||||
# gcr_vm_bootstrap_ssh); MicroOS snapshot's Hetzner datasource cannot
|
||||
# fetch user-data (DHCP Exception on this image lineage).
|
||||
attempt_n=0
|
||||
while :; do
|
||||
attempt_n=$((attempt_n + 1))
|
||||
if gcr_hcloud_req POST /servers "$payload"; then
|
||||
jq -r '.server.id' "$GCR_LAST_BODY"
|
||||
return 0
|
||||
fi
|
||||
gcr_log warn --ns=hcloud "create attempt=$attempt_n failed"
|
||||
[ "$attempt_n" -ge 3 ] && return 1
|
||||
sleep $((attempt_n * 10))
|
||||
done
|
||||
}
|
||||
|
||||
# gcr_vm_destroy SERVER_ID — idempotent best-effort destroy.
|
||||
gcr_vm_destroy() {
|
||||
if ! gcr_hcloud_req DELETE "/servers/$1"; then
|
||||
gcr_log warn --ns=hcloud "destroy failed or already gone: server $1"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Bootstrap delivery is SSH-push from the controller. The MicroOS snapshot's
|
||||
# cloud-init cannot fetch user-data (Hetzner datasource DHCP failure), so the
|
||||
# controller drives provisioning over SSH using GCR_SSH_PRIVKEY_FILE, whose
|
||||
# public half is authorized on every ephemeral VM (project ssh-key injection).
|
||||
gcr_bootstrap_script() {
|
||||
# gcr_bootstrap_script LABEL REG_TOKEN TTL_MIN RUNNER_NAME -> POSIX sh payload
|
||||
label="$1"; reg_token="$2"; ttl_min="$3"; runner_name="$4"
|
||||
nix_conf='accept-flake-config = true
|
||||
experimental-features = nix-command flakes
|
||||
substituters = https://cache.nixos.org https://cache.hectic-lab.com/hectic
|
||||
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gW4x6l1xP+GxgH0r7u+f6p1VFlr0= hectic:KMQsKow4SoA9K2vOJlOljmx7/Zpf91Yy+5qEtxDDCzA=
|
||||
sandbox = false'
|
||||
|
||||
runner_config="log:
|
||||
level: info
|
||||
runner:
|
||||
file: /var/lib/gitea-runner/.runner
|
||||
capacity: 1
|
||||
timeout: ${ttl_min}m
|
||||
insecure: false
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
labels:
|
||||
- \"$label:host\""
|
||||
|
||||
cat <<BSEOF
|
||||
exec >/var/log/gcr-bootstrap.log 2>&1
|
||||
set -eu
|
||||
mkdir -p /etc/nix /etc/gitea-runner /var/lib/gitea-runner /usr/local/bin
|
||||
cat > /etc/nix/nix.conf <<'NIXEOF'
|
||||
$nix_conf
|
||||
NIXEOF
|
||||
cat > /etc/gitea-runner/config.yaml <<'CFGEOF'
|
||||
$runner_config
|
||||
CFGEOF
|
||||
cat > /usr/local/sbin/gcr-runner-start <<STARTEOF
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ ! -f /var/lib/gitea-runner/.runner ]; then
|
||||
/usr/local/bin/act_runner register --no-interactive --instance $GCR_GITEA_URL --token $reg_token --name $runner_name --labels $label:host --config /etc/gitea-runner/config.yaml
|
||||
fi
|
||||
exec /usr/local/bin/act_runner daemon --config /etc/gitea-runner/config.yaml
|
||||
STARTEOF
|
||||
chmod 0700 /usr/local/sbin/gcr-runner-start
|
||||
cat > /etc/systemd/system/gitea-runner.service <<UNITEOF
|
||||
[Unit]
|
||||
Description=Gitea ephemeral Actions runner
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/var/lib/gitea-runner
|
||||
Environment=HOME=/var/lib/gitea-runner
|
||||
ExecStart=/usr/local/sbin/gcr-runner-start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNITEOF
|
||||
cat > /usr/local/sbin/gcr-install <<INSEOF
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
if [ "$label" = "nix" ]; then
|
||||
curl -fsSL "https://releases.nixos.org/nix/nix-$GCR_NIX_VERSION/nix-$GCR_NIX_VERSION-x86_64-linux.tar.xz" -o /tmp/nix.tar.xz
|
||||
printf '%s /tmp/nix.tar.xz\n' "$GCR_NIX_TARBALL_SHA256" | sha256sum -c -
|
||||
tar -xJf /tmp/nix.tar.xz -C /tmp
|
||||
/tmp/nix-$GCR_NIX_VERSION-x86_64-linux/install --no-daemon
|
||||
rm -rf /tmp/nix*
|
||||
fi
|
||||
curl -fsSL "https://gitea.com/gitea/act_runner/releases/download/$GCR_ACT_RUNNER_VERSION/act_runner-\$(printf '%s' "$GCR_ACT_RUNNER_VERSION" | sed 's/^v//')-linux-amd64" -o /usr/local/bin/act_runner
|
||||
printf '%s /usr/local/bin/act_runner\n' "$GCR_ACT_RUNNER_SHA256" | sha256sum -c -
|
||||
chmod 0755 /usr/local/bin/act_runner
|
||||
INSEOF
|
||||
chmod 0700 /usr/local/sbin/gcr-install
|
||||
/usr/local/sbin/gcr-install
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now gitea-runner.service
|
||||
BSEOF
|
||||
}
|
||||
|
||||
# gcr_vm_bootstrap_ssh IP LABEL REG_TOKEN — blocking; returns ssh exit status.
|
||||
gcr_vm_bootstrap_ssh() {
|
||||
ip="$1"; label="$2"; reg_token="$3"; runner_name="$4"
|
||||
test -n "${GCR_SSH_PRIVKEY_FILE:-}" && test -r "$GCR_SSH_PRIVKEY_FILE" || {
|
||||
gcr_log error --ns=hcloud "GCR_SSH_PRIVKEY_FILE missing or unreadable"
|
||||
return 1
|
||||
}
|
||||
key_tmp="$(mktemp "${TMPDIR:-/tmp}/gcr-sshkey.XXXXXX")"
|
||||
cat "$GCR_SSH_PRIVKEY_FILE" > "$key_tmp"
|
||||
printf '\n' >> "$key_tmp"
|
||||
chmod 0600 "$key_tmp"
|
||||
SSH_OPTS="-i $key_tmp -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 -o BatchMode=yes"
|
||||
|
||||
waited=0
|
||||
until ssh $SSH_OPTS "root@$ip" true 2>/dev/null; do
|
||||
waited=$((waited + 5))
|
||||
[ "$waited" -ge 300 ] && {
|
||||
gcr_log warn --ns=hcloud "sshd never came up on $ip"
|
||||
rm -f "$key_tmp"
|
||||
return 1
|
||||
}
|
||||
sleep 5
|
||||
done
|
||||
|
||||
ttl_min="$(printf '%s' "$(gcr_label_profile "$label")" | awk '{print $2}')"
|
||||
script="$(gcr_bootstrap_script "$label" "$reg_token" "$ttl_min" "$runner_name")"
|
||||
if printf '%s' "$script" | ssh $SSH_OPTS "root@$ip" sh -s; then
|
||||
rm -f "$key_tmp"
|
||||
return 0
|
||||
fi
|
||||
rm -f "$key_tmp"
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/bin/dash
|
||||
# Log helper for gitea-runner-controller.
|
||||
# Verbosity via GCR_LOG env: "<level>[;<ns>=<level>]..." e.g. "info;alloc=debug".
|
||||
|
||||
MAGENTA='\033[0;35m'
|
||||
BLUE='\033[0;34m'
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
YELLOW='\033[0;33m'
|
||||
RED='\033[0;31m'
|
||||
WHITE='\033[0;37m'
|
||||
NC='\033[0m'
|
||||
|
||||
gcr_log_enabled() {
|
||||
level="$1"
|
||||
ns="${2:-core}"
|
||||
conf="${GCR_LOG:-info}"
|
||||
ns_level=""
|
||||
default_level=""
|
||||
oldIFS="$IFS"
|
||||
IFS=';'
|
||||
for pair in $conf; do
|
||||
case "$pair" in
|
||||
*=*) ns_name="${pair%%=*}"; ns_level="${pair#*=}"
|
||||
[ "$ns_name" = "$ns" ] && { IFS="$oldIFS"; echo "$ns_level"; return 0; } ;;
|
||||
*) default_level="$pair" ;;
|
||||
esac
|
||||
done
|
||||
IFS="$oldIFS"
|
||||
echo "${default_level:-info}"
|
||||
}
|
||||
|
||||
gcr_log_level_rank() {
|
||||
case "$1" in
|
||||
trace) echo 0 ;;
|
||||
debug) echo 1 ;;
|
||||
info) echo 2 ;;
|
||||
notice) echo 3 ;;
|
||||
warn) echo 4 ;;
|
||||
error) echo 5 ;;
|
||||
panic) echo 6 ;;
|
||||
*) echo 7 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
gcr_log() {
|
||||
level="$1"; shift
|
||||
ns="core"
|
||||
case "$1" in
|
||||
--ns=*) ns="${1#--ns=}"; shift ;;
|
||||
esac
|
||||
|
||||
want="$(gcr_log_enabled "$level" "$ns")"
|
||||
[ "$(gcr_log_level_rank "$level")" -ge "$(gcr_log_level_rank "$want")" ] || return 0
|
||||
|
||||
ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
msg="$1"
|
||||
case "$level" in
|
||||
trace) color="$WHITE" ;;
|
||||
debug) color="$BLUE" ;;
|
||||
info) color="$GREEN" ;;
|
||||
notice) color="$CYAN" ;;
|
||||
warn) color="$YELLOW" ;;
|
||||
error) color="$RED" ;;
|
||||
panic) color="$MAGENTA" ;;
|
||||
*) color="$WHITE" ;;
|
||||
esac
|
||||
printf '%b\n' "${color}${ts} ${level}[${ns}]${NC} $msg" >&2
|
||||
}
|
||||
|
||||
# Security: keep credential values out of logs; keys containing TOKEN/SECRET
|
||||
# and Authorization token headers are rewritten to <redacted>.
|
||||
gcr_redact() {
|
||||
sed -E \
|
||||
-e 's/([A-Za-z0-9_]*(TOKEN|SECRET)[A-Za-z0-9_]*)(=|: ?"?)[^ "]+/\1\3<redacted>/g' \
|
||||
-e 's/(Authorization: *token )[^ ]+/\1<redacted>/g'
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/bin/dash
|
||||
# State primitives for gitea-runner-controller.
|
||||
# Layout:
|
||||
# $GCR_STATE_DIR/jobs/<job_id>.json allocation records
|
||||
# $GCR_STATE_DIR/jobs/.lock.<key>/ mkdir(2) atomicity guards
|
||||
# $GCR_STATE_DIR/events.jsonl append-only audit
|
||||
# $GCR_STATE_DIR/budget/<YYYY-MM> estimated EUR spent this month
|
||||
|
||||
gcr_state_init() {
|
||||
test -n "${GCR_STATE_DIR:-}" || { echo "GCR_STATE_DIR is not set" >&2; return 1; }
|
||||
mkdir -p "$GCR_STATE_DIR/jobs" "$GCR_STATE_DIR/budget"
|
||||
touch "$GCR_STATE_DIR/events.jsonl"
|
||||
}
|
||||
|
||||
gcr_alloc_key() {
|
||||
printf '%s-%s' "$1" "$2"
|
||||
}
|
||||
|
||||
gcr_record_path() {
|
||||
printf '%s/jobs/%s.json' "$GCR_STATE_DIR" "$(gcr_alloc_key "$1" "$2")"
|
||||
}
|
||||
|
||||
# mkdir(2) atomicity guard: succeeds exactly once per key until released.
|
||||
gcr_lock_acquire() {
|
||||
mkdir "$(printf '%s/jobs/.lock.%s' "$GCR_STATE_DIR" "$1")" 2>/dev/null
|
||||
}
|
||||
|
||||
gcr_lock_release() {
|
||||
rm -rf "$(printf '%s/jobs/.lock.%s' "$GCR_STATE_DIR" "$1")"
|
||||
}
|
||||
|
||||
gcr_record_get() {
|
||||
# Missing record is a normal answer, not an error; must not trip errexit.
|
||||
{ cat "$(gcr_record_path "$1" "$2")" 2>/dev/null || true; }
|
||||
}
|
||||
|
||||
# mktemp+mv keeps concurrent readers away from partially written records.
|
||||
gcr_record_put() {
|
||||
tmp="$(mktemp "$(dirname "$(gcr_record_path "$1" "$2")")/.tmp.XXXXXX")"
|
||||
printf '%s\n' "$3" > "$tmp"
|
||||
mv -f "$tmp" "$(gcr_record_path "$1" "$2")"
|
||||
}
|
||||
|
||||
gcr_record_del() {
|
||||
rm -f "$(gcr_record_path "$1" "$2")"
|
||||
}
|
||||
|
||||
gcr_record_field() {
|
||||
printf '%s' "$1" | jq -r --arg f "$2" '.[$f] // ""'
|
||||
}
|
||||
|
||||
gcr_event() {
|
||||
printf '{"ts":"%s","event":"%s","job_id":"%s","detail":%s}\n' \
|
||||
"$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1" "$2" \
|
||||
"$(printf '%s' "$3" | jq -Rs .)" >> "$GCR_STATE_DIR/events.jsonl"
|
||||
}
|
||||
|
||||
# Exit-code contract: 0 = recorded under budget, 1 = would exceed cap.
|
||||
gcr_budget_add() {
|
||||
rate="$1"; ttl_min="$2"
|
||||
month="$(date -u '+%Y-%m')"
|
||||
file="$GCR_STATE_DIR/budget/$month"
|
||||
current="$(cat "$file" 2>/dev/null || echo 0)"
|
||||
projected="$(awk -v c="$current" -v r="$rate" -v t="$ttl_min" 'BEGIN {printf "%.4f", c + r * t / 60}')"
|
||||
if awk -v p="$projected" -v b="${GCR_BUDGET_EUR_MONTHLY:-15}" 'BEGIN {exit !(p > b)}'; then
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$projected" > "$file"
|
||||
return 0
|
||||
}
|
||||
|
||||
gcr_active_records() {
|
||||
grep -l '"status":"\(pending_vm\|vm_active\|deferred\)"' "$GCR_STATE_DIR"/jobs/*.json 2>/dev/null || true
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/bin/dash
|
||||
# One-shot HTTP handler for Gitea workflow_job webhooks.
|
||||
# Invoked per connection by socat; request on stdin, response on stdout.
|
||||
|
||||
RESPONSE_CODE=204
|
||||
RESPONSE_BODY=""
|
||||
|
||||
gcr_respond() {
|
||||
code="$1"; body="${2:-}"
|
||||
reason=""
|
||||
case "$code" in
|
||||
200) reason="OK" ;;
|
||||
202) reason="Accepted" ;;
|
||||
204) reason="No Content" ;;
|
||||
400) reason="Bad Request" ;;
|
||||
403) reason="Forbidden" ;;
|
||||
413) reason="Payload Too Large" ;;
|
||||
*) reason="No Content" ;;
|
||||
esac
|
||||
printf 'HTTP/1.1 %s %s\r\n' "$code" "$reason"
|
||||
printf 'Content-Type: text/plain\r\n'
|
||||
printf 'Content-Length: %s\r\n' "$(printf '%s' "$body" | wc -c)"
|
||||
printf 'Connection: close\r\n\r\n'
|
||||
[ -n "$body" ] && printf '%s\n' "$body"
|
||||
}
|
||||
|
||||
gcr_read_request() {
|
||||
request_line=""
|
||||
gcr_hdr_event_type=""
|
||||
gcr_hdr_delivery=""
|
||||
gcr_hdr_signature=""
|
||||
content_length=0
|
||||
|
||||
# Correctness depends on dash reading stdin byte-by-byte (no lookahead
|
||||
# buffer); body bytes must remain unconsumed for `head -c` below.
|
||||
IFS= read -r request_line || return 1
|
||||
|
||||
while :; do
|
||||
IFS= read -r line || break
|
||||
line="$(printf '%s' "$line" | tr -d '\r')"
|
||||
[ -z "$line" ] && break
|
||||
name="$(printf '%s' "$line" | cut -d: -f1 | tr '[:upper:]' '[:lower:]')"
|
||||
value="$(printf '%s' "${line#*:}" | sed 's/^ *//')"
|
||||
case "$name" in
|
||||
x-gitea-event-type) gcr_hdr_event_type="$value" ;;
|
||||
x-gitea-delivery) gcr_hdr_delivery="$value" ;;
|
||||
x-gitea-signature) gcr_hdr_signature="$value" ;;
|
||||
content-length) content_length="$value" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$request_line" in
|
||||
"POST "*" HTTP/"*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
|
||||
case "$content_length" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
[ "$content_length" -le 65536 ] || { gcr_respond 413 "payload too large"; exit 0; }
|
||||
|
||||
gcr_body="$(head -c "$content_length")"
|
||||
}
|
||||
|
||||
gcr_verify_signature() {
|
||||
test -r "${GITEA_WEBHOOK_SECRET_FILE:-}" || {
|
||||
gcr_log error --ns=webhook "GITEA_WEBHOOK_SECRET_FILE missing"
|
||||
return 1
|
||||
}
|
||||
secret="$(cat "$GITEA_WEBHOOK_SECRET_FILE")"
|
||||
expected="$(printf '%s' "$gcr_body" \
|
||||
| openssl dgst -sha256 -hmac "$secret" -hex \
|
||||
| awk '{print $NF}')"
|
||||
# NOTE(yukkop): shell string compare is not constant-time; acceptable here
|
||||
# because the secret is high-entropy and bodies are signed, not encrypted.
|
||||
[ "$expected" = "$gcr_hdr_signature" ]
|
||||
}
|
||||
|
||||
gcr_alloc() {
|
||||
job_id="$1"; attempt="$2"; repo="$3"; labels_json="$4"
|
||||
|
||||
label="$(printf '%s' "$labels_json" | jq -r '.[0] // ""')"
|
||||
label_count="$(printf '%s' "$labels_json" | jq 'length')"
|
||||
|
||||
existing="$(gcr_record_get "$job_id" "$attempt")"
|
||||
if [ -n "$existing" ]; then
|
||||
gcr_log debug --ns=alloc "duplicate delivery for $(gcr_alloc_key "$job_id" "$attempt")"
|
||||
RESPONSE_CODE=204
|
||||
return 0
|
||||
fi
|
||||
|
||||
key="$(gcr_alloc_key "$job_id" "$attempt")"
|
||||
if ! gcr_lock_acquire "$key"; then
|
||||
RESPONSE_CODE=204
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$label_count" -ne 1 ]; then
|
||||
gcr_lock_release "$key"
|
||||
gcr_event "refused" "$job_id" "{\"repo\":\"$repo\",\"label_count\":$label_count}"
|
||||
RESPONSE_CODE=202; RESPONSE_BODY="refused: exactly one label required"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! profile="$(gcr_decide "$label" "$repo")"; then
|
||||
gcr_lock_release "$key"
|
||||
gcr_event "refused" "$job_id" "{\"repo\":\"$repo\",\"label\":\"$label\"}"
|
||||
RESPONSE_CODE=202; RESPONSE_BODY="refused: repo or label not allowed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
set -- $profile
|
||||
server_type="$1"; ttl_min="$2"; rate="$3"
|
||||
|
||||
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
|
||||
rec="$(jq -n --arg j "$job_id" --arg a "$attempt" --arg r "$repo" \
|
||||
--arg l "$label" --arg t "$(date -u '+%s')" \
|
||||
'{job_id:$j, run_attempt:$a, repo:$r, label:$l,
|
||||
created_at:$t, ttl_min:null, vm_id:"", vm_name:"",
|
||||
status:"deferred"}')"
|
||||
gcr_record_put "$job_id" "$attempt" "$rec"
|
||||
gcr_lock_release "$key"
|
||||
gcr_event "deferred" "$job_id" "{\"active\":$active,\"repo_active\":$repo_active}"
|
||||
RESPONSE_CODE=202; RESPONSE_BODY="deferred: capacity"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! gcr_budget_add "$rate" "$ttl_min"; then
|
||||
gcr_record_del "$job_id" "$attempt"
|
||||
gcr_lock_release "$key"
|
||||
gcr_event "budget-refused" "$job_id" "{\"rate\":$rate,\"ttl_min\":$ttl_min}"
|
||||
RESPONSE_CODE=202; RESPONSE_BODY="refused: monthly budget exhausted"
|
||||
return 0
|
||||
fi
|
||||
|
||||
reg_token="$(gcr_gitea_registration_token)" || {
|
||||
gcr_record_del "$job_id" "$attempt"
|
||||
gcr_lock_release "$key"
|
||||
gcr_event "token-error" "$job_id" "{}"
|
||||
RESPONSE_CODE=202; RESPONSE_BODY="registration token unavailable"
|
||||
return 0
|
||||
}
|
||||
|
||||
vm_name="gcr-${job_id}-${attempt}"
|
||||
vm_id="$(gcr_vm_create "$vm_name" "$label" "$server_type" "$ttl_min" \
|
||||
"$reg_token" "$job_id" "$attempt" "$repo")" || {
|
||||
gcr_record_del "$job_id" "$attempt"
|
||||
gcr_lock_release "$key"
|
||||
gcr_event "vm-create-failed" "$job_id" "{}"
|
||||
RESPONSE_CODE=202; RESPONSE_BODY="VM creation failed"
|
||||
return 0
|
||||
}
|
||||
|
||||
rec="$(jq -n --arg j "$job_id" --arg a "$attempt" --arg r "$repo" \
|
||||
--arg l "$label" --arg t "$(date -u '+%s')" --arg v "$vm_id" \
|
||||
--arg vn "$vm_name" --arg ttl "$ttl_min" \
|
||||
'{job_id:$j, run_attempt:$a, repo:$r, label:$l,
|
||||
created_at:$t, ttl_min:($ttl|tonumber), vm_id:($v|tonumber),
|
||||
vm_name:$vn, bootstrapped:false, status:"pending_vm"}')"
|
||||
gcr_record_put "$job_id" "$attempt" "$rec"
|
||||
gcr_lock_release "$key"
|
||||
gcr_event "vm-created" "$job_id" "{\"vm_id\":$vm_id,\"label\":\"$label\",\"ttl_min\":$ttl_min}"
|
||||
|
||||
RESPONSE_CODE=202; RESPONSE_BODY="allocated $vm_name"
|
||||
}
|
||||
|
||||
gcr_deallocate() {
|
||||
job_id="$1"; attempt="$2"; new_status="$3"
|
||||
|
||||
rec="$(gcr_record_get "$job_id" "$attempt")"
|
||||
[ -n "$rec" ] || return 0
|
||||
|
||||
vm_id="$(gcr_record_field "$rec" vm_id)"
|
||||
if [ -n "$vm_id" ] && [ "$vm_id" != "null" ] && [ "$vm_id" != "0" ]; then
|
||||
gcr_vm_destroy "$vm_id" || true
|
||||
gcr_event "vm-destroyed" "$job_id" "{\"vm_id\":$vm_id,\"reason\":\"$new_status\"}"
|
||||
fi
|
||||
|
||||
gcr_record_del "$job_id" "$attempt"
|
||||
gcr_lock_release "$(gcr_alloc_key "$job_id" "$attempt")"
|
||||
}
|
||||
|
||||
gcr_handle_webhook() {
|
||||
gcr_read_request || { gcr_respond 400 ""; exit 0; }
|
||||
|
||||
[ "$gcr_hdr_event_type" = "workflow_job" ] || {
|
||||
gcr_log debug --ns=webhook "ignored event type: $gcr_hdr_event_type"
|
||||
gcr_respond 204 ""; exit 0
|
||||
}
|
||||
|
||||
gcr_verify_signature || {
|
||||
gcr_log warn --ns=webhook "invalid signature, delivery=$gcr_hdr_delivery"
|
||||
gcr_respond 403 "invalid signature"; exit 0
|
||||
}
|
||||
|
||||
action="$(printf '%s' "$gcr_body" | jq -r '.action // ""')"
|
||||
job_id="$(printf '%s' "$gcr_body" | jq -r '.workflow_job.id // ""')"
|
||||
attempt="$(printf '%s' "$gcr_body" | jq -r '.workflow_job.run_attempt // ""')"
|
||||
repo="$(printf '%s' "$gcr_body" | jq -r '.repository.full_name // ""')"
|
||||
labels_json="$(printf '%s' "$gcr_body" | jq -c '.workflow_job.labels // []')"
|
||||
|
||||
case "$action:$job_id" in
|
||||
:*|"queued:"|*":0") gcr_respond 400 "malformed payload"; exit 0 ;;
|
||||
esac
|
||||
|
||||
case "$action" in
|
||||
queued)
|
||||
gcr_alloc "$job_id" "$attempt" "$repo" "$labels_json"
|
||||
gcr_log info --ns=alloc "queued job=$job_id repo=$repo code=$RESPONSE_CODE $RESPONSE_BODY"
|
||||
;;
|
||||
in_progress)
|
||||
rec="$(gcr_record_get "$job_id" "$attempt")"
|
||||
if [ -n "$rec" ]; then
|
||||
rec="$(printf '%s' "$rec" | jq -c '.status = "vm_active"')"
|
||||
gcr_record_put "$job_id" "$attempt" "$rec"
|
||||
fi
|
||||
RESPONSE_CODE=204
|
||||
;;
|
||||
completed)
|
||||
gcr_deallocate "$job_id" "$attempt" "completed"
|
||||
RESPONSE_CODE=204
|
||||
;;
|
||||
*)
|
||||
gcr_log debug --ns=webhook "unhandled action: $action"
|
||||
RESPONSE_CODE=204
|
||||
;;
|
||||
esac
|
||||
|
||||
gcr_respond "$RESPONSE_CODE" "$RESPONSE_BODY"
|
||||
}
|
||||
Reference in New Issue
Block a user