diff --git a/provider/go.mod b/provider/go.mod index e0e6401a2e..33d0f475ab 100644 --- a/provider/go.mod +++ b/provider/go.mod @@ -4,6 +4,7 @@ go 1.21 require ( github.com/apparentlymart/go-cidr v1.1.0 + github.com/aws/aws-lambda-go v1.47.0 github.com/aws/aws-sdk-go v1.50.36 github.com/aws/aws-sdk-go-v2 v1.32.3 github.com/aws/aws-sdk-go-v2/config v1.27.11 diff --git a/provider/go.sum b/provider/go.sum index e74891ebad..c391674ce5 100644 --- a/provider/go.sum +++ b/provider/go.sum @@ -91,6 +91,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI= +github.com/aws/aws-lambda-go v1.47.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-sdk-go v1.50.36 h1:PjWXHwZPuTLMR1NIb8nEjLucZBMzmf84TLoLbD8BZqk= github.com/aws/aws-sdk-go v1.50.36/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go-v2 v1.32.3 h1:T0dRlFBKcdaUPGNtkBSwHZxrtis8CQU17UpNBZYd0wk= diff --git a/provider/pkg/metadata/metadata.go b/provider/pkg/metadata/metadata.go index 3929019c81..f63f4aeead 100644 --- a/provider/pkg/metadata/metadata.go +++ b/provider/pkg/metadata/metadata.go @@ -50,3 +50,7 @@ type CloudAPIFunction struct { // ExtensionResourceToken is a Pulumi token for the resource to deploy // custom third-party CloudFormation types. const ExtensionResourceToken = "aws-native:index:ExtensionResource" + +// CfnCustomResourceToken is a Pulumi token for the resource to deploy +// CloudFormation custom resources. +const CfnCustomResourceToken = "aws-native:cloudformation:CustomResourceEmulator" diff --git a/provider/pkg/resources/cfn_custom_resource.go b/provider/pkg/resources/cfn_custom_resource.go new file mode 100644 index 0000000000..eb178e8b71 --- /dev/null +++ b/provider/pkg/resources/cfn_custom_resource.go @@ -0,0 +1,681 @@ +package resources + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "time" + + "github.com/aws/aws-lambda-go/cfn" + "github.com/golang/glog" + "github.com/google/uuid" + "github.com/pulumi/pulumi-aws-native/provider/pkg/client" + "github.com/pulumi/pulumi-aws-native/provider/pkg/naming" + "github.com/pulumi/pulumi-go-provider/resourcex" + pschema "github.com/pulumi/pulumi/pkg/v3/codegen/schema" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource/urn" +) + +// This is the default timeout for custom resource operations in CloudFormation +const DefaultCustomResourceTimeout = 60 * time.Minute + +var lambdaFunctionArnRegex = regexp.MustCompile(`^arn:[^:]+:lambda:[^:]+:[^:]+:function:[a-zA-Z0-9-_]+(:[a-zA-Z0-9-_]+)?$`) + +type Clock interface { + Now() time.Time + Since(time.Time) time.Duration +} + +type realClock struct{} + +func (realClock) Now() time.Time { + return time.Now() +} + +func (realClock) Since(t time.Time) time.Duration { + return time.Since(t) +} + +type cfnCustomResource struct { + providerName string + lambdaClient client.LambdaClient + s3Client client.S3Client + clock Clock +} + +// Check CfnCustomResource implements CustomResource +var _ CustomResource = (*cfnCustomResource)(nil) + +// The CloudFormation Custom Resource Emulator provides a bridge between Pulumi's resource model +// and AWS CloudFormation Custom Resources. It enables Pulumi programs to interact with CloudFormation +// Custom Resources while maintaining Pulumi's strong typing and error handling capabilities. +// +// When are Cloudformation Custom Resources used? +// - Managing resources outside of AWS (e.g., GitHub repositories, external APIs) +// - Implementing complex provisioning logic not possible with standard CloudFormation +// - Performing custom validations or transformations during resource lifecycle events +// - Integrating with legacy systems or third-party services +// - Implementing organization-specific infrastructure patterns +// +// The Custom Resource implementation (i.e. the Lambda function) is responsible for: +// - Processing the request based on the RequestType +// - Sending a response to the pre-signed URL. The response should include: +// - success/failure status and a reason +// - a PhysicalResourceId for the resource +// - optionally return data that can be referenced by other resources +// +// Example CloudFormation Custom Resource: +// Resources: +// MyCustomResource: +// Type: Custom::MyResource +// Properties: +// ServiceToken: arn:aws:lambda:region:account:function:name +// Property1: value1 +// Property2: value2 +// +// +// This emulator implements this lifecycle in Pulumi by: +// - Translating Pulumi resource operations to CloudFormation custom resource requests +// - Managing the asynchronous response collection via S3 with pre-signed URLs +// - Handling timeout and error scenarios according to CloudFormation specifications +// +// Architecture: +// ┌──────────────┐ ┌─────────────┐ ┌──────────────┐ +// │ Pulumi │ CRUD │ Custom │ AWS │ Lambda │ +// │ Engine ├────────►│ Resource ├────────►│ Function │ +// │ │ │ Emulator │ │ │ +// └──────────────┘ └─────────────┘ └──────────────┘ +// │ │ +// │ │ +// │ ┌──────────┐ │ +// └─────►│ S3 │◄──────┘ +// Poll for │ Bucket │ Response +// Response └──────────┘ +// +func NewCfnCustomResource(providerName string, s3Client client.S3Client, lambdaClient client.LambdaClient) *cfnCustomResource { + return &cfnCustomResource{ + providerName: providerName, + s3Client: s3Client, + lambdaClient: lambdaClient, + clock: &realClock{}, + } +} + +type CfnCustomResourceInputs struct { + // The name of the S3 bucket to use for storing the response from the custom resource + BucketName string + // The prefix to use for the bucket key when storing the response from the custom resource + BucketKeyPrefix string + // The service token, such as a Lambda function ARN. The service token must be in the same Region as this resource + ServiceToken string + // The custom resource properties to pass as an input to the Lambda function + CustomResourceProperties map[string]interface{} + // The CloudFormation type of the custom resource (e.g. "Custom::MyCustomResource") + ResourceType string + // A stand-in value for the CloudFormation stack ID required by the custom resource. If not provided, the project ID is used. + StackID *string +} + +type CfnCustomResourceState struct { + // The physical resource ID of the custom resource + PhysicalResourceID string `json:"physicalResourceId"` + // The response data returned by invoking the custom resource lambda + Data map[string]interface{} `json:"data"` + // The stand-in value for the CloudFormation stack ID required by the custom resource + StackID string `json:"stackId"` + // The service token, such as a Lambda function ARN. The service token must be in the same Region as this resource + ServiceToken string `json:"serviceToken"` + // The name of the S3 bucket to use for storing the response from the custom resource + Bucket string `json:"bucket"` + // The CloudFormation type of the custom resource (e.g. "Custom::MyCustomResource") + ResourceType string `json:"resourceType"` + // Whether the response data contains sensitive information that should be marked as secret and not logged + NoEcho bool `json:"noEcho"` +} + +func (s CfnCustomResourceState) ToPropertyMap() resource.PropertyMap { + return resource.NewPropertyMap(s) +} + +func CfnCustomResourceSpec() pschema.ResourceSpec { + return pschema.ResourceSpec{ + ObjectTypeSpec: pschema.ObjectTypeSpec{ + Description: "TODO: pulumi/pulumi-cdk#109", + Properties: map[string]pschema.PropertySpec{ + "physicalResourceId": { + Description: "The name or unique identifier that corresponds to the `PhysicalResourceId` included in the Custom Resource response. If no `PhysicalResourceId` is provided in the response, a random ID will be generated.", + TypeSpec: pschema.TypeSpec{Type: "string"}, + }, + "data": { + Description: "The response data returned by invoking the Custom Resource.", + TypeSpec: pschema.TypeSpec{ + Type: "object", + AdditionalProperties: &pschema.TypeSpec{ + Ref: "pulumi.json#/Any", + }, + }, + }, + "stackId": { + Description: "A stand-in value for the CloudFormation stack ID.", + TypeSpec: pschema.TypeSpec{Type: "string"}, + }, + "serviceToken": { + Description: "The service token, such as a Lambda function ARN, that is invoked when the resource is created, updated, or deleted.", + TypeSpec: pschema.TypeSpec{Type: "string"}, + }, + "bucket": { + Description: "The name of the S3 bucket to use for storing the response from the Custom Resource.", + TypeSpec: pschema.TypeSpec{Type: "string"}, + }, + "resourceType": { + Description: "The CloudFormation type of the Custom Resource provider. For example, `Custom::MyCustomResource`.", + TypeSpec: pschema.TypeSpec{Type: "string"}, + }, + "noEcho": { + Description: "Whether the response data contains sensitive information that should be marked as secret and not logged.", + TypeSpec: pschema.TypeSpec{Type: "boolean"}, + }, + }, + Required: []string{"physicalResourceId", "data", "stackId", "serviceToken", "bucket", "resourceType", "noEcho"}, + }, + InputProperties: map[string]pschema.PropertySpec{ + "bucketName": { + Description: "The name of the S3 bucket to use for storing the response from the Custom Resource.\n\n" + + "The IAM principal configured for the provider must have `s3:PutObject`, `s3:HeadObject` and `s3:GetObject` permissions on this bucket.", + TypeSpec: pschema.TypeSpec{Type: "string"}, + }, + "bucketKeyPrefix": { + Description: "The prefix to use for the bucket key when storing the response from the Custom Resource provider.", + TypeSpec: pschema.TypeSpec{Type: "string"}, + }, + "serviceToken": { + Description: "The service token to use for the Custom Resource. The service token is invoked when the resource is created, updated, or deleted.\n" + + "This can be a Lambda Function ARN with optional version or alias identifiers.\n\n" + + "The IAM principal configured for the provider must have `lambda:InvokeFunction` permissions on this service token.", + TypeSpec: pschema.TypeSpec{Type: "string"}, + }, + "customResourceProperties": { + Description: "The properties to pass as an input to the Custom Resource.\nThe properties are passed as a map of key-value pairs whereas all " + + "primitive values (number, boolean) are converted to strings for CloudFormation interoperability.", + TypeSpec: pschema.TypeSpec{ + Type: "object", + AdditionalProperties: &pschema.TypeSpec{ + Ref: "pulumi.json#/Any", + }, + }, + }, + "resourceType": { + Description: "The CloudFormation type of the Custom Resource. For example, `Custom::MyCustomResource`.\n" + + "This is required for CloudFormation interoperability.", + TypeSpec: pschema.TypeSpec{Type: "string"}, + }, + "stackId": { + Description: "A stand-in value for the CloudFormation stack ID. This is required for CloudFormation interoperability.\n" + + "If not provided, the Pulumi Stack ID is used.", + TypeSpec: pschema.TypeSpec{Type: "string"}, + }, + }, + RequiredInputs: []string{"bucketName", "bucketKeyPrefix", "serviceToken", "customResourceProperties", "resourceType"}, + } +} + +type customResourceInvokeData struct { + event cfn.Event + bucket string + bucketKeyPrefix string + timeout time.Duration + loggingLabel string + serviceToken string +} + +// Check validates the inputs of the resource and applies default values if necessary. +// It returns the inputs, validation failures, and an error if the inputs cannot be unmarshalled. +func (c *cfnCustomResource) Check(ctx context.Context, urn urn.URN, randomSeed []byte, inputs resource.PropertyMap, state resource.PropertyMap, defaultTags map[string]string) (resource.PropertyMap, []ValidationFailure, error) { + var typedInputs CfnCustomResourceInputs + _, err := resourcex.Unmarshal(&typedInputs, inputs, resourcex.UnmarshalOptions{}) + if err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal inputs: %w", err) + } + + var failures []ValidationFailure + + if !lambdaFunctionArnRegex.MatchString(typedInputs.ServiceToken) { + failures = append(failures, ValidationFailure{ + Path: "serviceToken", + Reason: "serviceToken must be a valid Lambda function ARN.", + }) + } + + if typedInputs.StackID == nil { + // if the stack ID is not provided, we use the pulumi stack ID as the stack ID + inputs[resource.PropertyKey("stackId")] = resource.NewStringProperty(urn.Stack().String()) + } + + if typedInputs.CustomResourceProperties != nil { + stringifiedCustomResourceProperties := naming.ToStringifiedMap(typedInputs.CustomResourceProperties) + inputs[resource.PropertyKey("customResourceProperties")] = resource.PropertyValue{V: resource.NewPropertyMapFromMap(stringifiedCustomResourceProperties)} + } + + return inputs, failures, nil +} + +// Create creates the Custom Resource by invoking the Lambda function with the CREATE request type. +// Returns the physical resource ID and outputs if the creation is successful, otherwise an error. +// +// Creation Flow: +// +// ┌─────────┐ ┌─────────────┐ ┌───────────────┐ ┌──────────┐ +// │ Create │ │Generate S3 │ │ Invoke Lambda │ │ Wait for │ +// │ Request ├──►│Presigned URL├──►│ with CREATE ├──►│ Response │ +// └─────────┘ └─────────────┘ │ RequestType │ └────┬─────┘ +// └───────────────┘ │ +// ▼ +// ┌───────────────┐ +// │Return Physical│ +// │Resource ID & │ +// │Outputs │ +// └───────────────┘ +// +// The Create operation: +// 1. Generates a presigned S3 URL for response collection +// 2. Constructs a CloudFormation CREATE event with: +// - Unique RequestID (UUID) +// - ResponseURL (presigned S3 URL) +// - ResourceType from input +// - LogicalResourceId from Pulumi URN +// - Custom properties from input +// 3. Invokes the Lambda function asynchronously +// 4. Waits for response in S3 bucket +// 5. Processes response: +// - On success: Returns PhysicalResourceId and properties +// - On failure: Returns error with reason +// - Handles `NoEcho` for sensitive data +func (c *cfnCustomResource) Create(ctx context.Context, urn urn.URN, inputs resource.PropertyMap, timeout time.Duration) (*string, resource.PropertyMap, error) { + var typedInputs CfnCustomResourceInputs + _, err := resourcex.Unmarshal(&typedInputs, inputs, resourcex.UnmarshalOptions{}) + if err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal inputs: %w", err) + } + + label := fmt.Sprintf("%s.Create(%s)", c.providerName, urn) + + event := cfn.Event{ + RequestType: cfn.RequestCreate, + ResourceType: typedInputs.ResourceType, + LogicalResourceID: urn.Name(), + StackID: *typedInputs.StackID, + ResourceProperties: typedInputs.CustomResourceProperties, + } + + response, err := c.invokeCustomResource(ctx, customResourceInvokeData{ + event: event, + bucket: typedInputs.BucketName, + bucketKeyPrefix: typedInputs.BucketKeyPrefix, + timeout: timeout, + loggingLabel: label, + serviceToken: typedInputs.ServiceToken, + }) + + if err != nil { + return nil, nil, fmt.Errorf("failed to create custom resource: %w", err) + } + + outputs := typedInputs.makeOutputs(inputs, response) + + if response.Status == cfn.StatusFailed { + glog.V(9).Infof("%s custom resource creation failed: %s", label, response.Reason) + + var partialID *string + + // if the custom resource creation partially succeeded, we should return the physical resource ID + // this could happen if parts of the custom resource creation succeeded but the overall operation failed + if response.PhysicalResourceID != "" { + partialID = &response.PhysicalResourceID + glog.V(9).Infof("%s custom resource creation partially succeeded, physical resource ID: %s", label, *partialID) + } + + return partialID, outputs, fmt.Errorf("failed to create custom resource: %s", response.Reason) + } + + glog.V(9).Infof("%s custom resource creation succeeded", label) + return &response.PhysicalResourceID, outputs, nil +} + +// Delete deletes the Custom Resource by invoking the Lambda function with the DELETE request type. +// Returns an error if the delete operation fails, otherwise nil. +// +// Delete Flow: +// +// ┌─────────┐ ┌─────────────┐ ┌───────────────┐ ┌──────────┐ +// │ Delete │ │Generate S3 │ │ Invoke Lambda │ │ Wait for │ +// │ Request ├──►│Presigned URL├──►│ with DELETE ├──►│Response │ +// └─────────┘ └─────────────┘ │ RequestType │ └────┬─────┘ +// └───────────────┘ │ +// ▼ +// ┌───────────────┐ +// │Verify Delete │ +// │Success │ +// └───────────────┘ +// +// The Delete operation: +// 1. Generates a presigned S3 URL for response collection +// 2. Constructs CloudFormation DELETE event with: +// - Existing PhysicalResourceId +// - Current ResourceProperties +// - All standard CloudFormation fields +// 3. Invokes Lambda and waits for response +// 4. Handles response: +// - Success: Completes deletion +// - Failure: Returns error with reason from Lambda +func (c *cfnCustomResource) Delete(ctx context.Context, urn urn.URN, id string, inputs, state resource.PropertyMap, timeout time.Duration) error { + var typedInputs CfnCustomResourceInputs + _, err := resourcex.Unmarshal(&typedInputs, inputs, resourcex.UnmarshalOptions{}) + if err != nil { + return fmt.Errorf("failed to unmarshal inputs: %w", err) + } + + var typedState CfnCustomResourceState + _, err = resourcex.Unmarshal(&typedState, state, resourcex.UnmarshalOptions{}) + if err != nil { + return fmt.Errorf("failed to unmarshal state: %w", err) + } + + label := fmt.Sprintf("%s.Delete(%s)", c.providerName, urn) + + event := cfn.Event{ + PhysicalResourceID: typedState.PhysicalResourceID, + RequestType: cfn.RequestDelete, + ResourceType: typedInputs.ResourceType, + LogicalResourceID: urn.Name(), + StackID: *typedInputs.StackID, + ResourceProperties: typedInputs.CustomResourceProperties, + } + + response, err := c.invokeCustomResource(ctx, customResourceInvokeData{ + event: event, + bucket: typedInputs.BucketName, + bucketKeyPrefix: typedInputs.BucketKeyPrefix, + timeout: timeout, + loggingLabel: label, + serviceToken: typedInputs.ServiceToken, + }) + + if err != nil { + return fmt.Errorf("failed to delete custom resource: %w", err) + } + + if response.Status == cfn.StatusFailed { + glog.V(9).Infof("%s custom resource deletion failed: %s", label, response.Reason) + return fmt.Errorf("failed to delete custom resource: %s", response.Reason) + } + + glog.V(9).Infof("%s custom resource deletion succeeded", label) + return nil +} + +// Update updates the custom resource with the given inputs and state by invoking the Lambda function with the UPDATE request type. +// If the update is successful and the physical resource ID has changed, +// it deletes the old resource. The function returns the updated resource properties or an error. +// +// Update Flow: +// +// ┌─────────┐ ┌─────────────┐ ┌───────────────┐ ┌──────────┐ +// │ Update │ │Generate S3 │ │ Invoke Lambda │ │ Wait for │ +// │ Request ├──►│Presigned URL├──►│ with UPDATE ├──►│ Response │ +// └─────────┘ └─────────────┘ │ RequestType │ └────┬─────┘ +// └───────────────┘ │ +// ▼ +// ┌───────────────┐ +// │Check Physical │ +// │Resource ID │ +// └───────┬───────┘ +// │ +// ▼ +// ┌───────────────┐ +// │Delete Old │ +// │Resource │ +// │(if ID changed)│ +// └───────┬───────┘ +// │ +// ▼ +// ┌───────────────┐ +// │Return updated │ +// │Physical │ +// │Resource ID & │ +// │Outputs │ +// └───────────────┘ +// +// The Update operation: +// 1. Generates a presigned S3 URL for response collection +// 2. Constructs a CloudFormation UPDATE event with: +// - Existing PhysicalResourceId +// - Old and new ResourceProperties +// - All standard CloudFormation fields +// 3. Invokes Lambda and collects response +// 4. If PhysicalResourceId changes: +// - Initiates cleanup of old resource +// - Sends DELETE event for old PhysicalResourceId +// 5. Returns updated properties and new PhysicalResourceId +func (c *cfnCustomResource) Update(ctx context.Context, urn urn.URN, id string, inputs, oldInputs, state resource.PropertyMap, timeout time.Duration) (resource.PropertyMap, error) { + var oldTypedInputs CfnCustomResourceInputs + _, err := resourcex.Unmarshal(&oldTypedInputs, oldInputs, resourcex.UnmarshalOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal old inputs: %w", err) + } + + var newTypedInputs CfnCustomResourceInputs + _, err = resourcex.Unmarshal(&newTypedInputs, inputs, resourcex.UnmarshalOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal new inputs: %w", err) + } + + var typedState CfnCustomResourceState + _, err = resourcex.Unmarshal(&typedState, state, resourcex.UnmarshalOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal state: %w", err) + } + + label := fmt.Sprintf("%s.Update(%s)", c.providerName, urn) + + event := cfn.Event{ + PhysicalResourceID: typedState.PhysicalResourceID, + RequestType: cfn.RequestUpdate, + ResourceType: newTypedInputs.ResourceType, + LogicalResourceID: urn.Name(), + StackID: *newTypedInputs.StackID, + ResourceProperties: newTypedInputs.CustomResourceProperties, + OldResourceProperties: oldTypedInputs.CustomResourceProperties, + } + + startTime := c.clock.Now() + response, err := c.invokeCustomResource(ctx, customResourceInvokeData{ + event: event, + bucket: newTypedInputs.BucketName, + bucketKeyPrefix: newTypedInputs.BucketKeyPrefix, + timeout: timeout, + loggingLabel: label, + serviceToken: newTypedInputs.ServiceToken, + }) + updateDuration := c.clock.Since(startTime) + glog.V(9).Infof("%s custom resource update took %s", label, updateDuration) + + if err != nil { + return nil, fmt.Errorf("failed to update custom resource: %w", err) + } + + if response.Status == cfn.StatusFailed { + glog.V(9).Infof("%s custom resource update failed: %s", label, response.Reason) + return nil, fmt.Errorf("failed to update custom resource: %s", response.Reason) + } + + glog.V(9).Infof("%s custom resource update succeeded", label) + + // if the physical resource ID has changed, we need to delete the old resource + if response.PhysicalResourceID != typedState.PhysicalResourceID { + glog.V(9).Infof("%s physical resource ID changed from %q to %q, deleting old resource", label, typedState.PhysicalResourceID, response.PhysicalResourceID) + + deleteEvent := cfn.Event{ + PhysicalResourceID: typedState.PhysicalResourceID, + RequestType: cfn.RequestDelete, + ResourceType: typedState.ResourceType, + LogicalResourceID: urn.Name(), + StackID: typedState.StackID, + ResourceProperties: oldTypedInputs.CustomResourceProperties, + } + + deleteTimeout := DefaultCustomResourceTimeout + // if a custom timeout is set, the delete operation is allowed to take the remaining time + // otherwise we allow it to take the default timeout + if timeout != 0 { + deleteTimeout = timeout - updateDuration + glog.V(9).Infof("%s custom resource update took %s, clean up of the old resource is allowed to take %s", label, updateDuration, deleteTimeout) + if deleteTimeout <= 0 { + return nil, fmt.Errorf("failed to clean up old custom resource: not enough time left to delete the old resource. Consider increasing the timeout") + } + } + + deleteResponse, err := c.invokeCustomResource(ctx, customResourceInvokeData{ + event: deleteEvent, + bucket: newTypedInputs.BucketName, + bucketKeyPrefix: newTypedInputs.BucketKeyPrefix, + timeout: deleteTimeout, + loggingLabel: label, + serviceToken: newTypedInputs.ServiceToken, + }) + + if err != nil { + return nil, fmt.Errorf("failed to clean up old custom resource: %w", err) + } + if deleteResponse.Status == cfn.StatusFailed { + return nil, fmt.Errorf("failed to clean up old custom resource %q: %s", typedState.PhysicalResourceID, deleteResponse.Reason) + } + glog.V(9).Infof("%s old custom resource %q successfully cleaned up", label, typedState.PhysicalResourceID) + } + + outputs := newTypedInputs.makeOutputs(inputs, response) + return outputs, nil +} + +// Read returns the current inputs and outputs of the custom resource because CFN custom resources do not store state. +// They are just a stateless wrapper around a Lambda function or SNS topic. +func (c *cfnCustomResource) Read(ctx context.Context, urn urn.URN, id string, oldInputs resource.PropertyMap, oldState resource.PropertyMap) (resource.PropertyMap, resource.PropertyMap, bool, error) { + // Assuming that Read without old state is an import operation + if len(oldState) == 0 { + // We can't support import because CustomResources do not store any state + return nil, nil, false, fmt.Errorf("CustomResourceEmulator import not implemented") + } + + return oldState, oldInputs, true, nil +} + +func (c *cfnCustomResource) invokeCustomResource(ctx context.Context, invokeData customResourceInvokeData) (*cfn.Response, error) { + timeout := invokeData.timeout + if timeout == 0 { + timeout = DefaultCustomResourceTimeout + } + + requestID := uuid.New().String() + bucketKey := fmt.Sprintf("%s/%s.json", invokeData.bucketKeyPrefix, requestID) + + responseUrl, err := c.s3Client.PresignPutObject(ctx, invokeData.bucket, bucketKey, timeout) + if err != nil { + // presigning is not an API call, it should not fail unless there's issues with the SDK or crypto libs + return nil, fmt.Errorf("failed to generate response URL: %w", err) + } + glog.V(9).Infof("%s created presigned response URL for s3://%s/%s", invokeData.loggingLabel, invokeData.bucket, bucketKey) + + event := invokeData.event + event.RequestID = requestID + event.ResponseURL = responseUrl + + eventPayload, err := json.Marshal(event) + if err != nil { + return nil, fmt.Errorf("failed to marshal event to JSON: %w", err) + } + + err = c.lambdaClient.InvokeAsync(ctx, invokeData.serviceToken, eventPayload) + if err != nil { + return nil, fmt.Errorf("failed to invoke lambda for custom resource: %w", err) + } + glog.V(9).Infof("%s invoked custom resource with request ID %q", invokeData.loggingLabel, requestID) + + glog.V(9).Infof("%s custom resource invocation succeeded, waiting for response to be sent", invokeData.loggingLabel) + body, err := c.s3Client.WaitForObject(ctx, invokeData.bucket, bucketKey, timeout) + if err != nil { + return nil, fmt.Errorf("failed to fetch custom resource response: %w", err) + } + defer body.Close() + + var response cfn.Response + err = json.NewDecoder(body).Decode(&response) + if err != nil { + return nil, fmt.Errorf("failed to decode custom resource response: %w", err) + } + + if glog.V(9) { + logCustomResourceResponse(invokeData.loggingLabel, &response) + } + + return sanitizeCustomResourceResponse(&event, &response), nil +} + +func logCustomResourceResponse(label string, response *cfn.Response) { + if response == nil { + return + } + + if response.NoEcho { + redactedResponse := *response + redactedResponse.Data = map[string]interface{}{} + responseJSON, err := json.Marshal(&redactedResponse) + if err != nil { + glog.V(9).Infof("%s failed to marshal custom resource response for logging: %v", label, err) + return + } + glog.V(9).Infof("%s received custom resource response with redacted secret data: %s", label, responseJSON) + } else { + responseJSON, err := json.Marshal(response) + if err != nil { + glog.V(9).Infof("%s failed to marshal custom resource response for logging: %v", label, err) + return + } + glog.V(9).Infof("%s received custom resource response: %s", label, responseJSON) + } +} + +func (i CfnCustomResourceInputs) makeOutputs(inputs resource.PropertyMap, response *cfn.Response) resource.PropertyMap { + state := CfnCustomResourceState{ + PhysicalResourceID: response.PhysicalResourceID, + Data: response.Data, + StackID: *i.StackID, + ServiceToken: i.ServiceToken, + Bucket: i.BucketName, + ResourceType: i.ResourceType, + } + checkpoint := CheckpointPropertyMap(inputs, state.ToPropertyMap()) + + // if NoEcho is set to true, it means the response contains sensitive data and we should mark it as a secret + if data, ok := checkpoint["data"]; ok && response.NoEcho { + checkpoint["data"] = resource.MakeSecret(data) + } + + return checkpoint +} + +func sanitizeCustomResourceResponse(event *cfn.Event, response *cfn.Response) *cfn.Response { + if response == nil || response.PhysicalResourceID == "" { + return response + } + + // ensure PhysicalResourceID is set. For Create requests we fall back to the RequestID, + // for Update and Delete requests we fall back to the PhysicalResourceID from state + if response.PhysicalResourceID == "" && (event.RequestType == cfn.RequestDelete || event.RequestType == cfn.RequestUpdate) { + response.PhysicalResourceID = event.PhysicalResourceID + } else if response.PhysicalResourceID == "" && event.RequestType == cfn.RequestCreate { + response.PhysicalResourceID = event.RequestID + } + + return response +} diff --git a/provider/pkg/resources/cfn_custom_resource_test.go b/provider/pkg/resources/cfn_custom_resource_test.go new file mode 100644 index 0000000000..a06ab6d2c5 --- /dev/null +++ b/provider/pkg/resources/cfn_custom_resource_test.go @@ -0,0 +1,1540 @@ +package resources + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/aws/aws-lambda-go/cfn" + "github.com/pulumi/pulumi-aws-native/provider/pkg/client" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource/urn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gomock "go.uber.org/mock/gomock" +) + +func TestCfnCustomResource_Check(t *testing.T) { + t.Parallel() + tests := []struct { + name string + inputs resource.PropertyMap + expectedInputs resource.PropertyMap + expectedError *string + expectedFailures []ValidationFailure + }{ + { + name: "Valid inputs", + inputs: resource.PropertyMap{ + "serviceToken": resource.NewStringProperty("arn:aws:lambda:us-west-2:123456789012:function:my-function"), + "stackId": resource.NewStringProperty("testProject"), + }, + expectedInputs: resource.PropertyMap{ + "serviceToken": resource.NewStringProperty("arn:aws:lambda:us-west-2:123456789012:function:my-function"), + "stackId": resource.NewStringProperty("testProject"), + }, + }, + { + name: "Invalid ServiceToken", + inputs: resource.PropertyMap{ + "serviceToken": resource.NewStringProperty("invalid-arn"), + "stackId": resource.NewStringProperty("testProject"), + }, + expectedFailures: []ValidationFailure{ + { + Path: "serviceToken", + Reason: "serviceToken must be a valid Lambda function ARN.", + }, + }, + }, + { + name: "No ServiceToken", + inputs: resource.PropertyMap{ + "stackId": resource.NewStringProperty("testProject"), + }, + expectedFailures: []ValidationFailure{ + { + Path: "serviceToken", + Reason: "serviceToken must be a valid Lambda function ARN.", + }, + }, + }, + { + name: "Default StackID", + inputs: resource.PropertyMap{ + "serviceToken": resource.NewStringProperty("arn:aws:lambda:us-west-2:123456789012:function:my-function"), + }, + expectedInputs: resource.PropertyMap{ + "serviceToken": resource.NewStringProperty("arn:aws:lambda:us-west-2:123456789012:function:my-function"), + "stackId": resource.NewStringProperty("testProject"), + }, + }, + { + name: "Stringify CustomResourceProperties", + inputs: resource.PropertyMap{ + "serviceToken": resource.NewStringProperty("arn:aws:lambda:us-west-2:123456789012:function:my-function"), + "stackId": resource.NewStringProperty("testProject"), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "level1": map[string]interface{}{ + "level2": []interface{}{ + map[string]interface{}{ + "key1": "value1", + "key2": 2, + }, + 3.14, + "string", + }, + "anotherKey": true, + "arrayOfMaps": []interface{}{ + map[string]interface{}{ + "key1": "value1", + "key2": 2, + }, + map[string]interface{}{ + "key3": "value3", + "key4": 4, + }, + }, + }, + })), + }, + expectedInputs: resource.PropertyMap{ + "serviceToken": resource.NewStringProperty("arn:aws:lambda:us-west-2:123456789012:function:my-function"), + "stackId": resource.NewStringProperty("testProject"), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "level1": map[string]interface{}{ + "level2": []interface{}{ + map[string]interface{}{ + "key1": "value1", + "key2": "2", + }, + "3.14", + "string", + }, + "anotherKey": "true", + "arrayOfMaps": []interface{}{ + map[string]interface{}{ + "key1": "value1", + "key2": "2", + }, + map[string]interface{}{ + "key3": "value3", + "key4": "4", + }, + }, + }, + })), + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := &cfnCustomResource{} + ctx := context.Background() + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + randomSeed := []byte{} + state := resource.PropertyMap{} + defaultTags := map[string]string{} + + newInputs, failures, err := c.Check(ctx, urn, randomSeed, tt.inputs, state, defaultTags) + + if tt.expectedError != nil { + assert.Error(t, err) + assert.Contains(t, err.Error(), *tt.expectedError) + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.expectedFailures, failures) + + if tt.expectedInputs != nil { + assert.Equal(t, tt.expectedInputs, newInputs) + } + }) + } +} + +func TestCfnCustomResource_Create(t *testing.T) { + t.Parallel() + tests := []struct { + name string + timeout time.Duration + noEcho bool + expectedError string + customResourceData map[string]interface{} + customResourceInputs map[string]interface{} + }{ + { + name: "Success", + customResourceData: map[string]interface{}{ + "prop1": "value1", + "prop2": true, + "prop3": []interface{}{"a", "b", "c"}, + "prop4": map[string]interface{}{ + "nestedProp1": "nestedValue1", + "nestedProp2": 42, + }, + }, + customResourceInputs: map[string]interface{}{ + "key": "value", + }, + }, + { + name: "SecretResponse", + noEcho: true, + customResourceData: map[string]interface{}{ + "prop1": "value1", + "prop2": true, + "prop3": []interface{}{"a", "b", "c"}, + "prop4": map[string]interface{}{ + "nestedProp1": "nestedValue1", + "nestedProp2": 42, + }, + }, + customResourceInputs: map[string]interface{}{ + "key": "value", + }, + }, + { + name: "CustomTimeout", + timeout: 10 * time.Minute, + customResourceData: map[string]interface{}{ + "prop1": "value1", + "prop2": true, + "prop3": []interface{}{"a", "b", "c"}, + "prop4": map[string]interface{}{ + "nestedProp1": "nestedValue1", + "nestedProp2": 42, + }, + }, + customResourceInputs: map[string]interface{}{ + "key": "value", + }, + }, + { + name: "EnsurePhysicalResourceID", + customResourceData: map[string]interface{}{ + "prop1": "value1", + "prop2": true, + "prop3": []interface{}{"a", "b", "c"}, + "prop4": map[string]interface{}{ + "nestedProp1": "nestedValue1", + "nestedProp2": 42, + }, + }, + customResourceInputs: map[string]interface{}{ + "key": "value", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + physicalResourceID := "physical-resource-id" + resourceType := "Custom::MyResource" + + expectedTimeout := DefaultCustomResourceTimeout + if tt.timeout != 0 { + expectedTimeout = tt.timeout + } + + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + responseUrl := "https://example.com" + expectedPayload := cfn.Event{ + RequestType: cfn.RequestCreate, + ResponseURL: responseUrl, + ResourceType: resourceType, + LogicalResourceID: urn.Name(), + StackID: stackID, + ResourceProperties: tt.customResourceInputs, + } + + mockLambdaClient.EXPECT().InvokeAsync( + gomock.Any(), + serviceToken, + gomock.Any(), + ).DoAndReturn(func(ctx context.Context, functionName string, payload []byte) error { + var event cfn.Event + err := json.Unmarshal(payload, &event) + require.NoError(t, err) + // ignore the RequestID field as it is randomly generated + expectedPayload.RequestID = event.RequestID + assert.Equal(t, expectedPayload, event) + return nil + }) + + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), gomock.Any()).Return(responseUrl, nil) + + response := cfn.Response{ + Status: cfn.StatusSuccess, + RequestID: "request-id", + LogicalResourceID: "logical-resource-id", + StackID: stackID, + PhysicalResourceID: physicalResourceID, + Data: tt.customResourceData, + NoEcho: tt.noEcho, + } + + responseMessage, err := json.Marshal(response) + require.NoError(t, err) + + mockS3Client.EXPECT().WaitForObject( + gomock.Any(), + bucketName, + matchesBucketKeyPrefix(bucketKeyPrefix), + expectedTimeout, + ).Return(io.NopCloser(bytes.NewReader(responseMessage)), nil) + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + } + ctx := context.Background() + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(tt.customResourceInputs)), + } + + id, outputs, err := c.Create(ctx, urn, inputs, tt.timeout) + + if tt.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + assert.Nil(t, outputs) + } else { + require.NoError(t, err) + + expectedID := physicalResourceID + expectedState := CfnCustomResourceState{ + PhysicalResourceID: physicalResourceID, + Data: tt.customResourceData, + StackID: stackID, + ServiceToken: serviceToken, + Bucket: bucketName, + ResourceType: resourceType, + } + + assert.Equal(t, &expectedID, id) + expectedOutputs := CheckpointPropertyMap(inputs, expectedState.ToPropertyMap()) + if tt.noEcho { + expectedOutputs["data"] = resource.MakeSecret(expectedOutputs["data"]) + } + assert.Equal(t, expectedOutputs, outputs) + } + }) + } +} + +func TestCfnCustomResource_Create_PartialError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + physicalResourceID string + expectedError string + customResourceData map[string]interface{} + }{ + { + name: "With PhysicalResourceID", + physicalResourceID: "physical-resource-id", + expectedError: "some error occurred", + customResourceData: map[string]interface{}{"key": "value"}, + }, + { + name: "Without PhysicalResourceID", + physicalResourceID: "", + expectedError: "some error occurred", + customResourceData: map[string]interface{}{"key": "value"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + resourceType := "Custom::MyResource" + + mockLambdaClient.EXPECT().InvokeAsync(gomock.Any(), serviceToken, gomock.Any()).Return(nil) + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), gomock.Any()).Return("https://example.com", nil) + + response := cfn.Response{ + Status: cfn.StatusFailed, + RequestID: "request-id", + LogicalResourceID: "logical-resource-id", + StackID: stackID, + PhysicalResourceID: tt.physicalResourceID, + Data: tt.customResourceData, + Reason: tt.expectedError, + } + + responseMessage, err := json.Marshal(response) + require.NoError(t, err) + + mockS3Client.EXPECT().WaitForObject( + gomock.Any(), + bucketName, + matchesBucketKeyPrefix(bucketKeyPrefix), + DefaultCustomResourceTimeout, + ).Return(io.NopCloser(bytes.NewReader(responseMessage)), nil) + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + } + ctx := context.Background() + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{"key": "value"})), + } + + id, outputs, err := c.Create(ctx, urn, inputs, 0) + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + if tt.physicalResourceID != "" { + assert.Equal(t, &tt.physicalResourceID, id) + } else { + assert.Nil(t, id) + } + + expectedState := CfnCustomResourceState{ + PhysicalResourceID: tt.physicalResourceID, + Data: tt.customResourceData, + StackID: stackID, + ServiceToken: serviceToken, + Bucket: bucketName, + ResourceType: resourceType, + } + + expectedOutputs := CheckpointPropertyMap(inputs, expectedState.ToPropertyMap()) + assert.Equal(t, expectedOutputs, outputs) + }) + } +} + +func TestCfnCustomResource_Create_PresignPutObjectFail(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + expectedError := fmt.Errorf("failed to presign put object") + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + resourceType := "Custom::MyResource" + + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), gomock.Any()).Return("", expectedError) + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + } + ctx := context.Background() + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.PropertyMap{ + "key": resource.NewStringProperty("value"), + }), + } + + id, outputs, err := c.Create(ctx, urn, inputs, 0) + + require.Error(t, err) + assert.Nil(t, id) + assert.Nil(t, outputs) + assert.Contains(t, err.Error(), expectedError.Error()) +} + +func TestCfnCustomResource_Create_LambdaInvokeFail(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + expectedError := fmt.Errorf("failed to invoke lambda") + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + resourceType := "Custom::MyResource" + + mockLambdaClient.EXPECT().InvokeAsync(gomock.Any(), serviceToken, gomock.Any()).Return(expectedError) + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), gomock.Any()).Return("https://example.com", nil) + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + } + ctx := context.Background() + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.PropertyMap{ + "key": resource.NewStringProperty("value"), + }), + } + + id, outputs, err := c.Create(ctx, urn, inputs, 0) + + require.Error(t, err) + assert.Nil(t, id) + assert.Nil(t, outputs) + assert.Contains(t, err.Error(), expectedError.Error()) +} + +func TestCfnCustomResource_Create_S3WaitForObjectFail(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + expectedError := fmt.Errorf("failed to fetch custom resource response") + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + resourceType := "Custom::MyResource" + + mockLambdaClient.EXPECT().InvokeAsync(gomock.Any(), serviceToken, gomock.Any()).Return(nil) + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), gomock.Any()).Return("https://example.com", nil) + mockS3Client.EXPECT().WaitForObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), DefaultCustomResourceTimeout).Return(nil, expectedError) + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + } + ctx := context.Background() + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.PropertyMap{ + "key": resource.NewStringProperty("value"), + }), + } + + id, outputs, err := c.Create(ctx, urn, inputs, 0) + + require.Error(t, err) + assert.Nil(t, id) + assert.Nil(t, outputs) + assert.Contains(t, err.Error(), expectedError.Error()) +} + +func TestCfnCustomResource_Update(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + timeout time.Duration + noEcho bool + newCustomResourceData map[string]interface{} + }{ + { + name: "Success", + newCustomResourceData: map[string]interface{}{"new": "value"}, + }, + { + name: "SecretResponse", + noEcho: true, + newCustomResourceData: map[string]interface{}{"new": "value"}, + }, + { + name: "CustomTimeout", + timeout: 10 * time.Minute, + newCustomResourceData: map[string]interface{}{"new": "value"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + physicalResourceID := "physical-resource-id" + resourceType := "Custom::MyResource" + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + expectedTimeout := DefaultCustomResourceTimeout + if tt.timeout != 0 { + expectedTimeout = tt.timeout + } + + oldResourceProperties := map[string]interface{}{ + "inputs": "old", + } + newResourceProperties := map[string]interface{}{ + "inputs": "new", + } + + responseUrl := "https://example.com" + expectedPayload := cfn.Event{ + RequestType: cfn.RequestUpdate, + ResponseURL: responseUrl, + ResourceType: resourceType, + PhysicalResourceID: physicalResourceID, + LogicalResourceID: urn.Name(), + StackID: stackID, + ResourceProperties: newResourceProperties, + OldResourceProperties: oldResourceProperties, + } + + mockLambdaClient.EXPECT().InvokeAsync( + gomock.Any(), + serviceToken, + gomock.Any(), + ).DoAndReturn(func(ctx context.Context, functionName string, payload []byte) error { + var event cfn.Event + err := json.Unmarshal(payload, &event) + require.NoError(t, err) + // ignore the RequestID field as it is randomly generated + expectedPayload.RequestID = event.RequestID + assert.Equal(t, expectedPayload, event) + return nil + }) + + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), expectedTimeout).Return("https://example.com", nil) + + response := cfn.Response{ + Status: cfn.StatusSuccess, + RequestID: "request-id", + LogicalResourceID: "logical-resource-id", + StackID: stackID, + PhysicalResourceID: physicalResourceID, + Data: tt.newCustomResourceData, + NoEcho: tt.noEcho, + } + + responseMessage, err := json.Marshal(response) + require.NoError(t, err) + + mockS3Client.EXPECT().WaitForObject( + gomock.Any(), + bucketName, + matchesBucketKeyPrefix(bucketKeyPrefix), + expectedTimeout, + ).Return(io.NopCloser(bytes.NewReader(responseMessage)), nil) + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + clock: &MockClock{}, + } + ctx := context.Background() + + oldInputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(oldResourceProperties)), + } + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(newResourceProperties)), + } + + oldState := CfnCustomResourceState{ + PhysicalResourceID: physicalResourceID, + Data: map[string]interface{}{ + "old": "value", + }, + StackID: stackID, + ServiceToken: serviceToken, + Bucket: bucketName, + ResourceType: resourceType, + } + + outputs, err := c.Update(ctx, urn, physicalResourceID, inputs, oldInputs, CheckpointPropertyMap(oldInputs, oldState.ToPropertyMap()), tt.timeout) + + require.NoError(t, err) + + expectedState := CfnCustomResourceState{ + PhysicalResourceID: physicalResourceID, + Data: tt.newCustomResourceData, + StackID: stackID, + ServiceToken: serviceToken, + Bucket: bucketName, + ResourceType: resourceType, + } + + expectedOutputs := CheckpointPropertyMap(inputs, expectedState.ToPropertyMap()) + if tt.noEcho { + expectedOutputs["data"] = resource.MakeSecret(expectedOutputs["data"]) + } + + assert.Equal(t, expectedOutputs, outputs) + }) + } +} + +func TestCfnCustomResource_Update_LambdaInvokeFailure(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + expectedError := fmt.Errorf("failed to invoke lambda") + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + physicalResourceID := "physical-resource-id" + resourceType := "Custom::MyResource" + + mockLambdaClient.EXPECT().InvokeAsync(gomock.Any(), serviceToken, gomock.Any()).Return(expectedError) + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), gomock.Any()).Return("https://example.com", nil) + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + clock: &MockClock{}, + } + ctx := context.Background() + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + + oldInputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "inputs": "old", + })), + } + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "inputs": "new", + })), + } + + oldState := CfnCustomResourceState{ + PhysicalResourceID: physicalResourceID, + Data: map[string]interface{}{ + "old": "value", + }, + StackID: stackID, + ServiceToken: serviceToken, + Bucket: bucketName, + ResourceType: resourceType, + } + + outputs, err := c.Update(ctx, urn, physicalResourceID, inputs, oldInputs, CheckpointPropertyMap(oldInputs, oldState.ToPropertyMap()), 0) + + require.Error(t, err) + assert.Nil(t, outputs) + assert.Contains(t, err.Error(), expectedError.Error()) +} + +func TestCfnCustomResource_Update_PhysicalResourceIDChange(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + timeout time.Duration + noEcho bool + newPhysicalResourceID string + newCustomResourceData map[string]interface{} + expectedError string + deleteError string + }{ + { + name: "Success", + newPhysicalResourceID: "new-physical-resource-id", + newCustomResourceData: map[string]interface{}{"new": "value"}, + }, + { + name: "SecretResponse", + noEcho: true, + newPhysicalResourceID: "new-physical-resource-id", + newCustomResourceData: map[string]interface{}{"new": "value"}, + }, + { + name: "CustomTimeout", + timeout: 10 * time.Minute, + newPhysicalResourceID: "new-physical-resource-id", + newCustomResourceData: map[string]interface{}{"new": "value"}, + }, + { + name: "DeleteOldResourceFail", + newPhysicalResourceID: "new-physical-resource-id", + newCustomResourceData: map[string]interface{}{"new": "value"}, + expectedError: "failed to clean up old custom resource", + deleteError: "failed to delete old resource", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClock := NewMockClock() + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + physicalResourceID := "physical-resource-id" + resourceType := "Custom::MyResource" + expectedTimeout := DefaultCustomResourceTimeout + if tt.timeout != 0 { + expectedTimeout = tt.timeout + } + + oldResourceProperties := map[string]interface{}{ + "inputs": "old", + } + newResourceProperties := map[string]interface{}{ + "inputs": "new", + } + + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + + responseUrl := "https://example.com" + expectedUpdatePayload := cfn.Event{ + RequestType: cfn.RequestUpdate, + ResponseURL: responseUrl, + ResourceType: resourceType, + PhysicalResourceID: physicalResourceID, + LogicalResourceID: urn.Name(), + StackID: stackID, + ResourceProperties: newResourceProperties, + OldResourceProperties: oldResourceProperties, + } + + mockLambdaClient.EXPECT().InvokeAsync( + gomock.Any(), + serviceToken, + gomock.Any(), + ).DoAndReturn(func(ctx context.Context, functionName string, payload []byte) error { + var event cfn.Event + err := json.Unmarshal(payload, &event) + require.NoError(t, err) + // ignore the RequestID field as it is randomly generated + expectedUpdatePayload.RequestID = event.RequestID + assert.Equal(t, expectedUpdatePayload, event) + return nil + }) + + expectedDeletePayload := cfn.Event{ + RequestType: cfn.RequestDelete, + ResponseURL: responseUrl, + ResourceType: resourceType, + PhysicalResourceID: physicalResourceID, + LogicalResourceID: urn.Name(), + StackID: stackID, + ResourceProperties: oldResourceProperties, + } + + mockLambdaClient.EXPECT().InvokeAsync( + gomock.Any(), + serviceToken, + gomock.Any(), + ).DoAndReturn(func(ctx context.Context, functionName string, payload []byte) error { + var event cfn.Event + err := json.Unmarshal(payload, &event) + require.NoError(t, err) + // ignore the RequestID field as it is randomly generated + expectedDeletePayload.RequestID = event.RequestID + assert.Equal(t, expectedDeletePayload, event) + return nil + }) + + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), expectedTimeout).Return("https://example.com", nil).Times(2) + + response := cfn.Response{ + Status: cfn.StatusSuccess, + RequestID: "request-id", + LogicalResourceID: "logical-resource-id", + StackID: stackID, + PhysicalResourceID: tt.newPhysicalResourceID, + Data: tt.newCustomResourceData, + NoEcho: tt.noEcho, + } + + responseMessage, err := json.Marshal(response) + require.NoError(t, err) + + mockS3Client.EXPECT().WaitForObject( + gomock.Any(), + bucketName, + matchesBucketKeyPrefix(bucketKeyPrefix), + expectedTimeout, + ).Return(io.NopCloser(bytes.NewReader(responseMessage)), nil) + + // Whether the deletion of the old resource succeeds or fails + if tt.deleteError != "" { + mockS3Client.EXPECT().WaitForObject( + gomock.Any(), + bucketName, + matchesBucketKeyPrefix(bucketKeyPrefix), + expectedTimeout, + ).Return(nil, fmt.Errorf(tt.deleteError)) + } else { + mockS3Client.EXPECT().WaitForObject( + gomock.Any(), + bucketName, + matchesBucketKeyPrefix(bucketKeyPrefix), + expectedTimeout, + ).Return(io.NopCloser(bytes.NewReader(responseMessage)), nil) + } + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + clock: mockClock, + } + ctx := context.Background() + + oldInputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(oldResourceProperties)), + } + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(newResourceProperties)), + } + + oldState := CfnCustomResourceState{ + PhysicalResourceID: physicalResourceID, + Data: map[string]interface{}{ + "old": "value", + }, + StackID: stackID, + ServiceToken: serviceToken, + Bucket: bucketName, + ResourceType: resourceType, + } + + outputs, err := c.Update(ctx, urn, physicalResourceID, inputs, oldInputs, CheckpointPropertyMap(oldInputs, oldState.ToPropertyMap()), tt.timeout) + + if tt.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + assert.Nil(t, outputs) + } else { + require.NoError(t, err) + + expectedState := CfnCustomResourceState{ + PhysicalResourceID: tt.newPhysicalResourceID, + Data: tt.newCustomResourceData, + StackID: stackID, + ServiceToken: serviceToken, + Bucket: bucketName, + ResourceType: resourceType, + } + + expectedOutputs := CheckpointPropertyMap(inputs, expectedState.ToPropertyMap()) + if tt.noEcho { + expectedOutputs["data"] = resource.MakeSecret(expectedOutputs["data"]) + } + + assert.Equal(t, expectedOutputs, outputs) + } + }) + } +} + +func TestCfnCustomResource_Update_PhysicalResourceIDChangeDeleteTimeout(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClock := NewMockClock() + expectedTimeout := 10 * time.Minute + // update consumed the whole timeout, nothing left for delete + mockClock.customDuration = expectedTimeout + 10*time.Second + + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + physicalResourceID := "physical-resource-id" + resourceType := "Custom::MyResource" + + mockLambdaClient.EXPECT().InvokeAsync(gomock.Any(), serviceToken, gomock.Any()).Return(nil) + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), expectedTimeout).Return("https://example.com", nil) + + response := cfn.Response{ + Status: cfn.StatusSuccess, + RequestID: "request-id", + LogicalResourceID: "logical-resource-id", + StackID: stackID, + PhysicalResourceID: "new-physical-resource-id", + Data: map[string]interface{}{"new": "value"}, + NoEcho: false, + } + + responseMessage, err := json.Marshal(response) + require.NoError(t, err) + + mockS3Client.EXPECT().WaitForObject( + gomock.Any(), + bucketName, + matchesBucketKeyPrefix(bucketKeyPrefix), + expectedTimeout, + ).Return(io.NopCloser(bytes.NewReader(responseMessage)), nil) + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + clock: mockClock, + } + ctx := context.Background() + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + + oldInputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "inputs": "old", + })), + } + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "inputs": "new", + })), + } + + oldState := CfnCustomResourceState{ + PhysicalResourceID: physicalResourceID, + Data: map[string]interface{}{ + "old": "value", + }, + StackID: stackID, + ServiceToken: serviceToken, + Bucket: bucketName, + ResourceType: resourceType, + } + + outputs, err := c.Update(ctx, urn, physicalResourceID, inputs, oldInputs, CheckpointPropertyMap(oldInputs, oldState.ToPropertyMap()), expectedTimeout) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to clean up old custom resource: not enough time left to delete the old resource. Consider increasing the timeout") + assert.Nil(t, outputs) +} + +func TestCfnCustomResource_Read(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + oldState resource.PropertyMap + oldInputs resource.PropertyMap + expectedState resource.PropertyMap + expectedInputs resource.PropertyMap + expectedError string + }{ + { + name: "Success", + oldState: resource.PropertyMap{ + "physicalResourceId": resource.NewStringProperty("physical-resource-id"), + "data": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "key": "value", + })), + "stackId": resource.NewStringProperty("stack-id"), + "serviceToken": resource.NewStringProperty("arn:aws:lambda:us-west-2:123456789012:function:my-function"), + "bucket": resource.NewStringProperty("bucket-name"), + "resourceType": resource.NewStringProperty("Custom::MyResource"), + }, + oldInputs: resource.PropertyMap{ + "serviceToken": resource.NewStringProperty("arn:aws:lambda:us-west-2:123456789012:function:my-function"), + "resourceType": resource.NewStringProperty("Custom::MyResource"), + "stackID": resource.NewStringProperty("stack-id"), + "bucketName": resource.NewStringProperty("bucket-name"), + "bucketKeyPrefix": resource.NewStringProperty("bucket-key-prefix"), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "key": "value", + })), + }, + expectedState: resource.PropertyMap{ + "physicalResourceId": resource.NewStringProperty("physical-resource-id"), + "data": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "key": "value", + })), + "stackId": resource.NewStringProperty("stack-id"), + "serviceToken": resource.NewStringProperty("arn:aws:lambda:us-west-2:123456789012:function:my-function"), + "bucket": resource.NewStringProperty("bucket-name"), + "resourceType": resource.NewStringProperty("Custom::MyResource"), + }, + expectedInputs: resource.PropertyMap{ + "serviceToken": resource.NewStringProperty("arn:aws:lambda:us-west-2:123456789012:function:my-function"), + "resourceType": resource.NewStringProperty("Custom::MyResource"), + "stackID": resource.NewStringProperty("stack-id"), + "bucketName": resource.NewStringProperty("bucket-name"), + "bucketKeyPrefix": resource.NewStringProperty("bucket-key-prefix"), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{ + "key": "value", + })), + }, + }, + { + name: "No State", + expectedError: "CustomResourceEmulator import not implemented", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := &cfnCustomResource{} + ctx := context.Background() + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + + var oldState resource.PropertyMap + if tt.oldState != nil { + oldState = CheckpointPropertyMap(tt.oldInputs, tt.oldState) + } + expectedState := CheckpointPropertyMap(tt.expectedInputs, tt.expectedState) + + state, inputs, supported, err := c.Read(ctx, urn, "physical-resource-id", tt.oldInputs, oldState) + + if tt.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + assert.Nil(t, state) + assert.Nil(t, inputs) + assert.False(t, supported) + } else { + require.NoError(t, err) + assert.Equal(t, expectedState, state) + assert.Equal(t, tt.expectedInputs, inputs) + assert.True(t, supported) + } + }) + } +} + +func TestCfnCustomResource_Delete(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + timeout time.Duration + expectedError string + customResourceInputs map[string]interface{} + }{ + { + name: "Success", + customResourceInputs: map[string]interface{}{ + "key": "value", + }, + }, + { + name: "CustomTimeout", + timeout: 10 * time.Minute, + customResourceInputs: map[string]interface{}{ + "key": "value", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + physicalResourceID := "physical-resource-id" + resourceType := "Custom::MyResource" + + expectedTimeout := DefaultCustomResourceTimeout + if tt.timeout != 0 { + expectedTimeout = tt.timeout + } + + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + responseUrl := "https://example.com" + expectedPayload := cfn.Event{ + RequestType: cfn.RequestDelete, + ResponseURL: responseUrl, + ResourceType: resourceType, + LogicalResourceID: urn.Name(), + StackID: stackID, + PhysicalResourceID: physicalResourceID, + ResourceProperties: tt.customResourceInputs, + } + + mockLambdaClient.EXPECT().InvokeAsync( + gomock.Any(), + serviceToken, + gomock.Any(), + ).DoAndReturn(func(ctx context.Context, functionName string, payload []byte) error { + var event cfn.Event + err := json.Unmarshal(payload, &event) + require.NoError(t, err) + // ignore the RequestID field as it is randomly generated + expectedPayload.RequestID = event.RequestID + assert.Equal(t, expectedPayload, event) + return nil + }) + + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), gomock.Any()).Return(responseUrl, nil) + + response := cfn.Response{ + Status: cfn.StatusSuccess, + RequestID: "request-id", + LogicalResourceID: "logical-resource-id", + StackID: stackID, + PhysicalResourceID: physicalResourceID, + Data: map[string]interface{}{}, + } + + responseMessage, err := json.Marshal(response) + require.NoError(t, err) + + mockS3Client.EXPECT().WaitForObject( + gomock.Any(), + bucketName, + matchesBucketKeyPrefix(bucketKeyPrefix), + expectedTimeout, + ).Return(io.NopCloser(bytes.NewReader(responseMessage)), nil) + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + } + ctx := context.Background() + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.NewPropertyMapFromMap(tt.customResourceInputs)), + } + + state := resource.PropertyMap{ + "physicalResourceId": resource.NewStringProperty(physicalResourceID), + "data": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{})), + "stackId": resource.NewStringProperty(stackID), + "serviceToken": resource.NewStringProperty(serviceToken), + "bucket": resource.NewStringProperty(bucketName), + "resourceType": resource.NewStringProperty(resourceType), + } + + err = c.Delete(ctx, urn, physicalResourceID, inputs, state, tt.timeout) + + if tt.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCfnCustomResource_Delete_PresignPutObjectFail(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + expectedError := fmt.Errorf("failed to presign put object") + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + resourceType := "Custom::MyResource" + physicalResourceID := "physical-resource-id" + + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), gomock.Any()).Return("", expectedError) + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + } + ctx := context.Background() + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.PropertyMap{ + "key": resource.NewStringProperty("value"), + }), + } + + state := resource.PropertyMap{ + "physicalResourceId": resource.NewStringProperty(physicalResourceID), + "data": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{})), + "stackId": resource.NewStringProperty(stackID), + "serviceToken": resource.NewStringProperty(serviceToken), + "bucket": resource.NewStringProperty(bucketName), + "resourceType": resource.NewStringProperty(resourceType), + } + + err := c.Delete(ctx, urn, physicalResourceID, inputs, state, 0) + + require.Error(t, err) + assert.Contains(t, err.Error(), expectedError.Error()) +} + +func TestCfnCustomResource_Delete_LambdaInvokeFail(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + expectedError := fmt.Errorf("failed to invoke lambda") + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + resourceType := "Custom::MyResource" + physicalResourceID := "physical-resource-id" + + mockLambdaClient.EXPECT().InvokeAsync(gomock.Any(), serviceToken, gomock.Any()).Return(expectedError) + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), gomock.Any()).Return("https://example.com", nil) + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + } + ctx := context.Background() + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.PropertyMap{ + "key": resource.NewStringProperty("value"), + }), + } + + state := resource.PropertyMap{ + "physicalResourceId": resource.NewStringProperty(physicalResourceID), + "data": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{})), + "stackId": resource.NewStringProperty(stackID), + "serviceToken": resource.NewStringProperty(serviceToken), + "bucket": resource.NewStringProperty(bucketName), + "resourceType": resource.NewStringProperty(resourceType), + } + + err := c.Delete(ctx, urn, physicalResourceID, inputs, state, 0) + + require.Error(t, err) + assert.Contains(t, err.Error(), expectedError.Error()) +} + +func TestCfnCustomResource_Delete_S3WaitForObjectFail(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLambdaClient := client.NewMockLambdaClient(ctrl) + mockS3Client := client.NewMockS3Client(ctrl) + + expectedError := fmt.Errorf("failed to fetch custom resource response") + stackID := "stack-id" + serviceToken := "arn:aws:lambda:us-west-2:123456789012:function:my-function" + bucketKeyPrefix := "bucket-key-prefix" + bucketName := "bucket-name" + resourceType := "Custom::MyResource" + physicalResourceID := "physical-resource-id" + + mockLambdaClient.EXPECT().InvokeAsync(gomock.Any(), serviceToken, gomock.Any()).Return(nil) + mockS3Client.EXPECT().PresignPutObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), gomock.Any()).Return("https://example.com", nil) + mockS3Client.EXPECT().WaitForObject(gomock.Any(), bucketName, matchesBucketKeyPrefix(bucketKeyPrefix), DefaultCustomResourceTimeout).Return(nil, expectedError) + + c := &cfnCustomResource{ + providerName: "testProvider", + lambdaClient: mockLambdaClient, + s3Client: mockS3Client, + } + ctx := context.Background() + urn := urn.URN("urn:pulumi:testProject::test::aws-native:cloudformation:CfnCustomResource::dummy") + + inputs := resource.PropertyMap{ + "serviceToken": resource.NewStringProperty(serviceToken), + "resourceType": resource.NewStringProperty(resourceType), + "stackID": resource.NewStringProperty(stackID), + "bucketName": resource.NewStringProperty(bucketName), + "bucketKeyPrefix": resource.NewStringProperty(bucketKeyPrefix), + "customResourceProperties": resource.NewObjectProperty(resource.PropertyMap{ + "key": resource.NewStringProperty("value"), + }), + } + + state := resource.PropertyMap{ + "physicalResourceId": resource.NewStringProperty(physicalResourceID), + "data": resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{})), + "stackId": resource.NewStringProperty(stackID), + "serviceToken": resource.NewStringProperty(serviceToken), + "bucket": resource.NewStringProperty(bucketName), + "resourceType": resource.NewStringProperty(resourceType), + } + + err := c.Delete(ctx, urn, physicalResourceID, inputs, state, 0) + + require.Error(t, err) + assert.Contains(t, err.Error(), expectedError.Error()) +} + +func matchesBucketKeyPrefix(prefix string) gomock.Matcher { + return gomock.Cond(func(x any) bool { return strings.HasPrefix(x.(string), prefix+"/") }) +} + +type MockClock struct { + freezeTime time.Time + customDuration time.Duration +} + +func NewMockClock() *MockClock { + return &MockClock{freezeTime: time.Now()} +} + +func (c *MockClock) Now() time.Time { + return c.freezeTime +} + +func (c *MockClock) Since(t time.Time) time.Duration { + return c.customDuration +}