diff --git a/ci/README.md b/ci/README.md index c38d944c..4ca988cc 100644 --- a/ci/README.md +++ b/ci/README.md @@ -75,6 +75,7 @@ Edit `/etc/omarchy-controller.env` on the box. The next tick reads it. | `REGIONS` | empty | Regions to try first; empty means any | | `MAX_AGE_MINUTES` | 200 | Delete a droplet older than this | | `MAX_BOOT_MINUTES` | 10 | Delete a droplet still provisioning after this | +| `DO_SSH_KEYS` | Ryan, DHH and Emir's account key IDs | Attached to every builder. Without one, DigitalOcean emails a root password per builder | ### Change the controller @@ -110,7 +111,8 @@ DIGITALOCEAN_TOKEN= GITHUB_TOKEN=&2; exit 1; } diff --git a/ci/controller.sh b/ci/controller.sh index d69f4e3a..1e5383f1 100755 --- a/ci/controller.sh +++ b/ci/controller.sh @@ -40,6 +40,12 @@ CLOUD_INIT=${CLOUD_INIT:-$(dirname "$0")/runner-cloud-init.yaml} # Operator public keys authorized on every builder (JSON array of strings). # The box's env file carries them; empty means no root login. SSH_KEYS_JSON=${SSH_KEYS_JSON:-[]} +# DigitalOcean account SSH key IDs or fingerprints attached at creation (JSON +# array). A droplet created with none gets a root password that DigitalOcean +# emails to the token's owner, once per builder. Attaching one needs the +# ssh_key:read scope on DIGITALOCEAN_TOKEN; without it every create is +# refused with 403. +DO_SSH_KEYS=${DO_SSH_KEYS:-[]} LOCK=${LOCK:-/tmp/omarchy-controller.lock} log() { echo "$(date '+%F %T') $*"; } @@ -152,8 +158,8 @@ create_droplet() { while read -r size region; do [[ -n $size ]] || continue body=$(jq -n --arg name "$name" --arg region "$region" --arg size "$size" --arg image "$IMAGE" \ - --arg tag "$TAG" --arg ud "$userdata" \ - '{name:$name, region:$region, size:$size, image:$image, tags:[$tag], user_data:$ud, monitoring:false}') + --arg tag "$TAG" --arg ud "$userdata" --argjson keys "$DO_SSH_KEYS" \ + '{name:$name, region:$region, size:$size, image:$image, tags:[$tag], user_data:$ud, ssh_keys:$keys, 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" diff --git a/ci/runner-cloud-init.yaml b/ci/runner-cloud-init.yaml index 1cb409e7..17da958b 100644 --- a/ci/runner-cloud-init.yaml +++ b/ci/runner-cloud-init.yaml @@ -14,7 +14,10 @@ # Operators can reach a builder by key while it lives; it powers off after # one job anyway. Keys are substituted by the controller (__SSH_KEYS_JSON__). +# Never by password: DigitalOcean sets and emails one when no account key is +# attached, and its vendor data turns password logins on to match. disable_root: false +ssh_pwauth: false chpasswd: expire: false ssh_authorized_keys: __SSH_KEYS_JSON__ diff --git a/tests/controller.sh b/tests/controller.sh index cd21aa6a..a7503456 100755 --- a/tests/controller.sh +++ b/tests/controller.sh @@ -105,6 +105,12 @@ jq -e '.tags == ["omarchy-builder"] and .size == "g5-32vcpu-64gb-50gb" and (.use && echo "PASS: create body carries tag, size, substituted user-data" \ || { echo "FAIL: create body"; jq . "$BODY_FILE" | head -20; exit 1; } +# Account keys reach the create, so DigitalOcean emails no root password. +CANDIDATES=""; DO_SSH_KEYS='[123]' create_droplet >/dev/null +jq -e '.ssh_keys == [123]' "$BODY_FILE" >/dev/null \ + && echo "PASS: create body attaches DO_SSH_KEYS" \ + || { echo "FAIL: create body ssh_keys"; jq .ssh_keys "$BODY_FILE"; 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.