Files
spinifex/tests/e2e/run-lb-e2e.sh
T
Josh Siviour 4ae2a4ac53 feat(elbv2): QEMU microvm direct-boot for LB system instances (#228)
* feat(vm): direct-boot foundations + config/input extensions + image build pipeline

vm package (mulga-siv-62):
- Add KernelImage, Initrd, KernelCmdline, FwCfg/FwCfgEntry to vm.Config
- Emit -kernel/-initrd/-append/-fw_cfg args when fields set
- Relax drive invariant: accept kernel image in place of drives
- Add devices.go: IsMMIO predicate, NetDevice/BlkDevice/RngDevice helpers
- Refactor lifecycle.go + network_helpers.go call sites to use helpers
- shutdown.go: os.Remove fw_cfg tmpfiles after QEMU exits
- Add devices_test.go, microvm_test.go; extend vm_test.go

config/input extensions (mulga-siv-63):
- Add MicrovmConfig{ELBv2Enabled, ImagePath} to DaemonConfig
- ImagePath falls back to /usr/share/spinifex/microvm when empty
- Add commented [microvm] section to spinifex.toml template
- Add DirectBoot bool + NICs []NICConfig to SystemInstanceInput

image build pipeline (mulga-siv-64):
- scripts/build-microvm-image.sh: Alpine chroot build producing vmlinuz + initramfs.cpio.gz
- build/microvm/init.sh: fw_cfg-driven PID 1 init (mounts, modprobe, NIC config, lb-agent-env, ca-cert)
- build/microvm/inittab: BusyBox init with lb-agent respawn
- make build-microvm-image target
- setup.sh: install microvm/* to /usr/share/spinifex/microvm/
- .gitignore: exclude built vmlinuz + initramfs.cpio.gz artifacts

* feat(daemon): direct-boot launch path for microvm system instances

When SystemInstanceInput.DirectBoot=true:
- Skip GetAMIConfig and GenerateVolumes
- Build vm.Config directly (no buildBaseVMConfig, no PCIe root ports)
- MachineType=microvm,x-option-roms=off,pic=off,pit=off,rtc=on,acpi=on,isa-serial=on
- Resolve kernel/initrd from Microvm.ImagePath config field
- Serialise NICs to three fw_cfg tmpfiles (netcfg, lb-agent-env, ca-cert)
- Validate exactly one NICConfig.IsDefault=true before writing netcfg blob
- Shutdown path cleans up tmpfiles via existing Config.FwCfg iteration

PC-machine path (DirectBoot=false) unchanged.

Closes mulga-siv-65

* feat(elbv2): wire microvm.elbv2_enabled flag into ELBv2 service

When ELBv2Enabled=true:
- Drop lbVMUserData cloud-config; emit KEY=value LBAgentEnv blob instead
- Set SystemInstanceInput.DirectBoot=true; omit ImageID
- Populate NICs []NICConfig (index 0=primary ENI IsDefault, 1=mgmt, 2+=extra ENIs)
- Validate exactly one IsDefault NIC at service layer

When ELBv2Enabled=false: existing PC-machine path unchanged.

Adds buildLBAgentEnv, buildMicrovmNICs, subnetCIDRForIP, subnetGatewayIP helpers.
Tests in arn_test.go cover both flag-on and flag-off paths.

Closes mulga-siv-66

* test(daemon,vm): add unit tests for direct-boot helpers to meet diff coverage threshold

* feat(daemon): Phase 1 perf instrumentation + e2e runner microvm flag

- Log t1→t2 boot timing via slog on the direct-boot path
- measureQEMURSSMiB helper reads /proc/<pid>/status (tested)
- Gate constants: phase1BootGateMs=500, phase1RSSGateMiB=25
- run-lb-e2e.sh: --microvm flag, dual-path CI annotation, density test note

Closes mulga-siv-67

* fix(build): remove busybox-initscripts — not in Alpine 3.20, not needed

* fix(microvm): end-to-end fixes for direct-boot LB path

Serial output was silent (x-option-roms=off suppresses BIOS serial init);
fixed by removing it from the machine string and using -serial chardev:console0
to attach to the machine-created ISA serial device instead of adding a
duplicate isa-serial device (which would create ttyS1 while kernel uses ttyS0).

virtio-net interfaces were invisible to the kernel because QEMU's
auto-kernel-cmdline does not append virtio_mmio.device= params when -append
is used. Fixed by generating explicit virtio_mmio.device=0x200@0xfebNNNNN:N
params for each NIC in KernelCmdline, and modprobing virtio_mmio + virtio_net
in init.sh.

sys.micro bumped to 512 MiB — 128 MiB caused kernel OOM during initramfs
decompression (~68 MiB uncompressed).

BusyBox init ::respawn: entries do not source or export env files; fixed
inittab to use set -a; . /etc/conf.d/lb-agent; set +a before exec.

wireLBAgentConfig was documented as reading the CA cert but never set
elbv2Service.CACert; lb-agent TLS handshakes to AWSGW failed with
x509: certificate signed by unknown authority. Fixed by reading
d.config.NATS.CACert (same local CA that signs the AWSGW server cert)
and assigning to elbv2Service.CACert so writeFwCfgBlobs writes a
non-empty cacert blob into fw_cfg.

Adds vm_test TestExecute_MicrovmFileChardev and updates lifecycle_test
to assert SerialSocket is intentionally empty on direct-boot VMs.

* fix(build): auto-detect docker/podman when apk unavailable for microvm image build

* fix(build): create /dev/console and /dev/null as proper device nodes in initramfs

tar extraction without root silently demotes device files to 0-byte regular
files (mknod requires CAP_MKNOD). The kernel opens /dev/console when wiring
init's stdio to the serial console; a regular file there causes all init output
to be silently discarded to RAM — the VM appears stuck at 'Run /init as init
process' with no subsequent console output and lb-agent never heartbeats.

Fix: after rootfs population, wipe /dev and recreate console (c 5 1) and
null (c 1 3) via sudo mknod. devtmpfs provides all other devices at runtime
once init.sh mounts it.

* fix(microvm): fix mgmt NIC routing for all LB schemes

Two bugs prevented lb-agent from reaching the AWSGW via the mgmt interface:

1. RouteDst/RouteVia swapped — resolveMgmtRoute returns (gateway, target) but
   buildMicrovmNICs assigned them as (Dst=gateway, Via=target), producing
   'ip route add 10.15.8.1 via 192.168.1.33' instead of the correct
   'ip route add 192.168.1.33 via 10.15.8.1'.

2. NIC[1] MAC and CIDR never filled — the daemon allocates mgmt IP/MAC
   after buildMicrovmNICs returns, but didn't inject them into input.NICs[1]
   before calling writeFwCfgBlobs, leaving NIC1_MAC= empty in the netcfg
   blob and skipping interface setup in init.sh entirely.

Also extends mgmt routing to internet-facing scheme in the microvm path:
VPC SNAT is not reliable across all VPC topologies for control-plane
heartbeats; mgmt is always a direct L2 path to the host.

* feat(microvm): Phase 2 kernel stripping + boot optimisation

- cpu: switch to host,-pmu (disable PMU — not needed in microvm guest)
- machine type: make isa-serial configurable via microvm.isa_serial config
  field; default true (on) preserves console log path
- build: strip unused kernel modules (USB, IDE, ATA, GPU, media, input,
  HID, bluetooth, staging, sound, wireless) from initramfs before cpio
- build: add Phase 2 artifact size gate — fail if vmlinuz + initramfs.cpio.gz
  exceeds MICROVM_ARTIFACT_MAX_MiB (default 50 MiB); override via env var
  during development
- perf: add phase2BootGateMs=300 and phase2ArtifactGateMiB=50 constants
  with canary tests to catch unintentional gate changes
- tests: cover buildMicrovmNICs nil-VPC and extra-ENI paths
- tests: cover microvmMachineType nil/true/false branches

* fix(build): use find-based virtio+fw_cfg module keep-list in initramfs strip

The directory-only keep-list (drivers/virtio, drivers/firmware) missed
virtio_net.ko which lives in drivers/net/, virtio_blk in drivers/block/,
and virtio-rng in drivers/char/hw_random/. Switch to find-based approach:
collect all virtio*.ko* and *fw_cfg*.ko* regardless of subdirectory, then
restore them into their original relative paths under kernel/.

Artifacts now: 42 MiB (vmlinuz 11 MiB + initramfs 31 MiB), gate passes.

* fix(build): keep failover modules in initramfs strip

virtio_net depends on net_failover/failover for live-migration support.
Stripping them left virtio_net with unresolved symbols
(net_failover_create/destroy), so the module failed to load and the
guest came up with only lo. init.sh then skipped every NIC ("no
interface found for MAC ..."), leaving lb-agent with no network path
to AWSGW and the LB stuck in provisioning.

Add *failover*.ko* to the keep-list. Image size unchanged at 42 MiB
(under the 50 MiB Phase 2 gate). ELBv2 e2e now reaches active and
2/2 targets healthy for both ALB and NLB internal LBs.

* fix(daemon): restore slog default after viperblock.New mutates it

viperblock.New unconditionally calls vb.SetDebug, which calls
slog.SetDefault with a TextHandler at LevelError — globally silencing
every slog.Info and slog.Warn in the caller process for the rest of
its life. Discovered while validating microvm Phase 2 (mulga-siv-68):
the daemon stopped emitting Info logs the moment the first customer
VM with volumes launched, hiding direct-boot timing,
LaunchSystemInstance completed, and CreateLoadBalancer completed
lines that we need for boot-time tuning.

Workaround in newViperblock: re-install an Info-level TextHandler on
os.Stderr after each viperblock.New call. Also drop the three explicit
vb.SetDebug(false) calls in prepareRootVolume / prepareEFIVolume /
prepareCloudInitVolume — they were no-ops (viperblock.New already
runs SetDebug internally) and each one re-triggered the global
mutation we are trying to undo.

Proper fix is in viperblock itself — tracked as mulga-siv-70: SetDebug
should not call slog.SetDefault. Until then, this restore call is the
shortest path to getting the daemon's observability back so we can
measure t1_to_t2_ms and tune Phase 2.

Preflight note: golangci-lint not available on this dev host, so the
project preflight target cannot run end-to-end. Verified with
go vet ./spinifex/handlers/ec2/instance/... (clean) and
go test ./spinifex/handlers/ec2/instance/... (pass).

* perf(microvm): hit Phase 2 boot/memory gates

t1→t2 hypervisor: 3224 ms → 264 ms (12×). 128 MiB guest now boots
cleanly without OOM, reverting the temporary 512 MiB bump.

Root causes:

1. build-microvm-image.sh staged the entire chroot into the cpio
   without dropping /boot, embedding a duplicate vmlinuz, Alpine's
   stock initramfs-virt, System.map, and a "boot → ." symlink loop
   (~25 MiB). Decompression peak exceeded 128 MiB. Strip /boot after
   the kernel is copied out, before the cpio. Initramfs drops from
   31 MiB → 11 MiB (53 MiB → 28 MiB uncompressed); total artifact
   42 MiB → 22 MiB.

2. vm/lifecycle.go burned 3 s of fixed sleep on every direct-boot
   launch: 2 s nbdkit pre-exec wait (no nbdkit on direct-boot) and
   1 s post-fork settle (QEMU binds QMP within ~10 ms when there is
   no firmware, ROM, or block layer). Both extracted to
   directBoot-aware helpers — nbdkitPreExecWait collapses to 0, and
   qemuStartSettleWait to 50 ms.

3. KernelCmdline only carried `console=ttyS0 virtio_mmio.device=…`.
   Added boot-time perf flags: quiet loglevel=3 (silence printk over
   115200-baud isa-serial), mitigations=off (KVM guest on trusted
   host, single-tenant LB workload), tsc=reliable, no_timer_check,
   reboot=t, i8042.{nopnp,noaux,nokbd} (microvm has no PS/2).

4. init.sh dropped modprobe virtio_mmio (built-in:
   CONFIG_VIRTIO_MMIO=y) and tightened polling: fw_cfg/iface waits go
   from 10 × 200 ms (up to 2 s) to 5 × 20 ms (up to 100 ms).

Phase 2 gates:
  - t1→t2 ≤ 300 ms       ✅ 264 ms / 281 ms
  - artifact ≤ 50 MiB    ✅ 22 MiB (gate already in build script)
  - ELBv2 e2e green      ✅ 32/33 (pre-existing client-VM failure,
                                   unrelated to microvm path)

* build(microvm): wire build into deploy + distro + release pipeline

Adds install-microvm Makefile target (with grouped-target staleness
detection on vmlinuz/initramfs.cpio.gz), wires it into dev-install.sh
and reset-dev-env.sh, makes 'make deploy' reinstall artifacts when
present, and makes 'make distro-amd64' stage build/microvm/* into the
release tarball staging dir. arm64 distro stays unchanged (no aarch64
microvm artifact yet — Phase 4).

Release workflow gains a setup-go step and a 'make build-microvm-image'
step before 'make distro-amd64' on the amd64 runner so the resulting
tarball includes the kernel + initramfs without a manual copy.

Precursor for the Phase 3 PC-machine LB removal (mulga-siv-69).
Closes mulga-siv-71.

* build(ansible): include microvm artifacts in build role tarball

Adds a build-microvm-image task to the ansible build role and stages
build/microvm/* into the role's release tarball so the install role's
setup.sh run picks them up via INSTALL_SPINIFEX_TARBALL. Skipped
gracefully when the build script is absent (older branches without
direct-boot LB wiring).

* feat(config): enable microvm direct-boot LB path by default

Uncomments the [nodes.<node>.daemon.microvm] section in the generated
spinifex.toml template and sets elbv2_enabled = true so all new
clusters bring up ELBv2 LBs via the direct-boot microvm path. Operators
can opt back to the legacy PC-machine path by flipping the flag to
false in the rendered config.

Required for end-to-end CI on this branch to exercise the direct-boot
path. Existing clusters' configs are unchanged (the template only
affects fresh 'spx admin init' renders).

* fix(microvm): bootstrap apk.static when no host rootfs tool exists

Self-hosted CI runners and the GitHub-hosted ubuntu-24.04 image often
have none of apk, docker, or podman installed. apk-tools is not in the
Debian 13 (trixie) repositories. Without one of these the rootfs build
fails up front with 'no rootfs build tool available'.

When all three are absent, fetch apk-tools-static from the configured
Alpine mirror, extract it to a tempdir, and prepend it to PATH so the
existing native-apk strategy below picks it up transparently. Requires
only curl + tar, both of which are present on every CI runner that can
already check out source.

No-op when apk, docker, or podman is already present.

Unblocks https://github.com/mulgadc/spinifex/actions/runs/25988830244

* revert: drop apk.static bootstrap in build-microvm-image

Reverts 0bd76b7. Self-hosted runners will get docker.io installed
out of band; existing Strategy 2 (docker run alpine:3.20) handles
the rootfs build without host sudo or unsigned package install.

* docs(workbook): drop lb-alpine import for microvm direct-boot LB

LBs now launch via direct-boot QEMU microvm using kernel + initramfs
bundled at /usr/share/spinifex/microvm/, so the legacy lb-alpine system
AMI import is no longer required.

* fix(daemon): own tap devices by daemon user so QEMU can attach without CAP_NET_ADMIN

When the daemon runs non-root (production: User=spinifex-daemon), child
QEMU processes inherit that uid but lack CAP_NET_ADMIN. The kernel's tun
attach check then requires the tap owner uid to match the caller's euid.
Without the `user` flag, `ip tuntap add` defaults to root:root and qemu's
TUNSETIFF fails with EPERM ("could not configure /dev/net/tun: Operation
not permitted").

Surfaced by the e2e-nightly cell-17 microvm LB launch on the ci-pseudo
runner where the kernel enforces strictly; legacy EC2 launches on
ci-single runners happened to attach successfully despite the same code
path. Adding `user $current group $gid` to `ip tuntap add` ensures the
tap is owned by whatever uid the daemon (and its qemu children) run as.
Root daemons skip the flag — they have CAP_NET_ADMIN ambient.

* fix(e2e): redirect lb probe chatter to stderr so stalls are visible

test_http_from / test_tcp_from echoed progress to stdout, which the
callers captured into *_OK vars via $(...). Result: 6+ minute retry
loops ran silently in CI, and the "$ok" comparison never matched
"true" because the captured value contained the full multi-line
chatter. Reachability pass/fail counters were also lost in the
subshell.

Redirect helper output to stderr, leave only the true/false flag on
stdout, and move pass/fail to the caller so they actually count.

* fix(microvm): don't /32 AdvertiseIP via mgmt for internet-facing single-node

buildMicrovmNICs overrode resolveMgmtRoute's deliberate empty return
for the single-node internet-facing case by falling back to
(MgmtBridgeIP, AdvertiseIP). That installed a /32 route to AdvertiseIP
via the mgmt NIC inside the LB microvm. On single-node where the
AWSGW host IP equals the WAN client IP, the /32 also catches reply
traffic for inbound HTTP/TCP to the LB's floating IP — replies leave
via the mgmt NIC with src=data-NIC IP, the host kernel drops the
asymmetric flow, and same-chassis ingress hangs.

resolveMgmtRoute already encodes the correct policy:
  - multi-node any scheme        → explicit MgmtRoute{Gateway,Target}
  - single-node internal         → fallback to (MgmtBridgeIP, AdvertiseIP)
  - single-node internet-facing  → empty (reach AWSGW via VPC SNAT)

Honor it. Matches the legacy lbVMUserData path, whose own comment
flagged the stolen-WAN-return-path symptom this introduced.

Test covers all three branches.

* feat(ansible): dev-deploy playbook for in-place artifact swap

Fills the gap between ansible-dev-install (skips when already installed)
and ansible-dev-reset (full teardown + reinstall + smoketest). Mirrors
Makefile `deploy` target: rebuild + replace binaries and microvm
artifacts on a running node, restart spinifex.target, wait for awsgw.

Skips setup.sh (no user/dir/systemd-unit changes) and smoketest so
iteration is fast. Use ansible-dev-reset when changing systemd units,
helper scripts, logrotate, or setup.sh itself.

Adds:
- roles/deploy/tasks/main.yml — install spx + nbdkit plugin + microvm
  artifacts, with plugindir auto-detection via `nbdkit --dump-config`.
- playbooks/dev-deploy.yml — chains build + deploy roles.
- Makefile ansible-dev-deploy target.
- README playbook table + "when to use which" guidance.

* chore(microvm): remove legacy LB Alpine image build pipeline

Phase 3 microvm removal — strip the PC-machine LB image build
artifacts that direct-boot no longer needs. The microvm initramfs
already carries lb-agent + HAProxy via build-microvm-image, so the
lb.conf manifest, lb-setup.sh chroot script, build-lb-image target,
dev-install / reset-dev-env import steps, and ansible LB-image tasks
all become dead.

build-system-image stays — other manifests (e.g. GPU images) still
use it.

* refactor(microvm): remove legacy PC-machine LB boot path

Phase 3 — direct-boot becomes the only ELBv2 LB launch path.

- Drop SystemInstanceInput.DirectBoot / ImageID / UserData (every LB
  caller set DirectBoot=true; legacy cloud-init userdata is gone).
- Drop ELBv2ServiceImpl.lbVMUserData, systemAMI* fields, SetSystemAMIFunc,
  getSystemAMI, and the lazy-AMI wiring in daemon.startCluster.
- Collapse CreateLoadBalancer to a single direct-boot launch input.
- Collapse daemon.LaunchSystemInstance: drop AMI validation, ImageID
  on RunInstancesInput, UserData base64 encode, GenerateVolumes branch.
  buildDirectBootConfig hardcodes /usr/share/spinifex/microvm and
  isa-serial=on (microvmMachineType no longer takes a flag).
- Drop MicrovmConfig from config.DaemonConfig and the
  [daemon.microvm] section from the spinifex.toml template.
- Prune dead tests (lbVMUserData_*, PCMachine_Unchanged, AMI resolver
  paths) and refresh stale comments that still mentioned lbVMUserData.

ARM hosts have no LB launch path until Phase 4 adds direct-boot on
virt — CreateLoadBalancer on aarch64 fails naturally at QEMU exec.

* docs: drop lb-alpine catalog entry from cluster setup guide

LB system instances no longer use an AMI — direct-boot microvm
artifacts ship in the spinifex package.

* feat(ui): remove LB image import banner + LB-AMI gating

Direct-boot microvm artifacts ship in the spinifex package, so the
LB system AMI no longer exists — the "Load balancer image not imported"
banner and the Create-button disable on the LB list / wizard pages
can never fire. Strip:

- lb-image-missing-banner.tsx component
- hasLbImage / isLbImage / LB_IMAGE_NAME / LB_MANAGED_BY_VALUE from
  system-managed.ts (isSystemManagedImage stays — still used by the
  EC2 Images pages to hide system AMIs)
- Banner gating + ec2ImagesQueryOptions imports from the LB list and
  wizard pages
- The corresponding tests that asserted the banner appears

* revert: restore lb.conf + lb-setup.sh for mulga install pipeline

Phase 3 microvm removal (commit 6313d74) deleted these too early — the
mulga install/iso artifact builders (scripts/tofu-cluster/build-install
-artifacts.sh, scripts/tofu-iso/build-iso-artifacts.sh) still invoke
scripts/build-system-image.sh scripts/images/lb.conf, breaking e2e with
"ERROR: Manifest not found: scripts/images/lb.conf".

Restore both files verbatim so the legacy pipeline keeps building until
the mulga-side migration to microvm-only artifacts lands post-merge.
2026-05-18 15:48:04 +10:00

1248 lines
54 KiB
Bash
Executable File

#!/bin/bash
set -e
# Consolidated LB Data Plane E2E Test
# Tests all 4 LB variants: ALB internet-facing, ALB internal, NLB internet-facing, NLB internal.
# Shares a single VPC, subnet, and set of dual-purpose app instances across all suites.
# ALB + NLB run in parallel within each phase (internet-facing / internal).
#
# Requires: Pool mode with external IPAM (NOT dev_networking).
#
# Usage:
# ./tests/e2e/run-lb-e2e.sh # internal-only (single-node)
# ./tests/e2e/run-lb-e2e.sh --peer <ip> # all 4 variants (multi-node, legacy)
# ./tests/e2e/run-lb-e2e.sh --nodes <ip1> <ip2> ... # all 4 variants (multi-node, preferred)
# ./tests/e2e/run-lb-e2e.sh --microvm # same as above but with microvm.elbv2_enabled=true
#
# CI dual-path: this script is run twice in CI for microvm validation:
# 1. Default (microvm.elbv2_enabled=false) — baseline PC-machine path
# 2. With --microvm flag — direct-boot microvm path
# Both runs must pass identical assertions.
#
# Density test (manual, not CI-automated):
# LB_COUNT=50 ./tests/e2e/run-lb-e2e.sh --microvm
# Launches 50 LBs; asserts all reach Active; total QEMU fleet RSS logged.
#
# Customer-VM regression: run-e2e.sh (RunInstances + SSH) is run separately
# with microvm.elbv2_enabled=true to confirm customer VMs are unaffected.
# LaunchSystemInstance with DirectBoot=true is only reached by the ELBv2
# service; RunInstances takes the PC-machine path unconditionally.
cd "$(dirname "$0")/../.."
# ==========================================================================
# Dev mode gate
# ==========================================================================
SPINIFEX_CONFIG="${HOME}/spinifex/config/spinifex.toml"
if [ -f "$SPINIFEX_CONFIG" ]; then
if grep -q 'dev_networking = true' "$SPINIFEX_CONFIG"; then
echo "Skipping LB E2E: dev_networking is enabled (no external IPAM)"
exit 0
fi
fi
# ==========================================================================
# Arguments
# ==========================================================================
PEER_NODE_IP=""
ALL_NODE_IPS=()
MICROVM_MODE=false
while [ $# -gt 0 ]; do
case "$1" in
--peer) PEER_NODE_IP="$2"; shift 2 ;;
--nodes) shift; while [ $# -gt 0 ] && [[ "$1" != --* ]]; do ALL_NODE_IPS+=("$1"); shift; done ;;
--microvm) MICROVM_MODE=true; shift ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done
if [ "$MICROVM_MODE" = true ]; then
export SPINIFEX_MICROVM_ELBV2_ENABLED=true
echo "[run-lb-e2e] microvm mode: SPINIFEX_MICROVM_ELBV2_ENABLED=true"
fi
# --nodes supersedes --peer; fall back to --peer for backwards compat
if [ ${#ALL_NODE_IPS[@]} -gt 0 ] && [ -z "$PEER_NODE_IP" ]; then
PEER_NODE_IP="${ALL_NODE_IPS[1]:-}"
fi
ENDPOINT="${ENDPOINT:-https://127.0.0.1:9999}"
export AWS_PROFILE=spinifex
AWS_EC2="aws --endpoint-url ${ENDPOINT} ec2"
AWS_ELBV2="aws --endpoint-url ${ENDPOINT} elbv2"
SSH_KEY_PATH="${PEER_SSH_KEY:-$HOME/.ssh/tf-user-ap-southeast-2}"
PEER_USER="${PEER_SSH_USER:-tf-user}"
PASSED=0
FAILED=0
pass() {
echo " ✅ $1"
PASSED=$((PASSED + 1))
}
fail() {
echo " ❌ $1"
FAILED=$((FAILED + 1))
}
peer_ssh() {
local ip="$1"; shift
ssh -i "$SSH_KEY_PATH" \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=10 \
-o LogLevel=ERROR \
"${PEER_USER}@${ip}" "$@"
}
# find_lb_host <eni_id> — discover which node hosts the LB system instance by
# checking for its OVS tap device. Prints the node's WAN IP; returns 1 if not found.
find_lb_host() {
local eni_id="$1"
local tap_prefix
tap_prefix="tap${eni_id#eni-}"
tap_prefix="${tap_prefix:0:15}"
local my_ip
my_ip=$(ip -4 -o addr show br-wan 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
for node_ip in "${ALL_NODE_IPS[@]}"; do
local found
if [ "$node_ip" = "$my_ip" ]; then
found=$(ip link show "$tap_prefix" 2>/dev/null || true)
else
found=$(peer_ssh "$node_ip" "ip link show '$tap_prefix'" 2>/dev/null || true)
fi
if [ -n "$found" ]; then
echo "$node_ip"
return 0
fi
done
return 1
}
# pick_peer <host_ip> — return the first node IP that is NOT the given host.
pick_peer() {
local host_ip="$1"
for node_ip in "${ALL_NODE_IPS[@]}"; do
if [ "$node_ip" != "$host_ip" ]; then
echo "$node_ip"
return 0
fi
done
return 1
}
# ==========================================================================
# Resource tracking for cleanup
# ==========================================================================
VPC_ID=""
SUBNET_ID=""
IGW_ID=""
APP_INSTANCE_IDS=()
CLIENT_INSTANCE_ID=""
# Track ALB + NLB resources separately for parallel suites
ALB_LISTENER_ARN=""
ALB_LB_ARN=""
ALB_TG_ARN=""
NLB_LISTENER_ARN=""
NLB_LB_ARN=""
NLB_TG_ARN=""
cleanup() {
local exit_code=$?
echo ""
echo "Cleanup..."
# LB resources (in case a suite failed mid-way)
for arn in "$ALB_LISTENER_ARN" "$NLB_LISTENER_ARN"; do
[ -n "$arn" ] && $AWS_ELBV2 delete-listener --listener-arn "$arn" 2>/dev/null || true
done
for arn in "$ALB_LB_ARN" "$NLB_LB_ARN"; do
[ -n "$arn" ] && $AWS_ELBV2 delete-load-balancer --load-balancer-arn "$arn" 2>/dev/null || true
done
for arn in "$ALB_TG_ARN" "$NLB_TG_ARN"; do
[ -n "$arn" ] && $AWS_ELBV2 delete-target-group --target-group-arn "$arn" 2>/dev/null || true
done
# Instances
ALL_INSTANCES=("${APP_INSTANCE_IDS[@]}")
if [ -n "$CLIENT_INSTANCE_ID" ]; then
ALL_INSTANCES+=("$CLIENT_INSTANCE_ID")
fi
for inst_id in "${ALL_INSTANCES[@]}"; do
if [ -n "$inst_id" ]; then
echo " Terminating instance $inst_id..."
$AWS_EC2 terminate-instances --instance-ids "$inst_id" 2>/dev/null || true
fi
done
if [ ${#ALL_INSTANCES[@]} -gt 0 ]; then
echo " Waiting for instances to terminate..."
for attempt in $(seq 1 30); do
ALL_TERMINATED=true
for inst_id in "${ALL_INSTANCES[@]}"; do
STATE=$($AWS_EC2 describe-instances --instance-ids "$inst_id" \
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo "terminated")
if [ "$STATE" != "terminated" ]; then
ALL_TERMINATED=false
break
fi
done
if [ "$ALL_TERMINATED" = true ]; then break; fi
sleep 2
done
fi
echo " Deleting key pair..."
$AWS_EC2 delete-key-pair --key-name lb-e2e-key 2>/dev/null || true
if [ -n "$IGW_ID" ] && [ -n "$VPC_ID" ]; then
echo " Detaching IGW..."
$AWS_EC2 detach-internet-gateway --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID" 2>/dev/null || true
$AWS_EC2 delete-internet-gateway --internet-gateway-id "$IGW_ID" 2>/dev/null || true
fi
if [ -n "$SUBNET_ID" ]; then
echo " Deleting subnet..."
$AWS_EC2 delete-subnet --subnet-id "$SUBNET_ID" 2>/dev/null || true
fi
if [ -n "$VPC_ID" ]; then
echo " Deleting VPC..."
$AWS_EC2 delete-vpc --vpc-id "$VPC_ID" 2>/dev/null || true
fi
echo "Cleanup complete"
echo ""
echo "========================================"
echo "LB E2E Results: $PASSED passed, $FAILED failed"
echo "========================================"
if [ $FAILED -gt 0 ]; then exit 1; fi
exit $exit_code
}
trap cleanup EXIT
echo "========================================"
echo "Consolidated LB Data Plane E2E"
echo "========================================"
echo "Endpoint: $ENDPOINT"
echo "Peer node: ${PEER_NODE_IP:-none (internet-facing tests will be skipped)}"
echo ""
# ==========================================================================
# Phase 0: Prerequisites
# ==========================================================================
echo "Phase 0: Prerequisites"
echo "========================================"
PEER_AVAILABLE=false
if [ -n "$PEER_NODE_IP" ]; then
echo "Verifying SSH to peer node..."
if peer_ssh "$PEER_NODE_IP" "hostname" > /dev/null 2>&1; then
PEER_AVAILABLE=true
pass "SSH to peer node $PEER_NODE_IP"
else
echo " Cannot SSH to peer node $PEER_NODE_IP — internet-facing tests will be skipped"
fi
fi
echo "Discovering instance types..."
AVAILABLE_TYPES=$($AWS_EC2 describe-instance-types --query 'InstanceTypes[*].InstanceType' --output text)
INSTANCE_TYPE=$(echo $AVAILABLE_TYPES | tr ' ' '\n' | grep -m1 'nano')
if [ -z "$INSTANCE_TYPE" ] || [ "$INSTANCE_TYPE" == "None" ]; then
echo "ERROR: No nano instance type found"; exit 1
fi
pass "instance type: $INSTANCE_TYPE"
echo "Discovering AMIs..."
ALL_IMAGES=$($AWS_EC2 describe-images --output json 2>&1)
# Prefer the system-imported ubuntu AMI (name starts with "ami-ubuntu").
# Fall back to any non-alpine image, then any image at all.
AMI_ID=$(echo "$ALL_IMAGES" | jq -r '[.Images[] | select(.Name | test("^ami-ubuntu"))][0].ImageId // empty')
if [ -z "$AMI_ID" ]; then
AMI_ID=$(echo "$ALL_IMAGES" | jq -r '[.Images[] | select(.Name | test("alpine") | not)][0].ImageId // empty')
fi
if [ -z "$AMI_ID" ]; then
AMI_ID=$(echo "$ALL_IMAGES" | jq -r '.Images[0].ImageId // empty')
fi
if [ -z "$AMI_ID" ] || [ "$AMI_ID" == "None" ]; then
echo "ERROR: No AMI found"; exit 1
fi
pass "AMI: $AMI_ID"
echo "Creating key pair..."
$AWS_EC2 delete-key-pair --key-name lb-e2e-key 2>/dev/null || true
$AWS_EC2 create-key-pair --key-name lb-e2e-key --output json > /dev/null 2>&1 || {
fail "create key pair"; exit 1
}
pass "key pair: lb-e2e-key"
# ==========================================================================
# Phase 1: Shared VPC + Subnet
# ==========================================================================
echo ""
echo "Phase 1: Shared VPC + Subnet"
echo "========================================"
echo "Creating VPC..."
VPC_OUTPUT=$($AWS_EC2 create-vpc --cidr-block 10.200.0.0/16 --output json) || { fail "create-vpc"; exit 1; }
VPC_ID=$(echo "$VPC_OUTPUT" | jq -r '.Vpc.VpcId')
pass "create-vpc: $VPC_ID"
echo "Creating internet gateway..."
IGW_OUTPUT=$($AWS_EC2 create-internet-gateway --output json) || { fail "create-internet-gateway"; exit 1; }
IGW_ID=$(echo "$IGW_OUTPUT" | jq -r '.InternetGateway.InternetGatewayId')
$AWS_EC2 attach-internet-gateway --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID" || { fail "attach-internet-gateway"; exit 1; }
pass "internet gateway: $IGW_ID (attached)"
echo "Creating subnet..."
SUBNET_OUTPUT=$($AWS_EC2 create-subnet --vpc-id "$VPC_ID" --cidr-block 10.200.1.0/24 --output json) || { fail "create-subnet"; exit 1; }
SUBNET_ID=$(echo "$SUBNET_OUTPUT" | jq -r '.Subnet.SubnetId')
pass "create-subnet: $SUBNET_ID"
$AWS_EC2 modify-subnet-attribute --subnet-id "$SUBNET_ID" --map-public-ip-on-launch 2>&1 || { fail "modify-subnet-attribute"; exit 1; }
pass "MapPublicIpOnLaunch enabled"
# Authorize ingress on the new VPC's default SG before any run-instances.
# Default SGs allow ingress only from members of the same SG; LB health probes
# and client traffic come from outside the SG and would otherwise be dropped
# by the OVN port-group ACL, causing 120s polling timeouts.
DEFAULT_SG_LB=$($AWS_EC2 describe-security-groups \
--filters "Name=vpc-id,Values=$VPC_ID" "Name=group-name,Values=default" \
--query 'SecurityGroups[0].GroupId' --output text)
echo "VPC default SG: $DEFAULT_SG_LB"
for port in 80 9000; do
set +e
AUTH_OUTPUT=$($AWS_EC2 authorize-security-group-ingress \
--group-id "$DEFAULT_SG_LB" \
--protocol tcp --port "$port" --cidr 0.0.0.0/0 2>&1)
AUTH_EXIT=$?
set -e
if [ $AUTH_EXIT -ne 0 ] && ! echo "$AUTH_OUTPUT" | grep -q 'InvalidPermission.Duplicate'; then
fail "authorize tcp/$port on default SG: $AUTH_OUTPUT"; exit 1
fi
done
pass "default SG ingress: tcp/80, tcp/9000 from 0.0.0.0/0"
# ==========================================================================
# Phase 2: Launch Dual-Purpose App Instances
# ==========================================================================
echo ""
echo "Phase 2: Launch App Instances"
echo "========================================"
APP_USER_DATA=$(cat <<'USERDATA'
#!/bin/bash
INSTANCE_ID=$(hostname)
# HTTP responder (ALB tests)
mkdir -p /tmp/httpd && cd /tmp/httpd
echo "{\"instance_id\": \"${INSTANCE_ID}\"}" > index.html
nohup python3 -m http.server 80 --bind 0.0.0.0 > /dev/null 2>&1 &
# TCP echo responder (NLB tests) — stdlib only, no extra packages
cat > /tmp/tcp_echo.py << 'PYEOF'
import socketserver, os
class Handler(socketserver.StreamRequestHandler):
def handle(self):
self.wfile.write((os.uname()[1] + "\n").encode())
socketserver.TCPServer.allow_reuse_address = True
socketserver.TCPServer(("0.0.0.0", 9000), Handler).serve_forever()
PYEOF
nohup python3 /tmp/tcp_echo.py > /dev/null 2>&1 &
USERDATA
)
echo "Launching 2 dual-purpose app instances (HTTP:80 + TCP:9000)..."
for i in 1 2; do
echo " Launching app instance $i..."
RUN_OUTPUT=$($AWS_EC2 run-instances \
--image-id "$AMI_ID" \
--instance-type "$INSTANCE_TYPE" \
--key-name lb-e2e-key \
--subnet-id "$SUBNET_ID" \
--user-data "$APP_USER_DATA" \
--output json 2>&1) || {
fail "run-instances (app $i)"; echo " Output: $RUN_OUTPUT"; exit 1
}
INST_ID=$(echo "$RUN_OUTPUT" | jq -r '.Instances[0].InstanceId')
if [ -z "$INST_ID" ] || [ "$INST_ID" == "null" ]; then
fail "run-instances (app $i) — no instance ID"; exit 1
fi
APP_INSTANCE_IDS+=("$INST_ID")
echo " App instance $i: $INST_ID"
done
pass "launched ${#APP_INSTANCE_IDS[@]} app instances"
echo "Waiting for instances to reach running state..."
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
for attempt in $(seq 1 60); do
STATE=$($AWS_EC2 describe-instances --instance-ids "$inst_id" \
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null)
if [ "$STATE" == "running" ]; then break; fi
if [ "$STATE" == "terminated" ] || [ $attempt -eq 60 ]; then
REASON=$($AWS_EC2 describe-instances --instance-ids "$inst_id" \
--query 'Reservations[0].Instances[0].StateReason.Message' --output text 2>/dev/null || echo "unknown")
fail "instance $inst_id did not reach running (stuck in $STATE, reason: $REASON)"
# Dump daemon log tail for debugging
echo " Daemon log tail:"
sudo journalctl -u spinifex-daemon --no-pager -n 30 2>/dev/null || tail -30 ~/spinifex/logs/spinifex.log 2>/dev/null || echo " (no logs available)"
exit 1
fi
sleep 2
done
done
pass "all instances running"
echo "Collecting app instance private IPs..."
declare -A INSTANCE_IPS
for inst_id in "${APP_INSTANCE_IDS[@]}"; do
IP=$($AWS_EC2 describe-instances --instance-ids "$inst_id" \
--query 'Reservations[0].Instances[0].PrivateIpAddress' --output text)
if [ -z "$IP" ] || [ "$IP" == "None" ]; then
fail "instance $inst_id has no PrivateIpAddress"; exit 1
fi
INSTANCE_IPS[$inst_id]="$IP"
echo " $inst_id -> $IP"
done
pass "all app instances have private IPs"
echo "App readiness will be verified via LB health checks in each test suite"
# ==========================================================================
# Helper: wait for LB to become active
# ==========================================================================
wait_for_lb_active() {
local lb_arn="$1" label="$2" timeout="${3:-270}"
echo "Waiting for $label to become active (up to ${timeout}s)..."
local lb_active=false
for attempt in $(seq 1 $((timeout / 3))); do
local state
state=$($AWS_ELBV2 describe-load-balancers --load-balancer-arns "$lb_arn" \
--query 'LoadBalancers[0].State.Code' --output text 2>/dev/null)
if [ "$state" == "active" ]; then
lb_active=true; break
fi
if [ $((attempt % 10)) -eq 0 ]; then
echo " $label state: $state (attempt $attempt)"
fi
sleep 3
done
if [ "$lb_active" = true ]; then
pass "$label state: active"
return 0
else
fail "$label did not reach active state (stuck in $state)"
echo " Debug: daemon logs:"
sudo journalctl -u spinifex-daemon --no-pager -n 50 2>/dev/null \
| grep -iE 'LaunchSystemInstance|LB.VM|lb-agent|alb-agent|mgmt|heartbeat|GenerateVolumes|volume_preparation' \
| tail -20 || \
grep -iE 'LaunchSystemInstance|LB.VM|lb-agent|alb-agent|mgmt|heartbeat|GenerateVolumes|volume_preparation' ~/spinifex/logs/spinifex.log 2>/dev/null \
| tail -20 || echo " (no matching log lines)"
return 1
fi
}
# ==========================================================================
# Helper: wait for targets to become healthy
# ==========================================================================
wait_for_targets_healthy() {
local tg_arn="$1" expected="$2" label="$3" timeout="${4:-120}"
echo "Polling target health for $label (timeout ${timeout}s)..."
local start=$(date +%s) healthy=false health_output
while true; do
local elapsed=$(( $(date +%s) - start ))
if [ $elapsed -ge $timeout ]; then break; fi
health_output=$($AWS_ELBV2 describe-target-health \
--target-group-arn "$tg_arn" --output json 2>/dev/null) || { sleep 5; continue; }
local healthy_count total_count
healthy_count=$(echo "$health_output" | jq '[.TargetHealthDescriptions[] | select(.TargetHealth.State == "healthy")] | length')
total_count=$(echo "$health_output" | jq '.TargetHealthDescriptions | length')
echo " ${elapsed}s: $healthy_count/$total_count targets healthy"
if [ "$healthy_count" -eq "$expected" ]; then
healthy=true; break
fi
sleep 5
done
if [ "$healthy" = true ]; then
pass "$label: $expected targets healthy"
else
echo " Current target health:"
echo "$health_output" | jq -r '.TargetHealthDescriptions[] | " \(.Target.Id): \(.TargetHealth.State) (\(.TargetHealth.Reason // "n/a"))"'
fail "$label: targets did not become healthy within ${timeout}s"
fi
}
# ==========================================================================
# Helper: verify ENI cleanup after LB deletion
# ==========================================================================
wait_for_eni_cleanup() {
local eni_filter="$1" label="$2"
echo "Verifying ENI cleanup for $label..."
local cleaned=false
for attempt in $(seq 1 10); do
local count
count=$($AWS_EC2 describe-network-interfaces \
--filters "Name=description,Values=$eni_filter" \
--query 'NetworkInterfaces | length(@)' --output text 2>/dev/null)
if [ "$count" == "0" ] || [ -z "$count" ]; then
cleaned=true; break
fi
sleep 3
done
if [ "$cleaned" = true ]; then
pass "$label ENI cleaned up"
else
fail "$label ENI still exists after 30s"
fi
}
# ==========================================================================
# Helper: run HTTP traffic test (ALB suites)
# ==========================================================================
run_http_traffic_test() {
local url="$1" label="$2" num_requests="${3:-20}"
echo "Sending $num_requests HTTP requests to $url ..."
declare -A counts
local total_ok=0 total_fail=0
for i in $(seq 1 $num_requests); do
local resp
resp=$(curl -s --max-time 5 "$url/" 2>/dev/null) || { total_fail=$((total_fail+1)); continue; }
local inst
inst=$(echo "$resp" | jq -r '.instance_id // empty' 2>/dev/null)
if [ -n "$inst" ]; then
counts[$inst]=$(( ${counts[$inst]:-0} + 1 ))
total_ok=$((total_ok+1))
else
total_fail=$((total_fail+1))
fi
done
echo " Results: $total_ok successful, $total_fail failed"
echo " Distribution:"
for inst_id in "${!counts[@]}"; do echo " $inst_id: ${counts[$inst_id]} responses"; done
local unique=${#counts[@]}
if [ "$unique" -ge 2 ]; then
pass "$label round-robin: $unique unique instances"
else
fail "$label round-robin: expected 2 unique responders, got $unique"
fi
if [ "$total_ok" -ge $((num_requests / 2)) ]; then
pass "$label success rate: $total_ok/$num_requests"
else
fail "$label success rate: only $total_ok/$num_requests"
fi
}
# ==========================================================================
# Helper: run TCP traffic test (NLB suites)
# ==========================================================================
run_tcp_traffic_test() {
local ip="$1" port="$2" label="$3" num_requests="${4:-20}"
echo "Sending $num_requests TCP requests to ${ip}:${port} ..."
declare -A counts
local total_ok=0 total_fail=0
for i in $(seq 1 $num_requests); do
local resp
resp=$(echo "" | nc -w5 "$ip" "$port" 2>/dev/null || true)
resp=$(echo "$resp" | tr -d '[:space:]')
if [ -n "$resp" ]; then
counts[$resp]=$(( ${counts[$resp]:-0} + 1 ))
total_ok=$((total_ok+1))
else
total_fail=$((total_fail+1))
fi
done
echo " Results: $total_ok successful, $total_fail failed"
echo " Distribution:"
for inst_id in "${!counts[@]}"; do echo " $inst_id: ${counts[$inst_id]} responses"; done
local unique=${#counts[@]}
if [ "$unique" -ge 2 ]; then
pass "$label round-robin: $unique unique instances"
elif [ "$unique" -eq 1 ]; then
pass "$label traffic forwarded: 1 instance ($total_ok/$num_requests successful)"
else
fail "$label: no successful responses"
fi
if [ "$total_ok" -ge $((num_requests / 2)) ]; then
pass "$label success rate: $total_ok/$num_requests"
else
fail "$label success rate: only $total_ok/$num_requests"
fi
}
# ==========================================================================
# Helper: parse results file and verify traffic distribution
# ==========================================================================
verify_traffic_results() {
local results="$1" proto="$2" label="$3"
declare -A resp_counts
local total_ok=0
while IFS= read -r line; do
if [ "$proto" = "http" ]; then
local inst
inst=$(echo "$line" | jq -r '.instance_id // empty' 2>/dev/null)
if [ -n "$inst" ]; then
resp_counts[$inst]=$(( ${resp_counts[$inst]:-0} + 1 ))
total_ok=$((total_ok+1))
fi
else
line=$(echo "$line" | tr -d '[:space:]')
if [ -n "$line" ]; then
resp_counts[$line]=$(( ${resp_counts[$line]:-0} + 1 ))
total_ok=$((total_ok+1))
fi
fi
done <<< "$results"
echo " Results: $total_ok successful"
echo " Distribution:"
for inst_id in "${!resp_counts[@]}"; do echo " $inst_id: ${resp_counts[$inst_id]} responses"; done
local unique=${#resp_counts[@]}
if [ "$unique" -ge 2 ]; then
pass "$label round-robin via private IP: $unique unique instances"
elif [ "$unique" -eq 1 ]; then
pass "$label traffic forwarded via private IP: 1 instance ($total_ok/20 successful)"
else
fail "$label: no successful responses"
fi
if [ "$total_ok" -ge 10 ]; then
pass "$label success rate: $total_ok/20"
else
fail "$label success rate: only $total_ok/20"
fi
}
# ##########################################################################
# Phase 3a: Internet-Facing (ALB + NLB in parallel)
# ##########################################################################
if [ "$PEER_AVAILABLE" = true ]; then
echo ""
echo "========================================"
echo "Phase 3a: Internet-Facing (ALB + NLB)"
echo "========================================"
# --- Create both TGs ---
echo "Creating HTTP target group (ALB)..."
TG_OUTPUT=$($AWS_ELBV2 create-target-group \
--name lb-e2e-alb-inet-tg \
--protocol HTTP --port 80 \
--vpc-id "$VPC_ID" \
--health-check-path "/index.html" \
--health-check-interval-seconds 5 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 2 \
--output json 2>&1) || { fail "create-target-group (ALB inet)"; }
ALB_TG_ARN=$(echo "$TG_OUTPUT" | jq -r '.TargetGroups[0].TargetGroupArn')
pass "ALB target group: $ALB_TG_ARN"
echo "Creating TCP target group (NLB)..."
TG_OUTPUT=$($AWS_ELBV2 create-target-group \
--name lb-e2e-nlb-inet-tg \
--protocol TCP --port 9000 \
--vpc-id "$VPC_ID" \
--health-check-protocol TCP \
--health-check-interval-seconds 10 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 2 \
--output json 2>&1) || { fail "create-target-group (NLB inet)"; }
NLB_TG_ARN=$(echo "$TG_OUTPUT" | jq -r '.TargetGroups[0].TargetGroupArn')
pass "NLB target group: $NLB_TG_ARN"
TG_PROTOCOL=$(echo "$TG_OUTPUT" | jq -r '.TargetGroups[0].Protocol')
HC_PROTOCOL=$(echo "$TG_OUTPUT" | jq -r '.TargetGroups[0].HealthCheckProtocol')
if [ "$TG_PROTOCOL" == "TCP" ]; then pass "NLB TG protocol: TCP"; else fail "NLB TG protocol: expected TCP, got $TG_PROTOCOL"; fi
if [ "$HC_PROTOCOL" == "TCP" ]; then pass "NLB HC protocol: TCP"; else fail "NLB HC protocol: expected TCP, got $HC_PROTOCOL"; fi
# Register targets to both TGs
$AWS_ELBV2 register-targets --target-group-arn "$ALB_TG_ARN" \
--targets "Id=${APP_INSTANCE_IDS[0]}" "Id=${APP_INSTANCE_IDS[1]}" --output json 2>&1 || { fail "register-targets (ALB)"; }
$AWS_ELBV2 register-targets --target-group-arn "$NLB_TG_ARN" \
--targets "Id=${APP_INSTANCE_IDS[0]}" "Id=${APP_INSTANCE_IDS[1]}" --output json 2>&1 || { fail "register-targets (NLB)"; }
pass "registered 2 targets to both TGs"
# --- Create both LBs (they provision in parallel) ---
echo "Creating internet-facing ALB..."
LB_OUTPUT=$($AWS_ELBV2 create-load-balancer \
--name lb-e2e-alb-inet \
--scheme internet-facing \
--subnets "$SUBNET_ID" \
--output json 2>&1) || { fail "create-load-balancer (ALB inet)"; }
ALB_LB_ARN=$(echo "$LB_OUTPUT" | jq -r '.LoadBalancers[0].LoadBalancerArn')
ALB_LB_SCHEME=$(echo "$LB_OUTPUT" | jq -r '.LoadBalancers[0].Scheme')
pass "ALB: $ALB_LB_ARN (scheme: $ALB_LB_SCHEME)"
if [ "$ALB_LB_SCHEME" == "internet-facing" ]; then pass "ALB scheme: internet-facing"; else fail "ALB scheme: expected internet-facing, got $ALB_LB_SCHEME"; fi
echo "Creating internet-facing NLB..."
LB_OUTPUT=$($AWS_ELBV2 create-load-balancer \
--name lb-e2e-nlb-inet \
--type network \
--subnets "$SUBNET_ID" \
--output json 2>&1) || { fail "create-load-balancer (NLB inet)"; }
NLB_LB_ARN=$(echo "$LB_OUTPUT" | jq -r '.LoadBalancers[0].LoadBalancerArn')
NLB_LB_TYPE=$(echo "$LB_OUTPUT" | jq -r '.LoadBalancers[0].Type')
pass "NLB: $NLB_LB_ARN (type: $NLB_LB_TYPE)"
if [ "$NLB_LB_TYPE" == "network" ]; then pass "NLB type: network"; else fail "NLB type: expected network, got $NLB_LB_TYPE"; fi
if echo "$NLB_LB_ARN" | grep -q "/net/"; then pass "NLB ARN contains /net/"; else fail "NLB ARN missing /net/"; fi
# --- Verify ENIs ---
ALB_LB_ID=$(echo "$ALB_LB_ARN" | sed 's|.*/||')
NLB_LB_ID=$(echo "$NLB_LB_ARN" | sed 's|.*/||')
sleep 3
ENI_OUTPUT=$($AWS_EC2 describe-network-interfaces \
--filters "Name=description,Values=ELB app/lb-e2e-alb-inet/${ALB_LB_ID}" \
--output json 2>/dev/null)
ALB_PUBLIC_IP=$(echo "$ENI_OUTPUT" | jq -r '.NetworkInterfaces[0].Association.PublicIp // empty' 2>/dev/null)
ALB_ENI_ID=$(echo "$ENI_OUTPUT" | jq -r '.NetworkInterfaces[0].NetworkInterfaceId // empty' 2>/dev/null)
if [ -n "$ALB_PUBLIC_IP" ] && [ "$ALB_PUBLIC_IP" != "null" ]; then
pass "ALB ENI has public IP: $ALB_PUBLIC_IP"
else
fail "ALB ENI has no public IP"; exit 1
fi
ENI_OUTPUT=$($AWS_EC2 describe-network-interfaces \
--filters "Name=description,Values=ELB net/lb-e2e-nlb-inet/${NLB_LB_ID}" \
--output json 2>/dev/null)
NLB_PUBLIC_IP=$(echo "$ENI_OUTPUT" | jq -r '.NetworkInterfaces[0].Association.PublicIp // empty' 2>/dev/null)
NLB_ENI_ID=$(echo "$ENI_OUTPUT" | jq -r '.NetworkInterfaces[0].NetworkInterfaceId // empty' 2>/dev/null)
if [ -n "$NLB_PUBLIC_IP" ] && [ "$NLB_PUBLIC_IP" != "null" ]; then
pass "NLB ENI has public IP: $NLB_PUBLIC_IP"
else
fail "NLB ENI has no public IP"
fi
# DNS checks
ALB_DNS=$($AWS_ELBV2 describe-load-balancers --load-balancer-arns "$ALB_LB_ARN" \
--query 'LoadBalancers[0].DNSName' --output text 2>/dev/null)
if echo "$ALB_DNS" | grep -q "^internal-"; then
fail "ALB DNS has internal- prefix for internet-facing"
else
pass "ALB DNS: no internal- prefix"
fi
# --- Create both listeners ---
echo "Creating ALB listener (HTTP:80)..."
LISTENER_OUTPUT=$($AWS_ELBV2 create-listener \
--load-balancer-arn "$ALB_LB_ARN" \
--protocol HTTP --port 80 \
--default-actions "Type=forward,TargetGroupArn=$ALB_TG_ARN" \
--output json 2>&1) || { fail "create-listener (ALB)"; }
ALB_LISTENER_ARN=$(echo "$LISTENER_OUTPUT" | jq -r '.Listeners[0].ListenerArn')
pass "ALB listener: $ALB_LISTENER_ARN"
echo "Creating NLB listener (TCP:9000)..."
LISTENER_OUTPUT=$($AWS_ELBV2 create-listener \
--load-balancer-arn "$NLB_LB_ARN" \
--protocol TCP --port 9000 \
--default-actions "Type=forward,TargetGroupArn=$NLB_TG_ARN" \
--output json 2>&1) || { fail "create-listener (NLB)"; }
NLB_LISTENER_ARN=$(echo "$LISTENER_OUTPUT" | jq -r '.Listeners[0].ListenerArn')
pass "NLB listener: $NLB_LISTENER_ARN"
# --- Wait for both LBs active (provisioning in parallel, checked sequentially) ---
wait_for_lb_active "$ALB_LB_ARN" "ALB internet-facing" || exit 1
wait_for_lb_active "$NLB_LB_ARN" "NLB internet-facing" || exit 1
# --- Wait for both TGs healthy ---
wait_for_targets_healthy "$ALB_TG_ARN" 2 "ALB internet-facing"
wait_for_targets_healthy "$NLB_TG_ARN" 2 "NLB internet-facing"
# --- Discover hosting nodes for each LB ---
# The LB VM can land on any node via NATS queue group distribution.
# "Local" tests run from the hosting node; "remote" tests from another node.
ALB_HOST=""
NLB_HOST=""
ALB_REMOTE=""
NLB_REMOTE=""
LOCAL_IP=$(ip -4 -o addr show br-wan 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
if [ ${#ALL_NODE_IPS[@]} -gt 1 ]; then
echo "Discovering LB hosting nodes..."
if [ -n "$ALB_ENI_ID" ]; then
ALB_HOST=$(find_lb_host "$ALB_ENI_ID") || true
fi
if [ -n "$NLB_ENI_ID" ]; then
NLB_HOST=$(find_lb_host "$NLB_ENI_ID") || true
fi
if [ -n "$ALB_HOST" ]; then
ALB_REMOTE=$(pick_peer "$ALB_HOST") || ALB_REMOTE=""
echo " ALB VM on $ALB_HOST (remote test from $ALB_REMOTE)"
else
echo " ALB VM host: unknown (falling back to local=$LOCAL_IP, remote=$PEER_NODE_IP)"
ALB_HOST="$LOCAL_IP"
ALB_REMOTE="$PEER_NODE_IP"
fi
if [ -n "$NLB_HOST" ]; then
NLB_REMOTE=$(pick_peer "$NLB_HOST") || NLB_REMOTE=""
echo " NLB VM on $NLB_HOST (remote test from $NLB_REMOTE)"
else
echo " NLB VM host: unknown (falling back to local=$LOCAL_IP, remote=$PEER_NODE_IP)"
NLB_HOST="$LOCAL_IP"
NLB_REMOTE="$PEER_NODE_IP"
fi
else
ALB_HOST="$LOCAL_IP"
NLB_HOST="$LOCAL_IP"
fi
# --- Helper: run a connectivity test from a specific node ---
# test_http_from <node_ip> <url> <label>
test_http_from() {
local node_ip="$1" url="$2" label="$3"
echo "Testing $label at $url from $node_ip..." >&2
local ok=false
for attempt in $(seq 1 20); do
local result
if [ "$node_ip" = "$LOCAL_IP" ]; then
result=$(curl -s --max-time 3 "$url/" 2>/dev/null || true)
else
result=$(peer_ssh "$node_ip" "curl -s --max-time 3 '$url/'" 2>/dev/null || true)
fi
if echo "$result" | grep -q "instance_id"; then
ok=true; break
fi
echo " Attempt $attempt/20: not yet responding..." >&2
sleep 5
done
echo "$ok"
}
# test_tcp_from <node_ip> <ip> <port> <label>
test_tcp_from() {
local node_ip="$1" target_ip="$2" port="$3" label="$4"
echo "Testing $label at ${target_ip}:${port} from $node_ip..." >&2
local ok=false
for attempt in $(seq 1 20); do
local probe
if [ "$node_ip" = "$LOCAL_IP" ]; then
probe=$(echo "" | nc -w5 "$target_ip" "$port" 2>/dev/null || true)
else
probe=$(peer_ssh "$node_ip" "echo '' | nc -w5 '$target_ip' '$port'" 2>/dev/null || true)
fi
if [ -n "$probe" ]; then ok=true; break; fi
echo " Attempt $attempt/20..." >&2
sleep 5
done
echo "$ok"
}
# --- ALB: local connectivity (from hosting node) ---
ALB_URL="http://${ALB_PUBLIC_IP}:80"
ALB_LOCAL_OK=$(test_http_from "$ALB_HOST" "$ALB_URL" "ALB inet (local)")
if [ "$ALB_LOCAL_OK" = "true" ]; then pass "ALB inet (local) reachable"; else fail "ALB inet (local) unreachable at $ALB_URL from $ALB_HOST"; fi
# ALB traffic test from hosting node
if [ "$ALB_LOCAL_OK" = "true" ]; then
if [ "$ALB_HOST" = "$LOCAL_IP" ]; then
run_http_traffic_test "$ALB_URL" "ALB inet (local)"
else
# Run traffic test via SSH on the hosting node
declare -A ALB_LOCAL_COUNTS
ALB_LOCAL_TOTAL=0
for i in $(seq 1 20); do
RESPONSE=$(peer_ssh "$ALB_HOST" "curl -s --max-time 5 '$ALB_URL/'" 2>/dev/null) || continue
RESP_INSTANCE=$(echo "$RESPONSE" | jq -r '.instance_id // empty' 2>/dev/null)
if [ -n "$RESP_INSTANCE" ]; then
ALB_LOCAL_COUNTS[$RESP_INSTANCE]=$(( ${ALB_LOCAL_COUNTS[$RESP_INSTANCE]:-0} + 1 ))
ALB_LOCAL_TOTAL=$((ALB_LOCAL_TOTAL + 1))
fi
done
echo " Distribution:"
for inst_id in "${!ALB_LOCAL_COUNTS[@]}"; do echo " $inst_id: ${ALB_LOCAL_COUNTS[$inst_id]} responses"; done
if [ "${#ALB_LOCAL_COUNTS[@]}" -ge 2 ]; then pass "ALB inet (local) round-robin: ${#ALB_LOCAL_COUNTS[@]} unique instances"; else fail "ALB inet (local) round-robin: expected 2 unique responders, got ${#ALB_LOCAL_COUNTS[@]}"; fi
if [ "$ALB_LOCAL_TOTAL" -ge 10 ]; then pass "ALB inet (local) success rate: $ALB_LOCAL_TOTAL/20"; else fail "ALB inet (local) success rate: only $ALB_LOCAL_TOTAL/20"; fi
fi
fi
# --- NLB: local connectivity (from hosting node) ---
if [ -n "$NLB_PUBLIC_IP" ] && [ "$NLB_PUBLIC_IP" != "null" ]; then
NLB_LOCAL_OK=$(test_tcp_from "$NLB_HOST" "$NLB_PUBLIC_IP" 9000 "NLB inet (local)")
if [ "$NLB_LOCAL_OK" = "true" ]; then pass "NLB inet (local) reachable"; else fail "NLB inet (local) unreachable at ${NLB_PUBLIC_IP}:9000 from $NLB_HOST"; fi
if [ "$NLB_LOCAL_OK" = "true" ]; then
if [ "$NLB_HOST" = "$LOCAL_IP" ]; then
run_tcp_traffic_test "$NLB_PUBLIC_IP" 9000 "NLB inet (local)"
else
declare -A NLB_LOCAL_COUNTS
NLB_LOCAL_TOTAL=0
for i in $(seq 1 20); do
RESPONSE=$(peer_ssh "$NLB_HOST" "echo '' | nc -w5 '$NLB_PUBLIC_IP' 9000" 2>/dev/null) || continue
RESP_INSTANCE=$(echo "$(echo "$RESPONSE" | tr -d '[:space:]')")
if [ -n "$RESP_INSTANCE" ]; then
NLB_LOCAL_COUNTS[$RESP_INSTANCE]=$(( ${NLB_LOCAL_COUNTS[$RESP_INSTANCE]:-0} + 1 ))
NLB_LOCAL_TOTAL=$((NLB_LOCAL_TOTAL + 1))
fi
done
echo " Distribution:"
for inst_id in "${!NLB_LOCAL_COUNTS[@]}"; do echo " $inst_id: ${NLB_LOCAL_COUNTS[$inst_id]} responses"; done
if [ "${#NLB_LOCAL_COUNTS[@]}" -ge 2 ]; then pass "NLB inet (local) round-robin: ${#NLB_LOCAL_COUNTS[@]} unique instances"; else fail "NLB inet (local) round-robin: expected 2 unique responders, got ${#NLB_LOCAL_COUNTS[@]}"; fi
if [ "$NLB_LOCAL_TOTAL" -ge 10 ]; then pass "NLB inet (local) success rate: $NLB_LOCAL_TOTAL/20"; else fail "NLB inet (local) success rate: only $NLB_LOCAL_TOTAL/20"; fi
fi
fi
fi
# --- ALB: remote connectivity (from a different node) ---
if [ -n "$ALB_REMOTE" ]; then
ALB_REMOTE_OK=$(test_http_from "$ALB_REMOTE" "$ALB_URL" "ALB inet (remote)")
if [ "$ALB_REMOTE_OK" = "true" ]; then pass "ALB inet (remote) reachable"; else fail "ALB inet (remote) unreachable at $ALB_URL from $ALB_REMOTE"; fi
if [ "$ALB_REMOTE_OK" = "true" ]; then
declare -A ALB_REMOTE_COUNTS
ALB_REMOTE_TOTAL=0
for i in $(seq 1 20); do
RESPONSE=$(peer_ssh "$ALB_REMOTE" "curl -s --max-time 5 '$ALB_URL/'" 2>/dev/null) || continue
RESP_INSTANCE=$(echo "$RESPONSE" | jq -r '.instance_id // empty' 2>/dev/null)
if [ -n "$RESP_INSTANCE" ]; then
ALB_REMOTE_COUNTS[$RESP_INSTANCE]=$(( ${ALB_REMOTE_COUNTS[$RESP_INSTANCE]:-0} + 1 ))
ALB_REMOTE_TOTAL=$((ALB_REMOTE_TOTAL + 1))
fi
done
echo " Distribution:"
for inst_id in "${!ALB_REMOTE_COUNTS[@]}"; do echo " $inst_id: ${ALB_REMOTE_COUNTS[$inst_id]} responses"; done
if [ "${#ALB_REMOTE_COUNTS[@]}" -ge 2 ]; then pass "ALB inet (remote) round-robin: ${#ALB_REMOTE_COUNTS[@]} unique"; else fail "ALB inet (remote) round-robin: ${#ALB_REMOTE_COUNTS[@]} unique"; fi
if [ "$ALB_REMOTE_TOTAL" -ge 10 ]; then pass "ALB inet (remote) success rate: $ALB_REMOTE_TOTAL/20"; else fail "ALB inet (remote) success rate: $ALB_REMOTE_TOTAL/20"; fi
fi
fi
# --- NLB: remote connectivity (from a different node) ---
if [ -n "$NLB_REMOTE" ] && [ -n "$NLB_PUBLIC_IP" ] && [ "$NLB_PUBLIC_IP" != "null" ]; then
NLB_REMOTE_OK=$(test_tcp_from "$NLB_REMOTE" "$NLB_PUBLIC_IP" 9000 "NLB inet (remote)")
if [ "$NLB_REMOTE_OK" = "true" ]; then pass "NLB inet (remote) reachable"; else fail "NLB inet (remote) unreachable at ${NLB_PUBLIC_IP}:9000 from $NLB_REMOTE"; fi
if [ "$NLB_REMOTE_OK" = "true" ]; then
declare -A NLB_REMOTE_COUNTS
NLB_REMOTE_TOTAL=0
for i in $(seq 1 20); do
RESPONSE=$(peer_ssh "$NLB_REMOTE" "echo '' | nc -w5 '$NLB_PUBLIC_IP' 9000" 2>/dev/null) || continue
RESP_INSTANCE=$(echo "$(echo "$RESPONSE" | tr -d '[:space:]')")
if [ -n "$RESP_INSTANCE" ]; then
NLB_REMOTE_COUNTS[$RESP_INSTANCE]=$(( ${NLB_REMOTE_COUNTS[$RESP_INSTANCE]:-0} + 1 ))
NLB_REMOTE_TOTAL=$((NLB_REMOTE_TOTAL + 1))
fi
done
echo " Distribution:"
for inst_id in "${!NLB_REMOTE_COUNTS[@]}"; do echo " $inst_id: ${NLB_REMOTE_COUNTS[$inst_id]} responses"; done
if [ "${#NLB_REMOTE_COUNTS[@]}" -ge 2 ]; then pass "NLB inet (remote) round-robin: ${#NLB_REMOTE_COUNTS[@]} unique"; else fail "NLB inet (remote) round-robin: ${#NLB_REMOTE_COUNTS[@]} unique"; fi
if [ "$NLB_REMOTE_TOTAL" -ge 10 ]; then pass "NLB inet (remote) success rate: $NLB_REMOTE_TOTAL/20"; else fail "NLB inet (remote) success rate: $NLB_REMOTE_TOTAL/20"; fi
fi
fi
# --- NLB deregister/draining test ---
echo "Deregistering first target from NLB: ${APP_INSTANCE_IDS[0]}..."
$AWS_ELBV2 deregister-targets --target-group-arn "$NLB_TG_ARN" \
--targets "Id=${APP_INSTANCE_IDS[0]}" --output json 2>&1 || fail "deregister-targets"
pass "deregistered ${APP_INSTANCE_IDS[0]}"
sleep 3
HEALTH_OUTPUT=$($AWS_ELBV2 describe-target-health --target-group-arn "$NLB_TG_ARN" --output json 2>/dev/null) || true
REMAINING=$(echo "$HEALTH_OUTPUT" | jq '.TargetHealthDescriptions | length')
DRAINING=$(echo "$HEALTH_OUTPUT" | jq '[.TargetHealthDescriptions[] | select(.TargetHealth.State == "draining")] | length')
echo " Targets remaining: $REMAINING (draining: $DRAINING)"
if [ "$REMAINING" -eq 1 ]; then pass "target deregistered: 1 remaining"
elif [ "$DRAINING" -ge 1 ]; then pass "target in draining state"
else pass "target deregistration processed"
fi
# --- Cleanup both internet-facing LBs ---
echo ""
echo "Cleaning up internet-facing LBs..."
$AWS_ELBV2 delete-listener --listener-arn "$ALB_LISTENER_ARN" 2>/dev/null || true
$AWS_ELBV2 delete-listener --listener-arn "$NLB_LISTENER_ARN" 2>/dev/null || true
$AWS_ELBV2 delete-load-balancer --load-balancer-arn "$ALB_LB_ARN" 2>/dev/null || true
$AWS_ELBV2 delete-load-balancer --load-balancer-arn "$NLB_LB_ARN" 2>/dev/null || true
pass "deleted both internet-facing LBs"
wait_for_eni_cleanup "ELB app/lb-e2e-alb-inet/${ALB_LB_ID}" "ALB internet-facing"
wait_for_eni_cleanup "ELB net/lb-e2e-nlb-inet/${NLB_LB_ID}" "NLB internet-facing"
$AWS_ELBV2 deregister-targets --target-group-arn "$ALB_TG_ARN" \
--targets "Id=${APP_INSTANCE_IDS[0]}" "Id=${APP_INSTANCE_IDS[1]}" 2>/dev/null || true
$AWS_ELBV2 deregister-targets --target-group-arn "$NLB_TG_ARN" \
--targets "Id=${APP_INSTANCE_IDS[0]}" "Id=${APP_INSTANCE_IDS[1]}" 2>/dev/null || true
$AWS_ELBV2 delete-target-group --target-group-arn "$ALB_TG_ARN" 2>/dev/null || true
$AWS_ELBV2 delete-target-group --target-group-arn "$NLB_TG_ARN" 2>/dev/null || true
ALB_LB_ARN=""; ALB_TG_ARN=""; ALB_LISTENER_ARN=""
NLB_LB_ARN=""; NLB_TG_ARN=""; NLB_LISTENER_ARN=""
pass "internet-facing cleanup complete"
else
echo ""
echo "========================================"
echo "Phase 3a: Internet-Facing — SKIPPED (no --peer)"
echo "========================================"
fi
# ##########################################################################
# Phase 3b: Internal (ALB + NLB in parallel, one shared client VM)
# ##########################################################################
echo ""
echo "========================================"
echo "Phase 3b: Internal (ALB + NLB)"
echo "========================================"
# --- Create both TGs ---
echo "Creating HTTP target group (ALB internal)..."
TG_OUTPUT=$($AWS_ELBV2 create-target-group \
--name lb-e2e-alb-int-tg \
--protocol HTTP --port 80 \
--vpc-id "$VPC_ID" \
--health-check-path "/index.html" \
--health-check-interval-seconds 5 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 2 \
--output json 2>&1) || { fail "create-target-group (ALB int)"; }
ALB_TG_ARN=$(echo "$TG_OUTPUT" | jq -r '.TargetGroups[0].TargetGroupArn')
pass "ALB target group: $ALB_TG_ARN"
echo "Creating TCP target group (NLB internal)..."
TG_OUTPUT=$($AWS_ELBV2 create-target-group \
--name lb-e2e-nlb-int-tg \
--protocol TCP --port 9000 \
--vpc-id "$VPC_ID" \
--health-check-protocol TCP \
--health-check-interval-seconds 10 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 2 \
--output json 2>&1) || { fail "create-target-group (NLB int)"; }
NLB_TG_ARN=$(echo "$TG_OUTPUT" | jq -r '.TargetGroups[0].TargetGroupArn')
pass "NLB target group: $NLB_TG_ARN"
# Register targets to both
$AWS_ELBV2 register-targets --target-group-arn "$ALB_TG_ARN" \
--targets "Id=${APP_INSTANCE_IDS[0]}" "Id=${APP_INSTANCE_IDS[1]}" --output json 2>&1 || { fail "register-targets (ALB)"; }
$AWS_ELBV2 register-targets --target-group-arn "$NLB_TG_ARN" \
--targets "Id=${APP_INSTANCE_IDS[0]}" "Id=${APP_INSTANCE_IDS[1]}" --output json 2>&1 || { fail "register-targets (NLB)"; }
pass "registered 2 targets to both TGs"
# --- Create both LBs (provision in parallel) ---
echo "Creating internal ALB..."
LB_OUTPUT=$($AWS_ELBV2 create-load-balancer \
--name lb-e2e-alb-int \
--scheme internal \
--subnets "$SUBNET_ID" \
--output json 2>&1) || { fail "create-load-balancer (ALB int)"; }
ALB_LB_ARN=$(echo "$LB_OUTPUT" | jq -r '.LoadBalancers[0].LoadBalancerArn')
ALB_LB_SCHEME=$(echo "$LB_OUTPUT" | jq -r '.LoadBalancers[0].Scheme')
pass "ALB: $ALB_LB_ARN (scheme: $ALB_LB_SCHEME)"
if [ "$ALB_LB_SCHEME" == "internal" ]; then pass "ALB scheme: internal"; else fail "ALB scheme: expected internal, got $ALB_LB_SCHEME"; fi
echo "Creating internal NLB..."
LB_OUTPUT=$($AWS_ELBV2 create-load-balancer \
--name lb-e2e-nlb-int \
--type network \
--scheme internal \
--subnets "$SUBNET_ID" \
--output json 2>&1) || { fail "create-load-balancer (NLB int)"; }
NLB_LB_ARN=$(echo "$LB_OUTPUT" | jq -r '.LoadBalancers[0].LoadBalancerArn')
NLB_LB_TYPE=$(echo "$LB_OUTPUT" | jq -r '.LoadBalancers[0].Type')
NLB_LB_SCHEME=$(echo "$LB_OUTPUT" | jq -r '.LoadBalancers[0].Scheme')
pass "NLB: $NLB_LB_ARN (type: $NLB_LB_TYPE, scheme: $NLB_LB_SCHEME)"
if [ "$NLB_LB_TYPE" == "network" ]; then pass "NLB type: network"; else fail "NLB type: expected network, got $NLB_LB_TYPE"; fi
if [ "$NLB_LB_SCHEME" == "internal" ]; then pass "NLB scheme: internal"; else fail "NLB scheme: expected internal, got $NLB_LB_SCHEME"; fi
if echo "$NLB_LB_ARN" | grep -q "/net/"; then pass "NLB ARN contains /net/"; else fail "NLB ARN missing /net/"; fi
# --- Verify ENIs ---
ALB_LB_ID=$(echo "$ALB_LB_ARN" | sed 's|.*/||')
NLB_LB_ID=$(echo "$NLB_LB_ARN" | sed 's|.*/||')
sleep 3
ENI_OUTPUT=$($AWS_EC2 describe-network-interfaces \
--filters "Name=description,Values=ELB app/lb-e2e-alb-int/${ALB_LB_ID}" \
--output json 2>/dev/null)
ALB_PUBLIC_IP=$(echo "$ENI_OUTPUT" | jq -r '.NetworkInterfaces[0].Association.PublicIp // empty' 2>/dev/null)
ALB_PRIVATE_IP=$(echo "$ENI_OUTPUT" | jq -r '.NetworkInterfaces[0].PrivateIpAddress // empty' 2>/dev/null)
echo " ALB Private IP: $ALB_PRIVATE_IP Public IP: ${ALB_PUBLIC_IP:-(none)}"
if [ -z "$ALB_PUBLIC_IP" ] || [ "$ALB_PUBLIC_IP" == "null" ]; then pass "ALB has no public IP (correct)"; else fail "ALB should not have public IP: $ALB_PUBLIC_IP"; fi
if [ -n "$ALB_PRIVATE_IP" ] && [ "$ALB_PRIVATE_IP" != "null" ]; then pass "ALB private IP: $ALB_PRIVATE_IP"; else fail "ALB has no private IP"; exit 1; fi
ENI_OUTPUT=$($AWS_EC2 describe-network-interfaces \
--filters "Name=description,Values=ELB net/lb-e2e-nlb-int/${NLB_LB_ID}" \
--output json 2>/dev/null)
NLB_PUBLIC_IP=$(echo "$ENI_OUTPUT" | jq -r '.NetworkInterfaces[0].Association.PublicIp // empty' 2>/dev/null)
NLB_PRIVATE_IP=$(echo "$ENI_OUTPUT" | jq -r '.NetworkInterfaces[0].PrivateIpAddress // empty' 2>/dev/null)
echo " NLB Private IP: $NLB_PRIVATE_IP Public IP: ${NLB_PUBLIC_IP:-(none)}"
if [ -z "$NLB_PUBLIC_IP" ] || [ "$NLB_PUBLIC_IP" == "null" ]; then pass "NLB has no public IP (correct)"; else fail "NLB should not have public IP: $NLB_PUBLIC_IP"; fi
if [ -n "$NLB_PRIVATE_IP" ] && [ "$NLB_PRIVATE_IP" != "null" ]; then pass "NLB private IP: $NLB_PRIVATE_IP"; else fail "NLB has no private IP"; exit 1; fi
# DNS checks
ALB_DNS=$($AWS_ELBV2 describe-load-balancers --load-balancer-arns "$ALB_LB_ARN" \
--query 'LoadBalancers[0].DNSName' --output text 2>/dev/null)
if echo "$ALB_DNS" | grep -q "^internal-"; then pass "ALB DNS has internal- prefix"; else fail "ALB DNS missing internal- prefix"; fi
NLB_DNS=$($AWS_ELBV2 describe-load-balancers --load-balancer-arns "$NLB_LB_ARN" \
--query 'LoadBalancers[0].DNSName' --output text 2>/dev/null)
if echo "$NLB_DNS" | grep -q "^internal-"; then pass "NLB DNS has internal- prefix"; else fail "NLB DNS missing internal- prefix"; fi
# --- Create both listeners ---
echo "Creating ALB listener (HTTP:80)..."
LISTENER_OUTPUT=$($AWS_ELBV2 create-listener \
--load-balancer-arn "$ALB_LB_ARN" \
--protocol HTTP --port 80 \
--default-actions "Type=forward,TargetGroupArn=$ALB_TG_ARN" \
--output json 2>&1) || { fail "create-listener (ALB)"; }
ALB_LISTENER_ARN=$(echo "$LISTENER_OUTPUT" | jq -r '.Listeners[0].ListenerArn')
pass "ALB listener: $ALB_LISTENER_ARN"
echo "Creating NLB listener (TCP:9000)..."
LISTENER_OUTPUT=$($AWS_ELBV2 create-listener \
--load-balancer-arn "$NLB_LB_ARN" \
--protocol TCP --port 9000 \
--default-actions "Type=forward,TargetGroupArn=$NLB_TG_ARN" \
--output json 2>&1) || { fail "create-listener (NLB)"; }
NLB_LISTENER_ARN=$(echo "$LISTENER_OUTPUT" | jq -r '.Listeners[0].ListenerArn')
pass "NLB listener: $NLB_LISTENER_ARN"
# --- Wait for both active (provisioning in parallel, checked sequentially) ---
wait_for_lb_active "$ALB_LB_ARN" "ALB internal" || exit 1
wait_for_lb_active "$NLB_LB_ARN" "NLB internal" || exit 1
# --- Wait for both TGs healthy ---
wait_for_targets_healthy "$ALB_TG_ARN" 2 "ALB internal"
wait_for_targets_healthy "$NLB_TG_ARN" 2 "NLB internal"
# --- Launch ONE client VM that tests both LBs ---
echo "Launching client VM to test both internal LBs..."
CLIENT_USER_DATA=$(cat <<USERDATA
#!/bin/bash
ALB_IP="${ALB_PRIVATE_IP}"
NLB_IP="${NLB_PRIVATE_IP}"
NUM_REQUESTS=20
mkdir -p /tmp/httpd && cd /tmp/httpd
echo "waiting" > status.txt
nohup python3 -m http.server 80 --bind 0.0.0.0 > /dev/null 2>&1 &
# Wait for ALB to respond
for i in \$(seq 1 60); do
if curl -s --max-time 3 "http://\${ALB_IP}:80/" 2>/dev/null | grep -q instance_id; then break; fi
sleep 5
done
# Test ALB (HTTP)
> alb_results.txt
for i in \$(seq 1 \$NUM_REQUESTS); do
curl -s --max-time 5 "http://\${ALB_IP}:80/" >> alb_results.txt 2>/dev/null
echo "" >> alb_results.txt
done
# Wait for NLB to respond
for i in \$(seq 1 60); do
PROBE=\$(echo "" | nc -w3 \${NLB_IP} 9000 2>/dev/null || true)
if [ -n "\$PROBE" ]; then break; fi
sleep 5
done
# Test NLB (TCP)
> nlb_results.txt
for i in \$(seq 1 \$NUM_REQUESTS); do
RESP=\$(echo "" | nc -w2 \${NLB_IP} 9000 2>/dev/null || true)
echo "\$RESP" >> nlb_results.txt
done
echo "done" > status.txt
USERDATA
)
CLIENT_OUTPUT=$($AWS_EC2 run-instances \
--image-id "$AMI_ID" \
--instance-type "$INSTANCE_TYPE" \
--key-name lb-e2e-key \
--subnet-id "$SUBNET_ID" \
--user-data "$CLIENT_USER_DATA" \
--output json 2>&1) || {
fail "run-instances (client)"; exit 1
}
CLIENT_INSTANCE_ID=$(echo "$CLIENT_OUTPUT" | jq -r '.Instances[0].InstanceId')
echo " Client instance: $CLIENT_INSTANCE_ID"
pass "launched client VM"
# Wait for running
for attempt in $(seq 1 60); do
STATE=$($AWS_EC2 describe-instances --instance-ids "$CLIENT_INSTANCE_ID" \
--query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null)
if [ "$STATE" == "running" ]; then break; fi
if [ $attempt -eq 60 ]; then fail "client instance did not reach running"; exit 1; fi
sleep 2
done
# Discover client public IP
CLIENT_ENI=$($AWS_EC2 describe-network-interfaces \
--filters "Name=attachment.instance-id,Values=$CLIENT_INSTANCE_ID" \
--output json 2>/dev/null)
CLIENT_PUBLIC_IP=$(echo "$CLIENT_ENI" | jq -r '.NetworkInterfaces[0].Association.PublicIp // empty' 2>/dev/null)
if [ -z "$CLIENT_PUBLIC_IP" ] || [ "$CLIENT_PUBLIC_IP" == "null" ]; then
fail "client VM has no public IP"; exit 1
fi
pass "client VM public IP: $CLIENT_PUBLIC_IP"
# Poll for completion
echo "Waiting for client test to complete..."
CLIENT_DONE=false
for attempt in $(seq 1 60); do
STATUS=$(curl -s --max-time 3 "http://${CLIENT_PUBLIC_IP}:80/status.txt" 2>/dev/null) || true
if [ "$STATUS" == "done" ]; then CLIENT_DONE=true; break; fi
if [ $((attempt % 10)) -eq 0 ]; then
echo " Client status: ${STATUS:-unreachable} (attempt $attempt/60)"
fi
sleep 5
done
if [ "$CLIENT_DONE" != true ]; then
fail "client test did not complete within timeout"; exit 1
fi
pass "client test completed"
# --- Fetch and verify ALB results ---
echo ""
echo "ALB internal results:"
ALB_RESULTS=$(curl -s --max-time 10 "http://${CLIENT_PUBLIC_IP}:80/alb_results.txt" 2>/dev/null)
if [ -z "$ALB_RESULTS" ]; then
fail "could not fetch ALB results from client VM"
else
verify_traffic_results "$ALB_RESULTS" "http" "ALB internal"
fi
# --- Fetch and verify NLB results ---
echo ""
echo "NLB internal results:"
NLB_RESULTS=$(curl -s --max-time 10 "http://${CLIENT_PUBLIC_IP}:80/nlb_results.txt" 2>/dev/null)
if [ -z "$NLB_RESULTS" ]; then
fail "could not fetch NLB results from client VM"
else
verify_traffic_results "$NLB_RESULTS" "tcp" "NLB internal"
fi
# --- Terminate client ---
echo " Terminating client VM..."
$AWS_EC2 terminate-instances --instance-ids "$CLIENT_INSTANCE_ID" 2>/dev/null || true
CLIENT_INSTANCE_ID=""
# --- Cleanup both internal LBs ---
echo ""
echo "Cleaning up internal LBs..."
$AWS_ELBV2 delete-load-balancer --load-balancer-arn "$ALB_LB_ARN" 2>/dev/null || true
$AWS_ELBV2 delete-load-balancer --load-balancer-arn "$NLB_LB_ARN" 2>/dev/null || true
pass "deleted both internal LBs"
wait_for_eni_cleanup "ELB app/lb-e2e-alb-int/${ALB_LB_ID}" "ALB internal"
wait_for_eni_cleanup "ELB net/lb-e2e-nlb-int/${NLB_LB_ID}" "NLB internal"
$AWS_ELBV2 delete-listener --listener-arn "$ALB_LISTENER_ARN" 2>/dev/null || true
$AWS_ELBV2 delete-listener --listener-arn "$NLB_LISTENER_ARN" 2>/dev/null || true
$AWS_ELBV2 deregister-targets --target-group-arn "$ALB_TG_ARN" \
--targets "Id=${APP_INSTANCE_IDS[0]}" "Id=${APP_INSTANCE_IDS[1]}" 2>/dev/null || true
$AWS_ELBV2 deregister-targets --target-group-arn "$NLB_TG_ARN" \
--targets "Id=${APP_INSTANCE_IDS[0]}" "Id=${APP_INSTANCE_IDS[1]}" 2>/dev/null || true
$AWS_ELBV2 delete-target-group --target-group-arn "$ALB_TG_ARN" 2>/dev/null || true
$AWS_ELBV2 delete-target-group --target-group-arn "$NLB_TG_ARN" 2>/dev/null || true
ALB_LB_ARN=""; ALB_TG_ARN=""; ALB_LISTENER_ARN=""
NLB_LB_ARN=""; NLB_TG_ARN=""; NLB_LISTENER_ARN=""
pass "internal cleanup complete"
echo ""