diff --git a/hive/gateway/ec2/instance/DescribeInstanceTypes_test.go b/hive/gateway/ec2/instance/DescribeInstanceTypes_test.go new file mode 100644 index 000000000..b8aa3b661 --- /dev/null +++ b/hive/gateway/ec2/instance/DescribeInstanceTypes_test.go @@ -0,0 +1,239 @@ +package gateway_ec2_instance + +import ( + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/hive/hive/utils" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDescribeInstanceTypes_SingleNode(t *testing.T) { + _, nc := startTestNATSServer(t) + + nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []*ec2.InstanceTypeInfo{ + {InstanceType: aws.String("t3.micro")}, + {InstanceType: aws.String("t3.small")}, + }, + }) + msg.Respond(data) + }) + + input := &ec2.DescribeInstanceTypesInput{} + output, err := DescribeInstanceTypes(input, nc, 1) + + require.NoError(t, err) + require.NotNil(t, output) + assert.Len(t, output.InstanceTypes, 2) + assert.Equal(t, "t3.micro", *output.InstanceTypes[0].InstanceType) + assert.Equal(t, "t3.small", *output.InstanceTypes[1].InstanceType) +} + +func TestDescribeInstanceTypes_DeduplicatesAcrossNodes(t *testing.T) { + _, nc := startTestNATSServer(t) + + // Node 1 + nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []*ec2.InstanceTypeInfo{ + {InstanceType: aws.String("t3.micro")}, + {InstanceType: aws.String("t3.small")}, + }, + }) + msg.Respond(data) + }) + + // Node 2 reports same types + nc2, err := nats.Connect(nc.ConnectedUrl()) + require.NoError(t, err) + defer nc2.Close() + + nc2.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []*ec2.InstanceTypeInfo{ + {InstanceType: aws.String("t3.micro")}, + {InstanceType: aws.String("m5.large")}, + }, + }) + msg.Respond(data) + }) + + nc.Flush() + nc2.Flush() + + input := &ec2.DescribeInstanceTypesInput{} + output, err := DescribeInstanceTypes(input, nc, 2) + + require.NoError(t, err) + require.NotNil(t, output) + // t3.micro should appear once, t3.small once, m5.large once = 3 unique + assert.Len(t, output.InstanceTypes, 3) + + seen := make(map[string]int) + for _, it := range output.InstanceTypes { + seen[*it.InstanceType]++ + } + assert.Equal(t, 1, seen["t3.micro"]) + assert.Equal(t, 1, seen["t3.small"]) + assert.Equal(t, 1, seen["m5.large"]) +} + +func TestDescribeInstanceTypes_CapacityFilterShowsDuplicates(t *testing.T) { + _, nc := startTestNATSServer(t) + + // Node 1 + nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []*ec2.InstanceTypeInfo{ + {InstanceType: aws.String("t3.micro")}, + }, + }) + msg.Respond(data) + }) + + // Node 2 reports same type + nc2, err := nats.Connect(nc.ConnectedUrl()) + require.NoError(t, err) + defer nc2.Close() + + nc2.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []*ec2.InstanceTypeInfo{ + {InstanceType: aws.String("t3.micro")}, + }, + }) + msg.Respond(data) + }) + + nc.Flush() + nc2.Flush() + + // With capacity filter = true, duplicates should be preserved + input := &ec2.DescribeInstanceTypesInput{ + Filters: []*ec2.Filter{ + { + Name: aws.String("capacity"), + Values: []*string{aws.String("true")}, + }, + }, + } + output, err := DescribeInstanceTypes(input, nc, 2) + + require.NoError(t, err) + require.NotNil(t, output) + // Both slots should appear since capacity filter is enabled + assert.Len(t, output.InstanceTypes, 2) +} + +func TestDescribeInstanceTypes_CapacityFilterFalseDeduplicates(t *testing.T) { + _, nc := startTestNATSServer(t) + + nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []*ec2.InstanceTypeInfo{ + {InstanceType: aws.String("t3.micro")}, + {InstanceType: aws.String("t3.micro")}, + }, + }) + msg.Respond(data) + }) + + // capacity=false should still deduplicate + input := &ec2.DescribeInstanceTypesInput{ + Filters: []*ec2.Filter{ + { + Name: aws.String("capacity"), + Values: []*string{aws.String("false")}, + }, + }, + } + output, err := DescribeInstanceTypes(input, nc, 1) + + require.NoError(t, err) + require.NotNil(t, output) + assert.Len(t, output.InstanceTypes, 1) +} + +func TestDescribeInstanceTypes_NoSubscribers(t *testing.T) { + _, nc := startTestNATSServer(t) + + input := &ec2.DescribeInstanceTypesInput{} + output, err := DescribeInstanceTypes(input, nc, 0) + + require.NoError(t, err) + require.NotNil(t, output) + assert.Empty(t, output.InstanceTypes) +} + +func TestDescribeInstanceTypes_NodeReturnsError(t *testing.T) { + _, nc := startTestNATSServer(t) + + nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) { + errorPayload := utils.GenerateErrorPayload("InternalError") + msg.Respond(errorPayload) + }) + + input := &ec2.DescribeInstanceTypesInput{} + output, err := DescribeInstanceTypes(input, nc, 1) + + require.NoError(t, err) + require.NotNil(t, output) + assert.Empty(t, output.InstanceTypes) +} + +func TestDescribeInstanceTypes_MalformedJSON(t *testing.T) { + _, nc := startTestNATSServer(t) + + nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) { + msg.Respond([]byte(`not valid json`)) + }) + + input := &ec2.DescribeInstanceTypesInput{} + output, err := DescribeInstanceTypes(input, nc, 1) + + require.NoError(t, err) + require.NotNil(t, output) + assert.Empty(t, output.InstanceTypes) +} + +func TestDescribeInstanceTypes_NilInstanceTypeSkipped(t *testing.T) { + _, nc := startTestNATSServer(t) + + nc.Subscribe("ec2.DescribeInstanceTypes", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstanceTypesOutput{ + InstanceTypes: []*ec2.InstanceTypeInfo{ + {InstanceType: aws.String("t3.micro")}, + {InstanceType: nil}, // nil InstanceType should be skipped in dedup + nil, // nil entry should be skipped + }, + }) + msg.Respond(data) + }) + + input := &ec2.DescribeInstanceTypesInput{} + output, err := DescribeInstanceTypes(input, nc, 1) + + require.NoError(t, err) + require.NotNil(t, output) + assert.Len(t, output.InstanceTypes, 1) + assert.Equal(t, "t3.micro", *output.InstanceTypes[0].InstanceType) +} + +func TestDescribeInstanceTypes_ClosedConnection(t *testing.T) { + _, nc := startTestNATSServer(t) + + closedNC, err := nats.Connect(nc.ConnectedUrl()) + require.NoError(t, err) + closedNC.Close() + + input := &ec2.DescribeInstanceTypesInput{} + _, err = DescribeInstanceTypes(input, closedNC, 1) + + require.Error(t, err) +} diff --git a/hive/gateway/ec2/instance/DescribeInstances_test.go b/hive/gateway/ec2/instance/DescribeInstances_test.go new file mode 100644 index 000000000..277767460 --- /dev/null +++ b/hive/gateway/ec2/instance/DescribeInstances_test.go @@ -0,0 +1,256 @@ +package gateway_ec2_instance + +import ( + "encoding/json" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/hive/hive/utils" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDescribeInstances_SingleNode(t *testing.T) { + _, nc := startTestNATSServer(t) + + reservation := &ec2.Reservation{ + ReservationId: aws.String("r-abc123"), + Instances: []*ec2.Instance{ + { + InstanceId: aws.String("i-001"), + InstanceType: aws.String("t3.micro"), + State: &ec2.InstanceState{Code: aws.Int64(16), Name: aws.String("running")}, + }, + }, + } + + _, err := nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{reservation}, + }) + msg.Respond(data) + }) + require.NoError(t, err) + + input := &ec2.DescribeInstancesInput{} + output, err := DescribeInstances(input, nc, 1) + + require.NoError(t, err) + require.NotNil(t, output) + require.Len(t, output.Reservations, 1) + assert.Equal(t, "r-abc123", *output.Reservations[0].ReservationId) + assert.Equal(t, "i-001", *output.Reservations[0].Instances[0].InstanceId) +} + +func TestDescribeInstances_MultipleNodes(t *testing.T) { + _, nc := startTestNATSServer(t) + + // Two nodes each return different instances + nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + ReservationId: aws.String("r-node1"), + Instances: []*ec2.Instance{ + {InstanceId: aws.String("i-node1-001")}, + }, + }, + }, + }) + msg.Respond(data) + }) + + nc2, err := nats.Connect(nc.ConnectedUrl()) + require.NoError(t, err) + defer nc2.Close() + + nc2.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + ReservationId: aws.String("r-node2"), + Instances: []*ec2.Instance{ + {InstanceId: aws.String("i-node2-001")}, + {InstanceId: aws.String("i-node2-002")}, + }, + }, + }, + }) + msg.Respond(data) + }) + + // Wait for subscriptions to propagate + nc.Flush() + nc2.Flush() + + input := &ec2.DescribeInstancesInput{} + output, err := DescribeInstances(input, nc, 2) + + require.NoError(t, err) + require.NotNil(t, output) + assert.Len(t, output.Reservations, 2) +} + +func TestDescribeInstances_NoSubscribers(t *testing.T) { + _, nc := startTestNATSServer(t) + + input := &ec2.DescribeInstancesInput{} + output, err := DescribeInstances(input, nc, 0) + + // No subscribers means timeout — function returns empty reservations, no error + require.NoError(t, err) + require.NotNil(t, output) + assert.Empty(t, output.Reservations) +} + +func TestDescribeInstances_NodeReturnsError(t *testing.T) { + _, nc := startTestNATSServer(t) + + nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) { + errorPayload := utils.GenerateErrorPayload("InternalError") + msg.Respond(errorPayload) + }) + + input := &ec2.DescribeInstancesInput{} + output, err := DescribeInstances(input, nc, 1) + + // Error responses from nodes are logged but don't fail the overall call + require.NoError(t, err) + require.NotNil(t, output) + assert.Empty(t, output.Reservations) +} + +func TestDescribeInstances_MixedResponses(t *testing.T) { + _, nc := startTestNATSServer(t) + + // Node 1: returns valid data + nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + ReservationId: aws.String("r-good"), + Instances: []*ec2.Instance{{InstanceId: aws.String("i-good")}}, + }, + }, + }) + msg.Respond(data) + }) + + // Node 2: returns error + nc2, err := nats.Connect(nc.ConnectedUrl()) + require.NoError(t, err) + defer nc2.Close() + + nc2.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) { + errorPayload := utils.GenerateErrorPayload("InternalError") + msg.Respond(errorPayload) + }) + + nc.Flush() + nc2.Flush() + + input := &ec2.DescribeInstancesInput{} + output, err := DescribeInstances(input, nc, 2) + + require.NoError(t, err) + require.NotNil(t, output) + // Only the valid node's reservation should appear + assert.Len(t, output.Reservations, 1) + assert.Equal(t, "r-good", *output.Reservations[0].ReservationId) +} + +func TestDescribeInstances_MalformedJSON(t *testing.T) { + _, nc := startTestNATSServer(t) + + nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) { + msg.Respond([]byte(`{invalid json`)) + }) + + input := &ec2.DescribeInstancesInput{} + output, err := DescribeInstances(input, nc, 1) + + // Malformed JSON from a node is skipped, not fatal + require.NoError(t, err) + require.NotNil(t, output) + assert.Empty(t, output.Reservations) +} + +func TestDescribeInstances_EmptyReservations(t *testing.T) { + _, nc := startTestNATSServer(t) + + nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstancesOutput{ + Reservations: nil, + }) + msg.Respond(data) + }) + + input := &ec2.DescribeInstancesInput{} + output, err := DescribeInstances(input, nc, 1) + + require.NoError(t, err) + require.NotNil(t, output) + assert.Empty(t, output.Reservations) +} + +func TestDescribeInstances_TimeoutCollection(t *testing.T) { + _, nc := startTestNATSServer(t) + + // Node responds after a delay (but within timeout) + nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) { + time.Sleep(500 * time.Millisecond) + data, _ := json.Marshal(&ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + {ReservationId: aws.String("r-delayed")}, + }, + }) + msg.Respond(data) + }) + + input := &ec2.DescribeInstancesInput{} + output, err := DescribeInstances(input, nc, 0) // 0 = timeout-based collection + + require.NoError(t, err) + require.NotNil(t, output) + assert.Len(t, output.Reservations, 1) +} + +func TestDescribeInstances_EarlyExitWithExpectedNodes(t *testing.T) { + _, nc := startTestNATSServer(t) + + nc.Subscribe("ec2.DescribeInstances", func(msg *nats.Msg) { + data, _ := json.Marshal(&ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + {ReservationId: aws.String("r-fast")}, + }, + }) + msg.Respond(data) + }) + + input := &ec2.DescribeInstancesInput{} + start := time.Now() + output, err := DescribeInstances(input, nc, 1) + duration := time.Since(start) + + require.NoError(t, err) + require.NotNil(t, output) + assert.Len(t, output.Reservations, 1) + // Should exit early, well before the 3-second timeout + assert.Less(t, duration, 2*time.Second) +} + +func TestDescribeInstances_ClosedConnection(t *testing.T) { + _, nc := startTestNATSServer(t) + + closedNC, err := nats.Connect(nc.ConnectedUrl()) + require.NoError(t, err) + closedNC.Close() + + input := &ec2.DescribeInstancesInput{} + _, err = DescribeInstances(input, closedNC, 1) + + require.Error(t, err) +} diff --git a/hive/gateway/ec2/instance/RunInstances_test.go b/hive/gateway/ec2/instance/RunInstances_test.go index d3dd5c917..22ff4df7d 100644 --- a/hive/gateway/ec2/instance/RunInstances_test.go +++ b/hive/gateway/ec2/instance/RunInstances_test.go @@ -115,6 +115,68 @@ func TestParseRunInstances(t *testing.T) { want: errors.New(awserrors.ErrorInvalidAMIIDMalformed), }, + { + name: "NilInput", + input: nil, + want: errors.New(awserrors.ErrorMissingParameter), + }, + + { + name: "NilMinCount", + input: &ec2.RunInstancesInput{ + ImageId: defaults.ImageId, + InstanceType: defaults.InstanceType, + MinCount: nil, + MaxCount: aws.Int64(1), + KeyName: defaults.KeyName, + SecurityGroupIds: defaults.SecurityGroupIds, + SubnetId: defaults.SubnetId, + }, + want: errors.New(awserrors.ErrorMissingParameter), + }, + + { + name: "MinCountGreaterThanMaxCount", + input: &ec2.RunInstancesInput{ + ImageId: defaults.ImageId, + InstanceType: defaults.InstanceType, + MinCount: aws.Int64(5), + MaxCount: aws.Int64(2), + KeyName: defaults.KeyName, + SecurityGroupIds: defaults.SecurityGroupIds, + SubnetId: defaults.SubnetId, + }, + want: errors.New(awserrors.ErrorInvalidParameterValue), + }, + + { + name: "NilImageId", + input: &ec2.RunInstancesInput{ + ImageId: nil, + InstanceType: defaults.InstanceType, + MinCount: aws.Int64(1), + MaxCount: aws.Int64(1), + KeyName: defaults.KeyName, + SecurityGroupIds: defaults.SecurityGroupIds, + SubnetId: defaults.SubnetId, + }, + want: errors.New(awserrors.ErrorMissingParameter), + }, + + { + name: "NilInstanceType", + input: &ec2.RunInstancesInput{ + ImageId: defaults.ImageId, + InstanceType: nil, + MinCount: aws.Int64(1), + MaxCount: aws.Int64(1), + KeyName: defaults.KeyName, + SecurityGroupIds: defaults.SecurityGroupIds, + SubnetId: defaults.SubnetId, + }, + want: errors.New(awserrors.ErrorMissingParameter), + }, + // Successful test { name: "ValidTest", diff --git a/hive/gateway/ec2/instance/StartInstances_test.go b/hive/gateway/ec2/instance/StartInstances_test.go new file mode 100644 index 000000000..5920857bc --- /dev/null +++ b/hive/gateway/ec2/instance/StartInstances_test.go @@ -0,0 +1,165 @@ +package gateway_ec2_instance + +import ( + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/hive/hive/qmp" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStartInstances_Success(t *testing.T) { + _, nc := startTestNATSServer(t) + + instanceID := "i-0123456789abcdef0" + + // Mock subscriber for the instance command topic + nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) { + var cmd qmp.Command + err := json.Unmarshal(msg.Data, &cmd) + require.NoError(t, err) + + assert.Equal(t, instanceID, cmd.ID) + assert.Equal(t, "cont", cmd.QMPCommand.Execute) + assert.True(t, cmd.Attributes.StartInstance) + assert.False(t, cmd.Attributes.StopInstance) + assert.False(t, cmd.Attributes.TerminateInstance) + + msg.Respond([]byte(`{"return":{}}`)) + }) + + input := &ec2.StartInstancesInput{ + InstanceIds: []*string{aws.String(instanceID)}, + } + + output, err := StartInstances(input, nc) + + require.NoError(t, err) + require.NotNil(t, output) + require.Len(t, output.StartingInstances, 1) + + sc := output.StartingInstances[0] + assert.Equal(t, instanceID, *sc.InstanceId) + assert.Equal(t, int64(0), *sc.CurrentState.Code) + assert.Equal(t, "pending", *sc.CurrentState.Name) + assert.Equal(t, int64(80), *sc.PreviousState.Code) + assert.Equal(t, "stopped", *sc.PreviousState.Name) +} + +func TestStartInstances_MultipleInstances(t *testing.T) { + _, nc := startTestNATSServer(t) + + ids := []string{"i-001", "i-002", "i-003"} + + for _, id := range ids { + id := id + nc.Subscribe("ec2.cmd."+id, func(msg *nats.Msg) { + msg.Respond([]byte(`{"return":{}}`)) + }) + } + + input := &ec2.StartInstancesInput{ + InstanceIds: []*string{aws.String(ids[0]), aws.String(ids[1]), aws.String(ids[2])}, + } + + output, err := StartInstances(input, nc) + + require.NoError(t, err) + require.NotNil(t, output) + assert.Len(t, output.StartingInstances, 3) + + for i, sc := range output.StartingInstances { + assert.Equal(t, ids[i], *sc.InstanceId) + assert.Equal(t, "pending", *sc.CurrentState.Name) + } +} + +func TestStartInstances_EmptyInstanceIds(t *testing.T) { + _, nc := startTestNATSServer(t) + + input := &ec2.StartInstancesInput{ + InstanceIds: []*string{}, + } + + _, err := StartInstances(input, nc) + require.Error(t, err) + assert.Contains(t, err.Error(), "no instance IDs provided") +} + +func TestStartInstances_NilInstanceIdSkipped(t *testing.T) { + _, nc := startTestNATSServer(t) + + instanceID := "i-valid" + nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) { + msg.Respond([]byte(`{"return":{}}`)) + }) + + input := &ec2.StartInstancesInput{ + InstanceIds: []*string{nil, aws.String(instanceID), nil}, + } + + output, err := StartInstances(input, nc) + + require.NoError(t, err) + require.NotNil(t, output) + // Only the valid ID should produce a state change + assert.Len(t, output.StartingInstances, 1) + assert.Equal(t, instanceID, *output.StartingInstances[0].InstanceId) +} + +func TestStartInstances_NATSRequestFails(t *testing.T) { + _, nc := startTestNATSServer(t) + + // No subscriber for this instance topic, so NATS request will fail + instanceID := "i-nosubscriber" + + input := &ec2.StartInstancesInput{ + InstanceIds: []*string{aws.String(instanceID)}, + } + + output, err := StartInstances(input, nc) + + require.NoError(t, err) // Function itself doesn't error, it records state change + require.NotNil(t, output) + require.Len(t, output.StartingInstances, 1) + + // On NATS failure, state should reflect "still stopped" + sc := output.StartingInstances[0] + assert.Equal(t, instanceID, *sc.InstanceId) + assert.Equal(t, int64(80), *sc.CurrentState.Code) + assert.Equal(t, "stopped", *sc.CurrentState.Name) + assert.Equal(t, int64(80), *sc.PreviousState.Code) + assert.Equal(t, "stopped", *sc.PreviousState.Name) +} + +func TestStartInstances_MixedSuccessAndFailure(t *testing.T) { + _, nc := startTestNATSServer(t) + + goodID := "i-good" + badID := "i-bad" // no subscriber + + nc.Subscribe("ec2.cmd."+goodID, func(msg *nats.Msg) { + msg.Respond([]byte(`{"return":{}}`)) + }) + + input := &ec2.StartInstancesInput{ + InstanceIds: []*string{aws.String(goodID), aws.String(badID)}, + } + + output, err := StartInstances(input, nc) + + require.NoError(t, err) + require.Len(t, output.StartingInstances, 2) + + // First instance succeeded: state → pending + assert.Equal(t, goodID, *output.StartingInstances[0].InstanceId) + assert.Equal(t, "pending", *output.StartingInstances[0].CurrentState.Name) + + // Second instance failed: state → stopped (unchanged) + assert.Equal(t, badID, *output.StartingInstances[1].InstanceId) + assert.Equal(t, "stopped", *output.StartingInstances[1].CurrentState.Name) +} diff --git a/hive/gateway/ec2/instance/StopInstances_test.go b/hive/gateway/ec2/instance/StopInstances_test.go new file mode 100644 index 000000000..b217d4a1a --- /dev/null +++ b/hive/gateway/ec2/instance/StopInstances_test.go @@ -0,0 +1,159 @@ +package gateway_ec2_instance + +import ( + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/hive/hive/qmp" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStopInstances_Success(t *testing.T) { + _, nc := startTestNATSServer(t) + + instanceID := "i-0123456789abcdef0" + + nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) { + var cmd qmp.Command + err := json.Unmarshal(msg.Data, &cmd) + require.NoError(t, err) + + assert.Equal(t, instanceID, cmd.ID) + assert.Equal(t, "system_powerdown", cmd.QMPCommand.Execute) + assert.True(t, cmd.Attributes.StopInstance) + assert.False(t, cmd.Attributes.TerminateInstance) + + msg.Respond([]byte(`{"return":{}}`)) + }) + + input := &ec2.StopInstancesInput{ + InstanceIds: []*string{aws.String(instanceID)}, + } + + output, err := StopInstances(input, nc) + + require.NoError(t, err) + require.NotNil(t, output) + require.Len(t, output.StoppingInstances, 1) + + sc := output.StoppingInstances[0] + assert.Equal(t, instanceID, *sc.InstanceId) + assert.Equal(t, int64(64), *sc.CurrentState.Code) + assert.Equal(t, "stopping", *sc.CurrentState.Name) + assert.Equal(t, int64(16), *sc.PreviousState.Code) + assert.Equal(t, "running", *sc.PreviousState.Name) +} + +func TestStopInstances_MultipleInstances(t *testing.T) { + _, nc := startTestNATSServer(t) + + ids := []string{"i-001", "i-002"} + + for _, id := range ids { + id := id + nc.Subscribe("ec2.cmd."+id, func(msg *nats.Msg) { + msg.Respond([]byte(`{"return":{}}`)) + }) + } + + input := &ec2.StopInstancesInput{ + InstanceIds: []*string{aws.String(ids[0]), aws.String(ids[1])}, + } + + output, err := StopInstances(input, nc) + + require.NoError(t, err) + require.Len(t, output.StoppingInstances, 2) + + for i, sc := range output.StoppingInstances { + assert.Equal(t, ids[i], *sc.InstanceId) + assert.Equal(t, "stopping", *sc.CurrentState.Name) + assert.Equal(t, "running", *sc.PreviousState.Name) + } +} + +func TestStopInstances_EmptyInstanceIds(t *testing.T) { + _, nc := startTestNATSServer(t) + + input := &ec2.StopInstancesInput{ + InstanceIds: []*string{}, + } + + _, err := StopInstances(input, nc) + require.Error(t, err) + assert.Contains(t, err.Error(), "no instance IDs provided") +} + +func TestStopInstances_NilInstanceIdSkipped(t *testing.T) { + _, nc := startTestNATSServer(t) + + instanceID := "i-valid" + nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) { + msg.Respond([]byte(`{"return":{}}`)) + }) + + input := &ec2.StopInstancesInput{ + InstanceIds: []*string{nil, aws.String(instanceID)}, + } + + output, err := StopInstances(input, nc) + + require.NoError(t, err) + assert.Len(t, output.StoppingInstances, 1) + assert.Equal(t, instanceID, *output.StoppingInstances[0].InstanceId) +} + +func TestStopInstances_NATSRequestFails(t *testing.T) { + _, nc := startTestNATSServer(t) + + instanceID := "i-nosubscriber" + + input := &ec2.StopInstancesInput{ + InstanceIds: []*string{aws.String(instanceID)}, + } + + output, err := StopInstances(input, nc) + + require.NoError(t, err) + require.Len(t, output.StoppingInstances, 1) + + // On NATS failure, state should reflect "still running" + sc := output.StoppingInstances[0] + assert.Equal(t, instanceID, *sc.InstanceId) + assert.Equal(t, int64(16), *sc.CurrentState.Code) + assert.Equal(t, "running", *sc.CurrentState.Name) + assert.Equal(t, int64(16), *sc.PreviousState.Code) + assert.Equal(t, "running", *sc.PreviousState.Name) +} + +func TestStopInstances_MixedSuccessAndFailure(t *testing.T) { + _, nc := startTestNATSServer(t) + + goodID := "i-good" + badID := "i-bad" + + nc.Subscribe("ec2.cmd."+goodID, func(msg *nats.Msg) { + msg.Respond([]byte(`{"return":{}}`)) + }) + + input := &ec2.StopInstancesInput{ + InstanceIds: []*string{aws.String(goodID), aws.String(badID)}, + } + + output, err := StopInstances(input, nc) + + require.NoError(t, err) + require.Len(t, output.StoppingInstances, 2) + + // First: success → stopping + assert.Equal(t, goodID, *output.StoppingInstances[0].InstanceId) + assert.Equal(t, "stopping", *output.StoppingInstances[0].CurrentState.Name) + + // Second: failure → still running + assert.Equal(t, badID, *output.StoppingInstances[1].InstanceId) + assert.Equal(t, "running", *output.StoppingInstances[1].CurrentState.Name) +} diff --git a/hive/gateway/ec2/instance/TerminateInstances_test.go b/hive/gateway/ec2/instance/TerminateInstances_test.go new file mode 100644 index 000000000..f22153d56 --- /dev/null +++ b/hive/gateway/ec2/instance/TerminateInstances_test.go @@ -0,0 +1,183 @@ +package gateway_ec2_instance + +import ( + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/hive/hive/qmp" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTerminateInstances_Success(t *testing.T) { + _, nc := startTestNATSServer(t) + + instanceID := "i-0123456789abcdef0" + + nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) { + var cmd qmp.Command + err := json.Unmarshal(msg.Data, &cmd) + require.NoError(t, err) + + assert.Equal(t, instanceID, cmd.ID) + assert.Equal(t, "system_powerdown", cmd.QMPCommand.Execute) + assert.True(t, cmd.Attributes.StopInstance) + assert.True(t, cmd.Attributes.TerminateInstance) + + msg.Respond([]byte(`{"return":{}}`)) + }) + + input := &ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String(instanceID)}, + } + + output, err := TerminateInstances(input, nc) + + require.NoError(t, err) + require.NotNil(t, output) + require.Len(t, output.TerminatingInstances, 1) + + sc := output.TerminatingInstances[0] + assert.Equal(t, instanceID, *sc.InstanceId) + assert.Equal(t, int64(32), *sc.CurrentState.Code) + assert.Equal(t, "shutting-down", *sc.CurrentState.Name) + assert.Equal(t, int64(16), *sc.PreviousState.Code) + assert.Equal(t, "running", *sc.PreviousState.Name) +} + +func TestTerminateInstances_MultipleInstances(t *testing.T) { + _, nc := startTestNATSServer(t) + + ids := []string{"i-001", "i-002", "i-003"} + + for _, id := range ids { + id := id + nc.Subscribe("ec2.cmd."+id, func(msg *nats.Msg) { + msg.Respond([]byte(`{"return":{}}`)) + }) + } + + input := &ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String(ids[0]), aws.String(ids[1]), aws.String(ids[2])}, + } + + output, err := TerminateInstances(input, nc) + + require.NoError(t, err) + require.Len(t, output.TerminatingInstances, 3) + + for i, sc := range output.TerminatingInstances { + assert.Equal(t, ids[i], *sc.InstanceId) + assert.Equal(t, "shutting-down", *sc.CurrentState.Name) + assert.Equal(t, "running", *sc.PreviousState.Name) + } +} + +func TestTerminateInstances_EmptyInstanceIds(t *testing.T) { + _, nc := startTestNATSServer(t) + + input := &ec2.TerminateInstancesInput{ + InstanceIds: []*string{}, + } + + _, err := TerminateInstances(input, nc) + require.Error(t, err) + assert.Contains(t, err.Error(), "no instance IDs provided") +} + +func TestTerminateInstances_NilInstanceIdSkipped(t *testing.T) { + _, nc := startTestNATSServer(t) + + instanceID := "i-valid" + nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) { + msg.Respond([]byte(`{"return":{}}`)) + }) + + input := &ec2.TerminateInstancesInput{ + InstanceIds: []*string{nil, aws.String(instanceID), nil}, + } + + output, err := TerminateInstances(input, nc) + + require.NoError(t, err) + assert.Len(t, output.TerminatingInstances, 1) + assert.Equal(t, instanceID, *output.TerminatingInstances[0].InstanceId) +} + +func TestTerminateInstances_NATSRequestFails(t *testing.T) { + _, nc := startTestNATSServer(t) + + instanceID := "i-nosubscriber" + + input := &ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String(instanceID)}, + } + + output, err := TerminateInstances(input, nc) + + require.NoError(t, err) + require.Len(t, output.TerminatingInstances, 1) + + // On NATS failure, state should reflect "still running" + sc := output.TerminatingInstances[0] + assert.Equal(t, instanceID, *sc.InstanceId) + assert.Equal(t, int64(16), *sc.CurrentState.Code) + assert.Equal(t, "running", *sc.CurrentState.Name) + assert.Equal(t, int64(16), *sc.PreviousState.Code) + assert.Equal(t, "running", *sc.PreviousState.Name) +} + +func TestTerminateInstances_MixedSuccessAndFailure(t *testing.T) { + _, nc := startTestNATSServer(t) + + goodID := "i-good" + badID := "i-bad" + + nc.Subscribe("ec2.cmd."+goodID, func(msg *nats.Msg) { + msg.Respond([]byte(`{"return":{}}`)) + }) + + input := &ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String(goodID), aws.String(badID)}, + } + + output, err := TerminateInstances(input, nc) + + require.NoError(t, err) + require.Len(t, output.TerminatingInstances, 2) + + // First: success → shutting-down + assert.Equal(t, goodID, *output.TerminatingInstances[0].InstanceId) + assert.Equal(t, "shutting-down", *output.TerminatingInstances[0].CurrentState.Name) + + // Second: failure → still running + assert.Equal(t, badID, *output.TerminatingInstances[1].InstanceId) + assert.Equal(t, "running", *output.TerminatingInstances[1].CurrentState.Name) +} + +func TestTerminateInstances_VerifiesQMPAttributes(t *testing.T) { + _, nc := startTestNATSServer(t) + + instanceID := "i-verify" + var receivedCmd qmp.Command + + nc.Subscribe("ec2.cmd."+instanceID, func(msg *nats.Msg) { + json.Unmarshal(msg.Data, &receivedCmd) + msg.Respond([]byte(`{"return":{}}`)) + }) + + input := &ec2.TerminateInstancesInput{ + InstanceIds: []*string{aws.String(instanceID)}, + } + + _, err := TerminateInstances(input, nc) + require.NoError(t, err) + + // Terminate should set both stop and terminate flags + assert.True(t, receivedCmd.Attributes.StopInstance, "StopInstance should be true for terminate") + assert.True(t, receivedCmd.Attributes.TerminateInstance, "TerminateInstance should be true") + assert.False(t, receivedCmd.Attributes.StartInstance, "StartInstance should be false") +} diff --git a/hive/gateway/ec2/instance/helpers_test.go b/hive/gateway/ec2/instance/helpers_test.go new file mode 100644 index 000000000..431edb122 --- /dev/null +++ b/hive/gateway/ec2/instance/helpers_test.go @@ -0,0 +1,42 @@ +package gateway_ec2_instance + +import ( + "testing" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/require" +) + +// startTestNATSServer starts an embedded NATS server for testing +func startTestNATSServer(t *testing.T) (*server.Server, *nats.Conn) { + t.Helper() + + opts := &server.Options{ + Host: "127.0.0.1", + Port: -1, + JetStream: false, + NoLog: true, + NoSigs: true, + } + + ns, err := server.NewServer(opts) + require.NoError(t, err, "Failed to create NATS server") + + go ns.Start() + + if !ns.ReadyForConnections(5 * time.Second) { + t.Fatal("NATS server failed to start") + } + + nc, err := nats.Connect(ns.ClientURL()) + require.NoError(t, err, "Failed to connect to NATS") + + t.Cleanup(func() { + nc.Close() + ns.Shutdown() + }) + + return ns, nc +} diff --git a/scripts/coverage.sh b/scripts/coverage.sh new file mode 100755 index 000000000..34de0ddac --- /dev/null +++ b/scripts/coverage.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run Go tests with coverage and report total code coverage. + +COVERPROFILE=$(mktemp /tmp/hive-coverage-XXXXXX.out) +trap 'rm -f "$COVERPROFILE"' EXIT + +echo "Running tests with coverage..." +echo "" +LOG_IGNORE=1 go test -timeout 120s -coverprofile="$COVERPROFILE" -covermode=atomic ./hive/... 2>&1 + +if [[ ! -s "$COVERPROFILE" ]]; then + echo "No coverage data collected." + exit 1 +fi + +echo "" +echo "=== Total ===" +go tool cover -func="$COVERPROFILE" | awk '/^total:/ { print $NF }'