mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-16 01:22:00 +00:00
4ae2a4ac53
* 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.
1248 lines
54 KiB
Bash
Executable File
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 ""
|