igneum/infra/cloud-devnet/create.sh
igneum-josh 6b5bd92b84 Cloud devnet: private-network mode (4 zone networks, 4 gateways), 12 nodes up, first latency measurement
A new Hetzner account is capped at 10 primary IPs (IPv4 and IPv6 both count), 20 shared vCPUs, 8 dedicated
vCPUs and no Arm, and a network cannot span zones. So: one private network per zone (10.20.<zone>.0/24),
the lowest-index node of each zone keeps a public IPv4 and is its gateway (NAT, MSS clamp, one DNAT port
27000+index per private node, persisted as igneum-nat.service), every other node has no public address.
nodes.tsv gains access, pub and port columns; lib resolves same-zone vs cross-zone dial addresses and jumps
ssh through the gateway for private nodes. All nodes.tsv loops read on fd 3 (a backgrounded ssh drained the
file). TYPE_BY_INDEX puts nodes 8 to 11 on ccx13; node 12 is the last shared one the account allows.
create.sh prints the plan's cost from the live API. provision.sh install takes node names and skips binaries
whose sha256 matches. Binaries copied from the seed's staged v4 build (same sources), no vCPU for a builder.

Results 2026-10-04: RTT matrix (hel1-fsn1 35 ms, ash-sin 289 ms) and a 10-minute propagation window of 644
blocks: p50 343 ms, p90 497 ms, p99 666 ms across 12 nodes in 5 locations.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 11:41:23 +01:00

236 lines
14 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
# Josh 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)
#
# NET_MODE=private (the default since 4 Oct 2026, see config.sh): one private network per Hetzner network zone,
# the lowest-index node of each zone is its public IPv4 gateway (NAT and one forwarded p2p port per private node),
# every other node is created without public addresses. Re-running the script is safe: servers that exist are kept
# and attached to their zone network if they are not in it yet; nodes.tsv is rewritten from the live state.
. "$(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
if [ "$NET_MODE" = private ]; then
local lo=$(( FWD_PORT_BASE + 1 )) hi=$(( FWD_PORT_BASE + 99 ))
if ! hcloud firewall describe "$PREFIX-devnet" -o json | python3 -c "import json,sys; r=json.load(sys.stdin)['rules']; sys.exit(0 if any(x.get('port')=='$lo-$hi' for x in r) else 1)"; then
hcloud firewall add-rule "$PREFIX-devnet" --direction in --protocol tcp --port "$lo-$hi" --source-ips 0.0.0.0/0 --source-ips ::/0 --description igneum-p2p-forwarded >/dev/null
log "firewall: opened tcp $lo-$hi (the gateways' forwarded p2p ports)"
fi
for z in $(zones_in_plan); do
net=$(network_name "$z")
if ! hcloud network describe "$net" >/dev/null 2>&1; then
hcloud network create --name "$net" --ip-range "$(zone_net "$z")" --label igneum=devnet --label zone="$z" >/dev/null
hcloud network add-subnet "$net" --type cloud --network-zone "$z" --ip-range "$(zone_net "$z")" >/dev/null
log "created network $net $(zone_net "$z") (zone $z)"
fi
done
fi
}
# net hourly price of a server type at a location, from the API (cached per run in build/prices.tsv)
hetzner_hourly() { # type location
local f="$BUILD_DIR/prices.tsv" p
p=$(awk -F'\t' -v t="$1" -v l="$2" '$1 == t && $2 == l { print $3; exit }' "$f" 2>/dev/null)
if [ -z "$p" ]; then
hcloud server-type describe "$1" -o json 2>/dev/null | python3 -c 'import json,sys; t=json.load(sys.stdin); [print(t["name"] + "\t" + p["location"] + "\t" + p["price_hourly"]["net"]) for p in t["prices"]]' >> "$f"
p=$(awk -F'\t' -v t="$1" -v l="$2" '$1 == t && $2 == l { print $3; exit }' "$f")
fi
printf '%s' "${p:-0}"
}
# the plan's hourly cost: every node at the live API price, plus USD 0.60/month per public IPv4
hetzner_plan_cost() {
local i reg t a total=0 ips=0
for i in $(seq 1 "$N"); do
reg=$(region_of "$i"); t=$(type_for_index "$i" "$reg"); a=$(access_of "$i")
total=$(python3 -c "print($total + $(hetzner_hourly "$t" "$reg"))"); [ "$a" = public ] && ips=$((ips + 1))
done
total=$(python3 -c "print(round($total + $ips * 0.60 / 730, 4))")
log "cost of this plan at the live API prices (net USD): $total per hour, about $(python3 -c "print(round($total * 730))") per month, $(python3 -c "print(round($total * 6, 2))") for an evening of 6 h; $ips public IPv4 at 0.60/month each"
}
hetzner_create_one() { # name type location [role] [access] (access public|private, private only in NET_MODE=private)
local name="$1" type="$2" loc="$3" role="${4:-node}" access="${5:-public}" net="" z
local -a netargs=()
if [ "$NET_MODE" = private ]; then
z=$(zone_of_region "$loc"); [ -n "$z" ] || die "$loc is in no zone of ZONES"
net=$(network_name "$z")
if [ "$access" = private ]; then netargs=(--without-ipv4 --without-ipv6 --network "$net"); else netargs=(--without-ipv6 --network "$net"); fi
fi
if hcloud server describe "$name" >/dev/null 2>&1; then
log "$name exists, keeping it"
if [ -n "$net" ] && ! hcloud server describe "$name" -o json | python3 -c "import json,sys; s=json.load(sys.stdin); sys.exit(0 if s['private_net'] else 1)"; then
hcloud server attach-to-network "$name" --network "$net" >/dev/null && log "attached $name to $net"
fi
return
fi
hcloud server create --name "$name" --type "$type" --image "$IMAGE" --location "$loc" "${netargs[@]}" \
--ssh-key "$SSH_KEY_NAME" --firewall "$PREFIX-devnet" --label igneum=devnet --label role="$role" --label access="$access" >/dev/null
log "created $name ($type, $loc, $access)"
}
# public IPv4 (or "-") and first private IP (or "-") of a server
hetzner_addrs() { hcloud server describe "$1" -o json | python3 -c 'import json,sys; s=json.load(sys.stdin); v4=(s["public_net"].get("ipv4") or {}).get("ip") or "-"; p=s["private_net"][0]["ip"] if s["private_net"] else "-"; print(v4 + "\t" + p)'; }
hetzner_ip() { hcloud server ip "$1"; }
# access of node index i in the plan: the zone gateway (lowest index of the zone) is public, the rest private
access_of() { if [ "$NET_MODE" = private ] && [ "$(gateway_index_for_zone "$(zone_of_region "$(region_of "$1")")")" != "$1" ]; then printf 'private'; else printf 'public'; fi; }
# ---- 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 log "builder $BTYPE in $(region_of 1): USD $(hetzner_hourly "$BTYPE" "$(region_of 1)")/h net"; 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")
elif [ "$NET_MODE" = private ]; then
# no public address (the primary IP limit); it sits behind the zone gateway of region 1, ssh jumps through it
require_nodes; hetzner_create_one "$PREFIX-builder" "$BTYPE" "$(region_of 1)" builder private; ip=$(hetzner_addrs "$PREFIX-builder" | cut -f2)
for try in $(seq 1 12); do nssh "$ip" true >/dev/null 2>&1 && break; sleep 10; done
nscp "$HERE/net/private-node.sh" "$SSH_USER@$ip:/root/private-node.sh"
nssh "$ip" "bash /root/private-node.sh '$(zone_hetzner_gw "$(zone_of_region "$(region_of 1)")")'" | sed 's/^/[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_index "$i" "$reg")
a=$(access_of "$i"); [ "$a" = public ] && [ "$NET_MODE" = private ] && a="public (zone gateway: NAT + port forwards)"
printf ' %s %s %s %s\n' "$(node_name "$i")" "$reg" "$t" "$a"
done
[ "$NET_MODE" = private ] && log "private mode: $(zones_in_plan | wc -l | tr -d ' ') zone networks ($NET_PREFIX.<zone>.0/24), $(for i in $(seq 1 "$N"); do access_of "$i"; printf '\n'; done | grep -c public) public IPv4 primary IPs in total"
if [ "$PROVIDER" = digitalocean ]; then
do_price "$TYPE"
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"
else
hetzner_plan_cost
fi
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_index "$i" "$reg")" "$reg" node "$(access_of "$i")"; fi
done
log "waiting 20 s for the servers to boot, then collecting addresses"
sleep 20
# pass 1: public addresses (the gateways' IPs are needed for the private rows); pass 2: the rows
for i in $(seq 1 "$N"); do
name=$(node_name "$i"); reg=$(region_of "$i"); a=$(access_of "$i")
if [ "$PROVIDER" = digitalocean ]; then ip=$(do_ip "$name"); pub="$ip"; priv="-"
else IFS=$'\t' read -r pub priv <<< "$(hetzner_addrs "$name")"; ip="$pub"; fi
if [ "$NET_MODE" = private ]; then ip="$priv"; fi
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$name" "$i" "$reg" "$ip" "$a" "$pub" "$P2P_PORT" >> "$NODES_FILE.tmp"
done
if [ "$NET_MODE" = private ]; then
# private rows: pub = the zone gateway's public IPv4, port = FWD_PORT_BASE + index
NODES_FILE="$NODES_FILE.tmp" python3 - "$NODES_FILE.tmp" "$FWD_PORT_BASE" "$(printf '%s' "$ZONES")" <<'PY'
import sys
path, base, zones = sys.argv[1], int(sys.argv[2]), sys.argv[3]
zone = {}
for part in zones.split(";"):
z, _, locs = part.split(":"); [zone.__setitem__(l, z) for l in locs.split()]
rows = [l.rstrip("\n").split("\t") for l in open(path) if l.strip()]
gw = {zone[r[2]]: r[5] for r in rows if r[4] == "public"}
out = []
for r in rows:
if r[4] == "private":
if zone[r[2]] not in gw: sys.exit("zone %s has no public gateway node" % zone[r[2]])
r[5] = gw[zone[r[2]]]; r[6] = str(base + int(r[1]))
out.append("\t".join(r))
open(path, "w").write("\n".join(out) + "\n")
PY
fi
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 (name, index, region, ip, access, pub, port):"
cat "$NODES_FILE"
if [ "$NET_MODE" = private ] && [ "$PROVIDER" != digitalocean ]; then
log "gateways: waiting for ssh, then routes, NAT and port forwards"
for n in $(public_nodes); do for try in $(seq 1 12); do nssh "$(node_ip "$n")" true >/dev/null 2>&1 && break; sleep 10; done; done
"$HERE/net/setup.sh" gateways
log "private nodes: waiting for ssh through the gateways, then the uplink check"
for n in $(private_nodes); do for try in $(seq 1 12); do nssh "$(node_ip "$n")" true >/dev/null 2>&1 && break; sleep 10; done; done
"$HERE/net/setup.sh" nodes
fi
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 -u 3 name idx reg ip access pub port; do
nssh "$ip" true >/dev/null 2>&1 </dev/null || { bad=$((bad + 1)); }
done 3< "$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"