diff --git a/modules/aws/lambda.go b/modules/aws/lambda.go index e7a9be1b3..634d7290a 100644 --- a/modules/aws/lambda.go +++ b/modules/aws/lambda.go @@ -2,6 +2,7 @@ package aws import ( "encoding/json" + "errors" "fmt" "github.com/aws/aws-sdk-go/service/lambda" @@ -9,6 +10,58 @@ import ( "github.com/stretchr/testify/require" ) +type InvocationTypeOption string + +const ( + InvocationTypeRequestResponse InvocationTypeOption = "RequestResponse" + InvocationTypeDryRun = "DryRun" +) + +func (itype *InvocationTypeOption) Value() (string, error) { + if itype != nil { + switch *itype { + case + InvocationTypeRequestResponse, + InvocationTypeDryRun: + return string(*itype), nil + default: + msg := fmt.Sprintf("LambdaOptions.InvocationType, if specified, must either be \"%s\" or \"%s\"", + InvocationTypeRequestResponse, + InvocationTypeDryRun) + return "", errors.New(msg) + } + } + return string(InvocationTypeRequestResponse), nil +} + +// LambdaOptions contains additional parameters for InvokeFunctionWithParams(). +// It contains a subset of the fields found in the lambda.InvokeInput struct. +type LambdaOptions struct { + // InvocationType can be one of InvocationTypeOption values: + // * InvocationTypeRequestResponse (default) - Invoke the function + // synchronously. Keep the connection open until the function + // returns a response or times out. + // * InvocationTypeDryRun - Validate parameter values and verify + // that the user or role has permission to invoke the function. + InvocationType *InvocationTypeOption + + // Lambda function input; will be converted to JSON. + Payload interface{} +} + +// LambdaOutput contains the output from InvokeFunctionWithParams(). The +// fields may or may not have a value depending on the invocation type and +// whether an error occurred or not. +type LambdaOutput struct { + // The response from the function, or an error object. + Payload []byte + + // The HTTP status code for a successful request is in the 200 range. + // For RequestResponse invocation type, the status code is 200. + // For the DryRun invocation type, the status code is 204. + StatusCode *int64 +} + // InvokeFunction invokes a lambda function. func InvokeFunction(t testing.TestingT, region, functionName string, payload interface{}) []byte { out, err := InvokeFunctionE(t, region, functionName, payload) @@ -49,6 +102,67 @@ func InvokeFunctionE(t testing.TestingT, region, functionName string, payload in return out.Payload, nil } +// InvokeFunctionWithParams invokes a lambda function using parameters +// supplied in the LambdaOptions struct and returns values in a LambdaOutput +// struct. Checks for failure using "require". +func InvokeFunctionWithParams(t testing.TestingT, region, functionName string, input *LambdaOptions) *LambdaOutput { + out, err := InvokeFunctionWithParamsE(t, region, functionName, input) + require.NoError(t, err) + return out +} + +// InvokeFunctionWithParamsE invokes a lambda function using parameters +// supplied in the LambdaOptions struct. Returns the status code and payload +// in a LambdaOutput struct and the error. A non-nil error will either reflect +// a problem with the parameters supplied to this function or an error returned +// by the Lambda. +func InvokeFunctionWithParamsE(t testing.TestingT, region, functionName string, input *LambdaOptions) (*LambdaOutput, error) { + lambdaClient, err := NewLambdaClientE(t, region) + if err != nil { + return nil, err + } + + // Verify the InvocationType is one of the allowed values and report + // an error if it's not. By default the InvocationType will be + // "RequestResponse". + invocationType, err := input.InvocationType.Value() + if err != nil { + return nil, err + } + + invokeInput := &lambda.InvokeInput{ + FunctionName: &functionName, + InvocationType: &invocationType, + } + + if input.Payload != nil { + payloadJson, err := json.Marshal(input.Payload) + if err != nil { + return nil, err + } + invokeInput.Payload = payloadJson + } + + out, err := lambdaClient.Invoke(invokeInput) + if err != nil { + return nil, err + } + + // As this function supports different invocation types, it must + // then support different combinations of output other than just + // payload. + lambdaOutput := LambdaOutput{ + Payload: out.Payload, + StatusCode: out.StatusCode, + } + + if out.FunctionError != nil { + return &lambdaOutput, errors.New(*out.FunctionError) + } + + return &lambdaOutput, nil +} + type FunctionError struct { Message string StatusCode int64 diff --git a/test/terraform_aws_lambda_example_test.go b/test/terraform_aws_lambda_example_test.go index aab8594f0..1e28afccc 100644 --- a/test/terraform_aws_lambda_example_test.go +++ b/test/terraform_aws_lambda_example_test.go @@ -7,6 +7,7 @@ import ( "github.com/gruntwork-io/terratest/modules/aws" "github.com/gruntwork-io/terratest/modules/random" "github.com/gruntwork-io/terratest/modules/terraform" + test_structure "github.com/gruntwork-io/terratest/modules/test-structure" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -15,6 +16,10 @@ import ( func TestTerraformAwsLambdaExample(t *testing.T) { t.Parallel() + // Make a copy of the terraform module to a temporary directory. This allows running multiple tests in parallel + // against the same terraform module. + exampleFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/terraform-aws-lambda-example") + // Give this lambda function a unique ID for a name so we can distinguish it from any other lambdas // in your AWS account functionName := fmt.Sprintf("terratest-aws-lambda-example-%s", random.UniqueId()) @@ -26,7 +31,7 @@ func TestTerraformAwsLambdaExample(t *testing.T) { // terraform testing. terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ // The path to where our Terraform code is located - TerraformDir: "../examples/terraform-aws-lambda-example", + TerraformDir: exampleFolder, // Variables to pass to our Terraform code using -var options Vars: map[string]interface{}{ @@ -52,7 +57,7 @@ func TestTerraformAwsLambdaExample(t *testing.T) { assert.Equal(t, `"hi!"`, string(response)) // Invoke the function, this time causing it to error and capturing the error - response, err := aws.InvokeFunctionE(t, awsRegion, functionName, ExampleFunctionPayload{ShouldFail: true, Echo: "hi!"}) + _, err := aws.InvokeFunctionE(t, awsRegion, functionName, ExampleFunctionPayload{ShouldFail: true, Echo: "hi!"}) // Function-specific errors have their own special return functionError, ok := err.(*aws.FunctionError) @@ -62,6 +67,85 @@ func TestTerraformAwsLambdaExample(t *testing.T) { assert.Contains(t, string(functionError.Payload), "Failed to handle") } +// Annother example of how to test the Terraform module in +// examples/terraform-aws-lambda-example using Terratest, this time with +// the aws.InvokeFunctionWithParams. +func TestTerraformAwsLambdaWithParamsExample(t *testing.T) { + t.Parallel() + + // Make a copy of the terraform module to a temporary directory. This allows running multiple tests in parallel + // against the same terraform module. + exampleFolder := test_structure.CopyTerraformFolderToTemp(t, "../", "examples/terraform-aws-lambda-example") + + // Give this lambda function a unique ID for a name so we can distinguish it from any other lambdas + // in your AWS account + functionName := fmt.Sprintf("terratest-aws-lambda-withparams-example-%s", random.UniqueId()) + + // Pick a random AWS region to test in. This helps ensure your code works in all regions. + awsRegion := aws.GetRandomStableRegion(t, nil, nil) + + // Construct the terraform options with default retryable errors to handle the most common retryable errors in + // terraform testing. + terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{ + // The path to where our Terraform code is located + TerraformDir: exampleFolder, + + // Variables to pass to our Terraform code using -var options + Vars: map[string]interface{}{ + "function_name": functionName, + }, + + // Environment variables to set when running Terraform + EnvVars: map[string]string{ + "AWS_DEFAULT_REGION": awsRegion, + }, + }) + + // At the end of the test, run `terraform destroy` to clean up any resources that were created + defer terraform.Destroy(t, terraformOptions) + + // This will run `terraform init` and `terraform apply` and fail the test if there are any errors + terraform.InitAndApply(t, terraformOptions) + + // Call InvokeFunctionWithParms with an InvocationType of "DryRun". + // A "DryRun" invocation does not execute the function, so the example + // test function will not be checking the payload. + var invocationType aws.InvocationTypeOption = aws.InvocationTypeDryRun + input := &aws.LambdaOptions{InvocationType: &invocationType} + out := aws.InvokeFunctionWithParams(t, awsRegion, functionName, input) + + // With "DryRun", there's no message in the output, but there is + // a status code which will have a value of 204 for a successful + // invocation. + assert.Equal(t, int(*out.StatusCode), 204) + + // Invoke the function, this time causing the Lambda to error and + // capturing the error. + invocationType = aws.InvocationTypeRequestResponse + input = &aws.LambdaOptions{ + InvocationType: &invocationType, + Payload: ExampleFunctionPayload{ShouldFail: true, Echo: "hi!"}, + } + out, err := aws.InvokeFunctionWithParamsE(t, awsRegion, functionName, input) + + // The Lambda executed, but should have failed. + assert.Error(t, err, "Unhandled") + + // Make sure the function-specific error comes back + assert.Contains(t, string(out.Payload), "Failed to handle") + + // Call InvokeFunctionWithParamsE with a LambdaOptions struct that has + // an unsupported InvocationType. The function should fail. + invocationType = "Event" + input = &aws.LambdaOptions{ + InvocationType: &invocationType, + Payload: ExampleFunctionPayload{ShouldFail: false, Echo: "hi!"}, + } + out, err = aws.InvokeFunctionWithParamsE(t, awsRegion, functionName, input) + require.NotNil(t, err) + assert.Contains(t, err.Error(), "LambdaOptions.InvocationType, if specified, must either be \"RequestResponse\" or \"DryRun\"") +} + type ExampleFunctionPayload struct { Echo string ShouldFail bool