From 2cdb575000943cc400dfffa0d355400df2e7f04e Mon Sep 17 00:00:00 2001 From: benduncan <5178243+benduncan@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:34:53 +0800 Subject: [PATCH] Improved unit-testing and framework for service events --- hive/awsparser/awsparser.go | 129 ------------- hive/awsparser/awsparser_test.go | 67 ------- hive/gateway/ec2.go | 69 ++++--- hive/gateway/ec2/instance/RunInstances.go | 66 +++---- .../gateway/ec2/instance/RunInstances_test.go | 181 ++++++++++++++++++ hive/{awsparser => gateway}/errors-client.txt | 0 hive/{awsparser => gateway}/errors-common.txt | 0 hive/{awsparser => gateway}/errors-server.txt | 0 hive/gateway/errors.go | 3 + hive/gateway/gateway.go | 14 ++ hive/utils/utils.go | 16 ++ 11 files changed, 283 insertions(+), 262 deletions(-) delete mode 100644 hive/awsparser/awsparser.go delete mode 100644 hive/awsparser/awsparser_test.go create mode 100644 hive/gateway/ec2/instance/RunInstances_test.go rename hive/{awsparser => gateway}/errors-client.txt (100%) rename hive/{awsparser => gateway}/errors-common.txt (100%) rename hive/{awsparser => gateway}/errors-server.txt (100%) diff --git a/hive/awsparser/awsparser.go b/hive/awsparser/awsparser.go deleted file mode 100644 index 886f7301d..000000000 --- a/hive/awsparser/awsparser.go +++ /dev/null @@ -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'\nInvalidParameterValueInvalid value \'t3.mcro\' for InstanceType.dc74f0b6-b4dd-4aec-afb1-3d32539e0955' - -*/ - -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 - -} diff --git a/hive/awsparser/awsparser_test.go b/hive/awsparser/awsparser_test.go deleted file mode 100644 index 4ef32c96f..000000000 --- a/hive/awsparser/awsparser_test.go +++ /dev/null @@ -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") -} -*/ diff --git a/hive/gateway/ec2.go b/hive/gateway/ec2.go index 4c7c65df3..0e3327323 100644 --- a/hive/gateway/ec2.go +++ b/hive/gateway/ec2.go @@ -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 -} - */ diff --git a/hive/gateway/ec2/instance/RunInstances.go b/hive/gateway/ec2/instance/RunInstances.go index 13a24267a..c78391dce 100644 --- a/hive/gateway/ec2/instance/RunInstances.go +++ b/hive/gateway/ec2/instance/RunInstances.go @@ -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 } diff --git a/hive/gateway/ec2/instance/RunInstances_test.go b/hive/gateway/ec2/instance/RunInstances_test.go new file mode 100644 index 000000000..4b2f9c1fd --- /dev/null +++ b/hive/gateway/ec2/instance/RunInstances_test.go @@ -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 + +} diff --git a/hive/awsparser/errors-client.txt b/hive/gateway/errors-client.txt similarity index 100% rename from hive/awsparser/errors-client.txt rename to hive/gateway/errors-client.txt diff --git a/hive/awsparser/errors-common.txt b/hive/gateway/errors-common.txt similarity index 100% rename from hive/awsparser/errors-common.txt rename to hive/gateway/errors-common.txt diff --git a/hive/awsparser/errors-server.txt b/hive/gateway/errors-server.txt similarity index 100% rename from hive/awsparser/errors-server.txt rename to hive/gateway/errors-server.txt diff --git a/hive/gateway/errors.go b/hive/gateway/errors.go index 7e09ae34a..ec0c4a985 100644 --- a/hive/gateway/errors.go +++ b/hive/gateway/errors.go @@ -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."}, } diff --git a/hive/gateway/gateway.go b/hive/gateway/gateway.go index 927f091d3..f8dce6f35 100644 --- a/hive/gateway/gateway.go +++ b/hive/gateway/gateway.go @@ -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 + +} diff --git a/hive/utils/utils.go b/hive/utils/utils.go index c499210e6..da4632b15 100644 --- a/hive/utils/utils.go +++ b/hive/utils/utils.go @@ -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() +}