igneum-seed-1 (Hetzner cx23, fsn1, 188.245.5.161:26611): built on the VM in 1,530 s, synced to the live devnet (12,204 blocks, same sink as the live node) through a non-mining relay igneumd on the Mac (the live node's addPeer RPC is refused in safe mode); the live node and the Windows PC learned the seed's address by peer exchange and dialled it. seeds.txt written. Hetzner prices corrected to USD (pricing API currency) in the plans, READMEs and scripts; current-generation types per location (cx23 EU, cpx22 sin, cpx21 US) in the cloud-devnet config. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
147 lines
8.1 KiB
Bash
Executable file
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, 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"
|