Files
spinifex/tests/e2e/lib/multinode-helpers.sh
T
2026-04-15 16:55:55 +10:00

918 lines
28 KiB
Bash
Executable File

#!/bin/bash
# Multi-node E2E test helper functions
# Provides utilities for managing simulated IPs, starting/stopping node services,
# and verifying NATS cluster health.
# Network configuration
SIMULATED_NETWORK="10.11.12"
NODE1_IP="${SIMULATED_NETWORK}.1"
NODE2_IP="${SIMULATED_NETWORK}.2"
NODE3_IP="${SIMULATED_NETWORK}.3"
# Port configuration
NATS_CLIENT_PORT=4222
NATS_CLUSTER_PORT=4248
NATS_MONITOR_PORT=8222
PREDASTORE_PORT=8443
AWSGW_PORT=9999
CLUSTER_PORT=4432
# Add simulated IPs to loopback interface
# Requires NET_ADMIN capability
add_simulated_ips() {
echo "Adding simulated IPs to loopback interface..."
for i in 1 2 3; do
local ip="${SIMULATED_NETWORK}.$i"
if ! ip addr show lo | grep -q "$ip"; then
sudo ip addr add "${ip}/24" dev lo
echo " Added $ip to lo"
else
echo " $ip already exists on lo"
fi
done
# Verify IPs were added
echo "Verifying simulated IPs..."
for i in 1 2 3; do
local ip="${SIMULATED_NETWORK}.$i"
if ip addr show lo | grep -q "$ip"; then
echo " $ip is configured"
else
echo " ERROR: Failed to add $ip"
return 1
fi
done
echo "Simulated IPs configured successfully"
}
# Remove simulated IPs from loopback interface
remove_simulated_ips() {
echo "Removing simulated IPs from loopback interface..."
for i in 1 2 3; do
local ip="${SIMULATED_NETWORK}.$i"
if ip addr show lo | grep -q "$ip"; then
sudo ip addr del "${ip}/24" dev lo 2>/dev/null || true
echo " Removed $ip from lo"
fi
done
echo "Simulated IPs removed"
}
# Start services for a specific node
# Usage: start_node_services <node_num> <data_dir>
# Example: start_node_services 1 ~/node1
start_node_services() {
local node_num="$1"
local data_dir="$2"
local node_ip="${SIMULATED_NETWORK}.$node_num"
echo "Starting services for node$node_num at $node_ip..."
# Start all services - each node's config binds to its specific IP
# UI is not needed for E2E tests and fails in pseudo multi-node (wrong cert path)
UI=false ./scripts/start-dev.sh "$data_dir"
echo "Node$node_num services started"
}
# Stop services for a specific node
# Usage: stop_node_services <node_num> <data_dir>
stop_node_services() {
local node_num="$1"
local data_dir="$2"
echo "Stopping services for node$node_num..."
# Stop using PID files in the node's log directory
./scripts/stop-dev.sh "$data_dir"
echo "Node$node_num services stopped"
}
# Stop all node services
stop_all_nodes() {
echo "Stopping all node services..."
for i in 1 2 3; do
local data_dir="$HOME/node$i"
if [ -d "$data_dir/logs" ]; then
stop_node_services "$i" "$data_dir" || true
fi
done
echo "All node services stopped"
}
# Verify NATS cluster health
# Checks that the cluster has expected number of members
# Usage: verify_nats_cluster [expected_members]
verify_nats_cluster() {
local expected_members="${1:-3}"
echo "Verifying NATS cluster health (expecting $expected_members members)..."
# Check cluster info via monitoring endpoint on node1
local cluster_info
cluster_info=$(curl -s "http://${NODE1_IP}:${NATS_MONITOR_PORT}/routez" 2>/dev/null) || {
echo " ERROR: Cannot reach NATS monitoring endpoint"
return 1
}
# Count unique remote servers (NATS creates multiple connections per peer)
local num_routes
num_routes=$(echo "$cluster_info" | jq -r '.num_routes // 0')
# Count unique peer names
local unique_peers
unique_peers=$(echo "$cluster_info" | jq -r '[.routes[].remote_name] | unique | length')
local expected_peers=$((expected_members - 1))
echo " NATS cluster routes: $num_routes (unique peers: $unique_peers, expected peers: $expected_peers)"
if [ "$unique_peers" -ge "$expected_peers" ]; then
echo " NATS cluster is healthy"
return 0
else
echo " WARNING: NATS cluster may not be fully formed"
echo " Cluster info: $cluster_info"
return 1
fi
}
# Wait for a specific instance state
# Usage: wait_for_instance_state <instance_id> <target_state> [max_attempts]
wait_for_instance_state() {
local instance_id="$1"
local target_state="$2"
local max_attempts="${3:-20}"
local attempt=0
echo "Waiting for instance $instance_id to reach state: $target_state..."
while [ $attempt -lt $max_attempts ]; do
local state
state=$(aws --endpoint-url https://${NODE1_IP}:${AWSGW_PORT} ec2 describe-instances \
--instance-ids "$instance_id" \
--query 'Reservations[0].Instances[0].State.Name' \
--output text) || {
echo " Attempt $((attempt + 1))/$max_attempts - Gateway request failed, retrying..."
sleep 1
attempt=$((attempt + 1))
continue
}
echo " Instance state: $state"
if [ "$state" == "$target_state" ]; then
echo " Instance reached target state: $target_state"
return 0
fi
if [ "$state" == "terminated" ] && [ "$target_state" != "terminated" ]; then
echo " ERROR: Instance terminated unexpectedly"
return 1
fi
sleep 1
attempt=$((attempt + 1))
done
echo " ERROR: Instance did not reach $target_state within $max_attempts attempts"
return 1
}
# Terminate instances and wait for them to reach terminated state
# Usage: terminate_and_wait <instance_id> [instance_id...]
terminate_and_wait() {
local ids=("$@")
for instance_id in "${ids[@]}"; do
local state
state=$($AWS_EC2 describe-instances --instance-ids "$instance_id" \
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "unknown")
if [ "$state" != "terminated" ] && [ "$state" != "unknown" ]; then
echo " Terminating $instance_id (state: $state)..."
$AWS_EC2 terminate-instances --instance-ids "$instance_id" > /dev/null 2>&1 || true
fi
done
local failed=0
for instance_id in "${ids[@]}"; do
if ! wait_for_instance_state "$instance_id" "terminated" 60; then
echo " WARNING: Failed to confirm termination of $instance_id"
failed=1
fi
done
return $failed
}
# Wait for gateway to be ready
# Usage: wait_for_gateway [host] [max_attempts]
wait_for_gateway() {
local host="${1:-localhost}"
local max_attempts="${2:-30}"
local attempt=0
echo "Waiting for AWS Gateway at $host:${AWSGW_PORT}..."
while [ $attempt -lt $max_attempts ]; do
if curl -k -s "https://${host}:${AWSGW_PORT}" > /dev/null 2>&1; then
echo " Gateway is ready"
return 0
fi
echo " Waiting for gateway... ($((attempt + 1))/$max_attempts)"
sleep 1
attempt=$((attempt + 1))
done
echo " ERROR: Gateway failed to start"
return 1
}
# Wait for daemon NATS subscriptions to be active.
# Polls describe-instance-types until it returns a non-empty result.
# Usage: wait_for_daemon_ready <gateway_endpoint> [max_attempts]
wait_for_daemon_ready() {
local endpoint="$1"
local max_attempts="${2:-30}"
local attempt=0
echo "Waiting for daemon readiness (NATS subscriptions)..."
while [ $attempt -lt $max_attempts ]; do
local types
types=$(aws --endpoint-url "$endpoint" ec2 describe-instance-types \
--query 'InstanceTypes[*].InstanceType' --output text 2>/dev/null || true)
if [ -n "$types" ] && [ "$types" != "None" ]; then
echo " Daemon is ready"
return 0
fi
echo " Waiting... ($((attempt + 1))/$max_attempts)"
sleep 1
attempt=$((attempt + 1))
done
echo " ERROR: Daemon not ready after $max_attempts attempts"
return 1
}
# Check instance distribution across nodes
# Counts QEMU instances per node by matching nbdkit host= to simulated node IPs
# Usage: check_instance_distribution [expected_nodes]
# If expected_nodes is provided, fails when instances don't span that many nodes
check_instance_distribution() {
local expected_nodes="${1:-0}"
echo "Checking instance distribution across nodes..."
local total=0
local nodes_used=0
for i in 1 2 3; do
local node_ip="${SIMULATED_NETWORK}.$i"
# Count nbdkit root-volume processes (not cloudinit) bound to this node's predastore
local count
count=$(ps auxw | grep nbdkit | grep -v grep | grep -v cloudinit | grep -c "host=${node_ip}:" 2>/dev/null) || count=0
echo " Node$i ($node_ip): $count instances"
total=$((total + count))
if [ "$count" -gt 0 ]; then
nodes_used=$((nodes_used + 1))
fi
done
echo " Total instances: $total (across $nodes_used nodes)"
if [ "$expected_nodes" -gt 0 ] && [ "$nodes_used" -lt "$expected_nodes" ]; then
echo " FAIL: Expected instances on $expected_nodes nodes, only used $nodes_used"
return 1
fi
}
# Find the SSH port for an instance from the QEMU process
# Usage: get_ssh_port <instance_id>
# Returns: the SSH port number, or empty string if not found
get_ssh_port() {
local instance_id="$1"
local max_attempts="${2:-30}"
local attempt=0
while [ $attempt -lt $max_attempts ]; do
local qemu_cmd
qemu_cmd=$(ps auxw | grep "$instance_id" | grep qemu-system | grep -v grep || true)
if [ -n "$qemu_cmd" ]; then
# Extract port from hostfwd=tcp:127.0.0.1:PORT-:22 or hostfwd=tcp::PORT-:22
local ssh_port
ssh_port=$(echo "$qemu_cmd" | sed -n 's/.*hostfwd=tcp:[^:]*:\([0-9]*\)-:22.*/\1/p')
if [ -n "$ssh_port" ]; then
echo "$ssh_port"
return 0
fi
fi
attempt=$((attempt + 1))
if [ $attempt -lt $max_attempts ]; then
sleep 1
fi
done
return 1
}
# Find the SSH host IP for an instance from the QEMU process hostfwd setting
# Usage: get_ssh_host <instance_id>
# Returns: the SSH host IP (e.g., 10.11.12.1, 127.0.0.1), or "127.0.0.1" if not found/empty
get_ssh_host() {
local instance_id="$1"
local qemu_cmd
qemu_cmd=$(ps auxw | grep "$instance_id" | grep qemu-system | grep -v grep || true)
if [ -n "$qemu_cmd" ]; then
# Extract host from hostfwd=tcp:HOST:PORT-:22
# Pattern matches: hostfwd=tcp:IP:PORT-:22 where IP can be empty
local ssh_host
ssh_host=$(echo "$qemu_cmd" | sed -n 's/.*hostfwd=tcp:\([^:]*\):[0-9]*-:22.*/\1/p')
if [ -n "$ssh_host" ]; then
echo "$ssh_host"
return 0
fi
fi
# Default to localhost if not found
echo "127.0.0.1"
return 0
}
# Find which node an instance is running on (for pseudo multi-node setups)
# Matches the QEMU process's root nbd socket to its nbdkit process's host IP
# Usage: get_instance_node <instance_id>
# Returns: node number (1, 2, or 3), or empty if not found
get_instance_node() {
local instance_id="$1"
# Find the QEMU process and extract the root nbd socket path
local qemu_cmd
qemu_cmd=$(ps auxw | grep "$instance_id" | grep qemu-system | grep -v grep || true)
if [ -z "$qemu_cmd" ]; then
return 1
fi
# Extract first nbd socket from drive args (root volume, not cloudinit)
local nbd_sock
nbd_sock=$(echo "$qemu_cmd" | grep -o 'nbd:unix:[^ ,]*' | head -1 | sed 's/nbd:unix://')
if [ -z "$nbd_sock" ]; then
return 1
fi
# Find the nbdkit process serving this socket and extract its host=IP
local nbdkit_cmd
nbdkit_cmd=$(ps auxw | grep nbdkit | grep -v grep | grep "$nbd_sock" || true)
if [ -z "$nbdkit_cmd" ]; then
return 1
fi
# Extract host=10.11.12.X from nbdkit args and map to node number
local host_ip
host_ip=$(echo "$nbdkit_cmd" | grep -o "host=${SIMULATED_NETWORK}\.[0-9]*" | sed "s/host=${SIMULATED_NETWORK}\.//" | head -1)
if [ -n "$host_ip" ]; then
echo "$host_ip"
return 0
fi
return 1
}
# Wait for SSH to be ready on an instance
# Usage: wait_for_ssh <host> <port> <key_file> [max_attempts]
# Returns: 0 if SSH is ready, 1 if timeout
wait_for_ssh() {
local host="$1"
local port="$2"
local key_file="$3"
local max_attempts="${4:-30}"
local attempt=0
echo " Waiting for SSH to be ready on $host:$port..."
while [ $attempt -lt $max_attempts ]; do
# Add host key to known_hosts (suppress errors)
ssh-keyscan -p "$port" "$host" >> ~/.ssh/known_hosts 2>/dev/null || true
# Try to connect with short timeout
if ssh -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=2 \
-o BatchMode=yes \
-p "$port" \
-i "$key_file" \
ec2-user@"$host" 'echo ready' > /dev/null 2>&1; then
echo " SSH is ready"
return 0
fi
attempt=$((attempt + 1))
if [ $attempt -lt $max_attempts ]; then
if [ $((attempt % 10)) -eq 0 ]; then
echo " Waiting for SSH... ($attempt/$max_attempts)"
fi
sleep 1
fi
done
echo " ERROR: SSH not ready after $max_attempts attempts"
return 1
}
# Test SSH connectivity by running 'id' command and verifying ec2-user in output
# Usage: test_ssh_connectivity <host> <port> <key_file>
# Returns: 0 if successful and ec2-user found, 1 otherwise
test_ssh_connectivity() {
local host="$1"
local port="$2"
local key_file="$3"
echo " Testing SSH connectivity (running 'id' command)..."
local id_output
id_output=$(ssh -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 \
-o BatchMode=yes \
-p "$port" \
-i "$key_file" \
ec2-user@"$host" 'id' 2>&1) || {
echo " ERROR: SSH command failed"
echo " Output: $id_output"
return 1
}
echo " SSH 'id' output: $id_output"
if echo "$id_output" | grep -q "ec2-user"; then
echo " SSH connectivity test passed (ec2-user confirmed)"
return 0
else
echo " ERROR: Expected 'ec2-user' in id output"
return 1
fi
}
# Verify SSH is NOT reachable (used after instance termination)
# Usage: verify_ssh_unreachable <host> <port> <key_file>
# Returns: 0 if SSH is unreachable (expected), 1 if SSH is still reachable (unexpected)
verify_ssh_unreachable() {
local host="$1"
local port="$2"
local key_file="$3"
echo " Verifying SSH is no longer reachable..."
# Try to connect with 1 second timeout - should fail
if ssh -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=1 \
-o BatchMode=yes \
-p "$port" \
-i "$key_file" \
ec2-user@"$host" 'echo connected' > /dev/null 2>&1; then
echo " ERROR: SSH is still reachable after termination"
return 1
else
echo " SSH is no longer reachable (as expected)"
return 0
fi
}
# Expect an AWS CLI command to fail with a specific error code
# Usage: expect_error "ErrorCode" aws ec2 some-command --args...
# Returns: 0 if the command fails with the expected error, 1 otherwise
expect_error() {
local expected_error="$1"
shift
set +e
local output
output=$("$@" 2>&1)
local exit_code=$?
set -e
if [ $exit_code -eq 0 ]; then
echo " FAIL: Expected error '$expected_error' but command succeeded"
echo " Output: $output"
return 1
fi
if echo "$output" | grep -q "$expected_error"; then
echo " Got expected error: $expected_error"
return 0
else
echo " FAIL: Expected error '$expected_error' but got different error"
echo " Output: $output"
return 1
fi
}
# Dump logs from all nodes (for debugging failures)
dump_all_node_logs() {
echo ""
echo "=========================================="
echo "DUMPING LOGS FROM ALL NODES"
echo "=========================================="
# Show running spx/nats processes for context
echo ""
echo "--- Running processes ---"
ps auxw | grep -E 'spx|nats-server' | grep -v grep || echo "(none)"
for i in 1 2 3; do
local data_dir="$HOME/node$i"
local logs_dir="$data_dir/logs"
echo ""
echo "=== Node$i ==="
# Show directory structure
echo "--- $data_dir contents ---"
sudo ls -la "$data_dir/" 2>/dev/null || echo "(dir not found)"
if sudo test -d "$logs_dir"; then
echo "--- $logs_dir contents ---"
sudo ls -la "$logs_dir/" 2>/dev/null || true
for log in nats predastore viperblock spinifex awsgw; do
if sudo test -f "$logs_dir/$log.log"; then
echo ""
echo "--- $log.log (last 50 lines) ---"
sudo tail -50 "$logs_dir/$log.log" 2>/dev/null || echo "(empty or not accessible)"
fi
done
else
echo "--- $logs_dir not found, checking PID files ---"
sudo find "$data_dir" -name '*.pid' -o -name '*.log' 2>/dev/null || true
fi
done
echo ""
echo "=========================================="
echo "END OF LOG DUMP"
echo "=========================================="
}
# Get the QEMU process PID for an instance
# Usage: get_qemu_pid <instance_id>
# Returns: the PID, or empty string if not found
get_qemu_pid() {
local instance_id="$1"
local pid
pid=$(ps auxw | grep "$instance_id" | grep qemu-system | grep -v grep | awk '{print $2}' | head -1)
if [ -n "$pid" ]; then
echo "$pid"
return 0
fi
return 1
}
# Wait for an instance to recover from a crash (error → running)
# Usage: wait_for_instance_recovery <instance_id> [max_attempts]
# Expects state transition: error → pending → running
wait_for_instance_recovery() {
local instance_id="$1"
local max_attempts="${2:-60}"
local attempt=0
local saw_error=false
echo " Waiting for instance $instance_id to recover..."
while [ $attempt -lt $max_attempts ]; do
local state
state=$(aws ec2 describe-instances \
--instance-ids "$instance_id" \
--query 'Reservations[0].Instances[0].State.Name' \
--output text 2>/dev/null) || {
sleep 1
attempt=$((attempt + 1))
continue
}
if [ "$state" == "error" ] || [ "$state" == "pending" ]; then
saw_error=true
echo " State: $state (attempt $((attempt + 1))/$max_attempts)"
fi
if [ "$state" == "running" ] && [ "$saw_error" = true ]; then
echo " Instance recovered to running state"
return 0
fi
if [ "$state" == "running" ] && [ "$saw_error" = false ]; then
# Still in original running state, crash not detected yet
:
fi
sleep 1
attempt=$((attempt + 1))
done
echo " ERROR: Instance did not recover within $max_attempts attempts"
return 1
}
# Verify all services are down on all nodes
# Returns 0 if everything is down, 1 if something is still running
verify_all_services_down() {
local all_down=true
for i in 1 2 3; do
local node_ip="${SIMULATED_NETWORK}.$i"
# Check gateway
if curl -k -s --connect-timeout 2 "https://${node_ip}:${AWSGW_PORT}" > /dev/null 2>&1; then
echo " Node$i: gateway still responding"
all_down=false
fi
# Check NATS
if curl -s --connect-timeout 2 "http://${node_ip}:${NATS_MONITOR_PORT}" > /dev/null 2>&1; then
echo " Node$i: NATS still responding"
all_down=false
fi
done
# Check for any remaining QEMU processes
if pgrep -x qemu-system-x86_64 > /dev/null 2>&1; then
echo " QEMU processes still running"
all_down=false
fi
if [ "$all_down" = true ]; then
echo " All services confirmed down"
return 0
fi
return 1
}
# Force-kill all service processes and clean up stale resources on all nodes.
# Used between shutdown and restart to ensure a clean slate.
# This kills processes by PID file, then by name, removes badger LOCK files,
# and waits for ports to be free.
force_cleanup_all_nodes() {
echo "Force-cleaning all nodes..."
# Step 1: Kill all service processes via PID files
for i in 1 2 3; do
local data_dir="$HOME/node$i"
local logs_dir="$data_dir/logs"
if [ -d "$logs_dir" ]; then
for svc in spinifex-ui spinifex awsgw viperblock predastore nats; do
local pidfile="$logs_dir/$svc.pid"
if [ -f "$pidfile" ]; then
local pid
pid=$(cat "$pidfile" 2>/dev/null || true)
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo " Node$i: killing $svc (PID $pid)..."
kill -TERM "$pid" 2>/dev/null || true
fi
fi
done
fi
done
# Brief wait for graceful shutdown
sleep 3
# Step 2: SIGKILL anything still alive
for i in 1 2 3; do
local data_dir="$HOME/node$i"
local logs_dir="$data_dir/logs"
if [ -d "$logs_dir" ]; then
for svc in spinifex-ui spinifex awsgw viperblock predastore nats; do
local pidfile="$logs_dir/$svc.pid"
if [ -f "$pidfile" ]; then
local pid
pid=$(cat "$pidfile" 2>/dev/null || true)
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo " Node$i: force-killing $svc (PID $pid)..."
kill -9 "$pid" 2>/dev/null || true
fi
fi
done
fi
done
# Kill any remaining QEMU processes
pkill -9 -x qemu-system-x86_64 2>/dev/null || true
sleep 1
# Step 3: Remove stale badger LOCK files from predastore directories
for i in 1 2 3; do
local data_dir="$HOME/node$i"
local predastore_dir="$data_dir/predastore"
if [ -d "$predastore_dir" ]; then
local lock_files
lock_files=$(find "$predastore_dir" -name "LOCK" -type f 2>/dev/null || true)
if [ -n "$lock_files" ]; then
echo " Node$i: removing stale badger LOCK files..."
echo "$lock_files" | while read -r f; do
rm -f "$f"
echo " removed $f"
done
fi
fi
done
# Step 4: Wait for key ports to be free
for i in 1 2 3; do
local node_ip="${SIMULATED_NETWORK}.$i"
local attempt=0
while [ $attempt -lt 10 ]; do
if ! ss -tlnp 2>/dev/null | grep -q "${node_ip}:${AWSGW_PORT}"; then
break
fi
echo " Node$i: waiting for port ${AWSGW_PORT} to be free..."
sleep 1
attempt=$((attempt + 1))
done
done
echo " Force cleanup complete"
}
# Global variable for init PID tracking (used by multi-node formation)
LEADER_INIT_PID=""
# Initialize leader node (node1)
# In multi-node mode (default --nodes 3), this backgrounds the init process
# because the formation server blocks waiting for joins.
# Usage: init_leader_node
init_leader_node() {
echo "Initializing leader node (node1)..."
# Remove old node directory
rm -rf "$HOME/node1/"
# External pool flags (optional — enables public subnet IP allocation)
local external_flags=""
if [ -n "${SPX_EXTERNAL_POOL:-}" ]; then
external_flags="--external-mode=pool --external-pool=${SPX_EXTERNAL_POOL}"
echo " External pool: ${SPX_EXTERNAL_POOL}"
fi
# Start init in background — formation server will wait for joins
# shellcheck disable=SC2086
./bin/spx admin init \
--node node1 \
--bind "${NODE1_IP}" \
--cluster-bind "${NODE1_IP}" \
--cluster-routes "${NODE1_IP}:${NATS_CLUSTER_PORT}" \
--predastore-nodes "${NODE1_IP},${NODE2_IP},${NODE3_IP}" \
--port ${CLUSTER_PORT} \
--region ap-southeast-2 \
--az ap-southeast-2a \
--spinifex-dir "$HOME/node1/" \
--config-dir "$HOME/node1/config/" \
$external_flags &
LEADER_INIT_PID=$!
# Wait for formation server to be ready
echo "Waiting for formation server..."
for i in $(seq 1 60); do
if curl -sk "https://${NODE1_IP}:${CLUSTER_PORT}/formation/health" > /dev/null 2>&1; then
echo " Formation server is ready (PID: $LEADER_INIT_PID)"
# Read join token written by admin init
JOIN_TOKEN=$(cat "$HOME/node1/config/join-token")
if [ -z "$JOIN_TOKEN" ]; then
echo " ERROR: Join token file is empty"
return 1
fi
echo " Join token loaded"
export JOIN_TOKEN
return 0
fi
sleep 1
done
echo " ERROR: Formation server failed to start"
return 1
}
# Join a follower node to the cluster
# Usage: join_follower_node <node_num>
join_follower_node() {
local node_num="$1"
local node_ip="${SIMULATED_NETWORK}.$node_num"
local data_dir="$HOME/node$node_num"
echo "Joining node$node_num ($node_ip) to cluster..."
# Remove old node directory
rm -rf "$data_dir/"
# Route to node1 (seed node) - other nodes discovered via NATS gossip
./bin/spx admin join \
--node "node$node_num" \
--bind "$node_ip" \
--cluster-bind "$node_ip" \
--cluster-routes "${NODE1_IP}:${NATS_CLUSTER_PORT}" \
--host "${NODE1_IP}:${CLUSTER_PORT}" \
--token "$JOIN_TOKEN" \
--data-dir "$data_dir/" \
--config-dir "$data_dir/config/" \
--region ap-southeast-2 \
--az "ap-southeast-2a"
echo "Node$node_num joined cluster"
}
# Verify Predastore cluster health
# Checks that Predastore is reachable on all node IPs
# Usage: verify_predastore_cluster [expected_nodes]
verify_predastore_cluster() {
local expected_nodes="${1:-3}"
local healthy=0
echo "Verifying Predastore cluster health (expecting $expected_nodes nodes)..."
for i in $(seq 1 "$expected_nodes"); do
local node_ip="${SIMULATED_NETWORK}.$i"
if curl -k -s "https://${node_ip}:${PREDASTORE_PORT}" > /dev/null 2>&1; then
echo " Node$i ($node_ip:${PREDASTORE_PORT}): reachable"
healthy=$((healthy + 1))
else
echo " Node$i ($node_ip:${PREDASTORE_PORT}): NOT reachable"
fi
done
echo " Healthy Predastore nodes: $healthy/$expected_nodes"
if [ "$healthy" -ge "$expected_nodes" ]; then
echo " Predastore cluster is healthy"
return 0
else
echo " WARNING: Predastore cluster may not be fully formed"
return 1
fi
}
# Count instances per node for given instance IDs
# Usage: count_instances_per_node INSTANCE_ID [INSTANCE_ID...]
# Sets NODE1_COUNT, NODE2_COUNT, NODE3_COUNT
count_instances_per_node() {
local ids=("$@")
NODE1_COUNT=0; NODE2_COUNT=0; NODE3_COUNT=0
for id in "${ids[@]}"; do
local node
node=$(get_instance_node "$id") || continue
eval "NODE${node}_COUNT=\$((NODE${node}_COUNT + 1))"
done
for i in 1 2 3; do
local node_ip="${SIMULATED_NETWORK}.$i"
eval "local count=\$NODE${i}_COUNT"
echo " Node$i ($node_ip): $count instances"
done
}
# Verify Placement field in DescribeInstances response
# Usage: verify_placement GROUP_NAME INSTANCE_ID [INSTANCE_ID...]
verify_placement() {
local group_name="$1"; shift
local ids=("$@")
for id in "${ids[@]}"; do
local placement_group
placement_group=$($AWS_EC2 describe-instances --instance-ids "$id" \
--query 'Reservations[0].Instances[0].Placement.GroupName' --output text)
if [ "$placement_group" != "$group_name" ]; then
echo " FAIL: Instance $id Placement.GroupName='$placement_group', expected '$group_name'"
return 1
fi
local az
az=$($AWS_EC2 describe-instances --instance-ids "$id" \
--query 'Reservations[0].Instances[0].Placement.AvailabilityZone' --output text)
if [ -z "$az" ] || [ "$az" = "None" ]; then
echo " FAIL: Instance $id missing Placement.AvailabilityZone"
return 1
fi
done
echo " All instances show Placement.GroupName=$group_name"
}