diff --git a/packages/@aws-cdk/custom-resources/README.md b/packages/@aws-cdk/custom-resources/README.md index b7b3d2cfe318b..8831f7897e7df 100644 --- a/packages/@aws-cdk/custom-resources/README.md +++ b/packages/@aws-cdk/custom-resources/README.md @@ -376,6 +376,19 @@ const awsCustom2 = new AwsCustomResource(this, 'API2', { }) ``` +### Error Handling + +Every error produced by the API call is treated as is and will cause a "FAILED" response to be submitted to CloudFormation. +You can ignore some errors by specifying the `ignoreErrorCodesMatching` property, which accepts a regular expression that is +tested against the `code` property of the response. If matched, a "SUCCESS" response is submitted. +Note that in such a case, the call response data and the `Data` key submitted to CloudFormation would both be an empty JSON object. +Since a successful resource provisioning might or might not produce outputs, this presents us with some limitations: + +- `PhysicalResourceId.fromResponse` - Since the call response data might be empty, we cannot use it to extract the physical id. +- `getData` and `getDataString` - Since the `Data` key is empty, the resource will not have any attributes, and therefore, invoking these functions will result in an error. + +In both the cases, you will get a synth time error if you attempt to use it in conjunction with `ignoreErrorCodesMatching`. + ### Examples #### Verify a domain with SES diff --git a/packages/@aws-cdk/custom-resources/lib/aws-custom-resource/aws-custom-resource.ts b/packages/@aws-cdk/custom-resources/lib/aws-custom-resource/aws-custom-resource.ts index 2aedc1871a993..d96d57aa93560 100644 --- a/packages/@aws-cdk/custom-resources/lib/aws-custom-resource/aws-custom-resource.ts +++ b/packages/@aws-cdk/custom-resources/lib/aws-custom-resource/aws-custom-resource.ts @@ -83,7 +83,7 @@ export interface AwsSdkCall { * * @default - do not catch errors */ - readonly catchErrorPattern?: string; + readonly ignoreErrorCodesMatching?: string; /** * API version to use for the service @@ -245,9 +245,21 @@ export interface AwsCustomResourceProps { * */ export class AwsCustomResource extends cdk.Construct implements iam.IGrantable { + + private static breakIgnoreErrorsCircuit(sdkCalls: Array, caller: string) { + + for (const call of sdkCalls) { + if (call?.ignoreErrorCodesMatching) { + throw new Error(`\`${caller}\`` + ' cannot be called along with `ignoreErrorCodesMatching`.'); + } + } + + } + public readonly grantPrincipal: iam.IPrincipal; private readonly customResource: CustomResource; + private readonly props: AwsCustomResourceProps; // 'props' cannot be optional, even though all its properties are optional. // this is because at least one sdk call must be provided. @@ -264,6 +276,14 @@ export class AwsCustomResource extends cdk.Construct implements iam.IGrantable { } } + for (const call of [props.onCreate, props.onUpdate, props.onDelete]) { + if (call?.physicalResourceId?.responsePath) { + AwsCustomResource.breakIgnoreErrorsCircuit([call], "PhysicalResourceId.fromResponse"); + } + } + + this.props = props; + const provider = new lambda.SingletonFunction(this, 'Provider', { code: lambda.Code.fromAsset(path.join(__dirname, 'runtime')), runtime: lambda.Runtime.NODEJS_12_X, @@ -313,9 +333,14 @@ export class AwsCustomResource extends cdk.Construct implements iam.IGrantable { * Use `Token.asXxx` to encode the returned `Reference` as a specific type or * use the convenience `getDataString` for string attributes. * + * Note that you cannot use this method if `ignoreErrorCodesMatching` + * is configured for any of the SDK calls. This is because in such a case, + * the response data might not exist, and will cause a CloudFormation deploy time error. + * * @param dataPath the path to the data */ public getData(dataPath: string) { + AwsCustomResource.breakIgnoreErrorsCircuit([this.props.onCreate, this.props.onUpdate], "getData"); return this.customResource.getAtt(dataPath); } @@ -324,11 +349,17 @@ export class AwsCustomResource extends cdk.Construct implements iam.IGrantable { * * Example for S3 / listBucket : 'Buckets.0.Name' * + * Note that you cannot use this method if `ignoreErrorCodesMatching` + * is configured for any of the SDK calls. This is because in such a case, + * the response data might not exist, and will cause a CloudFormation deploy time error. + * * @param dataPath the path to the data */ public getDataString(dataPath: string): string { + AwsCustomResource.breakIgnoreErrorsCircuit([this.props.onCreate, this.props.onUpdate], "getDataString"); return this.customResource.getAttString(dataPath); } + } /** diff --git a/packages/@aws-cdk/custom-resources/lib/aws-custom-resource/runtime/index.ts b/packages/@aws-cdk/custom-resources/lib/aws-custom-resource/runtime/index.ts index 9507a8f9388b1..36f5cb3ba3819 100644 --- a/packages/@aws-cdk/custom-resources/lib/aws-custom-resource/runtime/index.ts +++ b/packages/@aws-cdk/custom-resources/lib/aws-custom-resource/runtime/index.ts @@ -122,7 +122,7 @@ export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent ? filterKeys(flatData, k => k.startsWith(call.outputPath!)) : flatData; } catch (e) { - if (!call.catchErrorPattern || !new RegExp(call.catchErrorPattern).test(e.code)) { + if (!call.ignoreErrorCodesMatching || !new RegExp(call.ignoreErrorCodesMatching).test(e.code)) { throw e; } } diff --git a/packages/@aws-cdk/custom-resources/test/aws-custom-resource/aws-custom-resource-provider.test.ts b/packages/@aws-cdk/custom-resources/test/aws-custom-resource/aws-custom-resource-provider.test.ts index 9fa7fbfd936d6..d492dc7a8f26e 100644 --- a/packages/@aws-cdk/custom-resources/test/aws-custom-resource/aws-custom-resource-provider.test.ts +++ b/packages/@aws-cdk/custom-resources/test/aws-custom-resource/aws-custom-resource-provider.test.ts @@ -237,7 +237,7 @@ test('catch errors', async () => { Bucket: 'my-bucket' }, physicalResourceId: PhysicalResourceId.of('physicalResourceId'), - catchErrorPattern: 'NoSuchBucket' + ignoreErrorCodesMatching: 'NoSuchBucket' } as AwsSdkCall } }; diff --git a/packages/@aws-cdk/custom-resources/test/aws-custom-resource/aws-custom-resource.test.ts b/packages/@aws-cdk/custom-resources/test/aws-custom-resource/aws-custom-resource.test.ts index 18add368bf85b..dcfbfa15d194c 100644 --- a/packages/@aws-cdk/custom-resources/test/aws-custom-resource/aws-custom-resource.test.ts +++ b/packages/@aws-cdk/custom-resources/test/aws-custom-resource/aws-custom-resource.test.ts @@ -353,6 +353,97 @@ test('getData', () => { }); }); +test('fails when getData is used with `ignoreErrorCodesMatching`', () => { + + const stack = new cdk.Stack(); + + const resource = new AwsCustomResource(stack, 'AwsSdk', { + onUpdate: { + service: 'CloudWatchLogs', + action: 'putRetentionPolicy', + parameters: { + logGroupName: '/aws/lambda/loggroup', + retentionInDays: 90 + }, + ignoreErrorCodesMatching: ".*", + physicalResourceId: PhysicalResourceId.of("Id") + }, + policy: AwsCustomResourcePolicy.fromSdkCalls({resources: AwsCustomResourcePolicy.ANY_RESOURCE}), + }); + + expect(() => resource.getData("ShouldFail")).toThrow(/`getData`.+`ignoreErrorCodesMatching`/); + +}); + +test('fails when getDataString is used with `ignoreErrorCodesMatching`', () => { + + const stack = new cdk.Stack(); + + const resource = new AwsCustomResource(stack, 'AwsSdk', { + onUpdate: { + service: 'CloudWatchLogs', + action: 'putRetentionPolicy', + parameters: { + logGroupName: '/aws/lambda/loggroup', + retentionInDays: 90 + }, + ignoreErrorCodesMatching: ".*", + physicalResourceId: PhysicalResourceId.of("Id"), + }, + policy: AwsCustomResourcePolicy.fromSdkCalls({resources: AwsCustomResourcePolicy.ANY_RESOURCE}), + }); + + expect(() => resource.getDataString("ShouldFail")).toThrow(/`getDataString`.+`ignoreErrorCodesMatching`/); + +}); + +test('fail when `PhysicalResourceId.fromResponse` is used with `ignoreErrorCodesMatching', () => { + + const stack = new cdk.Stack(); + expect(() => new AwsCustomResource(stack, 'AwsSdkOnUpdate', { + onUpdate: { + service: 'CloudWatchLogs', + action: 'putRetentionPolicy', + parameters: { + logGroupName: '/aws/lambda/loggroup', + retentionInDays: 90 + }, + ignoreErrorCodesMatching: ".*", + physicalResourceId: PhysicalResourceId.fromResponse("Response") + }, + policy: AwsCustomResourcePolicy.fromSdkCalls({resources: AwsCustomResourcePolicy.ANY_RESOURCE}), + })).toThrow(/`PhysicalResourceId.fromResponse`.+`ignoreErrorCodesMatching`/); + + expect(() => new AwsCustomResource(stack, 'AwsSdkOnCreate', { + onCreate: { + service: 'CloudWatchLogs', + action: 'putRetentionPolicy', + parameters: { + logGroupName: '/aws/lambda/loggroup', + retentionInDays: 90 + }, + ignoreErrorCodesMatching: ".*", + physicalResourceId: PhysicalResourceId.fromResponse("Response") + }, + policy: AwsCustomResourcePolicy.fromSdkCalls({resources: AwsCustomResourcePolicy.ANY_RESOURCE}), + })).toThrow(/`PhysicalResourceId.fromResponse`.+`ignoreErrorCodesMatching`/); + + expect(() => new AwsCustomResource(stack, 'AwsSdkOnDelete', { + onDelete: { + service: 'CloudWatchLogs', + action: 'putRetentionPolicy', + parameters: { + logGroupName: '/aws/lambda/loggroup', + retentionInDays: 90 + }, + ignoreErrorCodesMatching: ".*", + physicalResourceId: PhysicalResourceId.fromResponse("Response") + }, + policy: AwsCustomResourcePolicy.fromSdkCalls({resources: AwsCustomResourcePolicy.ANY_RESOURCE}), + })).toThrow(/`PhysicalResourceId.fromResponse`.+`ignoreErrorCodesMatching`/); + +}); + test('getDataString', () => { // GIVEN const stack = new cdk.Stack(); diff --git a/packages/@aws-cdk/custom-resources/test/aws-custom-resource/integ.aws-custom-resource.expected.json b/packages/@aws-cdk/custom-resources/test/aws-custom-resource/integ.aws-custom-resource.expected.json index 68bebdc9bc22d..6c3436e07d7fc 100644 --- a/packages/@aws-cdk/custom-resources/test/aws-custom-resource/integ.aws-custom-resource.expected.json +++ b/packages/@aws-cdk/custom-resources/test/aws-custom-resource/integ.aws-custom-resource.expected.json @@ -113,7 +113,7 @@ "Properties": { "Code": { "S3Bucket": { - "Ref": "AssetParameters6bf0eac2bfd1c5dcd41b3cc53f24814f9dba9cce0cb7fe2e34d0ded661481748S3BucketFC283D1B" + "Ref": "AssetParameters4317eb4c8ccce73f91d75018d965c243c175c64101884e0f202d4a280e23313fS3Bucket71E9DB75" }, "S3Key": { "Fn::Join": [ @@ -126,7 +126,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters6bf0eac2bfd1c5dcd41b3cc53f24814f9dba9cce0cb7fe2e34d0ded661481748S3VersionKey7E916B81" + "Ref": "AssetParameters4317eb4c8ccce73f91d75018d965c243c175c64101884e0f202d4a280e23313fS3VersionKeyADF76D6F" } ] } @@ -139,7 +139,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters6bf0eac2bfd1c5dcd41b3cc53f24814f9dba9cce0cb7fe2e34d0ded661481748S3VersionKey7E916B81" + "Ref": "AssetParameters4317eb4c8ccce73f91d75018d965c243c175c64101884e0f202d4a280e23313fS3VersionKeyADF76D6F" } ] } @@ -242,17 +242,17 @@ } }, "Parameters": { - "AssetParameters6bf0eac2bfd1c5dcd41b3cc53f24814f9dba9cce0cb7fe2e34d0ded661481748S3BucketFC283D1B": { + "AssetParameters4317eb4c8ccce73f91d75018d965c243c175c64101884e0f202d4a280e23313fS3Bucket71E9DB75": { "Type": "String", - "Description": "S3 bucket for asset \"6bf0eac2bfd1c5dcd41b3cc53f24814f9dba9cce0cb7fe2e34d0ded661481748\"" + "Description": "S3 bucket for asset \"4317eb4c8ccce73f91d75018d965c243c175c64101884e0f202d4a280e23313f\"" }, - "AssetParameters6bf0eac2bfd1c5dcd41b3cc53f24814f9dba9cce0cb7fe2e34d0ded661481748S3VersionKey7E916B81": { + "AssetParameters4317eb4c8ccce73f91d75018d965c243c175c64101884e0f202d4a280e23313fS3VersionKeyADF76D6F": { "Type": "String", - "Description": "S3 key for asset version \"6bf0eac2bfd1c5dcd41b3cc53f24814f9dba9cce0cb7fe2e34d0ded661481748\"" + "Description": "S3 key for asset version \"4317eb4c8ccce73f91d75018d965c243c175c64101884e0f202d4a280e23313f\"" }, - "AssetParameters6bf0eac2bfd1c5dcd41b3cc53f24814f9dba9cce0cb7fe2e34d0ded661481748ArtifactHashB96EE827": { + "AssetParameters4317eb4c8ccce73f91d75018d965c243c175c64101884e0f202d4a280e23313fArtifactHash7FE12709": { "Type": "String", - "Description": "Artifact hash for asset \"6bf0eac2bfd1c5dcd41b3cc53f24814f9dba9cce0cb7fe2e34d0ded661481748\"" + "Description": "Artifact hash for asset \"4317eb4c8ccce73f91d75018d965c243c175c64101884e0f202d4a280e23313f\"" } }, "Outputs": {