Files
spinifex/tests/e2e/run-reboot-e2e.sh
T
joshsiv-mulga d8128ec2f3 refactor(vpcd): remove macvlan bridge mode
Macvlan was a single-NIC SSH-safe hack that predated veth mode and was
never selected by any active config path. Deletion is Phase 0 item 1 of
the network layer redesign (mulga-siv-125.1).

Removed:
- BridgeModeMacvlan const + setMacvlanMAC + ifaceIsMacvlan probe
- Macvlan MAC alignment block in launchService (only fired on dead path)
- Macvlan branch in detectBridgeMode + matching tests
- Macvlan duplicates of IGW/AddNAT centralized-NAT tests (veth versions
  already cover the same path)
- --macvlan flag + macvlan case branch in setup-ovn.sh

Refactored:
- useCentralizedNAT() now == BridgeModeVeth (was != BridgeModeDirect);
  verifyBridgeMode already rejects empty/unknown at startup
- Three IGW-pool tests now explicitly WithBridgeMode(BridgeModeVeth)
  (previously relied on empty-mode defaulting to centralized)

Kept intentionally:
- Deprecation notes in docs/compute/vpc-networking/README.md
- Legacy macvlan-interface cleanup in reset-dev-env.sh + ansible
  teardown role (migration for operator systems with stale spx-ext-*
  interfaces from pre-veth installs)
2026-05-24 21:08:52 +10:00

624 lines
26 KiB
Bash
Executable File

#!/bin/bash
# run-reboot-e2e.sh — single-node host reboot resilience driver (cell #18).
#
# Runs from the GitHub Actions runner (NOT via ssh-and-run) so the script
# survives the systemctl-reboot-induced ssh disconnect mid-run. Each step that
# touches the cluster opens its own ssh session into the spinifex node.
#
# Required env:
# WAN_IP — single-node cluster's WAN IP
# SSH_KEY — path to ssh key the runner can use to reach tf-user@WAN_IP
#
# Sequence:
# 1. Pre-reboot: VPC + 2 app EC2s + internal ALB; verify round-robin.
# 2. Snapshot pre-reboot state (instance IPs, ALB ENI MAC, ovn-nbctl show).
# 3. systemctl reboot the host; poll TCP/22 from the runner until SSH returns.
# 4. Wait for spinifex services + AWS gateway to respond.
# 5. Post-reboot assertions (each a separate pass/fail line).
# 6. Cleanup via EXIT trap.
set -u
WAN_IP="${WAN_IP:?WAN_IP is required}"
SSH_KEY="${SSH_KEY:?SSH_KEY is required}"
SSH_KEY="${SSH_KEY/#\~/$HOME}"
REBOOT_WAIT_SECS="${REBOOT_WAIT_SECS:-300}"
DAEMON_READY_SECS="${DAEMON_READY_SECS:-180}"
INSTANCE_RUNNING_SECS="${INSTANCE_RUNNING_SECS:-120}"
LB_RECOVER_SECS="${LB_RECOVER_SECS:-180}"
LAUNCH_TIME_SECS="${LAUNCH_TIME_SECS:-30}"
NODE_KEY_PATH="\$HOME/reboot-e2e-key"
NODE_USERDATA_PATH="\$HOME/reboot-e2e-userdata.txt"
SSH_OPTS=(
-i "$SSH_KEY"
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o ConnectTimeout=10
-o LogLevel=ERROR
)
PASSED=0
FAILED=0
log() { echo "[$(date +%H:%M:%S)] $*"; }
pass() { echo " ✅ $1"; PASSED=$((PASSED + 1)); }
fail() { echo " ❌ $1"; FAILED=$((FAILED + 1)); }
node_ssh() { ssh "${SSH_OPTS[@]}" "tf-user@${WAN_IP}" "$@"; }
# Pipe a file's contents into a path on the node (tf-user-owned).
put_to_node() {
local dest="$1"
node_ssh "cat > $dest"
}
# Run an aws cli call on the node with the spinifex profile already configured.
node_aws_ec2() { node_ssh "AWS_PROFILE=spinifex aws ec2 $*"; }
node_aws_elbv2() { node_ssh "AWS_PROFILE=spinifex aws elbv2 $*"; }
# ==========================================================================
# Resource tracking for cleanup
# ==========================================================================
VPC_ID=""
SUBNET_ID=""
IGW_ID=""
APP_INSTANCE_IDS=()
ALB_LISTENER_ARN=""
ALB_LB_ARN=""
ALB_TG_ARN=""
E2E_SG_ID=""
cleanup() {
local exit_code=$?
echo ""
echo "=== Cleanup ==="
if ! node_ssh "true" 2>/dev/null; then
log "Cleanup skipped: node unreachable (exit=$exit_code)"
echo "Reboot E2E: $PASSED passed, $FAILED failed"
if [ $FAILED -gt 0 ] || [ $exit_code -ne 0 ]; then exit 1; fi
exit 0
fi
[ -n "$ALB_LISTENER_ARN" ] && node_aws_elbv2 "delete-listener --listener-arn $ALB_LISTENER_ARN" >/dev/null 2>&1 || true
[ -n "$ALB_LB_ARN" ] && node_aws_elbv2 "delete-load-balancer --load-balancer-arn $ALB_LB_ARN" >/dev/null 2>&1 || true
[ -n "$ALB_TG_ARN" ] && node_aws_elbv2 "delete-target-group --target-group-arn $ALB_TG_ARN" >/dev/null 2>&1 || true
# SG can't be deleted while ENIs reference it; delete after instances + ALB are gone (below).
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
[ -n "$inst_id" ] && node_aws_ec2 "terminate-instances --instance-ids $inst_id" >/dev/null 2>&1 || true
done
if [ ${#APP_INSTANCE_IDS[@]} -gt 0 ]; then
for attempt in $(seq 1 30); do
local all_terminated=true
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
[ -z "$inst_id" ] && continue
local state
state=$(node_aws_ec2 "describe-instances --instance-ids $inst_id --query 'Reservations[0].Instances[0].State.Name' --output text" 2>/dev/null || echo "terminated")
if [ "$state" != "terminated" ]; then all_terminated=false; break; fi
done
if [ "$all_terminated" = true ]; then break; fi
sleep 2
done
fi
node_aws_ec2 "delete-key-pair --key-name reboot-e2e-key" >/dev/null 2>&1 || true
node_ssh "rm -f $NODE_KEY_PATH $NODE_USERDATA_PATH" >/dev/null 2>&1 || true
# SG must come after instances + ALB ENIs are gone (it can't be deleted while referenced).
[ -n "$E2E_SG_ID" ] && node_aws_ec2 "delete-security-group --group-id $E2E_SG_ID" >/dev/null 2>&1 || true
if [ -n "$IGW_ID" ] && [ -n "$VPC_ID" ]; then
node_aws_ec2 "detach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID" >/dev/null 2>&1 || true
node_aws_ec2 "delete-internet-gateway --internet-gateway-id $IGW_ID" >/dev/null 2>&1 || true
fi
[ -n "$SUBNET_ID" ] && node_aws_ec2 "delete-subnet --subnet-id $SUBNET_ID" >/dev/null 2>&1 || true
[ -n "$VPC_ID" ] && node_aws_ec2 "delete-vpc --vpc-id $VPC_ID" >/dev/null 2>&1 || true
echo ""
echo "================================="
echo "Reboot E2E: $PASSED passed, $FAILED failed"
echo "================================="
if [ $FAILED -gt 0 ]; then exit 1; fi
exit $exit_code
}
trap cleanup EXIT
dump_diagnostics() {
# Window from reboot onwards so recovery failure logs aren't scrolled out
# by NATS warm-up / per-instance state-transition spam.
local mode label
if [ -n "${REBOOT_START:-}" ]; then
mode="--since=@${REBOOT_START}"
label="since reboot (@${REBOOT_START})"
else
mode="-n 200"
label="last 200 lines each"
fi
log "--- spinifex service journals (${label}) ---"
for svc in spinifex-nats spinifex-predastore spinifex-viperblock \
spinifex-daemon spinifex-awsgw spinifex-vpcd \
ovn-controller ovs-vswitchd; do
echo "=== ${svc} ==="
node_ssh "sudo journalctl -u ${svc} --no-pager ${mode} 2>/dev/null || true"
done
}
# ==========================================================================
# HTTP traffic burst from the spinifex node against an ALB URL.
# ==========================================================================
run_http_burst() {
local url="$1" label="$2" num="${3:-20}"
log "Sending $num HTTP requests to $url ($label)..."
local results
results=$(node_ssh "for i in \$(seq 1 $num); do curl -s --max-time 5 '$url/' 2>/dev/null; echo; done" 2>/dev/null || true)
declare -A counts=()
local total_ok=0
while IFS= read -r line; do
local inst
inst=$(echo "$line" | jq -r '.instance_id // empty' 2>/dev/null)
if [ -n "$inst" ]; then
counts[$inst]=$(( ${counts[$inst]:-0} + 1 ))
total_ok=$((total_ok + 1))
fi
done <<< "$results"
local unique=${#counts[@]}
echo " Distribution:"
for inst_id in "${!counts[@]}"; do echo " $inst_id: ${counts[$inst_id]} responses"; done
if [ "$unique" -ge 2 ]; then
pass "$label round-robin: $unique unique instances"
else
fail "$label round-robin: expected >=2 unique responders, got $unique"
fi
if [ "$total_ok" -ge $((num / 2)) ]; then
pass "$label success rate: $total_ok/$num"
else
fail "$label success rate: only $total_ok/$num"
fi
}
wait_for_lb_active() {
local lb_arn="$1" label="$2" timeout="${3:-270}"
log "Waiting for $label active (up to ${timeout}s)..."
local state=""
for attempt in $(seq 1 $((timeout / 3))); do
state=$(node_aws_elbv2 "describe-load-balancers --load-balancer-arns $lb_arn --query 'LoadBalancers[0].State.Code' --output text" 2>/dev/null || true)
if [ "$state" = "active" ]; then
pass "$label active"
return 0
fi
sleep 3
done
fail "$label did not reach active (last state: ${state:-unknown})"
return 1
}
wait_for_targets_healthy() {
local tg_arn="$1" expected="$2" label="$3" timeout="${4:-180}"
log "Waiting for $label targets healthy (up to ${timeout}s)..."
local healthy_count=0 total_count=0
for attempt in $(seq 1 $((timeout / 5))); do
local out
out=$(node_aws_elbv2 "describe-target-health --target-group-arn $tg_arn --output json" 2>/dev/null || echo '{}')
healthy_count=$(echo "$out" | jq '[.TargetHealthDescriptions[] | select(.TargetHealth.State == "healthy")] | length' 2>/dev/null || echo 0)
total_count=$(echo "$out" | jq '.TargetHealthDescriptions | length' 2>/dev/null || echo 0)
if [ "$healthy_count" -eq "$expected" ]; then
pass "$label: $expected/$expected targets healthy"
return 0
fi
sleep 5
done
fail "$label: only $healthy_count/$total_count healthy after ${timeout}s"
return 1
}
# ==========================================================================
# Phase 0: Prerequisites
# ==========================================================================
echo "================================="
echo "Reboot Resilience E2E (cell 18)"
echo "================================="
log "WAN_IP: $WAN_IP"
log "Verifying SSH to node..."
node_ssh "hostname" >/dev/null || { fail "ssh to $WAN_IP"; exit 1; }
pass "ssh to $WAN_IP"
log "Discovering instance type + AMI..."
INSTANCE_TYPE=$(node_aws_ec2 "describe-instance-types --query 'InstanceTypes[*].InstanceType' --output text" \
| tr ' \t' '\n\n' | grep -m1 'nano' || true)
if [ -z "$INSTANCE_TYPE" ]; then fail "no nano instance type available"; exit 1; fi
pass "instance type: $INSTANCE_TYPE"
ALL_IMAGES=$(node_aws_ec2 "describe-images --output json" 2>/dev/null)
AMI_ID=$(echo "$ALL_IMAGES" | jq -r '[.Images[] | select(.Name | test("^ami-ubuntu"))][0].ImageId // empty')
if [ -z "$AMI_ID" ]; then
AMI_ID=$(echo "$ALL_IMAGES" | jq -r '[.Images[] | select(.Name | test("alpine") | not)][0].ImageId // empty')
fi
if [ -z "$AMI_ID" ]; then AMI_ID=$(echo "$ALL_IMAGES" | jq -r '.Images[0].ImageId // empty'); fi
if [ -z "$AMI_ID" ]; then fail "no AMI found"; exit 1; fi
pass "AMI: $AMI_ID"
# Create key pair, capturing the private key material so we can ssh into guests
node_aws_ec2 "delete-key-pair --key-name reboot-e2e-key" >/dev/null 2>&1 || true
KEY_RESPONSE=$(node_aws_ec2 "create-key-pair --key-name reboot-e2e-key --output json" 2>/dev/null) \
|| { fail "create-key-pair"; exit 1; }
KEY_MATERIAL=$(echo "$KEY_RESPONSE" | jq -r '.KeyMaterial')
if [ -z "$KEY_MATERIAL" ] || [ "$KEY_MATERIAL" = "null" ]; then
fail "create-key-pair returned no KeyMaterial"; exit 1
fi
printf '%s' "$KEY_MATERIAL" | put_to_node "$NODE_KEY_PATH"
node_ssh "chmod 600 $NODE_KEY_PATH" || { fail "chmod key"; exit 1; }
pass "key pair: reboot-e2e-key"
# Stage user-data on the node (HTTP responder for round-robin verification).
# Installed as a systemd unit so the responder survives guest VM reboots —
# nohup-from-cloud-init only runs on first boot (cloud-init's per-instance
# semaphore) and would leave the app silent after host_reboot, even though
# OVN/HAProxy recover correctly.
APP_USER_DATA=$(cat <<'USERDATA'
#!/bin/bash
set -e
INSTANCE_ID=$(hostname)
install -d -m 0755 /var/lib/httpd
echo "{\"instance_id\": \"${INSTANCE_ID}\"}" > /var/lib/httpd/index.html
cat > /etc/systemd/system/reboot-e2e-httpd.service <<'UNIT'
[Unit]
Description=Reboot E2E HTTP responder
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/python3 -m http.server 80 --bind 0.0.0.0 --directory /var/lib/httpd
Restart=always
RestartSec=1
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now reboot-e2e-httpd.service
# Workaround for viperblock WAL <4MB skip-upload bug (mulga-siv-122):
# WriteWALToChunk(false) is a no-op when the WAL is under 4MB so small
# user-data writes (the unit file, index.html) sit in the on-host WAL
# and are lost when the host reboots before the WAL hits the threshold.
# Write 8MB of zeros + sync to push WAL over the threshold so the
# unit-file write definitely lands in predastore before the host reboot.
# Drop when upstream fix lands.
dd if=/dev/zero of=/var/lib/spinifex-e2e-pad bs=1M count=8 status=none
sync
rm -f /var/lib/spinifex-e2e-pad
sync
USERDATA
)
printf '%s' "$APP_USER_DATA" | put_to_node "$NODE_USERDATA_PATH"
# ==========================================================================
# Phase 1: VPC + Subnet
# ==========================================================================
log "Creating VPC..."
VPC_ID=$(node_aws_ec2 "create-vpc --cidr-block 10.210.0.0/16 --output json" | jq -r '.Vpc.VpcId')
if [ -z "$VPC_ID" ] || [ "$VPC_ID" = "null" ]; then fail "create-vpc"; exit 1; fi
pass "create-vpc: $VPC_ID"
IGW_ID=$(node_aws_ec2 "create-internet-gateway --output json" | jq -r '.InternetGateway.InternetGatewayId')
node_aws_ec2 "attach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID" >/dev/null \
|| { fail "attach-internet-gateway"; exit 1; }
pass "internet gateway: $IGW_ID (attached)"
SUBNET_ID=$(node_aws_ec2 "create-subnet --vpc-id $VPC_ID --cidr-block 10.210.1.0/24 --output json" | jq -r '.Subnet.SubnetId')
if [ -z "$SUBNET_ID" ] || [ "$SUBNET_ID" = "null" ]; then fail "create-subnet"; exit 1; fi
pass "create-subnet: $SUBNET_ID"
node_aws_ec2 "modify-subnet-attribute --subnet-id $SUBNET_ID --map-public-ip-on-launch" >/dev/null || true
log "Creating shared security group (port 80 inbound)..."
E2E_SG_ID=$(node_aws_ec2 "create-security-group \
--group-name reboot-e2e-sg \
--description 'Reboot E2E shared SG (ALB + app instances)' \
--vpc-id $VPC_ID \
--output json" | jq -r '.GroupId')
if [ -z "$E2E_SG_ID" ] || [ "$E2E_SG_ID" = "null" ]; then fail "create-security-group"; exit 1; fi
node_aws_ec2 "authorize-security-group-ingress \
--group-id $E2E_SG_ID \
--protocol tcp --port 80 --cidr 0.0.0.0/0" >/dev/null \
|| { fail "authorize-security-group-ingress port 80"; exit 1; }
pass "security group: $E2E_SG_ID"
# ==========================================================================
# Phase 2: Launch app EC2 instances
# ==========================================================================
log "Launching 2 app instances..."
for i in 1 2; do
INST=$(node_aws_ec2 "run-instances \
--image-id $AMI_ID \
--instance-type $INSTANCE_TYPE \
--key-name reboot-e2e-key \
--subnet-id $SUBNET_ID \
--security-group-ids $E2E_SG_ID \
--user-data file://$NODE_USERDATA_PATH \
--output json" | jq -r '.Instances[0].InstanceId')
if [ -z "$INST" ] || [ "$INST" = "null" ]; then fail "run-instances (app $i)"; exit 1; fi
APP_INSTANCE_IDS+=("$INST")
log " app$i: $INST"
done
pass "launched ${#APP_INSTANCE_IDS[@]} app instances"
log "Waiting for instances to reach running..."
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
for attempt in $(seq 1 60); do
STATE=$(node_aws_ec2 "describe-instances --instance-ids $inst_id --query 'Reservations[0].Instances[0].State.Name' --output text" 2>/dev/null)
if [ "$STATE" = "running" ]; then break; fi
if [ $attempt -eq 60 ]; then fail "instance $inst_id stuck in $STATE"; exit 1; fi
sleep 2
done
done
pass "all instances running"
declare -A PRE_INSTANCE_IPS
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
IP=$(node_aws_ec2 "describe-instances --instance-ids $inst_id --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text" 2>/dev/null)
if [ -z "$IP" ] || [ "$IP" = "None" ]; then fail "$inst_id has no PrivateIpAddress"; exit 1; fi
PRE_INSTANCE_IPS[$inst_id]="$IP"
log " $inst_id -> $IP"
done
pass "all instances have private IPs"
# ==========================================================================
# Phase 3: ALB (internet-facing)
# ==========================================================================
log "Creating HTTP target group..."
ALB_TG_ARN=$(node_aws_elbv2 "create-target-group \
--name reboot-e2e-tg \
--protocol HTTP --port 80 \
--vpc-id $VPC_ID \
--health-check-path /index.html \
--health-check-interval-seconds 5 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 2 \
--output json" | jq -r '.TargetGroups[0].TargetGroupArn')
if [ -z "$ALB_TG_ARN" ] || [ "$ALB_TG_ARN" = "null" ]; then fail "create-target-group"; exit 1; fi
pass "target group: $ALB_TG_ARN"
node_aws_elbv2 "register-targets --target-group-arn $ALB_TG_ARN \
--targets Id=${APP_INSTANCE_IDS[0]} Id=${APP_INSTANCE_IDS[1]}" >/dev/null \
|| { fail "register-targets"; exit 1; }
pass "registered 2 targets"
log "Creating internet-facing ALB..."
ALB_LB_ARN=$(node_aws_elbv2 "create-load-balancer \
--name reboot-e2e-alb \
--scheme internet-facing \
--subnets $SUBNET_ID \
--security-groups $E2E_SG_ID \
--output json" | jq -r '.LoadBalancers[0].LoadBalancerArn')
if [ -z "$ALB_LB_ARN" ] || [ "$ALB_LB_ARN" = "null" ]; then fail "create-load-balancer"; exit 1; fi
pass "ALB: $ALB_LB_ARN"
ALB_LB_ID=$(echo "$ALB_LB_ARN" | sed 's|.*/||')
ALB_LISTENER_ARN=$(node_aws_elbv2 "create-listener \
--load-balancer-arn $ALB_LB_ARN \
--protocol HTTP --port 80 \
--default-actions Type=forward,TargetGroupArn=$ALB_TG_ARN \
--output json" | jq -r '.Listeners[0].ListenerArn')
if [ -z "$ALB_LISTENER_ARN" ] || [ "$ALB_LISTENER_ARN" = "null" ]; then fail "create-listener"; exit 1; fi
pass "listener: $ALB_LISTENER_ARN"
wait_for_lb_active "$ALB_LB_ARN" "ALB" || { dump_diagnostics; exit 1; }
ENI_OUT=$(node_aws_ec2 "describe-network-interfaces \
--filters Name=description,Values='ELB app/reboot-e2e-alb/${ALB_LB_ID}' \
--output json" 2>/dev/null)
ALB_PUBLIC_IP=$(echo "$ENI_OUT" | jq -r '.NetworkInterfaces[0].Association.PublicIp // empty')
ALB_ENI_ID=$(echo "$ENI_OUT" | jq -r '.NetworkInterfaces[0].NetworkInterfaceId // empty')
ALB_ENI_MAC=$(echo "$ENI_OUT" | jq -r '.NetworkInterfaces[0].MacAddress // empty')
if [ -z "$ALB_PUBLIC_IP" ] || [ "$ALB_PUBLIC_IP" = "null" ]; then fail "ALB has no public IP"; exit 1; fi
pass "ALB ENI $ALB_ENI_ID public IP: $ALB_PUBLIC_IP"
wait_for_targets_healthy "$ALB_TG_ARN" 2 "ALB" || { dump_diagnostics; exit 1; }
# ==========================================================================
# Phase 4: Pre-reboot traffic burst
# ==========================================================================
echo ""
echo "=== Phase 4: Pre-reboot traffic ==="
run_http_burst "http://${ALB_PUBLIC_IP}:80" "ALB pre-reboot"
# ==========================================================================
# Phase 5: Snapshot pre-reboot state
# ==========================================================================
echo ""
echo "=== Phase 5: Snapshot pre-reboot state ==="
PRE_OVN=$(node_ssh "sudo ovn-nbctl show 2>/dev/null" || echo "")
PRE_OVN_LS_COUNT=$(echo "$PRE_OVN" | grep -c '^switch ' || true)
PRE_OVN_PORT_COUNT=$(echo "$PRE_OVN" | grep -c '^ port ' || true)
log "ovn-nbctl: ${PRE_OVN_LS_COUNT} logical switches, ${PRE_OVN_PORT_COUNT} ports"
log "ALB ENI MAC: ${ALB_ENI_MAC:-<unknown>}"
pass "pre-reboot state snapshot captured"
# ==========================================================================
# Phase 6: Reboot the host
# ==========================================================================
echo ""
echo "=== Phase 6: systemctl reboot ==="
REBOOT_START=$(date +%s)
log "Issuing reboot via ssh (connection will drop)..."
node_ssh "sudo systemctl reboot" >/dev/null 2>&1 || true
# Give the host a moment to actually start shutting down before we begin polling
sleep 5
log "Polling TCP/22 (timeout ${REBOOT_WAIT_SECS}s)..."
HOST_UP=false
for attempt in $(seq 1 $((REBOOT_WAIT_SECS / 5))); do
if timeout 5 bash -c "</dev/tcp/${WAN_IP}/22" 2>/dev/null; then
if node_ssh "true" 2>/dev/null; then
HOST_UP=true
break
fi
fi
sleep 5
done
REBOOT_ELAPSED=$(( $(date +%s) - REBOOT_START ))
if [ "$HOST_UP" = true ]; then
pass "host SSH back after ${REBOOT_ELAPSED}s"
else
fail "host SSH did not come back within ${REBOOT_WAIT_SECS}s"
exit 1
fi
# ==========================================================================
# Phase 7: Wait for spinifex services + AWS gateway readiness
# ==========================================================================
echo ""
echo "=== Phase 7: Wait for spinifex readiness ==="
log "Service status:"
for svc in spinifex-daemon spinifex-predastore spinifex-viperblock \
spinifex-nats spinifex-awsgw spinifex-vpcd \
ovn-controller ovs-vswitchd ovn-northd; do
state=$(node_ssh "systemctl is-active $svc" 2>/dev/null || echo "n/a")
log " $svc: $state"
done
log "Polling describe-instance-types (timeout ${DAEMON_READY_SECS}s)..."
DAEMON_OK=false
for attempt in $(seq 1 $((DAEMON_READY_SECS / 5))); do
if node_aws_ec2 "describe-instance-types --query 'InstanceTypes[0].InstanceType' --output text" >/dev/null 2>&1; then
DAEMON_OK=true
break
fi
sleep 5
done
if [ "$DAEMON_OK" = true ]; then
pass "AWS gateway responding post-reboot"
else
fail "AWS gateway did not respond within ${DAEMON_READY_SECS}s"
dump_diagnostics
exit 1
fi
# ==========================================================================
# Phase 8: Post-reboot assertions
# ==========================================================================
echo ""
echo "=== Phase 8: Post-reboot assertions ==="
# 8.1: instances back to running with same IDs
log "Polling instance state (timeout ${INSTANCE_RUNNING_SECS}s)..."
ALL_RUNNING=false
for attempt in $(seq 1 $((INSTANCE_RUNNING_SECS / 5))); do
all=true
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
STATE=$(node_aws_ec2 "describe-instances --instance-ids $inst_id --query 'Reservations[0].Instances[0].State.Name' --output text" 2>/dev/null || echo "missing")
if [ "$STATE" != "running" ]; then all=false; break; fi
done
if [ "$all" = true ]; then ALL_RUNNING=true; break; fi
sleep 5
done
if [ "$ALL_RUNNING" = true ]; then
pass "all instances back to running"
else
fail "instances did not all reach running within ${INSTANCE_RUNNING_SECS}s"
fi
# 8.2: private IPs preserved (IPAM persistence)
log "Verifying private IP persistence..."
IPS_PRESERVED=true
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
POST_IP=$(node_aws_ec2 "describe-instances --instance-ids $inst_id --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text" 2>/dev/null)
if [ "$POST_IP" != "${PRE_INSTANCE_IPS[$inst_id]}" ]; then
fail "$inst_id IP drift: pre=${PRE_INSTANCE_IPS[$inst_id]} post=$POST_IP"
IPS_PRESERVED=false
fi
done
if [ "$IPS_PRESERVED" = true ]; then
pass "private IPs preserved across reboot"
fi
# 8.3: guest VMs relaunched post-reboot (LaunchTime > reboot time via describe-instances)
# Two-hop SSH to VPC private IPs is not possible in single-node mode —
# the host has no route to VPC subnets. LaunchTime from the daemon's recovery
# path (reset to time.Now() on relaunch) is a reliable reboot signal.
log "Verifying guest VMs relaunched post-reboot (LaunchTime check)..."
GUESTS_RELAUNCHED=true
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
LAUNCH_TS=$(node_aws_ec2 "describe-instances --instance-ids $inst_id \
--query 'Reservations[0].Instances[0].LaunchTime' \
--output text" 2>/dev/null || echo "")
if [ -z "$LAUNCH_TS" ] || [ "$LAUNCH_TS" = "None" ]; then
fail "$inst_id: could not retrieve LaunchTime"
GUESTS_RELAUNCHED=false
continue
fi
LAUNCH_EPOCH=$(date -d "$LAUNCH_TS" +%s 2>/dev/null || echo "0")
if [ "$LAUNCH_EPOCH" -ge "$REBOOT_START" ]; then
log " $inst_id: LaunchTime=${LAUNCH_TS} ✓"
else
fail "$inst_id: LaunchTime=${LAUNCH_TS} predates reboot — VM not relaunched"
GUESTS_RELAUNCHED=false
fi
done
if [ "$GUESTS_RELAUNCHED" = true ]; then
pass "all instances relaunched post-reboot"
fi
# 8.4: ALB back to active
wait_for_lb_active "$ALB_LB_ARN" "ALB post-reboot" "$LB_RECOVER_SECS" || true
# 8.5: targets healthy again
wait_for_targets_healthy "$ALB_TG_ARN" 2 "ALB post-reboot" "$LB_RECOVER_SECS" || true
# 8.6: wait for the ALB to *actually* serve traffic before the burst.
# `wait_for_targets_healthy` reads cached state from spinifex's TG store —
# post-reboot the cache still shows pre-reboot "healthy" until the lb-agent
# inside the ALB system VM sends a fresh report. Without this wait, the burst
# fires while the VM is still booting (HAProxy not yet bound to :80), the
# guest kernel sends RST for every SYN, and the test sees 0/20 even though
# OVN is fine — see histogram diagnosis on run 25899285589.
log "Waiting for ALB to actually serve HTTP (up to 90s)..."
ALB_READY=0
for attempt in $(seq 1 45); do
BODY=$(node_ssh "curl -s --connect-timeout 2 --max-time 3 'http://${ALB_PUBLIC_IP}:80/' 2>/dev/null" || true)
if echo "$BODY" | jq -e '.instance_id' >/dev/null 2>&1; then
log " ALB serving HTTP at attempt ${attempt}"
ALB_READY=1
break
fi
sleep 2
done
if [ "$ALB_READY" -ne 1 ]; then
fail "ALB did not begin serving HTTP within 90s post-reboot"
dump_diagnostics
fi
log "Re-running traffic burst against same ALB..."
PRE_BURST_FAILED=$FAILED
run_http_burst "http://${ALB_PUBLIC_IP}:80" "ALB post-reboot"
if [ "$FAILED" -gt "$PRE_BURST_FAILED" ]; then
log "Post-reboot traffic failed — dumping journals from reboot onwards before cleanup overwrites them."
dump_diagnostics
fi
# 8.7: ovn-nbctl diff (this is where the known persistence bug lands)
log "Diffing ovn-nbctl show against pre-reboot snapshot..."
POST_OVN=$(node_ssh "sudo ovn-nbctl show 2>/dev/null" || echo "")
POST_OVN_LS_COUNT=$(echo "$POST_OVN" | grep -c '^switch ' || true)
POST_OVN_PORT_COUNT=$(echo "$POST_OVN" | grep -c '^ port ' || true)
log "ovn-nbctl post: ${POST_OVN_LS_COUNT} logical switches, ${POST_OVN_PORT_COUNT} ports (pre: ${PRE_OVN_LS_COUNT} / ${PRE_OVN_PORT_COUNT})"
if [ "$POST_OVN_LS_COUNT" -eq "$PRE_OVN_LS_COUNT" ] && [ "$POST_OVN_PORT_COUNT" -eq "$PRE_OVN_PORT_COUNT" ]; then
pass "ovn-nbctl logical-switch + port counts match pre-reboot"
else
fail "ovn-nbctl drift: switches ${PRE_OVN_LS_COUNT}->${POST_OVN_LS_COUNT}, ports ${PRE_OVN_PORT_COUNT}->${POST_OVN_PORT_COUNT}"
fi
# Cleanup runs via the EXIT trap.