#!/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_image_id_for_arch() { arch="$1"; label="${2:-}" nix_image=0 case "$label" in nix) nix_image=1 ;; esac case "$arch:$nix_image" in amd64:0) [ -n "${GCR_IMAGE_ID:-}" ] && printf '%s' "$GCR_IMAGE_ID" ;; arm64:0) [ -n "${GCR_ARM_IMAGE_ID:-}" ] && printf '%s' "$GCR_ARM_IMAGE_ID" ;; amd64:1) [ -n "${GCR_NIX_IMAGE_ID:-}" ] && printf '%s' "$GCR_NIX_IMAGE_ID" ;; arm64:1) [ -n "${GCR_ARM_NIX_IMAGE_ID:-}" ] && printf '%s' "$GCR_ARM_NIX_IMAGE_ID" ;; *) return 1 ;; esac } 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 gitea-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://dl.gitea.com/gitea-runner/$GCR_ACT_RUNNER_VERSION/gitea-runner-$GCR_ACT_RUNNER_VERSION-linux-amd64\" -o /usr/local/bin/gitea-runner printf '%s /usr/local/bin/gitea-runner\n' \"$GCR_ACT_RUNNER_SHA256\" | sha256sum -c - chmod 0755 /usr/local/bin/gitea-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" ttl_min="$(gcr_label_ttl "$label")" || return 1 candidates="$(gcr_label_candidates "$label")" || return 1 candidate_n=0 while read -r candidate_type candidate_loc candidate_arch; do [ -n "${candidate_type:-}" ] || continue candidate_n=$((candidate_n + 1)) image_id="$(gcr_image_id_for_arch "$candidate_arch" "$label")" || { gcr_log warn --ns=hcloud "skip candidate[$candidate_n] label=$label arch=$candidate_arch no image" continue } payload="$(jq -n \ --arg name "$vm_name" \ --arg stype "$candidate_type" \ --arg image "$image_id" \ --arg loc "$candidate_loc" \ --arg jid "$job_id" \ --arg att "$attempt" \ --arg repo "$repo" \ --arg label "$label" \ --arg arch "$candidate_arch" \ --arg ts "$(date -u '+%s')" \ --arg ttl "$ttl_min" \ --arg ssh_key_id "${GCR_HCLOUD_SSH_KEY_ID:-}" \ --arg repo_safe "$(printf '%s' "$repo" | tr '/:' '--')" \ '{name:$name, server_type:$stype, image:$image, location:$loc, start_after_create:true, ssh_keys:(if $ssh_key_id == "" then [] else [$ssh_key_id | tonumber] end), labels:{ "gitea-runner-controller":"managed", "gcr.job-id":$jid, "gcr.run-attempt":$att, "gcr.repo":$repo_safe, "gcr.label":$label, "gcr.arch":$arch, "gcr.location":$loc, "gcr.created-at":$ts, "gcr.ttl-min":$ttl}}')" gcr_log info --ns=hcloud "try candidate[$candidate_n] label=$label type=$candidate_type arch=$candidate_arch loc=$candidate_loc" if gcr_hcloud_req POST /servers "$payload"; then jq -r '.server.id' "$GCR_LAST_BODY" return 0 fi if [ "$candidate_n" -le 3 ]; then sleep 5 else sleep 1 fi done < 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 </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 < /etc/systemd/system/gitea-runner.service < /usr/local/sbin/gcr-install <&2 exit 1 ;; esac curl -fsSL "https://releases.nixos.org/nix/nix-$GCR_NIX_VERSION/nix-$GCR_NIX_VERSION-\$nix_arch.tar.xz" -o /tmp/nix.tar.xz printf '%s /tmp/nix.tar.xz\n' "\$nix_sha" | sha256sum -c - tar -xJf /tmp/nix.tar.xz -C /tmp /tmp/nix-$GCR_NIX_VERSION-\$nix_arch/install --no-daemon rm -rf /tmp/nix* ;; esac case "$label" in nix|gross-nix-*) if command -v apt-get >/dev/null 2>&1; then apt-get update -qq DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nodejs fi ;; esac case "\$(uname -m)" in x86_64) runner_arch=amd64 ;; aarch64|arm64) runner_arch=arm64 ;; *) echo "unsupported arch for runner bootstrap: \$(uname -m)" >&2; exit 1 ;; esac curl -fsSL "https://dl.gitea.com/gitea-runner/$GCR_ACT_RUNNER_VERSION/gitea-runner-$GCR_ACT_RUNNER_VERSION-linux-\$runner_arch" -o /usr/local/bin/gitea-runner if [ "\$runner_arch" = amd64 ]; then printf '%s /usr/local/bin/gitea-runner\n' "$GCR_ACT_RUNNER_SHA256" | sha256sum -c - fi chmod 0755 /usr/local/bin/gitea-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 900 ] && { 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 }