#!/usr/bin/env bash # Create the cloud devnet VMs: N nodes, regions round-robin, one firewall, one SSH key. Writes nodes.tsv. # Hetzner Cloud through `hcloud` (primary). DigitalOcean through `doctl` with PROVIDER=digitalocean. # Spends money from the moment the servers exist (hourly billing on both providers). It prints the plan and # the provider's live price and asks for "yes" first (YES=1 skips the question). # # Before the first run: `hcloud context create igneum` (paste the project's API token; the project is created by # the project lead in the Hetzner console, not by this script) or `doctl auth init`. # usage: ./create.sh create N nodes # ./create.sh builder create only the builder VM (provision.sh does this itself when it needs one) . "$(dirname "$0")/lib/common.sh" what="${1:-nodes}" mkdir -p "$BUILD_DIR" # ---- SSH key ------------------------------------------------------------------------------------------------- if [ ! -f "$SSH_KEY_FILE" ]; then log "no key at $SSH_KEY_FILE, generating an ed25519 pair (no passphrase; it only opens these test VMs)" ssh-keygen -q -t ed25519 -N "" -C "igneum-devnet" -f "$SSH_KEY_FILE" fi PUBKEY_FILE="$SSH_KEY_FILE.pub" [ -f "$PUBKEY_FILE" ] || die "missing $PUBKEY_FILE" # ---- Hetzner ------------------------------------------------------------------------------------------------------ hetzner_prepare() { need hcloud "brew install hcloud" hcloud context active >/dev/null 2>&1 || die "no active hcloud context: hcloud context create igneum" if ! hcloud ssh-key describe "$SSH_KEY_NAME" >/dev/null 2>&1; then hcloud ssh-key create --name "$SSH_KEY_NAME" --public-key-from-file "$PUBKEY_FILE" >/dev/null log "uploaded ssh key $SSH_KEY_NAME" fi if ! hcloud firewall describe "$PREFIX-devnet" >/dev/null 2>&1; then hcloud firewall create --name "$PREFIX-devnet" --label igneum=devnet >/dev/null hcloud firewall add-rule "$PREFIX-devnet" --direction in --protocol tcp --port 22 --source-ips 0.0.0.0/0 --source-ips ::/0 --description ssh >/dev/null hcloud firewall add-rule "$PREFIX-devnet" --direction in --protocol tcp --port "$P2P_PORT" --source-ips 0.0.0.0/0 --source-ips ::/0 --description igneum-p2p >/dev/null hcloud firewall add-rule "$PREFIX-devnet" --direction in --protocol icmp --source-ips 0.0.0.0/0 --source-ips ::/0 --description ping >/dev/null log "created firewall $PREFIX-devnet (in: 22, $P2P_PORT, icmp; RPC never leaves loopback)" fi } hetzner_price() { log "live price list for $1 (per location, USD, excl. VAT):" hcloud server-type describe "$1" 2>/dev/null | sed -n '/Pricings/,$p' | head -40 || true } hetzner_create_one() { # name type location local name="$1" type="$2" loc="$3" if hcloud server describe "$name" >/dev/null 2>&1; then log "$name exists, keeping it"; return; fi hcloud server create --name "$name" --type "$type" --image "$IMAGE" --location "$loc" \ --ssh-key "$SSH_KEY_NAME" --firewall "$PREFIX-devnet" --label igneum=devnet --label role="${4:-node}" >/dev/null log "created $name ($type, $loc)" } hetzner_ip() { hcloud server ip "$1"; } # ---- DigitalOcean -------------------------------------------------------------------------------------------------- do_prepare() { need doctl "brew install doctl" doctl account get >/dev/null 2>&1 || die "doctl is not authenticated: doctl auth init" DO_KEY_ID=$(doctl compute ssh-key list --format ID,Name --no-header | awk -v n="$SSH_KEY_NAME" '$2 == n { print $1 }') if [ -z "$DO_KEY_ID" ]; then DO_KEY_ID=$(doctl compute ssh-key import "$SSH_KEY_NAME" --public-key-file "$PUBKEY_FILE" --format ID --no-header) log "imported ssh key $SSH_KEY_NAME ($DO_KEY_ID)" fi DO_FW_ID=$(doctl compute firewall list --format ID,Name --no-header | awk -v n="$PREFIX-devnet" '$2 == n { print $1 }') if [ -z "$DO_FW_ID" ]; then DO_FW_ID=$(doctl compute firewall create --name "$PREFIX-devnet" --tag-names "$PREFIX-devnet" \ --inbound-rules "protocol:tcp,ports:22,address:0.0.0.0/0,address:::/0 protocol:tcp,ports:$P2P_PORT,address:0.0.0.0/0,address:::/0 protocol:icmp,address:0.0.0.0/0,address:::/0" \ --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0 protocol:icmp,address:0.0.0.0/0,address:::/0" \ --format ID --no-header) log "created firewall $PREFIX-devnet ($DO_FW_ID), applied by tag" fi } do_price() { log "live price list for $1 (USD):"; doctl compute size list --format Slug,Memory,VCPUs,Disk,PriceMonthly,PriceHourly | grep -E "^Slug|^$1 " || true; } do_create_one() { # name size region local name="$1" size="$2" reg="$3" if doctl compute droplet get "$name" >/dev/null 2>&1; then log "$name exists, keeping it"; return; fi doctl compute droplet create "$name" --size "$size" --image "$DO_IMAGE" --region "$reg" --ssh-keys "$DO_KEY_ID" \ --tag-names "$PREFIX-devnet,role:${4:-node}" --wait >/dev/null log "created $name ($size, $reg)" } do_ip() { doctl compute droplet get "$1" --format PublicIPv4 --no-header; } # ---- plan and confirm -------------------------------------------------------------------------------------------- if [ "$PROVIDER" = digitalocean ]; then do_prepare; TYPE="$DO_SIZE"; BTYPE="$DO_BUILDER_SIZE" else hetzner_prepare; TYPE="${SERVER_TYPE:-by-location}"; BTYPE="$BUILDER_TYPE" fi if [ "$what" = builder ]; then log "plan: 1 builder VM $BTYPE in $(region_of 1) on $PROVIDER (deleted by provision.sh after the build unless KEEP_BUILDER=1)" if [ "$PROVIDER" = digitalocean ]; then do_price "$BTYPE"; else hetzner_price "$BTYPE"; fi confirm "create the builder now (hourly billing starts)?" if [ "$PROVIDER" = digitalocean ]; then do_create_one "$PREFIX-builder" "$BTYPE" "$(region_of 1)" builder; ip=$(do_ip "$PREFIX-builder") else hetzner_create_one "$PREFIX-builder" "$BTYPE" "$(region_of 1)" builder; ip=$(hetzner_ip "$PREFIX-builder"); fi printf '%s\n' "$ip" > "$BUILD_DIR/builder.ip" log "builder $ip (saved to build/builder.ip)" exit 0 fi log "plan: $N nodes ($IMAGE) on $PROVIDER, regions round-robin:" for i in $(seq 1 "$N"); do reg=$(region_of "$i"); t="$TYPE"; [ "$PROVIDER" = digitalocean ] || t=$(type_for_location "$reg") printf ' %s %s %s\n' "$(node_name "$i")" "$reg" "$t" done if [ "$PROVIDER" = digitalocean ]; then do_price "$TYPE"; else for t in $(for i in $(seq 1 "$N"); do type_for_location "$(region_of "$i")"; done | sort -u); do hetzner_price "$t"; done fi log "cost at the Hetzner API prices of 3 Oct 2026 (net USD per month: cx23 6.49 EU, cpx22 30.99 sin, cpx21 37.49 ash/hil):" log " 20 nodes as 4 per location = 8 x 6.49 + 4 x 30.99 + 8 x 37.49 = USD 476/mo, USD 0.76/h; an evening of 6 h about USD 5 plus the builder (about USD 0.03/h)" log " EU-heavy alternative REGIONS=hel1,fsn1,nbg1,hel1,fsn1,nbg1,hel1,ash,hil,sin (14 EU, 2 each elsewhere): about USD 303/mo, USD 0.47/h" log " DigitalOcean s-2vcpu-4gb: USD 24 per node per month (USD 0.036/h, DO pricing page): 20 nodes USD 480/mo, USD 0.71/h" confirm "create $N servers now (hourly billing starts)?" : > "$NODES_FILE.tmp" for i in $(seq 1 "$N"); do name=$(node_name "$i"); reg=$(region_of "$i") if [ "$PROVIDER" = digitalocean ]; then do_create_one "$name" "$TYPE" "$reg"; else hetzner_create_one "$name" "$(type_for_location "$reg")" "$reg"; fi done log "waiting 20 s for the servers to boot, then collecting addresses" sleep 20 for i in $(seq 1 "$N"); do name=$(node_name "$i"); reg=$(region_of "$i") if [ "$PROVIDER" = digitalocean ]; then ip=$(do_ip "$name"); else ip=$(hetzner_ip "$name"); fi printf '%s\t%s\t%s\t%s\n' "$name" "$i" "$reg" "$ip" >> "$NODES_FILE.tmp" done mv "$NODES_FILE.tmp" "$NODES_FILE" cp "$NODES_FILE" "$BUILD_DIR/nodes-$(date -u +%Y%m%d-%H%M%S).tsv" log "wrote $NODES_FILE:" cat "$NODES_FILE" log "checking ssh on every node (cloud-init can take a minute)" for try in 1 2 3 4 5 6; do bad=0 while IFS=$'\t' read -r name idx reg ip; do nssh "$ip" true >/dev/null 2>&1 || { bad=$((bad + 1)); } done < "$NODES_FILE" [ "$bad" = 0 ] && break log "$bad nodes not reachable yet (try $try), waiting 15 s"; sleep 15 done [ "$bad" = 0 ] || log "WARNING: $bad nodes still unreachable over ssh; provision.sh will retry them" log "done. Next: ./provision.sh"