mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 19:01:42 +00:00
91483d632e
The NAT GW E2E tests used 2-3 second sleeps before verifying SNAT rule creation, outbound connectivity, and rule removal. This caused flaky CI failures when OVN took longer to propagate flows to the datapath (varies by host load and OVN version). Replace with retry loops: - SNAT rule check: poll OVN NB every 1s for up to 30s - Outbound ping: retry up to 10 attempts with 5s backoff (~50s max) - SNAT removal: poll up to 15 attempts with 2s backoff (~30s max) Also dump OVN routes alongside NAT rules on failure for debugging.
3256 lines
124 KiB
Bash
Executable File
3256 lines
124 KiB
Bash
Executable File
#!/bin/bash
|
|
set -e
|
|
|
|
# Ensure Go is on PATH (SSH non-interactive shells don't source .bashrc)
|
|
export PATH="/usr/local/go/bin:$HOME/go/bin:$PATH"
|
|
|
|
# Ensure we are in the project root
|
|
cd "$(dirname "$0")/../.."
|
|
|
|
# Source helper functions
|
|
source ./tests/e2e/lib/multinode-helpers.sh
|
|
|
|
# Ensure services are stopped on exit and print logs on failure
|
|
cleanup() {
|
|
EXIT_CODE=$?
|
|
if [ $EXIT_CODE -ne 0 ]; then
|
|
echo ""
|
|
echo "=== NATS Service Log ==="
|
|
if [ -f ~/spinifex/logs/nats.log ]; then
|
|
tail -100 ~/spinifex/logs/nats.log 2>/dev/null
|
|
fi
|
|
echo ""
|
|
echo "=== Predastore Service Log ==="
|
|
if [ -f ~/spinifex/logs/predastore.log ]; then
|
|
tail -100 ~/spinifex/logs/predastore.log 2>/dev/null
|
|
fi
|
|
echo ""
|
|
echo "=== Viperblock Service Log ==="
|
|
if [ -f ~/spinifex/logs/viperblock.log ]; then
|
|
tail -100 ~/spinifex/logs/viperblock.log 2>/dev/null
|
|
fi
|
|
echo ""
|
|
echo "=== Spinifex Daemon Log ==="
|
|
if [ -f ~/spinifex/logs/spinifex.log ]; then
|
|
tail -200 ~/spinifex/logs/spinifex.log 2>/dev/null
|
|
fi
|
|
echo ""
|
|
echo "=== AWS Gateway Log ==="
|
|
if [ -f ~/spinifex/logs/awsgw.log ]; then
|
|
tail -200 ~/spinifex/logs/awsgw.log 2>/dev/null
|
|
fi
|
|
echo ""
|
|
echo "=== vpcd Log ==="
|
|
if [ -f ~/spinifex/logs/vpcd.log ]; then
|
|
tail -200 ~/spinifex/logs/vpcd.log 2>/dev/null
|
|
fi
|
|
fi
|
|
# Only stop services if we started them (not when bootstrapped)
|
|
if [ "$BOOTSTRAPPED" != "true" ]; then
|
|
./scripts/stop-dev.sh
|
|
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
|
|
|
|
# Detect bootstrapped environment from spinifex.toml config
|
|
BOOTSTRAPPED="false"
|
|
GATEWAY_HOST="localhost"
|
|
PREDASTORE_HOST="localhost"
|
|
if [ -f ~/spinifex/config/spinifex.toml ]; then
|
|
# Resolve gateway host from AWS profile endpoint_url (set by spx admin init)
|
|
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
|
|
# Resolve predastore host from spinifex.toml
|
|
DETECTED_PS_HOST=$(awk -F'"' '/\[nodes\..*\.predastore\]/{found=1} found && /^host/{print $2; exit}' ~/spinifex/config/spinifex.toml)
|
|
if [ -n "$DETECTED_PS_HOST" ]; then
|
|
PREDASTORE_HOST="${DETECTED_PS_HOST%%:*}"
|
|
fi
|
|
# Check if gateway is actually responding
|
|
if curl -sk "https://${GATEWAY_HOST}:9999" > /dev/null 2>&1; then
|
|
BOOTSTRAPPED="true"
|
|
echo "Detected bootstrapped environment — skipping cluster setup"
|
|
echo " Gateway host: $GATEWAY_HOST"
|
|
echo " Predastore host: $PREDASTORE_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 inside the container
|
|
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. Ensure --privileged and -v /dev/kvm:/dev/kvm are used."
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$BOOTSTRAPPED" != "true" ]; then
|
|
INIT_FLAGS="--region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1"
|
|
if [ -n "${SPX_EXTERNAL_POOL:-}" ]; then
|
|
INIT_FLAGS="$INIT_FLAGS --external-mode=pool --external-pool=$SPX_EXTERNAL_POOL"
|
|
fi
|
|
./bin/spx admin init $INIT_FLAGS
|
|
|
|
# Trust the Spinifex CA certificate for AWS CLI SSL verification
|
|
echo "Adding Spinifex CA certificate to system trust store..."
|
|
sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
|
|
sudo update-ca-certificates
|
|
|
|
# Start all services
|
|
# Ensure logs directory exists for start-dev.sh
|
|
mkdir -p ~/spinifex/logs
|
|
./scripts/start-dev.sh
|
|
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=$(./bin/spx get nodes --config ~/spinifex/config/spinifex.toml --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=$(./bin/spx top nodes --config ~/spinifex/config/spinifex.toml --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=$(./bin/spx get vms --config ~/spinifex/config/spinifex.toml --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"
|
|
# Detect correct image name based on architecture
|
|
if [ "$ARCH" = "x86_64" ]; then
|
|
IMAGE_NAME="ubuntu-24.04-x86_64"
|
|
else
|
|
IMAGE_NAME="ubuntu-24.04-arm64"
|
|
fi
|
|
echo "Using image: $IMAGE_NAME"
|
|
|
|
# Import the pre-downloaded Ubuntu image using file-based import
|
|
# When bootstrapped, the image is already imported — discover the existing AMI
|
|
if [ "$BOOTSTRAPPED" = "true" ]; then
|
|
echo "Discovering existing AMI (imported by bootstrap)..."
|
|
AMI_ID=$(aws ec2 describe-images --query 'Images[0].ImageId' --output text)
|
|
else
|
|
echo "Importing pre-cached Ubuntu image..."
|
|
IMPORT_LOG=$(./bin/spx admin images import \
|
|
--file ~/images/ubuntu-24.04.img \
|
|
--arch "$ARCH" \
|
|
--distro ubuntu \
|
|
--version 24.04 \
|
|
--force 2>/dev/null)
|
|
AMI_ID=$(echo "$IMPORT_LOG" | grep -o 'ami-[a-z0-9]\+')
|
|
fi
|
|
|
|
if [ -z "$AMI_ID" ] || [ "$AMI_ID" = "None" ]; then
|
|
echo "Failed to capture AMI ID"
|
|
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"
|
|
# 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=$(./bin/spx get vms --config ~/spinifex/config/spinifex.toml --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"
|
|
SSH_PORT=$(get_ssh_port "$INSTANCE_ID")
|
|
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" 30
|
|
|
|
# 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-<first 8 hex chars of instance ID>
|
|
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. Verify its volume config."
|
|
|
|
AWS_S3="aws --endpoint-url https://${PREDASTORE_HOST}:8443 s3"
|
|
|
|
# Verify the AMI snapshot exists in Predastore
|
|
echo "Checking AMI snapshot in Predastore..."
|
|
SNAP_PREFIX="snap-$AMI_ID"
|
|
SNAP_FILES=$($AWS_S3 ls "s3://predastore/$SNAP_PREFIX/" 2>&1 || echo "")
|
|
if echo "$SNAP_FILES" | grep -q "config.json"; then
|
|
echo "AMI snapshot config found at $SNAP_PREFIX/"
|
|
else
|
|
echo "AMI snapshot config not found at $SNAP_PREFIX/"
|
|
exit 1
|
|
fi
|
|
|
|
# Verify the Phase 5 instance's root volume has SnapshotID and SourceVolumeName
|
|
echo "Verifying root volume $VOLUME_ID is snapshot-backed via Predastore config..."
|
|
VOL_CONFIG=$($AWS_S3 cp "s3://predastore/$VOLUME_ID/config.json" - 2>/dev/null || echo "{}")
|
|
VOL_SNAPSHOT_ID=$(echo "$VOL_CONFIG" | jq -r '.SnapshotID // empty')
|
|
VOL_SOURCE_NAME=$(echo "$VOL_CONFIG" | jq -r '.SourceVolumeName // empty')
|
|
|
|
if [ -z "$VOL_SNAPSHOT_ID" ]; then
|
|
echo "Volume config missing SnapshotID — launch was NOT snapshot-backed"
|
|
exit 1
|
|
fi
|
|
if [ -z "$VOL_SOURCE_NAME" ]; then
|
|
echo "Volume config missing SourceVolumeName — launch was NOT snapshot-backed"
|
|
exit 1
|
|
fi
|
|
echo "Volume is snapshot-backed (SnapshotID=$VOL_SNAPSHOT_ID, SourceVolumeName=$VOL_SOURCE_NAME)"
|
|
|
|
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 config in Predastore
|
|
# (needed later to clean up before termination, so DeleteOnTermination can work)
|
|
CUSTOM_AMI_CONFIG=$($AWS_S3 cp "s3://predastore/$CUSTOM_AMI_ID/config.json" - 2>/dev/null || echo "{}")
|
|
CUSTOM_AMI_SNAP_ID=$(echo "$CUSTOM_AMI_CONFIG" | jq -r '.VolumeConfig.AMIMetadata.SnapshotID // empty')
|
|
if [ -n "$CUSTOM_AMI_SNAP_ID" ]; then
|
|
echo "Custom AMI backing snapshot: $CUSTOM_AMI_SNAP_ID"
|
|
else
|
|
echo "WARNING: Could not extract backing snapshot ID from custom AMI config"
|
|
fi
|
|
|
|
echo "CreateImage lifecycle test passed"
|
|
|
|
# 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
|
|
SSH_PORT=$(get_ssh_port "$INSTANCE_ID")
|
|
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" 30
|
|
|
|
# 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
|
|
SSH_PORT=$(get_ssh_port "$INSTANCE_ID")
|
|
SSH_HOST=$(get_ssh_host "$INSTANCE_ID")
|
|
fi
|
|
wait_for_ssh "$SSH_HOST" "$SSH_PORT" "test-key-1.pem" 30
|
|
|
|
# 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"
|
|
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=$(./bin/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=$(./bin/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=$(./bin/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"' ~/spinifex/config/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 (macvlan isolation or 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 " Dumping OVN NAT rules for debugging:"
|
|
sudo ovn-nbctl --no-leader-only lr-nat-list "vpc-${DEFAULT_VPC}" 2>/dev/null || true
|
|
echo " OVN routes:"
|
|
sudo ovn-nbctl --no-leader-only lr-route-list "vpc-${DEFAULT_VPC}" 2>/dev/null || true
|
|
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"
|
|
}
|
|
|
|
echo ""
|
|
echo "Phase 8b/8d Step: Cleanup"
|
|
# 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
|
|
|
|
# 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
|
|
|
|
# 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
|