* 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.
33 KiB
E2E Test Coverage
Single-Node (run-e2e.sh)
Phase 1: Environment Setup
- KVM support check
spx admin init(region/az/node config)- CA certificate trust
- Start all services (
start-dev.sh) - Wait for AWS gateway on
localhost:9999
Phase 1b: Cluster Stats CLI
spx get nodes— verify node shows Readyspx top nodes— verify CPU/MEM resource statsspx get vms— verify "No VMs found" before any launches
Phase 2: Discovery & Metadata
describe-regionsdescribe-availability-zones(verify zone name and state)describe-instance-types(discover available types)- Select nano instance type and detect architecture
Phase 2b: Serial Console Access Settings
get-serial-console-access-status(verify default disabled)enable-serial-console-access(verify returns true, get confirms true)disable-serial-console-access(verify returns false, get confirms false)
Phase 3: SSH Key Management
create-key-pair(test-key-1, verify private key material)import-key-pair(test-key-2, from local RSA key)describe-key-pairs(verify both keys exist)delete-key-pair(test-key-2, verify only test-key-1 remains)
Phase 4: Image Management
spx admin images import(file-based, architecture-aware)describe-images(verify AMI by ID)
Phase 5: Instance Lifecycle
run-instances(launch VM with key pair)describe-instances(poll pending -> running)
Phase 5a-pre: Cluster Stats CLI (with running VM)
spx get vms— verify running instance appears in output
Phase 5a: Instance Metadata Validation
describe-instances— verify InstanceType matches requested type- Verify KeyName matches requested key
- Verify ImageId matches requested AMI
- Verify at least 1 BlockDeviceMapping present
Phase 5a-ii: SSH Connectivity & Volume Verification
- SSH into instance via QEMU hostfwd port
- Verify SSH connectivity (
idcommand returns ec2-user) - Verify root volume size from inside VM matches API-reported size (
lsblkvsdescribe-volumes) - Verify VM hostname
Phase 5a-iii: Console Output
get-console-outputsucceeds (verify InstanceId in response)
Phase 5 (cont): Root Volume
describe-volumes(verify root volume attached)
Phase 5b: Volume Lifecycle
create-volume(10GB, ap-southeast-2a)modify-volume(resize to 20GB, poll to verify)attach-volume(to running instance, /dev/sdf)describe-volumes(verify in-use + attached state)detach-volume(verify available state)delete-volume(verify gone)
Phase 5b-ii: DescribeVolumeStatus
describe-volume-status(on root volume, verify VolumeId in response)
Phase 5c: Snapshot Lifecycle
- Uses root volume already attached to running instance (snapshots require a mounted VB instance)
create-snapshot(from attached root volume, with description)- Verify create response fields (VolumeId, VolumeSize, State, Progress)
describe-snapshots(by ID, verify VolumeId/Size/Description)copy-snapshot(with new description, verify distinct ID)describe-snapshots(verify both original + copy visible)delete-snapshot(original, verify gone while copy survives)delete-snapshot(copy, cleanup)
Phase 5d: Verify Snapshot-Backed Instance Launch
- All
run-instancescalls use the snapshot path (cloneAMIToVolume->OpenFromSnapshot), so the Phase 5 instance is already snapshot-backed - Verify AMI snapshot exists in Predastore (
snap-{amiId}/config.json) - Read Phase 5 root volume's
config.jsonfrom Predastore - Verify
SnapshotIDandSourceVolumeNameare set (proves zero-copy clone)
Phase 5e: CreateImage Lifecycle
create-image(from running instance, with name and description)- Verify returned ImageId is non-empty
describe-images(verify custom AMI name and state)- Extract backing snapshot ID from Predastore config (for cleanup before termination)
Phase 6: Tag Management
create-tags(3 tags on instance)describe-tags(filter by resource-id)create-tags(2 tags on volume)describe-tags(filter by key)describe-tags(filter by resource-type)create-tags(overwrite existing tag value)delete-tags(unconditional by key)delete-tags(with wrong value — should be no-op)delete-tags(with correct value)- Verify final tag count
Phase 7: Instance State Transitions
stop-instances(poll -> stopped)
Phase 7a: Attach Volume to Stopped Instance (Error Path)
create-volume(for attach test)attach-volumeto stopped instance (expectIncorrectInstanceStateerror)delete-volume(cleanup)
Phase 7b: ModifyInstanceAttribute
modify-instance-attribute(change instance type from nano → xlarge while stopped)describe-instances(verify type updated in KV)describe-instance-types(get expected vCPU count and memory for new type)start-instances(poll -> running with new type)- SSH:
nproc— verify vCPU count matches xlarge (4 vCPUs) - SSH:
/proc/meminfoMemTotal — verify memory matches xlarge (within 85% of expected)
Phase 7c: RunInstances with count > 1
run-instances --count 2(launch 2 instances in a single call)- Verify 2 instances returned in response
- Poll both to running state
terminate-instances(both, poll -> terminated)
Phase 8: Negative / Error Path Tests
run-instanceswith malformed AMI ID (expectInvalidAMIID.Malformed)run-instanceswith invalid instance type (expectInvalidInstanceType)attach-volumeon in-use volume (expectVolumeInUse)detach-volumeon boot volume (expectOperationNotPermitted)delete-snapshoton non-existent snapshot (expectInvalidSnapshot.NotFound)- Unsupported Action via raw HTTP (expect
InvalidActionor error response) run-instanceswith non-existent AMI ID (expectInvalidAMIID.NotFound)run-instanceswith non-existent key pair (expectInvalidKeyPair.NotFound)delete-volumeon non-existent volume (expectInvalidVolume.NotFound)create-key-pairwith duplicate name (expectInvalidKeyPair.Duplicate)import-key-pairwith duplicate name (expectInvalidKeyPair.Duplicate)import-key-pairwith invalid key format (expectInvalidKey.Format)describe-volumeswith non-existent volume ID (expectInvalidVolume.NotFound)describe-imageswith non-existent AMI ID (expectInvalidAMIID.NotFound)create-imagewith duplicate name (expectInvalidAMIName.Duplicate)delete-key-pairon non-existent key (expect success — idempotent, matches AWS)modify-instance-attributeon running instance (expectInvalidInstanceID.NotFound— running instances not in stopped KV)
IAM Phase 1: User CRUD
- Root auth via
iam list-users(root user exists) create-user(alice, bob with path)- Duplicate user (expect
EntityAlreadyExists) get-user(alice)get-usernon-existent (expectNoSuchEntity)list-users(verify count)list-userswith--path-prefixfilter
IAM Phase 2: Access Key Lifecycle
create-access-key(alice key 1, key 2)- Third key exceeds limit (expect
LimitExceeded) create-access-keyfor non-existent user (expectNoSuchEntity)list-access-keys(alice: 2 keys, bob: 0 keys)update-access-key(Inactive, verify status, reactivate)delete-access-key(verify count decremented)
IAM Phase 3: User Authentication
- Configure IAM user profile with access key
- Deactivate key → auth rejected (
InvalidClientTokenId) - Reactivate key
- Wrong secret →
SignatureDoesNotMatch - Non-existent key ID →
InvalidClientTokenId - Multi-user simultaneous auth (root + bob)
IAM Phase 4: Policy CRUD
create-policy(EC2ReadOnly, FullAdmin, DenyTerminate, IAMReadOnly, EC2DescribeAll)- Duplicate policy (expect
EntityAlreadyExists) - Malformed JSON (expect
MalformedPolicyDocument) get-policy(by ARN)get-policynon-existent (expectNoSuchEntity)get-policy-version(v1)list-policies(verify count)
IAM Phase 5: Policy Attachment & Enforcement
- Create charlie with access key
attach-user-policy(alice: EC2ReadOnly + IAMReadOnly, bob: DenyTerminate)list-attached-user-policies(verify count)- Idempotent attach (no duplicate)
- Attach non-existent policy (expect
NoSuchEntity) - Attach to non-existent user (expect
NoSuchEntity) - Default Deny: charlie (no policies) →
AccessDeniedon ec2 + iam - Explicit Allow: alice → ec2:Describe{Instances,Vpcs} allowed, ec2:DescribeKeyPairs denied, iam:ListUsers allowed, iam:CreateUser denied
- Deny Override: bob → ec2:Describe allowed, ec2:TerminateInstances denied (explicit Deny), iam denied
- Root Bypass: root user → all actions succeed
- Prefix Wildcard: swap alice to EC2DescribeAll (ec2:Describe*) → Describe* allowed, non-Describe denied
- FullAdmin: charlie with FullAdmin → all actions allowed
IAM Phase 6: Policy Lifecycle — Detach & Delete
detach-user-policy→ user loses accessdelete-policywhile attached (expectDeleteConflict)- Detach then delete →
get-policyreturnsNoSuchEntity
IAM Phase 7: IAM Cleanup
- Delete all test users (remove keys + policies first)
- Verify root-only remains (
list-userscount = 1) - Delete all test policies (
list-policiescount = 0) - Clean up AWS CLI profiles
Phase 8: EC2 Account Scoping
Tests that EC2 resources are properly isolated between tenant accounts (Alpha, Beta). Based on docs/development/feature/iam-phase4-e2e-test-guide.md. Skips CreateImage (mulga-612) and instance tags (mulga-613).
Step 1: Account Setup
spx admin account create(Team Alpha, Team Beta)- Configure AWS CLI profiles
- Verify auth for both accounts
Step 2: Instance Scoping
- Alpha + Beta each launch 1 instance
describe-instancesisolation (each sees only own)- OwnerId field matches account ID
- Cross-account
stop-instances→InvalidInstanceID.NotFound - Cross-account
terminate-instances→InvalidInstanceID.NotFound - Cross-account
start-instances→InvalidInstanceID.NotFound - Cross-account
modify-instance-attribute→InvalidInstanceID.NotFound - Cross-account
get-console-output→InvalidInstanceID.NotFound
Step 3: Volume Scoping
- Alpha + Beta each create 1 volume
describe-volumesisolation- Cross-account
describe-volumesby ID →InvalidVolume.NotFound - Cross-account
delete-volume→InvalidVolume.NotFound - Cross-account
attach-volume→InvalidVolume.NotFound - Cross-account
detach-volume→InvalidVolume.NotFound - Cross-account
modify-volume→InvalidVolume.NotFound
Step 4: Key Pair Scoping
- Alpha + Beta each create key pair
describe-key-pairsisolation- Same key name in both accounts (namespace isolation — different KeyPairIds)
- Cross-account
delete-key-pair→ no effect on other account's key import-key-pair→ invisible to other account
Step 5: Snapshot Scoping
- Alpha + Beta each create snapshot from own volume
describe-snapshotsisolation + OwnerId verification- Cross-account
delete-snapshot→UnauthorizedOperation - Cross-account
create-snapshotfrom other's volume →InvalidVolume.NotFound
Step 6: VPC/Subnet Scoping
- Alpha + Beta each create VPC (same CIDR — no conflict)
describe-vpcsisolation- Cross-account
describe-vpcsby ID →InvalidVpcID.NotFound - Cross-account
delete-vpc→InvalidVpcID.NotFound - Alpha + Beta each create subnet
describe-subnetsisolation- Cross-account
create-subnetin other's VPC →InvalidVpcID.NotFound - Cross-account
delete-subnet→InvalidSubnetID.NotFound
Step 7: IGW + EIGW Scoping
- Alpha + Beta each create IGW
describe-internet-gatewaysisolation- Cross-account
describe-internet-gatewaysby ID →InvalidInternetGatewayID.NotFound - Cross-account
delete-internet-gateway→InvalidInternetGatewayID.NotFound - Cross-account
attach-internet-gateway→InvalidInternetGatewayID.NotFound - Cross-account
detach-internet-gateway→InvalidInternetGatewayID.NotFound - Alpha + Beta each create EIGW
describe-egress-only-internet-gatewaysisolation- Cross-account EIGW delete → no effect
Step 8: Account Settings
- Alpha
enable-ebs-encryption-by-default→ Beta unaffected - Independent toggle verification
Step 9: Global Resources
describe-regionsidentical for both accountsdescribe-availability-zonesidenticaldescribe-instance-typesidentical
Step 10: Edge Cases
- Empty account (Gamma) — no resources visible
- Root isolation from tenants (root cannot see tenant instances)
- Non-existent resource IDs → same error as cross-account (no info leakage)
Step 11: Cleanup
- Terminate instances, delete volumes/snapshots/keys/VPCs/IGWs/EIGWs/subnets
- Clean up AWS CLI profiles
Phase 9: Terminate and Verify Cleanup
delete-snapshot(CreateImage backing snapshot, so DeleteOnTermination is not blocked)terminate-instances(poll -> terminated)
Phase 9a: SSH Unreachable Verification
- Verify SSH connection is refused/unreachable after termination
Phase 9b: Volume Cleanup Verification
describe-volumeson root volume after termination- Verify root volume is deleted (DeleteOnTermination)
Pseudo Multi-Node (run-pseudo-multinode-e2e.sh)
Phase 1: Environment Setup
- KVM support check
- Simulated network IPs (10.11.12.{1,2,3} on loopback)
- Simulated network IPs (no ramdisk — start-dev.sh uses disk-backed WAL/VB in CI)
Phase 2: Cluster Initialization
spx admin init(leader node1)- CA certificate trust
- Start node1 services
spx admin join(node2, node3)- Start node2 + node3 services
Phase 3: Cluster Health Verification
- Verify NATS cluster (3 nodes)
- Verify Predastore cluster (3 nodes)
- Wait for gateway on node1
- Wait for daemon NATS readiness
describe-regions(gateway connectivity check)
Phase 3b: Cluster Stats CLI
spx get nodes— verify all 3 nodes show Readyspx top nodes— verify instance type capacity tablespx get vms— verify empty (no instances yet)
Phase 4: Image and Key Setup
describe-instance-types(discover + select nano)create-key-pairspx admin images import(with node1 config paths)describe-images(verify AMI)
Phase 4b: Multi-Node Key Pair Operations
import-key-pair(multinode-test-key-2, from local RSA key)describe-key-pairs(verify both keys visible across cluster)delete-key-pair(multinode-test-key-2, verify deletion)
Phase 5: Multi-Node Instance Tests
Test 1: Instance Distribution
run-instancesx3 (distribute across nodes)- Poll all instances to running state
- Check instance distribution across nodes
spx get vms— verify all instances visible
Test 1a-ii: SSH Connectivity & Volume Verification
- SSH into all 3 instances via QEMU hostfwd port
- Verify SSH connectivity (
idcommand returns ec2-user) - Verify root volume size from inside VM matches API-reported size (
lsblkvsdescribe-volumes) - Verify VM hostname
Test 1b: Volume Lifecycle
create-volume(10GB)modify-volume(resize to 20GB)attach-volume(to first instance)detach-volumedelete-volume
Test 1c: Snapshot Lifecycle
- Uses root volume of first instance (snapshots require a mounted VB instance)
create-snapshot(from attached root volume, with description)- Verify create response fields (VolumeId, VolumeSize, State)
describe-snapshots(by ID, verify fields)copy-snapshot(with new description)describe-snapshots(verify both exist)delete-snapshot(original, verify copy survives)delete-snapshot(copy, cleanup)
Test 1c-ii: Verify Snapshot-Backed Instance Launch
- All
run-instancescalls use the snapshot path (cloneAMIToVolume->OpenFromSnapshot), so the Test 1 instances are already snapshot-backed - Verify AMI snapshot exists in Predastore (
snap-{amiId}/config.json) - Read first instance's root volume
config.jsonfrom Predastore - Verify
SnapshotIDandSourceVolumeNameare set (proves zero-copy clone)
Test 1d: Tag Management (Instances)
create-tags(3 tags on instance)describe-tags(filter by resource-id)describe-tags(filter by key)describe-tags(filter by resource-type)create-tags(overwrite tag value)delete-tags(unconditional by key)delete-tags(wrong value — no-op)delete-tags(correct value)- Verify final tag count
Test 1d-ii: Tag Management (Volumes)
create-tags(2 tags on root volume)describe-tags(filter by resource-id, verify count)describe-tags(filter by resource-type=volume)delete-tags(both tags, verify cleanup)
Test 2: DescribeInstances Aggregation
describe-instances(fan-out across all nodes, verify count)
Test 3: Cross-Node Operations
stop-instances(poll -> stopped)start-instances(poll -> running)
Test 4: NATS Cluster Health (Post-Operations)
- Verify NATS cluster still healthy after all operations
Test 5: VM Crash Recovery
- Kill QEMU process with SIGKILL (simulate OOM kill)
- Verify daemon detects crash (instance transitions to error/pending)
- Wait for auto-restart (backoff starts at 5s)
- Verify new QEMU PID differs from original
- Verify instance reaches running state
- Verify SSH connectivity after recovery
Test 5b: Crash Loop Prevention
- Kill QEMU 4 times rapidly on a third instance
- Verify crash loop is detected and restarts stop after max attempts (3 in 10-min window)
- Verify instance reaches error state (won't restart further)
Phase 5c: IAM Accounts & Cross-Account Isolation
Step 1: Create Accounts
spx admin account create(Team Alpha, Team Beta)- Verify sequential 12-digit account IDs
spx admin account list(verify both accounts)
Step 2: Account Admin Auth
- Alpha admin authenticates via ec2 + iam
- Beta admin authenticates via ec2 + iam
Step 3: Account-Scoped Users
create-useralice in Alpha + Beta (same name, different accounts)create-userteam-member (Alpha), dev-user (Beta)list-usersscoped per account (verify different user lists)- Cross-account isolation: Alpha cannot see Beta's users and vice versa (
NoSuchEntity)
Step 4: Account-Scoped Access Keys
create-access-keyfor alice in Alpha + Beta- Configure separate AWS CLI profiles per account user
Step 5: Account-Scoped Policies & Enforcement
create-policyEC2ReadOnly in Alpha (narrow: DescribeInstances + DescribeVpcs)create-policyEC2ReadOnly in Beta (broad: ec2:Describe*)attach-user-policyto alice in both accounts- Alpha alice: DescribeInstances allowed, DescribeKeyPairs denied (narrow)
- Beta alice: DescribeInstances allowed, DescribeKeyPairs allowed (broad Describe*)
- Both denied: CreateKeyPair
Step 6: Cross-Account Delete Isolation
- Delete Alpha's alice (detach policy, delete key, delete user)
- Verify Alpha alice gone (
NoSuchEntity) - Verify Beta alice unaffected (still exists, auth still works)
Step 7: EC2 Resource Scoping
Tests that EC2 resources are properly isolated between the Alpha/Beta accounts. Skips CreateImage (mulga-612) and instance tags (mulga-613).
7a: Instance Scoping
- Alpha + Beta each launch 1 instance
describe-instancesisolation (each sees only own)- OwnerId field matches account ID
- Cross-account
stop-instances→InvalidInstanceID.NotFound - Cross-account
terminate-instances→InvalidInstanceID.NotFound - Cross-account
get-console-output→InvalidInstanceID.NotFound
7b: Volume Scoping
- Alpha + Beta each create 1 volume
describe-volumesisolation- Cross-account
describe-volumesby ID →InvalidVolume.NotFound - Cross-account
delete-volume→InvalidVolume.NotFound - Cross-account
attach-volume→InvalidVolume.NotFound - Cross-account
detach-volume→InvalidVolume.NotFound - Cross-account
modify-volume→InvalidVolume.NotFound
7c: Key Pair Scoping
- Alpha + Beta each create key pair
describe-key-pairsisolation- Same key name in both accounts (namespace isolation — different KeyPairIds)
- Cross-account
delete-key-pair→ no effect on other account's key import-key-pair→ invisible to other account
7d: Snapshot Scoping
- Alpha + Beta each create snapshot from own volume
describe-snapshotsisolation + OwnerId verification- Cross-account
delete-snapshot→UnauthorizedOperation - Cross-account
create-snapshotfrom other's volume →InvalidVolume.NotFound
7e: VPC/Subnet Scoping
- Alpha + Beta each create VPC (same CIDR — no conflict)
describe-vpcs/describe-subnetsisolation- Cross-account VPC/subnet operations →
NotFound
7f: IGW + EIGW Scoping
- Alpha + Beta each create IGW + EIGW
- Describe isolation for both
- Cross-account attach/detach/delete →
NotFound
7g: Account Settings
enable-ebs-encryption-by-defaultper account — independent toggle
7h: Global Resources
describe-regions,describe-availability-zones,describe-instance-typesidentical for both accounts
Step 8: Edge Cases
- Empty account (Gamma) — no resources visible
- Root isolation from tenants
- Non-existent resource IDs → same error as cross-account
- Parallel key creation (race condition) — no cross-contamination
Step 9: EC2 + IAM Cleanup
- Terminate instances, delete volumes/snapshots/keys/VPCs/IGWs/EIGWs/subnets
- Delete all IAM users, policies, access keys
- Clean up AWS CLI profiles
Phase 5d: VPC Networking
- Step 1:
create-vpc(10.100.0.0/16) +create-subnet(10.100.1.0/24) - Step 2:
run-instancesx3 with--subnet-id(launch into VPC subnet) - Poll all VPC instances to running state
- Step 3: Verify
PrivateIpAddressindescribe-instancesfor each instance - Verify
SubnetIdandVpcIdmatch requested values - Verify at least 1
NetworkInterfaceper instance - Verify all IPs are unique and in subnet range (10.100.1.x)
- Step 4: SSH + ping connectivity (skipped in CI — OVN DHCP wait too slow)
- Step 5: Stop/start IP persistence
stop-instances(all VPC instances, poll -> stopped)- Verify
PrivateIpAddresspersists in stopped state start-instances(all VPC instances, poll -> running)- Verify
PrivateIpAddressidentical after restart
- Step 6: Cleanup — terminate VPC instances, delete subnet, delete VPC
Phase 6: Cluster Shutdown + Restart
Test 6a: Dry-Run Shutdown
spx admin cluster shutdown --dry-run- Validate output contains all 5 phases (GATE, DRAIN, STORAGE, PERSIST, INFRA)
Test 6b: Coordinated Cluster Shutdown
spx admin cluster shutdown --force --timeout 60s- Verify all services down on all nodes (gateway, NATS, QEMU)
Test 6c: Cluster Restart + Recovery
- Restart all 3 node services
- Verify NATS cluster reforms (3 members)
- Wait for gateway and daemon readiness
- Smoke test:
describe-instance-typesreturns valid results
Test 6d: Instance Relaunch + Terminate
- Wait for instances to finish relaunching after restart (pending → running/error)
terminate-instances(all 3 instances)- Poll all to terminated state
Cleanup
- Coordinated cluster shutdown (with fallback to per-node PID stops)
- Remove simulated IPs
Real Multi-Node (run-multinode-e2e.sh)
Runs on a real 3-node libvirt cluster provisioned by OpenTofu (scripts/tofu-cluster/). Each node is a separate VM with its own OVN, NATS, Predastore, and Spinifex daemon. Bootstrap (bootstrap.sh) handles all provisioning before the test script runs.
Bootstrap (pre-test)
- OpenTofu provisions 3 libvirt VMs (bottlebrush, ironbark, casuarina) with cloud-init
- Wait for all nodes to be SSH-reachable
- Copy SSH key to node1 for peer access
git fetch+ checkout test branch on all nodes (all 3 repos)make buildon all nodes (parallel)setup-ovn.sh— primary gets--management, secondaries connect to primary's OVN centralspx admin initon primary,spx admin joinon secondaries (formation server handshake)start-dev.shon all nodes- Wait for
/health(awsgw=ok) on all nodes - Install Spinifex CA certificate on all nodes
import-key-pair(spinifex-key) +spx admin images import(Ubuntu 24.04)create-vpc(10.200.0.0/16) +create-subnet(10.200.1.0/24)
Phase 1: Pre-flight Validation
- KVM support check (
/dev/kvmwritable) - SSH connectivity to all peer nodes
Phase 2: Cluster Health
- NATS cluster: verify 2 unique route peers (3-node quorum)
- Predastore reachable on all 3 nodes
- AWS gateway reachable on all 3 nodes
- Daemon readiness (
describe-instance-typesreturns results) spx get nodes— verify all 3 nodes show Readyspx get vms— verify empty (no instances yet)
Phase 3: Instance Lifecycle + Distribution
- Discover nano instance type and AMI (from bootstrap import)
run-instancesx3 with staggered launches- Poll all instances to running state
- Check instance distribution across physical nodes (QEMU process check)
- Verify at least 2 different hosting nodes (non-deterministic, non-fatal if all on 1)
spx get vms— verify all 3 instances visible
Phase 4: SSH into Guest VMs
- For each instance across all nodes:
- Find hosting node via QEMU process scan
- Extract SSH hostfwd port from QEMU command line on remote node
- Wait for SSH readiness (up to 60s)
- Verify SSH connectivity (
idreturns ec2-user) - Verify block device (
lsblk)
Phase 5: Volume Lifecycle
create-volume(10GB)attach-volume(to first instance, /dev/sdf)- Poll for in-use + attached state
detach-volume(poll for available)delete-volume(verify gone)
Phase 6: Cross-Node Gateway Access
describe-instancesvia node1 gateway (baseline count)describe-instancesvia node2 and node3 gateways- Verify all gateways return the same instance count (fan-out aggregation)
Phase 7: Cross-Node Operations
- Find which node hosts instance 0
- Pick a different node's gateway for stop
stop-instancesvia remote gateway (poll -> stopped)- Pick a third node's gateway for start
start-instancesvia another remote gateway (poll -> running)
Phase 8: Node Failure
stop-dev.shon node2 (simulate node failure)- Verify node1 still serves requests (
describe-instance-types) - Verify node3 still serves requests
- Check NATS degraded state (1 peer instead of 2)
describe-instancesstill works from surviving nodes
Phase 9: Node Recovery
start-dev.shon node2 (restart failed node)- Wait for NATS cluster to reform (2 peers again)
- Verify node2 gateway is back
spx get nodes— verify all 3 nodes Ready again- Verify node2 serves requests after recovery
Phase 10: Cleanup
terminate-instances(all 3 instances, poll -> terminated)
VPC (run-vpc-e2e.sh)
Standalone VPC networking test suite. Runs against a running Spinifex endpoint (configurable via ENDPOINT env var). OVN topology tests are skipped when OVN is unavailable.
Phase 1: VPC CRUD
create-vpc(10.99.0.0/16, verify VpcId)describe-vpcs(by ID, verify exactly 1 returned)
Phase 2: Subnet CRUD
create-subnet(10.99.1.0/24 in VPC, verify SubnetId)describe-subnets(by ID, verify exactly 1 returned)
Phase 3: Internet Gateway CRUD
create-internet-gateway(verify InternetGatewayId)describe-internet-gateways(by ID, verify exactly 1 returned)
Phase 4: Internet Gateway Attach / Detach
attach-internet-gateway(IGW to VPC)describe-internet-gateways(verify attachment VpcId)delete-internet-gatewayon attached IGW (expectDependencyViolationor rejection)detach-internet-gateway(IGW from VPC)describe-internet-gateways(verify no attachments)
Phase 5: OVN Topology Verification (requires OVN)
- Re-attach IGW for topology inspection
- Verify OVN logical switch exists for subnet
- Verify OVN logical router exists for VPC
- Verify OVN external switch exists (IGW attached)
- Verify SNAT rule on VPC router
- Verify default route (0.0.0.0/0) on VPC router
- Dump full OVN NB DB topology for debugging
- Detach IGW
- Skipped when
ovn-nbctlis not available
Phase 6: Cleanup
delete-internet-gatewaydelete-subnetdelete-vpc- Verify OVN cleanup (no VPC routers remaining, when OVN available)
ELBv2 / ALB (run-elbv2-e2e.sh)
Standalone ELBv2 (Application Load Balancer) E2E test suite. Requires VPC networking
for ALB ENI creation. Configurable endpoint via ENDPOINT env var.
Phase 0: VPC + Subnet Setup
create-vpc(10.200.0.0/16, prerequisite for ALB)create-subnet(10.200.1.0/24)
Phase 1: Target Group CRUD
create-target-group(HTTP, port 80, verify health check defaults)describe-target-groups(by ARN, verify single result)create-target-group(second TG, port 8080)describe-target-groups(all, verify >= 2)- Duplicate name detection (same name → error)
Phase 2: Target Registration
register-targets(2 fake instances)describe-target-health(verify 2 targets, initial state)deregister-targets(remove 1 target)describe-target-health(verify 1 target remains)
Phase 3: Load Balancer CRUD
create-load-balancer(ALB with subnet)- Verify fields: type=application, state=active, DNS name, scheme=internet-facing
- Verify ENIs created (describe-network-interfaces with ELB filter)
describe-load-balancers(by ARN, verify single result)- Duplicate name detection (same name → error)
Phase 4: Listener CRUD
create-listener(port 80, forward to TG)- Verify fields: port=80, protocol=HTTP
describe-listeners(by LB ARN, verify single result)- Duplicate port detection (same port → error)
Phase 5: In-Use Protection
delete-target-groupwhile referenced by listener → error (ResourceInUse)
Phase 6: Listener Deletion
delete-listener- Verify 0 listeners remain
delete-target-group(now succeeds after listener removed)
Phase 7: Load Balancer Deletion
delete-load-balancer- Verify ALB gone from describe
- Verify ENIs cleaned up
delete-target-group(second TG)
Phase 8: Error Path Tests
- Describe non-existent ALB → empty result
- Delete non-existent ALB → error
- Delete non-existent TG → error
- Create TG without name → error
- Create listener on non-existent ALB → error
Consolidated LB Data Plane (run-lb-e2e.sh)
Consolidated LB data plane test covering all 4 variants: ALB internet-facing, ALB internal, NLB internet-facing, NLB internal. Shares a single VPC, subnet, and set of dual-purpose app instances (HTTP:80 + TCP:9000) across all suites. Requires pool mode with external IPAM (NOT dev_networking).
Phase 0: Prerequisites
- Dev-mode gate (skip if dev_networking enabled)
- SSH to peer node (if
--peerprovided) - Discover instance types (nano), AMI (non-Alpine)
- Create key pair (lb-e2e-key)
Phase 1: Shared VPC + Subnet
create-vpc(10.200.0.0/16)create-internet-gateway+ attachcreate-subnet(10.200.1.0/24) + MapPublicIpOnLaunch
Phase 2: Launch App Instances
run-instancesx2 with dual-purpose cloud-init (HTTP on 80, TCP echo on 9000)- Poll instances to running state, collect private IPs
- Poll app ports for readiness (replaces
sleep 100)
Phase 3a: ALB Internet-Facing [skip if no --peer]
- Create HTTP target group, register 2 targets
create-load-balancer(scheme=internet-facing), verify scheme- Verify ENI has public IP, DNS has no
internal-prefix create-listener(HTTP:80)- Wait for ALB active (agent heartbeat)
- Host HTTP traffic test (curl 20x, verify round-robin)
- Peer validation (curl from --peer node, 20x)
- Cleanup: delete listener, LB, verify ENI removal, delete TG
Phase 3b: ALB Internal
- Create HTTP target group, register 2 targets
create-load-balancer(scheme=internal), verify scheme- Verify ENI has NO public IP, DNS has
internal-prefix create-listener(HTTP:80)- Wait for ALB active (mgmt NIC heartbeat)
- Wait for targets healthy
- Launch client VM (cloud-init curls ALB private IP, serves results)
- Poll client status.txt, fetch results, verify round-robin
- Terminate client VM
- Cleanup: delete LB, verify ENI removal, delete TG
Phase 3c: NLB Internet-Facing [skip if no --peer]
- Create TCP target group (port 9000), verify protocol=TCP
- Register 2 targets
create-load-balancer(type=network), verify type, ARN/net/create-listener(TCP:9000)- Wait for NLB active (agent heartbeat)
- Verify ENI has public IP
- Wait for targets healthy (TCP health check)
- Host TCP traffic test (nc 20x, verify round-robin)
- Peer validation (nc from --peer node)
- Deregister target, verify draining state
- Cleanup: delete listener, LB, verify ENI removal, delete TG
Phase 3d: NLB Internal
- Create TCP target group (port 9000), register 2 targets
create-load-balancer(type=network, scheme=internal)- Verify type=network, scheme=internal, ARN
/net/ - Verify ENI has NO public IP, DNS has
internal-prefix create-listener(TCP:9000)- Wait for NLB active (mgmt NIC heartbeat)
- Wait for targets healthy (TCP health check)
- Launch client VM (cloud-init nc's NLB private IP, serves results)
- Poll client status.txt, fetch results, verify TCP responses
- Terminate client VM
- Cleanup: delete LB, verify ENI removal, delete TG
Phase 4: Cleanup (trap)
- Terminate app instances, wait for terminated
- Delete key pair, subnet, IGW, VPC