Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add function to invoke a lambda using an invocation type of "DryRun" #817

Merged
merged 6 commits into from
Apr 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions modules/aws/lambda.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,66 @@ package aws

import (
"encoding/json"
"errors"
"fmt"

"github.com/aws/aws-sdk-go/service/lambda"
"github.com/gruntwork-io/terratest/modules/testing"
"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)
Expand Down Expand Up @@ -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
Expand Down
88 changes: 86 additions & 2 deletions test/terraform_aws_lambda_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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())
Expand All @@ -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{}{
Expand All @@ -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)
Expand All @@ -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
Expand Down