test(integration): migrate RunInstances multi-count expansion off live guests

RunInstances with MinCount/MaxCount=2 expands into a single per-node
targeted launch (distributeInstances in gateway/ec2/instance/placement.go)
before the daemon-facing NATS hop, so the real gateway router plus a
stubbed daemon response is sufficient to prove it — no throwaway VM is
needed to exercise the expansion logic itself.

The live single-node E2E test's WaitForInstanceState/TerminateInstances
sequence was teardown hygiene for the real guests it booted, not an
assertion of the expansion behavior, so it is dropped rather than
emulated. The migrated test additionally asserts the per-node launch
request itself carries MinCount=MaxCount=2, proving the count actually
propagates rather than the launch succeeding by coincidence or via the
zero-daemon degenerate path in placement.go.
This commit is contained in:
joshsiv-mulga
2026-07-20 20:34:35 +10:00
parent a4504800b4
commit 1be5206538
4 changed files with 94 additions and 67 deletions
-1
View File
@@ -76,7 +76,6 @@ create tests/e2e/single/baseline_test.go RunInstances #1
create tests/e2e/single/datapath_test.go CreateSecurityGroup #1
create tests/e2e/single/datapath_test.go RunInstances #1
create tests/e2e/single/lifecycle_test.go CreateVolume #1
create tests/e2e/single/lifecycle_test.go RunInstances #1
create tests/e2e/single/natgw_test.go AllocateAddress #1
create tests/e2e/single/natgw_test.go CreateNatGateway #1
create tests/e2e/single/natgw_test.go CreateRouteTable #1
+2 -62
View File
@@ -354,68 +354,8 @@ func runRebootInstance(t *testing.T, fix *Fixture) {
assert.Equalf(t, preRebootIP, postRebootIP,
"PrivateIpAddress changed across reboot: %s -> %s", preRebootIP, postRebootIP)
// Leave instance running — Phase 7c launches sibling instances next,
// and Phase 8 expects the primary instance to still be up.
}
// runRunInstancesMultiCount launches 2 sibling instances in a single
// RunInstances call, waits for both to reach "running", then terminates
// them and waits for both to reach "terminated". The primary fix.Instance
// is untouched and remains running for Phase 8. Maps to run-e2e.sh
// ~1238–1296.
func runRunInstancesMultiCount(t *testing.T, fix *Fixture) {
harness.Phase(t, "Single — RunInstances with MinCount/MaxCount > 1")
amiID := needAMI(t, fix)
instType, _ := needInstanceTypeArch(t, fix)
keyName, _ := needKeyPair(t, fix)
harness.Step(t, "run-instances ami=%s type=%s count=2", amiID, instType)
out, err := fix.AWS.EC2.RunInstances(&ec2.RunInstancesInput{
ImageId: aws.String(amiID),
InstanceType: aws.String(instType),
KeyName: aws.String(keyName),
MinCount: aws.Int64(2),
MaxCount: aws.Int64(2),
})
require.NoError(t, err, "run-instances --count 2")
require.Lenf(t, out.Instances, 2,
"expected 2 instances from run-instances, got %d", len(out.Instances))
multiIDs := []string{
aws.StringValue(out.Instances[0].InstanceId),
aws.StringValue(out.Instances[1].InstanceId),
}
require.NotEmpty(t, multiIDs[0], "first sibling InstanceId empty")
require.NotEmpty(t, multiIDs[1], "second sibling InstanceId empty")
harness.Detail(t, "sibling_1", multiIDs[0], "sibling_2", multiIDs[1])
// Always tear down the siblings — pre-register before any blocking
// wait so a t.Fatal on state polling still triggers cleanup.
terminated := false
t.Cleanup(func() {
if terminated {
return
}
_, _ = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
InstanceIds: aws.StringSlice(multiIDs),
})
})
for _, id := range multiIDs {
harness.WaitForInstanceState(t, fix.AWS, id, "running")
}
harness.Step(t, "terminate-instances %s %s", multiIDs[0], multiIDs[1])
_, err = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
InstanceIds: aws.StringSlice(multiIDs),
})
require.NoError(t, err, "terminate-instances")
for _, id := range multiIDs {
harness.WaitForInstanceState(t, fix.AWS, id, "terminated")
}
terminated = true
// Leave instance running — later phases expect the primary instance to
// still be up.
}
// waitForSSHReady probes a full SSH handshake (BatchMode + ConnectTimeout)
-4
View File
@@ -154,10 +154,6 @@ func TestRebootInstance(t *testing.T) {
runRebootInstance(t, requireSingleNodeFixture(t))
}
func TestRunInstancesMultiCount(t *testing.T) {
runRunInstancesMultiCount(t, requireSingleNodeFixture(t))
}
// TestLaunchTemplates launches a throwaway VM to verify the referenced
// template's effective image and instance type, so it runs sequentially.
func TestLaunchTemplates(t *testing.T) {
+92
View File
@@ -0,0 +1,92 @@
//go:build integration
package integration
import (
"encoding/json"
"fmt"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/require"
)
// TestRunInstances_MultiCountExpansion migrates the load-bearing assertion of
// the live single-node E2E suite's runRunInstancesMultiCount
// (spinifex/tests/e2e/single/lifecycle_test.go): a RunInstances call with
// MinCount=MaxCount=2 returns 2 instances with non-empty InstanceIds. That
// expansion is gateway-side logic — distributeInstances in
// spinifex/gateway/ec2/instance/placement.go queries node capacity over NATS,
// computes how many instances to launch, and dispatches a single targeted
// launch per node — resolved entirely before the daemon-facing NATS hop, so a
// static stub of the daemon side is sufficient; no real guest is needed.
//
// The live test's WaitForInstanceState(running)/TerminateInstances/
// WaitForInstanceState(terminated) sequence is left behind: it is teardown
// hygiene for a real throwaway VM, not part of what this test asserts.
//
// To prove the expansion is genuinely exercised rather than short-circuited by
// the zero-daemon degenerate path (placement.go's queryNodeCapacity returning
// no eligible nodes, which also yields InsufficientInstanceCapacity — see
// placement.go:44), this test subscribes to the per-node launch subject
// directly instead of using StubSubject's static responder, and asserts the
// daemon actually received MinCount=MaxCount=2 on that targeted request.
func TestRunInstances_MultiCountExpansion(t *testing.T) {
gw := StartGateway(t)
const (
instanceType = "t3.micro"
nodeID = "node-1"
)
// A single node reporting capacity for 2 of instanceType — enough for
// distributeInstances to resolve MinCount=MaxCount=2 onto one node.
statusResp := mustMarshal(t, &types.NodeStatusResponse{
Node: nodeID,
InstanceTypes: []types.InstanceTypeCap{
{Name: instanceType, Available: 2},
},
})
gw.StubSubject(t, "spinifex.node.status", statusResp)
var gotMinCount, gotMaxCount int64
launchSubject := fmt.Sprintf("ec2.RunInstances.%s.%s", instanceType, nodeID)
sub, err := gw.NATSConn.Subscribe(launchSubject, func(msg *nats.Msg) {
var nodeInput ec2.RunInstancesInput
if jsonErr := json.Unmarshal(msg.Data, &nodeInput); jsonErr != nil {
t.Errorf("unmarshal per-node RunInstances request: %v", jsonErr)
return
}
gotMinCount = aws.Int64Value(nodeInput.MinCount)
gotMaxCount = aws.Int64Value(nodeInput.MaxCount)
_ = msg.Respond(mustMarshal(t, &ec2.Reservation{
ReservationId: aws.String("r-multi-count"),
Instances: []*ec2.Instance{
{InstanceId: aws.String("i-multi-count-1")},
{InstanceId: aws.String("i-multi-count-2")},
},
}))
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
out, err := gw.EC2Client(t).RunInstances(&ec2.RunInstancesInput{
ImageId: aws.String("ami-0123456789abcdef0"),
InstanceType: aws.String(instanceType),
MinCount: aws.Int64(2),
MaxCount: aws.Int64(2),
})
require.NoError(t, err, "run-instances --count 2")
require.Lenf(t, out.Instances, 2, "expected 2 instances from run-instances, got %d", len(out.Instances))
require.NotEmpty(t, aws.StringValue(out.Instances[0].InstanceId), "first sibling InstanceId empty")
require.NotEmpty(t, aws.StringValue(out.Instances[1].InstanceId), "second sibling InstanceId empty")
// Proves the gateway genuinely expanded the count onto the per-node
// launch, rather than the zero-daemon path (which never reaches this
// subject at all) or a daemon stub that ignores its input.
require.Equal(t, int64(2), gotMinCount, "gateway must dispatch MinCount=2 on the per-node launch")
require.Equal(t, int64(2), gotMaxCount, "gateway must dispatch MaxCount=2 on the per-node launch")
}