igneum/infra/cloud-devnet/create.sh
igneum-labs 36a4f4ca62 infra: cloud devnet (20 nodes), rented GPU bench and seed node scripts; plans for both
infra/cloud-devnet: hcloud (doctl variant) create, builder-VM provision from a git-archive source tarball,
systemd units for igneumd --devnet-suffix with a sparse --addpeer mesh and a CPU trickle miner per node,
stdlib wRPC client, experiments (latency, partition, hop, collect, observer hookup), README with the command
sequence and the Hetzner API prices of 3 Oct 2026.
infra/gpu-bench: RunPod image recipes (CUDA 12.8, ROCm), bundle, run.sh (vectors gate, 10-min raw, sweep,
inline shortcut ratio, nvcc/NVRTC/OpenCL recompile timings, results row, intake upload), bench-log template.
infra/seed-nodes: create-seed (persistent IPv4, firewall), provision on the VM, health check, addPeer from the
Mac over grpcurl, seeds.txt; igneum-seed-1 created at 188.245.5.161 (Hetzner cx23, fsn1).
docs/plans/cloud-devnet.md and docs/plans/seed-nodes.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 22:01:50 +00:00

147 lines
8.1 KiB
Bash
Executable file

#!/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, EUR, 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 EUR 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 = EUR 476/mo, EUR 0.76/h; an evening of 6 h about EUR 5 plus the builder (about EUR 0.03/h)"
log " EU-heavy alternative REGIONS=hel1,fsn1,nbg1,hel1,fsn1,nbg1,hel1,ash,hil,sin (14 EU, 2 each elsewhere): about EUR 303/mo, EUR 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"