#!/bin/bash # run-e2e.sh — Single-node E2E test suite (production binary install). # # Assumes services are already running via systemd (bootstrap-install.sh). # Uses production paths: /etc/spinifex, /var/log/spinifex, spx from PATH. set -e # Run from the directory containing this script (~/e2e-tests/) cd "$(dirname "$0")" # Source helper functions source ./lib/multinode-helpers.sh # Dump journald logs on failure cleanup() { EXIT_CODE=$? if [ $EXIT_CODE -ne 0 ]; then for svc in spinifex-nats spinifex-predastore spinifex-viperblock \ spinifex-daemon spinifex-awsgw spinifex-vpcd; do echo "=== ${svc} ===" sudo journalctl -u "${svc}" --no-pager -n 200 2>/dev/null || true done fi exit $EXIT_CODE } trap cleanup EXIT # Use Spinifex profile (spx admin init sets endpoint_url, ca_bundle, region in ~/.aws/config) export AWS_PROFILE=spinifex # Resolve gateway host from config GATEWAY_HOST="localhost" ENDPOINT_URL=$(aws configure get endpoint_url 2>/dev/null || true) if [ -n "$ENDPOINT_URL" ]; then DETECTED_GW_HOST=$(echo "$ENDPOINT_URL" | sed 's|https\?://||;s|:.*||') if [ -n "$DETECTED_GW_HOST" ]; then GATEWAY_HOST="$DETECTED_GW_HOST" fi fi # Helper: set up an AWS CLI profile with credentials + endpoint/CA config from the spinifex profile setup_test_profile() { local profile="$1" key_id="$2" secret="$3" aws configure set aws_access_key_id "$key_id" --profile "$profile" aws configure set aws_secret_access_key "$secret" --profile "$profile" aws configure set region "$(aws configure get region)" --profile "$profile" aws configure set endpoint_url "$(aws configure get endpoint_url)" --profile "$profile" aws configure set ca_bundle "$(aws configure get ca_bundle)" --profile "$profile" } # Phase 1: Environment Setup echo "Phase 1: Environment Setup" # Check for KVM support echo "Checking for KVM support..." if [ -e /dev/kvm ]; then echo "✅ /dev/kvm exists" if [ -w /dev/kvm ]; then echo "✅ /dev/kvm is writable" else echo "❌ /dev/kvm is NOT writable. QEMU will fail." exit 1 fi else echo "❌ /dev/kvm does NOT exist." exit 1 fi # Wait for health checks on AWS Gateway echo "Waiting for AWS Gateway..." MAX_RETRIES=30 COUNT=0 until curl -sk "https://${GATEWAY_HOST}:9999" > /dev/null || [ $COUNT -eq $MAX_RETRIES ]; do echo "Waiting for gateway... ($COUNT/$MAX_RETRIES)" sleep 1 COUNT=$((COUNT + 1)) done if [ $COUNT -eq $MAX_RETRIES ]; then echo "Gateway failed to start" exit 1 fi # Wait for daemon NATS subscriptions to be active wait_for_daemon_ready "https://${GATEWAY_HOST}:9999" # Discover the cluster's availability zone and region dynamically SPINIFEX_AZ=$(aws ec2 describe-availability-zones --query 'AvailabilityZones[0].ZoneName' --output text) SPINIFEX_REGION=$(aws ec2 describe-availability-zones --query 'AvailabilityZones[0].RegionName' --output text) echo "Discovered AZ: $SPINIFEX_AZ, Region: $SPINIFEX_REGION" # No need for AWS_EC2/AWS_IAM vars — endpoint_url and ca_bundle are in the profile config # Phase 1b: Cluster Stats CLI echo "Phase 1b: Cluster Stats CLI" # Test spx get nodes — should show node1 as Ready echo "Testing spx get nodes..." GET_NODES_OUTPUT=$(spx get nodes --timeout 5s 2>/dev/null) echo "$GET_NODES_OUTPUT" if ! echo "$GET_NODES_OUTPUT" | grep -q "Ready"; then echo "spx get nodes did not show any Ready nodes" exit 1 fi echo "spx get nodes passed" # Test spx top nodes — should show CPU/MEM stats echo "Testing spx top nodes..." TOP_NODES_OUTPUT=$(spx top nodes --timeout 5s 2>/dev/null) echo "$TOP_NODES_OUTPUT" if ! echo "$TOP_NODES_OUTPUT" | grep -q "0/"; then echo "spx top nodes did not show resource stats" exit 1 fi echo "spx top nodes passed" # Test spx get vms — should show no VMs yet echo "Testing spx get vms (empty)..." GET_VMS_OUTPUT=$(spx get vms --timeout 5s 2>/dev/null) echo "$GET_VMS_OUTPUT" if ! echo "$GET_VMS_OUTPUT" | grep -q "No VMs found"; then echo "spx get vms should show 'No VMs found' before any launches" exit 1 fi echo "spx get vms (empty) passed" # Phase 2: Discovery & Metadata echo "Phase 2: Discovery & Metadata" # Verify describe-regions (just ensure it returns at least one region) aws ec2 describe-regions | jq -e '.Regions | length > 0' # Verify describe-availability-zones echo "Verifying describe-availability-zones..." AZ_OUTPUT=$(aws ec2 describe-availability-zones) echo "$AZ_OUTPUT" | jq -e '.AvailabilityZones | length > 0' AZ_NAME=$(echo "$AZ_OUTPUT" | jq -r '.AvailabilityZones[0].ZoneName') AZ_STATE=$(echo "$AZ_OUTPUT" | jq -r '.AvailabilityZones[0].State') if [ "$AZ_STATE" != "available" ]; then echo "Expected AZ state 'available', got '$AZ_STATE'" exit 1 fi echo "DescribeAvailabilityZones verified (Zone=$AZ_NAME, State=$AZ_STATE)" # Discover available instance types from Spinifex # Spinifex generates these based on the host CPU (e.g., m7i.micro, m8g.small, etc.) echo "Discovering available instance types..." AVAILABLE_TYPES=$(aws ec2 describe-instance-types --query 'InstanceTypes[*].InstanceType' --output text) echo "Available instance types: $AVAILABLE_TYPES" # Pick the nano instance type for minimal resource usage in tests INSTANCE_TYPE=$(echo $AVAILABLE_TYPES | tr ' ' '\n' | grep -m1 'nano') if [ -z "$INSTANCE_TYPE" ] || [ "$INSTANCE_TYPE" == "None" ]; then echo "No instance types found!" exit 1 fi echo "Selected instance type for test: $INSTANCE_TYPE" # Get architecture for the selected instance type ARCH=$(aws ec2 describe-instance-types --instance-types "$INSTANCE_TYPE" --query 'InstanceTypes[0].ProcessorInfo.SupportedArchitectures[0]' --output text) echo "Detected architecture for $INSTANCE_TYPE: $ARCH" # Verify describe-instance-types (ensure the chosen type is available) aws ec2 describe-instance-types | jq -e ".InstanceTypes[] | select(.InstanceType==\"$INSTANCE_TYPE\")" # Phase 2b: Serial Console Access Settings echo "Phase 2b: Serial Console Access Settings" # Default should be disabled SERIAL_DEFAULT=$(aws ec2 get-serial-console-access-status --query 'SerialConsoleAccessEnabled' --output text) if [ "$SERIAL_DEFAULT" != "False" ]; then echo "Expected serial console access default to be False, got $SERIAL_DEFAULT" exit 1 fi echo " Default state: disabled" # Enable ENABLE_RESULT=$(aws ec2 enable-serial-console-access --query 'SerialConsoleAccessEnabled' --output text) if [ "$ENABLE_RESULT" != "True" ]; then echo "Expected enable to return True, got $ENABLE_RESULT" exit 1 fi SERIAL_ENABLED=$(aws ec2 get-serial-console-access-status --query 'SerialConsoleAccessEnabled' --output text) if [ "$SERIAL_ENABLED" != "True" ]; then echo "Expected serial console access to be True after enable, got $SERIAL_ENABLED" exit 1 fi echo " Enabled: $SERIAL_ENABLED" # Disable DISABLE_RESULT=$(aws ec2 disable-serial-console-access --query 'SerialConsoleAccessEnabled' --output text) if [ "$DISABLE_RESULT" != "False" ]; then echo "Expected disable to return False, got $DISABLE_RESULT" exit 1 fi SERIAL_DISABLED=$(aws ec2 get-serial-console-access-status --query 'SerialConsoleAccessEnabled' --output text) if [ "$SERIAL_DISABLED" != "False" ]; then echo "Expected serial console access to be False after disable, got $SERIAL_DISABLED" exit 1 fi echo " Disabled: $SERIAL_DISABLED" echo "Serial console access settings tests passed" # Phase 3: SSH Key Management echo "Phase 3: SSH Key Management" # Create test-key-1 (create-key-pair) and verify private key material is returned KEY_MATERIAL=$(aws ec2 create-key-pair --key-name test-key-1 --query 'KeyMaterial' --output text) if [ -z "$KEY_MATERIAL" ] || [ "$KEY_MATERIAL" == "None" ]; then echo "Failed to create key pair test-key-1" exit 1 fi echo "$KEY_MATERIAL" > test-key-1.pem chmod 600 test-key-1.pem # Generate a local RSA key and import it as test-key-2 (import-key-pair) ssh-keygen -t rsa -b 2048 -f test-key-2-local -N "" aws ec2 import-key-pair --key-name test-key-2 --public-key-material "fileb://test-key-2-local.pub" # Verify both keys are present (describe-key-pairs) aws ec2 describe-key-pairs --query 'KeyPairs[*].KeyName' --output text | grep test-key-1 aws ec2 describe-key-pairs --query 'KeyPairs[*].KeyName' --output text | grep test-key-2 # Delete test-key-2 (delete-key-pair) and verify only one remains aws ec2 delete-key-pair --key-name test-key-2 REMAINING_KEYS=$(aws ec2 describe-key-pairs --query 'KeyPairs[*].KeyName' --output text) echo "Remaining keys: $REMAINING_KEYS" echo "$REMAINING_KEYS" | grep test-key-1 if echo "$REMAINING_KEYS" | grep -q test-key-2; then echo "test-key-2 was not deleted" exit 1 fi # Phase 4: Image Management echo "Phase 4: Image Management" # Discover whichever Ubuntu AMI bootstrap-install.sh staged. v6+ gold image # stages 26.04 (resolute); v3 staged 24.04 (noble). Catalog name follows # `ami-ubuntu--`. if [ "$ARCH" = "x86_64" ]; then ARCH_LABEL="x86_64" else ARCH_LABEL="arm64" fi echo "Looking up Ubuntu AMI for arch=${ARCH_LABEL}..." AMI_ID=$(aws ec2 describe-images \ --filters "Name=name,Values=ami-ubuntu-26.04-${ARCH_LABEL},ami-ubuntu-24.04-${ARCH_LABEL}" \ --query 'Images[0].ImageId' --output text) IMAGE_NAME=$(aws ec2 describe-images \ --filters "Name=name,Values=ami-ubuntu-26.04-${ARCH_LABEL},ami-ubuntu-24.04-${ARCH_LABEL}" \ --query 'Images[0].Name' --output text | sed 's/^ami-//') echo "Using image: $IMAGE_NAME" if [ -z "$AMI_ID" ] || [ "$AMI_ID" = "None" ]; then echo "Failed to capture AMI ID — bootstrap-install.sh did not stage ubuntu-{24,26}.04-${ARCH_LABEL}" exit 1 fi echo "Captured AMI ID: $AMI_ID" # Verify the AMI exists using its ID (describe-images) echo "Verifying AMI availability..." aws ec2 describe-images --image-ids "$AMI_ID" | jq -e ".Images[0] | select(.ImageId==\"$AMI_ID\")" # Phase 5: Instance Lifecycle echo "Phase 5: Instance Lifecycle" # Authorize SSH on the default VPC's default SG before any run-instances. AWS # default SGs allow ingress only from members of the same SG; subsequent SSH # probes (Phase 5a-ii, 5a-iv, 7, 8c, 8d) come from the test runner's IP and # would be dropped by the OVN port-group ACL otherwise. DEFAULT_VPC_PHASE5=$(aws ec2 describe-vpcs \ --query 'Vpcs[?IsDefault==`true`].VpcId | [0]' --output text) DEFAULT_SG_PHASE5=$(aws ec2 describe-security-groups \ --filters "Name=vpc-id,Values=$DEFAULT_VPC_PHASE5" "Name=group-name,Values=default" \ --query 'SecurityGroups[0].GroupId' --output text) echo "Default VPC: $DEFAULT_VPC_PHASE5; default SG: $DEFAULT_SG_PHASE5" # Tolerate InvalidPermission.Duplicate on re-runs (idempotent). set +e AUTH_OUTPUT=$(aws ec2 authorize-security-group-ingress \ --group-id "$DEFAULT_SG_PHASE5" \ --protocol tcp --port 22 --cidr 0.0.0.0/0 2>&1) AUTH_EXIT=$? set -e if [ $AUTH_EXIT -ne 0 ] && ! echo "$AUTH_OUTPUT" | grep -q 'InvalidPermission.Duplicate'; then echo "Failed to authorize SSH ingress on default SG: $AUTH_OUTPUT" exit 1 fi echo " Default SG ingress: tcp/22 from 0.0.0.0/0" # Phase 5f exercises ICMP egress through OVN; the reply path needs explicit # ICMP ingress on the default SG even though SGs are stateful in AWS — keeps # parity with the Go single-suite fixture. set +e ICMP_OUTPUT=$(aws ec2 authorize-security-group-ingress \ --group-id "$DEFAULT_SG_PHASE5" \ --protocol icmp --port -1 --cidr 0.0.0.0/0 2>&1) ICMP_EXIT=$? set -e if [ $ICMP_EXIT -ne 0 ] && ! echo "$ICMP_OUTPUT" | grep -q 'InvalidPermission.Duplicate'; then echo "Failed to authorize ICMP ingress on default SG: $ICMP_OUTPUT" exit 1 fi echo " Default SG ingress: icmp from 0.0.0.0/0" # Launch a VM (run-instances) echo "Running: aws ec2 run-instances --image-id $AMI_ID --instance-type $INSTANCE_TYPE --key-name test-key-1" # Capture full output for debugging set +e # Temporarily disable exit on error to capture output RUN_OUTPUT=$(aws ec2 run-instances --image-id "$AMI_ID" --instance-type "$INSTANCE_TYPE" --key-name test-key-1 2>&1) RUN_EXIT_CODE=$? set -e # Re-enable exit on error echo "Run instances exit code: $RUN_EXIT_CODE" echo "Run instances output: $RUN_OUTPUT" if [ $RUN_EXIT_CODE -ne 0 ]; then echo "❌ Failed to launch instance - AWS CLI returned error" exit 1 fi INSTANCE_ID=$(echo "$RUN_OUTPUT" | jq -r '.Instances[0].InstanceId') if [ -z "$INSTANCE_ID" ] || [ "$INSTANCE_ID" == "None" ] || [ "$INSTANCE_ID" == "null" ]; then echo "Failed to launch instance - no InstanceId in response" exit 1 fi echo "Launched Instance ID: $INSTANCE_ID" # Poll until state is running (describe-instances) echo "Polling for instance running state..." COUNT=0 STATE="unknown" while [ $COUNT -lt 60 ]; do # Capture full output to check if instance even exists in the response DESCRIBE_OUTPUT=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID") || { echo "⚠️ Gateway request failed, retrying... ($COUNT/60)" sleep 1 COUNT=$((COUNT + 1)) continue } if [ -z "$DESCRIBE_OUTPUT" ]; then echo "⚠️ Gateway returned empty response, retrying..." sleep 1 COUNT=$((COUNT + 1)) continue fi # Extract state using jq STATE=$(echo "$DESCRIBE_OUTPUT" | jq -r '.Reservations[0].Instances[0].State.Name // "not-found"') echo "Instance state: $STATE" if [ "$STATE" == "running" ]; then break fi if [ "$STATE" == "terminated" ]; then echo "❌ Instance terminated unexpectedly!" exit 1 fi sleep 1 COUNT=$((COUNT + 1)) done if [ "$STATE" != "running" ]; then echo "Instance failed to reach running state" exit 1 fi # Phase 5a-pre: Verify spx get vms shows running instance echo "Phase 5a-pre: Cluster Stats CLI (with running VM)" GET_VMS_OUTPUT=$(spx get vms --timeout 5s 2>/dev/null) echo "$GET_VMS_OUTPUT" if ! echo "$GET_VMS_OUTPUT" | grep -q "$INSTANCE_ID"; then echo "spx get vms did not show running instance $INSTANCE_ID" exit 1 fi echo "spx get vms shows running instance" # Phase 5a: Validate instance metadata fields echo "Phase 5a: Instance Metadata Validation" DESCRIBE_META=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID") META_TYPE=$(echo "$DESCRIBE_META" | jq -r '.Reservations[0].Instances[0].InstanceType') META_KEY=$(echo "$DESCRIBE_META" | jq -r '.Reservations[0].Instances[0].KeyName') META_IMAGE=$(echo "$DESCRIBE_META" | jq -r '.Reservations[0].Instances[0].ImageId') META_BDM=$(echo "$DESCRIBE_META" | jq -r '.Reservations[0].Instances[0].BlockDeviceMappings | length') if [ "$META_TYPE" != "$INSTANCE_TYPE" ]; then echo "InstanceType mismatch: expected $INSTANCE_TYPE, got $META_TYPE" exit 1 fi if [ "$META_KEY" != "test-key-1" ]; then echo "KeyName mismatch: expected test-key-1, got $META_KEY" exit 1 fi if [ "$META_IMAGE" != "$AMI_ID" ]; then echo "ImageId mismatch: expected $AMI_ID, got $META_IMAGE" exit 1 fi if [ "$META_BDM" -lt 1 ]; then echo "Expected at least 1 BlockDeviceMapping, got $META_BDM" exit 1 fi echo "Instance metadata validated (Type=$META_TYPE, Key=$META_KEY, Image=$META_IMAGE, BDMs=$META_BDM)" # Phase 5a-ii: SSH Connectivity & Volume Verification echo "Phase 5a-ii: SSH Connectivity & Volume Verification" # Determine SSH connection method: # - If instance has a public IP (external networking enabled), SSH via public IP on port 22 # - Otherwise, fall back to QEMU hostfwd (dev_networking mode) INST_PUBLIC_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \ --query 'Reservations[0].Instances[0].PublicIpAddress' --output text 2>/dev/null || echo "None") if [ -n "$INST_PUBLIC_IP" ] && [ "$INST_PUBLIC_IP" != "None" ] && [ "$INST_PUBLIC_IP" != "null" ]; then SSH_HOST="$INST_PUBLIC_IP" SSH_PORT=22 echo "SSH via public IP: $SSH_HOST:$SSH_PORT" else echo "No public IP — using QEMU hostfwd for SSH" # set -e would abort on $() returning non-zero, hiding the diagnostic below if ! SSH_PORT=$(get_ssh_port "$INSTANCE_ID"); then SSH_PORT="" fi if [ -z "$SSH_PORT" ]; then echo "Failed to get SSH port for instance $INSTANCE_ID" exit 1 fi SSH_HOST=$(get_ssh_host "$INSTANCE_ID") echo "SSH endpoint: $SSH_HOST:$SSH_PORT" fi # Wait for SSH to become ready (VM boot + cloud-init) wait_for_ssh "$SSH_HOST" "$SSH_PORT" "test-key-1.pem" 60 # Test basic SSH connectivity test_ssh_connectivity "$SSH_HOST" "$SSH_PORT" "test-key-1.pem" # Check root volume size via lsblk echo "Verifying root volume size from inside the VM..." ROOT_VOL_SIZE_API=$(aws ec2 describe-volumes --query 'Volumes[0].Size' --output text) # Find the disk backing the root filesystem (avoids picking up floppy/cdrom devices) # 1. findmnt gets the source device for / (e.g. /dev/vda1) # 2. lsblk PKNAME resolves to parent disk name (e.g. vda) # 3. lsblk -b -d gets that disk's byte size ROOT_DISK_BYTES=$(ssh -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ -o LogLevel=ERROR \ -o ConnectTimeout=5 \ -o BatchMode=yes \ -p "$SSH_PORT" \ -i "test-key-1.pem" \ ec2-user@"$SSH_HOST" 'SRC=$(findmnt -n -o SOURCE /); PKN=$(lsblk -n -o PKNAME "$SRC" 2>/dev/null | head -1); DEV=${PKN:-$(basename "$SRC")}; lsblk -b -d -n -o SIZE "/dev/$DEV"' | tr -d '[:space:]') if [ -z "$ROOT_DISK_BYTES" ] || [ "$ROOT_DISK_BYTES" = "0" ]; then echo "Failed to get root disk size from VM (got: '$ROOT_DISK_BYTES')" echo "lsblk debug output:" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \ -o ConnectTimeout=5 -o BatchMode=yes -p "$SSH_PORT" -i "test-key-1.pem" \ ec2-user@"$SSH_HOST" 'lsblk -b -d; echo "---"; findmnt -n -o SOURCE /; cat /proc/partitions' || true exit 1 fi ROOT_DISK_GIB=$((ROOT_DISK_BYTES / 1073741824)) echo "Root disk size from VM: ${ROOT_DISK_GIB}GiB (API reports: ${ROOT_VOL_SIZE_API}GiB)" if [ "$ROOT_DISK_GIB" -ne "$ROOT_VOL_SIZE_API" ]; then echo "Root volume size mismatch: VM reports ${ROOT_DISK_GIB}GiB, API reports ${ROOT_VOL_SIZE_API}GiB" exit 1 fi echo "Root volume size verified" # Verify hostname contains instance ID echo "Verifying hostname inside the VM..." VM_HOSTNAME=$(ssh -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ -o ConnectTimeout=5 \ -o BatchMode=yes \ -p "$SSH_PORT" \ -i "test-key-1.pem" \ ec2-user@"$SSH_HOST" 'hostname' 2>/dev/null) echo "VM hostname: $VM_HOSTNAME" # Hostname uses truncated ID: spinifex-vm- SHORT_ID=$(echo "$INSTANCE_ID" | sed 's/^i-//' | cut -c1-8) if echo "$VM_HOSTNAME" | grep -q "$SHORT_ID"; then echo "Hostname contains instance ID prefix ($SHORT_ID)" else echo "WARNING: Hostname '$VM_HOSTNAME' does not contain instance ID prefix '$SHORT_ID' (non-fatal)" fi echo "SSH connectivity and volume verification passed" # Phase 5a-iii: Console Output echo "Phase 5a-iii: Console Output" CONSOLE_OUTPUT=$(aws ec2 get-console-output --instance-id "$INSTANCE_ID") CONSOLE_INSTANCE=$(echo "$CONSOLE_OUTPUT" | jq -r '.InstanceId') CONSOLE_DATA=$(echo "$CONSOLE_OUTPUT" | jq -r '.Output // empty') if [ "$CONSOLE_INSTANCE" != "$INSTANCE_ID" ]; then echo "GetConsoleOutput InstanceId mismatch: expected $INSTANCE_ID, got $CONSOLE_INSTANCE" exit 1 fi echo " GetConsoleOutput succeeded (InstanceId=$CONSOLE_INSTANCE, has output=$([ -n "$CONSOLE_DATA" ] && echo yes || echo no))" echo "Console output tests passed" # Verify root volume attached to the instance (describe-volumes) VOLUME_ID=$(aws ec2 describe-volumes --query 'Volumes[0].VolumeId' --output text) if [ -z "$VOLUME_ID" ] || [ "$VOLUME_ID" == "None" ]; then echo "Failed to find volume for instance $INSTANCE_ID" exit 1 fi echo "Volume ID: $VOLUME_ID" # Phase 5b: Volume Lifecycle (Attach/Detach) echo "Phase 5b: Volume Lifecycle (Attach/Detach)" echo "Testing volume create -> resize -> attach -> detach -> delete..." # Create a test volume echo "Creating 10GB volume in ${SPINIFEX_AZ}..." CREATE_OUTPUT=$(aws ec2 create-volume --size 10 --availability-zone "$SPINIFEX_AZ") TEST_VOLUME_ID=$(echo "$CREATE_OUTPUT" | jq -r '.VolumeId') if [ -z "$TEST_VOLUME_ID" ] || [ "$TEST_VOLUME_ID" == "null" ]; then echo "Failed to create test volume" echo "Output: $CREATE_OUTPUT" exit 1 fi echo "Created volume: $TEST_VOLUME_ID" # Resize to 20GB NEW_SIZE=20 echo "Modifying volume to ${NEW_SIZE}GB..." aws ec2 modify-volume --volume-id "$TEST_VOLUME_ID" --size "$NEW_SIZE" # Verify resize echo "Verifying resize..." COUNT=0 while [ $COUNT -lt 60 ]; do VOLUME_SIZE=$(aws ec2 describe-volumes --volume-ids "$TEST_VOLUME_ID" \ --query 'Volumes[0].Size' --output text) if [ "$VOLUME_SIZE" -eq "$NEW_SIZE" ]; then echo "Volume resized successfully to ${NEW_SIZE}GB" break fi sleep 1 COUNT=$((COUNT + 1)) done if [ "$VOLUME_SIZE" -ne "$NEW_SIZE" ]; then echo "Volume failed to resize to ${NEW_SIZE}GB (current: ${VOLUME_SIZE}GB)" exit 1 fi # Attach volume to the running instance echo "Attaching volume $TEST_VOLUME_ID to instance $INSTANCE_ID..." aws ec2 attach-volume --volume-id "$TEST_VOLUME_ID" --instance-id "$INSTANCE_ID" --device /dev/sdf # Verify attachment echo "Verifying volume attachment..." COUNT=0 while [ $COUNT -lt 60 ]; do ATTACH_STATE=$(aws ec2 describe-volumes --volume-ids "$TEST_VOLUME_ID" \ --query 'Volumes[0].Attachments[0].State' --output text) ATTACH_INSTANCE=$(aws ec2 describe-volumes --volume-ids "$TEST_VOLUME_ID" \ --query 'Volumes[0].Attachments[0].InstanceId' --output text) VOL_STATE=$(aws ec2 describe-volumes --volume-ids "$TEST_VOLUME_ID" \ --query 'Volumes[0].State' --output text) if [ "$VOL_STATE" == "in-use" ] && [ "$ATTACH_STATE" == "attached" ] && [ "$ATTACH_INSTANCE" == "$INSTANCE_ID" ]; then echo "Volume attached successfully (State=$VOL_STATE, AttachState=$ATTACH_STATE, Instance=$ATTACH_INSTANCE)" break fi sleep 1 COUNT=$((COUNT + 1)) done if [ "$ATTACH_STATE" != "attached" ] || [ "$ATTACH_INSTANCE" != "$INSTANCE_ID" ]; then echo "Volume attachment verification failed (AttachState=$ATTACH_STATE, Instance=$ATTACH_INSTANCE)" exit 1 fi # Detach volume (without --instance-id to test gateway resolution path) echo "Detaching volume $TEST_VOLUME_ID..." aws ec2 detach-volume --volume-id "$TEST_VOLUME_ID" # Verify detachment echo "Verifying volume detachment..." COUNT=0 while [ $COUNT -lt 60 ]; do VOL_STATE=$(aws ec2 describe-volumes --volume-ids "$TEST_VOLUME_ID" \ --query 'Volumes[0].State' --output text) if [ "$VOL_STATE" == "available" ]; then echo "Volume detached successfully (State=$VOL_STATE)" break fi sleep 1 COUNT=$((COUNT + 1)) done if [ "$VOL_STATE" != "available" ]; then echo "Volume detachment verification failed (State=$VOL_STATE)" exit 1 fi # Delete the test volume echo "Deleting test volume $TEST_VOLUME_ID..." aws ec2 delete-volume --volume-id "$TEST_VOLUME_ID" # Verify deletion echo "Verifying volume deletion..." COUNT=0 while [ $COUNT -lt 60 ]; do set +e VOLUME_CHECK=$(aws ec2 describe-volumes --volume-ids "$TEST_VOLUME_ID" \ --query 'Volumes[0].VolumeId' --output text 2>&1) DESCRIBE_EXIT=$? set -e if [ $DESCRIBE_EXIT -ne 0 ] || [ "$VOLUME_CHECK" == "None" ] || [ -z "$VOLUME_CHECK" ]; then echo "Volume deleted successfully" break fi sleep 1 COUNT=$((COUNT + 1)) done if [ $COUNT -ge 60 ]; then echo "Volume deletion verification timed out" exit 1 fi echo "Volume lifecycle test passed (create -> resize -> attach -> detach -> delete)" # Phase 5b-ii: DescribeVolumeStatus echo "Phase 5b-ii: DescribeVolumeStatus" echo "Testing describe-volume-status on root volume..." VOL_STATUS_OUTPUT=$(aws ec2 describe-volume-status --volume-ids "$VOLUME_ID") VOL_STATUS_ID=$(echo "$VOL_STATUS_OUTPUT" | jq -r '.VolumeStatuses[0].VolumeId') VOL_STATUS_STATE=$(echo "$VOL_STATUS_OUTPUT" | jq -r '.VolumeStatuses[0].VolumeStatus.Status') if [ "$VOL_STATUS_ID" != "$VOLUME_ID" ]; then echo "DescribeVolumeStatus VolumeId mismatch: expected $VOLUME_ID, got $VOL_STATUS_ID" exit 1 fi echo "DescribeVolumeStatus verified (VolumeId=$VOL_STATUS_ID, Status=$VOL_STATUS_STATE)" # Phase 5c: Snapshot Lifecycle echo "Phase 5c: Snapshot Lifecycle" echo "Testing snapshot create -> describe -> copy -> delete..." # Use the root volume from Phase 5 — it's already attached and mounted in # viperblockd, which is required for create-snapshot (the ebs.snapshot handler # needs a live VB instance to flush). echo "Using root volume $VOLUME_ID (already attached to $INSTANCE_ID)" ROOT_VOL_SIZE=$(aws ec2 describe-volumes --volume-ids "$VOLUME_ID" \ --query 'Volumes[0].Size' --output text) # Create a snapshot from the attached root volume echo "Creating snapshot from volume $VOLUME_ID..." SNAP_OUTPUT=$(aws ec2 create-snapshot --volume-id "$VOLUME_ID" --description "e2e-test-snapshot") SNAPSHOT_ID=$(echo "$SNAP_OUTPUT" | jq -r '.SnapshotId') if [ -z "$SNAPSHOT_ID" ] || [ "$SNAPSHOT_ID" == "null" ]; then echo "Failed to create snapshot" echo "Output: $SNAP_OUTPUT" exit 1 fi echo "Created snapshot: $SNAPSHOT_ID" # Verify snapshot fields from create response SNAP_STATE=$(echo "$SNAP_OUTPUT" | jq -r '.State') SNAP_VOL_REF=$(echo "$SNAP_OUTPUT" | jq -r '.VolumeId') SNAP_SIZE=$(echo "$SNAP_OUTPUT" | jq -r '.VolumeSize') SNAP_PROGRESS=$(echo "$SNAP_OUTPUT" | jq -r '.Progress') if [ "$SNAP_VOL_REF" != "$VOLUME_ID" ]; then echo "Snapshot VolumeId mismatch: expected $VOLUME_ID, got $SNAP_VOL_REF" exit 1 fi if [ "$SNAP_SIZE" -ne "$ROOT_VOL_SIZE" ]; then echo "Snapshot VolumeSize mismatch: expected $ROOT_VOL_SIZE, got $SNAP_SIZE" exit 1 fi echo "Snapshot create response verified (State=$SNAP_STATE, VolumeId=$SNAP_VOL_REF, Size=$SNAP_SIZE, Progress=$SNAP_PROGRESS)" # Poll until snapshot is completed (should be immediate in v1, but poll for forward-compat) echo "Waiting for snapshot to complete..." COUNT=0 while [ $COUNT -lt 60 ]; do SNAP_STATE=$(aws ec2 describe-snapshots --snapshot-ids "$SNAPSHOT_ID" \ --query 'Snapshots[0].State' --output text) if [ "$SNAP_STATE" == "completed" ]; then echo "Snapshot completed" break fi sleep 1 COUNT=$((COUNT + 1)) done if [ "$SNAP_STATE" != "completed" ]; then echo "Snapshot failed to reach completed state (State=$SNAP_STATE)" exit 1 fi # Describe snapshot by ID and verify fields echo "Verifying snapshot via describe-snapshots..." DESCRIBE_SNAP=$(aws ec2 describe-snapshots --snapshot-ids "$SNAPSHOT_ID") DESC_VOL_ID=$(echo "$DESCRIBE_SNAP" | jq -r '.Snapshots[0].VolumeId') DESC_SIZE=$(echo "$DESCRIBE_SNAP" | jq -r '.Snapshots[0].VolumeSize') DESC_DESC=$(echo "$DESCRIBE_SNAP" | jq -r '.Snapshots[0].Description') if [ "$DESC_VOL_ID" != "$VOLUME_ID" ]; then echo "Describe snapshot VolumeId mismatch: expected $VOLUME_ID, got $DESC_VOL_ID" exit 1 fi if [ "$DESC_SIZE" -ne "$ROOT_VOL_SIZE" ]; then echo "Describe snapshot VolumeSize mismatch: expected $ROOT_VOL_SIZE, got $DESC_SIZE" exit 1 fi if [ "$DESC_DESC" != "e2e-test-snapshot" ]; then echo "Describe snapshot Description mismatch: expected 'e2e-test-snapshot', got '$DESC_DESC'" exit 1 fi echo "Describe snapshot verified (VolumeId=$DESC_VOL_ID, Size=$DESC_SIZE, Description=$DESC_DESC)" # Copy the snapshot echo "Copying snapshot $SNAPSHOT_ID..." COPY_OUTPUT=$(aws ec2 copy-snapshot --source-snapshot-id "$SNAPSHOT_ID" --source-region "$SPINIFEX_REGION" --description "e2e-copy") COPY_SNAPSHOT_ID=$(echo "$COPY_OUTPUT" | jq -r '.SnapshotId') if [ -z "$COPY_SNAPSHOT_ID" ] || [ "$COPY_SNAPSHOT_ID" == "null" ]; then echo "Failed to copy snapshot" echo "Output: $COPY_OUTPUT" exit 1 fi echo "Copied snapshot: $COPY_SNAPSHOT_ID" # Verify the copy is a distinct snapshot if [ "$COPY_SNAPSHOT_ID" == "$SNAPSHOT_ID" ]; then echo "Copy snapshot ID should differ from original" exit 1 fi # Describe all snapshots — should see both original and copy TOTAL_SNAPS=$(aws ec2 describe-snapshots \ --snapshot-ids "$SNAPSHOT_ID" "$COPY_SNAPSHOT_ID" \ --query 'length(Snapshots)' --output text) if [ "$TOTAL_SNAPS" -ne 2 ]; then echo "Expected 2 snapshots, got $TOTAL_SNAPS" exit 1 fi echo "Both snapshots visible via describe-snapshots" # Verify copy has correct description COPY_DESC=$(aws ec2 describe-snapshots --snapshot-ids "$COPY_SNAPSHOT_ID" \ --query 'Snapshots[0].Description' --output text) if [ "$COPY_DESC" != "e2e-copy" ]; then echo "Copy description mismatch: expected 'e2e-copy', got '$COPY_DESC'" exit 1 fi # Delete the original snapshot echo "Deleting original snapshot $SNAPSHOT_ID..." aws ec2 delete-snapshot --snapshot-id "$SNAPSHOT_ID" # Verify original is gone, copy remains echo "Verifying snapshot deletion..." COUNT=0 while [ $COUNT -lt 60 ]; do set +e SNAP_CHECK=$(aws ec2 describe-snapshots --snapshot-ids "$SNAPSHOT_ID" \ --query 'Snapshots[0].SnapshotId' --output text 2>&1) SNAP_EXIT=$? set -e if [ $SNAP_EXIT -ne 0 ] || [ "$SNAP_CHECK" == "None" ] || [ -z "$SNAP_CHECK" ]; then echo "Original snapshot deleted successfully" break fi sleep 1 COUNT=$((COUNT + 1)) done if [ $COUNT -ge 60 ]; then echo "Snapshot deletion verification timed out" exit 1 fi # Verify copy still exists COPY_STATE=$(aws ec2 describe-snapshots --snapshot-ids "$COPY_SNAPSHOT_ID" \ --query 'Snapshots[0].State' --output text) if [ "$COPY_STATE" != "completed" ]; then echo "Copy snapshot should still exist (State=$COPY_STATE)" exit 1 fi echo "Copy snapshot still intact after original deletion" # Delete the copy echo "Deleting copy snapshot $COPY_SNAPSHOT_ID..." aws ec2 delete-snapshot --snapshot-id "$COPY_SNAPSHOT_ID" echo "Snapshot lifecycle test passed (create -> describe -> copy -> delete)" # Phase 5d: Verify Snapshot-Backed Instance Launch echo "Phase 5d: Verify Snapshot-Backed Instance Launch" echo "All run-instances calls go through cloneAMIToVolume() -> OpenFromSnapshot()," echo "so the Phase 5 instance is already snapshot-backed. Successful boot in" echo "Phase 5a-iii is itself proof; here we just confirm the AMI carries a" echo "snapshot reference via the EC2 API." AMI_SNAP_ID=$(aws ec2 describe-images --image-ids "$AMI_ID" \ --query 'Images[0].BlockDeviceMappings[0].Ebs.SnapshotId' --output text 2>/dev/null || echo "") if [ -z "$AMI_SNAP_ID" ] || [ "$AMI_SNAP_ID" == "None" ]; then echo "AMI $AMI_ID has no snapshot reference — launch was NOT snapshot-backed" exit 1 fi echo "AMI $AMI_ID is snapshot-backed (SnapshotId=$AMI_SNAP_ID)" echo "Snapshot-backed instance launch verified" # Phase 5e: CreateImage Lifecycle echo "Phase 5e: CreateImage Lifecycle" echo "Creating custom AMI from running instance $INSTANCE_ID..." CREATE_IMAGE_OUTPUT=$(aws ec2 create-image --instance-id "$INSTANCE_ID" --name "e2e-custom-ami" --description "E2E test custom image") CUSTOM_AMI_ID=$(echo "$CREATE_IMAGE_OUTPUT" | jq -r '.ImageId') if [ -z "$CUSTOM_AMI_ID" ] || [ "$CUSTOM_AMI_ID" == "null" ]; then echo "Failed to create custom image" echo "Output: $CREATE_IMAGE_OUTPUT" exit 1 fi echo "Created custom AMI: $CUSTOM_AMI_ID" # Verify the custom AMI exists via describe-images echo "Verifying custom AMI via describe-images..." CUSTOM_IMAGE=$(aws ec2 describe-images --image-ids "$CUSTOM_AMI_ID") CUSTOM_IMAGE_NAME=$(echo "$CUSTOM_IMAGE" | jq -r '.Images[0].Name') CUSTOM_IMAGE_STATE=$(echo "$CUSTOM_IMAGE" | jq -r '.Images[0].State') if [ "$CUSTOM_IMAGE_NAME" != "e2e-custom-ami" ]; then echo "Custom AMI name mismatch: expected 'e2e-custom-ami', got '$CUSTOM_IMAGE_NAME'" exit 1 fi echo "Custom AMI verified (Name=$CUSTOM_IMAGE_NAME, State=$CUSTOM_IMAGE_STATE)" # Extract the backing snapshot ID from the custom AMI via describe-images # (needed later to clean up before termination, so DeleteOnTermination can work) CUSTOM_AMI_SNAP_ID=$(aws ec2 describe-images --image-ids "$CUSTOM_AMI_ID" \ --query 'Images[0].BlockDeviceMappings[0].Ebs.SnapshotId' --output text 2>/dev/null || echo "") if [ "$CUSTOM_AMI_SNAP_ID" == "None" ]; then CUSTOM_AMI_SNAP_ID="" fi if [ -n "$CUSTOM_AMI_SNAP_ID" ]; then echo "Custom AMI backing snapshot: $CUSTOM_AMI_SNAP_ID" else echo "WARNING: Could not resolve backing snapshot ID from custom AMI" fi echo "CreateImage lifecycle test passed" # Phase 5f: Security Group Enforcement (egress) # Verifies vpcd programs OVN ACLs that actually drop traffic when an SG # rule is revoked. We test EGRESS because in single-node dev_networking # mode, INGRESS SSH bypasses OVN via the QEMU hostfwd NIC — the OVN tap # only owns the default route inside the VM, so egress is what hits the # ACL. The default SG ships with one allow-all egress rule we can flip. echo "Phase 5f: Security Group Enforcement (egress ACL)" # Discover the VM's OVN-side gateway from inside the VM (cloud-init # suppresses the default route on the dev NIC, so this is always the # OVN logical-router port). PROBE_GW=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o LogLevel=ERROR -o ConnectTimeout=5 -o BatchMode=yes \ -p "$SSH_PORT" -i "test-key-1.pem" \ ec2-user@"$SSH_HOST" "ip route show default | awk '{print \$3}' | head -1" 2>/dev/null | tr -d '[:space:]') if [ -z "$PROBE_GW" ]; then echo " ERROR: could not discover default gateway inside VM — skipping Phase 5f" else echo " Probe target (VM default gateway): $PROBE_GW" probe_egress_icmp() { ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o LogLevel=ERROR -o ConnectTimeout=5 -o BatchMode=yes \ -p "$SSH_PORT" -i "test-key-1.pem" \ ec2-user@"$SSH_HOST" "ping -c 3 -W 2 $PROBE_GW" 2>&1 } # Restore the default egress rule on any failure so later phases stay healthy. sg_egress_restore() { aws ec2 authorize-security-group-egress \ --group-id "$DEFAULT_SG_PHASE5" \ --ip-permissions 'IpProtocol=-1,IpRanges=[{CidrIp=0.0.0.0/0}]' >/dev/null 2>&1 || true } # Test 5f-1: Baseline — allow-all egress is in place. # `|| true` so set -e doesn't kill us when the probe legitimately fails # (e.g., OVN logical router not responding to ICMP echo in this env); # the SKIP branch below needs to be reachable. echo "Test 5f-1: Egress baseline (allow-all)" BASELINE_OUT=$(probe_egress_icmp || true) if echo "$BASELINE_OUT" | grep -qE '[1-3] (packets )?received'; then echo " PASS: ICMP egress works with default allow-all" SG_ENFORCE_RUN=true else echo " SKIP: baseline ICMP did not work; cannot verify enforcement" echo " (env may block ICMP between VM and gateway regardless of SG)" echo " Output: $BASELINE_OUT" SG_ENFORCE_RUN=false fi if [ "$SG_ENFORCE_RUN" = "true" ]; then # Test 5f-2: Revoke egress → ICMP must be dropped. echo "Test 5f-2: Revoke egress → expect drop" aws ec2 revoke-security-group-egress \ --group-id "$DEFAULT_SG_PHASE5" \ --ip-permissions 'IpProtocol=-1,IpRanges=[{CidrIp=0.0.0.0/0}]' >/dev/null sleep 3 # ACL propagation (vpcd handler is synchronous; small buffer for OVN flow install) REVOKE_OUT=$(probe_egress_icmp || true) if echo "$REVOKE_OUT" | grep -qE '0 (packets )?received|100% packet loss'; then echo " PASS: ICMP dropped after revoke" else echo " FAIL: ICMP still succeeded after revoking allow-all egress" echo " Output: $REVOKE_OUT" sg_egress_restore exit 1 fi # Test 5f-3: Re-authorize → ICMP works again. echo "Test 5f-3: Re-authorize egress → expect allow" sg_egress_restore sleep 3 RESTORE_OUT=$(probe_egress_icmp || true) if echo "$RESTORE_OUT" | grep -qE '[1-3] (packets )?received'; then echo " PASS: ICMP restored after re-authorize" else echo " FAIL: ICMP still dropped after re-authorize" echo " Output: $RESTORE_OUT" exit 1 fi echo "Phase 5f: Security Group enforcement verified (revoke → drop, authorize → allow)" fi fi # Phase 6: Tag Management echo "Phase 6: Tag Management" # 6a: Create tags on the instance echo "Creating tags on instance $INSTANCE_ID..." aws ec2 create-tags --resources "$INSTANCE_ID" --tags Key=Name,Value=e2e-test Key=Environment,Value=testing Key=DeleteMe,Value=please # 6b: Verify tags with describe-tags (resource-id filter) echo "Verifying tags on instance..." TAG_COUNT=$(aws ec2 describe-tags --filters "Name=resource-id,Values=$INSTANCE_ID" \ --query 'length(Tags || `[]`)' --output text) if [ "$TAG_COUNT" -ne 3 ]; then echo "Expected 3 tags on instance, got $TAG_COUNT" exit 1 fi echo "Instance has $TAG_COUNT tags" # 6c: Create tags on the root volume echo "Creating tags on volume $VOLUME_ID..." aws ec2 create-tags --resources "$VOLUME_ID" --tags Key=Name,Value=e2e-root-vol Key=Environment,Value=testing # 6d: Filter by key echo "Testing key filter..." ENV_TAGS=$(aws ec2 describe-tags --filters "Name=key,Values=Environment" \ --query 'length(Tags || `[]`)' --output text) if [ "$ENV_TAGS" -ne 2 ]; then echo "Expected 2 'Environment' tags across resources, got $ENV_TAGS" exit 1 fi echo "Key filter returned $ENV_TAGS tags" # 6e: Filter by resource-type echo "Testing resource-type filter..." INSTANCE_TAGS=$(aws ec2 describe-tags --filters "Name=resource-type,Values=instance" \ --query 'length(Tags || `[]`)' --output text) if [ "$INSTANCE_TAGS" -ne 3 ]; then echo "Expected 3 instance tags, got $INSTANCE_TAGS" exit 1 fi echo "Resource-type filter returned $INSTANCE_TAGS instance tags" # 6f: Overwrite a tag value echo "Overwriting Name tag on instance..." aws ec2 create-tags --resources "$INSTANCE_ID" --tags Key=Name,Value=e2e-test-updated UPDATED_NAME=$(aws ec2 describe-tags \ --filters "Name=resource-id,Values=$INSTANCE_ID" "Name=key,Values=Name" \ --query 'Tags[0].Value' --output text) if [ "$UPDATED_NAME" != "e2e-test-updated" ]; then echo "Tag overwrite failed: expected 'e2e-test-updated', got '$UPDATED_NAME'" exit 1 fi echo "Tag overwrite verified" # 6g: Delete tag by key (unconditional) echo "Deleting DeleteMe tag unconditionally..." aws ec2 delete-tags --resources "$INSTANCE_ID" --tags Key=DeleteMe REMAINING=$(aws ec2 describe-tags --filters "Name=resource-id,Values=$INSTANCE_ID" \ --query 'length(Tags || `[]`)' --output text) if [ "$REMAINING" -ne 2 ]; then echo "Expected 2 tags after unconditional delete, got $REMAINING" exit 1 fi echo "Unconditional delete verified ($REMAINING tags remaining)" # 6h: Delete tag with wrong value (should NOT delete) echo "Attempting delete with wrong value (should be no-op)..." aws ec2 delete-tags --resources "$INSTANCE_ID" --tags Key=Environment,Value=production ENV_STILL=$(aws ec2 describe-tags \ --filters "Name=resource-id,Values=$INSTANCE_ID" "Name=key,Values=Environment" \ --query 'length(Tags || `[]`)' --output text) if [ "$ENV_STILL" -ne 1 ]; then echo "Value-conditional delete incorrectly removed tag" exit 1 fi echo "Value-conditional mismatch preserved tag" # 6i: Delete tag with correct value echo "Deleting Environment tag with correct value..." aws ec2 delete-tags --resources "$INSTANCE_ID" --tags Key=Environment,Value=testing ENV_GONE=$(aws ec2 describe-tags \ --filters "Name=resource-id,Values=$INSTANCE_ID" "Name=key,Values=Environment" \ --query 'length(Tags || `[]`)' --output text) if [ "$ENV_GONE" -ne 0 ]; then echo "Value-conditional delete failed to remove matching tag" exit 1 fi echo "Value-conditional match deleted tag" # 6j: Verify only Name tag remains on instance FINAL_COUNT=$(aws ec2 describe-tags --filters "Name=resource-id,Values=$INSTANCE_ID" \ --query 'length(Tags || `[]`)' --output text) if [ "$FINAL_COUNT" -ne 1 ]; then echo "Expected 1 tag remaining on instance, got $FINAL_COUNT" exit 1 fi echo "Tag management tests passed" # Phase 7: Instance State Transitions echo "Phase 7: Instance State Transitions" # Stop instance (stop-instances) and verify transition to stopped (describe-instances) echo "Stopping instance..." aws ec2 stop-instances --instance-ids "$INSTANCE_ID" COUNT=0 while [ $COUNT -lt 60 ]; do STATE=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" --query 'Reservations[0].Instances[0].State.Name' --output text) echo "Instance state: $STATE" if [ "$STATE" == "stopped" ]; then break fi sleep 1 COUNT=$((COUNT + 1)) done if [ "$STATE" != "stopped" ]; then echo "Instance failed to reach stopped state" exit 1 fi # Reboot stopped instance (should fail) echo "Attempting reboot of stopped instance (should fail)..." expect_error "IncorrectInstanceState" aws ec2 reboot-instances --instance-ids "$INSTANCE_ID" echo "Reboot-stopped correctly rejected" # Phase 7a: Attach volume to stopped instance (should fail) echo "Phase 7a: Attach Volume to Stopped Instance (Error Path)" echo "Creating a volume to test attach-to-stopped..." STOPPED_VOL_OUTPUT=$(aws ec2 create-volume --size 10 --availability-zone "$SPINIFEX_AZ") STOPPED_VOL_ID=$(echo "$STOPPED_VOL_OUTPUT" | jq -r '.VolumeId') echo "Created volume: $STOPPED_VOL_ID" echo "Attempting attach to stopped instance (should fail)..." expect_error "IncorrectInstanceState" aws ec2 attach-volume \ --volume-id "$STOPPED_VOL_ID" --instance-id "$INSTANCE_ID" --device /dev/sdg echo "Attach-to-stopped correctly rejected" # Clean up the test volume aws ec2 delete-volume --volume-id "$STOPPED_VOL_ID" echo "Cleaned up test volume $STOPPED_VOL_ID" # Phase 7b: ModifyInstanceAttribute (change instance type while stopped, verify via SSH) echo "Phase 7b: ModifyInstanceAttribute" echo "Instance is stopped — modifying instance type to verify changes take effect on restart" # Derive an upsized type in the same family: nano → small (same vCPUs, more RAM) # Note: xlarge needs 16GB RAM which exceeds the 8GB CI VM — use small (2GB) instead MODIFY_TYPE="${INSTANCE_TYPE%.nano}.small" echo "Changing instance type from $INSTANCE_TYPE to $MODIFY_TYPE..." # Get expected vCPU and memory for the new type # Note: --instance-types filter may not be supported; use jq to select the correct type TYPES_JSON=$(aws ec2 describe-instance-types) EXPECTED_VCPUS=$(echo "$TYPES_JSON" | jq -r ".InstanceTypes[] | select(.InstanceType==\"$MODIFY_TYPE\") | .VCpuInfo.DefaultVCpus") EXPECTED_MEM_MIB=$(echo "$TYPES_JSON" | jq -r ".InstanceTypes[] | select(.InstanceType==\"$MODIFY_TYPE\") | .MemoryInfo.SizeInMiB") echo "Expected resources after modify: ${EXPECTED_VCPUS} vCPUs, ${EXPECTED_MEM_MIB} MiB RAM" # Modify the instance type aws ec2 modify-instance-attribute --instance-id "$INSTANCE_ID" \ --instance-type "{\"Value\": \"$MODIFY_TYPE\"}" # Verify describe-instances reflects the new type MODIFIED_TYPE=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \ --query 'Reservations[0].Instances[0].InstanceType' --output text) if [ "$MODIFIED_TYPE" != "$MODIFY_TYPE" ]; then echo "ModifyInstanceAttribute failed: expected type $MODIFY_TYPE, got $MODIFIED_TYPE" exit 1 fi echo "Instance type updated to $MODIFIED_TYPE" # Start instance with the new type echo "Starting instance with modified type..." aws ec2 start-instances --instance-ids "$INSTANCE_ID" COUNT=0 while [ $COUNT -lt 60 ]; do STATE=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \ --query 'Reservations[0].Instances[0].State.Name' --output text) echo "Instance state: $STATE" if [ "$STATE" == "running" ]; then break fi sleep 1 COUNT=$((COUNT + 1)) done if [ "$STATE" != "running" ]; then echo "Instance failed to reach running state after type change" exit 1 fi # Re-detect SSH endpoint (may have changed after restart with new QEMU config) echo "Detecting SSH endpoint for restarted instance..." INST_PUBLIC_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \ --query 'Reservations[0].Instances[0].PublicIpAddress' --output text 2>/dev/null || echo "None") if [ -n "$INST_PUBLIC_IP" ] && [ "$INST_PUBLIC_IP" != "None" ] && [ "$INST_PUBLIC_IP" != "null" ]; then SSH_HOST="$INST_PUBLIC_IP" SSH_PORT=22 else # set -e would abort on $() returning non-zero if ! SSH_PORT=$(get_ssh_port "$INSTANCE_ID"); then echo "ERROR: Failed to get SSH port for instance $INSTANCE_ID" exit 1 fi SSH_HOST=$(get_ssh_host "$INSTANCE_ID") fi echo "SSH endpoint: $SSH_HOST:$SSH_PORT" # Wait for SSH to become ready wait_for_ssh "$SSH_HOST" "$SSH_PORT" "test-key-1.pem" 60 # Verify vCPU count matches the new instance type (nproc reports online CPUs) echo "Verifying vCPU count inside the VM..." VM_VCPUS=$(ssh -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ -o LogLevel=ERROR \ -o ConnectTimeout=5 \ -o BatchMode=yes \ -p "$SSH_PORT" \ -i "test-key-1.pem" \ ec2-user@"$SSH_HOST" 'nproc' | tr -d '[:space:]') echo "VM reports $VM_VCPUS vCPUs (expected $EXPECTED_VCPUS)" if [ "$VM_VCPUS" != "$EXPECTED_VCPUS" ]; then echo "vCPU count mismatch after ModifyInstanceAttribute: VM reports $VM_VCPUS, expected $EXPECTED_VCPUS" exit 1 fi echo "vCPU count verified" # Verify memory matches the new instance type (MemTotal from /proc/meminfo) echo "Verifying memory inside the VM..." VM_MEM_KB=$(ssh -o StrictHostKeyChecking=no \ -o UserKnownHostsFile=/dev/null \ -o LogLevel=ERROR \ -o ConnectTimeout=5 \ -o BatchMode=yes \ -p "$SSH_PORT" \ -i "test-key-1.pem" \ ec2-user@"$SSH_HOST" "awk '/MemTotal/ {print \$2}' /proc/meminfo" | tr -d '[:space:]') VM_MEM_MIB=$((VM_MEM_KB / 1024)) # Allow 15% margin for kernel reserved memory EXPECTED_MEM_LOW=$((EXPECTED_MEM_MIB * 85 / 100)) echo "VM reports ${VM_MEM_MIB} MiB total RAM (expected ~${EXPECTED_MEM_MIB} MiB, threshold ${EXPECTED_MEM_LOW} MiB)" if [ "$VM_MEM_MIB" -lt "$EXPECTED_MEM_LOW" ]; then echo "Memory too low after ModifyInstanceAttribute: VM reports ${VM_MEM_MIB} MiB, expected at least ${EXPECTED_MEM_LOW} MiB" exit 1 fi echo "Memory verified" echo "ModifyInstanceAttribute test passed (type change + vCPU + memory verified via SSH)" # Phase 7c-pre: Reboot Running Instance echo "Phase 7c-pre: Reboot Instance" # Capture pre-reboot metadata PRE_REBOOT_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \ --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text) echo "Rebooting instance..." aws ec2 reboot-instances --instance-ids "$INSTANCE_ID" # Verify state stays running (poll a few times to confirm no transient state change) COUNT=0 while [ $COUNT -lt 10 ]; do STATE=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \ --query 'Reservations[0].Instances[0].State.Name' --output text) if [ "$STATE" != "running" ]; then echo "Instance unexpectedly left running state: $STATE" exit 1 fi sleep 1 COUNT=$((COUNT + 1)) done echo "Instance state remained running during reboot" # Wait for SSH to come back (guest OS restarts) INST_PUBLIC_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \ --query 'Reservations[0].Instances[0].PublicIpAddress' --output text 2>/dev/null || echo "None") if [ -n "$INST_PUBLIC_IP" ] && [ "$INST_PUBLIC_IP" != "None" ] && [ "$INST_PUBLIC_IP" != "null" ]; then SSH_HOST="$INST_PUBLIC_IP" SSH_PORT=22 else # set -e would abort on $() returning non-zero if ! SSH_PORT=$(get_ssh_port "$INSTANCE_ID"); then echo "ERROR: Failed to get SSH port for instance $INSTANCE_ID" exit 1 fi SSH_HOST=$(get_ssh_host "$INSTANCE_ID") fi wait_for_ssh "$SSH_HOST" "$SSH_PORT" "test-key-1.pem" 60 # Verify guest actually rebooted (uptime < 120 seconds) UPTIME_SECS=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o LogLevel=ERROR -o ConnectTimeout=5 -o BatchMode=yes \ -p "$SSH_PORT" -i "test-key-1.pem" \ ec2-user@"$SSH_HOST" 'cat /proc/uptime | cut -d. -f1' | tr -d '[:space:]') echo "Guest uptime after reboot: ${UPTIME_SECS}s" if [ "$UPTIME_SECS" -gt 120 ]; then echo "Guest uptime too high — reboot may not have occurred" exit 1 fi # Verify metadata unchanged POST_REBOOT_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \ --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text) if [ "$PRE_REBOOT_IP" != "$POST_REBOOT_IP" ]; then echo "IP changed after reboot: $PRE_REBOOT_IP -> $POST_REBOOT_IP" exit 1 fi echo "Reboot instance test passed" # Phase 7c: RunInstances with count > 1 echo "Phase 7c: RunInstances with MinCount/MaxCount > 1" echo "Launching 2 instances in a single run-instances call..." MULTI_RUN_OUTPUT=$(aws ec2 run-instances \ --image-id "$AMI_ID" \ --instance-type "$INSTANCE_TYPE" \ --key-name test-key-1 \ --count 2) MULTI_COUNT=$(echo "$MULTI_RUN_OUTPUT" | jq '.Instances | length') if [ "$MULTI_COUNT" -ne 2 ]; then echo "Expected 2 instances from run-instances --count 2, got $MULTI_COUNT" exit 1 fi MULTI_ID_1=$(echo "$MULTI_RUN_OUTPUT" | jq -r '.Instances[0].InstanceId') MULTI_ID_2=$(echo "$MULTI_RUN_OUTPUT" | jq -r '.Instances[1].InstanceId') echo "Launched 2 instances: $MULTI_ID_1, $MULTI_ID_2" # Wait for both to reach running state for MID in "$MULTI_ID_1" "$MULTI_ID_2"; do echo "Waiting for $MID to reach running state..." COUNT=0 while [ $COUNT -lt 60 ]; do MSTATE=$(aws ec2 describe-instances --instance-ids "$MID" \ --query 'Reservations[0].Instances[0].State.Name' --output text) || { sleep 1 COUNT=$((COUNT + 1)) continue } if [ "$MSTATE" == "running" ]; then echo "Instance $MID is running" break fi sleep 1 COUNT=$((COUNT + 1)) done if [ "$MSTATE" != "running" ]; then echo "Instance $MID failed to reach running state" exit 1 fi done # Terminate the multi-launch instances echo "Terminating multi-launch instances..." aws ec2 terminate-instances --instance-ids "$MULTI_ID_1" "$MULTI_ID_2" for MID in "$MULTI_ID_1" "$MULTI_ID_2"; do COUNT=0 while [ $COUNT -lt 60 ]; do MSTATE=$(aws ec2 describe-instances --instance-ids "$MID" \ --query 'Reservations[0].Instances[0].State.Name' --output text) if [ "$MSTATE" == "terminated" ] || [ "$MSTATE" == "None" ]; then break fi sleep 1 COUNT=$((COUNT + 1)) done done echo "RunInstances count>1 test passed" # Phase 8: Negative / Error Path Tests echo "Phase 8: Negative / Error Path Tests" # 8a: RunInstances with malformed AMI ID (missing ami- prefix) echo "8a: RunInstances with malformed AMI ID..." expect_error "InvalidAMIID.Malformed" aws ec2 run-instances \ --image-id notanami --instance-type "$INSTANCE_TYPE" --key-name test-key-1 # 8b: RunInstances with invalid instance type echo "8b: RunInstances with invalid instance type..." expect_error "InvalidInstanceType" aws ec2 run-instances \ --image-id "$AMI_ID" --instance-type "x99.superlarge" --key-name test-key-1 # 8c: Attach an already in-use volume (root volume is attached to running instance) echo "8c: Attach already in-use volume..." expect_error "VolumeInUse" aws ec2 attach-volume \ --volume-id "$VOLUME_ID" --instance-id "$INSTANCE_ID" --device /dev/sdg # 8d: Detach boot/root volume (should be rejected) echo "8d: Detach boot volume..." expect_error "OperationNotPermitted" aws ec2 detach-volume \ --volume-id "$VOLUME_ID" --instance-id "$INSTANCE_ID" # 8e: Delete a non-existent snapshot echo "8e: Delete non-existent snapshot..." expect_error "InvalidSnapshot.NotFound" aws ec2 delete-snapshot \ --snapshot-id snap-nonexistent000000 # 8f: Call an unsupported Action (use raw curl to send an invalid Action) echo "8f: Unsupported Action..." set +e UNSUPPORTED_OUTPUT=$(curl -s -k -X POST "https://${GATEWAY_HOST}:9999/" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "Action=DescribeFakeThings&Version=2016-11-15" 2>&1) set -e if echo "$UNSUPPORTED_OUTPUT" | grep -q "InvalidAction\|UnknownAction\|Error"; then echo " Got expected error for unsupported action" else echo " WARNING: Unsupported action did not return expected error (may need auth)" fi # 8g: RunInstances with non-existent AMI (valid format but doesn't exist) echo "8g: RunInstances with non-existent AMI..." expect_error "InvalidAMIID.NotFound" aws ec2 run-instances \ --image-id ami-0000000000000dead --instance-type "$INSTANCE_TYPE" --key-name test-key-1 # 8h: RunInstances with non-existent key pair echo "8h: RunInstances with non-existent key pair..." expect_error "InvalidKeyPair.NotFound" aws ec2 run-instances \ --image-id "$AMI_ID" --instance-type "$INSTANCE_TYPE" --key-name nonexistent-key-xyz # 8i: DeleteVolume on non-existent volume echo "8i: DeleteVolume non-existent volume..." expect_error "InvalidVolume.NotFound" aws ec2 delete-volume \ --volume-id vol-0000000000000dead # 8j: CreateKeyPair with duplicate name (test-key-1 exists from Phase 3) echo "8j: CreateKeyPair duplicate name..." expect_error "InvalidKeyPair.Duplicate" aws ec2 create-key-pair \ --key-name test-key-1 # 8k: ImportKeyPair with duplicate name (test-key-1 exists from Phase 3) echo "8k: ImportKeyPair duplicate name..." expect_error "InvalidKeyPair.Duplicate" aws ec2 import-key-pair \ --key-name test-key-1 --public-key-material "fileb://test-key-2-local.pub" # 8l: ImportKeyPair with invalid key format echo "8l: ImportKeyPair invalid key format..." echo "not-a-valid-public-key" > /tmp/bad-key.pub expect_error "InvalidKey.Format" aws ec2 import-key-pair \ --key-name bad-format-key --public-key-material "fileb:///tmp/bad-key.pub" # 8m: DescribeVolumes with non-existent volume ID echo "8m: DescribeVolumes non-existent volume..." expect_error "InvalidVolume.NotFound" aws ec2 describe-volumes \ --volume-ids vol-0000000000000dead # 8n: DescribeImages with non-existent AMI ID echo "8n: DescribeImages non-existent AMI..." expect_error "InvalidAMIID.NotFound" aws ec2 describe-images \ --image-ids ami-0000000000000dead # 8o: CreateImage with duplicate name (e2e-custom-ami exists from Phase 5e) echo "8o: CreateImage duplicate name..." expect_error "InvalidAMIName.Duplicate" aws ec2 create-image \ --instance-id "$INSTANCE_ID" --name "e2e-custom-ami" # 8p: DeleteKeyPair for non-existent key — should succeed (idempotent, matches AWS) echo "8p: DeleteKeyPair non-existent key (idempotent)..." aws ec2 delete-key-pair --key-name nonexistent-key-99999 echo " DeleteKeyPair for non-existent key succeeded (idempotent)" # 8q: ModifyInstanceAttribute on running instance (instance not in stopped KV → NotFound) echo "8q: ModifyInstanceAttribute on running instance..." expect_error "InvalidInstanceID.NotFound" aws ec2 modify-instance-attribute \ --instance-id "$INSTANCE_ID" --instance-type "{\"Value\": \"$INSTANCE_TYPE\"}" # 8r: Reboot non-existent instance echo "8r: Reboot non-existent instance..." expect_error "InvalidInstanceID.NotFound" aws ec2 reboot-instances --instance-ids "i-nonexistent" echo "Negative test suite passed" # ============================================================================= # IAM E2E Tests # ============================================================================= # IAM Phase 1: User CRUD echo "" echo "IAM Phase 1: User CRUD" # Root auth — verify list-users works (root user exists) echo " Verifying root auth via iam list-users..." ROOT_USERS=$(aws iam list-users) echo "$ROOT_USERS" | jq -e '.Users | length > 0' > /dev/null echo " Root auth verified" # CreateUser — alice echo " Creating user alice..." ALICE_OUTPUT=$(aws iam create-user --user-name alice) echo "$ALICE_OUTPUT" | jq -e '.User.UserName == "alice"' > /dev/null ALICE_ARN=$(echo "$ALICE_OUTPUT" | jq -r '.User.Arn') echo " Created alice: $ALICE_ARN" # CreateUser — bob with path echo " Creating user bob with path /engineering/..." BOB_OUTPUT=$(aws iam create-user --user-name bob --path /engineering/) echo "$BOB_OUTPUT" | jq -e '.User.Path == "/engineering/"' > /dev/null echo " Created bob" # CreateUser — duplicate (expect EntityAlreadyExists) echo " Creating duplicate user alice (expect error)..." expect_error "EntityAlreadyExists" aws iam create-user --user-name alice # GetUser echo " Getting user alice..." aws iam get-user --user-name alice | jq -e '.User.UserName == "alice"' > /dev/null echo " GetUser alice passed" # GetUser — not found echo " Getting nonexistent user (expect error)..." expect_error "NoSuchEntity" aws iam get-user --user-name nonexistent # ListUsers — should have root, alice, bob echo " Listing users..." USER_COUNT=$(aws iam list-users | jq '.Users | length') if [ "$USER_COUNT" -lt 3 ]; then echo " ERROR: Expected at least 3 users (root, alice, bob), got $USER_COUNT" exit 1 fi echo " ListUsers: $USER_COUNT users" # ListUsers with path-prefix echo " Listing users with path-prefix /engineering/..." ENG_USERS=$(aws iam list-users --path-prefix /engineering/ | jq '.Users | length') if [ "$ENG_USERS" -ne 1 ]; then echo " ERROR: Expected 1 user with path /engineering/, got $ENG_USERS" exit 1 fi echo " Path-prefix filter passed" echo "IAM Phase 1 passed" # IAM Phase 2: Access Key Lifecycle echo "" echo "IAM Phase 2: Access Key Lifecycle" # CreateAccessKey — alice key 1 echo " Creating access key 1 for alice..." KEY1=$(aws iam create-access-key --user-name alice) ALICE_KEY_ID=$(echo "$KEY1" | jq -r '.AccessKey.AccessKeyId') ALICE_SECRET=$(echo "$KEY1" | jq -r '.AccessKey.SecretAccessKey') echo " Key 1: $ALICE_KEY_ID" if [ -z "$ALICE_KEY_ID" ] || [ "$ALICE_KEY_ID" == "null" ]; then echo " ERROR: Failed to create access key for alice" exit 1 fi # CreateAccessKey — alice key 2 echo " Creating access key 2 for alice..." KEY2=$(aws iam create-access-key --user-name alice) ALICE_KEY2_ID=$(echo "$KEY2" | jq -r '.AccessKey.AccessKeyId') echo " Key 2: $ALICE_KEY2_ID" # CreateAccessKey — alice key 3 (exceed limit) echo " Creating access key 3 for alice (expect LimitExceeded)..." expect_error "LimitExceeded" aws iam create-access-key --user-name alice # CreateAccessKey — non-existent user echo " Creating access key for ghost (expect error)..." expect_error "NoSuchEntity" aws iam create-access-key --user-name ghost # ListAccessKeys echo " Listing access keys for alice..." ALICE_KEY_COUNT=$(aws iam list-access-keys --user-name alice | jq '.AccessKeyMetadata | length') if [ "$ALICE_KEY_COUNT" -ne 2 ]; then echo " ERROR: Expected 2 keys for alice, got $ALICE_KEY_COUNT" exit 1 fi echo " Alice has $ALICE_KEY_COUNT keys" echo " Listing access keys for bob (expect 0)..." BOB_KEY_COUNT=$(aws iam list-access-keys --user-name bob | jq '.AccessKeyMetadata // [] | length') if [ "${BOB_KEY_COUNT:-0}" -ne 0 ]; then echo " ERROR: Expected 0 keys for bob, got $BOB_KEY_COUNT" exit 1 fi echo " Bob has 0 keys" # UpdateAccessKey — deactivate echo " Deactivating alice key 1..." aws iam update-access-key --user-name alice --access-key-id "$ALICE_KEY_ID" --status Inactive STATUS=$(aws iam list-access-keys --user-name alice | \ jq -r ".AccessKeyMetadata[] | select(.AccessKeyId == \"$ALICE_KEY_ID\") | .Status") if [ "$STATUS" != "Inactive" ]; then echo " ERROR: Expected Inactive, got $STATUS" exit 1 fi echo " Key deactivated" # UpdateAccessKey — reactivate echo " Reactivating alice key 1..." aws iam update-access-key --user-name alice --access-key-id "$ALICE_KEY_ID" --status Active echo " Key reactivated" # DeleteAccessKey — key 2 echo " Deleting alice key 2..." aws iam delete-access-key --user-name alice --access-key-id "$ALICE_KEY2_ID" ALICE_KEY_COUNT=$(aws iam list-access-keys --user-name alice | jq '.AccessKeyMetadata | length') if [ "$ALICE_KEY_COUNT" -ne 1 ]; then echo " ERROR: Expected 1 key after delete, got $ALICE_KEY_COUNT" exit 1 fi echo " Key 2 deleted, alice has $ALICE_KEY_COUNT key" echo "IAM Phase 2 passed" # IAM Phase 3: User Authentication echo "" echo "IAM Phase 3: User Authentication" # Configure alice profile echo " Configuring spx-alice profile..." setup_test_profile spx-alice "$ALICE_KEY_ID" "$ALICE_SECRET" # Deactivate key → auth should fail echo " Deactivating alice key, verifying auth rejection..." aws iam update-access-key --user-name alice --access-key-id "$ALICE_KEY_ID" --status Inactive expect_error "InvalidClientTokenId" \ aws ec2 describe-instances --profile spx-alice echo " Inactive key correctly rejected" # Reactivate key echo " Reactivating alice key..." aws iam update-access-key --user-name alice --access-key-id "$ALICE_KEY_ID" --status Active # Bad secret → SignatureDoesNotMatch echo " Testing bad secret (expect SignatureDoesNotMatch)..." setup_test_profile spx-bad "$ALICE_KEY_ID" "WRONG_SECRET_KEY_HERE_12345678901" expect_error "SignatureDoesNotMatch" \ aws ec2 describe-instances --profile spx-bad # Non-existent key ID → InvalidClientTokenId echo " Testing fake key ID (expect InvalidClientTokenId)..." setup_test_profile spx-fake "AKIAXXXXXXXXXXXXXXXX" "doesntmatter" expect_error "InvalidClientTokenId" \ aws ec2 describe-instances --profile spx-fake # Multi-user auth — create bob key and verify both auth echo " Creating access key for bob..." BOB_KEY=$(aws iam create-access-key --user-name bob) BOB_KEY_ID=$(echo "$BOB_KEY" | jq -r '.AccessKey.AccessKeyId') BOB_SECRET=$(echo "$BOB_KEY" | jq -r '.AccessKey.SecretAccessKey') setup_test_profile spx-bob "$BOB_KEY_ID" "$BOB_SECRET" # Root still works echo " Verifying root auth..." aws ec2 describe-instances > /dev/null echo " Root auth OK" echo "IAM Phase 3 passed" # IAM Phase 4: Policy CRUD echo "" echo "IAM Phase 4: Policy CRUD" # Get the admin account ID from the current profile POLICY_OUTPUT=$(aws iam create-policy \ --policy-name EC2ReadOnly \ --policy-document '{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["ec2:DescribeInstances", "ec2:DescribeVolumes", "ec2:DescribeVpcs"], "Resource": "*" }] }') ADMIN_ACCOUNT=$(echo "$POLICY_OUTPUT" | jq -r '.Policy.Arn' | cut -d: -f5) if [ -z "$ADMIN_ACCOUNT" ]; then echo " ERROR: Failed to extract account ID from CreatePolicy response" exit 1 fi echo " Created EC2ReadOnly (account=$ADMIN_ACCOUNT)" # CreatePolicy — FullAdmin echo " Creating FullAdmin policy..." aws iam create-policy \ --policy-name FullAdmin \ --path /admin/ \ --description "Full access to all services" \ --policy-document '{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": "*", "Resource": "*" }] }' > /dev/null echo " Created FullAdmin" # CreatePolicy — DenyTerminate (mixed Allow + Deny) echo " Creating DenyTerminate policy..." aws iam create-policy \ --policy-name DenyTerminate \ --policy-document '{ "Version": "2012-10-17", "Statement": [ {"Effect": "Allow", "Action": "ec2:*", "Resource": "*"}, {"Effect": "Deny", "Action": "ec2:TerminateInstances", "Resource": "*"} ] }' > /dev/null echo " Created DenyTerminate" # CreatePolicy — IAMReadOnly echo " Creating IAMReadOnly policy..." aws iam create-policy \ --policy-name IAMReadOnly \ --policy-document '{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": ["iam:GetUser", "iam:ListUsers", "iam:ListPolicies", "iam:GetPolicy"], "Resource": "*" }] }' > /dev/null echo " Created IAMReadOnly" # CreatePolicy — EC2DescribeAll (wildcard) echo " Creating EC2DescribeAll policy..." aws iam create-policy \ --policy-name EC2DescribeAll \ --policy-document '{ "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": "ec2:Describe*", "Resource": "*" }] }' > /dev/null echo " Created EC2DescribeAll" # CreatePolicy — duplicate (expect EntityAlreadyExists) echo " Creating duplicate EC2ReadOnly (expect error)..." expect_error "EntityAlreadyExists" aws iam create-policy --policy-name EC2ReadOnly \ --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}' # CreatePolicy — malformed JSON echo " Creating policy with malformed JSON (expect error)..." expect_error "MalformedPolicyDocument" aws iam create-policy --policy-name BadPolicy \ --policy-document '{"not valid"}' # GetPolicy echo " Getting EC2ReadOnly policy..." aws iam get-policy \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" | \ jq -e '.Policy.PolicyName == "EC2ReadOnly"' > /dev/null echo " GetPolicy passed" # GetPolicy — not found echo " Getting non-existent policy (expect error)..." expect_error "NoSuchEntity" aws iam get-policy \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/Ghost" # GetPolicyVersion echo " Getting EC2ReadOnly policy version v1..." aws iam get-policy-version \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" \ --version-id v1 | jq -e '.PolicyVersion.VersionId == "v1"' > /dev/null echo " GetPolicyVersion passed" # ListPolicies echo " Listing policies..." POLICY_COUNT=$(aws iam list-policies | jq '.Policies | length') if [ "$POLICY_COUNT" -lt 5 ]; then echo " ERROR: Expected at least 5 policies, got $POLICY_COUNT" exit 1 fi echo " ListPolicies: $POLICY_COUNT policies" echo "IAM Phase 4 passed" # IAM Phase 5: Policy Attachment & Enforcement echo "" echo "IAM Phase 5: Policy Attachment & Enforcement" # Create charlie with key echo " Creating user charlie with access key..." aws iam create-user --user-name charlie > /dev/null CHARLIE_KEY=$(aws iam create-access-key --user-name charlie) CHARLIE_KEY_ID=$(echo "$CHARLIE_KEY" | jq -r '.AccessKey.AccessKeyId') CHARLIE_SECRET=$(echo "$CHARLIE_KEY" | jq -r '.AccessKey.SecretAccessKey') setup_test_profile spx-charlie "$CHARLIE_KEY_ID" "$CHARLIE_SECRET" # Attach policies echo " Attaching EC2ReadOnly + IAMReadOnly to alice..." aws iam attach-user-policy --user-name alice \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" aws iam attach-user-policy --user-name alice \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/IAMReadOnly" echo " Attaching DenyTerminate to bob..." aws iam attach-user-policy --user-name bob \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate" # ListAttachedUserPolicies echo " Verifying alice's attached policies..." ALICE_POLICIES=$(aws iam list-attached-user-policies --user-name alice | jq '.AttachedPolicies | length') if [ "$ALICE_POLICIES" -ne 2 ]; then echo " ERROR: Expected 2 policies for alice, got $ALICE_POLICIES" exit 1 fi echo " Alice has $ALICE_POLICIES policies" # Idempotent attach echo " Testing idempotent attach..." aws iam attach-user-policy --user-name alice \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" ALICE_POLICIES=$(aws iam list-attached-user-policies --user-name alice | jq '.AttachedPolicies | length') if [ "$ALICE_POLICIES" -ne 2 ]; then echo " ERROR: Expected 2 policies after idempotent attach, got $ALICE_POLICIES" exit 1 fi echo " Idempotent attach passed" # Attach edge cases echo " Attaching non-existent policy (expect error)..." expect_error "NoSuchEntity" aws iam attach-user-policy --user-name alice \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/Ghost" echo " Attaching to non-existent user (expect error)..." expect_error "NoSuchEntity" aws iam attach-user-policy --user-name ghost \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" # --- Enforcement Tests --- # Default Deny — charlie has no policies echo " Testing default deny (charlie, no policies)..." expect_error "AccessDenied" \ aws ec2 describe-instances --profile spx-charlie expect_error "AccessDenied" \ aws iam list-users --profile spx-charlie echo " Default deny passed" # Explicit Allow — alice has EC2ReadOnly + IAMReadOnly echo " Testing explicit allow (alice, EC2ReadOnly + IAMReadOnly)..." aws ec2 describe-instances --profile spx-alice > /dev/null echo " ec2:DescribeInstances — allowed" aws ec2 describe-vpcs --profile spx-alice > /dev/null echo " ec2:DescribeVpcs — allowed" aws iam list-users --profile spx-alice > /dev/null echo " iam:ListUsers — allowed" # Actions NOT in alice's policies → denied expect_error "AccessDenied" \ aws ec2 describe-key-pairs --profile spx-alice echo " ec2:DescribeKeyPairs — denied (not in policy)" expect_error "AccessDenied" \ aws iam create-user --user-name hack --profile spx-alice echo " iam:CreateUser — denied (not in policy)" echo " Explicit allow passed" # Wildcard Allow with Explicit Deny — bob has DenyTerminate (ec2:* Allow + ec2:TerminateInstances Deny) echo " Testing deny override (bob, DenyTerminate)..." aws ec2 describe-instances --profile spx-bob > /dev/null echo " ec2:DescribeInstances — allowed (ec2:* wildcard)" aws ec2 describe-key-pairs --profile spx-bob > /dev/null echo " ec2:DescribeKeyPairs — allowed (ec2:* wildcard)" expect_error "AccessDenied" \ aws ec2 terminate-instances --instance-ids i-fake --profile spx-bob echo " ec2:TerminateInstances — denied (explicit Deny overrides Allow)" expect_error "AccessDenied" \ aws iam list-users --profile spx-bob echo " iam:ListUsers — denied (IAM not covered by ec2:*)" echo " Deny override passed" # Root user bypass echo " Testing root user bypass..." aws ec2 describe-instances > /dev/null aws iam list-users > /dev/null aws iam create-user --user-name temp > /dev/null aws iam delete-user --user-name temp > /dev/null echo " Root bypass passed" # Prefix wildcard — swap alice to EC2DescribeAll echo " Testing prefix wildcard (swap alice to EC2DescribeAll)..." aws iam detach-user-policy --user-name alice \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" aws iam detach-user-policy --user-name alice \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/IAMReadOnly" aws iam attach-user-policy --user-name alice \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2DescribeAll" aws ec2 describe-instances --profile spx-alice > /dev/null echo " ec2:DescribeInstances — allowed (Describe*)" aws ec2 describe-key-pairs --profile spx-alice > /dev/null echo " ec2:DescribeKeyPairs — allowed (Describe*)" expect_error "AccessDenied" \ aws ec2 create-key-pair --key-name x --profile spx-alice echo " ec2:CreateKeyPair — denied (not Describe*)" expect_error "AccessDenied" \ aws iam list-users --profile spx-alice echo " iam:ListUsers — denied (not ec2:Describe*)" echo " Prefix wildcard passed" # FullAdmin — give charlie full access echo " Testing FullAdmin (charlie)..." aws iam attach-user-policy --user-name charlie \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/admin/FullAdmin" aws ec2 describe-instances --profile spx-charlie > /dev/null echo " ec2:DescribeInstances — allowed (was denied)" aws iam list-users --profile spx-charlie > /dev/null echo " iam:ListUsers — allowed (was denied)" echo " FullAdmin passed" echo "IAM Phase 5 passed" # IAM Phase 6: Policy Lifecycle — Detach & Delete echo "" echo "IAM Phase 6: Policy Lifecycle — Detach & Delete" # Detach alice's policy → she loses access echo " Detaching EC2DescribeAll from alice..." aws iam detach-user-policy --user-name alice \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2DescribeAll" expect_error "AccessDenied" \ aws ec2 describe-instances --profile spx-alice echo " Alice lost access after detach" # DeletePolicy — conflict (DenyTerminate still attached to bob) echo " Deleting DenyTerminate while attached (expect DeleteConflict)..." expect_error "DeleteConflict" aws iam delete-policy \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate" # Detach first, then delete echo " Detaching DenyTerminate from bob, then deleting..." aws iam detach-user-policy --user-name bob \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate" aws iam delete-policy \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate" # Verify it's gone expect_error "NoSuchEntity" aws iam get-policy \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/DenyTerminate" echo " DenyTerminate deleted" echo "IAM Phase 6 passed" # IAM Phase 7: Cleanup echo "" echo "IAM Phase 7: IAM Cleanup" # Delete alice (detach remaining, delete key, delete user) echo " Cleaning up alice..." aws iam delete-access-key --user-name alice --access-key-id "$ALICE_KEY_ID" aws iam delete-user --user-name alice # Delete bob echo " Cleaning up bob..." aws iam delete-access-key --user-name bob --access-key-id "$BOB_KEY_ID" aws iam delete-user --user-name bob # Delete charlie (detach FullAdmin first) echo " Cleaning up charlie..." aws iam detach-user-policy --user-name charlie \ --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/admin/FullAdmin" aws iam delete-access-key --user-name charlie --access-key-id "$CHARLIE_KEY_ID" aws iam delete-user --user-name charlie # Verify only root remains FINAL_USER_COUNT=$(aws iam list-users | jq '.Users | length') if [ "$FINAL_USER_COUNT" -ne 1 ]; then echo " ERROR: Expected 1 user (root) after cleanup, got $FINAL_USER_COUNT" exit 1 fi echo " Users cleaned up (root only)" # Delete remaining policies (including bootstrap AdministratorAccess) echo " Cleaning up policies..." aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2ReadOnly" aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/admin/FullAdmin" aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/IAMReadOnly" aws iam delete-policy --policy-arn "arn:aws:iam::${ADMIN_ACCOUNT}:policy/EC2DescribeAll" # AdministratorAccess stays attached to admin — detaching it would revoke # admin's own permissions and block the delete (account root bypass not yet implemented). FINAL_POLICY_COUNT=$(aws iam list-policies | jq '.Policies | length') if [ "$FINAL_POLICY_COUNT" -ne 1 ]; then echo " ERROR: Expected 1 policy (AdministratorAccess) after cleanup, got $FINAL_POLICY_COUNT" exit 1 fi echo " Policies cleaned up" echo "IAM Phase 7 passed" # IAM Phase 8: Roles & Instance Profiles (CRUD + lifecycle guards) # Runs against the admin root profile after Phase 7 cleanup, so the only # pre-existing policy is bootstrap AdministratorAccess. echo "" echo "IAM Phase 8: Roles & Instance Profiles" ROLE_NAME="app-role" PROFILE_NAME="app-profile" OTHER_PROFILE_NAME="other-profile" ADMIN_POLICY_ARN="arn:aws:iam::${ADMIN_ACCOUNT}:policy/AdministratorAccess" ROLE_ARN="arn:aws:iam::${ADMIN_ACCOUNT}:role/${ROLE_NAME}" TRUST_POLICY_DOC='{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}' TRUST_POLICY_V2_DOC='{"Version":"2012-10-17","Statement":[{"Sid":"v2","Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}' # CreateRole — valid trust policy echo " Creating role ${ROLE_NAME}..." ROLE_OUT=$(aws iam create-role --role-name "${ROLE_NAME}" \ --assume-role-policy-document "${TRUST_POLICY_DOC}" \ --description "E2E test role") echo "${ROLE_OUT}" | jq -e ".Role.RoleName == \"${ROLE_NAME}\"" > /dev/null echo "${ROLE_OUT}" | jq -e ".Role.Arn == \"${ROLE_ARN}\"" > /dev/null echo " Created role: ${ROLE_ARN}" # CreateRole — duplicate echo " Creating duplicate role (expect EntityAlreadyExists)..." expect_error "EntityAlreadyExists" aws iam create-role \ --role-name "${ROLE_NAME}" --assume-role-policy-document "${TRUST_POLICY_DOC}" # CreateRole — malformed trust policy echo " Creating role with malformed trust policy (expect MalformedPolicyDocument)..." expect_error "MalformedPolicyDocument" aws iam create-role \ --role-name "bad-role" --assume-role-policy-document '{not valid json' # GetRole echo " Getting role ${ROLE_NAME}..." aws iam get-role --role-name "${ROLE_NAME}" \ | jq -e ".Role.RoleName == \"${ROLE_NAME}\"" > /dev/null # GetRole — missing expect_error "NoSuchEntity" aws iam get-role --role-name "ghost-role" # ListRoles (>= 1) ROLE_COUNT=$(aws iam list-roles | jq '.Roles | length') if [ "${ROLE_COUNT}" -lt 1 ]; then echo " ERROR: Expected >= 1 role, got ${ROLE_COUNT}" exit 1 fi # ListRoles with path-prefix aws iam list-roles --path-prefix / | jq -e '.Roles | length >= 1' > /dev/null # UpdateRole — description + max-session-duration echo " Updating role description and max-session-duration..." aws iam update-role --role-name "${ROLE_NAME}" \ --description "updated" --max-session-duration 7200 UPDATED_DESC=$(aws iam get-role --role-name "${ROLE_NAME}" | jq -r '.Role.Description') if [ "${UPDATED_DESC}" != "updated" ]; then echo " ERROR: Expected description 'updated', got '${UPDATED_DESC}'" exit 1 fi # UpdateRole — out-of-range MaxSessionDuration is enforced by the AWS CLI # client-side (min 3600) before the request leaves the host. Server-side # range validation (900-43200) is exercised in handlers/iam/roles_test.go. # UpdateAssumeRolePolicy echo " Updating trust policy..." aws iam update-assume-role-policy --role-name "${ROLE_NAME}" \ --policy-document "${TRUST_POLICY_V2_DOC}" # AttachRolePolicy + ListAttachedRolePolicies echo " Attaching AdministratorAccess to ${ROLE_NAME}..." aws iam attach-role-policy --role-name "${ROLE_NAME}" --policy-arn "${ADMIN_POLICY_ARN}" ATTACHED=$(aws iam list-attached-role-policies --role-name "${ROLE_NAME}" \ | jq '.AttachedPolicies | length') if [ "${ATTACHED}" -ne 1 ]; then echo " ERROR: Expected 1 attached policy, got ${ATTACHED}" exit 1 fi # Idempotent re-attach aws iam attach-role-policy --role-name "${ROLE_NAME}" --policy-arn "${ADMIN_POLICY_ARN}" RE_ATTACHED=$(aws iam list-attached-role-policies --role-name "${ROLE_NAME}" \ | jq '.AttachedPolicies | length') if [ "${RE_ATTACHED}" -ne 1 ]; then echo " ERROR: Idempotent re-attach grew count to ${RE_ATTACHED}" exit 1 fi # CreateInstanceProfile — two profiles so Replace has a target in Phase 9 echo " Creating instance profile ${PROFILE_NAME}..." aws iam create-instance-profile --instance-profile-name "${PROFILE_NAME}" \ | jq -e ".InstanceProfile.InstanceProfileName == \"${PROFILE_NAME}\"" > /dev/null echo " Creating instance profile ${OTHER_PROFILE_NAME}..." aws iam create-instance-profile --instance-profile-name "${OTHER_PROFILE_NAME}" > /dev/null # CreateInstanceProfile — duplicate expect_error "EntityAlreadyExists" aws iam create-instance-profile \ --instance-profile-name "${PROFILE_NAME}" # GetInstanceProfile / ListInstanceProfiles aws iam get-instance-profile --instance-profile-name "${PROFILE_NAME}" \ | jq -e ".InstanceProfile.InstanceProfileName == \"${PROFILE_NAME}\"" > /dev/null aws iam list-instance-profiles | jq -e '.InstanceProfiles | length >= 2' > /dev/null # AddRoleToInstanceProfile echo " Adding ${ROLE_NAME} to ${PROFILE_NAME}..." aws iam add-role-to-instance-profile \ --instance-profile-name "${PROFILE_NAME}" --role-name "${ROLE_NAME}" # AddRoleToInstanceProfile — second role rejected (one-role limit) expect_error "LimitExceeded" aws iam add-role-to-instance-profile \ --instance-profile-name "${PROFILE_NAME}" --role-name "${ROLE_NAME}" # ListInstanceProfilesForRole — reverse lookup PROFILES_FOR_ROLE=$(aws iam list-instance-profiles-for-role --role-name "${ROLE_NAME}" \ | jq '.InstanceProfiles | length') if [ "${PROFILES_FOR_ROLE}" -ne 1 ]; then echo " ERROR: Expected 1 profile for role, got ${PROFILES_FOR_ROLE}" exit 1 fi # DeleteRole — refused while attached policies + still in profile expect_error "DeleteConflict" aws iam delete-role --role-name "${ROLE_NAME}" # DeleteInstanceProfile — refused while role attached expect_error "DeleteConflict" aws iam delete-instance-profile \ --instance-profile-name "${PROFILE_NAME}" # Add role to OTHER_PROFILE_NAME so Phase 9 has a Replace target aws iam add-role-to-instance-profile \ --instance-profile-name "${OTHER_PROFILE_NAME}" --role-name "${ROLE_NAME}" echo "IAM Phase 8 passed" # IAM Phase 9: EC2 IAM Instance Profile Association # Uses the long-lived # singleton $INSTANCE_ID for the Associate/Disassociate/Replace lifecycle, # then a dedicated short-lived VM to exercise auto-disassociate on terminate. echo "" echo "IAM Phase 9: EC2 IAM Instance Profile Association" # Sanity: singleton must still be running. SINGLETON_STATE=$(aws ec2 describe-instances --instance-ids "${INSTANCE_ID}" \ --query 'Reservations[0].Instances[0].State.Name' --output text) if [ "${SINGLETON_STATE}" != "running" ]; then echo " ERROR: Expected singleton ${INSTANCE_ID} running, got ${SINGLETON_STATE}" exit 1 fi # Associate-iam-instance-profile (post-launch) echo " Associating ${PROFILE_NAME} -> ${INSTANCE_ID}..." ASSOC_OUT=$(aws ec2 associate-iam-instance-profile \ --instance-id "${INSTANCE_ID}" \ --iam-instance-profile "Name=${PROFILE_NAME}") ASSOC_ID=$(echo "${ASSOC_OUT}" | jq -r '.IamInstanceProfileAssociation.AssociationId') if [[ ! "${ASSOC_ID}" =~ ^iip-assoc- ]]; then echo " ERROR: AssociationId malformed: ${ASSOC_ID}" exit 1 fi echo " Got ${ASSOC_ID}" # Associate again — already associated expect_error "IamInstanceProfileAlreadyAssociated" aws ec2 associate-iam-instance-profile \ --instance-id "${INSTANCE_ID}" --iam-instance-profile "Name=${PROFILE_NAME}" # DescribeInstances surfaces IamInstanceProfile PROFILE_ARN_FROM_DESC=$(aws ec2 describe-instances --instance-ids "${INSTANCE_ID}" \ --query 'Reservations[0].Instances[0].IamInstanceProfile.Arn' --output text) if [[ "${PROFILE_ARN_FROM_DESC}" != *":instance-profile/${PROFILE_NAME}" ]]; then echo " ERROR: describe-instances IamInstanceProfile.Arn = '${PROFILE_ARN_FROM_DESC}'" exit 1 fi # DescribeIamInstanceProfileAssociations DESC_OUT=$(aws ec2 describe-iam-instance-profile-associations \ --association-ids "${ASSOC_ID}") echo "${DESC_OUT}" | jq -e ".IamInstanceProfileAssociations[0].InstanceId == \"${INSTANCE_ID}\"" > /dev/null # DeleteInstanceProfile while in-use by a live instance — refused expect_error "DeleteConflict" aws iam delete-instance-profile \ --instance-profile-name "${PROFILE_NAME}" # Replace association → other-profile (yields a fresh association id) echo " Replacing association -> ${OTHER_PROFILE_NAME}..." REPLACE_OUT=$(aws ec2 replace-iam-instance-profile-association \ --association-id "${ASSOC_ID}" \ --iam-instance-profile "Name=${OTHER_PROFILE_NAME}") NEW_ASSOC_ID=$(echo "${REPLACE_OUT}" | jq -r '.IamInstanceProfileAssociation.AssociationId') if [ "${NEW_ASSOC_ID}" = "${ASSOC_ID}" ] || [[ ! "${NEW_ASSOC_ID}" =~ ^iip-assoc- ]]; then echo " ERROR: Replace must mint a new AssociationId (old=${ASSOC_ID}, new=${NEW_ASSOC_ID})" exit 1 fi # Replace with stale id expect_error "NoSuchAssociation" aws ec2 replace-iam-instance-profile-association \ --association-id "${ASSOC_ID}" --iam-instance-profile "Name=${PROFILE_NAME}" # Disassociate via the current (new) id echo " Disassociating ${NEW_ASSOC_ID}..." aws ec2 disassociate-iam-instance-profile --association-id "${NEW_ASSOC_ID}" > /dev/null # Disassociate again with stale id expect_error "NoSuchAssociation" aws ec2 disassociate-iam-instance-profile \ --association-id "${NEW_ASSOC_ID}" # DescribeInstances no longer carries the profile after Disassociate. # AWS CLI --output text prints "None" when the field is null. POST_PROFILE=$(aws ec2 describe-instances --instance-ids "${INSTANCE_ID}" \ --query 'Reservations[0].Instances[0].IamInstanceProfile.Arn' --output text) if [ "${POST_PROFILE}" != "None" ]; then echo " ERROR: IamInstanceProfile.Arn still set after disassociate: '${POST_PROFILE}'" exit 1 fi # RunInstances --iam-instance-profile at launch + auto-disassociate on terminate. # Reuse the AMI/type/key/subnet/SG the singleton was launched against in one # describe-instances round trip. EPH_DESC=$(aws ec2 describe-instances --instance-ids "${INSTANCE_ID}" \ --query 'Reservations[0].Instances[0].[ImageId,InstanceType,KeyName,SubnetId,SecurityGroups[0].GroupId]' \ --output text) EPH_AMI=$(echo "${EPH_DESC}" | awk '{print $1}') EPH_TYPE=$(echo "${EPH_DESC}" | awk '{print $2}') EPH_KEY=$(echo "${EPH_DESC}" | awk '{print $3}') EPH_SUBNET=$(echo "${EPH_DESC}" | awk '{print $4}') EPH_SG=$(echo "${EPH_DESC}" | awk '{print $5}') echo " Launching ephemeral VM with --iam-instance-profile ${PROFILE_NAME}..." EPH_RUN=$(aws ec2 run-instances \ --image-id "${EPH_AMI}" --instance-type "${EPH_TYPE}" \ --key-name "${EPH_KEY}" --subnet-id "${EPH_SUBNET}" \ --security-group-ids "${EPH_SG}" \ --iam-instance-profile "Name=${PROFILE_NAME}" \ --count 1) EPH_ID=$(echo "${EPH_RUN}" | jq -r '.Instances[0].InstanceId') if [[ ! "${EPH_ID}" =~ ^i- ]]; then echo " ERROR: Failed to launch ephemeral VM (got '${EPH_ID}')" exit 1 fi echo " Ephemeral VM: ${EPH_ID}" # Poll until running so DescribeInstances surfaces a stable profile field. for i in $(seq 1 60); do EPH_STATE=$(aws ec2 describe-instances --instance-ids "${EPH_ID}" \ --query 'Reservations[0].Instances[0].State.Name' --output text) if [ "${EPH_STATE}" = "running" ]; then break; fi sleep 2 done if [ "${EPH_STATE}" != "running" ]; then echo " ERROR: Ephemeral VM never reached running (last=${EPH_STATE})" aws ec2 terminate-instances --instance-ids "${EPH_ID}" > /dev/null || true exit 1 fi EPH_ASSOC_ID=$(aws ec2 describe-iam-instance-profile-associations \ --filters "Name=instance-id,Values=${EPH_ID}" \ --query 'IamInstanceProfileAssociations[0].AssociationId' --output text) if [[ ! "${EPH_ASSOC_ID}" =~ ^iip-assoc- ]]; then echo " ERROR: ephemeral VM has no association (got '${EPH_ASSOC_ID}')" aws ec2 terminate-instances --instance-ids "${EPH_ID}" > /dev/null || true exit 1 fi # Terminate → auto-disassociate echo " Terminating ${EPH_ID} (expect auto-disassociate)..." aws ec2 terminate-instances --instance-ids "${EPH_ID}" > /dev/null for i in $(seq 1 60); do EPH_STATE=$(aws ec2 describe-instances --instance-ids "${EPH_ID}" \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "gone") if [ "${EPH_STATE}" = "terminated" ] || [ "${EPH_STATE}" = "gone" ]; then break; fi sleep 2 done # Auto-disassociate is flushed from the daemon's terminate handler — wait # until the binding is cleared (up to 30s) so the NoSuchAssociation assertion # is deterministic. Matches the Eventually(30s, 1s) loop in the Go port. AUTO_DISASSOC_OK=0 for i in $(seq 1 30); do if aws ec2 disassociate-iam-instance-profile --association-id "${EPH_ASSOC_ID}" 2>&1 \ | grep -q "NoSuchAssociation"; then AUTO_DISASSOC_OK=1 break fi sleep 1 done if [ "${AUTO_DISASSOC_OK}" -ne 1 ]; then echo " ERROR: ${EPH_ASSOC_ID} never auto-disassociated after terminate" exit 1 fi echo " Auto-disassociated ${EPH_ASSOC_ID}" # Cleanup: profiles + role + policy. Each step is best-effort so a partial # failure further up doesn't cascade — matches IAM Phase 7 style. aws iam remove-role-from-instance-profile \ --instance-profile-name "${PROFILE_NAME}" --role-name "${ROLE_NAME}" || true aws iam remove-role-from-instance-profile \ --instance-profile-name "${OTHER_PROFILE_NAME}" --role-name "${ROLE_NAME}" || true aws iam delete-instance-profile --instance-profile-name "${PROFILE_NAME}" || true aws iam delete-instance-profile --instance-profile-name "${OTHER_PROFILE_NAME}" || true aws iam detach-role-policy --role-name "${ROLE_NAME}" --policy-arn "${ADMIN_POLICY_ARN}" || true aws iam delete-role --role-name "${ROLE_NAME}" || true # Verify role gone expect_error "NoSuchEntity" aws iam get-role --role-name "${ROLE_NAME}" echo "IAM Phase 9 passed" echo "" echo "IAM E2E Tests Completed Successfully" # ============================================================================= # Phase 8: EC2 Account Scoping # ============================================================================= # Tests that EC2 resources (instances, volumes, key pairs, snapshots, VPCs, # IGWs, EIGWs) are properly isolated between tenant accounts. # Based on: docs/development/feature/iam-phase4-e2e-test-guide.md # Skips: Section 6 (CreateImage — mulga-612), Tags on instances (mulga-613) echo "" echo "Phase 8: EC2 Account Scoping" echo "========================================" # --- Step 1: Account Setup --- echo "" echo "Phase 8 Step 1: Account Setup" echo "----------------------------------------" echo " Creating Team Alpha account..." ALPHA_OUTPUT=$(spx admin account create --name "Team Alpha" 2>&1) echo "$ALPHA_OUTPUT" ALPHA_ACCOUNT=$(echo "$ALPHA_OUTPUT" | grep "Account ID:" | awk '{print $NF}') ALPHA_KEY_ID=$(echo "$ALPHA_OUTPUT" | grep "Access Key ID:" | awk '{print $NF}') ALPHA_SECRET=$(echo "$ALPHA_OUTPUT" | grep "Secret Access Key:" | awk '{print $NF}') if [ -z "$ALPHA_ACCOUNT" ] || [ -z "$ALPHA_KEY_ID" ]; then echo " ERROR: Failed to parse Team Alpha account output" exit 1 fi echo " Team Alpha: account=$ALPHA_ACCOUNT key=$ALPHA_KEY_ID" setup_test_profile spx-team-alpha "$ALPHA_KEY_ID" "$ALPHA_SECRET" echo " Creating Team Beta account..." BETA_OUTPUT=$(spx admin account create --name "Team Beta" 2>&1) echo "$BETA_OUTPUT" BETA_ACCOUNT=$(echo "$BETA_OUTPUT" | grep "Account ID:" | awk '{print $NF}') BETA_KEY_ID=$(echo "$BETA_OUTPUT" | grep "Access Key ID:" | awk '{print $NF}') BETA_SECRET=$(echo "$BETA_OUTPUT" | grep "Secret Access Key:" | awk '{print $NF}') if [ -z "$BETA_ACCOUNT" ] || [ -z "$BETA_KEY_ID" ]; then echo " ERROR: Failed to parse Team Beta account output" exit 1 fi echo " Team Beta: account=$BETA_ACCOUNT key=$BETA_KEY_ID" setup_test_profile spx-team-beta "$BETA_KEY_ID" "$BETA_SECRET" # Verify auth aws ec2 describe-instances --profile spx-team-alpha > /dev/null echo " Alpha auth OK" aws ec2 describe-instances --profile spx-team-beta > /dev/null echo " Beta auth OK" echo " Account setup complete" # --- Step 2: Instance Scoping --- echo "" echo "Phase 8 Step 2: Instance Scoping" echo "----------------------------------------" # Create per-account key pairs (key pairs are account-scoped, root's test-key-1 is invisible) aws ec2 create-key-pair --key-name alpha-instance-key --profile spx-team-alpha > /dev/null aws ec2 create-key-pair --key-name beta-instance-key --profile spx-team-beta > /dev/null echo " Created per-account key pairs for instance launches" echo " Alpha launching instance..." ALPHA_RUN=$(aws ec2 run-instances \ --image-id "$AMI_ID" \ --instance-type "$INSTANCE_TYPE" \ --key-name alpha-instance-key \ --profile spx-team-alpha) ALPHA_INST=$(echo "$ALPHA_RUN" | jq -r '.Instances[0].InstanceId') echo " Alpha instance: $ALPHA_INST" echo " Beta launching instance..." BETA_RUN=$(aws ec2 run-instances \ --image-id "$AMI_ID" \ --instance-type "$INSTANCE_TYPE" \ --key-name beta-instance-key \ --profile spx-team-beta) BETA_INST=$(echo "$BETA_RUN" | jq -r '.Instances[0].InstanceId') echo " Beta instance: $BETA_INST" # Wait for running echo " Waiting for instances to reach running state..." COUNT=0 while [ $COUNT -lt 60 ]; do A_STATE=$(aws ec2 describe-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "pending") B_STATE=$(aws ec2 describe-instances --instance-ids "$BETA_INST" --profile spx-team-beta \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "pending") echo " Alpha=$A_STATE, Beta=$B_STATE" if [ "$A_STATE" == "running" ] && [ "$B_STATE" == "running" ]; then break; fi sleep 1 COUNT=$((COUNT + 1)) done if [ "$A_STATE" != "running" ] || [ "$B_STATE" != "running" ]; then echo " ERROR: Instances failed to reach running state" exit 1 fi echo " Both instances running" # Describe isolation ALPHA_INSTANCES=$(aws ec2 describe-instances --profile spx-team-alpha \ --query 'Reservations[].Instances[].InstanceId' --output text) if echo "$ALPHA_INSTANCES" | grep -q "$BETA_INST"; then echo " ERROR: Alpha can see Beta's instance" exit 1 fi echo " Alpha sees only own instances" BETA_INSTANCES=$(aws ec2 describe-instances --profile spx-team-beta \ --query 'Reservations[].Instances[].InstanceId' --output text) if echo "$BETA_INSTANCES" | grep -q "$ALPHA_INST"; then echo " ERROR: Beta can see Alpha's instance" exit 1 fi echo " Beta sees only own instances" # OwnerId verification ALPHA_OWNER=$(aws ec2 describe-instances --profile spx-team-alpha \ --query 'Reservations[0].OwnerId' --output text) if [ "$ALPHA_OWNER" != "$ALPHA_ACCOUNT" ]; then echo " ERROR: Alpha OwnerId mismatch: expected $ALPHA_ACCOUNT, got $ALPHA_OWNER" exit 1 fi echo " Alpha OwnerId correct: $ALPHA_OWNER" # Cross-account operations expect_error "InvalidInstanceID.NotFound" \ aws ec2 stop-instances --instance-ids "$BETA_INST" --profile spx-team-alpha echo " Alpha cannot stop Beta's instance" expect_error "InvalidInstanceID.NotFound" \ aws ec2 terminate-instances --instance-ids "$ALPHA_INST" --profile spx-team-beta echo " Beta cannot terminate Alpha's instance" expect_error "InvalidInstanceID.NotFound" \ aws ec2 reboot-instances --instance-ids "$BETA_INST" --profile spx-team-alpha echo " Alpha cannot reboot Beta's instance" # Stop Alpha's instance for cross-account start/modify/console tests aws ec2 stop-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha > /dev/null COUNT=0 while [ $COUNT -lt 60 ]; do A_STATE=$(aws ec2 describe-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null) if [ "$A_STATE" == "stopped" ]; then break; fi sleep 1 COUNT=$((COUNT + 1)) done expect_error "InvalidInstanceID.NotFound" \ aws ec2 start-instances --instance-ids "$ALPHA_INST" --profile spx-team-beta echo " Beta cannot start Alpha's stopped instance" expect_error "InvalidInstanceID.NotFound" \ aws ec2 modify-instance-attribute --instance-id "$ALPHA_INST" \ --instance-type '{"Value":"t2.small"}' --profile spx-team-beta echo " Beta cannot modify Alpha's instance" expect_error "InvalidInstanceID.NotFound" \ aws ec2 get-console-output --instance-id "$ALPHA_INST" --profile spx-team-beta echo " Beta cannot get console output of Alpha's instance" # Restart Alpha's instance for later tests aws ec2 start-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha > /dev/null COUNT=0 while [ $COUNT -lt 60 ]; do A_STATE=$(aws ec2 describe-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null) if [ "$A_STATE" == "running" ]; then break; fi sleep 1 COUNT=$((COUNT + 1)) done echo " Instance scoping passed" # --- Step 3: Volume Scoping --- echo "" echo "Phase 8 Step 3: Volume Scoping" echo "----------------------------------------" ALPHA_VOL=$(aws ec2 create-volume --availability-zone "$AZ_NAME" --size 10 \ --volume-type gp3 --profile spx-team-alpha | jq -r '.VolumeId') echo " Alpha volume: $ALPHA_VOL" BETA_VOL=$(aws ec2 create-volume --availability-zone "$AZ_NAME" --size 10 \ --volume-type gp3 --profile spx-team-beta | jq -r '.VolumeId') echo " Beta volume: $BETA_VOL" # Describe isolation ALPHA_VOLS=$(aws ec2 describe-volumes --profile spx-team-alpha \ --query 'Volumes[].VolumeId' --output text) if echo "$ALPHA_VOLS" | grep -q "$BETA_VOL"; then echo " ERROR: Alpha can see Beta's volume" exit 1 fi echo " Alpha sees only own volumes" # Cross-account filter expect_error "InvalidVolume.NotFound" \ aws ec2 describe-volumes --volume-ids "$BETA_VOL" --profile spx-team-alpha echo " Alpha cannot describe Beta's volume by ID" # Cross-account delete expect_error "InvalidVolume.NotFound" \ aws ec2 delete-volume --volume-id "$ALPHA_VOL" --profile spx-team-beta echo " Beta cannot delete Alpha's volume" # Cross-account attach (other's volume) expect_error "InvalidVolume.NotFound" \ aws ec2 attach-volume --volume-id "$ALPHA_VOL" \ --instance-id "$BETA_INST" --device /dev/sdf --profile spx-team-beta echo " Beta cannot attach Alpha's volume" # Attach Alpha's volume to Alpha's instance, then test cross-account detach aws ec2 attach-volume --volume-id "$ALPHA_VOL" \ --instance-id "$ALPHA_INST" --device /dev/sdf --profile spx-team-alpha > /dev/null sleep 1 expect_error "InvalidVolume.NotFound" \ aws ec2 detach-volume --volume-id "$ALPHA_VOL" --profile spx-team-beta echo " Beta cannot detach Alpha's volume" # Cross-account modify expect_error "InvalidVolume.NotFound" \ aws ec2 modify-volume --volume-id "$ALPHA_VOL" --size 20 --profile spx-team-beta echo " Beta cannot modify Alpha's volume" # Detach for cleanup later aws ec2 detach-volume --volume-id "$ALPHA_VOL" --profile spx-team-alpha > /dev/null sleep 1 echo " Volume scoping passed" # --- Step 4: Key Pair Scoping --- echo "" echo "Phase 8 Step 4: Key Pair Scoping" echo "----------------------------------------" aws ec2 create-key-pair --key-name alpha-key --profile spx-team-alpha > /dev/null ALPHA_KEYPAIR_ID=$(aws ec2 describe-key-pairs --key-names alpha-key \ --profile spx-team-alpha --query 'KeyPairs[0].KeyPairId' --output text) echo " Alpha key: alpha-key ($ALPHA_KEYPAIR_ID)" aws ec2 create-key-pair --key-name beta-key --profile spx-team-beta > /dev/null echo " Beta key: beta-key" # Describe isolation ALPHA_KEYS=$(aws ec2 describe-key-pairs --profile spx-team-alpha \ --query 'KeyPairs[].KeyName' --output text) if echo "$ALPHA_KEYS" | grep -q "beta-key"; then echo " ERROR: Alpha can see Beta's key" exit 1 fi echo " Alpha sees only own keys" # Same name in both accounts (namespace isolation) aws ec2 create-key-pair --key-name shared-name --profile spx-team-alpha > /dev/null aws ec2 create-key-pair --key-name shared-name --profile spx-team-beta > /dev/null ALPHA_SHARED_ID=$(aws ec2 describe-key-pairs --key-names shared-name \ --profile spx-team-alpha --query 'KeyPairs[0].KeyPairId' --output text) BETA_SHARED_ID=$(aws ec2 describe-key-pairs --key-names shared-name \ --profile spx-team-beta --query 'KeyPairs[0].KeyPairId' --output text) if [ "$ALPHA_SHARED_ID" == "$BETA_SHARED_ID" ]; then echo " ERROR: Same KeyPairId for shared-name in both accounts" exit 1 fi echo " Namespace isolation: alpha=$ALPHA_SHARED_ID, beta=$BETA_SHARED_ID" # Cross-account delete (idempotent, but shouldn't affect other account) aws ec2 delete-key-pair --key-name alpha-key --profile spx-team-beta ALPHA_KEY_CHECK=$(aws ec2 describe-key-pairs --key-names alpha-key \ --profile spx-team-alpha --query 'KeyPairs[0].KeyPairId' --output text) if [ "$ALPHA_KEY_CHECK" != "$ALPHA_KEYPAIR_ID" ]; then echo " ERROR: Beta's delete affected Alpha's key" exit 1 fi echo " Cross-account delete had no effect on Alpha's key" # Import key pair — account scoped ssh-keygen -t ed25519 -f /tmp/test-import-key -N "" -q aws ec2 import-key-pair --key-name imported-key \ --public-key-material fileb:///tmp/test-import-key.pub --profile spx-team-alpha > /dev/null BETA_IMPORT_CHECK=$(aws ec2 describe-key-pairs --profile spx-team-beta \ --query 'KeyPairs[].KeyName' --output text) if echo "$BETA_IMPORT_CHECK" | grep -q "imported-key"; then echo " ERROR: Beta can see Alpha's imported key" exit 1 fi echo " Imported key invisible to Beta" rm -f /tmp/test-import-key /tmp/test-import-key.pub echo " Key pair scoping passed" # --- Step 5: Snapshot Scoping --- echo "" echo "Phase 8 Step 5: Snapshot Scoping" echo "----------------------------------------" ALPHA_SNAP=$(aws ec2 create-snapshot --volume-id "$ALPHA_VOL" \ --description "Alpha snapshot" --profile spx-team-alpha | jq -r '.SnapshotId') echo " Alpha snapshot: $ALPHA_SNAP" BETA_SNAP=$(aws ec2 create-snapshot --volume-id "$BETA_VOL" \ --description "Beta snapshot" --profile spx-team-beta | jq -r '.SnapshotId') echo " Beta snapshot: $BETA_SNAP" # Describe isolation ALPHA_SNAPS=$(aws ec2 describe-snapshots --owner-ids self --profile spx-team-alpha \ --query 'Snapshots[].SnapshotId' --output text) if echo "$ALPHA_SNAPS" | grep -q "$BETA_SNAP"; then echo " ERROR: Alpha can see Beta's snapshot" exit 1 fi echo " Alpha sees only own snapshots" # OwnerId verification ALPHA_SNAP_OWNER=$(aws ec2 describe-snapshots --owner-ids self --profile spx-team-alpha \ --query 'Snapshots[0].OwnerId' --output text) if [ "$ALPHA_SNAP_OWNER" != "$ALPHA_ACCOUNT" ]; then echo " ERROR: Snapshot OwnerId mismatch: expected $ALPHA_ACCOUNT, got $ALPHA_SNAP_OWNER" exit 1 fi echo " Alpha snapshot OwnerId correct" # Cross-account delete expect_error "UnauthorizedOperation" \ aws ec2 delete-snapshot --snapshot-id "$ALPHA_SNAP" --profile spx-team-beta echo " Beta cannot delete Alpha's snapshot" # Cross-account snapshot from other's volume expect_error "InvalidVolume.NotFound" \ aws ec2 create-snapshot --volume-id "$ALPHA_VOL" \ --description "stolen" --profile spx-team-beta echo " Beta cannot snapshot Alpha's volume" echo " Snapshot scoping passed" # --- Step 6: VPC/Subnet Scoping --- echo "" echo "Phase 8 Step 6: VPC/Subnet Scoping" echo "----------------------------------------" ALPHA_VPC=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \ --profile spx-team-alpha --query 'Vpc.VpcId' --output text) echo " Alpha VPC: $ALPHA_VPC" BETA_VPC=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \ --profile spx-team-beta --query 'Vpc.VpcId' --output text) echo " Beta VPC: $BETA_VPC (same CIDR — no conflict)" # Describe isolation ALPHA_VPCS=$(aws ec2 describe-vpcs --profile spx-team-alpha \ --query 'Vpcs[].VpcId' --output text) if echo "$ALPHA_VPCS" | grep -q "$BETA_VPC"; then echo " ERROR: Alpha can see Beta's VPC" exit 1 fi echo " VPC describe isolation OK" # Cross-account describe by ID expect_error "InvalidVpcID.NotFound" \ aws ec2 describe-vpcs --vpc-ids "$BETA_VPC" --profile spx-team-alpha echo " Alpha cannot describe Beta's VPC by ID" # Cross-account delete expect_error "InvalidVpcID.NotFound" \ aws ec2 delete-vpc --vpc-id "$ALPHA_VPC" --profile spx-team-beta echo " Beta cannot delete Alpha's VPC" # Create subnets ALPHA_SUBNET=$(aws ec2 create-subnet --vpc-id "$ALPHA_VPC" --cidr-block 10.0.1.0/24 \ --profile spx-team-alpha --query 'Subnet.SubnetId' --output text) echo " Alpha subnet: $ALPHA_SUBNET" BETA_SUBNET=$(aws ec2 create-subnet --vpc-id "$BETA_VPC" --cidr-block 10.0.1.0/24 \ --profile spx-team-beta --query 'Subnet.SubnetId' --output text) echo " Beta subnet: $BETA_SUBNET" # Subnet describe isolation ALPHA_SUBNETS=$(aws ec2 describe-subnets --profile spx-team-alpha \ --query 'Subnets[].SubnetId' --output text) if echo "$ALPHA_SUBNETS" | grep -q "$BETA_SUBNET"; then echo " ERROR: Alpha can see Beta's subnet" exit 1 fi echo " Subnet describe isolation OK" # Cross-account create subnet in other's VPC expect_error "InvalidVpcID.NotFound" \ aws ec2 create-subnet --vpc-id "$ALPHA_VPC" --cidr-block 10.0.2.0/24 \ --profile spx-team-beta echo " Beta cannot create subnet in Alpha's VPC" # Cross-account subnet delete expect_error "InvalidSubnetID.NotFound" \ aws ec2 delete-subnet --subnet-id "$ALPHA_SUBNET" --profile spx-team-beta echo " Beta cannot delete Alpha's subnet" echo " VPC/Subnet scoping passed" # --- Step 7: IGW + EIGW Scoping --- echo "" echo "Phase 8 Step 7: IGW + EIGW Scoping" echo "----------------------------------------" # IGW ALPHA_IGW=$(aws ec2 create-internet-gateway --profile spx-team-alpha \ --query 'InternetGateway.InternetGatewayId' --output text) echo " Alpha IGW: $ALPHA_IGW" BETA_IGW=$(aws ec2 create-internet-gateway --profile spx-team-beta \ --query 'InternetGateway.InternetGatewayId' --output text) echo " Beta IGW: $BETA_IGW" # IGW describe isolation ALPHA_IGWS=$(aws ec2 describe-internet-gateways --profile spx-team-alpha \ --query 'InternetGateways[].InternetGatewayId' --output text) if echo "$ALPHA_IGWS" | grep -q "$BETA_IGW"; then echo " ERROR: Alpha can see Beta's IGW" exit 1 fi echo " IGW describe isolation OK" # Cross-account IGW describe by ID expect_error "InvalidInternetGatewayID.NotFound" \ aws ec2 describe-internet-gateways --internet-gateway-ids "$BETA_IGW" \ --profile spx-team-alpha echo " Alpha cannot describe Beta's IGW by ID" # Cross-account IGW delete expect_error "InvalidInternetGatewayID.NotFound" \ aws ec2 delete-internet-gateway --internet-gateway-id "$ALPHA_IGW" \ --profile spx-team-beta echo " Beta cannot delete Alpha's IGW" # Cross-account attach IGW to other's VPC expect_error "InvalidInternetGatewayID.NotFound" \ aws ec2 attach-internet-gateway --internet-gateway-id "$BETA_IGW" \ --vpc-id "$ALPHA_VPC" --profile spx-team-alpha echo " Alpha cannot attach Beta's IGW to own VPC" # Attach Alpha's IGW, test cross-account detach aws ec2 attach-internet-gateway --internet-gateway-id "$ALPHA_IGW" \ --vpc-id "$ALPHA_VPC" --profile spx-team-alpha > /dev/null expect_error "InvalidInternetGatewayID.NotFound" \ aws ec2 detach-internet-gateway --internet-gateway-id "$ALPHA_IGW" \ --vpc-id "$ALPHA_VPC" --profile spx-team-beta echo " Beta cannot detach Alpha's IGW" # EIGW ALPHA_EIGW=$(aws ec2 create-egress-only-internet-gateway --vpc-id "$ALPHA_VPC" \ --profile spx-team-alpha \ --query 'EgressOnlyInternetGateway.EgressOnlyInternetGatewayId' --output text) echo " Alpha EIGW: $ALPHA_EIGW" BETA_EIGW=$(aws ec2 create-egress-only-internet-gateway --vpc-id "$BETA_VPC" \ --profile spx-team-beta \ --query 'EgressOnlyInternetGateway.EgressOnlyInternetGatewayId' --output text) echo " Beta EIGW: $BETA_EIGW" # EIGW describe isolation ALPHA_EIGWS=$(aws ec2 describe-egress-only-internet-gateways --profile spx-team-alpha \ --query 'EgressOnlyInternetGateways[].EgressOnlyInternetGatewayId' --output text) if echo "$ALPHA_EIGWS" | grep -q "$BETA_EIGW"; then echo " ERROR: Alpha can see Beta's EIGW" exit 1 fi echo " EIGW describe isolation OK" # Cross-account EIGW delete expect_error "" \ aws ec2 delete-egress-only-internet-gateway \ --egress-only-internet-gateway-id "$ALPHA_EIGW" --profile spx-team-beta 2>&1 || true # Verify Alpha's EIGW still exists ALPHA_EIGW_CHECK=$(aws ec2 describe-egress-only-internet-gateways --profile spx-team-alpha \ --query 'EgressOnlyInternetGateways[].EgressOnlyInternetGatewayId' --output text) if ! echo "$ALPHA_EIGW_CHECK" | grep -q "$ALPHA_EIGW"; then echo " ERROR: Alpha's EIGW was deleted by Beta" exit 1 fi echo " Beta cannot delete Alpha's EIGW" # Cross-account EIGW creation in other's VPC expect_error "" \ aws ec2 create-egress-only-internet-gateway --vpc-id "$ALPHA_VPC" \ --profile spx-team-beta 2>&1 || true echo " Beta cannot create EIGW in Alpha's VPC" echo " IGW + EIGW scoping passed" # --- Step 8: Account Settings --- echo "" echo "Phase 8 Step 8: Account Settings" echo "----------------------------------------" aws ec2 enable-ebs-encryption-by-default --profile spx-team-alpha > /dev/null BETA_ENC=$(aws ec2 get-ebs-encryption-by-default --profile spx-team-beta \ --query 'EbsEncryptionByDefault' --output text) if [ "$BETA_ENC" != "False" ]; then echo " ERROR: Alpha's encryption setting leaked to Beta (got $BETA_ENC)" exit 1 fi echo " Alpha enable did not affect Beta" # Independent toggle aws ec2 enable-ebs-encryption-by-default --profile spx-team-beta > /dev/null aws ec2 disable-ebs-encryption-by-default --profile spx-team-alpha > /dev/null ALPHA_ENC=$(aws ec2 get-ebs-encryption-by-default --profile spx-team-alpha \ --query 'EbsEncryptionByDefault' --output text) BETA_ENC=$(aws ec2 get-ebs-encryption-by-default --profile spx-team-beta \ --query 'EbsEncryptionByDefault' --output text) if [ "$ALPHA_ENC" != "False" ] || [ "$BETA_ENC" != "True" ]; then echo " ERROR: Independent settings failed: alpha=$ALPHA_ENC beta=$BETA_ENC" exit 1 fi echo " Independent toggle verified: alpha=$ALPHA_ENC, beta=$BETA_ENC" # Reset aws ec2 disable-ebs-encryption-by-default --profile spx-team-beta > /dev/null echo " Account settings scoping passed" # --- Step 9: Global Resources --- echo "" echo "Phase 8 Step 9: Global Resources" echo "----------------------------------------" ALPHA_REGIONS=$(aws ec2 describe-regions --profile spx-team-alpha \ --query 'Regions[].RegionName' --output text) BETA_REGIONS=$(aws ec2 describe-regions --profile spx-team-beta \ --query 'Regions[].RegionName' --output text) if [ "$ALPHA_REGIONS" != "$BETA_REGIONS" ]; then echo " ERROR: Regions differ between accounts" exit 1 fi echo " Regions identical" ALPHA_AZS=$(aws ec2 describe-availability-zones --profile spx-team-alpha \ --query 'AvailabilityZones[].ZoneName' --output text) BETA_AZS=$(aws ec2 describe-availability-zones --profile spx-team-beta \ --query 'AvailabilityZones[].ZoneName' --output text) if [ "$ALPHA_AZS" != "$BETA_AZS" ]; then echo " ERROR: AZs differ between accounts" exit 1 fi echo " Availability zones identical" ALPHA_TYPES=$(aws ec2 describe-instance-types --profile spx-team-alpha \ --query 'InstanceTypes[].InstanceType' --output text | tr '\t' '\n' | sort) BETA_TYPES=$(aws ec2 describe-instance-types --profile spx-team-beta \ --query 'InstanceTypes[].InstanceType' --output text | tr '\t' '\n' | sort) if [ "$ALPHA_TYPES" != "$BETA_TYPES" ]; then echo " ERROR: Instance types differ between accounts" exit 1 fi echo " Instance types identical" echo " Global resources passed" # --- Step 10: Edge Cases --- echo "" echo "Phase 8 Step 10: Edge Cases" echo "----------------------------------------" # Empty account (Gamma) echo " Creating empty Gamma account..." GAMMA_OUTPUT=$(spx admin account create --name "Team Gamma" 2>&1) GAMMA_KEY_ID=$(echo "$GAMMA_OUTPUT" | grep "Access Key ID:" | awk '{print $NF}') GAMMA_SECRET=$(echo "$GAMMA_OUTPUT" | grep "Secret Access Key:" | awk '{print $NF}') setup_test_profile spx-team-gamma "$GAMMA_KEY_ID" "$GAMMA_SECRET" GAMMA_INSTANCES=$(aws ec2 describe-instances --profile spx-team-gamma \ --query 'Reservations' --output text) if [ -n "$GAMMA_INSTANCES" ] && [ "$GAMMA_INSTANCES" != "None" ]; then echo " ERROR: Gamma has instances" exit 1 fi echo " Gamma: no instances" # Skip volume check: root-account volumes (empty TenantID) are visible to all accounts by design echo " Gamma: volumes skipped (root legacy volumes visible to all)" GAMMA_KEYS=$(aws ec2 describe-key-pairs --profile spx-team-gamma \ --query 'KeyPairs' --output text) if [ -n "$GAMMA_KEYS" ] && [ "$GAMMA_KEYS" != "None" ]; then echo " ERROR: Gamma has key pairs" exit 1 fi echo " Gamma: no key pairs" GAMMA_SNAPS=$(aws ec2 describe-snapshots --owner-ids self --profile spx-team-gamma \ --query 'Snapshots' --output text) if [ -n "$GAMMA_SNAPS" ] && [ "$GAMMA_SNAPS" != "None" ]; then echo " ERROR: Gamma has snapshots" exit 1 fi echo " Gamma: no snapshots" # Root isolation from tenants echo " Verifying root isolation from tenants..." aws ec2 create-key-pair --key-name root-scoping-key > /dev/null ALPHA_ROOT_CHECK=$(aws ec2 describe-key-pairs --profile spx-team-alpha \ --query 'KeyPairs[].KeyName' --output text) if echo "$ALPHA_ROOT_CHECK" | grep -q "root-scoping-key"; then echo " ERROR: Alpha can see root's key pair" exit 1 fi echo " Tenants cannot see root's key pairs" ROOT_INSTANCE_CHECK=$(aws ec2 describe-instances \ --query 'Reservations[].Instances[].InstanceId' --output text) if echo "$ROOT_INSTANCE_CHECK" | grep -q "$ALPHA_INST"; then echo " ERROR: Root can see Alpha's instance" exit 1 fi echo " Root cannot see tenant instances" aws ec2 delete-key-pair --key-name root-scoping-key > /dev/null # Non-existent resource IDs — same error as cross-account expect_error "InvalidVolume.NotFound" \ aws ec2 delete-volume --volume-id vol-00000000000000000 --profile spx-team-alpha echo " Non-existent volume: same error as cross-account" expect_error "InvalidSnapshot.NotFound" \ aws ec2 delete-snapshot --snapshot-id snap-00000000000000000 --profile spx-team-alpha echo " Non-existent snapshot: same error as cross-account" echo " Edge cases passed" # --- Step 11: EC2 Account Scoping Cleanup --- echo "" echo "Phase 8 Step 11: EC2 Account Scoping Cleanup" echo "----------------------------------------" # Terminate instances echo " Terminating Alpha instance..." aws ec2 terminate-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha > /dev/null echo " Terminating Beta instance..." aws ec2 terminate-instances --instance-ids "$BETA_INST" --profile spx-team-beta > /dev/null # Wait for termination COUNT=0 while [ $COUNT -lt 60 ]; do A_STATE=$(aws ec2 describe-instances --instance-ids "$ALPHA_INST" --profile spx-team-alpha \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "terminated") B_STATE=$(aws ec2 describe-instances --instance-ids "$BETA_INST" --profile spx-team-beta \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "terminated") if [ "$A_STATE" == "terminated" ] && [ "$B_STATE" == "terminated" ]; then break fi sleep 1 COUNT=$((COUNT + 1)) done echo " Instances terminated" # Delete snapshots echo " Deleting snapshots..." aws ec2 delete-snapshot --snapshot-id "$ALPHA_SNAP" --profile spx-team-alpha 2>/dev/null || true aws ec2 delete-snapshot --snapshot-id "$BETA_SNAP" --profile spx-team-beta 2>/dev/null || true # Delete volumes (wait for detach/termination cleanup) sleep 3 echo " Deleting volumes..." aws ec2 delete-volume --volume-id "$ALPHA_VOL" --profile spx-team-alpha 2>/dev/null || true aws ec2 delete-volume --volume-id "$BETA_VOL" --profile spx-team-beta 2>/dev/null || true # Delete key pairs echo " Deleting key pairs..." for key in alpha-key alpha-instance-key shared-name imported-key; do aws ec2 delete-key-pair --key-name "$key" --profile spx-team-alpha 2>/dev/null || true done for key in beta-key beta-instance-key shared-name; do aws ec2 delete-key-pair --key-name "$key" --profile spx-team-beta 2>/dev/null || true done # Delete EIGWs echo " Deleting EIGWs..." aws ec2 delete-egress-only-internet-gateway \ --egress-only-internet-gateway-id "$ALPHA_EIGW" --profile spx-team-alpha 2>/dev/null || true aws ec2 delete-egress-only-internet-gateway \ --egress-only-internet-gateway-id "$BETA_EIGW" --profile spx-team-beta 2>/dev/null || true # Detach + delete IGWs echo " Deleting IGWs..." aws ec2 detach-internet-gateway --internet-gateway-id "$ALPHA_IGW" \ --vpc-id "$ALPHA_VPC" --profile spx-team-alpha 2>/dev/null || true aws ec2 delete-internet-gateway --internet-gateway-id "$ALPHA_IGW" \ --profile spx-team-alpha 2>/dev/null || true aws ec2 delete-internet-gateway --internet-gateway-id "$BETA_IGW" \ --profile spx-team-beta 2>/dev/null || true # Delete subnets echo " Deleting subnets..." aws ec2 delete-subnet --subnet-id "$ALPHA_SUBNET" --profile spx-team-alpha 2>/dev/null || true aws ec2 delete-subnet --subnet-id "$BETA_SUBNET" --profile spx-team-beta 2>/dev/null || true # Delete VPCs echo " Deleting VPCs..." aws ec2 delete-vpc --vpc-id "$ALPHA_VPC" --profile spx-team-alpha 2>/dev/null || true aws ec2 delete-vpc --vpc-id "$BETA_VPC" --profile spx-team-beta 2>/dev/null || true # Clean up AWS CLI profiles for p in spx-team-alpha spx-team-beta spx-team-gamma; do aws configure set aws_access_key_id "" --profile $p 2>/dev/null || true aws configure set aws_secret_access_key "" --profile $p 2>/dev/null || true done echo " EC2 account scoping cleanup complete" echo "" echo "Phase 8: EC2 Account Scoping PASSED" # Phase 8c: Route Table Validation echo "" echo "Phase 8c: Route Table Validation" echo "========================================" # Step 1: Default VPC has a main route table with local + IGW routes echo "Step 1: Verify default VPC main route table" RTB_DEFAULT_VPC=$(aws ec2 describe-vpcs --query 'Vpcs[?IsDefault==`true`].VpcId | [0]' --output text) if [ -z "$RTB_DEFAULT_VPC" ] || [ "$RTB_DEFAULT_VPC" = "None" ]; then echo "FAIL: No default VPC found — route table tests require a default VPC" exit 1 fi MAIN_RTB=$(aws ec2 describe-route-tables \ --filters "Name=vpc-id,Values=$RTB_DEFAULT_VPC" "Name=association.main,Values=true" \ --query 'RouteTables[0].RouteTableId' --output text) if [ -z "$MAIN_RTB" ] || [ "$MAIN_RTB" = "None" ]; then echo "FAIL: No main route table found for default VPC $RTB_DEFAULT_VPC" exit 1 fi echo " Main route table: $MAIN_RTB" # Verify local route exists LOCAL_ROUTE=$(aws ec2 describe-route-tables --route-table-ids "$MAIN_RTB" \ --query 'RouteTables[0].Routes[?GatewayId==`local`].DestinationCidrBlock | [0]' --output text) if [ -z "$LOCAL_ROUTE" ] || [ "$LOCAL_ROUTE" = "None" ]; then echo "FAIL: Main route table missing local route" exit 1 fi echo " Local route: $LOCAL_ROUTE → local" # Verify IGW route exists IGW_ROUTE=$(aws ec2 describe-route-tables --route-table-ids "$MAIN_RTB" \ --query 'RouteTables[0].Routes[?DestinationCidrBlock==`0.0.0.0/0`].GatewayId | [0]' --output text) if [ -z "$IGW_ROUTE" ] || [ "$IGW_ROUTE" = "None" ]; then echo "FAIL: Main route table missing 0.0.0.0/0 → IGW route" exit 1 fi echo " Default route: 0.0.0.0/0 → $IGW_ROUTE" echo " PASS: Default VPC main route table verified" # Step 2: Custom route table lifecycle echo "" echo "Step 2: Custom route table CRUD lifecycle" RTB_VPC=$RTB_DEFAULT_VPC # Create custom route table CUSTOM_RTB=$(aws ec2 create-route-table --vpc-id "$RTB_VPC" \ --query 'RouteTable.RouteTableId' --output text) echo " Created: $CUSTOM_RTB" # Verify local route present CUSTOM_LOCAL=$(aws ec2 describe-route-tables --route-table-ids "$CUSTOM_RTB" \ --query 'RouteTables[0].Routes[?GatewayId==`local`].DestinationCidrBlock | [0]' --output text) if [ -z "$CUSTOM_LOCAL" ] || [ "$CUSTOM_LOCAL" = "None" ]; then echo "FAIL: Custom route table missing local route" exit 1 fi echo " Local route present: $CUSTOM_LOCAL" # Find attached IGW for this VPC RTB_IGW=$(aws ec2 describe-internet-gateways \ --filters "Name=attachment.vpc-id,Values=$RTB_VPC" \ --query 'InternetGateways[0].InternetGatewayId' --output text) # Add IGW route aws ec2 create-route --route-table-id "$CUSTOM_RTB" \ --destination-cidr-block 0.0.0.0/0 --gateway-id "$RTB_IGW" > /dev/null echo " Added route: 0.0.0.0/0 → $RTB_IGW" # Find default subnet RTB_SUBNET=$(aws ec2 describe-subnets \ --filters "Name=vpc-id,Values=$RTB_VPC" "Name=default-for-az,Values=true" \ --query 'Subnets[0].SubnetId' --output text) # Associate with default subnet RTB_ASSOC=$(aws ec2 associate-route-table --route-table-id "$CUSTOM_RTB" \ --subnet-id "$RTB_SUBNET" --query 'AssociationId' --output text) echo " Associated: $RTB_ASSOC ($RTB_SUBNET → $CUSTOM_RTB)" # Disassociate aws ec2 disassociate-route-table --association-id "$RTB_ASSOC" echo " Disassociated: $RTB_ASSOC" # Delete route, then table aws ec2 delete-route --route-table-id "$CUSTOM_RTB" --destination-cidr-block 0.0.0.0/0 echo " Deleted route: 0.0.0.0/0" aws ec2 delete-route-table --route-table-id "$CUSTOM_RTB" echo " Deleted route table: $CUSTOM_RTB" echo " PASS: Custom route table lifecycle complete" # Step 3: Error paths echo "" echo "Step 3: Route table error paths" # Create a temporary route table for error testing ERR_RTB=$(aws ec2 create-route-table --vpc-id "$RTB_VPC" \ --query 'RouteTable.RouteTableId' --output text) # Cannot delete local route if aws ec2 delete-route --route-table-id "$ERR_RTB" \ --destination-cidr-block "$CUSTOM_LOCAL" 2>/dev/null; then echo "FAIL: Should not be able to delete local route" exit 1 fi echo " PASS: Delete local route correctly rejected" # Cannot delete main route table if aws ec2 delete-route-table --route-table-id "$MAIN_RTB" 2>/dev/null; then echo "FAIL: Should not be able to delete main route table" exit 1 fi echo " PASS: Delete main route table correctly rejected" # Cleanup aws ec2 delete-route-table --route-table-id "$ERR_RTB" echo " Cleanup: deleted $ERR_RTB" echo "" echo "Phase 8c: Route Table Validation PASSED" # Phase 8b: VPC Public/Private Subnet E2E echo "" echo "Phase 8b: VPC Public/Private Subnet E2E" echo "========================================" # Check if external networking is configured HAS_EXTERNAL=false if grep -q 'external_mode = "pool"' /etc/spinifex/spinifex.toml 2>/dev/null; then HAS_EXTERNAL=true fi HAS_OVN=false if command -v ovn-nbctl &>/dev/null; then HAS_OVN=true fi if [ "$HAS_EXTERNAL" != "true" ]; then echo "Skipping Phase 8b: no external networking configured" echo "(set SPX_EXTERNAL_POOL=start-end to enable)" else echo "Phase 8b Step 1: Discover default VPC infrastructure" # The default VPC, subnet, and IGW are created by admin init DEFAULT_VPC=$(aws ec2 describe-vpcs --query 'Vpcs[?IsDefault==`true`].VpcId | [0]' --output text) if [ -z "$DEFAULT_VPC" ] || [ "$DEFAULT_VPC" = "None" ]; then echo "FATAL: No default VPC found — admin init may not have run" exit 1 fi DEFAULT_SUBNET=$(aws ec2 describe-subnets --query "Subnets[?VpcId=='${DEFAULT_VPC}' && DefaultForAz==\`true\`].SubnetId | [0]" --output text) echo "Default VPC: $DEFAULT_VPC" echo "Default subnet: $DEFAULT_SUBNET (MapPublicIpOnLaunch=true)" echo "" echo "Phase 8b Step 2: Public subnet instance" # Launch instance in default subnet (MapPublicIpOnLaunch=true, IGW attached) echo "Launching instance in public subnet..." PUB_RUN_OUTPUT=$(aws ec2 run-instances \ --image-id "$AMI_ID" \ --instance-type "$INSTANCE_TYPE" \ --subnet-id "$DEFAULT_SUBNET" \ --key-name test-key-1 \ --output json 2>&1) PUB_INSTANCE_ID=$(echo "$PUB_RUN_OUTPUT" | jq -r '.Instances[0].InstanceId') echo "Public instance: $PUB_INSTANCE_ID" # Wait for running echo "Waiting for public instance to reach running state..." PUB_COUNT=0 PUB_STATE="unknown" while [ $PUB_COUNT -lt 90 ]; do PUB_STATE=$(aws ec2 describe-instances --instance-ids "$PUB_INSTANCE_ID" \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "pending") if [ "$PUB_STATE" == "running" ]; then echo "Public instance is running" break fi sleep 1 PUB_COUNT=$((PUB_COUNT + 1)) done if [ "$PUB_STATE" != "running" ]; then echo "FAIL: Public instance did not reach running state (got: $PUB_STATE)" exit 1 fi # Verify public IP is assigned PUB_IP=$(aws ec2 describe-instances --instance-ids "$PUB_INSTANCE_ID" \ --query 'Reservations[0].Instances[0].PublicIpAddress' --output text) PUB_PRIVATE_IP=$(aws ec2 describe-instances --instance-ids "$PUB_INSTANCE_ID" \ --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text) echo "Public IP: $PUB_IP, Private IP: $PUB_PRIVATE_IP" if [ -z "$PUB_IP" ] || [ "$PUB_IP" = "None" ]; then echo "FAIL: Public subnet instance has no PublicIpAddress" exit 1 fi echo "PASS: Public subnet instance has PublicIpAddress=$PUB_IP" # OVN verification: per-VM dnat_and_snat rule exists if [ "$HAS_OVN" = true ]; then NAT_RULES=$(sudo ovn-nbctl --no-leader-only lr-nat-list "vpc-${DEFAULT_VPC}" 2>/dev/null || echo "") # Verify dnat_and_snat exists for this VM's private IP if echo "$NAT_RULES" | grep -q "dnat_and_snat.*${PUB_PRIVATE_IP}"; then echo "PASS: OVN dnat_and_snat rule exists for $PUB_PRIVATE_IP" else echo "FAIL: No OVN dnat_and_snat rule for $PUB_PRIVATE_IP" echo "NAT rules:" echo "$NAT_RULES" exit 1 fi # Verify NO blanket VPC CIDR SNAT (mulga-754 fix) if echo "$NAT_RULES" | grep -q "snat.*172.31.0.0/16"; then echo "FAIL: Blanket VPC CIDR SNAT exists (should have been removed by mulga-754)" echo "NAT rules:" echo "$NAT_RULES" exit 1 fi echo "PASS: No blanket VPC CIDR SNAT on router (AWS parity)" fi # SSH into public instance via its public IP and verify outbound connectivity. # This proves the full path: host → public IP → OVN DNAT → VM → OVN SNAT → internet. echo "Waiting for SSH via public IP $PUB_IP..." PUB_SSH_READY=false for attempt in $(seq 1 60); do if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \ -o ConnectTimeout=3 -o BatchMode=yes -i "test-key-1.pem" \ ec2-user@"$PUB_IP" 'true' 2>/dev/null; then PUB_SSH_READY=true break fi sleep 2 done if [ "$PUB_SSH_READY" = true ]; then echo "PASS: SSH via public IP $PUB_IP succeeded" # Verify outbound internet from VM (via OVN SNAT, not QEMU user-mode) set +e OUTBOUND_OK=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \ -o ConnectTimeout=5 -o BatchMode=yes -i "test-key-1.pem" \ ec2-user@"$PUB_IP" 'ping -c 2 -W 5 8.8.8.8 >/dev/null 2>&1 && echo yes || echo no' 2>/dev/null) set -e if [ "$OUTBOUND_OK" = "yes" ]; then echo "PASS: Outbound internet from public instance via OVN SNAT" else echo "WARN: Outbound internet test inconclusive (may depend on WAN gateway)" fi # Verify cloud-init injected the Spinifex CA into the guest trust store. # The guest should be able to curl Spinifex services (awsgw, predastore) # WITHOUT --insecure, proving the CA cert was installed via ca_certs module. echo "" echo "Phase 8b Step 2b: In-guest TLS trust (cloud-init CA injection)" # Test 1: Guest can reach awsgw via HTTPS without --insecure set +e GUEST_TLS_AWSGW=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \ -o ConnectTimeout=5 -o BatchMode=yes -i "test-key-1.pem" \ ec2-user@"$PUB_IP" "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 https://${GATEWAY_HOST}:9999/ 2>/dev/null || echo 000" 2>/dev/null) set -e if [ -n "$GUEST_TLS_AWSGW" ] && [ "$GUEST_TLS_AWSGW" != "000" ]; then echo "PASS: Guest VM trusts awsgw TLS (HTTP $GUEST_TLS_AWSGW) — cloud-init CA injection works" else echo "FAIL: Guest VM cannot reach awsgw via HTTPS without --insecure (got: $GUEST_TLS_AWSGW)" echo " This means cloud-init did not inject the Spinifex CA into the guest trust store." echo " Checking guest user-data for ca_certs block..." ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \ -o BatchMode=yes -i "test-key-1.pem" \ ec2-user@"$PUB_IP" "cat /var/lib/cloud/instance/user-data.txt 2>/dev/null | head -30" 2>/dev/null || true exit 1 fi # Test 2: Guest can reach predastore S3 via HTTPS without --insecure set +e GUEST_TLS_S3=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \ -o ConnectTimeout=5 -o BatchMode=yes -i "test-key-1.pem" \ ec2-user@"$PUB_IP" "curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 https://${GATEWAY_HOST}:8443/ 2>/dev/null || echo 000" 2>/dev/null) set -e if [ -n "$GUEST_TLS_S3" ] && [ "$GUEST_TLS_S3" != "000" ]; then echo "PASS: Guest VM trusts predastore S3 TLS (HTTP $GUEST_TLS_S3)" else echo "WARN: Guest VM cannot reach predastore S3 at https://${GATEWAY_HOST}:8443 (got: $GUEST_TLS_S3)" echo " Predastore may use a separate self-signed cert (QUIC) — this is expected for now." fi # Test 3: Verify the ca_certs block exists in guest user-data set +e ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \ -o BatchMode=yes -i "test-key-1.pem" \ ec2-user@"$PUB_IP" "sudo grep -q ca_certs /var/lib/cloud/instance/user-data.txt" 2>/dev/null GREP_EXIT=$? set -e if [ "$GREP_EXIT" -eq 0 ]; then echo "PASS: Guest user-data contains ca_certs block" else echo "FAIL: Guest user-data does NOT contain ca_certs block" exit 1 fi else echo "WARN: SSH via public IP $PUB_IP not reachable (bridge not ready)" echo "Falling back to API-only verification (OVN state already validated above)" fi echo "" echo "Phase 8b Step 3: Private subnet isolation" # Create a second subnet in the default VPC without MapPublicIpOnLaunch echo "Creating private subnet 172.31.16.0/20 in default VPC..." PRIV_SUBNET_OUTPUT=$(aws ec2 create-subnet \ --vpc-id "$DEFAULT_VPC" \ --cidr-block 172.31.16.0/20 \ --output json 2>&1) PRIV_SUBNET_ID=$(echo "$PRIV_SUBNET_OUTPUT" | jq -r '.Subnet.SubnetId') echo "Private subnet: $PRIV_SUBNET_ID" # Verify MapPublicIpOnLaunch is false (default) MAP_PUB=$(echo "$PRIV_SUBNET_OUTPUT" | jq -r '.Subnet.MapPublicIpOnLaunch') if [ "$MAP_PUB" = "false" ]; then echo "PASS: Private subnet MapPublicIpOnLaunch=false" else echo "FAIL: Expected MapPublicIpOnLaunch=false, got $MAP_PUB" exit 1 fi # Launch instance in private subnet echo "Launching instance in private subnet..." PRIV_RUN_OUTPUT=$(aws ec2 run-instances \ --image-id "$AMI_ID" \ --instance-type "$INSTANCE_TYPE" \ --subnet-id "$PRIV_SUBNET_ID" \ --key-name test-key-1 \ --output json 2>&1) PRIV_INSTANCE_ID=$(echo "$PRIV_RUN_OUTPUT" | jq -r '.Instances[0].InstanceId') echo "Private instance: $PRIV_INSTANCE_ID" # Wait for running echo "Waiting for private instance to reach running state..." PRIV_COUNT=0 PRIV_STATE="unknown" while [ $PRIV_COUNT -lt 90 ]; do PRIV_STATE=$(aws ec2 describe-instances --instance-ids "$PRIV_INSTANCE_ID" \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "pending") if [ "$PRIV_STATE" == "running" ]; then echo "Private instance is running" break fi sleep 1 PRIV_COUNT=$((PRIV_COUNT + 1)) done if [ "$PRIV_STATE" != "running" ]; then echo "FAIL: Private instance did not reach running state (got: $PRIV_STATE)" exit 1 fi # Verify NO public IP assigned PRIV_PUB_IP=$(aws ec2 describe-instances --instance-ids "$PRIV_INSTANCE_ID" \ --query 'Reservations[0].Instances[0].PublicIpAddress' --output text) PRIV_PRIVATE_IP=$(aws ec2 describe-instances --instance-ids "$PRIV_INSTANCE_ID" \ --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text) echo "Private instance — PublicIpAddress: $PRIV_PUB_IP, PrivateIpAddress: $PRIV_PRIVATE_IP" if [ -z "$PRIV_PUB_IP" ] || [ "$PRIV_PUB_IP" = "None" ] || [ "$PRIV_PUB_IP" = "null" ]; then echo "PASS: Private subnet instance has no PublicIpAddress" else echo "FAIL: Private subnet instance should NOT have PublicIpAddress (got: $PRIV_PUB_IP)" exit 1 fi # OVN verification: NO dnat_and_snat for private instance if [ "$HAS_OVN" = true ]; then NAT_RULES=$(sudo ovn-nbctl --no-leader-only lr-nat-list "vpc-${DEFAULT_VPC}" 2>/dev/null || echo "") if echo "$NAT_RULES" | grep -q "dnat_and_snat.*${PRIV_PRIVATE_IP}"; then echo "FAIL: Private instance has dnat_and_snat rule (should not)" echo "NAT rules:" echo "$NAT_RULES" exit 1 fi echo "PASS: No OVN dnat_and_snat rule for private instance $PRIV_PRIVATE_IP" # Confirm still no blanket SNAT if echo "$NAT_RULES" | grep -q "snat.*172.31.0.0/16"; then echo "FAIL: Blanket VPC CIDR SNAT appeared after private instance launch" exit 1 fi echo "PASS: Still no blanket VPC CIDR SNAT (private instances are fully isolated)" fi # Phase 8d: NAT Gateway E2E (mulga-763) # Reuses PUB_INSTANCE_ID/PUB_IP (bastion) and PRIV_INSTANCE_ID/PRIV_PRIVATE_IP from Phase 8b echo "" echo "Phase 8d: NAT Gateway E2E" echo "========================================" # SSH helper: hop through public VM (bastion) to reach private VM BASTION_SSH="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o BatchMode=yes -i test-key-1.pem ec2-user@$PUB_IP" PRIV_SSH_CMD="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=10 -o BatchMode=yes -i /tmp/key.pem ec2-user@$PRIV_PRIVATE_IP" # Copy SSH key to bastion for hop to private instance echo "Setting up bastion SSH key..." scp -o StrictHostKeyChecking=no -o LogLevel=ERROR -i test-key-1.pem test-key-1.pem "ec2-user@$PUB_IP:/tmp/key.pem" $BASTION_SSH "chmod 600 /tmp/key.pem" # Step 1: Wait for private instance SSH to be ready via bastion hop # The private VM needs cloud-init time (~30-40s after "running" state). echo "" echo "Step 1: Waiting for private instance SSH via bastion (cloud-init boot)..." BASTION_SSH_OK=false for attempt in $(seq 1 30); do if PRIV_HOSTNAME=$($BASTION_SSH "$PRIV_SSH_CMD 'hostname'" 2>/dev/null); then BASTION_SSH_OK=true break fi echo " Attempt $attempt/30: private instance SSH not ready yet..." sleep 5 done if [ "$BASTION_SSH_OK" != "true" ]; then echo "FAIL: Cannot reach private instance from bastion after 150s" echo " Public VM (bastion): $PUB_IP → $PUB_PRIVATE_IP" echo " Private VM target: $PRIV_PRIVATE_IP" echo " Debugging: checking OVN port bindings..." sudo ovn-sbctl --no-leader-only show 2>/dev/null | head -20 || true exit 1 fi echo " PASS: Bastion → private instance SSH works (hostname: $PRIV_HOSTNAME)" { # Step 2: Baseline — private instance has NO internet echo "" echo "Step 2: Confirm private instance has no internet (baseline)" if $BASTION_SSH "$PRIV_SSH_CMD 'ping -c 1 -W 3 8.8.8.8'" 2>/dev/null; then echo "FAIL: Private instance can reach internet WITHOUT NAT GW" exit 1 fi echo " PASS: Private instance cannot reach internet (expected)" # Step 3: Create NAT Gateway + route echo "" echo "Step 3: Create NAT Gateway" NAT_EIP_OUTPUT=$(aws ec2 allocate-address --domain vpc --output json) NAT_ALLOC_ID=$(echo "$NAT_EIP_OUTPUT" | jq -r '.AllocationId') NAT_PUB_IP=$(echo "$NAT_EIP_OUTPUT" | jq -r '.PublicIp') echo " Allocated EIP: $NAT_PUB_IP ($NAT_ALLOC_ID)" NAT_GW_OUTPUT=$(aws ec2 create-nat-gateway \ --subnet-id "$DEFAULT_SUBNET" \ --allocation-id "$NAT_ALLOC_ID" --output json) NAT_GW_ID=$(echo "$NAT_GW_OUTPUT" | jq -r '.NatGateway.NatGatewayId') echo " NAT Gateway: $NAT_GW_ID" # Create route table, associate with private subnet, THEN add NAT GW route. # Order matters: CreateRoute publishes SNAT events for associated subnets, # so the association must exist before the route is added. NAT_RTB=$(aws ec2 create-route-table --vpc-id "$DEFAULT_VPC" \ --query 'RouteTable.RouteTableId' --output text) NAT_RTB_ASSOC=$(aws ec2 associate-route-table --route-table-id "$NAT_RTB" \ --subnet-id "$PRIV_SUBNET_ID" --query 'AssociationId' --output text) aws ec2 create-route --route-table-id "$NAT_RTB" \ --destination-cidr-block 0.0.0.0/0 --nat-gateway-id "$NAT_GW_ID" > /dev/null echo " Route: 0.0.0.0/0 → $NAT_GW_ID (rtb: $NAT_RTB, assoc: $NAT_RTB_ASSOC)" # Verify OVN SNAT rule exists (poll — NATS → vpcd → OVN can take several seconds) if [ "$HAS_OVN" = true ]; then SNAT_FOUND=false for attempt in $(seq 1 30); do NAT_RULES=$(sudo ovn-nbctl --no-leader-only lr-nat-list "vpc-${DEFAULT_VPC}" 2>/dev/null || echo "") if echo "$NAT_RULES" | grep -q "snat.*${NAT_PUB_IP}"; then SNAT_FOUND=true echo " PASS: OVN SNAT rule created for NAT Gateway (after ${attempt}s)" break fi sleep 1 done if [ "$SNAT_FOUND" = false ]; then echo " WARN: OVN SNAT rule not found after 30s" echo " NAT rules: $NAT_RULES" fi fi # Step 4: Verify private instance CAN now reach internet # OVN needs time to install datapath flows after the SNAT rule is created. # Retry with increasing backoff rather than a fixed sleep. echo "" echo "Step 4: Verify outbound connectivity via NAT GW" NAT_GW_OK=false for attempt in $(seq 1 10); do if $BASTION_SSH "$PRIV_SSH_CMD 'ping -c 2 -W 3 8.8.8.8'" 2>/dev/null; then NAT_GW_OK=true echo " PASS: Private instance can reach internet via NAT Gateway (attempt $attempt)" break fi echo " Attempt $attempt: NAT GW not routing yet, retrying in 5s..." sleep 5 done if [ "$NAT_GW_OK" = false ]; then echo " FAIL: Private instance cannot reach internet WITH NAT GW after 10 attempts" echo "" echo "==================== Phase 8d failure diagnostics ====================" echo "VPC: vpc-${DEFAULT_VPC}" echo "Private subnet: ${PRIV_SUBNET_ID} (private VM IP: ${PRIV_PRIVATE_IP})" echo "NATGW: ${NAT_GW_ID} → ${NAT_PUB_IP}" echo "" echo "--- ovn-nbctl lr-nat-list vpc-${DEFAULT_VPC} ---" sudo ovn-nbctl --no-leader-only lr-nat-list "vpc-${DEFAULT_VPC}" 2>&1 || true echo "" echo "--- ovn-nbctl lr-route-list vpc-${DEFAULT_VPC} ---" sudo ovn-nbctl --no-leader-only lr-route-list "vpc-${DEFAULT_VPC}" 2>&1 || true echo "" echo "--- ovn-nbctl show ---" sudo ovn-nbctl --no-leader-only show 2>&1 || true echo "" echo "--- ovn-sbctl show ---" sudo ovn-sbctl --no-leader-only show 2>&1 || true echo "" echo "--- ovn-sbctl list chassis ---" sudo ovn-sbctl --no-leader-only list chassis 2>&1 || true echo "" echo "--- ovn-nbctl find Port_Group (membership + ACLs) ---" sudo ovn-nbctl --no-leader-only --columns=name,ports,acls find Port_Group 2>&1 || true echo "" echo "--- ovn-nbctl list ACL (all VPC ACLs) ---" sudo ovn-nbctl --no-leader-only list ACL 2>&1 || true echo "" echo "--- ovn-nbctl find Logical_Switch_Port name=*${PRIV_PRIVATE_IP//./_}* (private VM LSP) ---" sudo ovn-nbctl --no-leader-only find Logical_Switch_Port addresses=\"*${PRIV_PRIVATE_IP}*\" 2>&1 || true echo "" echo "--- ovs-vsctl show ---" sudo ovs-vsctl show 2>&1 || true echo "" echo "--- ovs-ofctl dump-flows br-int (filtered: NATGW IP/subnet/private IP) ---" sudo ovs-ofctl dump-flows br-int 2>&1 \ | grep -E "${NAT_PUB_IP//./\\.}|172\\.31\\.16\\.|${PRIV_PRIVATE_IP//./\\.}" \ | head -200 || true echo "" echo "--- ovs-ofctl dump-flows br-int | wc -l (total flows) ---" sudo ovs-ofctl dump-flows br-int 2>&1 | wc -l || true echo "" echo "--- ip route show ---" ip route show 2>&1 || true echo "" echo "--- ip -br addr show ---" ip -br addr show 2>&1 || true echo "" echo "--- private VM: ip addr / ip route / ip neigh ---" $BASTION_SSH "$PRIV_SSH_CMD 'ip -br addr; echo ---; ip route; echo ---; ip neigh'" 2>&1 || true echo "" echo "--- private VM: ping -c 1 subnet gateway (172.31.16.1) ---" $BASTION_SSH "$PRIV_SSH_CMD 'ping -c 1 -W 3 172.31.16.1; echo rc=\$?'" 2>&1 || true echo "" echo "==================== end Phase 8d diagnostics ====================" exit 1 fi # Step 5: Delete NAT GW and verify internet stops echo "" echo "Step 5: Delete NAT GW and verify internet stops" aws ec2 delete-nat-gateway --nat-gateway-id "$NAT_GW_ID" > /dev/null aws ec2 disassociate-route-table --association-id "$NAT_RTB_ASSOC" aws ec2 delete-route --route-table-id "$NAT_RTB" --destination-cidr-block 0.0.0.0/0 aws ec2 delete-route-table --route-table-id "$NAT_RTB" aws ec2 release-address --allocation-id "$NAT_ALLOC_ID" echo " NAT GW deleted, route table cleaned up, EIP released" # Poll for SNAT removal — OVN datapath may cache the old flow briefly SNAT_GONE=false for attempt in $(seq 1 15); do if ! $BASTION_SSH "$PRIV_SSH_CMD 'ping -c 1 -W 3 8.8.8.8'" 2>/dev/null; then SNAT_GONE=true echo " PASS: Private instance lost internet after NAT GW deletion (after ${attempt}s)" break fi echo " Attempt $attempt: SNAT still cached, waiting..." sleep 2 done if [ "$SNAT_GONE" = false ]; then echo " FAIL: Private instance still has internet after NAT GW deletion" exit 1 fi echo "" echo "Phase 8d: NAT Gateway E2E PASSED" } # Free single-node capacity before Phase 8e by terminating the Phase 8b # instances early — nothing between here and Phase 8b's later cleanup uses # PUB/PRIV. On nano-only single-node CI the running pool would otherwise be # main + PUB + PRIV (3 instances) + Phase 8e's 2 more = 5, which exceeds # capacity. The gateway masquerades that as "InvalidInstanceType: None" # (RunInstances.go isKnownInstanceType masquerade), masking the real cause. echo "" echo "Freeing capacity for Phase 8e: terminating Phase 8b instances early..." aws ec2 terminate-instances --instance-ids "$PUB_INSTANCE_ID" "$PRIV_INSTANCE_ID" > /dev/null for EARLY_ID in "$PUB_INSTANCE_ID" "$PRIV_INSTANCE_ID"; do EARLY_COUNT=0 while [ $EARLY_COUNT -lt 60 ]; do EARLY_STATE=$(aws ec2 describe-instances --instance-ids "$EARLY_ID" \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "terminated") if [ "$EARLY_STATE" = "terminated" ] || [ "$EARLY_STATE" = "None" ]; then break fi sleep 1 EARLY_COUNT=$((EARLY_COUNT + 1)) done done # Mark as already cleaned so the later cleanup block becomes a no-op. PHASE8B_INSTANCES_TERMINATED=true echo " PUB+PRIV terminated; capacity reclaimed" # Phase 8e: Security Group Enforcement # Reuses DEFAULT_VPC, DEFAULT_SUBNET, AMI_ID, INSTANCE_TYPE, test-key-1.pem # from earlier phases. Validates the closed loop from RunInstances # (--security-group-ids) → ENI → vpcd → OVN port group + ACL → datapath drop. echo "" echo "Phase 8e: Security Group Enforcement" echo "========================================" # Step 1: Create client-sg and target-sg. echo "Step 1: Create security groups" SGE_CLIENT_SG=$(aws ec2 create-security-group \ --group-name sge-client \ --description "Phase 8e client SG (SSH ingress from anywhere)" \ --vpc-id "$DEFAULT_VPC" \ --query 'GroupId' --output text) echo " client-sg: $SGE_CLIENT_SG" SGE_TARGET_SG=$(aws ec2 create-security-group \ --group-name sge-target \ --description "Phase 8e target SG (TCP/8080 ingress from client-sg only)" \ --vpc-id "$DEFAULT_VPC" \ --query 'GroupId' --output text) echo " target-sg: $SGE_TARGET_SG" aws ec2 authorize-security-group-ingress \ --group-id "$SGE_CLIENT_SG" \ --protocol tcp --port 22 --cidr 0.0.0.0/0 > /dev/null echo " client-sg ingress: tcp/22 from 0.0.0.0/0" # SG-to-SG via UserIdGroupPair (the VPC-form; --source-group is EC2-Classic only). aws ec2 authorize-security-group-ingress \ --group-id "$SGE_TARGET_SG" \ --ip-permissions "IpProtocol=tcp,FromPort=8080,ToPort=8080,UserIdGroupPairs=[{GroupId=$SGE_CLIENT_SG}]" > /dev/null echo " target-sg ingress: tcp/8080 from $SGE_CLIENT_SG (SG-to-SG)" # Step 2: Launch client-vm and target-vm in the public subnet so both get # public IPs (MapPublicIpOnLaunch=true). Target's HTTP server is started via # cloud-init user-data — target-sg has no SSH ingress, so we cannot ssh into it. echo "" echo "Step 2: Launch client-vm and target-vm" SGE_CLIENT_VM=$(aws ec2 run-instances \ --image-id "$AMI_ID" \ --instance-type "$INSTANCE_TYPE" \ --subnet-id "$DEFAULT_SUBNET" \ --key-name test-key-1 \ --security-group-ids "$SGE_CLIENT_SG" \ --query 'Instances[0].InstanceId' --output text) echo " client-vm: $SGE_CLIENT_VM" # systemd-run fully detaches the http server from cloud-init's process group; # `nohup ... &` alone races with cloud-init exit and the server can get killed. # Pass plaintext — AWS CLI base64-encodes --user-data automatically; awsgw # decodes once on receipt. Pre-encoding here results in a double-encoded blob # landing verbatim in /tmp/cloud-init-startup.sh. SGE_TARGET_USERDATA=$(cat <<'EOF' #!/bin/bash systemd-run --unit=sge-http --description="Phase 8e HTTP server" \ /usr/bin/python3 -m http.server 8080 --bind 0.0.0.0 EOF ) SGE_TARGET_VM=$(aws ec2 run-instances \ --image-id "$AMI_ID" \ --instance-type "$INSTANCE_TYPE" \ --subnet-id "$DEFAULT_SUBNET" \ --key-name test-key-1 \ --security-group-ids "$SGE_TARGET_SG" \ --user-data "$SGE_TARGET_USERDATA" \ --query 'Instances[0].InstanceId' --output text) echo " target-vm: $SGE_TARGET_VM" # Wait both running. for SGE_VM in "$SGE_CLIENT_VM" "$SGE_TARGET_VM"; do SGE_W=0 SGE_S="unknown" while [ $SGE_W -lt 90 ]; do SGE_S=$(aws ec2 describe-instances --instance-ids "$SGE_VM" \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "pending") if [ "$SGE_S" = "running" ]; then break; fi sleep 1 SGE_W=$((SGE_W + 1)) done if [ "$SGE_S" != "running" ]; then echo "FAIL: $SGE_VM did not reach running (got: $SGE_S)" exit 1 fi done echo " Both VMs running" SGE_CLIENT_PUB=$(aws ec2 describe-instances --instance-ids "$SGE_CLIENT_VM" \ --query 'Reservations[0].Instances[0].PublicIpAddress' --output text) SGE_CLIENT_PRIV=$(aws ec2 describe-instances --instance-ids "$SGE_CLIENT_VM" \ --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text) SGE_TARGET_PRIV=$(aws ec2 describe-instances --instance-ids "$SGE_TARGET_VM" \ --query 'Reservations[0].Instances[0].PrivateIpAddress' --output text) SGE_CLIENT_ENI=$(aws ec2 describe-instances --instance-ids "$SGE_CLIENT_VM" \ --query 'Reservations[0].Instances[0].NetworkInterfaces[0].NetworkInterfaceId' --output text) SGE_TARGET_ENI=$(aws ec2 describe-instances --instance-ids "$SGE_TARGET_VM" \ --query 'Reservations[0].Instances[0].NetworkInterfaces[0].NetworkInterfaceId' --output text) echo " client-vm: pub=$SGE_CLIENT_PUB priv=$SGE_CLIENT_PRIV eni=$SGE_CLIENT_ENI" echo " target-vm: priv=$SGE_TARGET_PRIV eni=$SGE_TARGET_ENI" # Step 3: Internal correctness via ovn-nbctl — confirm each LSP joined the # expected port group. Catches Phase 4 membership regressions independently # of packet timing. echo "" echo "Step 3: ovn-nbctl port_group membership" # OVN port group names use [_a-zA-Z0-9]; spinifex maps sg-XXXX → sg_XXXX. SGE_CLIENT_PG="${SGE_CLIENT_SG//-/_}" SGE_TARGET_PG="${SGE_TARGET_SG//-/_}" SGE_CLIENT_LSP="port-${SGE_CLIENT_ENI}" SGE_TARGET_LSP="port-${SGE_TARGET_ENI}" SGE_CLIENT_LSP_UUID=$(sudo ovn-nbctl --no-leader-only --bare --columns=_uuid find logical_switch_port name="$SGE_CLIENT_LSP" 2>/dev/null || echo "") SGE_TARGET_LSP_UUID=$(sudo ovn-nbctl --no-leader-only --bare --columns=_uuid find logical_switch_port name="$SGE_TARGET_LSP" 2>/dev/null || echo "") if [ -z "$SGE_CLIENT_LSP_UUID" ] || [ -z "$SGE_TARGET_LSP_UUID" ]; then echo "FAIL: LSP UUIDs not found in OVN NB" echo " client-lsp ($SGE_CLIENT_LSP): '$SGE_CLIENT_LSP_UUID'" echo " target-lsp ($SGE_TARGET_LSP): '$SGE_TARGET_LSP_UUID'" exit 1 fi SGE_CLIENT_PG_PORTS=$(sudo ovn-nbctl --no-leader-only --bare --columns=ports find port_group name="$SGE_CLIENT_PG" 2>/dev/null || echo "") SGE_TARGET_PG_PORTS=$(sudo ovn-nbctl --no-leader-only --bare --columns=ports find port_group name="$SGE_TARGET_PG" 2>/dev/null || echo "") if echo "$SGE_CLIENT_PG_PORTS" | grep -q "$SGE_CLIENT_LSP_UUID"; then echo " PASS: client LSP in port_group $SGE_CLIENT_PG" else echo "FAIL: client LSP $SGE_CLIENT_LSP_UUID not in port_group $SGE_CLIENT_PG" echo " ports: $SGE_CLIENT_PG_PORTS" exit 1 fi if echo "$SGE_TARGET_PG_PORTS" | grep -q "$SGE_TARGET_LSP_UUID"; then echo " PASS: target LSP in port_group $SGE_TARGET_PG" else echo "FAIL: target LSP $SGE_TARGET_LSP_UUID not in port_group $SGE_TARGET_PG" echo " ports: $SGE_TARGET_PG_PORTS" exit 1 fi # Confirm client's private IP made it into the client_pg_ip4 address set so # target-sg's SG-to-SG match expression resolves. The _ip4 / _ip6 # sets are auto-derived by ovn-northd from port_group membership and live in # SB, not NB (see security.go:73, topology.go:803). northd is async so retry # briefly to ride out the post-join propagation window. SGE_AS_OK=false for SGE_W in $(seq 1 10); do SGE_CLIENT_AS_ADDRS=$(sudo ovn-sbctl --no-leader-only --bare --columns=addresses find address_set name="${SGE_CLIENT_PG}_ip4" 2>/dev/null || echo "") if echo "$SGE_CLIENT_AS_ADDRS" | grep -qF "$SGE_CLIENT_PRIV"; then SGE_AS_OK=true break fi sleep 1 done if [ "$SGE_AS_OK" = "true" ]; then echo " PASS: client private IP $SGE_CLIENT_PRIV in SB address_set ${SGE_CLIENT_PG}_ip4" else echo "FAIL: client private IP $SGE_CLIENT_PRIV missing from SB address_set ${SGE_CLIENT_PG}_ip4" echo " addresses: $SGE_CLIENT_AS_ADDRS" exit 1 fi # Wait for client SSH to be ready (cloud-init). SGE_CLIENT_SSH="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 -o BatchMode=yes -i test-key-1.pem ec2-user@$SGE_CLIENT_PUB" echo "" echo "Waiting for client-vm SSH..." SGE_SSH_OK=false for SGE_W in $(seq 1 60); do if $SGE_CLIENT_SSH 'true' 2>/dev/null; then SGE_SSH_OK=true break fi sleep 2 done if [ "$SGE_SSH_OK" != "true" ]; then echo "FAIL: client-vm SSH did not become ready" exit 1 fi echo " PASS: client-vm SSH ready" # Step 4: Allowed traffic — client → target:8080 must succeed. # Retry to give target's cloud-init time to start python3 -m http.server. echo "" echo "Step 4: Allowed traffic (client → target:8080, SG-to-SG allow rule)" SGE_HTTP_OK=false for SGE_W in $(seq 1 30); do if $SGE_CLIENT_SSH "curl -sS -o /dev/null -m 5 http://${SGE_TARGET_PRIV}:8080/" 2>/dev/null; then SGE_HTTP_OK=true echo " PASS: client → target:8080 succeeded (after ${SGE_W} attempts)" break fi sleep 2 done if [ "$SGE_HTTP_OK" != "true" ]; then echo "FAIL: client cannot reach target:8080" # Disambiguate: curl -v shows "Connection refused" (server down) vs # "Connection timed out" (ACL filtered). Then dump target console for # cloud-init / systemd-run / python3 evidence. echo " --- curl -v from client to target:8080 ---" $SGE_CLIENT_SSH "curl -v --connect-timeout 3 http://${SGE_TARGET_PRIV}:8080/" 2>&1 | tail -25 || true echo " --- target console output (tail 80) ---" aws ec2 get-console-output --instance-id "$SGE_TARGET_VM" --output text 2>&1 | tail -80 || true exit 1 fi # Step 5: Denied traffic — client → target:22 must fail. # target-sg has no SSH ingress; default-deny ACL must drop. echo "" echo "Step 5: Denied traffic (client → target:22, no SSH ingress on target-sg)" if $SGE_CLIENT_SSH "nc -z -w 5 ${SGE_TARGET_PRIV} 22" 2>/dev/null; then echo "FAIL: client reached target:22 — default-deny ACL not enforced" exit 1 fi echo " PASS: client → target:22 blocked" # Step 6: Revoke target-sg's 8080 rule and retest immediately. # The synchronous vpc.update-sg RequestEvent contract (Phase 7) makes # propagation to OVN immediate — no sleep needed. echo "" echo "Step 6: Revoke target-sg ingress, retest (sync RequestEvent contract)" aws ec2 revoke-security-group-ingress \ --group-id "$SGE_TARGET_SG" \ --ip-permissions "IpProtocol=tcp,FromPort=8080,ToPort=8080,UserIdGroupPairs=[{GroupId=$SGE_CLIENT_SG}]" > /dev/null echo " Revoked tcp/8080 from $SGE_CLIENT_SG" # Fresh TCP connection — conntrack does not affect new connections. if $SGE_CLIENT_SSH "curl -sS -o /dev/null -m 5 http://${SGE_TARGET_PRIV}:8080/" 2>/dev/null; then echo "FAIL: client still reached target:8080 after revoke — propagation not immediate" exit 1 fi echo " PASS: client → target:8080 now blocked after revoke" # Step 7: Cleanup — terminate Phase 8e VMs and delete SGs before the shared # 8b/8d cleanup runs. Otherwise the SGs would still reference live ENIs. echo "" echo "Step 7: Cleanup" aws ec2 terminate-instances --instance-ids "$SGE_CLIENT_VM" "$SGE_TARGET_VM" > /dev/null for SGE_VM in "$SGE_CLIENT_VM" "$SGE_TARGET_VM"; do SGE_W=0 SGE_S="unknown" while [ $SGE_W -lt 60 ]; do SGE_S=$(aws ec2 describe-instances --instance-ids "$SGE_VM" \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "terminated") if [ "$SGE_S" = "terminated" ] || [ "$SGE_S" = "None" ]; then break; fi sleep 1 SGE_W=$((SGE_W + 1)) done done aws ec2 delete-security-group --group-id "$SGE_TARGET_SG" aws ec2 delete-security-group --group-id "$SGE_CLIENT_SG" echo " PASS: VMs terminated, SGs deleted" echo "" echo "Phase 8e: Security Group Enforcement PASSED" echo "" echo "Phase 8b/8d Step: Cleanup" if [ "$PHASE8B_INSTANCES_TERMINATED" = "true" ]; then echo " PUB+PRIV already terminated before Phase 8e (capacity reclaim)" else # Terminate both instances echo "Terminating public instance $PUB_INSTANCE_ID..." aws ec2 terminate-instances --instance-ids "$PUB_INSTANCE_ID" echo "Terminating private instance $PRIV_INSTANCE_ID..." aws ec2 terminate-instances --instance-ids "$PRIV_INSTANCE_ID" # Wait for both to terminate for CLEANUP_ID in "$PUB_INSTANCE_ID" "$PRIV_INSTANCE_ID"; do CLEANUP_COUNT=0 while [ $CLEANUP_COUNT -lt 60 ]; do CLEANUP_STATE=$(aws ec2 describe-instances --instance-ids "$CLEANUP_ID" \ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "terminated") if [ "$CLEANUP_STATE" == "terminated" ] || [ "$CLEANUP_STATE" == "None" ]; then echo "Instance $CLEANUP_ID terminated" break fi sleep 1 CLEANUP_COUNT=$((CLEANUP_COUNT + 1)) done done fi # Verify public IP NAT rule removed after termination (async via NATS) if [ "$HAS_OVN" = true ]; then echo "Waiting for NAT rule cleanup..." NAT_CLEANED=false for nat_wait in $(seq 1 15); do NAT_RULES=$(sudo ovn-nbctl --no-leader-only lr-nat-list "vpc-${DEFAULT_VPC}" 2>/dev/null || echo "") if ! echo "$NAT_RULES" | grep -q "dnat_and_snat.*${PUB_PRIVATE_IP}"; then NAT_CLEANED=true break fi sleep 2 done if [ "$NAT_CLEANED" = true ]; then echo "PASS: NAT rule cleaned up after public instance termination" else echo "WARN: dnat_and_snat rule for $PUB_PRIVATE_IP still present after 30s (non-fatal)" fi fi # Delete private subnet echo "Deleting private subnet $PRIV_SUBNET_ID..." aws ec2 delete-subnet --subnet-id "$PRIV_SUBNET_ID" echo "PASS: Private subnet deleted" echo "" echo "Phase 8b: VPC Public/Private Subnet E2E PASSED" fi # end HAS_EXTERNAL check # Phase 9: Terminate and Verify Cleanup echo "Phase 9: Terminate and Verify Cleanup" # Save root volume ID before termination for cleanup verification ROOT_VOLUME_ID="$VOLUME_ID" echo "Root volume to verify cleanup: $ROOT_VOLUME_ID" # Clean up the CreateImage backing snapshot so DeleteOnTermination can delete the root volume. # checkVolumeHasNoSnapshots() correctly blocks volume deletion when snapshots reference it. if [ -n "$CUSTOM_AMI_SNAP_ID" ]; then echo "Deleting CreateImage backing snapshot $CUSTOM_AMI_SNAP_ID before termination..." aws ec2 delete-snapshot --snapshot-id "$CUSTOM_AMI_SNAP_ID" echo "CreateImage snapshot deleted" fi # Deregister the custom AMI so later test suites (e.g. run-lb-e2e.sh) don't # discover an AMI whose backing snapshot has been deleted. if [ -n "$CUSTOM_AMI_ID" ]; then echo "Deregistering custom AMI $CUSTOM_AMI_ID..." aws ec2 deregister-image --image-id "$CUSTOM_AMI_ID" 2>/dev/null || true echo "Custom AMI deregistered" fi # Terminate instance (terminate-instances) and verify termination (describe-instances) echo "Terminating instance..." aws ec2 terminate-instances --instance-ids "$INSTANCE_ID" COUNT=0 while [ $COUNT -lt 60 ]; do STATE=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" --query 'Reservations[0].Instances[0].State.Name' --output text) echo "Instance state: $STATE" if [ "$STATE" == "terminated" ] || [ "$STATE" == "None" ]; then break fi sleep 1 COUNT=$((COUNT + 1)) done # Phase 9a: Verify SSH unreachable after termination echo "Phase 9a: SSH Unreachable Verification" verify_ssh_unreachable "$SSH_HOST" "$SSH_PORT" "test-key-1.pem" echo "SSH unreachable verification passed" # Phase 9b: Verify root volume cleanup after termination echo "Phase 9b: Volume Cleanup Verification" echo "Verifying root volume $ROOT_VOLUME_ID is cleaned up after termination..." sleep 5 # Allow time for async volume deletion COUNT=0 VOLUME_CLEANED=false while [ $COUNT -lt 20 ]; do set +e VOL_CHECK=$(aws ec2 describe-volumes --volume-ids "$ROOT_VOLUME_ID" \ --query 'Volumes[0].State' --output text 2>&1) VOL_EXIT=$? set -e if [ $VOL_EXIT -ne 0 ] || [ "$VOL_CHECK" == "None" ] || [ -z "$VOL_CHECK" ]; then VOLUME_CLEANED=true echo "Root volume $ROOT_VOLUME_ID has been cleaned up (DeleteOnTermination)" break fi echo "Volume still exists (State=$VOL_CHECK), waiting... ($COUNT/20)" sleep 3 COUNT=$((COUNT + 1)) done if [ "$VOLUME_CLEANED" != "true" ]; then echo "WARNING: Root volume $ROOT_VOLUME_ID was not cleaned up after termination" echo "This may indicate a DeleteOnTermination regression" exit 1 fi echo "Volume cleanup verification passed" echo "E2E Test Completed Successfully" exit 0