From 1be52065384b5f44e1ba7e9736c7256aabfaff0a Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Mon, 20 Jul 2026 20:34:35 +1000 Subject: [PATCH] test(integration): migrate RunInstances multi-count expansion off live guests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/e2e/manifest-lint/baseline.txt | 1 - tests/e2e/single/lifecycle_test.go | 64 +---------------- tests/e2e/single/tests_test.go | 4 -- tests/integration/run_instances_test.go | 92 +++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 67 deletions(-) create mode 100644 tests/integration/run_instances_test.go diff --git a/tests/e2e/manifest-lint/baseline.txt b/tests/e2e/manifest-lint/baseline.txt index 33c416cd8..d859be4d3 100644 --- a/tests/e2e/manifest-lint/baseline.txt +++ b/tests/e2e/manifest-lint/baseline.txt @@ -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 diff --git a/tests/e2e/single/lifecycle_test.go b/tests/e2e/single/lifecycle_test.go index d528db116..88efe6b46 100644 --- a/tests/e2e/single/lifecycle_test.go +++ b/tests/e2e/single/lifecycle_test.go @@ -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) diff --git a/tests/e2e/single/tests_test.go b/tests/e2e/single/tests_test.go index f7c688f04..ebbc40d9a 100644 --- a/tests/e2e/single/tests_test.go +++ b/tests/e2e/single/tests_test.go @@ -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) { diff --git a/tests/integration/run_instances_test.go b/tests/integration/run_instances_test.go new file mode 100644 index 000000000..c578d06b7 --- /dev/null +++ b/tests/integration/run_instances_test.go @@ -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") +}