Keep builders coming when DigitalOcean sells out a size or region (#861)
* Keep builders coming when DigitalOcean sells out a droplet size ric1 sold out of g5-32vcpu-64gb-50gb, and every create came back 422. curl -f dropped the reason and set -e ended the tick, so builders only appeared when capacity happened to free up, and the journal showed nothing but "curl: (22)". Try each of SIZES in turn, logging DigitalOcean's refusal message, and fail the tick only when every size is refused. Builders now power off however start.sh exits, so a failed registration is reaped instead of counting as a booting runner until MAX_AGE_MINUTES. The controller unit pulls the checkout before each tick, so merged controller fixes reach the box. * Create builders in any region that has the size in stock ric1 sold out of g5-32vcpu-128gb-50gb within minutes of the box switching to it. Builders need nothing from a particular region, so read DigitalOcean's size catalog once per tick and try each of SIZES in every region it lists in stock, REGIONS first if set. A refused pair is dropped for the rest of the tick.
This commit is contained in:
1 parent
024943141d
commit
128c5647ba
6 files changed
+111
-17
No files matched your search
+12
-2
@@ -12,9 +12,12 @@ signing on merge exactly as before.
|
|||||||
- `runner-cloud-init.yaml` — Ubuntu 24.04 user-data: docker + buildx, the
|
- `runner-cloud-init.yaml` — Ubuntu 24.04 user-data: docker + buildx, the
|
||||||
GitHub runner registered `--ephemeral`, runs one job, powers off.
|
GitHub runner registered `--ephemeral`, runs one job, powers off.
|
||||||
- `controller.sh` — systemd timer every minute on a small always-on droplet.
|
- `controller.sh` — systemd timer every minute on a small always-on droplet.
|
||||||
Polls for queued jobs with our label, creates one g5-32vcpu-64gb-50gb droplet (ric1) per job up
|
Polls for queued jobs with our label, creates one g5-32vcpu-64gb-50gb droplet per job up
|
||||||
to `MAX_DROPLETS`, deletes droplets that are powered off or older than
|
to `MAX_DROPLETS`, deletes droplets that are powered off or older than
|
||||||
`MAX_AGE_MINUTES`. No inbound endpoint. Plain curl against both APIs, no
|
`MAX_AGE_MINUTES`. Builders go in any region DigitalOcean lists the size in
|
||||||
|
stock in (`REGIONS` only sets which to try first); a refused create, logged
|
||||||
|
with DigitalOcean's message, falls back to the next region, then the next
|
||||||
|
of `SIZES`. No inbound endpoint. Plain curl against both APIs, no
|
||||||
doctl and no gh: a token in the environment cannot pick the wrong account
|
doctl and no gh: a token in the environment cannot pick the wrong account
|
||||||
the way a saved doctl context can. Needs curl and jq.
|
the way a saved doctl context can. Needs curl and jq.
|
||||||
`tests/controller.sh` exercises every decision against canned responses.
|
`tests/controller.sh` exercises every decision against canned responses.
|
||||||
@@ -31,6 +34,13 @@ Administration read+write (registration tokens). The DO token is baked into
|
|||||||
the box's env file, so it is the account that pays for builder droplets.
|
the box's env file, so it is the account that pays for builder droplets.
|
||||||
Watch it with `journalctl -u omarchy-controller -f` on the box.
|
Watch it with `journalctl -u omarchy-controller -f` on the box.
|
||||||
|
|
||||||
|
Each tick pulls the box's checkout first, so a merged `controller.sh` is live
|
||||||
|
within a minute. The unit and timer are copies made at creation; after
|
||||||
|
changing them, on the box:
|
||||||
|
|
||||||
|
cp /opt/omarchy-pkgs/ci/controller-box/omarchy-controller.{service,timer} /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
## What the spike proved (2026-09-17, fork ryanrhughes/omarchy-pkgs)
|
## What the spike proved (2026-09-17, fork ryanrhughes/omarchy-pkgs)
|
||||||
|
|
||||||
- `bin/build` works from a bare clone: with no local published tree it
|
- `bin/build` works from a bare clone: with no local published tree it
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ GITHUB_TOKEN=github_pat_...
|
|||||||
REPO=omacom/omarchy-pkgs
|
REPO=omacom/omarchy-pkgs
|
||||||
LABEL=omarchy-builder
|
LABEL=omarchy-builder
|
||||||
TAG=omarchy-builder
|
TAG=omarchy-builder
|
||||||
REGION=ric1
|
# Builder sizes, tried in order in any region that has them in stock.
|
||||||
SIZE=g5-32vcpu-64gb-50gb
|
SIZES="g5-32vcpu-64gb-50gb g5-32vcpu-128gb-50gb"
|
||||||
|
# Optional: regions to try first, e.g. "ric1". Empty means any.
|
||||||
|
REGIONS=
|
||||||
MAX_DROPLETS=6
|
MAX_DROPLETS=6
|
||||||
MAX_AGE_MINUTES=200
|
MAX_AGE_MINUTES=200
|
||||||
LOCK=/run/omarchy-controller/lock
|
LOCK=/run/omarchy-controller/lock
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ Wants=network-online.target
|
|||||||
Type=oneshot
|
Type=oneshot
|
||||||
User=controller
|
User=controller
|
||||||
EnvironmentFile=/etc/omarchy-controller.env
|
EnvironmentFile=/etc/omarchy-controller.env
|
||||||
|
# Run the branch's current controller, not the one cloned when the box was
|
||||||
|
# built. As root (the checkout's owner); a failed pull keeps the last one.
|
||||||
|
ExecStartPre=-+/usr/bin/git -C /opt/omarchy-pkgs pull --ff-only --quiet
|
||||||
ExecStart=/opt/omarchy-pkgs/ci/controller.sh
|
ExecStart=/opt/omarchy-pkgs/ci/controller.sh
|
||||||
# The reaper's safety net is time, not state; a hung tick must not hold the lock.
|
# The reaper's safety net is time, not state; a hung tick must not hold the lock.
|
||||||
TimeoutStartSec=240
|
TimeoutStartSec=240
|
||||||
+51
-9
@@ -21,8 +21,12 @@ REPO=${REPO:?owner/name}
|
|||||||
: "${DIGITALOCEAN_TOKEN:?}" "${GITHUB_TOKEN:?}"
|
: "${DIGITALOCEAN_TOKEN:?}" "${GITHUB_TOKEN:?}"
|
||||||
LABEL=${LABEL:-omarchy-builder}
|
LABEL=${LABEL:-omarchy-builder}
|
||||||
TAG=${TAG:-omarchy-builder}
|
TAG=${TAG:-omarchy-builder}
|
||||||
REGION=${REGION:-ric1}
|
# Sizes to try, in order, in any region DigitalOcean lists them in stock. A
|
||||||
SIZE=${SIZE:-g5-32vcpu-64gb-50gb}
|
# size can sell out in a region for hours; the create is then refused with
|
||||||
|
# 422 and the next region, then the next size, is tried. REGIONS only orders
|
||||||
|
# the regions tried first. SIZE and REGION, if set, are one-item lists.
|
||||||
|
SIZES=${SIZES:-${SIZE:-g5-32vcpu-64gb-50gb g5-32vcpu-128gb-50gb}}
|
||||||
|
REGIONS=${REGIONS:-${REGION:-}}
|
||||||
IMAGE=${IMAGE:-ubuntu-24-04-x64}
|
IMAGE=${IMAGE:-ubuntu-24-04-x64}
|
||||||
MAX_DROPLETS=${MAX_DROPLETS:-4}
|
MAX_DROPLETS=${MAX_DROPLETS:-4}
|
||||||
MAX_AGE_MINUTES=${MAX_AGE_MINUTES:-200}
|
MAX_AGE_MINUTES=${MAX_AGE_MINUTES:-200}
|
||||||
@@ -39,7 +43,8 @@ log() { echo "$(date '+%F %T') $*"; }
|
|||||||
# both, so every decision below is exercised against canned responses.
|
# both, so every decision below is exercised against canned responses.
|
||||||
do_api() { # do_api <path> [curl args...]
|
do_api() { # do_api <path> [curl args...]
|
||||||
local path=$1; shift
|
local path=$1; shift
|
||||||
curl -fsS -H "Authorization: Bearer $DIGITALOCEAN_TOKEN" \
|
# --fail-with-body: a refused create still prints why.
|
||||||
|
curl -sS --fail-with-body -H "Authorization: Bearer $DIGITALOCEAN_TOKEN" \
|
||||||
-H "Content-Type: application/json" "https://api.digitalocean.com/v2/$path" "$@"
|
-H "Content-Type: application/json" "https://api.digitalocean.com/v2/$path" "$@"
|
||||||
}
|
}
|
||||||
gh_api() { # gh_api <path> [curl args...]
|
gh_api() { # gh_api <path> [curl args...]
|
||||||
@@ -102,22 +107,59 @@ busy_runners() {
|
|||||||
| jq --arg l "$LABEL" '[.runners[] | select(.busy) | select(any(.labels[]; .name == $l))] | length'
|
| jq --arg l "$LABEL" '[.runners[] | select(.busy) | select(any(.labels[]; .name == $l))] | length'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- capacity --------------------------------------------------------------
|
||||||
|
# "size region" lines to try, best first: SIZES order, then REGIONS order,
|
||||||
|
# then every other region where DigitalOcean lists the size in stock.
|
||||||
|
candidates() {
|
||||||
|
local page=1 response count catalog=""
|
||||||
|
while :; do
|
||||||
|
response=$(do_api "sizes?per_page=200&page=$page") || return 1
|
||||||
|
count=$(jq -er '.sizes | arrays | length' <<< "$response") || return 1
|
||||||
|
catalog+=$(jq -c '.sizes[]' <<< "$response")$'\n'
|
||||||
|
(( count == 200 )) || break
|
||||||
|
((page += 1))
|
||||||
|
done
|
||||||
|
jq -rs --arg sizes "$SIZES" --arg regions "$REGIONS" '
|
||||||
|
($regions | split(" ") | map(select(length > 0))) as $pref
|
||||||
|
| INDEX(.slug) as $by
|
||||||
|
| $sizes | split(" ") | map(select(length > 0)) | .[]
|
||||||
|
| . as $size | $by[$size] // {} | select(.available == true)
|
||||||
|
| .regions as $in
|
||||||
|
| (($pref | map(select(. as $r | $in | index($r)))) + ($in - $pref))[]
|
||||||
|
| "\($size) \(.)"' <<< "$catalog"
|
||||||
|
}
|
||||||
|
|
||||||
# --- create ----------------------------------------------------------------
|
# --- create ----------------------------------------------------------------
|
||||||
|
# Built once per tick by the first create; a refused pair is dropped from it.
|
||||||
|
CANDIDATES=""
|
||||||
create_droplet() {
|
create_droplet() {
|
||||||
local token userdata name body
|
local token userdata name size region body response
|
||||||
|
[[ -n $CANDIDATES ]] || CANDIDATES=$(candidates) || return 1
|
||||||
token=$(gh_api "repos/$REPO/actions/runners/registration-token" -X POST | jq -r .token)
|
token=$(gh_api "repos/$REPO/actions/runners/registration-token" -X POST | jq -r .token)
|
||||||
userdata=$(sed -e "s|__REPO__|$REPO|g" -e "s|__RUNNER_TOKEN__|$token|g" \
|
userdata=$(sed -e "s|__REPO__|$REPO|g" -e "s|__RUNNER_TOKEN__|$token|g" \
|
||||||
-e "s|__RUNNER_LABELS__|$LABEL|g" -e "s|__RUNNER_VERSION__|$RUNNER_VERSION|g" \
|
-e "s|__RUNNER_LABELS__|$LABEL|g" -e "s|__RUNNER_VERSION__|$RUNNER_VERSION|g" \
|
||||||
-e "s|__SSH_KEYS_JSON__|$SSH_KEYS_JSON|" "$CLOUD_INIT")
|
-e "s|__SSH_KEYS_JSON__|$SSH_KEYS_JSON|" "$CLOUD_INIT")
|
||||||
name="$TAG-$(date +%s)-$RANDOM"
|
name="$TAG-$(date +%s)-$RANDOM"
|
||||||
body=$(jq -n --arg name "$name" --arg region "$REGION" --arg size "$SIZE" --arg image "$IMAGE" \
|
while read -r size region; do
|
||||||
--arg tag "$TAG" --arg ud "$userdata" \
|
[[ -n $size ]] || continue
|
||||||
'{name:$name, region:$region, size:$size, image:$image, tags:[$tag], user_data:$ud, monitoring:false}')
|
body=$(jq -n --arg name "$name" --arg region "$region" --arg size "$size" --arg image "$IMAGE" \
|
||||||
log "creating $name ($SIZE)"
|
--arg tag "$TAG" --arg ud "$userdata" \
|
||||||
do_api droplets -X POST -d "$body" | jq -r '"created droplet \(.droplet.id)"'
|
'{name:$name, region:$region, size:$size, image:$image, tags:[$tag], user_data:$ud, monitoring:false}')
|
||||||
|
log "creating $name ($size in $region)"
|
||||||
|
if response=$(do_api droplets -X POST -d "$body"); then
|
||||||
|
jq -r '"created droplet \(.droplet.id)"' <<< "$response"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "$size in $region refused: $(jq -r .message <<< "$response" 2>/dev/null || echo "$response")"
|
||||||
|
CANDIDATES=$(grep -Fvx "$size $region" <<< "$CANDIDATES" || true)
|
||||||
|
done <<< "$CANDIDATES"
|
||||||
|
# Every size refused everywhere: the rest of this tick's creates would be too.
|
||||||
|
log "no size in '$SIZES' can be created in any region"
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
controller_tick() {
|
controller_tick() {
|
||||||
|
CANDIDATES=""
|
||||||
reap
|
reap
|
||||||
local queued live busy available need room
|
local queued live busy available need room
|
||||||
queued=$(queued_jobs)
|
queued=$(queued_jobs)
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ write_files:
|
|||||||
content: |
|
content: |
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
# Power off after the one job, and also when the download or the
|
||||||
|
# registration fails: the controller deletes powered-off droplets, but
|
||||||
|
# counts a running one as a runner still booting until MAX_AGE_MINUTES.
|
||||||
|
trap 'sudo poweroff' EXIT
|
||||||
cd /home/runner
|
cd /home/runner
|
||||||
mkdir -p actions-runner && cd actions-runner
|
mkdir -p actions-runner && cd actions-runner
|
||||||
arch=$(uname -m); [[ $arch == x86_64 ]] && arch=x64
|
arch=$(uname -m); [[ $arch == x86_64 ]] && arch=x64
|
||||||
@@ -58,8 +62,6 @@ write_files:
|
|||||||
--labels "__RUNNER_LABELS__" \
|
--labels "__RUNNER_LABELS__" \
|
||||||
--replace
|
--replace
|
||||||
./run.sh
|
./run.sh
|
||||||
# One job done. Power off; the controller deletes powered-off droplets.
|
|
||||||
sudo poweroff
|
|
||||||
|
|
||||||
runcmd:
|
runcmd:
|
||||||
# With no account ssh key attached, DO expires root's password, and sshd
|
# With no account ssh key attached, DO expires root's password, and sshd
|
||||||
|
|||||||
+37
-2
@@ -22,6 +22,7 @@ do_api() {
|
|||||||
local path=$1; shift
|
local path=$1; shift
|
||||||
echo "do $path $*" >>"$CALLS_FILE"
|
echo "do $path $*" >>"$CALLS_FILE"
|
||||||
case "$path" in
|
case "$path" in
|
||||||
|
sizes\?*) echo '{"sizes":[{"slug":"g5-32vcpu-64gb-50gb","available":true,"regions":["ric1"]}]}' ;;
|
||||||
droplets\?*) printf '%s\n' "$DROPLETS" | jq -Rs '{droplets: [split("\n")[] | select(length>0) | split(" ") | {id: .[0]|tonumber, status: .[1], created_at: .[2]}]}' ;;
|
droplets\?*) printf '%s\n' "$DROPLETS" | jq -Rs '{droplets: [split("\n")[] | select(length>0) | split(" ") | {id: .[0]|tonumber, status: .[1], created_at: .[2]}]}' ;;
|
||||||
droplets) echo '{"droplet":{"id":999}}' ;;
|
droplets) echo '{"droplet":{"id":999}}' ;;
|
||||||
droplets/*) echo '{}' ;;
|
droplets/*) echo '{}' ;;
|
||||||
@@ -88,9 +89,43 @@ echo "PASS: API failures stop the queue query"
|
|||||||
|
|
||||||
# The create body must carry the tag (reaper scope) and substituted user-data.
|
# The create body must carry the tag (reaper scope) and substituted user-data.
|
||||||
BODY_FILE=$(mktemp); trap 'rm -f "$CALLS_FILE" "$BODY_FILE"' EXIT
|
BODY_FILE=$(mktemp); trap 'rm -f "$CALLS_FILE" "$BODY_FILE"' EXIT
|
||||||
do_api() { if [[ $1 == droplets ]]; then printf '%s' "${*: -1}" >"$BODY_FILE"; echo '{"droplet":{"id":1}}'; else echo '{"droplets":[]}'; fi; }
|
do_api() {
|
||||||
|
case "$1" in
|
||||||
|
droplets) printf '%s' "${*: -1}" >"$BODY_FILE"; echo '{"droplet":{"id":1}}' ;;
|
||||||
|
sizes*) echo '{"sizes":[{"slug":"g5-32vcpu-64gb-50gb","available":true,"regions":["ric1"]}]}' ;;
|
||||||
|
*) echo '{"droplets":[]}' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
gh_api() { echo '{"token":"TOK"}'; }
|
gh_api() { echo '{"token":"TOK"}'; }
|
||||||
create_droplet >/dev/null
|
CANDIDATES=""; create_droplet >/dev/null
|
||||||
jq -e '.tags == ["omarchy-builder"] and .size == "g5-32vcpu-64gb-50gb" and (.user_data | test("--token \"TOK\"")) and (.user_data | test("__") | not)' "$BODY_FILE" >/dev/null \
|
jq -e '.tags == ["omarchy-builder"] and .size == "g5-32vcpu-64gb-50gb" and (.user_data | test("--token \"TOK\"")) and (.user_data | test("__") | not)' "$BODY_FILE" >/dev/null \
|
||||||
&& echo "PASS: create body carries tag, size, substituted user-data" \
|
&& echo "PASS: create body carries tag, size, substituted user-data" \
|
||||||
|| { echo "FAIL: create body"; jq . "$BODY_FILE" | head -20; exit 1; }
|
|| { echo "FAIL: create body"; jq . "$BODY_FILE" | head -20; exit 1; }
|
||||||
|
|
||||||
|
# Sizes are tried in SIZES order, each in the regions DigitalOcean lists it
|
||||||
|
# in stock (REGIONS first); a 422 falls through to the next pair with the
|
||||||
|
# refusal's message logged, and a refused pair is not retried in the tick.
|
||||||
|
# When every pair is refused, the create fails.
|
||||||
|
do_api() {
|
||||||
|
case "$1" in
|
||||||
|
sizes*) echo '{"sizes":[
|
||||||
|
{"slug":"small","available":true,"regions":["r1","r2"]},
|
||||||
|
{"slug":"gone","available":false,"regions":["r1"]},
|
||||||
|
{"slug":"big","available":true,"regions":["r3"]}]}' ;;
|
||||||
|
droplets)
|
||||||
|
local pair; pair=$(jq -r '"\(.size)@\(.region)"' <<< "${*: -1}"); echo "$pair" >>"$CALLS_FILE"
|
||||||
|
[[ $pair == big@r3 ]] && { echo '{"droplet":{"id":2}}'; return 0; }
|
||||||
|
echo '{"id":"unprocessable_entity","message":"Size is not available in this region."}'; return 22 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
: >"$CALLS_FILE"; CANDIDATES=""
|
||||||
|
out=$(SIZES="small gone big" REGIONS="r2"; create_droplet; create_droplet)
|
||||||
|
[[ $(paste -sd' ' "$CALLS_FILE") == "small@r2 small@r1 big@r3 big@r3" \
|
||||||
|
&& $out == *"small in r2 refused: Size is not available in this region."* && $out == *"created droplet 2"* ]] \
|
||||||
|
&& echo "PASS: a refused size falls back to the next region, then the next size, logging why" \
|
||||||
|
|| { echo "FAIL: size and region fallback"; echo "$out"; cat "$CALLS_FILE"; exit 1; }
|
||||||
|
CANDIDATES=""
|
||||||
|
if out=$(SIZES="small gone" create_droplet); then echo "FAIL: every pair refused looks like a create"; exit 1; fi
|
||||||
|
[[ $out == *"no size in 'small gone' can be created in any region"* ]] \
|
||||||
|
&& echo "PASS: every size refused everywhere fails the create" \
|
||||||
|
|| { echo "FAIL: all-refused message"; echo "$out"; exit 1; }
|
||||||
Reference in new issue
Block a user