Improved unit-testing and framework for service events

This commit is contained in:
benduncan
2025-10-08 11:34:53 +08:00
parent cc28dd0768
commit 2cdb575000
11 changed files with 283 additions and 262 deletions
-129
View File
@@ -1,129 +0,0 @@
package awsparser
import (
"bytes"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/utils"
)
/*
XML error
2025-10-02 20:19:36,786 - MainThread - urllib3.connectionpool - DEBUG - https://ec2.ap-southeast-2.amazonaws.com:443 "POST / HTTP/1.1" 400 None
2025-10-02 20:19:36,787 - MainThread - botocore.parsers - DEBUG - Response headers: {'x-amzn-RequestId': 'dc74f0b6-b4dd-4aec-afb1-3d32539e0955', 'Cache-Control': 'no-cache, no-store', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', 'vary': 'accept-encoding', 'Content-Type': 'text/xml;charset=UTF-8', 'Transfer-Encoding': 'chunked', 'Date': 'Thu, 02 Oct 2025 12:19:36 GMT', 'Connection': 'close', 'Server': 'AmazonEC2'}
2025-10-02 20:19:36,788 - MainThread - botocore.parsers - DEBUG - Response body:
b'<?xml version="1.0" encoding="UTF-8"?>\n<Response><Errors><Error><Code>InvalidParameterValue</Code><Message>Invalid value \'t3.mcro\' for InstanceType.</Message></Error></Errors><RequestID>dc74f0b6-b4dd-4aec-afb1-3d32539e0955</RequestID></Response>'
*/
type runInstancesResponse struct {
XMLName xml.Name `xml:"RunInstancesResponse"`
Xmlns string `xml:"xmlns,attr"`
RunInstancesResult runInstancesResult `xml:"RunInstancesResult"`
ResponseMetadata responseMetadata `xml:"ResponseMetadata"`
}
type runInstancesResult struct {
Reservation *ec2.Reservation `xml:"reservationSet>item"`
}
type responseMetadata struct {
RequestID string `xml:"RequestId"`
}
func EC2_Process_RunInstances(jsonData []byte) (output []byte, err error) {
var input ec2.RunInstancesInput
err = json.Unmarshal(jsonData, &input)
if err != nil {
return output, fmt.Errorf("failed to unmarshal JSON to RunInstancesInput: %w", err)
}
// Here you would add the logic to actually create the instance in your system.
// For this example, we'll just create a dummy response.
instance := &ec2.Instance{
InstanceId: aws.String("i-0123456789abcdef0"),
State: &ec2.InstanceState{
Code: aws.Int64(16),
Name: aws.String("running"),
},
ImageId: input.ImageId,
InstanceType: input.InstanceType,
KeyName: input.KeyName,
SubnetId: input.SubnetId,
}
reservation := &ec2.Reservation{
Instances: []*ec2.Instance{instance},
OwnerId: aws.String("123456789012"),
}
// Return as JSON, to simulate the NATS response
jsonResponse, err := json.Marshal(reservation)
if err != nil {
return output, fmt.Errorf("failed to marshal reservation to JSON: %w", err)
}
return jsonResponse, nil
}
func EC2_RunInstances(input *ec2.RunInstancesInput) (output bytes.Buffer, err error) {
// Simple validation
if input.MinCount == nil || input.MaxCount == nil || *input.MinCount < 1 || *input.MaxCount < 1 || *input.MinCount > *input.MaxCount {
return output, fmt.Errorf("Invalid MinCount or MaxCount")
}
if input.ImageId == nil || *input.ImageId == "" {
return output, fmt.Errorf("ImageId is required")
}
if input.InstanceType == nil || *input.InstanceType == "" {
return output, fmt.Errorf("InstanceType is required")
}
// Marshal to JSON, to send over the wire for processing (NATS)
jsonData, err := json.Marshal(input)
if err != nil {
return output, fmt.Errorf("failed to marshal input to JSON: %w", err)
}
// Run the simulated JSON request via NATS, which will return a JSON response
jsonResp, err := EC2_Process_RunInstances(jsonData)
if err != nil {
return output, fmt.Errorf("failed to process RunInstances request: %w", err)
}
// Unmarshal the JSON response back into a Reservation struct
var reservation ec2.Reservation
err = json.Unmarshal(jsonResp, &reservation)
if err != nil {
return output, fmt.Errorf("failed to unmarshal JSON response to Reservation: %w", err)
}
// Convert to XML
payload := runInstancesResponse{
Xmlns: "http://ec2.amazonaws.com/doc/2016-11-15/",
RunInstancesResult: runInstancesResult{
Reservation: &reservation,
},
ResponseMetadata: responseMetadata{
RequestID: "00000000-0000-0000-0000-000000000000",
},
}
output, err = utils.MarshalToXML(payload)
if err != nil {
return output, errors.New("failed to marshal response to XML")
}
return output, nil
}
-67
View File
@@ -1,67 +0,0 @@
package awsparser
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/assert"
)
func TestParseRunInstance(t *testing.T) {
runInstance := &ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String("t2.micro"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
KeyName: aws.String("my-key-pair"),
SecurityGroupIds: []*string{
aws.String("sg-0123456789abcdef0"),
},
SubnetId: aws.String("subnet-6e7f829e"),
}
// Call the function to process the RunInstances request
response, err := EC2_RunInstances(runInstance)
if err != nil {
t.Fatalf("Failed to run instances: %v", err)
}
spew.Dump(response)
}
func TestInvalidRunInstance(t *testing.T) {
runInstance := &ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String("t2.micro"),
MinCount: aws.Int64(0),
MaxCount: aws.Int64(0),
KeyName: aws.String("my-key-pair"),
SecurityGroupIds: []*string{
aws.String("sg-0123456789abcdef0"),
},
SubnetId: aws.String("subnet-6e7f829e"),
}
// Call the function to process the RunInstances request
response, err := EC2_RunInstances(runInstance)
assert.Error(t, err, "Expected error for invalid RunInstances input")
spew.Dump(response)
}
/*
func TestGenerateEC2ErrorResponse(t *testing.T) {
errorCode := "InvalidInstanceID.NotFound"
errorMessage := "The instance ID 'i-1234567890abcdef0' does not exist"
requestId := "123e4567-e89b-12d3-a456-426614174000"
xmlResponse := GenerateEC2ErrorResponse(errorCode, errorMessage, requestId)
assert.Contains(t, string(xmlResponse), "InvalidInstanceID.NotFound")
}
*/
+38 -31
View File
@@ -3,9 +3,13 @@ package gateway
import (
"errors"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/davecgh/go-spew/spew"
"github.com/gofiber/fiber/v2"
"github.com/mulgadc/hive/hive/awsec2query"
gateway_ec2_core "github.com/mulgadc/hive/hive/gateway/ec2/core"
gateway_ec2_instance "github.com/mulgadc/hive/hive/gateway/ec2/instance"
"github.com/mulgadc/hive/hive/utils"
)
func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error {
@@ -21,7 +25,39 @@ func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error {
case "DescribeInstances":
xmlOutput, err = gateway_ec2_instance.DescribeInstances(ctx, queryArgs)
case "RunInstances":
xmlOutput, err = gateway_ec2_instance.RunInstances(ctx, queryArgs)
var input = &ec2.RunInstancesInput{}
err = awsec2query.QueryParamsToStruct(queryArgs, input)
if err != nil {
return err
}
output, err := gateway_ec2_instance.RunInstances(input)
if err != nil {
return err
}
// Convert to XML
payload := utils.GenerateXMLPayload("RunInstanceResponse", output)
spew.Dump(payload)
/*
payload := gateway_ec2_instance.RunInstancesResponse{
Reservation: &output,
}
*/
xmlBuffer, err := utils.MarshalToXML(payload)
xmlOutput = xmlBuffer.Bytes()
if err != nil {
return errors.New("failed to marshal response to XML")
}
case "CreateKeyPair":
xmlOutput, err = gateway_ec2_core.CreateKeyPair(ctx, queryArgs)
default:
@@ -34,6 +70,7 @@ func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error {
}
ctx.Status(fiber.StatusOK).Type("text/xml").Send(xmlOutput)
return nil
}
@@ -154,34 +191,4 @@ func (gw *GatewayConfig) EC2_DescribeInstances(ctx *fiber.Ctx, args map[string]s
return ctx.SendString(response)
}
func (gw *GatewayConfig) EC2_RunInstances(ctx *fiber.Ctx, args map[string]string) error {
slog.Info("EC2 RunInstances called")
// Convert HTTP post args, to the input struct
var ec2RunInstance = &ec2.RunInstancesInput{}
awsec2query.QueryParamsToStruct(args, ec2RunInstance)
// Send the query
xmlResponse, err := awsparser.EC2_RunInstances(ec2RunInstance)
if err != nil {
slog.Error("EC2 RunInstances error", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "EC2 RunInstances error")
}
// Return the XML response
ctx.Set("Content-Type", "text/xml")
return ctx.XML(xmlResponse)
return nil
}
func (gw *GatewayConfig) EC2_CreateKeyPair(ctx *fiber.Ctx, args map[string]string) error {
slog.Info("EC2 CreateKeyPair called")
return nil
}
*/
+31 -35
View File
@@ -4,22 +4,16 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/gofiber/fiber/v2"
"github.com/mulgadc/hive/hive/awsec2query"
"github.com/mulgadc/hive/hive/utils"
)
type runInstancesResponse struct {
type RunInstancesResponse struct {
Reservation *ec2.Reservation `locationName:"RunInstancesResponse"`
}
type runInstancesResult struct {
Reservation *ec2.Reservation `xml:"reservationSet>item"`
}
/*
Sample JSON:
@@ -134,58 +128,60 @@ Sample JSON:
},
*/
// AUTO-GENERATED: RunInstances
// Generated from Function name: RunInstances
func RunInstances(ctx *fiber.Ctx, args map[string]string) ([]byte, error) {
func RunInstances(input *ec2.RunInstancesInput) (reservation ec2.Reservation, err error) {
// Check required args from JSON above
// required:[
// "MaxCount",
// "MinCount"
// ]
if _, ok := args["MaxCount"]; !ok {
return nil, errors.New("InvalidParameter")
}
if _, ok := args["MinCount"]; !ok {
return nil, errors.New("InvalidParameter")
if *input.MinCount == 0 {
return reservation, errors.New("InvalidParameterValue")
}
// Generated from input shape: RunInstancesRequest
var input = &ec2.RunInstancesInput{}
awsec2query.QueryParamsToStruct(args, input)
if *input.MaxCount == 0 {
return reservation, errors.New("InvalidParameterValue")
}
// Additional validation from EC2 spec
if *input.MinCount > *input.MaxCount {
return reservation, errors.New("InvalidParameterValue")
}
if *input.ImageId == "" {
return reservation, errors.New("MissingParameter")
}
if *input.InstanceType == "" {
return reservation, errors.New("MissingParameter")
}
if !strings.HasPrefix(*aws.String(*input.ImageId), "ami-") {
return reservation, errors.New("InvalidAMIID.Malformed")
}
// Marshal to JSON, to send over the wire for processing (NATS)
jsonData, err := json.Marshal(input)
if err != nil {
return nil, fmt.Errorf("failed to marshal input to JSON: %w", err)
return reservation, fmt.Errorf("failed to marshal input to JSON: %w", err)
}
// Run the simulated JSON request via NATS, which will return a JSON response
jsonResp, err := EC2_Process_RunInstances(jsonData)
if err != nil {
return nil, fmt.Errorf("failed to process RunInstances request: %w", err)
return reservation, fmt.Errorf("failed to process RunInstances request: %w", err)
}
// Unmarshal the JSON response back into a Reservation struct
var reservation ec2.Reservation
err = json.Unmarshal(jsonResp, &reservation)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON response to Reservation: %w", err)
return reservation, fmt.Errorf("failed to unmarshal JSON response to Reservation: %w", err)
}
// Convert to XML
payload := runInstancesResponse{
Reservation: &reservation,
}
output, err := utils.MarshalToXML(payload)
if err != nil {
return output.Bytes(), errors.New("failed to marshal response to XML")
}
return output.Bytes(), nil
return reservation, nil
}
@@ -0,0 +1,181 @@
package gateway_ec2_instance
import (
"errors"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/stretchr/testify/assert"
)
func TestParseRunInstances(t *testing.T) {
var defaults = ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String("t2.micro"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
KeyName: aws.String("my-key-pair"),
SecurityGroupIds: []*string{
aws.String("sg-0123456789abcdef0"),
},
SubnetId: aws.String("subnet-6e7f829e"),
}
// Group multiple tests
tests := []struct {
name string
input *ec2.RunInstancesInput
want error
}{
{
name: "InvalidMinCount",
input: &ec2.RunInstancesInput{
ImageId: defaults.ImageId,
InstanceType: defaults.InstanceType,
MinCount: aws.Int64(0),
MaxCount: aws.Int64(1),
KeyName: defaults.KeyName,
SecurityGroupIds: defaults.SecurityGroupIds,
SubnetId: defaults.SubnetId,
},
want: errors.New("InvalidParameterValue"),
},
{
name: "InvalidMaxCount",
input: &ec2.RunInstancesInput{
ImageId: defaults.ImageId,
InstanceType: defaults.InstanceType,
MinCount: aws.Int64(1),
MaxCount: aws.Int64(0),
KeyName: defaults.KeyName,
SecurityGroupIds: defaults.SecurityGroupIds,
SubnetId: defaults.SubnetId,
},
want: errors.New("InvalidParameterValue"),
},
{
name: "InvalidMinCount",
input: &ec2.RunInstancesInput{
ImageId: defaults.ImageId,
InstanceType: defaults.InstanceType,
MinCount: aws.Int64(0),
MaxCount: aws.Int64(1),
KeyName: defaults.KeyName,
SecurityGroupIds: defaults.SecurityGroupIds,
SubnetId: defaults.SubnetId,
},
want: errors.New("InvalidParameterValue"),
},
{
name: "InvalidNoImageId",
input: &ec2.RunInstancesInput{
ImageId: aws.String(""),
InstanceType: defaults.InstanceType,
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
KeyName: defaults.KeyName,
SecurityGroupIds: defaults.SecurityGroupIds,
SubnetId: defaults.SubnetId,
},
want: errors.New("MissingParameter"),
},
{
name: "InvalidNoInstanceType",
input: &ec2.RunInstancesInput{
ImageId: defaults.ImageId,
InstanceType: aws.String(""),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
KeyName: defaults.KeyName,
SecurityGroupIds: defaults.SecurityGroupIds,
SubnetId: defaults.SubnetId,
},
want: errors.New("MissingParameter"),
},
{
name: "InvalidNoInstanceType",
input: &ec2.RunInstancesInput{
ImageId: aws.String("invalid-name-here"),
InstanceType: defaults.InstanceType,
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
KeyName: defaults.KeyName,
SecurityGroupIds: defaults.SecurityGroupIds,
SubnetId: defaults.SubnetId,
},
want: errors.New("InvalidAMIID.Malformed"),
},
// Successful test
{
name: "ValidTest",
input: &ec2.RunInstancesInput{
ImageId: defaults.ImageId,
InstanceType: defaults.InstanceType,
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
KeyName: defaults.KeyName,
SecurityGroupIds: defaults.SecurityGroupIds,
SubnetId: defaults.SubnetId,
},
want: nil,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
response, err := RunInstances(test.input)
// Use assert to check if the error is as expected
assert.Equal(t, test.want, err)
if err != nil {
assert.Len(t, response.Instances, 0)
//assert.Nil(t, response)
} else {
// Check expected output
assert.Len(t, response.Instances, 1)
// ImageID returned
if len(response.Instances) > 0 {
// ImageId matches our AMI
assert.Equal(t, defaults.ImageId, response.Instances[0].ImageId)
// InstanceID starts with i-
assert.True(t, true, strings.HasPrefix(*response.Instances[0].ImageId, "i-"))
// InstanceType matches
assert.Equal(t, defaults.InstanceType, response.Instances[0].InstanceType)
// KeyName matches
assert.Equal(t, defaults.KeyName, response.Instances[0].KeyName)
// State should be 16, booting.
assert.Equal(t, aws.Int64(16), response.Instances[0].State.Code)
assert.Equal(t, aws.String("running"), response.Instances[0].State.Name)
// Subnet should match
assert.Equal(t, defaults.SubnetId, response.Instances[0].SubnetId)
}
}
})
}
// Additional test
}
+3
View File
@@ -39,4 +39,7 @@ var ErrorLookup = map[string]ErrorMessage{
Message: "You are not authorized to perform this operation.",
},
"UnsupportedOperation": {HTTPCode: 400, Message: "The action requested is not supported."},
// AMI
"InvalidAMIID.Malformed": {HTTPCode: 400, Message: "The specified AMI ID is malformed. Ensure that you provide the full AMI ID, in the form ami-xxxxxxxxxxxxxxxxx."},
}
+14
View File
@@ -9,6 +9,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/mulgadc/hive/hive/awsec2query"
"github.com/mulgadc/predastore/s3"
"github.com/google/uuid"
@@ -255,3 +256,16 @@ func GenerateEC2ErrorResponse(code, message, requestID string) (output []byte) {
return output
}
func ParseArgsToStruct(input *interface{}, args map[string]string) (err error) {
// Generated from input shape: RunInstancesRequest
err = awsec2query.QueryParamsToStruct(args, input)
if err != nil {
return errors.New("InvalidParameter")
}
return nil
}
+16
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"syscall"
"time"
@@ -217,3 +218,18 @@ func MarshalToXML(payload interface{}) (bytes.Buffer, error) {
return buf, nil
}
// wrapWithLocation decorates payload with the requested locationName tag.
func GenerateXMLPayload(locationName string, payload interface{}) interface{} {
t := reflect.StructOf([]reflect.StructField{
{
Name: "Value",
Type: reflect.TypeOf(payload),
Tag: reflect.StructTag(`locationName:"` + locationName + `"`),
},
})
v := reflect.New(t).Elem()
v.Field(0).Set(reflect.ValueOf(payload))
return v.Interface()
}