Skip to content

Commit

Permalink
F aws lambda invocation crud support (#29367)
Browse files Browse the repository at this point in the history
* feature(lambda): Support CRUD

Add a lifecycle_scope argument which allows configuring the lambda_invocation
resource to trigger for each lifecycle transition of the terraform resource.
By adding the argument it is possible to maintain backwards compatibility
and enable lambda invocations for resource updates/deletes.

See the updated documentation for a detailed explanation of the feature.

* testing: Add test cases for CRUD functionality

Add test cases for all the different resource state transitions:
- create
- update
- delete

Also verify that the terraform_key argument works as expected.

Refactor existing tests a bit to avoid heavy duplication.

Verified using:
 `make testacc TESTS=TestAccLambdaInvocation_lifecycle_ PKG=lambda`

Both prior and after this commit all the tests pass.

* Add changelog

* refactor: test refactor and add test for onboarding to CRUD scope

* r/aws_lambda_invocation(test): add context to test helpers

* r/aws_lambda_invocation(test): use function local ssm parameter name

* r/aws_lambda_invocation(test): tidy test functions

* r/aws_lambda_invocation: tidy helper functions

* r/aws_lambda_invocation: force new on input change in create only mode

* chore: terrafmt acctest linting

* chore(providerlint): avoid hardcoded partitions

* chore(semgrep): prefer WithContext api calls

* chore(semgrep): test config func naming

* chore(semgrep): avoid lambda in func, const names

* r/aws_lambda_invocation: adjust CRUD lifecycle docs

* chore(markdown-lint): spacing before ordered list

---------

Co-authored-by: Jared Baker <[email protected]>
  • Loading branch information
pvbouwel and jar-b authored May 30, 2023
1 parent 22a7867 commit 2b317c7
Show file tree
Hide file tree
Showing 8 changed files with 588 additions and 88 deletions.
3 changes: 3 additions & 0 deletions .changelog/29367.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:enhancement
resource/aws_lambda_invocation: Add lifecycle_scope CRUD to invoke on each resource state transition
```
18 changes: 18 additions & 0 deletions internal/service/lambda/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,21 @@ const (
const (
propagationTimeout = 5 * time.Minute
)

const (
invocationActionCreate = "create"
invocationActionDelete = "delete"
invocationActionUpdate = "update"
)

const (
lifecycleScopeCreateOnly = "CREATE_ONLY"
lifecycleScopeCrud = "CRUD"
)

func lifecycleScope_Values() []string {
return []string{
lifecycleScopeCreateOnly,
lifecycleScopeCrud,
}
}
33 changes: 33 additions & 0 deletions internal/service/lambda/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package lambda

import (
"context"
"errors"
"strings"

"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

// customizeDiffValidateInput validates that `input` is JSON object when
// `lifecycle_scope` is not "CREATE_ONLY"
func customizeDiffValidateInput(_ context.Context, diff *schema.ResourceDiff, v interface{}) error {
if diff.Get("lifecycle_scope") == lifecycleScopeCreateOnly {
return nil
}
// input is validated to be valid JSON in the schema already.
inputNoSpaces := strings.TrimSpace(diff.Get("input").(string))
if strings.HasPrefix(inputNoSpaces, "{") && strings.HasSuffix(inputNoSpaces, "}") {
return nil
}

return errors.New(`lifecycle_scope other than "CREATE_ONLY" requires input to be a JSON object`)
}

// customizeDiffInputChangeWithCreateOnlyScope forces a new resource when `input` has
// a change and `lifecycle_scope` is set to "CREATE_ONLY"
func customizeDiffInputChangeWithCreateOnlyScope(_ context.Context, diff *schema.ResourceDiff, v interface{}) error {
if diff.HasChange("input") && diff.Get("lifecycle_scope").(string) == lifecycleScopeCreateOnly {
return diff.ForceNew("input")
}
return nil
}
123 changes: 110 additions & 13 deletions internal/service/lambda/invocation.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,29 @@ package lambda
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"log"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/lambda"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
"github.com/hashicorp/terraform-provider-aws/internal/conns"
"github.com/hashicorp/terraform-provider-aws/internal/errs/sdkdiag"
)

const defaultInvocationTerraformKey = "tf"

// @SDKResource("aws_lambda_invocation")
func ResourceInvocation() *schema.Resource {
return &schema.Resource{
CreateWithoutTimeout: resourceInvocationCreate,
ReadWithoutTimeout: resourceInvocationRead,
DeleteWithoutTimeout: resourceInvocationDelete,
UpdateWithoutTimeout: resourceInvocationUpdate,

Schema: map[string]*schema.Schema{
"function_name": {
Expand All @@ -31,7 +36,6 @@ func ResourceInvocation() *schema.Resource {
"input": {
Type: schema.TypeString,
Required: true,
ForceNew: true,
ValidateFunc: validation.StringIsJSON,
},
"qualifier": {
Expand All @@ -50,17 +54,121 @@ func ResourceInvocation() *schema.Resource {
ForceNew: true,
Elem: &schema.Schema{Type: schema.TypeString},
},
"lifecycle_scope": {
Type: schema.TypeString,
Optional: true,
Default: lifecycleScopeCreateOnly,
ValidateFunc: validation.StringInSlice(lifecycleScope_Values(), false),
},
"terraform_key": {
Type: schema.TypeString,
Optional: true,
Default: defaultInvocationTerraformKey,
},
},
CustomizeDiff: customdiff.Sequence(
customizeDiffValidateInput,
customizeDiffInputChangeWithCreateOnlyScope,
),
}
}

func resourceInvocationCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
return invoke(ctx, invocationActionCreate, d, meta)
}

func resourceInvocationRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
var diags diag.Diagnostics
return diags
}

func resourceInvocationUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
return invoke(ctx, invocationActionUpdate, d, meta)
}

func resourceInvocationDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
if !isCreateOnlyScope(d) {
log.Printf("[DEBUG] Lambda Invocation (%s) \"deleted\" by invocation & removing from state", d.Id())
return invoke(ctx, invocationActionDelete, d, meta)
}
var diags diag.Diagnostics
log.Printf("[DEBUG] Lambda Invocation (%s) \"deleted\" by removing from state", d.Id())
return diags
}

// buildInput makes sure that the user provided input is enriched for handling lifecycle events
//
// In order to make this a non-breaking change this function only manipulates input if
// the invocation is not only for creation of resources. In order for the lambda
// to understand the action it has to take we pass on the action that terraform wants to do
// on the invocation resource.
//
// Because Lambda functions by default are stateless we must pass the input from the previous
// invocation to allow implementation of delete/update at Lambda side.
func buildInput(d *schema.ResourceData, action string) ([]byte, error) {
if isCreateOnlyScope(d) {
jsonBytes := []byte(d.Get("input").(string))
return jsonBytes, nil
}
oldInputMap, newInputMap, err := getInputChange(d)
if err != nil {
log.Printf("[DEBUG] input serialization %s", err)
return nil, err
}

newInputMap[d.Get("terraform_key").(string)] = map[string]interface{}{
"action": action,
"prev_input": oldInputMap,
}
return json.Marshal(&newInputMap)
}

func getObjectFromJSONString(s string) (map[string]interface{}, error) {
if len(s) == 0 {
return nil, nil
}
var mapObject map[string]interface{}
if err := json.Unmarshal([]byte(s), &mapObject); err != nil {
log.Printf("[ERROR] input JSON deserialization '%s'", s)
return nil, err
}
return mapObject, nil
}

// getInputChange gets old an new input as maps
func getInputChange(d *schema.ResourceData) (map[string]interface{}, map[string]interface{}, error) {
old, new := d.GetChange("input")
oldMap, err := getObjectFromJSONString(old.(string))
if err != nil {
log.Printf("[ERROR] old input serialization '%s'", old.(string))
return nil, nil, err
}
newMap, err := getObjectFromJSONString(new.(string))
if err != nil {
log.Printf("[ERROR] new input serialization '%s'", new.(string))
return nil, nil, err
}
return oldMap, newMap, nil
}

// isCreateOnlyScope returns True if Lambda is only invoked when the resource is
// created or replaced.
//
// The original invocation logic only triggers on create.
func isCreateOnlyScope(d *schema.ResourceData) bool {
return d.Get("lifecycle_scope").(string) == lifecycleScopeCreateOnly
}

func invoke(ctx context.Context, action string, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
var diags diag.Diagnostics
conn := meta.(*conns.AWSClient).LambdaConn()

functionName := d.Get("function_name").(string)
qualifier := d.Get("qualifier").(string)
input := []byte(d.Get("input").(string))
input, err := buildInput(d, action)
if err != nil {
return sdkdiag.AppendErrorf(diags, "Lambda Invocation (%s) input transformation failed for input (%s): %s", d.Id(), d.Get("input").(string), err)
}

res, err := conn.InvokeWithContext(ctx, &lambda.InvokeInput{
FunctionName: aws.String(functionName),
Expand All @@ -82,14 +190,3 @@ func resourceInvocationCreate(ctx context.Context, d *schema.ResourceData, meta

return diags
}

func resourceInvocationRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
var diags diag.Diagnostics
return diags
}

func resourceInvocationDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
var diags diag.Diagnostics
log.Printf("[DEBUG] Lambda Invocation (%s) \"deleted\" by removing from state", d.Id())
return diags
}
Loading

0 comments on commit 2b317c7

Please sign in to comment.