fix: replace fixed test waits with exact barriers and overridable deadlines

This commit is contained in:
Julian Sommer
2026-09-01 12:54:08 +10:00
parent 4d429f231b
commit 07dce613eb
25 changed files with 421 additions and 94 deletions
@@ -18,12 +18,14 @@ import (
// --- Validation tests ---
func TestValidateDescribeInstanceAttributeInput_NilInput(t *testing.T) {
t.Parallel()
err := ValidateDescribeInstanceAttributeInput(nil)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
}
func TestValidateDescribeInstanceAttributeInput_MissingInstanceId(t *testing.T) {
t.Parallel()
err := ValidateDescribeInstanceAttributeInput(&ec2.DescribeInstanceAttributeInput{
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
})
@@ -32,6 +34,7 @@ func TestValidateDescribeInstanceAttributeInput_MissingInstanceId(t *testing.T)
}
func TestValidateDescribeInstanceAttributeInput_EmptyInstanceId(t *testing.T) {
t.Parallel()
err := ValidateDescribeInstanceAttributeInput(&ec2.DescribeInstanceAttributeInput{
InstanceId: aws.String(""),
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
@@ -41,6 +44,7 @@ func TestValidateDescribeInstanceAttributeInput_EmptyInstanceId(t *testing.T) {
}
func TestValidateDescribeInstanceAttributeInput_BadPrefix(t *testing.T) {
t.Parallel()
err := ValidateDescribeInstanceAttributeInput(&ec2.DescribeInstanceAttributeInput{
InstanceId: aws.String("x-12345"),
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
@@ -50,6 +54,7 @@ func TestValidateDescribeInstanceAttributeInput_BadPrefix(t *testing.T) {
}
func TestValidateDescribeInstanceAttributeInput_MissingAttribute(t *testing.T) {
t.Parallel()
err := ValidateDescribeInstanceAttributeInput(&ec2.DescribeInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
})
@@ -58,6 +63,7 @@ func TestValidateDescribeInstanceAttributeInput_MissingAttribute(t *testing.T) {
}
func TestValidateDescribeInstanceAttributeInput_EmptyAttribute(t *testing.T) {
t.Parallel()
err := ValidateDescribeInstanceAttributeInput(&ec2.DescribeInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
Attribute: aws.String(""),
@@ -67,6 +73,7 @@ func TestValidateDescribeInstanceAttributeInput_EmptyAttribute(t *testing.T) {
}
func TestValidateDescribeInstanceAttributeInput_Valid(t *testing.T) {
t.Parallel()
err := ValidateDescribeInstanceAttributeInput(&ec2.DescribeInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
@@ -100,6 +107,7 @@ func respondWithError(code string) nats.MsgHandler {
}
func TestDescribeInstanceAttribute_SingleNode(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.DescribeInstanceAttribute", respondWithInstance(t, "i-test123", "t3.micro"))
@@ -125,6 +133,7 @@ func TestDescribeInstanceAttribute_SingleNode(t *testing.T) {
// ~50% of the time. Post-fix, the aggregator drops the NotFound and surfaces
// the success.
func TestDescribeInstanceAttribute_MultipleNodes_OwnerWins(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.DescribeInstanceAttribute", respondWithError(awserrors.ErrorInvalidInstanceIDNotFound))
@@ -154,6 +163,7 @@ func TestDescribeInstanceAttribute_MultipleNodes_OwnerWins(t *testing.T) {
// TestDescribeInstanceAttribute_AllNodesNotFound covers the genuine missing
// instance case: every daemon confirms the instance is absent.
func TestDescribeInstanceAttribute_AllNodesNotFound(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.DescribeInstanceAttribute", respondWithError(awserrors.ErrorInvalidInstanceIDNotFound))
@@ -182,6 +192,7 @@ func TestDescribeInstanceAttribute_AllNodesNotFound(t *testing.T) {
// validation-class error (deterministic across daemons) is surfaced to the
// caller rather than getting masked by NotFound.
func TestDescribeInstanceAttribute_ClientErrorPropagates(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.DescribeInstanceAttribute", respondWithError(awserrors.ErrorInvalidParameterValue))
@@ -203,6 +214,7 @@ func TestDescribeInstanceAttribute_ClientErrorPropagates(t *testing.T) {
// masked by sibling NotFound replies — otherwise terraform treats a live
// instance as deleted.
func TestDescribeInstanceAttribute_ServerErrorNotMaskedByNotFound(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.DescribeInstanceAttribute", respondWithError(awserrors.ErrorServerInternal))
@@ -230,6 +242,7 @@ func TestDescribeInstanceAttribute_ServerErrorNotMaskedByNotFound(t *testing.T)
// TestDescribeInstanceAttribute_NoResponders returns NotFound (not a NATS
// timeout) so terraform retries cleanly rather than hanging.
func TestDescribeInstanceAttribute_NoResponders(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
input := &ec2.DescribeInstanceAttributeInput{
@@ -248,6 +261,7 @@ func TestDescribeInstanceAttribute_NoResponders(t *testing.T) {
}
func TestDescribeInstanceAttribute_ValidationFailure(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := DescribeInstanceAttribute(context.Background(), nil, nc, 1, "123456789012")
@@ -11,6 +11,7 @@ import (
)
func TestDescribeInstanceCreditSpecifications_NilInput(t *testing.T) {
t.Parallel()
out, err := DescribeInstanceCreditSpecifications(nil)
require.Error(t, err)
assert.Nil(t, out)
@@ -18,12 +19,14 @@ func TestDescribeInstanceCreditSpecifications_NilInput(t *testing.T) {
}
func TestDescribeInstanceCreditSpecifications_EmptyInstanceIds(t *testing.T) {
t.Parallel()
out, err := DescribeInstanceCreditSpecifications(&ec2.DescribeInstanceCreditSpecificationsInput{})
require.NoError(t, err)
assert.Empty(t, out.InstanceCreditSpecifications)
}
func TestDescribeInstanceCreditSpecifications_SingleInstance(t *testing.T) {
t.Parallel()
out, err := DescribeInstanceCreditSpecifications(&ec2.DescribeInstanceCreditSpecificationsInput{
InstanceIds: []*string{aws.String("i-abc123")},
})
@@ -34,6 +37,7 @@ func TestDescribeInstanceCreditSpecifications_SingleInstance(t *testing.T) {
}
func TestDescribeInstanceCreditSpecifications_MultipleInstances(t *testing.T) {
t.Parallel()
out, err := DescribeInstanceCreditSpecifications(&ec2.DescribeInstanceCreditSpecificationsInput{
InstanceIds: []*string{aws.String("i-aaa"), aws.String("i-bbb"), aws.String("i-ccc")},
})
@@ -45,6 +49,7 @@ func TestDescribeInstanceCreditSpecifications_MultipleInstances(t *testing.T) {
}
func TestDescribeInstanceCreditSpecifications_SkipsNilAndEmpty(t *testing.T) {
t.Parallel()
out, err := DescribeInstanceCreditSpecifications(&ec2.DescribeInstanceCreditSpecificationsInput{
InstanceIds: []*string{nil, aws.String(""), aws.String("i-valid"), nil, aws.String("i-also")},
})
@@ -42,6 +42,7 @@ func respondJSON(t *testing.T, msg *nats.Msg, payload any) {
}
func TestDescribeInstanceStatus_SingleNode(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.DescribeInstanceStatus", func(msg *nats.Msg) {
@@ -60,6 +61,7 @@ func TestDescribeInstanceStatus_SingleNode(t *testing.T) {
}
func TestDescribeInstanceStatus_TwoNodesDedup(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// First node returns i-001 (running)
@@ -92,6 +94,7 @@ func TestDescribeInstanceStatus_TwoNodesDedup(t *testing.T) {
}
func TestDescribeInstanceStatus_OneNodeErrorOthersData(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Node 1: data
@@ -121,6 +124,7 @@ func TestDescribeInstanceStatus_OneNodeErrorOthersData(t *testing.T) {
}
func TestDescribeInstanceStatus_AllNodesError(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.DescribeInstanceStatus", func(msg *nats.Msg) {
@@ -134,6 +138,7 @@ func TestDescribeInstanceStatus_AllNodesError(t *testing.T) {
}
func TestDescribeInstanceStatus_AllNodesTimeoutReturnsEmpty(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
out, err := DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, nc, 0, "123456789012", "az-a")
@@ -143,6 +148,7 @@ func TestDescribeInstanceStatus_AllNodesTimeoutReturnsEmpty(t *testing.T) {
}
func TestDescribeInstanceStatus_IncludeAllAddsStoppedAsNotApplicable(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Fan-out responder: a running instance
@@ -192,6 +198,7 @@ func TestDescribeInstanceStatus_IncludeAllAddsStoppedAsNotApplicable(t *testing.
}
func TestDescribeInstanceStatus_RunningWinsOverStoppedDuringRace(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Fan-out: i-001 is running
@@ -225,6 +232,7 @@ func TestDescribeInstanceStatus_RunningWinsOverStoppedDuringRace(t *testing.T) {
}
func TestDescribeInstanceStatus_NilInput(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.DescribeInstanceStatus", func(msg *nats.Msg) {
@@ -239,6 +247,7 @@ func TestDescribeInstanceStatus_NilInput(t *testing.T) {
}
func TestDescribeInstanceStatus_ClosedConnection(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
closed, err := nats.Connect(nc.ConnectedUrl())
@@ -250,6 +259,7 @@ func TestDescribeInstanceStatus_ClosedConnection(t *testing.T) {
}
func TestBuildInstanceStatusFromInstance_PopulatesState(t *testing.T) {
t.Parallel()
in := &ec2.Instance{
InstanceId: aws.String("i-x"),
State: &ec2.InstanceState{Code: aws.Int64(80), Name: aws.String("stopped")},
@@ -265,6 +275,7 @@ func TestBuildInstanceStatusFromInstance_PopulatesState(t *testing.T) {
}
func TestBuildInstanceStatusFromInstance_FallbackStateName(t *testing.T) {
t.Parallel()
in := &ec2.Instance{InstanceId: aws.String("i-x")}
got := buildInstanceStatusFromInstance(in, "az-a")
assert.Equal(t, "stopped", *got.InstanceState.Name)
@@ -275,6 +286,7 @@ func TestBuildInstanceStatusFromInstance_FallbackStateName(t *testing.T) {
// because of the IncludeAllInstances field. The gateway must project to
// DescribeInstancesInput before publishing.
func TestDescribeInstanceStatus_IncludeAllProjectsInputForStoppedHandler(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.DescribeInstanceStatus", func(msg *nats.Msg) {
@@ -313,6 +325,7 @@ func TestDescribeInstanceStatus_IncludeAllProjectsInputForStoppedHandler(t *test
}
func TestStoppedCompatibleFilters_StripsStatusOnlyNames(t *testing.T) {
t.Parallel()
filters := []*ec2.Filter{
{Name: aws.String("availability-zone"), Values: []*string{aws.String("az-a")}},
{Name: aws.String("instance-state-code"), Values: []*string{aws.String("80")}},
@@ -326,11 +339,13 @@ func TestStoppedCompatibleFilters_StripsStatusOnlyNames(t *testing.T) {
}
func TestStoppedCompatibleFilters_Empty(t *testing.T) {
t.Parallel()
assert.Nil(t, stoppedCompatibleFilters(nil))
assert.Nil(t, stoppedCompatibleFilters([]*ec2.Filter{}))
}
func TestFilterStoppedStatuses_AvailabilityZoneMatch(t *testing.T) {
t.Parallel()
in := []*ec2.InstanceStatus{
{InstanceId: aws.String("i-1"), AvailabilityZone: aws.String("az-a"),
InstanceState: &ec2.InstanceState{Code: aws.Int64(80)}},
@@ -343,6 +358,7 @@ func TestFilterStoppedStatuses_AvailabilityZoneMatch(t *testing.T) {
}
func TestFilterStoppedStatuses_AvailabilityZoneMiss(t *testing.T) {
t.Parallel()
in := []*ec2.InstanceStatus{
{InstanceId: aws.String("i-1"), AvailabilityZone: aws.String("az-a"),
InstanceState: &ec2.InstanceState{Code: aws.Int64(80)}},
@@ -355,6 +371,7 @@ func TestFilterStoppedStatuses_AvailabilityZoneMiss(t *testing.T) {
}
func TestFilterStoppedStatuses_StateCodeMatch(t *testing.T) {
t.Parallel()
in := []*ec2.InstanceStatus{
{InstanceId: aws.String("i-1"), AvailabilityZone: aws.String("az-a"),
InstanceState: &ec2.InstanceState{Code: aws.Int64(80)}},
@@ -367,6 +384,7 @@ func TestFilterStoppedStatuses_StateCodeMatch(t *testing.T) {
}
func TestFilterStoppedStatuses_StateCodeMiss(t *testing.T) {
t.Parallel()
in := []*ec2.InstanceStatus{
{InstanceId: aws.String("i-1"), AvailabilityZone: aws.String("az-a"),
InstanceState: &ec2.InstanceState{Code: aws.Int64(80)}},
@@ -379,12 +397,14 @@ func TestFilterStoppedStatuses_StateCodeMiss(t *testing.T) {
}
func TestFilterStoppedStatuses_NoFiltersPassthrough(t *testing.T) {
t.Parallel()
in := []*ec2.InstanceStatus{{InstanceId: aws.String("i-1")}}
got := filterStoppedStatuses(in, nil, "az-a")
assert.Equal(t, in, got)
}
func TestFilterStoppedStatuses_UnknownFilterIgnored(t *testing.T) {
t.Parallel()
in := []*ec2.InstanceStatus{
{InstanceId: aws.String("i-1"), AvailabilityZone: aws.String("az-a"),
InstanceState: &ec2.InstanceState{Code: aws.Int64(80)}},
@@ -399,6 +419,7 @@ func TestFilterStoppedStatuses_UnknownFilterIgnored(t *testing.T) {
}
func TestDescribeInstanceStatus_IncludeAllWithAZFilterMatches(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.DescribeInstanceStatus", func(msg *nats.Msg) {
@@ -429,6 +450,7 @@ func TestDescribeInstanceStatus_IncludeAllWithAZFilterMatches(t *testing.T) {
}
func TestDescribeInstanceStatus_IncludeAllWithAZFilterMisses(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.DescribeInstanceStatus", func(msg *nats.Msg) {
@@ -458,6 +480,7 @@ func TestDescribeInstanceStatus_IncludeAllWithAZFilterMisses(t *testing.T) {
}
func TestDedupStatuses_FirstWins(t *testing.T) {
t.Parallel()
first := runningStatus("i-001", "az-a")
second := &ec2.InstanceStatus{
InstanceId: aws.String("i-001"),
@@ -14,6 +14,7 @@ import (
)
func TestDescribeInstanceTypes_SingleNode(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) {
@@ -37,6 +38,7 @@ func TestDescribeInstanceTypes_SingleNode(t *testing.T) {
}
func TestDescribeInstanceTypes_DeduplicatesAcrossNodes(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Node 1
@@ -86,6 +88,7 @@ func TestDescribeInstanceTypes_DeduplicatesAcrossNodes(t *testing.T) {
}
func TestDescribeInstanceTypes_CapacityFilterShowsDuplicates(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Node 1
@@ -133,6 +136,7 @@ func TestDescribeInstanceTypes_CapacityFilterShowsDuplicates(t *testing.T) {
}
func TestDescribeInstanceTypes_CapacityFilterFalseDeduplicates(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) {
@@ -162,6 +166,7 @@ func TestDescribeInstanceTypes_CapacityFilterFalseDeduplicates(t *testing.T) {
}
func TestDescribeInstanceTypes_NoSubscribers(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
input := &ec2.DescribeInstanceTypesInput{}
@@ -173,6 +178,7 @@ func TestDescribeInstanceTypes_NoSubscribers(t *testing.T) {
}
func TestDescribeInstanceTypes_NodeReturnsError(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) {
@@ -189,6 +195,7 @@ func TestDescribeInstanceTypes_NodeReturnsError(t *testing.T) {
}
func TestDescribeInstanceTypes_MalformedJSON(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) {
@@ -204,6 +211,7 @@ func TestDescribeInstanceTypes_MalformedJSON(t *testing.T) {
}
func TestDescribeInstanceTypes_NilInstanceTypeSkipped(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) {
@@ -227,6 +235,7 @@ func TestDescribeInstanceTypes_NilInstanceTypeSkipped(t *testing.T) {
}
func TestDescribeInstanceTypes_ClosedConnection(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
closedNC, err := nats.Connect(nc.ConnectedUrl())
@@ -17,6 +17,24 @@ import (
"github.com/nats-io/nats.go"
)
// defaultDescribeFanoutTimeout is the hard deadline the describe fan-out waits
// for node replies before treating the sweep as incomplete.
const defaultDescribeFanoutTimeout = 3 * time.Second
// describeConfig holds the tunable knobs of the describe fan-out.
type describeConfig struct {
fanoutTimeout time.Duration
}
// DescribeOption customises a describe fan-out. Production callers pass none
// and get defaultDescribeFanoutTimeout.
type DescribeOption func(*describeConfig)
// WithFanoutTimeout overrides the fan-out deadline.
func WithFanoutTimeout(d time.Duration) DescribeOption {
return func(c *describeConfig) { c.fanoutTimeout = d }
}
// DescribeInstances fans out to all nodes via NATS and aggregates the results.
// It is lenient: partial results from a slow or unreachable node are returned
// without error, and an explicitly named instance ID that is simply absent
@@ -28,8 +46,8 @@ import (
// which adds the InvalidInstanceID.NotFound assertion AWS callers expect.
// Callers that must not act on a partial view at all (the quota reconcile)
// use DescribeInstancesForReconcile.
func DescribeInstances(ctx context.Context, input *ec2.DescribeInstancesInput, natsConn *nats.Conn, expectedNodes int, accountID string) (*ec2.DescribeInstancesOutput, error) {
reservations, _, firstClient4xx, err := gatherInstances(ctx, input, natsConn, expectedNodes, accountID)
func DescribeInstances(ctx context.Context, input *ec2.DescribeInstancesInput, natsConn *nats.Conn, expectedNodes int, accountID string, opts ...DescribeOption) (*ec2.DescribeInstancesOutput, error) {
reservations, _, firstClient4xx, err := gatherInstances(ctx, input, natsConn, expectedNodes, accountID, opts...)
if err != nil {
return nil, err
}
@@ -49,8 +67,8 @@ func DescribeInstances(ctx context.Context, input *ec2.DescribeInstancesInput, n
// fan-out must never turn into a false NotFound for an instance that actually
// exists on that node. A --filters-only query (no explicit IDs) is never
// affected and keeps returning an empty list with no error.
func DescribeInstancesChecked(ctx context.Context, input *ec2.DescribeInstancesInput, natsConn *nats.Conn, expectedNodes int, accountID string) (*ec2.DescribeInstancesOutput, error) {
reservations, complete, firstClient4xx, err := gatherInstances(ctx, input, natsConn, expectedNodes, accountID)
func DescribeInstancesChecked(ctx context.Context, input *ec2.DescribeInstancesInput, natsConn *nats.Conn, expectedNodes int, accountID string, opts ...DescribeOption) (*ec2.DescribeInstancesOutput, error) {
reservations, complete, firstClient4xx, err := gatherInstances(ctx, input, natsConn, expectedNodes, accountID, opts...)
if err != nil {
return nil, err
}
@@ -95,7 +113,12 @@ func DescribeInstancesForReconcile(ctx context.Context, input *ec2.DescribeInsta
// both bucket queries succeeded — the precondition reconcile needs before it may
// lower a counter. firstClient4xx carries the first deterministic 4xx for the
// lenient caller to surface when nothing was collected.
func gatherInstances(ctx context.Context, input *ec2.DescribeInstancesInput, natsConn *nats.Conn, expectedNodes int, accountID string) (reservations []*ec2.Reservation, complete bool, firstClient4xx string, err error) {
func gatherInstances(ctx context.Context, input *ec2.DescribeInstancesInput, natsConn *nats.Conn, expectedNodes int, accountID string, opts ...DescribeOption) (reservations []*ec2.Reservation, complete bool, firstClient4xx string, err error) {
cfg := describeConfig{fanoutTimeout: defaultDescribeFanoutTimeout}
for _, opt := range opts {
opt(&cfg)
}
jsonData, err := json.Marshal(input)
if err != nil {
slog.ErrorContext(ctx, "DescribeInstances: Failed to marshal input", "err", err)
@@ -103,7 +126,7 @@ func gatherInstances(ctx context.Context, input *ec2.DescribeInstancesInput, nat
}
frames, sum, err := utils.Gather(ctx, natsConn, "ec2.DescribeInstances", jsonData,
utils.GatherOpts{Timeout: 3 * time.Second, ExpectedNodes: expectedNodes, AccountID: accountID})
utils.GatherOpts{Timeout: cfg.fanoutTimeout, ExpectedNodes: expectedNodes, AccountID: accountID})
if err != nil {
return nil, false, "", err
}
@@ -18,6 +18,7 @@ import (
)
func TestDescribeInstances_SingleNode(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
reservation := &ec2.Reservation{
@@ -50,6 +51,7 @@ func TestDescribeInstances_SingleNode(t *testing.T) {
}
func TestDescribeInstances_MultipleNodes(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Two nodes each return different instances
@@ -99,6 +101,7 @@ func TestDescribeInstances_MultipleNodes(t *testing.T) {
}
func TestDescribeInstances_NoSubscribers(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
input := &ec2.DescribeInstancesInput{}
@@ -111,6 +114,7 @@ func TestDescribeInstances_NoSubscribers(t *testing.T) {
}
func TestDescribeInstances_NodeReturnsError(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) {
@@ -128,6 +132,7 @@ func TestDescribeInstances_NodeReturnsError(t *testing.T) {
}
func TestDescribeInstances_MixedResponses(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Node 1: returns valid data
@@ -167,6 +172,7 @@ func TestDescribeInstances_MixedResponses(t *testing.T) {
}
func TestDescribeInstances_MalformedJSON(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) {
@@ -183,6 +189,7 @@ func TestDescribeInstances_MalformedJSON(t *testing.T) {
}
func TestDescribeInstances_EmptyReservations(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) {
@@ -201,11 +208,12 @@ func TestDescribeInstances_EmptyReservations(t *testing.T) {
}
func TestDescribeInstances_TimeoutCollection(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Node responds after a delay (but within timeout)
nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) {
time.Sleep(500 * time.Millisecond)
time.Sleep(50 * time.Millisecond)
data, _ := json.Marshal(&ec2.DescribeInstancesOutput{
Reservations: []*ec2.Reservation{
{ReservationId: aws.String("r-delayed")},
@@ -215,7 +223,9 @@ func TestDescribeInstances_TimeoutCollection(t *testing.T) {
})
input := &ec2.DescribeInstancesInput{}
output, err := DescribeInstances(context.Background(), input, nc, 0, "123456789012") // 0 = timeout-based collection
// 0 expected nodes = timeout-based collection: the whole window is waited out.
output, err := DescribeInstances(context.Background(), input, nc, 0, "123456789012",
WithFanoutTimeout(300*time.Millisecond))
require.NoError(t, err)
require.NotNil(t, output)
@@ -223,6 +233,7 @@ func TestDescribeInstances_TimeoutCollection(t *testing.T) {
}
func TestDescribeInstances_EarlyExitWithExpectedNodes(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) {
@@ -267,6 +278,7 @@ func subscribeEmptyInstanceBuckets(t *testing.T, nc *nats.Conn) {
// fully-answered fan-out returns InvalidInstanceID.NotFound instead of a
// silent empty list.
func TestDescribeInstancesChecked_ExplicitIDNotFound_CompleteSweep(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
subscribeEmptyInstanceBuckets(t, nc)
@@ -297,6 +309,7 @@ func TestDescribeInstancesChecked_ExplicitIDNotFound_CompleteSweep(t *testing.T)
// meaning an empty list, not an error. Only DescribeInstancesChecked — used
// solely by the gateway's customer-facing action — may assert NotFound.
func TestDescribeInstances_ExplicitIDNotFound_StaysSilent(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
subscribeEmptyInstanceBuckets(t, nc)
@@ -321,6 +334,7 @@ func TestDescribeInstances_ExplicitIDNotFound_StaysSilent(t *testing.T) {
// counterpart: the same complete sweep, but the requested ID is present, so
// no error is raised.
func TestDescribeInstancesChecked_ExplicitIDFound_CompleteSweep(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
subscribeEmptyInstanceBuckets(t, nc)
@@ -350,6 +364,7 @@ func TestDescribeInstancesChecked_ExplicitIDFound_CompleteSweep(t *testing.T) {
// must still return rc=0 with an empty list, never NotFound (no instance IDs
// were named).
func TestDescribeInstancesChecked_FilterOnlyNoMatch_ReturnsEmptyNotNotFound(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
subscribeEmptyInstanceBuckets(t, nc)
@@ -376,11 +391,12 @@ func TestDescribeInstancesChecked_FilterOnlyNoMatch_ReturnsEmptyNotNotFound(t *t
// sweep is provably incomplete and the naive found-set check must be
// suppressed.
func TestDescribeInstancesChecked_ExplicitIDNotFound_NodeTimeout(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
subscribeEmptyInstanceBuckets(t, nc)
// Only one of the two expected nodes has a subscriber; the second never
// answers, so the fan-out hits its 3s deadline with sum.TimedOut=true.
// answers, so the fan-out hits its deadline with sum.TimedOut=true.
_, err := nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) {
data, _ := json.Marshal(&ec2.DescribeInstancesOutput{Reservations: []*ec2.Reservation{}})
msg.Respond(data)
@@ -389,7 +405,8 @@ func TestDescribeInstancesChecked_ExplicitIDNotFound_NodeTimeout(t *testing.T) {
nc.Flush()
input := &ec2.DescribeInstancesInput{InstanceIds: []*string{aws.String("i-on-the-slow-node")}}
output, err := DescribeInstancesChecked(context.Background(), input, nc, 2, "123456789012")
output, err := DescribeInstancesChecked(context.Background(), input, nc, 2, "123456789012",
WithFanoutTimeout(300*time.Millisecond))
require.NoError(t, err, "an incomplete sweep must never assert a false NotFound")
require.NotNil(t, output)
@@ -397,6 +414,7 @@ func TestDescribeInstancesChecked_ExplicitIDNotFound_NodeTimeout(t *testing.T) {
}
func TestDescribeInstances_ClosedConnection(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
closedNC, err := nats.Connect(nc.ConnectedUrl())
@@ -427,6 +445,7 @@ func describeOutputWithProfiles(arns ...string) *ec2.DescribeInstancesOutput {
}
func TestEnrichInstanceProfileIDs_CachesPerARN(t *testing.T) {
t.Parallel()
arn := "arn:aws:iam::123456789012:instance-profile/shared"
var calls int
svc := &fakeIAMService{
@@ -446,6 +465,7 @@ func TestEnrichInstanceProfileIDs_CachesPerARN(t *testing.T) {
}
func TestEnrichInstanceProfileIDs_ResolveErrorLeavesIdEmpty(t *testing.T) {
t.Parallel()
failingARN := "arn:aws:iam::123456789012:instance-profile/missing"
okARN := "arn:aws:iam::123456789012:instance-profile/present"
svc := &fakeIAMService{
@@ -467,6 +487,7 @@ func TestEnrichInstanceProfileIDs_ResolveErrorLeavesIdEmpty(t *testing.T) {
}
func TestEnrichInstanceProfileIDs_NoOpInputs(t *testing.T) {
t.Parallel()
// Should not panic on nil out or nil iamSvc, and instances without a
// profile attached are untouched.
EnrichInstanceProfileIDs(nil, &fakeIAMService{}, "123456789012")
@@ -26,6 +26,7 @@ func shrinkPasswordDataTimeout(t *testing.T) {
}
func TestValidateGetPasswordDataInput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input *ec2.GetPasswordDataInput
@@ -66,6 +67,7 @@ func TestValidateGetPasswordDataInput(t *testing.T) {
}
func TestGetPasswordData_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-0123456789abcdef0"
@@ -108,6 +110,7 @@ func TestGetPasswordData_Success(t *testing.T) {
}
func TestGetPasswordData_NoResponders(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// No subscriber on the topic: an unknown/stopped/terminated instance.
@@ -140,6 +143,7 @@ func TestGetPasswordData_Timeout(t *testing.T) {
}
func TestGetPasswordData_DaemonError(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-error"
@@ -158,6 +162,7 @@ func TestGetPasswordData_DaemonError(t *testing.T) {
}
func TestGetPasswordData_MalformedResponse(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-malformed"
@@ -76,6 +76,7 @@ func profileNoRole() *handlers_iam.InstanceProfile {
// --- helper-level tests --------------------------------------------------
func TestResolveAndAuthorizeProfile_NameForm(t *testing.T) {
t.Parallel()
gotNameOrARN := ""
svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
gotNameOrARN = nameOrARN
@@ -99,6 +100,7 @@ func TestResolveAndAuthorizeProfile_NameForm(t *testing.T) {
}
func TestResolveAndAuthorizeProfile_ArnForm(t *testing.T) {
t.Parallel()
gotNameOrARN := ""
svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
gotNameOrARN = nameOrARN
@@ -112,6 +114,7 @@ func TestResolveAndAuthorizeProfile_ArnForm(t *testing.T) {
}
func TestResolveAndAuthorizeProfile_MissingNameAndArn(t *testing.T) {
t.Parallel()
svc := &fakeIAMService{}
_, err := resolveAndAuthorizeProfile(
&ec2.IamInstanceProfileSpecification{},
@@ -121,6 +124,7 @@ func TestResolveAndAuthorizeProfile_MissingNameAndArn(t *testing.T) {
}
func TestResolveAndAuthorizeProfile_NilIAMService(t *testing.T) {
t.Parallel()
_, err := resolveAndAuthorizeProfile(
&ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)},
nil, testGwAccountID, nil)
@@ -129,6 +133,7 @@ func TestResolveAndAuthorizeProfile_NilIAMService(t *testing.T) {
}
func TestResolveAndAuthorizeProfile_NotFoundMapsToEC2Error(t *testing.T) {
t.Parallel()
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
}}
@@ -141,6 +146,7 @@ func TestResolveAndAuthorizeProfile_NotFoundMapsToEC2Error(t *testing.T) {
}
func TestResolveAndAuthorizeProfile_PassthroughOtherIAMError(t *testing.T) {
t.Parallel()
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return nil, errors.New(awserrors.ErrorAccessDenied)
}}
@@ -153,6 +159,7 @@ func TestResolveAndAuthorizeProfile_PassthroughOtherIAMError(t *testing.T) {
}
func TestResolveAndAuthorizeProfile_PassRoleDenied(t *testing.T) {
t.Parallel()
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return profileWithRole(), nil
}}
@@ -165,6 +172,7 @@ func TestResolveAndAuthorizeProfile_PassRoleDenied(t *testing.T) {
}
func TestResolveAndAuthorizeProfile_NoRoleSkipsPassRole(t *testing.T) {
t.Parallel()
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return profileNoRole(), nil
}}
@@ -183,6 +191,7 @@ func TestResolveAndAuthorizeProfile_NoRoleSkipsPassRole(t *testing.T) {
}
func TestEnrichProfileID_FillsIdFromResolvedProfile(t *testing.T) {
t.Parallel()
assoc := &ec2.IamInstanceProfileAssociation{
IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNApp)},
}
@@ -193,10 +202,12 @@ func TestEnrichProfileID_FillsIdFromResolvedProfile(t *testing.T) {
}
func TestEnrichProfileID_NilAssociationIsNoOp(t *testing.T) {
t.Parallel()
enrichProfileID(nil, profileWithRole()) // must not panic
}
func TestEnrichProfileID_NilInnerProfileIsAllocated(t *testing.T) {
t.Parallel()
assoc := &ec2.IamInstanceProfileAssociation{}
enrichProfileID(assoc, profileWithRole())
require.NotNil(t, assoc.IamInstanceProfile)
@@ -206,12 +217,14 @@ func TestEnrichProfileID_NilInnerProfileIsAllocated(t *testing.T) {
// --- AssociateIamInstanceProfile ---------------------------------------------
func TestAssociateIamInstanceProfile_NilInput(t *testing.T) {
t.Parallel()
_, err := AssociateIamInstanceProfile(context.Background(), nil, nil, &fakeIAMService{}, testGwAccountID, nil)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
}
func TestAssociateIamInstanceProfile_MissingInstanceID(t *testing.T) {
t.Parallel()
in := &ec2.AssociateIamInstanceProfileInput{
IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)},
}
@@ -221,6 +234,7 @@ func TestAssociateIamInstanceProfile_MissingInstanceID(t *testing.T) {
}
func TestAssociateIamInstanceProfile_MissingProfileSpec(t *testing.T) {
t.Parallel()
in := &ec2.AssociateIamInstanceProfileInput{InstanceId: aws.String("i-001")}
_, err := AssociateIamInstanceProfile(context.Background(), in, nil, &fakeIAMService{}, testGwAccountID, nil)
require.Error(t, err)
@@ -228,6 +242,7 @@ func TestAssociateIamInstanceProfile_MissingProfileSpec(t *testing.T) {
}
func TestAssociateIamInstanceProfile_ProfileNotFound(t *testing.T) {
t.Parallel()
svc := &fakeIAMService{} // default resolve → NoSuchEntity
in := &ec2.AssociateIamInstanceProfileInput{
InstanceId: aws.String("i-001"),
@@ -239,6 +254,7 @@ func TestAssociateIamInstanceProfile_ProfileNotFound(t *testing.T) {
}
func TestAssociateIamInstanceProfile_PassRoleDenied(t *testing.T) {
t.Parallel()
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return profileWithRole(), nil
}}
@@ -253,6 +269,7 @@ func TestAssociateIamInstanceProfile_PassRoleDenied(t *testing.T) {
}
func TestAssociateIamInstanceProfile_NoResponders(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return profileNoRole(), nil
@@ -270,6 +287,7 @@ func TestAssociateIamInstanceProfile_NoResponders(t *testing.T) {
}
func TestAssociateIamInstanceProfile_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return profileWithRole(), nil
@@ -315,6 +333,7 @@ func TestAssociateIamInstanceProfile_Success(t *testing.T) {
}
func TestAssociateIamInstanceProfile_DaemonAlreadyAssociated(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return profileNoRole(), nil
@@ -336,18 +355,21 @@ func TestAssociateIamInstanceProfile_DaemonAlreadyAssociated(t *testing.T) {
// --- DisassociateIamInstanceProfile -----------------------------------------
func TestDisassociateIamInstanceProfile_MissingAssociationID(t *testing.T) {
t.Parallel()
_, err := DisassociateIamInstanceProfile(context.Background(), &ec2.DisassociateIamInstanceProfileInput{}, nil, 0, testGwAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
}
func TestDisassociateIamInstanceProfile_NilInput(t *testing.T) {
t.Parallel()
_, err := DisassociateIamInstanceProfile(context.Background(), nil, nil, 0, testGwAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
}
func TestDisassociateIamInstanceProfile_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
const assocID = "iip-assoc-disassoc00000001"
const instanceID = "i-dis-success"
@@ -396,6 +418,7 @@ func TestDisassociateIamInstanceProfile_Success(t *testing.T) {
}
func TestDisassociateIamInstanceProfile_NoSuchAssociation(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Every daemon NoOps with JSON null — gateway must surface
// NoSuchAssociation, not timeout.
@@ -414,12 +437,14 @@ func TestDisassociateIamInstanceProfile_NoSuchAssociation(t *testing.T) {
// --- ReplaceIamInstanceProfileAssociation -----------------------------------
func TestReplaceIamInstanceProfileAssociation_NilInput(t *testing.T) {
t.Parallel()
_, err := ReplaceIamInstanceProfileAssociation(context.Background(), nil, nil, &fakeIAMService{}, 0, testGwAccountID, nil)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
}
func TestReplaceIamInstanceProfileAssociation_MissingAssociationID(t *testing.T) {
t.Parallel()
in := &ec2.ReplaceIamInstanceProfileAssociationInput{
IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)},
}
@@ -429,6 +454,7 @@ func TestReplaceIamInstanceProfileAssociation_MissingAssociationID(t *testing.T)
}
func TestReplaceIamInstanceProfileAssociation_MissingProfileSpec(t *testing.T) {
t.Parallel()
in := &ec2.ReplaceIamInstanceProfileAssociationInput{AssociationId: aws.String("iip-assoc-x")}
_, err := ReplaceIamInstanceProfileAssociation(context.Background(), in, nil, &fakeIAMService{}, 0, testGwAccountID, nil)
require.Error(t, err)
@@ -436,6 +462,7 @@ func TestReplaceIamInstanceProfileAssociation_MissingProfileSpec(t *testing.T) {
}
func TestReplaceIamInstanceProfileAssociation_PassRoleDeniedBeforeBroadcast(t *testing.T) {
t.Parallel()
// nc=nil proves the call is rejected before any NATS dispatch.
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return profileWithRole(), nil
@@ -450,6 +477,7 @@ func TestReplaceIamInstanceProfileAssociation_PassRoleDeniedBeforeBroadcast(t *t
}
func TestReplaceIamInstanceProfileAssociation_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return profileWithRole(), nil
@@ -490,6 +518,7 @@ func TestReplaceIamInstanceProfileAssociation_Success(t *testing.T) {
}
func TestReplaceIamInstanceProfileAssociation_NoSuchAssociation(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return profileNoRole(), nil
@@ -510,6 +539,7 @@ func TestReplaceIamInstanceProfileAssociation_NoSuchAssociation(t *testing.T) {
// --- DescribeIamInstanceProfileAssociations ---------------------------------
func TestDescribeIamInstanceProfileAssociations_FanOutAggregates(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Two daemons each return one record; the gateway must concatenate them.
_, err := nc.Subscribe("ec2.IamProfileAssociation.describe", func(msg *nats.Msg) {
@@ -550,6 +580,7 @@ func TestDescribeIamInstanceProfileAssociations_FanOutAggregates(t *testing.T) {
}
func TestDescribeIamInstanceProfileAssociations_ForwardsFilters(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
var gotInput ec2.DescribeIamInstanceProfileAssociationsInput
_, err := nc.Subscribe("ec2.IamProfileAssociation.describe", func(msg *nats.Msg) {
@@ -575,6 +606,7 @@ func TestDescribeIamInstanceProfileAssociations_ForwardsFilters(t *testing.T) {
}
func TestDescribeIamInstanceProfileAssociations_InvalidFilterName(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := DescribeIamInstanceProfileAssociations(context.Background(), &ec2.DescribeIamInstanceProfileAssociationsInput{
Filters: []*ec2.Filter{{Name: aws.String("not-a-valid-filter")}},
@@ -584,6 +616,7 @@ func TestDescribeIamInstanceProfileAssociations_InvalidFilterName(t *testing.T)
}
func TestDescribeIamInstanceProfileAssociations_EmptyResultIsValid(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.IamProfileAssociation.describe", func(msg *nats.Msg) {
resp, _ := json.Marshal(&ec2.DescribeIamInstanceProfileAssociationsOutput{})
@@ -599,6 +632,7 @@ func TestDescribeIamInstanceProfileAssociations_EmptyResultIsValid(t *testing.T)
// --- CountInstanceProfileAssociations ---------------------------------------
func TestCountInstanceProfileAssociations_MatchesByARN(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.IamProfileAssociation.describe", func(msg *nats.Msg) {
resp, _ := json.Marshal(&ec2.DescribeIamInstanceProfileAssociationsOutput{
@@ -618,6 +652,7 @@ func TestCountInstanceProfileAssociations_MatchesByARN(t *testing.T) {
}
func TestCountInstanceProfileAssociations_NoMatches(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := nc.Subscribe("ec2.IamProfileAssociation.describe", func(msg *nats.Msg) {
resp, _ := json.Marshal(&ec2.DescribeIamInstanceProfileAssociationsOutput{
@@ -640,6 +675,7 @@ func TestCountInstanceProfileAssociations_NoMatches(t *testing.T) {
// (which produces it) and clients (who parse it via SDKs); regressions in the
// utils generator would break replay parsing of Disassociate/Replace.
func TestAssociationIDFormat(t *testing.T) {
t.Parallel()
id := utils.GenerateResourceID("iip-assoc")
assert.True(t, strings.HasPrefix(id, "iip-assoc-"),
"GenerateResourceID must preserve the AWS-style hyphen between prefix and suffix")
@@ -16,18 +16,21 @@ import (
// --- Validation tests ---
func TestValidateModifyInstanceAttributeInput_NilInput(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(nil)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
}
func TestValidateModifyInstanceAttributeInput_MissingInstanceId(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error())
}
func TestValidateModifyInstanceAttributeInput_EmptyInstanceId(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String(""),
})
@@ -36,6 +39,7 @@ func TestValidateModifyInstanceAttributeInput_EmptyInstanceId(t *testing.T) {
}
func TestValidateModifyInstanceAttributeInput_BadPrefix(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("x-12345"),
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.micro")},
@@ -45,6 +49,7 @@ func TestValidateModifyInstanceAttributeInput_BadPrefix(t *testing.T) {
}
func TestValidateModifyInstanceAttributeInput_NoAttributeSet(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
})
@@ -53,6 +58,7 @@ func TestValidateModifyInstanceAttributeInput_NoAttributeSet(t *testing.T) {
}
func TestValidateModifyInstanceAttributeInput_MultipleAttributes(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.micro")},
@@ -63,6 +69,7 @@ func TestValidateModifyInstanceAttributeInput_MultipleAttributes(t *testing.T) {
}
func TestValidateModifyInstanceAttributeInput_EmptyInstanceType(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
InstanceType: &ec2.AttributeValue{Value: aws.String("")},
@@ -72,6 +79,7 @@ func TestValidateModifyInstanceAttributeInput_EmptyInstanceType(t *testing.T) {
}
func TestValidateModifyInstanceAttributeInput_NilInstanceTypeValue(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
InstanceType: &ec2.AttributeValue{},
@@ -81,6 +89,7 @@ func TestValidateModifyInstanceAttributeInput_NilInstanceTypeValue(t *testing.T)
}
func TestValidateModifyInstanceAttributeInput_ValidInstanceType(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.medium")},
@@ -89,6 +98,7 @@ func TestValidateModifyInstanceAttributeInput_ValidInstanceType(t *testing.T) {
}
func TestValidateModifyInstanceAttributeInput_ValidUserData(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
UserData: &ec2.BlobAttributeValue{Value: []byte("IyEvYmluL2Jhc2g=")},
@@ -97,6 +107,7 @@ func TestValidateModifyInstanceAttributeInput_ValidUserData(t *testing.T) {
}
func TestValidateModifyInstanceAttributeInput_ValidDisableApiTermination(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
DisableApiTermination: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
@@ -105,6 +116,7 @@ func TestValidateModifyInstanceAttributeInput_ValidDisableApiTermination(t *test
}
func TestValidateModifyInstanceAttributeInput_DisableApiTerminationWithOther(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
DisableApiTermination: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
@@ -115,6 +127,7 @@ func TestValidateModifyInstanceAttributeInput_DisableApiTerminationWithOther(t *
}
func TestValidateModifyInstanceAttributeInput_ValidSourceDestCheck(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
SourceDestCheck: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
@@ -123,6 +136,7 @@ func TestValidateModifyInstanceAttributeInput_ValidSourceDestCheck(t *testing.T)
}
func TestValidateModifyInstanceAttributeInput_SourceDestCheckWithOtherAttribute(t *testing.T) {
t.Parallel()
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
SourceDestCheck: &ec2.AttributeBooleanValue{Value: aws.Bool(false)},
@@ -135,6 +149,7 @@ func TestValidateModifyInstanceAttributeInput_SourceDestCheckWithOtherAttribute(
// --- Gateway function tests ---
func TestModifyInstanceAttribute_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
nc.QueueSubscribe("ec2.ModifyInstanceAttribute", "spinifex-workers", func(msg *nats.Msg) {
@@ -155,6 +170,7 @@ func TestModifyInstanceAttribute_Success(t *testing.T) {
}
func TestModifyInstanceAttribute_SourceDestCheck(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
nc.QueueSubscribe("ec2.ModifyInstanceAttribute", "spinifex-workers", func(msg *nats.Msg) {
@@ -171,6 +187,7 @@ func TestModifyInstanceAttribute_SourceDestCheck(t *testing.T) {
}
func TestModifyInstanceAttribute_DaemonError(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
nc.QueueSubscribe("ec2.ModifyInstanceAttribute", "spinifex-workers", func(msg *nats.Msg) {
@@ -188,6 +205,7 @@ func TestModifyInstanceAttribute_DaemonError(t *testing.T) {
}
func TestModifyInstanceAttribute_ValidationFailure(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := ModifyInstanceAttribute(context.Background(), nil, nc, "123456789012")
@@ -14,6 +14,7 @@ import (
// A nil, empty, or non-"i-" instance ID is rejected at the gateway before any
// NATS round-trip, so a nil connection is never dereferenced.
func TestModifyInstanceMetadataOptions_RejectsMalformedID(t *testing.T) {
t.Parallel()
cases := map[string]*ec2.ModifyInstanceMetadataOptionsInput{
"nil input": nil,
"nil id": {},
@@ -39,6 +39,7 @@ func serveMonitoring(t *testing.T, nc *nats.Conn, subject, state string, ids ...
}
func TestMonitorInstances_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
seen := serveMonitoring(t, nc, "ec2.MonitorInstances", ec2.MonitoringStateEnabled, "i-0123456789abcdef0")
@@ -53,6 +54,7 @@ func TestMonitorInstances_Success(t *testing.T) {
}
func TestUnmonitorInstances_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
seen := serveMonitoring(t, nc, "ec2.UnmonitorInstances", ec2.MonitoringStateDisabled, "i-0123456789abcdef0")
@@ -69,6 +71,7 @@ func TestUnmonitorInstances_Success(t *testing.T) {
// The two actions must not share a subject, or disabling monitoring would
// enable it.
func TestMonitorInstances_DistinctSubjects(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
serveMonitoring(t, nc, "ec2.MonitorInstances", ec2.MonitoringStateEnabled, "i-0123456789abcdef0")
@@ -79,6 +82,7 @@ func TestMonitorInstances_DistinctSubjects(t *testing.T) {
}
func TestMonitorInstances_InvalidInput(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
tests := []struct {
@@ -107,6 +111,7 @@ func TestMonitorInstances_InvalidInput(t *testing.T) {
}
func TestMonitorInstances_NilInput(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := MonitorInstances(context.Background(), nil, nc, "123456789012")
@@ -16,6 +16,7 @@ import (
)
func TestRebootInstances_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-0123456789abcdef0"
@@ -42,6 +43,7 @@ func TestRebootInstances_Success(t *testing.T) {
}
func TestRebootInstances_MultipleInstances(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
ids := []string{"i-001", "i-002"}
@@ -63,6 +65,7 @@ func TestRebootInstances_MultipleInstances(t *testing.T) {
}
func TestRebootInstances_EmptyInstanceIds(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
input := &ec2.RebootInstancesInput{
@@ -75,6 +78,7 @@ func TestRebootInstances_EmptyInstanceIds(t *testing.T) {
}
func TestRebootInstances_NATSRequestFails(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
input := &ec2.RebootInstancesInput{
@@ -87,6 +91,7 @@ func TestRebootInstances_NATSRequestFails(t *testing.T) {
}
func TestRebootInstances_StoppedInstance(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-stopped"
@@ -113,6 +118,7 @@ func TestRebootInstances_StoppedInstance(t *testing.T) {
}
func TestRebootInstances_DaemonError(t *testing.T) {
t.Parallel()
for _, code := range []string{
awserrors.ErrorIncorrectInstanceState,
awserrors.ErrorServerInternal,
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
@@ -29,6 +28,7 @@ func crTargetedInput(crID string) *ec2.RunInstancesInput {
}
func TestCapacityReservationTargetID(t *testing.T) {
t.Parallel()
assert.Empty(t, capacityReservationTargetID(&ec2.RunInstancesInput{}), "no spec → general path")
assert.Empty(t, capacityReservationTargetID(&ec2.RunInstancesInput{
CapacityReservationSpecification: &ec2.CapacityReservationSpecification{
@@ -40,6 +40,7 @@ func TestCapacityReservationTargetID(t *testing.T) {
// A malformed reservation id is rejected at the gateway before any NATS call.
func TestRunInstancesInner_TargetedMalformedID(t *testing.T) {
t.Parallel()
_, err := runInstancesInner(context.Background(), crTargetedInput("bogus-id"), nil, nil, "123456789012", nil, nil, 1)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidCapacityReservationIdMalformed, err.Error())
@@ -47,6 +48,7 @@ func TestRunInstancesInner_TargetedMalformedID(t *testing.T) {
// The placement-group + capacity-reservation combination is rejected.
func TestRunInstancesInner_TargetedWithPlacementGroup(t *testing.T) {
t.Parallel()
input := crTargetedInput(testCRID)
input.Placement = &ec2.Placement{GroupName: aws.String("pg-cluster")}
_, err := runInstancesInner(context.Background(), input, nil, nil, "123456789012", nil, nil, 1)
@@ -57,6 +59,7 @@ func TestRunInstancesInner_TargetedWithPlacementGroup(t *testing.T) {
// No responder on the cr-subject (cancelled / restarted reservation) maps the
// transport-level ErrNoResponders to InvalidCapacityReservationId.NotFound.
func TestRunIntoReservation_NoResponder(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
_, err := runIntoReservation(context.Background(), crTargetedInput(testCRID), nc, "123456789012", testCRID)
require.Error(t, err)
@@ -66,6 +69,7 @@ func TestRunIntoReservation_NoResponder(t *testing.T) {
// A targeted launch routes to ec2.RunInstances.cr.<crID> and returns the owning
// daemon's reservation verbatim.
func TestRunIntoReservation_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
sub, err := nc.Subscribe("ec2.RunInstances.cr."+testCRID, func(msg *nats.Msg) {
@@ -77,7 +81,7 @@ func TestRunIntoReservation_Success(t *testing.T) {
})
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
reservation, err := runIntoReservation(context.Background(), crTargetedInput(testCRID), nc, "123456789012", testCRID)
require.NoError(t, err)
@@ -88,6 +92,7 @@ func TestRunIntoReservation_Success(t *testing.T) {
// A semantic error from the owning daemon (e.g. a full reservation) rides back
// as its awserror code, not the generic NotFound.
func TestRunIntoReservation_DaemonErrorPropagates(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
sub, err := nc.Subscribe("ec2.RunInstances.cr."+testCRID, func(msg *nats.Msg) {
@@ -95,7 +100,7 @@ func TestRunIntoReservation_DaemonErrorPropagates(t *testing.T) {
})
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
_, err = runIntoReservation(context.Background(), crTargetedInput(testCRID), nc, "123456789012", testCRID)
require.Error(t, err)
@@ -105,6 +110,7 @@ func TestRunIntoReservation_DaemonErrorPropagates(t *testing.T) {
// The full runInstancesInner branch routes a targeted launch to the cr-subject
// (not the general distribute path) and enriches the result.
func TestRunInstancesInner_TargetedRoutesToCRSubject(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
sub, err := nc.Subscribe("ec2.RunInstances.cr."+testCRID, func(msg *nats.Msg) {
@@ -116,7 +122,7 @@ func TestRunInstancesInner_TargetedRoutesToCRSubject(t *testing.T) {
})
require.NoError(t, err)
defer func() { _ = sub.Unsubscribe() }()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
reservation, err := runInstancesInner(context.Background(), crTargetedInput(testCRID), nc, nil, "123456789012", nil, nil, 1)
require.NoError(t, err)
@@ -26,6 +26,7 @@ var defaults = ec2.RunInstancesInput{
}
func TestParseRunInstances(t *testing.T) {
t.Parallel()
// Group multiple tests
tests := []struct {
name string
@@ -231,6 +232,7 @@ func runValidInputWithProfile(spec *ec2.IamInstanceProfileSpecification) *ec2.Ru
}
func TestResolveAndAuthorizeInstanceProfile_Absent(t *testing.T) {
t.Parallel()
// No IamInstanceProfile on the input → no resolution, no PassRole check.
in := &ec2.RunInstancesInput{} // empty
profile, err := resolveAndAuthorizeInstanceProfile(in, nil, testGwAccountID, nil)
@@ -239,6 +241,7 @@ func TestResolveAndAuthorizeInstanceProfile_Absent(t *testing.T) {
}
func TestResolveAndAuthorizeInstanceProfile_NameForm_NormalisesToARN(t *testing.T) {
t.Parallel()
svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
assert.Equal(t, testProfileNameApp, nameOrARN)
return profileWithRole(), nil
@@ -257,6 +260,7 @@ func TestResolveAndAuthorizeInstanceProfile_NameForm_NormalisesToARN(t *testing.
}
func TestResolveAndAuthorizeInstanceProfile_ArnForm(t *testing.T) {
t.Parallel()
svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
assert.Equal(t, testProfileARNApp, nameOrARN)
return profileWithRole(), nil
@@ -269,6 +273,7 @@ func TestResolveAndAuthorizeInstanceProfile_ArnForm(t *testing.T) {
}
func TestResolveAndAuthorizeInstanceProfile_MissingNameAndArn(t *testing.T) {
t.Parallel()
in := runValidInputWithProfile(&ec2.IamInstanceProfileSpecification{})
_, err := resolveAndAuthorizeInstanceProfile(in, &fakeIAMService{}, testGwAccountID, nil)
require.Error(t, err)
@@ -276,6 +281,7 @@ func TestResolveAndAuthorizeInstanceProfile_MissingNameAndArn(t *testing.T) {
}
func TestResolveAndAuthorizeInstanceProfile_NilIAMService(t *testing.T) {
t.Parallel()
in := runValidInputWithProfile(&ec2.IamInstanceProfileSpecification{Name: aws.String(testProfileNameApp)})
_, err := resolveAndAuthorizeInstanceProfile(in, nil, testGwAccountID, nil)
require.Error(t, err)
@@ -283,6 +289,7 @@ func TestResolveAndAuthorizeInstanceProfile_NilIAMService(t *testing.T) {
}
func TestResolveAndAuthorizeInstanceProfile_NotFoundMapsToEC2Error(t *testing.T) {
t.Parallel()
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
}}
@@ -293,6 +300,7 @@ func TestResolveAndAuthorizeInstanceProfile_NotFoundMapsToEC2Error(t *testing.T)
}
func TestResolveAndAuthorizeInstanceProfile_CrossAccountARNPassthrough(t *testing.T) {
t.Parallel()
// IAM service rejects cross-account; gateway surfaces the AccessDenied verbatim.
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return nil, errors.New(awserrors.ErrorAccessDenied)
@@ -304,6 +312,7 @@ func TestResolveAndAuthorizeInstanceProfile_CrossAccountARNPassthrough(t *testin
}
func TestResolveAndAuthorizeInstanceProfile_PassRoleDenied(t *testing.T) {
t.Parallel()
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return profileWithRole(), nil
}}
@@ -318,6 +327,7 @@ func TestResolveAndAuthorizeInstanceProfile_PassRoleDenied(t *testing.T) {
}
func TestResolveAndAuthorizeInstanceProfile_NoRoleSkipsPassRole(t *testing.T) {
t.Parallel()
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
return profileNoRole(), nil
}}
@@ -334,6 +344,7 @@ func TestResolveAndAuthorizeInstanceProfile_NoRoleSkipsPassRole(t *testing.T) {
}
func TestEnrichReservationWithProfileID_PopulatesID(t *testing.T) {
t.Parallel()
r := &ec2.Reservation{Instances: []*ec2.Instance{
{InstanceId: aws.String("i-001"), IamInstanceProfile: &ec2.IamInstanceProfile{Arn: aws.String(testProfileARNApp)}},
{InstanceId: aws.String("i-002")}, // no IamInstanceProfile yet — gateway fills both Arn + Id
@@ -348,11 +359,13 @@ func TestEnrichReservationWithProfileID_PopulatesID(t *testing.T) {
}
func TestEnrichReservationWithProfileID_NoProfileIsNoOp(t *testing.T) {
t.Parallel()
r := &ec2.Reservation{Instances: []*ec2.Instance{{InstanceId: aws.String("i-001")}}}
enrichReservationWithProfileID(r, nil)
assert.Nil(t, r.Instances[0].IamInstanceProfile, "nil profile must not synthesise an empty IamInstanceProfile")
}
func TestEnrichReservationWithProfileID_NilReservationIsSafe(t *testing.T) {
t.Parallel()
enrichReservationWithProfileID(nil, profileWithRole()) // must not panic
}
@@ -14,6 +14,7 @@ import (
)
func TestStartInstances_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-0123456789abcdef0"
@@ -48,6 +49,7 @@ func TestStartInstances_Success(t *testing.T) {
}
func TestStartInstances_MultipleInstances(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
ids := []string{"i-001", "i-002", "i-003"}
@@ -76,6 +78,7 @@ func TestStartInstances_MultipleInstances(t *testing.T) {
}
func TestStartInstances_EmptyInstanceIds(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
input := &ec2.StartInstancesInput{
@@ -88,6 +91,7 @@ func TestStartInstances_EmptyInstanceIds(t *testing.T) {
}
func TestStartInstances_NilInstanceIdSkipped(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-valid"
@@ -109,6 +113,7 @@ func TestStartInstances_NilInstanceIdSkipped(t *testing.T) {
}
func TestStartInstances_NATSRequestFails(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// No subscriber for ec2.start, so NATS request will fail
@@ -134,6 +139,7 @@ func TestStartInstances_NATSRequestFails(t *testing.T) {
}
func TestStartInstances_MixedSuccessAndFailure(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
goodID := "i-good"
@@ -15,6 +15,7 @@ import (
)
func TestStopInstances_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-0123456789abcdef0"
@@ -50,6 +51,7 @@ func TestStopInstances_Success(t *testing.T) {
}
func TestStopInstances_MultipleInstances(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
ids := []string{"i-001", "i-002"}
@@ -77,6 +79,7 @@ func TestStopInstances_MultipleInstances(t *testing.T) {
}
func TestStopInstances_EmptyInstanceIds(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
input := &ec2.StopInstancesInput{
@@ -89,6 +92,7 @@ func TestStopInstances_EmptyInstanceIds(t *testing.T) {
}
func TestStopInstances_NilInstanceIdSkipped(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-valid"
@@ -108,6 +112,7 @@ func TestStopInstances_NilInstanceIdSkipped(t *testing.T) {
}
func TestStopInstances_NATSRequestFails(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-nosubscriber"
@@ -131,6 +136,7 @@ func TestStopInstances_NATSRequestFails(t *testing.T) {
}
func TestStopInstances_MixedSuccessAndFailure(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
goodID := "i-good"
@@ -15,6 +15,7 @@ import (
)
func TestTerminateInstances_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-0123456789abcdef0"
@@ -50,6 +51,7 @@ func TestTerminateInstances_Success(t *testing.T) {
}
func TestTerminateInstances_MultipleInstances(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
ids := []string{"i-001", "i-002", "i-003"}
@@ -77,6 +79,7 @@ func TestTerminateInstances_MultipleInstances(t *testing.T) {
}
func TestTerminateInstances_EmptyInstanceIds(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
input := &ec2.TerminateInstancesInput{
@@ -89,6 +92,7 @@ func TestTerminateInstances_EmptyInstanceIds(t *testing.T) {
}
func TestTerminateInstances_NilInstanceIdSkipped(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-valid"
@@ -148,6 +152,7 @@ func TestTerminateInstances_MixedSuccessAndFailure(t *testing.T) {
}
func TestTerminateInstances_VerifiesQMPAttributes(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
instanceID := "i-verify"
@@ -28,6 +28,7 @@ func newTestClientTokenStore(t *testing.T) *ClientTokenStore {
// A completed token replays the stored reservation for a duplicate caller with
// matching params.
func TestClientToken_ReplaysCompletedReservation(t *testing.T) {
t.Parallel()
store := newTestClientTokenStore(t)
const tok, hash = "tok-1", "hash-a"
@@ -47,6 +48,7 @@ func TestClientToken_ReplaysCompletedReservation(t *testing.T) {
// Reusing a token with different params is IdempotentParameterMismatch.
func TestClientToken_ParamMismatchRejected(t *testing.T) {
t.Parallel()
store := newTestClientTokenStore(t)
const tok = "tok-2"
@@ -62,6 +64,7 @@ func TestClientToken_ParamMismatchRejected(t *testing.T) {
// A failed launch aborts the token so a later retry with the same token
// re-launches instead of replaying a non-existent reservation.
func TestClientToken_AbortAllowsRelaunch(t *testing.T) {
t.Parallel()
store := newTestClientTokenStore(t)
const tok, hash = "tok-3", "hash-a"
@@ -79,6 +82,7 @@ func TestClientToken_AbortAllowsRelaunch(t *testing.T) {
// Concurrent callers with the same token must yield exactly one owner; the
// others replay the owner's reservation. Proves single-launch under -race.
func TestClientToken_ConcurrentSingleOwner(t *testing.T) {
t.Parallel()
store := newTestClientTokenStore(t)
const tok, hash = "tok-4", "hash-a"
res := &ec2.Reservation{ReservationId: aws.String("r-only")}
@@ -122,6 +126,7 @@ func TestClientToken_ConcurrentSingleOwner(t *testing.T) {
// clientTokenParamHash ignores ClientToken (same params, different token →
// same hash) but reflects a real parameter change.
func TestClientTokenParamHash_IgnoresTokenReflectsParams(t *testing.T) {
t.Parallel()
base := &ec2.RunInstancesInput{
ImageId: aws.String("ami-123"),
InstanceType: aws.String("t3.micro"),
@@ -144,6 +149,7 @@ func TestClientTokenParamHash_IgnoresTokenReflectsParams(t *testing.T) {
// A completed token replays its reservation and must NOT invoke the launcher.
func TestRunInstancesWithClientToken_ReplaySkipsLaunch(t *testing.T) {
t.Parallel()
store := newTestClientTokenStore(t)
const tok, hash = "rt-1", "h"
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, hash)
@@ -163,6 +169,7 @@ func TestRunInstancesWithClientToken_ReplaySkipsLaunch(t *testing.T) {
// The owner launches once and finalizes; a duplicate replays without launching.
func TestRunInstancesWithClientToken_OwnerLaunchesOnceThenReplay(t *testing.T) {
t.Parallel()
store := newTestClientTokenStore(t)
const tok, hash = "rt-2", "h"
launches := 0
@@ -183,6 +190,7 @@ func TestRunInstancesWithClientToken_OwnerLaunchesOnceThenReplay(t *testing.T) {
// A launch failure aborts the token so a retry re-launches.
func TestRunInstancesWithClientToken_LaunchFailureAborts(t *testing.T) {
t.Parallel()
store := newTestClientTokenStore(t)
const tok, hash = "rt-3", "h"
@@ -204,6 +212,7 @@ func TestRunInstancesWithClientToken_LaunchFailureAborts(t *testing.T) {
// Token reuse with different params maps to the AWS IdempotentParameterMismatch
// error code and never launches.
func TestRunInstancesWithClientToken_ParamMismatchMapsAWSError(t *testing.T) {
t.Parallel()
store := newTestClientTokenStore(t)
const tok = "rt-4"
_, owned, err := store.Claim(t.Context(), ctTestAccount, tok, "hA")
@@ -223,6 +232,7 @@ func TestRunInstancesWithClientToken_ParamMismatchMapsAWSError(t *testing.T) {
// getClientTokenStore binds the process-wide store once and returns the same
// instance on subsequent calls.
func TestGetClientTokenStore_BindsOnce(t *testing.T) {
t.Parallel()
_, nc, _ := testutil.StartTestJetStream(t)
s1, err := getClientTokenStore(t.Context(), nc)
require.NoError(t, err)
+62 -19
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
@@ -21,6 +20,7 @@ import (
// --- spreadAllocate tests (pure algorithm) ---
func TestSpreadAllocate_EqualDistribution(t *testing.T) {
t.Parallel()
// 3 instances across 3 nodes → 1 per node
nodes := []nodeAllocation{
{NodeID: "A", Available: 4},
@@ -36,6 +36,7 @@ func TestSpreadAllocate_EqualDistribution(t *testing.T) {
}
func TestSpreadAllocate_SpreadThenPack(t *testing.T) {
t.Parallel()
// 5 instances across 3 nodes (capacities: A=4, B=3, C=2)
// Round 1: A=1, B=1, C=1 (3 assigned, 2 remaining)
// Round 2: A gets 1 (remaining cap 3), B gets 1 (remaining cap 2)
@@ -57,6 +58,7 @@ func TestSpreadAllocate_SpreadThenPack(t *testing.T) {
}
func TestSpreadAllocate_SingleNode(t *testing.T) {
t.Parallel()
// All 3 instances on 1 node
nodes := []nodeAllocation{
{NodeID: "A", Available: 5},
@@ -69,6 +71,7 @@ func TestSpreadAllocate_SingleNode(t *testing.T) {
}
func TestSpreadAllocate_MoreNodesThanInstances(t *testing.T) {
t.Parallel()
// 2 instances across 5 nodes → only 2 get assigned
nodes := []nodeAllocation{
{NodeID: "A", Available: 4},
@@ -89,6 +92,7 @@ func TestSpreadAllocate_MoreNodesThanInstances(t *testing.T) {
}
func TestSpreadAllocate_HeavyPacking(t *testing.T) {
t.Parallel()
// 10 instances across 2 nodes (A=8, B=6)
// Round 1: A=1, B=1
// Packing: each round picks node with most remaining
@@ -110,6 +114,7 @@ func TestSpreadAllocate_HeavyPacking(t *testing.T) {
}
func TestSpreadAllocate_ExactCapacity(t *testing.T) {
t.Parallel()
// Request exactly matches total capacity
nodes := []nodeAllocation{
{NodeID: "A", Available: 2},
@@ -127,6 +132,7 @@ func TestSpreadAllocate_ExactCapacity(t *testing.T) {
}
func TestSpreadAllocate_ZeroCount(t *testing.T) {
t.Parallel()
nodes := []nodeAllocation{
{NodeID: "A", Available: 4},
}
@@ -135,6 +141,7 @@ func TestSpreadAllocate_ZeroCount(t *testing.T) {
}
func TestSpreadAllocate_EmptyNodes(t *testing.T) {
t.Parallel()
result := spreadAllocate(nil, 3)
assert.Empty(t, result)
}
@@ -142,6 +149,7 @@ func TestSpreadAllocate_EmptyNodes(t *testing.T) {
// --- queryNodeCapacity tests (NATS-based) ---
func TestQueryNodeCapacity_FiltersEligibleNodes(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Simulate 3 daemons responding to spinifex.node.status
@@ -190,6 +198,7 @@ func TestQueryNodeCapacity_FiltersEligibleNodes(t *testing.T) {
}
func TestQueryNodeCapacity_NoNodes(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// No subscribers → timeout, empty result
@@ -201,6 +210,7 @@ func TestQueryNodeCapacity_NoNodes(t *testing.T) {
// --- aggregateResults tests ---
func TestAggregateResults_AllSucceed(t *testing.T) {
t.Parallel()
results := []nodeLaunchResult{
{
NodeID: "node-1",
@@ -231,6 +241,7 @@ func TestAggregateResults_AllSucceed(t *testing.T) {
}
func TestAggregateResults_PartialSuccessMeetsMinCount(t *testing.T) {
t.Parallel()
results := []nodeLaunchResult{
{
NodeID: "node-1",
@@ -284,6 +295,7 @@ func TestAggregateResults_PartialFailureBelowMinCount(t *testing.T) {
}
func TestAggregateResults_AllFail(t *testing.T) {
t.Parallel()
results := []nodeLaunchResult{
{NodeID: "node-1", Err: assert.AnError},
{NodeID: "node-2", Err: assert.AnError},
@@ -298,6 +310,7 @@ func TestAggregateResults_AllFail(t *testing.T) {
// A node-side capacity race (queryNodeCapacity saw room, the node then rejected)
// still propagates as InsufficientInstanceCapacity via the client-error whitelist.
func TestAggregateResults_NodeCapacityRacePropagates(t *testing.T) {
t.Parallel()
inner := errors.New(awserrors.ErrorInsufficientInstanceCapacity)
wrapped := fmt.Errorf("launch on node-1: %w", inner)
results := []nodeLaunchResult{
@@ -311,6 +324,7 @@ func TestAggregateResults_NodeCapacityRacePropagates(t *testing.T) {
// A node RPC timeout must surface as the real error, never masquerade as capacity.
func TestAggregateResults_TimeoutSurfacedNotMasked(t *testing.T) {
t.Parallel()
timeoutErr := fmt.Errorf("launch on node-1: %w", context.DeadlineExceeded)
results := []nodeLaunchResult{
{NodeID: "node-1", Err: timeoutErr},
@@ -346,6 +360,7 @@ func TestAggregateResults_ShortWithoutNodeErrors(t *testing.T) {
// --- extractClientError tests ---
func TestExtractClientError_NoErrors(t *testing.T) {
t.Parallel()
results := []nodeLaunchResult{
{NodeID: "node-1", Reservation: &ec2.Reservation{}},
}
@@ -353,6 +368,7 @@ func TestExtractClientError_NoErrors(t *testing.T) {
}
func TestExtractClientError_GenericError(t *testing.T) {
t.Parallel()
results := []nodeLaunchResult{
{NodeID: "node-1", Err: assert.AnError},
}
@@ -360,6 +376,7 @@ func TestExtractClientError_GenericError(t *testing.T) {
}
func TestExtractClientError_AMINotFound(t *testing.T) {
t.Parallel()
// Simulate the error wrapping that launchOnNodes does
inner := errors.New(awserrors.ErrorInvalidAMIIDNotFound)
wrapped := fmt.Errorf("launch on node-1: %w", inner)
@@ -372,6 +389,7 @@ func TestExtractClientError_AMINotFound(t *testing.T) {
}
func TestExtractClientError_NestedWrappedCode(t *testing.T) {
t.Parallel()
inner := errors.New(awserrors.ErrorInvalidParameterValue)
wrapped := fmt.Errorf("launch on node-1: %w", fmt.Errorf("prepare instance: %w", inner))
results := []nodeLaunchResult{
@@ -383,6 +401,7 @@ func TestExtractClientError_NestedWrappedCode(t *testing.T) {
}
func TestExtractClientError_JoinedCode(t *testing.T) {
t.Parallel()
joined := errors.Join(assert.AnError, errors.New(awserrors.ErrorInvalidGroupNotFound))
results := []nodeLaunchResult{
{NodeID: "node-1", Err: fmt.Errorf("launch on node-1: %w", joined)},
@@ -393,6 +412,7 @@ func TestExtractClientError_JoinedCode(t *testing.T) {
}
func TestExtractClientError_KeyPairNotFound(t *testing.T) {
t.Parallel()
inner := errors.New(awserrors.ErrorInvalidKeyPairNotFound)
wrapped := fmt.Errorf("launch on node-1: %w", inner)
results := []nodeLaunchResult{
@@ -404,6 +424,7 @@ func TestExtractClientError_KeyPairNotFound(t *testing.T) {
}
func TestAggregateResults_PropagatesClientError(t *testing.T) {
t.Parallel()
inner := errors.New(awserrors.ErrorInvalidAMIIDNotFound)
wrapped := fmt.Errorf("launch on node-1: %w", inner)
results := []nodeLaunchResult{
@@ -419,6 +440,7 @@ func TestAggregateResults_PropagatesClientError(t *testing.T) {
// --- distributeInstances integration tests (end-to-end with mock daemons) ---
func TestDistributeInstances_SuccessfulSpread(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Mock node.status responder
@@ -458,7 +480,7 @@ func TestDistributeInstances_SuccessfulSpread(t *testing.T) {
require.NoError(t, err)
defer sub2.Unsubscribe()
time.Sleep(50 * time.Millisecond) // let subscriptions propagate
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -481,6 +503,7 @@ func TestDistributeInstances_SuccessfulSpread(t *testing.T) {
}
func TestDistributeInstances_InsufficientCapacity(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Mock node.status with only 1 available slot total
@@ -495,7 +518,7 @@ func TestDistributeInstances_InsufficientCapacity(t *testing.T) {
require.NoError(t, err)
defer statusSub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -510,6 +533,7 @@ func TestDistributeInstances_InsufficientCapacity(t *testing.T) {
}
func TestDistributeInstances_PropagatesAMINotFound(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Mock node.status with capacity available
@@ -531,7 +555,7 @@ func TestDistributeInstances_PropagatesAMINotFound(t *testing.T) {
require.NoError(t, err)
defer sub1.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-0000000000000dead"),
@@ -554,6 +578,7 @@ func TestDistributeInstances_PropagatesAMINotFound(t *testing.T) {
// Terraform / SDK consumers branch on the specific code; masking it as
// "no capacity" makes a typo'd SG ID look like a transient cluster issue.
func TestDistributeInstances_PropagatesSGValidationErrors(t *testing.T) {
t.Parallel()
cases := []struct {
name string
daemonErrCode string
@@ -583,7 +608,7 @@ func TestDistributeInstances_PropagatesSGValidationErrors(t *testing.T) {
require.NoError(t, err)
defer daemonSub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -601,6 +626,7 @@ func TestDistributeInstances_PropagatesSGValidationErrors(t *testing.T) {
}
func TestDistributeInstances_LaunchCountCappedToMaxCount(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// 3 nodes with capacity, but MaxCount=2
@@ -631,7 +657,7 @@ func TestDistributeInstances_LaunchCountCappedToMaxCount(t *testing.T) {
defer sub.Unsubscribe()
}
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -647,10 +673,11 @@ func TestDistributeInstances_LaunchCountCappedToMaxCount(t *testing.T) {
}
func TestDistributeInstances_NoNodesAvailable(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// No responders to node.status
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -667,6 +694,7 @@ func TestDistributeInstances_NoNodesAvailable(t *testing.T) {
// --- RunInstances routing tests ---
func TestRunInstances_SingleInstanceDistributes(t *testing.T) {
t.Parallel()
// For MinCount=MaxCount=1, RunInstances should still query node capacity
// and route to a specific node via targeted topic (not queue group).
_, nc := startTestNATSServer(t)
@@ -695,7 +723,7 @@ func TestRunInstances_SingleInstanceDistributes(t *testing.T) {
require.NoError(t, err)
defer nodeSub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -714,6 +742,7 @@ func TestRunInstances_SingleInstanceDistributes(t *testing.T) {
// --- placementGroupName tests ---
func TestPlacementGroupName_WithGroupName(t *testing.T) {
t.Parallel()
input := &ec2.RunInstancesInput{
Placement: &ec2.Placement{
GroupName: aws.String("my-group"),
@@ -723,11 +752,13 @@ func TestPlacementGroupName_WithGroupName(t *testing.T) {
}
func TestPlacementGroupName_NilPlacement(t *testing.T) {
t.Parallel()
input := &ec2.RunInstancesInput{}
assert.Empty(t, placementGroupName(input))
}
func TestPlacementGroupName_NilGroupName(t *testing.T) {
t.Parallel()
input := &ec2.RunInstancesInput{
Placement: &ec2.Placement{},
}
@@ -735,6 +766,7 @@ func TestPlacementGroupName_NilGroupName(t *testing.T) {
}
func TestPlacementGroupName_EmptyGroupName(t *testing.T) {
t.Parallel()
input := &ec2.RunInstancesInput{
Placement: &ec2.Placement{
GroupName: aws.String(""),
@@ -746,6 +778,7 @@ func TestPlacementGroupName_EmptyGroupName(t *testing.T) {
// --- lookupPlacementGroupStrategy tests ---
func TestLookupPlacementGroupStrategy_Success(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Mock DescribePlacementGroups responder
@@ -765,7 +798,7 @@ func TestLookupPlacementGroupStrategy_Success(t *testing.T) {
require.NoError(t, err)
defer sub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
strategy, err := lookupPlacementGroupStrategy(context.Background(), nc, "test-account", "my-group")
require.NoError(t, err)
@@ -773,6 +806,7 @@ func TestLookupPlacementGroupStrategy_Success(t *testing.T) {
}
func TestLookupPlacementGroupStrategy_NotFound(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
// Mock responder returns error
@@ -783,13 +817,14 @@ func TestLookupPlacementGroupStrategy_NotFound(t *testing.T) {
require.NoError(t, err)
defer sub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
_, err = lookupPlacementGroupStrategy(context.Background(), nc, "test-account", "ghost-group")
require.Error(t, err)
}
func TestLookupPlacementGroupStrategy_NotAvailable(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
sub, err := nc.QueueSubscribe("ec2.DescribePlacementGroups", "spinifex-workers", func(msg *nats.Msg) {
@@ -808,7 +843,7 @@ func TestLookupPlacementGroupStrategy_NotAvailable(t *testing.T) {
require.NoError(t, err)
defer sub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
_, err = lookupPlacementGroupStrategy(context.Background(), nc, "test-account", "my-group")
require.Error(t, err)
@@ -816,6 +851,7 @@ func TestLookupPlacementGroupStrategy_NotAvailable(t *testing.T) {
}
func TestLookupPlacementGroupStrategy_EmptyResult(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
sub, err := nc.QueueSubscribe("ec2.DescribePlacementGroups", "spinifex-workers", func(msg *nats.Msg) {
@@ -828,7 +864,7 @@ func TestLookupPlacementGroupStrategy_EmptyResult(t *testing.T) {
require.NoError(t, err)
defer sub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
_, err = lookupPlacementGroupStrategy(context.Background(), nc, "test-account", "my-group")
require.Error(t, err)
@@ -838,6 +874,7 @@ func TestLookupPlacementGroupStrategy_EmptyResult(t *testing.T) {
// --- distributeInstancesCluster tests ---
func TestDistributeInstancesCluster_FirstLaunchPicksBestNode(t *testing.T) {
t.Parallel()
// First launch on empty cluster group should pick node with most capacity
_, nc := startTestNATSServer(t)
@@ -889,7 +926,7 @@ func TestDistributeInstancesCluster_FirstLaunchPicksBestNode(t *testing.T) {
require.NoError(t, err)
defer finalizeSub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -909,6 +946,7 @@ func TestDistributeInstancesCluster_FirstLaunchPicksBestNode(t *testing.T) {
}
func TestDistributeInstancesCluster_SubsequentLaunchPinsToExistingNode(t *testing.T) {
t.Parallel()
// Subsequent launch should go to the same node the group already uses
_, nc := startTestNATSServer(t)
@@ -966,7 +1004,7 @@ func TestDistributeInstancesCluster_SubsequentLaunchPinsToExistingNode(t *testin
require.NoError(t, err)
defer finalizeSub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -982,6 +1020,7 @@ func TestDistributeInstancesCluster_SubsequentLaunchPinsToExistingNode(t *testin
}
func TestDistributeInstancesCluster_InsufficientCapacityOnPinnedNode(t *testing.T) {
t.Parallel()
// Pinned node doesn't have enough capacity → InsufficientInstanceCapacity
_, nc := startTestNATSServer(t)
@@ -1009,7 +1048,7 @@ func TestDistributeInstancesCluster_InsufficientCapacityOnPinnedNode(t *testing.
require.NoError(t, err)
defer reserveSub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -1024,6 +1063,7 @@ func TestDistributeInstancesCluster_InsufficientCapacityOnPinnedNode(t *testing.
}
func TestDistributeInstancesCluster_PinnedNodeNotInCapacityResults(t *testing.T) {
t.Parallel()
// Pinned node has no capacity at all (not in fan-out results) → InsufficientInstanceCapacity
_, nc := startTestNATSServer(t)
@@ -1050,7 +1090,7 @@ func TestDistributeInstancesCluster_PinnedNodeNotInCapacityResults(t *testing.T)
require.NoError(t, err)
defer reserveSub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -1065,6 +1105,7 @@ func TestDistributeInstancesCluster_PinnedNodeNotInCapacityResults(t *testing.T)
}
func TestDistributeInstancesCluster_LaunchCountCappedByCapacityAndMaxCount(t *testing.T) {
t.Parallel()
// Target node has 3 available but MaxCount=2 → should launch 2
_, nc := startTestNATSServer(t)
@@ -1115,7 +1156,7 @@ func TestDistributeInstancesCluster_LaunchCountCappedByCapacityAndMaxCount(t *te
require.NoError(t, err)
defer finalizeSub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -1130,6 +1171,7 @@ func TestDistributeInstancesCluster_LaunchCountCappedByCapacityAndMaxCount(t *te
}
func TestRunInstances_ClusterPlacementGroupRouting(t *testing.T) {
t.Parallel()
// RunInstances with a cluster placement group should route through
// the cluster path (lookupPlacementGroupStrategy → distributeInstancesCluster).
_, nc := startTestNATSServer(t)
@@ -1197,7 +1239,7 @@ func TestRunInstances_ClusterPlacementGroupRouting(t *testing.T) {
require.NoError(t, err)
defer finalizeSub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -1216,6 +1258,7 @@ func TestRunInstances_ClusterPlacementGroupRouting(t *testing.T) {
}
func TestRunInstances_MultiInstanceUsesDistribution(t *testing.T) {
t.Parallel()
// For MaxCount > 1, RunInstances should use the distribution path,
// which queries spinifex.node.status.
_, nc := startTestNATSServer(t)
@@ -1248,7 +1291,7 @@ func TestRunInstances_MultiInstanceUsesDistribution(t *testing.T) {
require.NoError(t, err)
defer nodeSub.Unsubscribe()
time.Sleep(50 * time.Millisecond)
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
@@ -8,6 +8,7 @@ import (
)
func TestNewStateChange(t *testing.T) {
t.Parallel()
tests := []struct {
name string
instanceID string
+4 -3
View File
@@ -33,8 +33,9 @@ class = 1
}
// fakeZoneS3 is a mutable path-style S3 endpoint (GET only) backing HostsZone
// lookups. Keys listed in errorKeys return 500 instead of the usual 200/404,
// simulating a transient S3 failure for that specific zone object. requested
// lookups. Keys listed in errorKeys return 403 instead of the usual 200/404,
// simulating a read failure for that zone object — 403 rather than a retried
// (and so slow) 5xx, since HostsZone branches only on err != nil. requested
// records every key fetched, letting a test assert which candidate zones were
// actually queried.
func fakeZoneS3(t *testing.T, bucket string, objects map[string]string, errorKeys map[string]bool) (endpoint string, requested *[]string) {
@@ -48,7 +49,7 @@ func fakeZoneS3(t *testing.T, bucket string, objects map[string]string, errorKey
log = append(log, key)
mu.Unlock()
if errorKeys[key] {
http.Error(w, "internal error", http.StatusInternalServerError)
http.Error(w, "access denied", http.StatusForbidden)
return
}
body, ok := objects[key]
@@ -11,23 +11,27 @@ import (
// --- rtbMatchesFilters ---
func TestRtbMatchesFilters_EmptyFilters(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{RouteTableId: "rtb-1", VpcId: "vpc-1"}
assert.True(t, rtbMatchesFilters(record, map[string][]string{}))
}
func TestRtbMatchesFilters_VpcId(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{RouteTableId: "rtb-1", VpcId: "vpc-aaa"}
assert.True(t, rtbMatchesFilters(record, map[string][]string{"vpc-id": {"vpc-aaa"}}))
assert.False(t, rtbMatchesFilters(record, map[string][]string{"vpc-id": {"vpc-bbb"}}))
}
func TestRtbMatchesFilters_RouteTableId(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{RouteTableId: "rtb-xyz", VpcId: "vpc-1"}
assert.True(t, rtbMatchesFilters(record, map[string][]string{"route-table-id": {"rtb-xyz"}}))
assert.False(t, rtbMatchesFilters(record, map[string][]string{"route-table-id": {"rtb-other"}}))
}
func TestRtbMatchesFilters_AssociationMain_True(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{
Associations: []AssociationRecord{
{AssociationId: "rtbassoc-1", Main: true},
@@ -38,6 +42,7 @@ func TestRtbMatchesFilters_AssociationMain_True(t *testing.T) {
}
func TestRtbMatchesFilters_AssociationMain_False(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{
Associations: []AssociationRecord{
{AssociationId: "rtbassoc-1", Main: false},
@@ -47,6 +52,7 @@ func TestRtbMatchesFilters_AssociationMain_False(t *testing.T) {
}
func TestRtbMatchesFilters_AssociationId(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{
Associations: []AssociationRecord{
{AssociationId: "rtbassoc-abc"},
@@ -61,6 +67,7 @@ func TestRtbMatchesFilters_AssociationId(t *testing.T) {
}
func TestRtbMatchesFilters_AssociationSubnetId(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{
Associations: []AssociationRecord{
{AssociationId: "rtbassoc-1", SubnetId: "subnet-aaa"},
@@ -75,6 +82,7 @@ func TestRtbMatchesFilters_AssociationSubnetId(t *testing.T) {
}
func TestRtbMatchesFilters_RouteDestinationCidr(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{
Routes: []RouteRecord{
{DestinationCidrBlock: "10.0.0.0/16", GatewayId: "local", State: "active"},
@@ -90,6 +98,7 @@ func TestRtbMatchesFilters_RouteDestinationCidr(t *testing.T) {
}
func TestRtbMatchesFilters_RouteGatewayId(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{
Routes: []RouteRecord{
{DestinationCidrBlock: "0.0.0.0/0", GatewayId: "igw-abc", State: "active"},
@@ -104,6 +113,7 @@ func TestRtbMatchesFilters_RouteGatewayId(t *testing.T) {
}
func TestRtbMatchesFilters_MultipleFilters(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{
RouteTableId: "rtb-1",
VpcId: "vpc-aaa",
@@ -124,6 +134,7 @@ func TestRtbMatchesFilters_MultipleFilters(t *testing.T) {
}
func TestRtbMatchesFilters_MultipleValues(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{VpcId: "vpc-bbb"}
assert.True(t, rtbMatchesFilters(record, map[string][]string{
"vpc-id": {"vpc-aaa", "vpc-bbb", "vpc-ccc"},
@@ -131,6 +142,7 @@ func TestRtbMatchesFilters_MultipleValues(t *testing.T) {
}
func TestRtbMatchesFilters_NoAssociations(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{RouteTableId: "rtb-1"}
assert.False(t, rtbMatchesFilters(record, map[string][]string{
"association.subnet-id": {"subnet-1"},
@@ -138,6 +150,7 @@ func TestRtbMatchesFilters_NoAssociations(t *testing.T) {
}
func TestRtbMatchesFilters_NoRoutes(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{RouteTableId: "rtb-1"}
assert.False(t, rtbMatchesFilters(record, map[string][]string{
"route.destination-cidr-block": {"0.0.0.0/0"},
@@ -145,6 +158,7 @@ func TestRtbMatchesFilters_NoRoutes(t *testing.T) {
}
func TestRtbMatchesFilters_UnknownFilter(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{RouteTableId: "rtb-1", VpcId: "vpc-1"}
assert.False(t, rtbMatchesFilters(record, map[string][]string{
"bogus-filter": {"value"},
@@ -152,6 +166,7 @@ func TestRtbMatchesFilters_UnknownFilter(t *testing.T) {
}
func TestRtbMatchesFilters_Wildcard(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{RouteTableId: "rtb-1", VpcId: "vpc-aaa"}
assert.True(t, rtbMatchesFilters(record, map[string][]string{
"vpc-id": {"vpc-*"},
@@ -159,6 +174,7 @@ func TestRtbMatchesFilters_Wildcard(t *testing.T) {
}
func TestRtbMatchesFilters_TagFilter(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{
RouteTableId: "rtb-1",
VpcId: "vpc-1",
@@ -178,6 +194,7 @@ func TestRtbMatchesFilters_TagFilter(t *testing.T) {
// --- recordToEC2 ---
func TestRecordToEC2_Basic(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{
RouteTableId: "rtb-abc",
VpcId: "vpc-123",
@@ -193,6 +210,7 @@ func TestRecordToEC2_Basic(t *testing.T) {
}
func TestRecordToEC2_Routes(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{
RouteTableId: "rtb-1",
VpcId: "vpc-1",
@@ -217,6 +235,7 @@ func TestRecordToEC2_Routes(t *testing.T) {
}
func TestRecordToEC2_Associations(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{
RouteTableId: "rtb-1",
VpcId: "vpc-1",
@@ -240,6 +259,7 @@ func TestRecordToEC2_Associations(t *testing.T) {
}
func TestRecordToEC2_Tags(t *testing.T) {
t.Parallel()
record := &RouteTableRecord{
RouteTableId: "rtb-1",
VpcId: "vpc-1",
@@ -16,6 +16,7 @@ import (
// table, not success. Idempotent convergence belongs to destroy orchestration,
// which tolerates NotFound via awserrors.IsNotFound; the public API stays AWS compatible.
func TestRLC1_RouteTableDeleteNotFoundOnAbsent(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
_, err := svc.DeleteRouteTable(t.Context(), &ec2.DeleteRouteTableInput{
@@ -19,6 +19,7 @@ func rtbTagVal(tags []*ec2.Tag, key string) string {
}
func TestRouteTableRecordTagsMirror(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -57,6 +58,7 @@ func TestRouteTableRecordTagsMirror(t *testing.T) {
}
func TestRouteTableRecordTagsMirror_UnownedNoError(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
// Absent rtb + non-rtb id: both skipped without error.
require.NoError(t, svc.ApplyRecordTags(&ec2.CreateTagsInput{
@@ -53,6 +53,32 @@ func setupTestService(t *testing.T) *RouteTableServiceImpl {
return svc
}
// pendingEvents flushes the service connection and reports how many events are
// already queued on sub. Publishes and deliveries share one connection, so a
// completed flush is an exact barrier: no sleep is needed to prove a negative.
func pendingEvents(t *testing.T, svc *RouteTableServiceImpl, sub *nats.Subscription) int {
t.Helper()
require.NoError(t, svc.natsConn.Flush())
msgs, _, err := sub.Pending()
require.NoError(t, err)
return msgs
}
// assertNoEvents asserts that nothing was published on sub.
func assertNoEvents(t *testing.T, svc *RouteTableServiceImpl, sub *nats.Subscription, msg string) {
t.Helper()
assert.Zero(t, pendingEvents(t, svc, sub), msg)
}
// drainEvents discards every event already queued on sub.
func drainEvents(t *testing.T, svc *RouteTableServiceImpl, sub *nats.Subscription) {
t.Helper()
for range pendingEvents(t, svc, sub) {
_, err := sub.NextMsg(time.Second)
require.NoError(t, err)
}
}
func seedKV(t *testing.T, js jetstream.JetStream, bucket string, entries map[string][]byte) jetstream.KeyValue {
t.Helper()
kv, err := js.CreateOrUpdateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: bucket, History: 1})
@@ -74,6 +100,7 @@ func createTestRtb(t *testing.T, svc *RouteTableServiceImpl) string {
}
func TestCreateRouteTable(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
out, err := svc.CreateRouteTable(t.Context(), &ec2.CreateRouteTableInput{
VpcId: aws.String("vpc-test1"),
@@ -97,6 +124,7 @@ func TestCreateRouteTable(t *testing.T) {
// teardown can find the private route table, clear its NAT-GW route, and let
// the NAT gateway (and its billable EIP) be reclaimed.
func TestCreateRouteTable_PersistsTagsForTagFilterDiscovery(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
_, err := svc.CreateRouteTable(t.Context(), &ec2.CreateRouteTableInput{
VpcId: aws.String("vpc-test1"),
@@ -128,6 +156,7 @@ func TestCreateRouteTable_PersistsTagsForTagFilterDiscovery(t *testing.T) {
}
func TestCreateRouteTable_VpcNotFound(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
_, err := svc.CreateRouteTable(t.Context(), &ec2.CreateRouteTableInput{
VpcId: aws.String("vpc-nonexistent"),
@@ -136,6 +165,7 @@ func TestCreateRouteTable_VpcNotFound(t *testing.T) {
}
func TestDeleteRouteTable(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -150,6 +180,7 @@ func TestDeleteRouteTable(t *testing.T) {
}
func TestDeleteRouteTable_Main(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
record, err := svc.CreateRouteTableForVPC("vpc-test1", "10.0.0.0/16", testAccountID, true, "")
require.NoError(t, err)
@@ -161,6 +192,7 @@ func TestDeleteRouteTable_Main(t *testing.T) {
}
func TestDeleteRouteTable_WithAssociations(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -179,6 +211,7 @@ func TestDeleteRouteTable_WithAssociations(t *testing.T) {
}
func TestDescribeRouteTables(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -189,6 +222,7 @@ func TestDescribeRouteTables(t *testing.T) {
}
func TestDescribeRouteTables_FilterByVpcId(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
createTestRtb(t, svc)
@@ -210,6 +244,7 @@ func TestDescribeRouteTables_FilterByVpcId(t *testing.T) {
}
func TestDescribeRouteTables_FilterByMain(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
// Create a main route table
@@ -230,6 +265,7 @@ func TestDescribeRouteTables_FilterByMain(t *testing.T) {
}
func TestDescribeRouteTables_FilterByRouteState(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
createTestRtb(t, svc) // has local route with state=active
@@ -249,6 +285,7 @@ func TestDescribeRouteTables_FilterByRouteState(t *testing.T) {
}
func TestDescribeRouteTables_FilterByRouteOrigin(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
createTestRtb(t, svc) // local route has origin=CreateRouteTable
@@ -266,6 +303,7 @@ func TestDescribeRouteTables_FilterByRouteOrigin(t *testing.T) {
}
func TestDescribeRouteTables_FilterByRouteNatGatewayId(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
createTestRtb(t, svc) // no NAT GW route
@@ -285,6 +323,7 @@ func TestDescribeRouteTables_FilterByRouteNatGatewayId(t *testing.T) {
}
func TestDescribeRouteTables_FilterByOwnerId(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
createTestRtb(t, svc)
@@ -311,6 +350,7 @@ func TestDescribeRouteTables_FilterByOwnerId(t *testing.T) {
}
func TestCreateRoute(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -330,6 +370,7 @@ func TestCreateRoute(t *testing.T) {
}
func TestCreateRoute_DuplicateDestination(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -350,6 +391,7 @@ func TestCreateRoute_DuplicateDestination(t *testing.T) {
}
func TestDeleteRoute(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -372,6 +414,7 @@ func TestDeleteRoute(t *testing.T) {
}
func TestDeleteRoute_LocalRoute(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -383,6 +426,7 @@ func TestDeleteRoute_LocalRoute(t *testing.T) {
}
func TestDeleteRoute_NotFound(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -394,6 +438,7 @@ func TestDeleteRoute_NotFound(t *testing.T) {
}
func TestReplaceRoute(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -414,6 +459,7 @@ func TestReplaceRoute(t *testing.T) {
}
func TestAssociateRouteTable(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -427,6 +473,7 @@ func TestAssociateRouteTable(t *testing.T) {
}
func TestAssociateRouteTable_DuplicateSubnet(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -450,6 +497,7 @@ func TestAssociateRouteTable_DuplicateSubnet(t *testing.T) {
// from the same revision and was clobbered, so the provider timed out waiting
// for 'associated'); the CAS path must persist both.
func TestAssociateRouteTable_ConcurrentDistinctSubnets(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -483,6 +531,7 @@ func TestAssociateRouteTable_ConcurrentDistinctSubnets(t *testing.T) {
}
func TestDisassociateRouteTable(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtbID := createTestRtb(t, svc)
@@ -504,6 +553,7 @@ func TestDisassociateRouteTable(t *testing.T) {
}
func TestDisassociateRouteTable_Main(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
record, err := svc.CreateRouteTableForVPC("vpc-test1", "10.0.0.0/16", testAccountID, true, "")
require.NoError(t, err)
@@ -515,6 +565,7 @@ func TestDisassociateRouteTable_Main(t *testing.T) {
}
func TestReplaceRouteTableAssociation(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
rtb1ID := createTestRtb(t, svc)
rtb2ID := createTestRtb(t, svc)
@@ -546,6 +597,7 @@ func TestReplaceRouteTableAssociation(t *testing.T) {
}
func TestCreateRouteTableForVPC_Main(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
record, err := svc.CreateRouteTableForVPC("vpc-test1", "10.0.0.0/16", testAccountID, true, "")
require.NoError(t, err)
@@ -581,6 +633,7 @@ func receiveNatGWEvent(t *testing.T, sub *nats.Subscription, wantCidr string) (n
// TestAssociateRouteTable_PublishesNatGatewayEvent covers the terraform flow where
// AssociateRouteTable must emit a NAT GW event so vpcd installs the SNAT rule.
func TestAssociateRouteTable_PublishesNatGatewayEvent(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
sub, err := svc.natsConn.SubscribeSync("vpc.add-nat-gateway")
require.NoError(t, err)
@@ -597,8 +650,7 @@ func TestAssociateRouteTable_PublishesNatGatewayEvent(t *testing.T) {
require.NoError(t, err)
// No subnet yet → no event from CreateRoute.
_, err = sub.NextMsg(100 * time.Millisecond)
assert.Error(t, err, "CreateRoute must not publish when table has no associations")
assertNoEvents(t, svc, sub, "CreateRoute must not publish when table has no associations")
_, err = svc.AssociateRouteTable(t.Context(), &ec2.AssociateRouteTableInput{
RouteTableId: aws.String(rtbID),
@@ -614,6 +666,7 @@ func TestAssociateRouteTable_PublishesNatGatewayEvent(t *testing.T) {
// TestAssociateRouteTable_NoNatGatewayRoute ensures Associate stays silent when
// the route table has no NAT GW routes.
func TestAssociateRouteTable_NoNatGatewayRoute(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
sub, err := svc.natsConn.SubscribeSync("vpc.add-nat-gateway")
require.NoError(t, err)
@@ -627,13 +680,13 @@ func TestAssociateRouteTable_NoNatGatewayRoute(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
_, err = sub.NextMsg(100 * time.Millisecond)
assert.Error(t, err, "Associate must not publish when table has no NAT GW routes")
assertNoEvents(t, svc, sub, "Associate must not publish when table has no NAT GW routes")
}
// TestDisassociateRouteTable_PublishesNatGatewayDeleteEvent verifies SNAT
// teardown when a subnet leaves a table with a NAT GW route.
func TestDisassociateRouteTable_PublishesNatGatewayDeleteEvent(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
delSub, err := svc.natsConn.SubscribeSync("vpc.delete-nat-gateway")
require.NoError(t, err)
@@ -665,6 +718,7 @@ func TestDisassociateRouteTable_PublishesNatGatewayDeleteEvent(t *testing.T) {
// TestDeleteRoute_NATGW_PublishesDeleteForAssociatedSubnets verifies that every
// associated subnet receives a vpc.delete-nat-gateway event when a NATGW route is removed.
func TestDeleteRoute_NATGW_PublishesDeleteForAssociatedSubnets(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
addSub, err := svc.natsConn.SubscribeSync("vpc.add-nat-gateway")
require.NoError(t, err)
@@ -703,6 +757,7 @@ func TestDeleteRoute_NATGW_PublishesDeleteForAssociatedSubnets(t *testing.T) {
// path: subnet leaves a NAT-routed table for a NAT-free table. We expect a
// delete event against the old table's NAT GW route and no add event.
func TestReplaceRouteTableAssociation_PublishesNatGatewayEvents(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
addSub, err := svc.natsConn.SubscribeSync("vpc.add-nat-gateway")
require.NoError(t, err)
@@ -740,8 +795,7 @@ func TestReplaceRouteTableAssociation_PublishesNatGatewayEvents(t *testing.T) {
assert.Equal(t, "nat-test1", natgwID)
// New table has no NAT GW routes → no add event.
_, err = addSub.NextMsg(100 * time.Millisecond)
assert.Error(t, err, "Replace must not publish add when new table has no NAT GW routes")
assertNoEvents(t, svc, addSub, "Replace must not publish add when new table has no NAT GW routes")
}
func receiveIGWRouteEvent(t *testing.T, sub *nats.Subscription, wantCidr string) (subnetID, igwID, destCidr string) {
@@ -763,6 +817,7 @@ func receiveIGWRouteEvent(t *testing.T, sub *nats.Subscription, wantCidr string)
// CreateRoute(IGW) flow: emits one vpc.add-igw-route event per associated
// subnet so the network subscriber installs per-subnet egress policies.
func TestCreateRoute_IGW_PublishesAddIGWRouteForAssociatedSubnets(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
sub, err := svc.natsConn.SubscribeSync("vpc.add-igw-route")
require.NoError(t, err)
@@ -793,6 +848,7 @@ func TestCreateRoute_IGW_PublishesAddIGWRouteForAssociatedSubnets(t *testing.T)
// TestAssociateRouteTable_PublishesAddIGWRouteForExistingRoute covers the terraform
// ordering: AssociateRouteTable must emit an IGW route event for the joining subnet.
func TestAssociateRouteTable_PublishesAddIGWRouteForExistingRoute(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
sub, err := svc.natsConn.SubscribeSync("vpc.add-igw-route")
require.NoError(t, err)
@@ -808,8 +864,7 @@ func TestAssociateRouteTable_PublishesAddIGWRouteForExistingRoute(t *testing.T)
require.NoError(t, err)
// No subnet yet → no event from CreateRoute.
_, err = sub.NextMsg(100 * time.Millisecond)
assert.Error(t, err, "CreateRoute(IGW) must not publish when table has no associations")
assertNoEvents(t, svc, sub, "CreateRoute(IGW) must not publish when table has no associations")
_, err = svc.AssociateRouteTable(t.Context(), &ec2.AssociateRouteTableInput{
RouteTableId: aws.String(rtbID),
@@ -825,6 +880,7 @@ func TestAssociateRouteTable_PublishesAddIGWRouteForExistingRoute(t *testing.T)
// TestDeleteRoute_IGW_PublishesDeleteIGWRoute verifies policy teardown when
// the IGW route is removed from a table with associations.
func TestDeleteRoute_IGW_PublishesDeleteIGWRoute(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
delSub, err := svc.natsConn.SubscribeSync("vpc.delete-igw-route")
require.NoError(t, err)
@@ -858,6 +914,7 @@ func TestDeleteRoute_IGW_PublishesDeleteIGWRoute(t *testing.T) {
// TestDisassociateRouteTable_PublishesDeleteIGWRoute verifies the egress policy
// is torn down when a subnet leaves a table that carries an IGW route.
func TestDisassociateRouteTable_PublishesDeleteIGWRoute(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
delSub, err := svc.natsConn.SubscribeSync("vpc.delete-igw-route")
require.NoError(t, err)
@@ -889,11 +946,11 @@ func TestDisassociateRouteTable_PublishesDeleteIGWRoute(t *testing.T) {
// drainIGWSubnets reads up to `count` events from sub and returns the unique
// SubnetIds. Used to assert fan-out shape regardless of NATS message order.
func drainIGWSubnets(t *testing.T, sub *nats.Subscription, count int) map[string]bool {
func drainIGWSubnets(t *testing.T, svc *RouteTableServiceImpl, sub *nats.Subscription, count int) map[string]bool {
t.Helper()
got := map[string]bool{}
for range count {
msg, err := sub.NextMsg(500 * time.Millisecond)
for range min(count, pendingEvents(t, svc, sub)) {
msg, err := sub.NextMsg(time.Second)
if err != nil {
break
}
@@ -909,6 +966,7 @@ func drainIGWSubnets(t *testing.T, sub *nats.Subscription, count int) map[string
// TestCreateRoute_IGW_OnMainRT_FansOutToImplicitSubnets verifies CreateRoute(IGW) on
// the main RT emits one vpc.add-igw-route per implicit-main subnet.
func TestCreateRoute_IGW_OnMainRT_FansOutToImplicitSubnets(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
sub, err := svc.natsConn.SubscribeSync("vpc.add-igw-route")
require.NoError(t, err)
@@ -924,7 +982,7 @@ func TestCreateRoute_IGW_OnMainRT_FansOutToImplicitSubnets(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
got := drainIGWSubnets(t, sub, 4)
got := drainIGWSubnets(t, svc, sub, 4)
assert.True(t, got["subnet-test1"], "implicit main-RT subnet must receive add event")
assert.True(t, got["subnet-priv1"], "implicit main-RT subnet must receive add event")
assert.Len(t, got, 2)
@@ -934,6 +992,7 @@ func TestCreateRoute_IGW_OnMainRT_FansOutToImplicitSubnets(t *testing.T) {
// subnet with an explicit non-main RT association does NOT receive the main
// RT's IGW route event — its effective route table is the explicit one.
func TestCreateRoute_IGW_OnMainRT_SkipsExplicitlyAssociatedSubnets(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
sub, err := svc.natsConn.SubscribeSync("vpc.add-igw-route")
require.NoError(t, err)
@@ -956,7 +1015,7 @@ func TestCreateRoute_IGW_OnMainRT_SkipsExplicitlyAssociatedSubnets(t *testing.T)
}, testAccountID)
require.NoError(t, err)
got := drainIGWSubnets(t, sub, 4)
got := drainIGWSubnets(t, svc, sub, 4)
assert.True(t, got["subnet-test1"], "implicit subnet must receive add event")
assert.False(t, got["subnet-priv1"], "explicitly-associated subnet must NOT receive main-RT event")
}
@@ -964,6 +1023,7 @@ func TestCreateRoute_IGW_OnMainRT_SkipsExplicitlyAssociatedSubnets(t *testing.T)
// TestAssociateRouteTable_RemovesMainRTRoutesForJoiningSubnet verifies that the main
// RT's per-subnet rules are torn down when a subnet joins an explicit non-main RT.
func TestAssociateRouteTable_RemovesMainRTRoutesForJoiningSubnet(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
delSub, err := svc.natsConn.SubscribeSync("vpc.delete-igw-route")
require.NoError(t, err)
@@ -985,7 +1045,7 @@ func TestAssociateRouteTable_RemovesMainRTRoutesForJoiningSubnet(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
got := drainIGWSubnets(t, delSub, 2)
got := drainIGWSubnets(t, svc, delSub, 2)
assert.True(t, got["subnet-priv1"], "joining subnet must receive delete event for main-RT routes")
assert.Len(t, got, 1)
}
@@ -1005,11 +1065,11 @@ func receiveGateEvent(t *testing.T, sub *nats.Subscription) (subnetID, destCidr
return evt.SubnetId, evt.DestinationCidr
}
func drainGateSubnets(t *testing.T, sub *nats.Subscription, limit int) map[string]string {
func drainGateSubnets(t *testing.T, svc *RouteTableServiceImpl, sub *nats.Subscription, limit int) map[string]string {
t.Helper()
got := map[string]string{}
for range limit {
msg, err := sub.NextMsg(200 * time.Millisecond)
for range min(limit, pendingEvents(t, svc, sub)) {
msg, err := sub.NextMsg(time.Second)
if err != nil {
break
}
@@ -1026,6 +1086,7 @@ func drainGateSubnets(t *testing.T, sub *nats.Subscription, limit int) map[strin
// TestCreateRoute_IGW_PublishesUngateForAssociatedSubnets: adding an IGW default route
// to a table must flip associated subnets to "egress allowed" (ungate).
func TestCreateRoute_IGW_PublishesUngateForAssociatedSubnets(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
ungate, err := svc.natsConn.SubscribeSync("vpc.ungate-subnet-egress")
require.NoError(t, err)
@@ -1038,11 +1099,7 @@ func TestCreateRoute_IGW_PublishesUngateForAssociatedSubnets(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
// Drain Associate-time gate event (gate, no egress yet).
for {
if _, err := ungate.NextMsg(100 * time.Millisecond); err != nil {
break
}
}
drainEvents(t, svc, ungate)
_, err = svc.CreateRoute(t.Context(), &ec2.CreateRouteInput{
RouteTableId: aws.String(rtbID),
@@ -1060,6 +1117,7 @@ func TestCreateRoute_IGW_PublishesUngateForAssociatedSubnets(t *testing.T) {
// default route from a table flips associated subnets back to "no egress"
// so the subscriber must reinstall the drop policy.
func TestDeleteRoute_IGW_PublishesGateForAssociatedSubnets(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
gate, err := svc.natsConn.SubscribeSync("vpc.gate-subnet-egress")
require.NoError(t, err)
@@ -1077,11 +1135,7 @@ func TestDeleteRoute_IGW_PublishesGateForAssociatedSubnets(t *testing.T) {
GatewayId: aws.String("igw-test1"),
}, testAccountID)
require.NoError(t, err)
for {
if _, err := gate.NextMsg(100 * time.Millisecond); err != nil {
break
}
}
drainEvents(t, svc, gate)
_, err = svc.DeleteRoute(t.Context(), &ec2.DeleteRouteInput{
RouteTableId: aws.String(rtbID),
@@ -1097,6 +1151,7 @@ func TestDeleteRoute_IGW_PublishesGateForAssociatedSubnets(t *testing.T) {
// TestAssociateRouteTable_NoDefaultRoute_PublishesGate: joining a table that
// has no 0.0.0.0/0 route must publish a gate event so the subnet is dropped.
func TestAssociateRouteTable_NoDefaultRoute_PublishesGate(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
gate, err := svc.natsConn.SubscribeSync("vpc.gate-subnet-egress")
require.NoError(t, err)
@@ -1118,6 +1173,7 @@ func TestAssociateRouteTable_NoDefaultRoute_PublishesGate(t *testing.T) {
// already carries 0.0.0.0/0 -> IGW must publish ungate so any pre-existing
// drop policy is removed.
func TestAssociateRouteTable_HasIGWRoute_PublishesUngate(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
ungate, err := svc.natsConn.SubscribeSync("vpc.ungate-subnet-egress")
require.NoError(t, err)
@@ -1145,6 +1201,7 @@ func TestAssociateRouteTable_HasIGWRoute_PublishesUngate(t *testing.T) {
// TestDisassociateRouteTable_MainHasNoEgress_PublishesGate: leaving an
// egress-carrying table for a main RT without 0.0.0.0/0 must gate.
func TestDisassociateRouteTable_MainHasNoEgress_PublishesGate(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
gate, err := svc.natsConn.SubscribeSync("vpc.gate-subnet-egress")
require.NoError(t, err)
@@ -1165,11 +1222,7 @@ func TestDisassociateRouteTable_MainHasNoEgress_PublishesGate(t *testing.T) {
SubnetId: aws.String("subnet-priv1"),
}, testAccountID)
require.NoError(t, err)
for {
if _, err := gate.NextMsg(100 * time.Millisecond); err != nil {
break
}
}
drainEvents(t, svc, gate)
_, err = svc.DisassociateRouteTable(t.Context(), &ec2.DisassociateRouteTableInput{
AssociationId: assocOut.AssociationId,
@@ -1184,6 +1237,7 @@ func TestDisassociateRouteTable_MainHasNoEgress_PublishesGate(t *testing.T) {
// TestCreateRoute_NonDefaultPrefix_NoGateEvent: only 0.0.0.0/0 routes drive
// gate decisions today (subnet-level scoping is what AWS RTs gate on).
func TestCreateRoute_NonDefaultPrefix_NoGateEvent(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
gate, err := svc.natsConn.SubscribeSync("vpc.gate-subnet-egress")
require.NoError(t, err)
@@ -1198,12 +1252,7 @@ func TestCreateRoute_NonDefaultPrefix_NoGateEvent(t *testing.T) {
SubnetId: aws.String("subnet-priv1"),
}, testAccountID)
require.NoError(t, err)
// Drain the Associate-time gate event.
for {
if _, err := gate.NextMsg(100 * time.Millisecond); err != nil {
break
}
}
drainEvents(t, svc, gate)
_, err = svc.CreateRoute(t.Context(), &ec2.CreateRouteInput{
RouteTableId: aws.String(rtbID),
@@ -1212,15 +1261,14 @@ func TestCreateRoute_NonDefaultPrefix_NoGateEvent(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
_, err = gate.NextMsg(200 * time.Millisecond)
assert.Error(t, err, "non-default prefix must not emit a gate event")
_, err = ungate.NextMsg(200 * time.Millisecond)
assert.Error(t, err, "non-default prefix must not emit an ungate event")
assertNoEvents(t, svc, gate, "non-default prefix must not emit a gate event")
assertNoEvents(t, svc, ungate, "non-default prefix must not emit an ungate event")
}
// TestCreateRoute_IGW_OnMainRT_FansOutGateToImplicitSubnets: when the main
// RT acquires an IGW default route, implicit-main subnets receive ungate.
func TestCreateRoute_IGW_OnMainRT_FansOutUngateToImplicitSubnets(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
ungate, err := svc.natsConn.SubscribeSync("vpc.ungate-subnet-egress")
require.NoError(t, err)
@@ -1235,12 +1283,13 @@ func TestCreateRoute_IGW_OnMainRT_FansOutUngateToImplicitSubnets(t *testing.T) {
}, testAccountID)
require.NoError(t, err)
got := drainGateSubnets(t, ungate, 3)
got := drainGateSubnets(t, svc, ungate, 3)
assert.Equal(t, "0.0.0.0/0", got["subnet-test1"])
assert.Equal(t, "0.0.0.0/0", got["subnet-priv1"])
}
func TestDisassociateRouteTable_RestoresMainRTRoutesForDepartingSubnet(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
addSub, err := svc.natsConn.SubscribeSync("vpc.add-igw-route")
require.NoError(t, err)
@@ -1262,20 +1311,14 @@ func TestDisassociateRouteTable_RestoresMainRTRoutesForDepartingSubnet(t *testin
}, testAccountID)
require.NoError(t, err)
// Drain everything published during setup so the Disassociate assertions
// only see post-disassociate events.
for {
if _, err := addSub.NextMsg(100 * time.Millisecond); err != nil {
break
}
}
drainEvents(t, svc, addSub)
_, err = svc.DisassociateRouteTable(t.Context(), &ec2.DisassociateRouteTableInput{
AssociationId: assocOut.AssociationId,
}, testAccountID)
require.NoError(t, err)
got := drainIGWSubnets(t, addSub, 2)
got := drainIGWSubnets(t, svc, addSub, 2)
assert.True(t, got["subnet-priv1"], "departing subnet must receive add event for main-RT routes")
assert.Len(t, got, 1)
}
@@ -1284,6 +1327,7 @@ func TestDisassociateRouteTable_RestoresMainRTRoutesForDepartingSubnet(t *testin
// carrying only the local route, every subnet in the VPC must receive a gate
// event. Exercises the IGW attach/detach fan-out hook.
func TestPublishGateDecisionsForVPC_GatesAllSubnetsWhenNoEgress(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
_, err := svc.CreateRouteTableForVPC("vpc-test1", "10.0.0.0/16", testAccountID, true, "")
require.NoError(t, err)
@@ -1294,7 +1338,7 @@ func TestPublishGateDecisionsForVPC_GatesAllSubnetsWhenNoEgress(t *testing.T) {
svc.PublishGateDecisionsForVPC(testAccountID, "vpc-test1", "0.0.0.0/0")
got := drainGateSubnets(t, gate, 4)
got := drainGateSubnets(t, svc, gate, 4)
assert.Equal(t, "0.0.0.0/0", got["subnet-test1"])
assert.Equal(t, "0.0.0.0/0", got["subnet-priv1"])
assert.Len(t, got, 2)
@@ -1304,6 +1348,7 @@ func TestPublishGateDecisionsForVPC_GatesAllSubnetsWhenNoEgress(t *testing.T) {
// effective RT carries 0.0.0.0/0 -> IGW must receive ungate; the other
// (implicit-main, no egress) subnet still receives gate.
func TestPublishGateDecisionsForVPC_UngatesSubnetWithIGWRoute(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
_, err := svc.CreateRouteTableForVPC("vpc-test1", "10.0.0.0/16", testAccountID, true, "")
require.NoError(t, err)
@@ -1331,8 +1376,8 @@ func TestPublishGateDecisionsForVPC_UngatesSubnetWithIGWRoute(t *testing.T) {
svc.PublishGateDecisionsForVPC(testAccountID, "vpc-test1", "0.0.0.0/0")
gateGot := drainGateSubnets(t, gate, 4)
ungateGot := drainGateSubnets(t, ungate, 4)
gateGot := drainGateSubnets(t, svc, gate, 4)
ungateGot := drainGateSubnets(t, svc, ungate, 4)
assert.Equal(t, "0.0.0.0/0", ungateGot["subnet-test1"], "subnet-test1 has IGW route → ungate")
assert.Equal(t, "0.0.0.0/0", gateGot["subnet-priv1"], "subnet-priv1 implicit-main no egress → gate")
assert.Len(t, ungateGot, 1)
@@ -1343,6 +1388,7 @@ func TestPublishGateDecisionsForVPC_UngatesSubnetWithIGWRoute(t *testing.T) {
// for the same VPC and asserts effectiveRouteTable deterministically picks the one
// with more routes (the "real" RT) over the orphan with only the implicit local route.
func TestEffectiveRouteTable_DeterministicWithDuplicateMain(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
now := time.Now()
@@ -1383,6 +1429,7 @@ func TestEffectiveRouteTable_DeterministicWithDuplicateMain(t *testing.T) {
// primary tiebreaker — a later-created RT with more routes beats an older
// orphan with only the implicit local route.
func TestPreferMain_RouteCountWinsOverCreatedAt(t *testing.T) {
t.Parallel()
older := &RouteTableRecord{RouteTableId: "rtb-a", CreatedAt: time.Unix(100, 0), Routes: []RouteRecord{{}}}
newer := &RouteTableRecord{RouteTableId: "rtb-b", CreatedAt: time.Unix(200, 0), Routes: []RouteRecord{{}, {}}}
assert.True(t, preferMain(older, newer), "more routes wins regardless of CreatedAt")
@@ -1392,6 +1439,7 @@ func TestPreferMain_RouteCountWinsOverCreatedAt(t *testing.T) {
// TestPreferMain_CreatedAtTiebreak: equal route counts → oldest CreatedAt
// wins (original record beats the late dup).
func TestPreferMain_CreatedAtTiebreak(t *testing.T) {
t.Parallel()
older := &RouteTableRecord{RouteTableId: "rtb-b", CreatedAt: time.Unix(100, 0), Routes: []RouteRecord{{}}}
newer := &RouteTableRecord{RouteTableId: "rtb-a", CreatedAt: time.Unix(200, 0), Routes: []RouteRecord{{}}}
assert.True(t, preferMain(newer, older), "older CreatedAt wins on equal route count")
@@ -1401,6 +1449,7 @@ func TestPreferMain_CreatedAtTiebreak(t *testing.T) {
// TestPublishGateDecisionsForVPC_EmptyVPCNoOp: VPC with zero subnets must
// silently emit no events.
func TestPublishGateDecisionsForVPC_EmptyVPCNoOp(t *testing.T) {
t.Parallel()
svc := setupTestService(t)
gate, err := svc.natsConn.SubscribeSync("vpc.gate-subnet-egress")
require.NoError(t, err)
@@ -1411,8 +1460,6 @@ func TestPublishGateDecisionsForVPC_EmptyVPCNoOp(t *testing.T) {
svc.PublishGateDecisionsForVPC(testAccountID, "vpc-empty", "0.0.0.0/0")
_, err = gate.NextMsg(150 * time.Millisecond)
assert.Error(t, err, "expected no gate event")
_, err = ungate.NextMsg(150 * time.Millisecond)
assert.Error(t, err, "expected no ungate event")
assertNoEvents(t, svc, gate, "expected no gate event")
assertNoEvents(t, svc, ungate, "expected no ungate event")
}