mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 19:25:52 +00:00
ab95566795
* feat: add ALB target health reporting via HAProxy stats Agent-side: periodically query HAProxy stats socket and publish target health status to NATS (elbv2.alb.<id>.health topic). Control-plane: healthChecker subscribes to health reports from all ALB agents and updates target health state in the KV store. Wire into ELBv2ServiceImpl lifecycle and DeregisterTargets cleanup. E2E: remove health check skip — targets must now become healthy. * feat: allocate public IP for internet-facing ALB VMs in daemon Add Scheme field to SystemInstanceInput so the daemon knows whether an ALB is internet-facing or internal. For internet-facing, allocate a public IP via externalIPAM and publish vpc.add-nat for the OVN dnat_and_snat rule. Internal ALBs skip public IP assignment entirely. * fix: release auto-assigned public IPs on DeleteNetworkInterface DeleteNetworkInterface previously leaked public IPs and NAT rules. Now checks if the ENI has a public IP, verifies it's not EIP-owned, and releases via externalIPAM + publishes vpc.delete-nat. Daemon injects externalIPAM and EIP KV into VPC service at init time. Tests cover: auto-assigned IP release with NAT event, EIP-owned IP skipped, and no-op when externalIPAM is not configured. * feat: enforce multi-AZ for internet-facing ALBs in pool mode (mulga-770) In CreateLoadBalancer, when DevNetworking is false (pool mode), require 2+ subnets in different AZs for internet-facing scheme. Dev mode and internal scheme skip the validation. * feat: ALB scheme unit tests + DNS internal prefix + public IP in AZ response (mulga-771) Add 9 unit tests covering scheme-dependent ALB behavior: - DNS name uses internal- prefix for internal scheme (AWS-compatible) - Internet-facing ALBs include public IP in AvailabilityZones response - Internal ALBs have no public IP in AZ response - Launcher receives correct scheme, delete cleans up VM + ENIs Also implements the code changes these tests exercise: - AvailZoneInfo.PublicIP field populated from launcher output - lbRecordToSDK populates LoadBalancerAddresses for internet-facing * feat: add custom AMI for ALB to images list * feat: add internet-facing ALB data plane E2E test (mulga-772) New E2E script that validates internet-facing ALBs get public IPs and are reachable from a peer cluster node (external validation). Skips in dev mode. * fix: set ENDPOINT to runner node IP in ALB E2E workflow * fix: correct paths filter to reference alb-e2e.yml * fix: alb ami name and workflow step to import the image * fix: spx path * fix: remove ALB AMI import from workflow and added to bootstrap.sh * fix: use two subnets in different AZs for internet-facing ALB E2E Multi-AZ enforcement (mulga-770) requires internet-facing ALBs to have subnets in 2+ AZs in pool mode. Discover AZs and create a subnet in each. * refactor: remove multi-AZ enforcement for internet-facing ALBs Team decision: ALBs must work with single-AZ and single-node clusters. Removes the multi-AZ validation, pool mode helper, associated tests, and error constant. E2E script reverted to single subnet. * fix: use correct account ID for ALB ENI public IP assignment The ENI is created under the caller's account but LaunchSystemInstance was looking it up with GlobalAccountID (system account), causing the UpdateENIPublicIP call to fail with "ENI not found". Pass the caller's account ID via SystemInstanceInput.AccountID. * fix: add debug logging for ALB public IP assignment failure * fix: expand debug logging to include peer node and more keywords * fix: lazy system AMI discovery for ALB VMs The daemon was caching the system AMI at startup, but the ALB image is imported after services start (bootstrap step 9). Replace SetSystemAMI with SetSystemAMIFunc so the AMI is resolved at request time when CreateLoadBalancer actually needs it. * fix: resolve ALB VM instance type dynamically from resource manager The instance type was hardcoded to t3.nano which may not exist on all clusters. Now the daemon provides a resolver that picks the smallest available instance type from the resource manager. * push interim changes * refactor: rewrite ALB agent from NATS client to HTTP server Replace NATS subscriptions with an HTTP server on :8405 exposing POST /config, GET /ping, and GET /health. The daemon will push config and poll health over HTTP through the VPC network, removing the agent's direct NATS dependency. - agent.go: net/http server replaces nats.Conn/Subscriptions - health.go: remove push-based reportHealth; /health handler calls queryHAProxyStats synchronously on each request - cmd/alb-agent: --nats flag replaced with --listen - tests: rewritten with httptest instead of NATS test server - health_checker_test: inline topic strings (HealthTopic removed) * refactor: replace NATS with HTTP client for daemon-to-ALB agent comms Daemon now pushes config via POST and polls ping via GET to the ALB agent's HTTP server on the VPC IP, eliminating the agent's NATS dependency from the daemon side. - service_impl: pushConfig uses http.Post, waitForAgentReady uses http.Get, both target http://<vpcIP>:8405 - albVMUserData: drop natsURL param (agent no longer needs NATS) - types: add VPCIP field to LoadBalancerRecord - daemon: remove SetNATSURL call and 10.0.2.2 translation block - arn_test: update user data tests for new signature * refactor: rewrite ALB health checker from NATS subscription to HTTP polling Health checker now polls each active ALB's GET /health endpoint on a 10s ticker instead of subscribing to a NATS wildcard topic. Removes the NATS connection dependency from the health checker. - health_checker: replace nc/sub with httpClient + stopCh polling loop - newHealthChecker: drop nc param, add agentURLFn for testability - service_impl: update newHealthChecker call site - tests: rewrite with httptest, test pollAll/pollOne paths * refactor: remove NATS wiring from ALB image and make hostfwd dev-only - alb-setup.sh: remove --nats flag, use --listen=:8405; remove placeholder HAProxy frontend on 8405 (agent owns the port now); update description and comments - service_impl: HostfwdPorts (80/443) now only set when DevNetworking is enabled — production ALBs are reached via VPC IP directly * feat: system credentials file + daemon wiring for ALB agent SigV4 auth Write config/system-credentials.json during admin init/join with the system access key. Daemon reads it at startup, resolves the gateway URL from external IPAM (or 10.0.2.2 in dev mode), and wires both into the ELBv2 service. albVMUserData now injects ALB_GATEWAY_URL, ALB_ACCESS_KEY, and ALB_SECRET_KEY into cloud-init for ALB VMs. * feat: ALB agent gateway actions + daemon handlers (heartbeat, config) Add ALBAgentHeartbeat and GetALBConfig actions to the gateway so the ALB agent can communicate via the AWS gateway instead of direct HTTP. - Add agent_types.go with input/output types for both actions - Add ALBAgentHeartbeat: processes heartbeat, transitions provisioning→active on first contact, updates LastHeartbeat, processes health report - Add GetALBConfig: returns pre-computed HAProxy config text and hash - Replace pushConfig (HTTP POST to agent) with updateStoredConfig (stores config + SHA256 hash on LB record for agent pull) - Remove waitForAgentReady polling goroutine (heartbeat handles transition) - Remove HTTP client from ELBv2ServiceImpl (no longer needed) - Strip health checker HTTP polling (pollLoop/pollAll/pollOne) — health reports now arrive directly via ALBAgentHeartbeat - Add ConfigText, ConfigHash, LastHeartbeat fields to LoadBalancerRecord - Wire NATS subscriptions + daemon handlers for both new actions - Add gateway handler files and elbv2Actions map entries - Comprehensive tests for heartbeat, config, and updateStoredConfig * feat: rewrite ALB agent as AWS SDK HTTP client (Bead 3r) Replace the HTTP server agent with a poll-based client that communicates with the gateway using SigV4-signed requests. The agent now sends ALBAgentHeartbeat every 5s with health reports and fetches config via GetALBConfig when the config hash changes. * feat: add VPC service route for ALB agent gateway access ALB VMs need to reach the gateway for heartbeat/config polling, but VPCs without an IGW have no external routing. Instead of requiring an IGW, the daemon now publishes a vpc.ensure-service-route event before launching ALB VMs. vpcd handles this by creating minimal external connectivity (external switch, localnet port, gateway router port, chassis bindings) if not already present, then adds a /32 static route for the gateway IP. Refactors handleIGWAttach to share the external connectivity setup via ensureExternalConnectivity helper. * Revert "feat: add VPC service route for ALB agent gateway access" This reverts commitd4220d8806. * feat: add management NIC infrastructure for system instance control plane Add config, IP allocator, TAP helpers, and VM struct fields for the management NIC that gives system instances (ALBs etc.) L3 access to the host control plane via br-mgmt, independent of VPC routing. - DaemonConfig.MgmtBridge (default "br-mgmt") - MgmtIPAllocator: thread-safe static IP allocation (.10-.249), rebuild from VMs on restart - generateMgmtMAC (02:a0:00 prefix), MgmtTapName, GetBridgeIPv4 - SetupMgmtTapDevice / CleanupMgmtTapDevice (plain Linux bridge TAP) - VM struct: MgmtMAC, MgmtIP, MgmtTap fields * feat: wire management NIC into QEMU launch and cloud-init config Add QEMU NIC injection for mgmt TAP (virtio-net-pci on br-mgmt), extend generateNetworkConfig to emit mgmt0 stanza with static IP, and wire mgmt MAC/IP allocation into LaunchSystemInstance. Includes mgmt TAP cleanup on instance stop and failed launch cleanup. * test: add coverage for ELBv2 service lifecycle and setter methods * fix: move br-mgmt detection before wireALBAgentConfig so gateway URL uses management bridge IP * fix: generate per-interface cloud-init config when eniMAC present without devMAC The guard in generateNetworkConfig required both eniMAC and devMAC to produce per-interface config, falling back to wildcard DHCP otherwise. System instances (ALBs) have eniMAC + mgmtMAC but no devMAC (DevNetworking is independent), so the mgmt NIC stanza was never emitted. The wildcard config does DHCP on all NICs, but br-mgmt has no DHCP server, leaving the management NIC unconfigured and the agent unable to reach the gateway. * fix: add wildcard support to ENI description filter + add ca-certificates to ALB image - ENI DescribeNetworkInterfaces description filter did exact string match, but AWS supports * wildcards. E2E test uses "ELB *" which didn't match "ELB app/my-alb/lb-123". Added matchFilter helper for wildcard matching. - ALB image was missing ca-certificates package, so cloud-init ca_certs module couldn't add the Spinifex CA to the system trust store. Agent TLS handshakes failed with "certificate signed by unknown authority". - Fixed data race in TestDeleteLoadBalancer_TerminatesVM_WithPublicIP where async TerminateSystemInstance goroutine wrote to mock fields while the test read them. * fix: detach ENI before delete on termination + defer ENI cleanup to stopInstance DeleteLoadBalancer was trying to delete ENIs synchronously, but they were still attached (in-use) to the running VM. The delete was rejected with InvalidNetworkInterface.InUse. Fix: DeleteLoadBalancer only deletes ENIs when no VM was launched (InstanceID empty). When a VM exists, ENI cleanup is deferred to stopInstance which runs after QEMU exits. stopInstance now calls DetachENI before DeleteNetworkInterface to clear the in-use status. E2E test now polls for ENI cleanup (up to 30s) since VM termination is async. * fix: detach then delete ENIs in DeleteLoadBalancer ENIs are attached (in-use) to the ALB VM, so DeleteNetworkInterface rejects them. Fix: call DetachENI first to clear in-use status, then delete. stopInstance tolerates already-deleted ENIs. * test: add internal ALB dataplane E2E test Add run-elbv2-dataplane-internal-e2e.sh replacing the generic dataplane test. Internet-facing coverage is already provided by run-elbv2-dataplane-internet-facing-e2e.sh. The internal test validates: - ALB reaches active via mgmt NIC heartbeat (no IGW dependency) - Scheme=internal, no public IP, internal- DNS prefix - Health checks pass via VPC L2 (OVN logical switch) - Round-robin traffic from a VPC client to the ALB's private IP - ENI cleanup on ALB deletion Uses a client VM inside the VPC that curls the ALB private IP and serves results via HTTP on its public IP for host-side verification. * feat: add NLB constants and data model changes (mulga-838) * feat: type-aware validation in CreateLoadBalancer (mulga-839) Accept Type=network for NLB, reject SecurityGroups for NLB, set CrossZoneEnabled defaults (false for NLB, true for ALB), and use /net/ ARN path segment for network load balancers. * feat: type-aware validation in CreateTargetGroup (mulga-840) Accept TCP/UDP/TLS/TCP_UDP protocols and apply NLB health check defaults (TCP protocol, no path/matcher, 10s timeout, 3/3 thresholds). Reject invalid protocols with InvalidParameterValue. * feat: type-aware validation in CreateListener (mulga-841) * feat: add NLB listener and protocol compatibility unit tests Cover the remaining Phase 1 test gaps: NLB CreateListener with all L4 protocols, protocol rejection for wrong LB type, listener-to-target-group protocol compatibility validation, and default protocol rejection on NLB. * feat: add TCP HAProxy config generation for NLB Add TCP mode template and generateNLBConfig function for Network Load Balancer support. GenerateHAProxyConfig now branches on LB type, producing mode-tcp configs for NLBs with tcp-check or httpchk health checks depending on the target group's health check protocol. * refactor: rename albagent to lbagent for NLB generalization Rename the ALB-specific agent package and binary to generic LB names in preparation for NLB support. Both ALB and NLB VMs use the same agent — it just applies whatever HAProxy config the control plane generates. - Rename albagent/ → lbagent/, cmd/alb-agent/ → cmd/lb-agent/ - Rename types: ALBAgentHeartbeat* → LBAgentHeartbeat*, GetALBConfig* → GetLBConfig* - Rename NATS topics: elbv2.ALBAgentHeartbeat → elbv2.LBAgentHeartbeat - Rename cloud-init user data: albVMUserData → lbVMUserData with LB_* env vars - Rename Makefile targets: build-alb-agent → build-lb-agent - Update gateway action map, daemon subscriptions, service interface - Fix pre-existing unused matchFilter function lint error - Fix awserrors test count for InvalidConfigurationRequest * feat: add sys.micro system instance type for LB VMs (mulga-845) Add sys.micro (1 vCPU, 128MB) as an internal-only instance type for system VMs (load balancers, NAT gateways). Registered in the type map for allocation but excluded from DescribeInstanceTypes, NATS subscriptions, and resource stats. Daemon SetSystemInstanceTypeFunc now returns sys.micro instead of scanning for smallest guest type. * feat: LB image shrink and rename (mulga-846) Rename alb.conf → lb.conf, alb-setup.sh → lb-setup.sh with IMAGE_SIZE reduced from 512M to 192M. Update all references to use lb-agent binary and LB_* env vars. Update COMMANDS.md for NLB support. * chore: rename file * feat: NLB E2E test and bug fixes (mulga-847) Add full NLB data plane E2E test (33 assertions, 9 phases): VPC/subnet setup, TCP target group with health checks, NLB creation (type=network), TCP listener, agent heartbeat, target health verification, TCP traffic round-robin, target deregistration, and full cleanup verification. Bug fixes found during E2E testing: - Fix HAProxy stats socket path mismatch: config used alb-/nlb- prefix but agent expects lb- prefix, preventing health reports - Fix system AMI lookup to match new -lb- image name, falling back to legacy -alb- name - Bump LB image size from 192M to 256M (Alpine base is 200M) * test: validate NLB internal scheme (mulga-857) NLB with scheme=internal works out of the box — shared code paths with ALB handle it correctly. Add unit test and E2E coverage: - Unit test: NLB internal creates with no public IP, internal- DNS prefix - E2E phases 10-14: internal NLB lifecycle with VPC client TCP traffic - E2E passed 56/56 with perfect round-robin (20/20 requests) * feat: consolidate LB e2e tests into run-lb-e2e.sh (mulga-854) Replace 3 separate LB dataplane scripts with a single consolidated test that covers all 4 variants (ALB/NLB × internet-facing/internal). - Share one VPC, subnet, and 2 dual-purpose app instances across all suites - ALB + NLB provision in parallel within each phase (internet-facing / internal) - One client VM tests both internal LBs simultaneously - Replace sleep 100 with LB health check gates - Delete standalone alb-e2e.yml, integrate into e2e.yml for both single and multi-node * chore: disable LB E2E tests in CI until dev env issue resolved * fix: read LB agent credentials from spinifex.toml instead of system-credentials.json The daemon couldn't read system-credentials.json due to systemd hardening (file owned by root:root 0600, not in ReadOnlyPaths). Use the same system credentials already loaded from spinifex.toml via the predastore config section — these are identical keys written by admin init. * feat: automate LB image build and import (mulga-861) - dev-install.sh: build lb-agent and run build-system-image.sh --import after services start so the LB image is available out of the box - build-system-image.sh: add --quiet flag to suppress build output, remove hardcoded --config path (uses auto-detected default) - images.go: rename alb-alpine → lb-alpine to match current naming * update go version * add LB e2e tests * fix: clean LB image build dir and wait for nbd device readiness Previous CI runs can leave stale state in /tmp/lb-image-build/ causing e2fsck failures. Clean the build dir before each CI build. Also add a readiness check after qemu-nbd connect to avoid race conditions. * fix: serialize nbd access with flock for concurrent CI builds Single-node and multi-node CI jobs share the same host and both build the LB system image using /dev/nbd0. Use flock to serialize the nbd connect/mount/customize/disconnect section so concurrent builds wait rather than collide. * fix: move flock to cover entire image build, not just nbd section Concurrent CI jobs share BUILD_DIR and /dev/nbd0. The lock must be acquired before downloading/copying the image, not just before nbd connect, otherwise the second job can clobber the first job's files. * fix: skip LB image build if output is fresh from concurrent CI job When concurrent CI jobs build the same image, the second job now reuses the first's output if it's less than 10 minutes old, avoiding a duplicate download + build cycle. * fix: filter AMI discovery by name in E2E tests With the LB Alpine image now imported alongside Ubuntu, the unfiltered describe-images call picked up the wrong AMI. Filter by the expected image name so tests use the Ubuntu image for instance lifecycle. * fix: filter AMI discovery by name in multi-node E2E tests Same fix as run-e2e.sh — with the LB Alpine image now imported, the unfiltered describe-images picks the wrong AMI. * fix: use awsgw bind IP as LB agent gateway URL fallback When br-mgmt is not configured and dev_networking is off (production with external pool), the gateway URL was falling through to the external IPAM pool's GatewayIP — the WAN router, not the Spinifex node. LB VMs couldn't reach the awsgw, so heartbeats never arrived and ALBs stayed stuck in provisioning. Use the awsgw bind IP instead — LB VMs on the OVN overlay can reach the host node's WAN IP where the gateway listens. * fix: capture StateReason and daemon logs when LB app instances fail Instances with user-data terminate immediately in CI. Add describe-instances StateReason query and daemon log dump to diagnose whether it's volume_preparation_failed or launch_failed. * fix: prevent orphaned AMI from breaking LB E2E and avoid stuck provisioning Three fixes for CI E2E failures: 1. run-e2e.sh: Remove custom AMI from predastore after deleting its backing snapshot. Previously the AMI remained discoverable but unusable, causing run-lb-e2e.sh to pick it and fail with NoSuchKey on the deleted snapshot. 2. run-lb-e2e.sh: Prefer ubuntu AMI by name for app instances instead of picking any non-alpine image. Also improve debug log dump to use journalctl (binary-install CI uses systemd, not log files). 3. CreateLoadBalancer: Only set provisioning state when a system VM was actually launched. Previously a failed launch still set provisioning, leaving the LB stuck forever with no agent to send heartbeats. * fix: use AWSGW bind IP as LB agent gateway URL in multi-node In multi-node deployments, the AWSGW binds to the WAN IP (e.g. 192.168.1.26:9999), but wireLBAgentConfig was using the br-mgmt IP as the gateway URL. Since the AWSGW doesn't listen on br-mgmt, the LB agent's heartbeat was refused, leaving internet-facing ALBs stuck in provisioning. Internet-facing LBs now use the AWSGW bind IP directly — the VM reaches it via VPC/external networking. Internal LBs get a host route on the mgmt NIC so they can reach the AWSGW bind IP through br-mgmt. * fix: add mgmt route via bootcmd for multi-node internal LBs Alpine cloud-init does not support v2 routes under ethernets — adding a routes: block to the network-config breaks all networking in the VM. Instead, add the host route via a bootcmd in the LB user data. This runs early in cloud-init and adds `ip route add {awsgw}/32 via {br-mgmt}` so internal LB agents can reach the AWSGW through the management NIC when the gateway binds to a specific WAN IP. Also moves the networking service from boot to default runlevel in the LB Alpine image to prevent a race with cloud-init. Verified locally: internal ALB transitions to active with AWSGW bound to WAN IP (simulating multi-node). * add LB enable flag to e2e * test: add elbv2 tests to meet diff coverage threshold Cover health checker edge cases (invalid JSON, empty servers, zero thresholds, port-0 fallback) and service_impl paths (mgmt route cloud-init, protocol validation, missing target group). * fix: swallowed health report error + needless marshal round-trip in LBAgentHeartbeat Add handleHealthReportDirect(report) to healthChecker so LBAgentHeartbeat can pass the struct directly instead of marshaling to JSON and immediately unmarshaling. Promote handleHealthReport unmarshal error from Debug to Warn so failures are visible in logs. * fix: validate credentials before launching LB VM + set failed state on launch error (mulga-867) Guard lbVMUserData with empty-value check for gatewayURL, systemAccessKey, and systemSecretKey — blank env vars previously caused agent auth-loop with no daemon-side log. Set LB state to failed (not active) when VM launch fails so the API reflects the broken data-plane instead of black-holing traffic. * fix: data race in getSystemAMI/getSystemInstanceType lazy caching Use sync.Once to guard lazy resolution of systemAMI and systemInstanceType fields. Concurrent CreateLoadBalancer calls previously raced on unsynchronized read+write of these cached values. The setter methods reset the Once so re-resolution works when the func is set after initial construction. Also fix pre-existing nosprintfhostport lint in daemon.go. * fix: use utils.PublishEvent for NAT events + return errors from isEIPOwned publishNATEvent in eni.go and daemon was copy-pasted boilerplate — now delegates to utils.PublishEvent (matching the EIP service pattern). isEIPOwned previously swallowed all KV errors and returned false, which could release an EIP-owned public IP if the store was temporarily unavailable. Now returns (bool, error) so the caller skips release on KV failure instead of assuming no EIP ownership. * fix: narrow health checker to only scan target groups for reporting LB handleHealthReportDirect was calling ListTargetGroups() on every heartbeat (every 5s per LB agent), scanning all TGs. Now uses report.LBID to follow LB → listeners → TG ARNs and fetch only the attached target groups via direct key lookups. Falls back to full scan if LBID is missing. * fix: pass region through to LB agent SigV4 signing (mulga-871) LB agent hardcoded us-east-1 as the SigV4 signing region. Thread region from ELBv2 service config → cloud-init LB_REGION env var → lb-agent --region flag → lbagent.New() → signer.Sign() call. * fix: add account-level authorization to LBAgentHeartbeat and GetLBConfig Both endpoints accepted any accountID without verifying the LB belongs to that account. Now verify accountID matches the LB's AccountID, with an exception for the system account (000000000000) which the ALB agent uses for heartbeats and config fetches. * fix: upgrade silent failure paths from Debug to Warn/Error - healthChecker target group list failure: Debug → Warn - HAProxy stats socket error in agent tick(): Debug → Warn - ENI delete EIP-owned skip: Debug → Info (operational visibility) - wireLBAgentConfig: warn when no gateway host is resolved - detectMgmtIP: warn when bridge exists but IP is empty - stale mgmt TAP cleanup: log errors instead of discarding * refactor: deduplicate MAC hash function into utils.HashMAC Three identical hash-to-MAC implementations (generateDevMAC, generateMgmtMAC, generateENIMac) now delegate to a shared utils.HashMAC(prefix, id) helper. * fix: guard LBAgentHeartbeat KV writes to state transitions or stale timestamps Previously the full LoadBalancerRecord (including ConfigText) was written to JetStream KV on every heartbeat (every 5s). Now only persists on state transitions (provisioning→active) or when LastHeartbeat is older than 60s. * fix: fail ALB launch when public IP allocation fails for internet-facing scheme Previously, IPAM allocation failure was logged as a warning and the ALB VM continued launching without a public IP. The ALB reached active state but was unreachable from the internet. Now the launch returns an error and cleans up the partially-created instance, causing the ALB to enter the failed state. * fix: retry system AMI resolution when image not yet imported sync.Once cached empty results permanently — if the daemon started before the system image was imported, all CreateLoadBalancer calls silently skipped VM creation. Replace sync.Once with a mutex that only caches non-empty results, allowing retry on subsequent calls. Same fix applied to getSystemInstanceType for consistency. * fix: propagate updateStoredConfig errors to API callers (mulga-884) updateStoredConfig and updateStoredConfigForTargetGroup now return errors instead of silently swallowing them. Callers (CreateListener, DeleteListener, RegisterTargets, DeregisterTargets) return ErrorServerInternal when config persistence fails, preventing the API from reporting success while HAProxy serves stale configuration. * test: add unit tests for wireLBAgentConfig, isEIPOwned, toHealthReport (mulga-885) * fix: parse HAProxy templates once at package level (mulga-875) generateALBConfig and generateNLBConfig were calling template.New().Parse() on every invocation. Move to package-level template.Must for one-time parsing. * fix: fail launch for internal-scheme ALBs when mgmt NIC setup fails (mulga-886) When management TAP or IP allocation fails, internal-scheme ALBs now return an error instead of silently continuing without a management NIC. Without the mgmt NIC, the LB agent cannot reach the gateway and the ALB gets stuck in provisioning forever. Internet-facing ALBs retain the existing degrade-and-continue behavior. * refactor: extract lbArnPathSegment helper for ALB/NLB ARN construction (mulga-877) The app/net path segment derivation was duplicated in buildLBArn, buildListenerArn, and inline in CreateLoadBalancer. Extracted into a single lbArnPathSegment helper. * refactor: use aws.StringValue in toHealthReport (mulga-878) Replace manual nil-check-then-deref with aws.StringValue for cleaner pointer-to-value conversion in the heartbeat health report builder. * refactor: use testutil.StartTestJetStream in health_checker_test.go (mulga-876) Replace hand-rolled setupTestNATS (~20 lines) with the shared testutil.StartTestJetStream helper. Simplify return to just *Store since no caller needed the *nats.Conn. * fix: address PR #122 review comments (mulga-887) - Extract NATEvent named type from anonymous struct in publishNATEvent - Remove unused build-lb-image.sh thin wrapper - Remove SPX_ENABLE_LB env var from e2e workflow (unused) - Remove no-op healthChecker start/stop methods - Export simple ELBv2 fields, remove trivial setter methods - Delete old e2e scripts replaced by consolidated run-lb-e2e.sh * fix: check specific error type for ENI deletion on termination (mulga-888) ENI deletion errors were all silently downgraded to Debug level, masking genuine failures (KV issues, permissions, ENI stuck in-use) that cause orphaned ENIs and leaked IPAM addresses. Now only NotFound is logged at Debug; all other errors log at Error. * fix: propagate non-ENODEV errors from GetBridgeIPv4 (mulga-890) GetBridgeIPv4 returned ("", nil) for ALL InterfaceByName failures, not just missing devices. Permission errors on /sys/class/net/ were silently treated as "bridge absent". Now only "no such network interface" returns nil; other errors propagate to the caller. * fix: remove backend address from proxy error response (mulga-892) The reverse proxy error handler included the internal backend host:port in the XML error body sent to clients. The address is already logged server-side; the client now sees a generic message. * fix: release mutex before KV store I/O in health checker (mulga-893) handleHealthReportDirect held hc.mu across PutTargetGroup calls (JetStream KV Put — network I/O), serializing concurrent heartbeats from independent LBs. Now collects changed TGs under the lock and persists them after releasing it. * fix: warn when system credentials missing in wireLBAgentConfig (mulga-894) fix: log DetachENI errors instead of discarding with blank identifier (mulga-895) * fix: detect veth bridge mode and use centralized NAT for it The veth-pair bridge topology (Linux bridge ↔ veth ↔ OVS bridge) was misclassified as direct bridge mode, causing OVN to use distributed NAT. Distributed NAT hairpin routing fails through the Linux bridge intermediary, breaking host-to-local-LB connectivity. Add BridgeModeVeth constant, detect veth-wan-ovs interface in detectBridgeMode(), and introduce useCentralizedNAT() to gate NAT mode and nat-addresses=router for both macvlan and veth modes. isMacvlanMode() is preserved for macvlan-specific behavior (MAC alignment). * fix: add retry loop for peer NLB reachability test With centralized NAT, ARP resolution for NAT external IPs relies on gratuitous ARPs from the gateway chassis. These take time to propagate to peer nodes. The peer→NLB test had a single 3s attempt while all other connectivity tests use retry loops. * fix: use request-reply for vpc.add-nat to prevent ARP propagation races publishNATEvent was fire-and-forget for vpc.add-nat, meaning the daemon continued before vpcd committed the OVN NAT rule. This caused intermittent NLB connectivity failures: the LB was reported active before OVN had the DNAT rule and sent GARPs for the public IP. Switch vpc.add-nat to request-reply (RequestEvent) so the daemon waits for OVN confirmation. Also increase nc timeout from 2-3s to 5s in E2E tests for more reliable TCP probing during ARP resolution. * fix: clean up stale NAT rules before adding new ones for reused IPs When a public IP is returned to the external pool and quickly reallocated (e.g. instance terminated, then LB created), the fire-and-forget vpc.delete-nat may not have been processed yet. handleAddNAT now removes any existing dnat_and_snat rule for the same external IP before creating the new one, preventing duplicate/conflicting OVN NAT rules. This was causing intermittent NLB connectivity failures in multi-node E2E where NLB public IPs collided with recently-freed instance IPs. * fix: clean up stale NAT rules across all VPC routers on IP reuse The previous fix (0a93081) only removed stale dnat_and_snat rules from the target VPC's router. When a public IP is freed from one VPC and reallocated to a different VPC before the fire-and-forget vpc.delete-nat is processed, the stale rule on the old VPC's router caused duplicate OVN ARP responses — traffic was black-holed when the host resolved to the wrong router's MAC. DeleteAllNATsByExternalIP now searches every logical router for stale NAT rules matching the external IP and removes them in one transaction. Also adds host connectivity diagnostics (ARP, routing, OVN NAT state) to run-lb-e2e.sh so the next CI failure surfaces the root cause of the persistent NLB-from-host issue. * fix: run LB inet tests from actual hosting node, not hardcoded node1 The host/peer connectivity tests were hardcoded: "host" = node1 (where the test script runs), "peer" = node2. But LB VMs land on whichever node picks up the NATS queue message, so the tests were randomly testing local vs remote connectivity depending on placement luck. Now the test: 1. Accepts --nodes <ip1> <ip2> ... (all cluster node IPs) 2. After LB creation, discovers which node hosts each LB VM by checking for the ENI's tap device 3. Runs "local" tests from the hosting node and "remote" tests from a different node Also updates e2e.yml to pass --nodes instead of --peer.
1202 lines
51 KiB
Bash
Executable File
1202 lines
51 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)
|
|
|
|
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=()
|
|
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 ;;
|
|
*) echo "Unknown arg: $1"; exit 1 ;;
|
|
esac
|
|
done
|
|
# --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="$HOME/.ssh/tf-user-ap-southeast-2"
|
|
|
|
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 \
|
|
"tf-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"
|
|
|
|
# ==========================================================================
|
|
# 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")
|
|
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")
|
|
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..."
|
|
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..."
|
|
sleep 5
|
|
done
|
|
if [ "$ok" = true ]; then pass "$label reachable"; else fail "$label unreachable at $url from $node_ip"; fi
|
|
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..."
|
|
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..."
|
|
sleep 5
|
|
done
|
|
if [ "$ok" = true ]; then pass "$label reachable"; else fail "$label unreachable at ${target_ip}:${port} from $node_ip"; fi
|
|
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)")
|
|
|
|
# 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
|
|
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
|
|
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
|
|
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 ""
|