* feat(elbv2): implement ModifyListener
Adds AWS-compatible ModifyListener to the spinifex ELBv2 stack so
listeners can be edited in place — port, protocol, and default
actions — without the current delete+create dance that briefly drops
the listener from HAProxy.
- Handler interface, impl, and NATS wrapper get ModifyListener;
validation mirrors CreateListener (LB type ↔ protocol, dup-port
excluding self, target-group existence + protocol compatibility).
- Daemon subscribes to elbv2.ModifyListener on the spinifex-workers
queue and a new gateway action dispatches it.
- Frontend exposes a per-row edit button that pre-fills a dialog from
the existing listener and calls ModifyListenerCommand, replacing
the "Listeners cannot be edited" note.
- Unit + NATS round-trip + frontend tests cover the new flow.
Cert / SslPolicy / Alpn handling stays out of scope and lands with
HTTPS listener support.
* test(e2e): cover ModifyListener in lb harness
Adds an Internal_ALB_ModifyListener subtest to TestLoadBalancer that
exercises in-place listener edits end-to-end:
- Provisions tgA + tgB sharing the same app instances.
- Brings up an internal ALB with HTTP:80 → tgA, baselines round-robin.
- ModifyListener port=8090, asserts DescribeListeners shows the new
port and that probes on 8090 still round-robin — proves HAProxy is
reconciled without dropping the listener (the regression mulga-944
fixes; the frontend previously delete+created on every edit).
- ModifyListener DefaultActions → tgB, asserts traffic still flows.
Also factors a probeAtPort helper so the order JSON can target any
listener port, and updates TEST_COVERAGE.md with phase 3e.
* fix(ui): widen listener add/edit dialog so content stays inside the box
AlertDialogContent defaults to max-w-xs (20rem) which clipped the listener
form on the right edge. Use a 32rem cap with grid-cols-[minmax(0,1fr)] +
min-w-0 on the form so CSS grid tracks shrink instead of expanding to
max-content.
* test: cover ModifyListener + system instance dispatch
Lifts ModifyListener diff coverage by exercising nil/empty arn paths,
NLB protocol acceptance + rejection, protocol-only revalidation against
existing target groups, and the launcher-nats wire format error paths
(missing input, missing instance type, decode failures, no responder,
default timeout fallback).
Adds a new spinifex/daemon/daemon_system_dispatch_test.go covering
splitSubjectTail edge cases, respondWithSystem{Launch,Terminate}{Output,
OK,Error} envelope shape, handler entry points for invalid JSON and
missing instance ID, and subscribeSystemTerminate nil-conn + idempotent
re-subscribe behaviour.
Fixes the flaky TestHandleEC2DescribeInstanceTypes/NoFilter… subtest by
selecting a 2-vCPU instance type that also fits schedulable memory, so
hosts with smaller schedulableMem (CI 4 vCPU / ~13 GiB) don't pick a
16 GiB-mem type that fails allocation.
* test(e2e): wait for tgB after listener swap, not before
Spinifex health-checks only target groups wired into a listener's
HAProxy config. Waiting for tgB to report healthy before
modifyListenerDefaultTG attaches it to the listener is impossible —
the agent never probes its targets, so they stay in initial and the
2m EventuallyErr trips.
Drop the pre-swap tgB wait. After modifyListenerDefaultTG, replace
the fixed 3s sleep with WaitForTargetsHealthy on tgB (post-swap),
which is now the listener's DefaultAction.
* test(e2e): derive LB name from ARN in lbENI/deleteLB ENI filter
The ENI description filter was building the LB-name token from a
hard-coded "alb"/"nlb" template (e.g. `ELB app/lb-e2e-alb-int/<id>`),
which only matched LBs whose name happened to be `lb-e2e-alb-*` or
`lb-e2e-nlb-*`. The ModifyListener suite names its LB `lb-e2e-mod`,
so lbENI's EventuallyErr could never find the ENI and tripped after
30s. deleteLB had the same bug: sysInstanceID stayed empty and
WaitForENICleanup waited for a non-existent filter, allowing the
next suite's createLB to race the sys.micro VM reclaim.
Parse the LB name straight out of lb.ARN (penultimate path segment,
same shape createLB already relies on) so the filter matches any
caller-chosen name. Drop the now-redundant scheme arg from lbENI.
* test(e2e): poll for listener serving on new port after ModifyListener
The post-modifyListenerPort settle was a hard 3s sleep, but lb-agent
polls for new HAProxy config on a 5s tick. After the API returns, the
agent has typically not yet detected the config-hash change, so
HAProxy is still bound to the old port — every probe to the new port
RSTs and probeAtPort sees 0/0 successful.
Replace the sleep with waitForListenerServing: a polling loop that
fires 2 probes via the shared trigger client every 2s until at least
one returns a parsable instance_id, with a 60s ceiling. Factor the
trigger-and-fetch out of probeAtPort into probeOnce so the polling
helper can reuse it without the require.* asserts.
40 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 5f: Security Group Enforcement (egress ACL)
Proves vpcd programs OVN ACLs that actually drop traffic, not just nominal SG records. Tests egress only — in single-node dev_networking mode, runner→VM SSH bypasses OVN via the hostfwd NIC, so ingress isn't a faithful probe. Egress goes through the OVN tap NIC (default route).
- Discover VM's OVN gateway from inside the VM (
ip route show default) - Test 5f-1 (baseline): ICMP from VM to gateway works under default SG (allow-all egress)
- Skipped gracefully if env blocks ICMP regardless of SG (no false fail)
- Test 5f-2 (revoke):
revoke-security-group-egressstrips the allow-all rule → ICMP must drop - Test 5f-3 (re-authorize):
authorize-security-group-egressrestores the rule → ICMP must work again - Restore the default egress rule on any failure path so subsequent phases (6, 7, 8) keep their SSH probes working
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 8b: VPC Public/Private Subnet E2E
Gated on external_mode = "pool" (skipped when only dev_networking is configured). Validates AWS-parity public/private subnet semantics end-to-end through OVN.
- Step 1: Discover default VPC + default subnet (
MapPublicIpOnLaunch=true) - Step 2: Public subnet instance
run-instancesinto default subnet → auto-assignedPublicIpAddressovn-nbctl lr-nat-listshows per-instancednat_and_snatrule forPUB_PRIVATE_IP- Asserts NO blanket VPC CIDR SNAT (mulga-754 AWS-parity regression)
- SSH via public IP succeeds (when bridge reachable)
- Outbound internet from inside the VM (via OVN SNAT)
- Step 2b: In-guest TLS trust — cloud-init injects Spinifex CA into the guest, so
curl https://awsgwandhttps://predastore-s3work without--insecure
- Step 3: Private subnet isolation
create-subnet172.31.16.0/20 withMapPublicIpOnLaunch=falserun-instancesinto private subnet → no public IP, no internet access from inside the VM
Phase 8c: Route Table Validation
- Step 1: Default VPC main route table — local route + 0.0.0.0/0 → IGW present
- Step 2: Custom route table CRUD lifecycle — create, list local route,
create-route,associate-route-table,disassociate-route-table,delete-route,delete-route-table - Step 3: Error paths — cannot delete the local route; cannot delete the main route table
Phase 8d: NAT Gateway E2E (mulga-763)
Reuses PUB_INSTANCE_ID (bastion) + PRIV_INSTANCE_ID from Phase 8b. End-to-end proof that NAT GW creation/teardown flips private-instance outbound connectivity.
- Step 1: Bastion → private SSH ready (via
ProxyJumpthrough PUB_IP) - Step 2: Baseline — private VM cannot reach internet
- Step 3:
allocate-address+create-nat-gateway+create-route(0.0.0.0/0 → NAT GW); verify OVN SNAT rule appears - Step 4: Private VM CAN now reach internet (retried, NAT propagation can take a few seconds)
- Step 5:
delete-nat-gateway+ cleanup route +release-address; verify private VM loses internet again
Phase 8e: Security Group Enforcement (SG-to-SG datapath)
End-to-end proof that the RunInstances → ENI → vpcd → OVN port group + ACL → datapath chain actually drops unauthorized traffic. This is the deepest SG enforcement test in the suite and supersedes Phase 5f for SG-to-SG semantics.
- Capacity note: Phase 8b's instance termination (PUB+PRIV) runs before Phase 8e on single-node to free 2 nano slots. Without that, 5 simultaneous nanos (main + PUB + PRIV + client + target) exhaust the runner and the gateway masks
InsufficientInstanceCapacityasInvalidInstanceTypeviaisKnownInstanceType. - Step 1: Create
sge-clientandsge-targetSGs; authorize tcp/22 on client-sg from 0.0.0.0/0; authorize tcp/8080 on target-sg from client-sg (SG-to-SG viaUserIdGroupPairs) - Step 2: Launch
client-vm(sge-client) andtarget-vm(sge-target) in default subnet; target VM's HTTP server started via cloud-inituser-data(port 8080) — target-sg has no SSH ingress, so we never SSH into it - Step 3:
ovn-nbctlport_group membership inspection — both client + target LSPs are in their port groups; verifies the SG-to-SG match expression resolves - Step 4: Allowed —
curl client → target:8080succeeds (SG-to-SG allow) - Step 5: Denied —
nc client → target:22blocked (target-sg has no SSH ingress; default-deny ACL drops) - Step 6: Revoke target-sg's tcp/8080 rule and retest immediately — confirms vpcd's sync
RequestEventcontract (no eventually-consistent gap) - Step 7: Cleanup — terminate both VMs, delete both SGs
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) — generates join token, writes to~/node1/config/join-token- CA certificate trust
- Read join token from node1 config
spx admin joinwith--token(node2, node3 — concurrent)- Start all 3 node services concurrently
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)create-security-group+ 2×authorize-security-group-ingress(tcp/22 + icmp from 0.0.0.0/0)- All subsequent run-instances use this SG so VMs are reachable. The default SG is egress-only, so without this every Phase 5 SSH probe would be dropped by OVN ACL.
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.
Go port: every phase below has a
TestMultinode*counterpart intests/e2e/multinode/(phase N ->phaseN_*helper). CI runs the Go suite via thee2e_go_multinodejob in.github/workflows/e2e-go.yml; the bash driver stays in place as the executable spec until the Go run has a soak-tested cadence onci-multi.
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 (generates join token), read token via SSH,spx admin join --tokenon secondariesstart-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)
authorize-security-group-ingress(tcp/22 on default VPC's default SG, 0.0.0.0/0)- Required because the default SG is egress-only and the runner's IP is not a member; without this Phase 4 SSH would be dropped by OVN port-group ACL.
- Idempotent (tolerates
InvalidPermission.Duplicateon re-runs).
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)
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 3e: ALB Internal — ModifyListener
- Create two HTTP target groups (tgA, tgB), register the same 2 app instances in each
create-load-balancer(type=application, scheme=internal)create-listener(HTTP:80) → tgA- Wait for ALB active, both TGs healthy
- Baseline probe at port 80 → assert round-robin across both instances
modify-listenerPort=8090 (same tgA), verify DescribeListeners persisted- Probe at port 8090 → assert round-robin (proves HAProxy reconciled without dropping the listener)
modify-listenerDefaultActions → tgB (same port 8090)- Probe at port 8090 → assert round-robin still flows through tgB
- Cleanup: delete listener, LB, deregister, delete both TGs
Phase 4: Cleanup (trap)
- Terminate app instances, wait for terminated
- Delete key pair, subnet, IGW, VPC
DDIL (tests/e2e/ddil/)
Go-based harness and scenarios that exercise the Denied / Disrupted /
Intermittent / Limited failure modes called out in the DDIL vulnerability
assessment. Design and rationale live in
docs/development/improvements/ddil-e2e-test-harness.md;
the Go helper API lives in
tests/e2e/ddil/harness/ with the authoritative link
profile values in harness/profiles.go.
Scenarios begin as t.Skip("requires <dep>") and are flipped to real
assertions by the hardening epics they cover (daemon-local-autonomy,
predastore-ddil-hardening). The status column below is the authoritative
live state — update it in the same PR that changes the test body.
TestCoverageDrift enforces the letter column against the
TestScenario<L>_... functions in ddil/scenarios/.
Scenarios
| Letter | Scenario | DDIL finding closed | Status |
|---|---|---|---|
| A | NATS-only kill (daemon stays up, standalone mode) | Finding 1 | ENABLED |
| B | Daemon restart without NATS (recovers from local state) | Finding 1 | ENABLED |
| C | Clean cluster-network partition (majority + isolated both progress) | Finding 1/2 | SKIPPED (needs predastore §2 — see notes) |
| D | Degraded link under SATCOM profile (fan-out + Raft) | Finding 3 | SKIPPED |
| E | Predastore write under partition (repair journal drains on heal) | Finding 2 | SKIPPED |
| F | Raft under SATCOM latency (≤1 election over 5 min) | Finding 3 | SKIPPED |
Scenario C quarantine note: the witness VM's block I/O is backed by
distributed predastore, so an iptables peer-partition (PartitionNode's
OUTPUT DROP rules) also severs the witness disk writes. The result is
BLOCK_IO_ERROR → QEMU power-down → daemon termination → ssh handshake
EOF on the next AssertProgressed. Needs
predastore-ddil-hardening §2
(write-quorum with deferred parity repair) before C can assert without
flapping; flip the row back to ENABLED in the same PR that lands §2.
Link profile validation
Values in harness/profiles.go are unvalidated approximations derived
from public specifications — real-hardware measurement is deferred until
SATCOM/HF access is available. Update the last column when a measurement
lands.
| Profile | Specification source | Validation status | Last measured against real hardware |
|---|---|---|---|
| LAN | baseline | unvalidated | — |
| WAN | public site-to-site norms | unvalidated | — |
| LTEDegraded | public congested-cellular norms | unvalidated | — |
| SATCOM | published GEO SATCOM figures | unvalidated | — |
| HFData | HF modem specifications | unvalidated | — |
| Flapping | synthetic (10s up / 5s down) | unvalidated | — |
Known gaps
- Security group enforcement breadth — Phase 5f covers egress
authorize/revoke for the default SG (dev_networking single-node);
Phase 8e covers SG-to-SG ingress + revoke under external pool mode.
Not yet covered:
ModifyInstanceAttribute --groupsswap (verify port group membership changes mid-flight), reconciler convergence after a vpcd restart with drift (kill vpcd, delete an OVN port group, restart vpcd, assert it converges), and per-port-range allow/deny combinations on a single SG. Follow-up: mulga-js-79 (in progress). - Chaos framework (randomised fault injection, long-running stress) — follow-up plan TBD.
- Multi-day partition — requires long-running CI infrastructure; follow-up plan TBD.
- Real-hardware SATCOM/HF validation — follow-up plan TBD.
- Clock skew injection — no hardening epic claims Finding 7 yet; revisit when one lands.
- Dual-NIC cluster network — Scenario C uses peer-IP
iptablesrules on the single-NIC tofu-cluster today; dedicated cluster NIC is deferred todocs/development/improvements/e2e-multinode-dual-nic.md(TBC). - Dockerised per-node pseudo-multinode isolation — candidate under
docs/development/improvements/docker-e2e-local.md. - Full Go migration of the rest of the E2E suite — TBD follow-up to the DDIL harness pilot.