fuzzyworld-tools / files / server / runner-api
runner-api
1758548c 2026-10-27 Pavel Safronov | 1 | #!/bin/bash |
| 2 | # Forced command for CI runner keys in ~/.ssh/authorized_keys on DreamHost: | |
| 3 | # | |
| 4 | # restrict,command="/home/fuzzygamer/fw/tools/server/runner-api" ssh-ed25519 AAAA... truenas-runner | |
| 5 | # | |
| 6 | # A runner (runner/fw-runner.py) can then only: | |
a4997156 2026-10-31 Pavel Safronov | 7 | # claim <runner> [tags] take the oldest queued job this runner hasn't declined; prints its |
| 8 | # JSON, or nothing. `tags` (comma-separated) is what the runner can do. | |
| 9 | # beat <job> <runner> "still working on it": a job whose runner is quiet for ~2 minutes | |
| 10 | # (lid closed, network gone) goes back in the queue | |
| 11 | # release <job> <runner> "I can't run this one" (missing tag or secrets): back in the queue, | |
| 12 | # and this runner is not offered it again | |
1758548c 2026-10-27 Pavel Safronov | 13 | # git-upload-pack '<repo>' clone/fetch ~/git/<name>.git (the job's source) |
| 14 | # put <repo> <sha> <file> store one result file (stdin) in ~/fw/ci/<repo>/<sha>/ | |
462e50c2 2026-10-31 Pavel Safronov | 15 | # done <repo> <sha> <job-id> [runner] |
| 16 | # the job is finished: remove it from the queue, refresh pages. With | |
| 17 | # `runner`, refused if the job was meanwhile given to another runner | |
1758548c 2026-10-27 Pavel Safronov | 18 | # put-shot <app> <file> store one screenshot (stdin) in apps.fuzzyworld.net/_shots/<app>/ |
| 19 | # shots-done <app> <repo> refresh the apps index and the repo's page | |
| 20 | # Anything else is refused. Every call is logged to ~/fw/logs/runner-api.log, except a claim that | |
0330e724 2026-11-01 Pavel Safronov | 21 | # finds nothing (runners ask every few minutes) and heartbeats. A claim also records the runner in ~/fw/runners/<runner> |
a4997156 2026-10-31 Pavel Safronov | 22 | # (its file's time is when it last checked in; the CI dashboard says "waiting for a runner" when none has). |
1758548c 2026-10-27 Pavel Safronov | 23 | set -u |
| 24 | cmd=${SSH_ORIGINAL_COMMAND:-} | |
| 25 | QUEUE=$HOME/fw/queue | |
a4997156 2026-10-31 Pavel Safronov | 26 | RUNNERS=$HOME/fw/runners |
| 27 | LEASE_MIN=2 # a job whose runner has not sent a heartbeat for this long goes back to pending/ | |
1758548c 2026-10-27 Pavel Safronov | 28 | CI=$HOME/fw/ci |
| 29 | SHOTS=$HOME/apps.fuzzyworld.net/_shots | |
| 30 | MAX_FILE=$((20 * 1024 * 1024)) | |
| 31 | log="$HOME/fw/logs/runner-api.log" | |
a4997156 2026-10-31 Pavel Safronov | 32 | mkdir -p "$(dirname "$log")" "$QUEUE/pending" "$QUEUE/running" "$QUEUE/declined" "$RUNNERS" |
1758548c 2026-10-27 Pavel Safronov | 33 | note() { echo "$(date -u '+%F %T') ${SSH_CLIENT%% *} $*" >> "$log"; } |
0330e724 2026-11-01 Pavel Safronov | 34 | [[ "$cmd" == claim* || "$cmd" == beat* ]] || note "$cmd" # not the polling and heartbeats (they would fill the log) |
1758548c 2026-10-27 Pavel Safronov | 35 | |
| 36 | die() { echo "runner-api: $*" >&2; exit 1; } | |
a5a72f55 2026-10-27 Pavel Safronov | 37 | # Page updates run in the background, so the runner never waits for them. |
| 38 | bg() { setsid nohup "$@" >> "$HOME/fw/logs/runner-api-pages.log" 2>&1 < /dev/null & } | |
1758548c 2026-10-27 Pavel Safronov | 39 | valid_name() { [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] && [[ "$1" != *.git ]]; } |
| 40 | valid_repo() { valid_name "$1" && [ -d "$HOME/git/$1.git" ]; } | |
| 41 | valid_sha() { [[ "$1" =~ ^[0-9a-f]{40}$ ]]; } | |
a4997156 2026-10-31 Pavel Safronov | 42 | valid_tags() { [[ "$1" =~ ^[A-Za-z0-9,_-]*$ ]]; } |
1758548c 2026-10-27 Pavel Safronov | 43 | valid_job() { [[ "$1" =~ ^[0-9]+-[A-Za-z0-9._-]+-[0-9a-f]{12}$ ]]; } |
| 44 | ||
| 45 | # stdin to a file, refusing anything over MAX_FILE; written beside the target, then renamed. | |
| 46 | store() { | |
| 47 | local dest=$1 tmp="$1.part" | |
| 48 | head -c $((MAX_FILE + 1)) > "$tmp" | |
| 49 | if [ "$(stat -c %s "$tmp")" -gt "$MAX_FILE" ]; then rm -f "$tmp"; die "file too large"; fi | |
| 50 | mv "$tmp" "$dest" | |
| 51 | } | |
| 52 | ||
| 53 | read -r -a w <<< "$cmd" | |
| 54 | case "${w[0]:-}" in | |
| 55 | claim) | |
a4997156 2026-10-31 Pavel Safronov | 56 | runner=${w[1]:-} tags=${w[2]:-} |
| 57 | valid_name "$runner" && valid_tags "$tags" || die "usage: claim <runner> [tag,tag,...]" | |
| 58 | echo "$tags" > "$RUNNERS/$runner" | |
| 59 | # Jobs whose runner went quiet go back in the queue. A runner that sends heartbeats (it claimed with | |
| 60 | # tags) has a <job>.beat file, and is given LEASE_MIN minutes; an older runner is given 2 hours. | |
| 61 | for f in "$QUEUE"/running/*.json; do | |
| 62 | [ -e "$f" ] || continue | |
| 63 | id=$(basename "$f" .json) | |
| 64 | if [ -e "$QUEUE/running/$id.beat" ]; then | |
| 65 | [ -n "$(find "$QUEUE/running/$id.beat" -mmin +$LEASE_MIN)" ] || continue | |
| 66 | else | |
| 67 | [ -n "$(find "$f" -mmin +120)" ] || continue | |
| 68 | fi | |
| 69 | mv "$f" "$QUEUE/pending/" 2>/dev/null && { rm -f "$QUEUE/running/$id.beat"; note "requeued $id (its runner went quiet)"; } | |
| 70 | done | |
1758548c 2026-10-27 Pavel Safronov | 71 | for f in $(ls "$QUEUE/pending" 2>/dev/null | sort); do |
a4997156 2026-10-31 Pavel Safronov | 72 | id=${f%.json} |
| 73 | [ -e "$QUEUE/declined/$id.$runner" ] && continue | |
1758548c 2026-10-27 Pavel Safronov | 74 | # rename(2) is atomic: if two runners race for the same job, only one mv succeeds. |
| 75 | if mv "$QUEUE/pending/$f" "$QUEUE/running/$f" 2>/dev/null; then | |
| 76 | touch "$QUEUE/running/$f" | |
a4997156 2026-10-31 Pavel Safronov | 77 | [ -n "$tags" ] && echo "$runner" > "$QUEUE/running/$id.beat" |
1758548c 2026-10-27 Pavel Safronov | 78 | note "$cmd: $f" |
a5a72f55 2026-10-27 Pavel Safronov | 79 | bg "$HOME/bin/fw" ci-dashboard |
1758548c 2026-10-27 Pavel Safronov | 80 | cat "$QUEUE/running/$f" |
| 81 | exit 0 | |
| 82 | fi | |
| 83 | done ;; | |
a4997156 2026-10-31 Pavel Safronov | 84 | beat) |
| 85 | job=${w[1]:-} runner=${w[2]:-} | |
| 86 | valid_job "$job" && valid_name "$runner" || die "usage: beat <job-id> <runner>" | |
| 87 | [ -e "$QUEUE/running/$job.json" ] && [ "$(cat "$QUEUE/running/$job.beat" 2>/dev/null)" = "$runner" ] \ | |
0330e724 2026-11-01 Pavel Safronov | 88 | || { note "$cmd refused (not running on $runner)"; die "job $job is not running on $runner (it was put back in the queue)"; } |
a4997156 2026-10-31 Pavel Safronov | 89 | touch "$QUEUE/running/$job.beat" ;; |
| 90 | release) | |
| 91 | job=${w[1]:-} runner=${w[2]:-} | |
| 92 | valid_job "$job" && valid_name "$runner" || die "usage: release <job-id> <runner>" | |
| 93 | [ -e "$QUEUE/running/$job.json" ] && [ "$(cat "$QUEUE/running/$job.beat" 2>/dev/null)" = "$runner" ] \ | |
| 94 | || die "job $job is not running on $runner" | |
| 95 | : > "$QUEUE/declined/$job.$runner" | |
| 96 | mv "$QUEUE/running/$job.json" "$QUEUE/pending/$job.json" && rm -f "$QUEUE/running/$job.beat" | |
| 97 | bg "$HOME/bin/fw" ci-dashboard ;; | |
1758548c 2026-10-27 Pavel Safronov | 98 | git-upload-pack) |
| 99 | repo=${cmd#git-upload-pack \'}; repo=${repo%\'}; repo=${repo#/home/fuzzygamer/}; repo=${repo#\~/} | |
| 100 | [[ "$repo" =~ ^git/([A-Za-z0-9][A-Za-z0-9._-]*)\.git$ ]] && valid_repo "${BASH_REMATCH[1]}" || die "no such repo" | |
| 101 | exec git-upload-pack "$HOME/$repo" ;; | |
| 102 | put) | |
| 103 | repo=${w[1]:-} sha=${w[2]:-} file=${w[3]:-} | |
| 104 | valid_repo "$repo" && valid_sha "$sha" && [[ "$file" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.(log|json)$ ]] || die "usage: put <repo> <sha> <file.log|file.json>" | |
| 105 | mkdir -p "$CI/$repo/$sha" | |
| 106 | store "$CI/$repo/$sha/$file" | |
a5a72f55 2026-10-27 Pavel Safronov | 107 | # A finished pipeline refreshes all the repo's pages (✓/✗ on its log and summary); anything else while |
| 108 | # it runs (a job starting, a growing log) only its CI pages and the dashboard. Both are locked per repo. | |
| 109 | if [ "$file" = pipeline.json ] && ! grep -q '"status": "running"' "$CI/$repo/$sha/pipeline.json"; then | |
| 110 | bg "$HOME/bin/fw" publish "$repo" | |
| 111 | else | |
| 112 | bg "$HOME/bin/fw" ci-pages "$repo" | |
1758548c 2026-10-27 Pavel Safronov | 113 | fi ;; |
| 114 | done) | |
462e50c2 2026-10-31 Pavel Safronov | 115 | repo=${w[1]:-} sha=${w[2]:-} job=${w[3]:-} runner=${w[4]:-} |
| 116 | valid_repo "$repo" && valid_sha "$sha" && valid_job "$job" && { [ -z "$runner" ] || valid_name "$runner"; } \ | |
| 117 | || die "usage: done <repo> <sha> <job-id> [runner]" | |
| 118 | # A runner that lost its lease (laptop slept, job re-claimed by another runner) must not remove the new holder's claim. | |
| 119 | holder=$(cat "$QUEUE/running/$job.beat" 2>/dev/null) | |
| 120 | # (Every current runner has a .beat file, even without tags; only an old runner, which sends no runner name, has none.) | |
| 121 | [ -z "$runner" ] || [ "$holder" = "$runner" ] || die "job $job is not held by $runner (held by: ${holder:-nobody})" | |
a4997156 2026-10-31 Pavel Safronov | 122 | rm -f "$QUEUE/running/$job.json" "$QUEUE/running/$job.beat" "$QUEUE/declined/$job".* |
a5a72f55 2026-10-27 Pavel Safronov | 123 | bg "$HOME/bin/fw" ci-dashboard ;; |
1758548c 2026-10-27 Pavel Safronov | 124 | put-shot) |
| 125 | app=${w[1]:-} file=${w[2]:-} | |
| 126 | valid_name "$app" && [[ "$file" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.(png|json)$ ]] || die "usage: put-shot <app> <file.png|shots.json>" | |
| 127 | mkdir -p "$SHOTS/$app" | |
| 128 | store "$SHOTS/$app/$file" ;; | |
| 129 | shots-done) | |
| 130 | app=${w[1]:-} repo=${w[2]:-} | |
| 131 | valid_name "$app" && valid_repo "$repo" || die "usage: shots-done <app> <repo>" | |
| 132 | # Screenshots from earlier runs that shots.json no longer lists are removed. | |
| 133 | python3 - "$SHOTS/$app" <<'PY' | |
| 134 | import json, os, sys | |
| 135 | d = sys.argv[1] | |
| 136 | keep = {s["file"] for s in json.load(open(os.path.join(d, "shots.json"))).get("shots", [])} | |
| 137 | for f in os.listdir(d): | |
| 138 | if f.endswith(".png") and f not in keep: | |
| 139 | os.remove(os.path.join(d, f)) | |
| 140 | PY | |
| 141 | setsid nohup "$HOME/bin/fw" app-deployed "$app" "$repo" >> "$HOME/fw/logs/$repo.log" 2>&1 < /dev/null & ;; | |
| 142 | *) | |
| 143 | die "this key may only run CI jobs (refused: $cmd)" ;; | |
| 144 | esac |