igneum/infra/cloud-devnet/lib/common.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

91 lines
4.2 KiB
Bash

# Shared helpers for infra/cloud-devnet. Source this; it sources config.sh.
# Needs bash 3.2 or newer (the Mac's /bin/bash is fine), ssh, scp, python3.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
export REPO
# shellcheck source=../config.sh
. "$HERE/config.sh"
NODES_FILE="${NODES_FILE:-$HERE/nodes.tsv}" # name<TAB>index<TAB>region<TAB>ip, one line per node, written by create.sh
BUILD_DIR="$HERE/build"
BIN_DIR="$BUILD_DIR/bin"
RESULTS_DIR="$HERE/results"
# Hetzner API token: the only line of ~/.config/igneum/hetzner-token (never printed, never in the repo).
if [ -z "${HCLOUD_TOKEN:-}" ] && [ -s "${HETZNER_TOKEN_FILE:-$HOME/.config/igneum/hetzner-token}" ]; then
HCLOUD_TOKEN="$(head -1 "${HETZNER_TOKEN_FILE:-$HOME/.config/igneum/hetzner-token}" | tr -d '[:space:]')"; export HCLOUD_TOKEN
fi
log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*"; }
die() { log "ERROR: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "$1 is not installed ($2)"; }
# Hetzner location name for node index i (1-based) and the DigitalOcean one.
region_of() {
local i="$1" list
if [ "$PROVIDER" = digitalocean ]; then list="$DO_REGIONS"; else list="$REGIONS"; fi
local n; n=$(printf '%s' "$list" | tr ',' '\n' | grep -c .)
printf '%s' "$list" | tr ',' '\n' | sed -n "$(( (i - 1) % n + 1 ))p"
}
node_name() { printf '%s-%02d' "$PREFIX" "$1"; }
# Server type for a Hetzner location: SERVER_TYPE if set, else the TYPE_BY_LOCATION map, else cx23.
type_for_location() {
if [ -n "$SERVER_TYPE" ]; then printf '%s' "$SERVER_TYPE"; return; fi
local t; t=$(printf '%s' "$TYPE_BY_LOCATION" | tr ',' '\n' | awk -F= -v l="$1" '$1 == l { print $2 }')
printf '%s' "${t:-cx23}"
}
# nodes.tsv accessors
require_nodes() { [ -s "$NODES_FILE" ] || die "no $NODES_FILE: run create.sh first"; }
node_count() { grep -c . "$NODES_FILE"; }
node_ip() { awk -F'\t' -v n="$1" '$1 == n || $2 == n { print $4; exit }' "$NODES_FILE"; }
node_region() { awk -F'\t' -v n="$1" '$1 == n || $2 == n { print $3; exit }' "$NODES_FILE"; }
node_names() { cut -f1 "$NODES_FILE"; }
nodes_in_region() { awk -F'\t' -v r="$1" '$3 == r { print $1 }' "$NODES_FILE"; }
regions_present() { cut -f3 "$NODES_FILE" | sort -u; }
# Outbound peers of node index i: the ring neighbour i+1 and chords at i + k*N/MESH_OUT (1-based, wrapping).
# With MESH_OUT=2 that is i+1 and i+N/2; inbound links double it, so each node sees about 4 peers.
peers_of() {
local i="$1" n; n=$(node_count)
local k step idx out=""
for k in $(seq 1 "$MESH_OUT"); do
if [ "$k" = 1 ]; then step=1; else step=$(( (k - 1) * n / MESH_OUT )); fi
idx=$(( (i - 1 + step) % n + 1 ))
[ "$idx" = "$i" ] && continue
out="$out $(node_ip "$idx"):$P2P_PORT"
done
printf '%s' "$out" | sed 's/^ //' | tr ' ' ','
}
SSH_OPTS=(-i "$SSH_KEY_FILE" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$HERE/build/known_hosts" -o ConnectTimeout=15 -o BatchMode=yes -o ServerAliveInterval=30)
nssh() { local ip="$1"; shift; ssh "${SSH_OPTS[@]}" "$SSH_USER@$ip" "$@"; }
nscp() { scp -q "${SSH_OPTS[@]}" "$@"; }
# Run a command on every node in parallel (PAR at a time), prefixing output with the node name.
# usage: on_all '<shell command>' (the command runs on the node through ssh)
on_all() {
local cmd="$1" par="${PAR:-10}"
mkdir -p "$BUILD_DIR"
node_names | xargs -P "$par" -I{} sh -c '
name="$1"; cmd="$2"; ip=$(awk -F"\t" -v n="$name" "\$1 == n { print \$4; exit }" "'"$NODES_FILE"'")
ssh -i "'"$SSH_KEY_FILE"'" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="'"$HERE/build/known_hosts"'" -o ConnectTimeout=15 -o BatchMode=yes "'"$SSH_USER"'@$ip" "$cmd" 2>&1 | sed "s/^/[$name] /"
' _ {} "$cmd"
}
# One RPC call on a node through the loopback wRPC JSON endpoint (node/wrpc.py is installed by provision.sh).
rpc() { local node="$1" method="$2" params="${3:-{\}}"; nssh "$(node_ip "$node")" "python3 /opt/igneum/bin/wrpc.py call $method '$params'"; }
results_dir_for() { local d="$RESULTS_DIR/${1:-$(date -u +%Y-%m-%d)}"; mkdir -p "$d"; printf '%s' "$d"; }
genesis_bits_decimal() { printf '%d' "$GENESIS_BITS"; }
confirm() {
if [ "${YES:-0}" = 1 ]; then return 0; fi
printf '%s [type yes]: ' "$1"; read -r a; [ "$a" = yes ] || die "not confirmed"
}