diff --git a/README.md b/README.md index c413d0df84cb2..8979eef888e5b 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ You may also find help on these community resources: and tag it with `aws-cdk` * Come join the AWS CDK community on [Gitter](https://gitter.im/awslabs/aws-cdk) * Talk in the CDK channel of the [AWS Developers Slack workspace](https://awsdevelopers.slack.com) (invite required) -* Check out the [partitions.io board](https://partitions.io/cdk) +* A community-driven Slack channel is also available, invite at [cdk.dev](https://cdk.dev) ### Roadmap diff --git a/packages/@aws-cdk/aws-iam/lib/role.ts b/packages/@aws-cdk/aws-iam/lib/role.ts index 4e4174de420cd..42ccfc707582b 100644 --- a/packages/@aws-cdk/aws-iam/lib/role.ts +++ b/packages/@aws-cdk/aws-iam/lib/role.ts @@ -101,7 +101,7 @@ export interface RoleProps { * Acknowledging IAM Resources in AWS CloudFormation Templates. * * @default - AWS CloudFormation generates a unique physical ID and uses that ID - * for the group name. + * for the role name. */ readonly roleName?: string; diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/lambda/invoke.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/lambda/invoke.ts index 540322ca9c405..b11041190a723 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/lambda/invoke.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/lambda/invoke.ts @@ -123,7 +123,7 @@ export class LambdaInvoke extends sfn.TaskStateBase { }), ]; - if (props.retryOnServiceExceptions ?? true) + if (props.retryOnServiceExceptions ?? true) { // Best practice from https://docs.aws.amazon.com/step-functions/latest/dg/bp-lambda-serviceexception.html this.addRetry({ errors: ['Lambda.ServiceException', 'Lambda.AWSLambdaException', 'Lambda.SdkClientException'], @@ -131,6 +131,7 @@ export class LambdaInvoke extends sfn.TaskStateBase { maxAttempts: 6, backoffRate: 2, }); + } } /** diff --git a/packages/@aws-cdk/aws-stepfunctions/README.md b/packages/@aws-cdk/aws-stepfunctions/README.md index fc1ee6b294dcd..123baeb22083b 100644 --- a/packages/@aws-cdk/aws-stepfunctions/README.md +++ b/packages/@aws-cdk/aws-stepfunctions/README.md @@ -154,7 +154,7 @@ and also injects a field called `otherData`. ```ts const pass = new stepfunctions.Pass(this, 'Filter input and inject data', { parameters: { // input to the pass state - input: stepfunctions.JsonPath.stringAt('$.input.greeting') + input: stepfunctions.JsonPath.stringAt('$.input.greeting'), otherData: 'some-extra-stuff' }, }); @@ -217,6 +217,54 @@ If your `Choice` doesn't have an `otherwise()` and none of the conditions match the JSON state, a `NoChoiceMatched` error will be thrown. Wrap the state machine in a `Parallel` state if you want to catch and recover from this. +#### Available Conditions: +see [step function comparison operators](https://docs.aws.amazon.com/step-functions/latest/dg/amazon-states-language-choice-state.html#amazon-states-language-choice-state-rules) +* `Condition.isPresent` - matches if a json path is present +* `Condition.isNotPresent` - matches if a json path is not present +* `Condition.isString` - matches if a json path contains a string +* `Condition.isNotString` - matches if a json path is not a string +* `Condition.isNumeric` - matches if a json path is numeric +* `Condition.isNotNumeric` - matches if a json path is not numeric +* `Condition.isBoolean` - matches if a json path is boolean +* `Condition.isNotBoolean` - matches if a json path is not boolean +* `Condition.isTimestamp` - matches if a json path is a timestamp +* `Condition.isNotTimestamp` - matches if a json path is not a timestamp +* `Condition.isNotNull` - matches if a json path is not null +* `Condition.isNull` - matches if a json path is null +* `Condition.booleanEquals` - matches if a boolean field has a given value +* `Condition.booleanEqualsJsonPath` - matches if a boolean field equals a value in a given mapping path +* `Condition.stringEqualsJsonPath` - matches if a string field equals a given mapping path +* `Condition.stringEquals` - matches if a field equals a string value +* `Condition.stringLessThan` - matches if a string field sorts before a given value +* `Condition.stringLessThanJsonPath` - matches if a string field sorts before a value at given mapping path +* `Condition.stringLessThanEquals` - matches if a string field sorts equal to or before a given value +* `Condition.stringLessThanEqualsJsonPath` - matches if a string field sorts equal to or before a given mapping +* `Condition.stringGreaterThan` - matches if a string field sorts after a given value +* `Condition.stringGreaterThanJsonPath` - matches if a string field sorts after a value at a given mapping path +* `Condition.stringGreaterThanEqualsJsonPath` - matches if a string field sorts after or equal to value at a given mapping path +* `Condition.stringGreaterThanEquals` - matches if a string field sorts after or equal to a given value +* `Condition.numberEquals` - matches if a numeric field has the given value +* `Condition.numberEqualsJsonPath` - matches if a numeric field has the value in a given mapping path +* `Condition.numberLessThan` - matches if a numeric field is less than the given value +* `Condition.numberLessThanJsonPath` - matches if a numeric field is less than the value at the given mapping path +* `Condition.numberLessThanEquals` - matches if a numeric field is less than or equal to the given value +* `Condition.numberLessThanEqualsJsonPath` - matches if a numeric field is less than or equal to the numeric value at given mapping path +* `Condition.numberGreaterThan` - matches if a numeric field is greater than the given value +* `Condition.numberGreaterThanJsonPath` - matches if a numeric field is greater than the value at a given mapping path +* `Condition.numberGreaterThanEquals` - matches if a numeric field is greater than or equal to the given value +* `Condition.numberGreaterThanEqualsJsonPath` - matches if a numeric field is greater than or equal to the value at a given mapping path +* `Condition.timestampEquals` - matches if a timestamp field is the same time as the given timestamp +* `Condition.timestampEqualsJsonPath` - matches if a timestamp field is the same time as the timestamp at a given mapping path +* `Condition.timestampLessThan` - matches if a timestamp field is before the given timestamp +* `Condition.timestampLessThanJsonPath` - matches if a timestamp field is before the timestamp at a given mapping path +* `Condition.timestampLessThanEquals` - matches if a timestamp field is before or equal to the given timestamp +* `Condition.timestampLessThanEqualsJsonPath` - matches if a timestamp field is before or equal to the timestamp at a given mapping path +* `Condition.timestampGreaterThan` - matches if a timestamp field is after the timestamp at a given mapping path +* `Condition.timestampGreaterThanJsonPath` - matches if a timestamp field is after the timestamp at a given mapping path +* `Condition.timestampGreaterThanEquals` - matches if a timestamp field is after or equal to the given timestamp +* `Condition.timestampGreaterThanEqualsJsonPath` - matches if a timestamp field is after or equal to the timestamp at a given mapping path +* `Condition.stringMatches` - matches if a field matches a string pattern that can contain a wild card (\*) e.g: log-\*.txt or \*LATEST\*. No other characters other than "\*" have any special meaning - \* can be escaped: \\\\* + ### Parallel A `Parallel` state executes one or more subworkflows in parallel. It can also diff --git a/packages/@aws-cdk/aws-stepfunctions/lib/condition.ts b/packages/@aws-cdk/aws-stepfunctions/lib/condition.ts index 2f334af06190c..bc264874f8093 100644 --- a/packages/@aws-cdk/aws-stepfunctions/lib/condition.ts +++ b/packages/@aws-cdk/aws-stepfunctions/lib/condition.ts @@ -2,6 +2,89 @@ * A Condition for use in a Choice state branch */ export abstract class Condition { + + /** + * Matches if variable is present + */ + public static isPresent(variable: string): Condition { + return new VariableComparison(variable, ComparisonOperator.IsPresent, true); + } + + /** + * Matches if variable is not present + */ + public static isNotPresent(variable: string): Condition { + return new VariableComparison(variable, ComparisonOperator.IsPresent, false); + } + + /** + * Matches if variable is a string + */ + public static isString(variable: string): Condition { + return new VariableComparison(variable, ComparisonOperator.IsString, true); + } + + /** + * Matches if variable is not a string + */ + public static isNotString(variable: string): Condition { + return new VariableComparison(variable, ComparisonOperator.IsString, false); + } + + /** + * Matches if variable is numeric + */ + public static isNumeric(variable: string): Condition { + return new VariableComparison(variable, ComparisonOperator.IsNumeric, true); + } + + /** + * Matches if variable is not numeric + */ + public static isNotNumeric(variable: string): Condition { + return new VariableComparison(variable, ComparisonOperator.IsNumeric, false); + } + + /** + * Matches if variable is boolean + */ + public static isBoolean(variable: string): Condition { + return new VariableComparison(variable, ComparisonOperator.IsBoolean, true); + } + + /** + * Matches if variable is not boolean + */ + public static isNotBoolean(variable: string): Condition { + return new VariableComparison(variable, ComparisonOperator.IsBoolean, false); + } + + /** + * Matches if variable is a timestamp + */ + public static isTimestamp(variable: string): Condition { + return new VariableComparison(variable, ComparisonOperator.IsTimestamp, true); + } + + /** + * Matches if variable is not a timestamp + */ + public static isNotTimestamp(variable: string): Condition { + return new VariableComparison(variable, ComparisonOperator.IsTimestamp, false); + } + + /** + * Matches if variable is not null + */ + public static isNotNull(variable: string): Condition { + return new VariableComparison(variable, ComparisonOperator.IsNull, false); + } + /** + * Matches if variable is Null + */ + public static isNull(variable: string): Condition { + return new VariableComparison(variable, ComparisonOperator.IsNull, true); + } /** * Matches if a boolean field has the given value */ @@ -9,6 +92,20 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.BooleanEquals, value); } + /** + * Matches if a boolean field equals to a value at a given mapping path + */ + public static booleanEqualsJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.BooleanEqualsPath, value); + } + + /** + * Matches if a string field equals to a value at a given mapping path + */ + public static stringEqualsJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.StringEqualsPath, value); + } + /** * Matches if a string field has the given value */ @@ -23,6 +120,13 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.StringLessThan, value); } + /** + * Matches if a string field sorts before a given value at a particular mapping + */ + public static stringLessThanJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.StringLessThanPath, value); + } + /** * Matches if a string field sorts equal to or before a given value */ @@ -30,6 +134,13 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.StringLessThanEquals, value); } + /** + * Matches if a string field sorts equal to or before a given mapping + */ + public static stringLessThanEqualsJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.StringLessThanEqualsPath, value); + } + /** * Matches if a string field sorts after a given value */ @@ -37,6 +148,20 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.StringGreaterThan, value); } + /** + * Matches if a string field sorts after a value at a given mapping path + */ + public static stringGreaterThanJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.StringGreaterThanPath, value); + } + + /** + * Matches if a string field sorts after or equal to value at a given mapping path + */ + public static stringGreaterThanEqualsJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.StringGreaterThanEqualsPath, value); + } + /** * Matches if a string field sorts after or equal to a given value */ @@ -51,6 +176,13 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.NumericEquals, value); } + /** + * Matches if a numeric field has the value in a given mapping path + */ + public static numberEqualsJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.NumericEqualsPath, value); + } + /** * Matches if a numeric field is less than the given value */ @@ -58,6 +190,13 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.NumericLessThan, value); } + /** + * Matches if a numeric field is less than the value at the given mapping path + */ + public static numberLessThanJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.NumericLessThanPath, value); + } + /** * Matches if a numeric field is less than or equal to the given value */ @@ -65,6 +204,13 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.NumericLessThanEquals, value); } + /** + * Matches if a numeric field is less than or equal to the numeric value at given mapping path + */ + public static numberLessThanEqualsJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.NumericLessThanEqualsPath, value); + } + /** * Matches if a numeric field is greater than the given value */ @@ -72,6 +218,13 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.NumericGreaterThan, value); } + /** + * Matches if a numeric field is greater than the value at a given mapping path + */ + public static numberGreaterThanJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.NumericGreaterThanPath, value); + } + /** * Matches if a numeric field is greater than or equal to the given value */ @@ -79,6 +232,13 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.NumericGreaterThanEquals, value); } + /** + * Matches if a numeric field is greater than or equal to the value at a given mapping path + */ + public static numberGreaterThanEqualsJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.NumericGreaterThanEqualsPath, value); + } + /** * Matches if a timestamp field is the same time as the given timestamp */ @@ -86,6 +246,13 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.TimestampEquals, value); } + /** + * Matches if a timestamp field is the same time as the timestamp at a given mapping path + */ + public static timestampEqualsJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.TimestampEqualsPath, value); + } + /** * Matches if a timestamp field is before the given timestamp */ @@ -93,6 +260,13 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.TimestampLessThan, value); } + /** + * Matches if a timestamp field is before the timestamp at a given mapping path + */ + public static timestampLessThanJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.TimestampLessThanPath, value); + } + /** * Matches if a timestamp field is before or equal to the given timestamp */ @@ -100,6 +274,13 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.TimestampLessThanEquals, value); } + /** + * Matches if a timestamp field is before or equal to the timestamp at a given mapping path + */ + public static timestampLessThanEqualsJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.TimestampLessThanEqualsPath, value); + } + /** * Matches if a timestamp field is after the given timestamp */ @@ -107,6 +288,13 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.TimestampGreaterThan, value); } + /** + * Matches if a timestamp field is after the timestamp at a given mapping path + */ + public static timestampGreaterThanJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.TimestampGreaterThanPath, value); + } + /** * Matches if a timestamp field is after or equal to the given timestamp */ @@ -114,6 +302,21 @@ export abstract class Condition { return new VariableComparison(variable, ComparisonOperator.TimestampGreaterThanEquals, value); } + /** + * Matches if a timestamp field is after or equal to the timestamp at a given mapping path + */ + public static timestampGreaterThanEqualsJsonPath(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.TimestampGreaterThanEqualsPath, value); + } + + /** + * Matches if a field matches a string pattern that can contain a wild card (*) e.g: log-*.txt or *LATEST*. + * No other characters other than "*" have any special meaning - * can be escaped: \\* + */ + public static stringMatches(variable: string, value: string): Condition { + return new VariableComparison(variable, ComparisonOperator.StringMatches, value); + } + /** * Combine two or more conditions with a logical AND */ @@ -146,21 +349,45 @@ export abstract class Condition { */ enum ComparisonOperator { StringEquals, + StringEqualsPath, StringLessThan, + StringLessThanPath, StringGreaterThan, + StringGreaterThanPath, StringLessThanEquals, + StringLessThanEqualsPath, StringGreaterThanEquals, + StringGreaterThanEqualsPath, NumericEquals, + NumericEqualsPath, NumericLessThan, + NumericLessThanPath, NumericGreaterThan, + NumericGreaterThanPath, NumericLessThanEquals, + NumericLessThanEqualsPath, NumericGreaterThanEquals, + NumericGreaterThanEqualsPath, BooleanEquals, + BooleanEqualsPath, TimestampEquals, + TimestampEqualsPath, TimestampLessThan, + TimestampLessThanPath, TimestampGreaterThan, + TimestampGreaterThanPath, TimestampLessThanEquals, + TimestampLessThanEqualsPath, TimestampGreaterThanEquals, + TimestampGreaterThanEqualsPath, + IsNull, + IsBoolean, + IsNumeric, + IsString, + IsTimestamp, + IsPresent, + StringMatches, + } /** diff --git a/packages/@aws-cdk/aws-stepfunctions/test/condition.test.ts b/packages/@aws-cdk/aws-stepfunctions/test/condition.test.ts index aef483b4583a5..55fa5a6d2c535 100644 --- a/packages/@aws-cdk/aws-stepfunctions/test/condition.test.ts +++ b/packages/@aws-cdk/aws-stepfunctions/test/condition.test.ts @@ -37,6 +37,91 @@ describe('Condition Variables', () => { [stepfunctions.Condition.numberEquals('$.a', 5), { Variable: '$.a', NumericEquals: 5 }], ]; + for (const [cond, expected] of cases) { + assertRendersTo(cond, expected); + } + }), + test('Exercise string conditions', () => { + const cases: Array<[stepfunctions.Condition, object]> = [ + [stepfunctions.Condition.stringEquals('$.a', 'foo'), { Variable: '$.a', StringEquals: 'foo' }], + [stepfunctions.Condition.stringEqualsJsonPath('$.a', '$.b'), { Variable: '$.a', StringEqualsPath: '$.b' }], + [stepfunctions.Condition.stringLessThan('$.a', 'foo'), { Variable: '$.a', StringLessThan: 'foo' }], + [stepfunctions.Condition.stringLessThanJsonPath('$.a', '$.b'), { Variable: '$.a', StringLessThanPath: '$.b' }], + [stepfunctions.Condition.stringLessThanEquals('$.a', 'foo'), { Variable: '$.a', StringLessThanEquals: 'foo' }], + [stepfunctions.Condition.stringLessThanEqualsJsonPath('$.a', '$.b'), { Variable: '$.a', StringLessThanEqualsPath: '$.b' }], + [stepfunctions.Condition.stringGreaterThan('$.a', 'foo'), { Variable: '$.a', StringGreaterThan: 'foo' }], + [stepfunctions.Condition.stringGreaterThanJsonPath('$.a', '$.b'), { Variable: '$.a', StringGreaterThanPath: '$.b' }], + [stepfunctions.Condition.stringGreaterThanEquals('$.a', 'foo'), { Variable: '$.a', StringGreaterThanEquals: 'foo' }], + [stepfunctions.Condition.stringGreaterThanEqualsJsonPath('$.a', '$.b'), { Variable: '$.a', StringGreaterThanEqualsPath: '$.b' }], + ]; + + for (const [cond, expected] of cases) { + assertRendersTo(cond, expected); + } + }), + test('Exercise number conditions', () => { + const cases: Array<[stepfunctions.Condition, object]> = [ + [stepfunctions.Condition.numberEquals('$.a', 5), { Variable: '$.a', NumericEquals: 5 }], + [stepfunctions.Condition.numberEqualsJsonPath('$.a', '$.b'), { Variable: '$.a', NumericEqualsPath: '$.b' }], + [stepfunctions.Condition.numberLessThan('$.a', 5), { Variable: '$.a', NumericLessThan: 5 }], + [stepfunctions.Condition.numberLessThanJsonPath('$.a', '$.b'), { Variable: '$.a', NumericLessThanPath: '$.b' }], + [stepfunctions.Condition.numberGreaterThan('$.a', 5), { Variable: '$.a', NumericGreaterThan: 5 }], + [stepfunctions.Condition.numberGreaterThanJsonPath('$.a', '$.b'), { Variable: '$.a', NumericGreaterThanPath: '$.b' }], + [stepfunctions.Condition.numberLessThanEquals('$.a', 5), { Variable: '$.a', NumericLessThanEquals: 5 }], + [stepfunctions.Condition.numberLessThanEqualsJsonPath('$.a', '$.b'), { Variable: '$.a', NumericLessThanEqualsPath: '$.b' }], + [stepfunctions.Condition.numberGreaterThanEquals('$.a', 5), { Variable: '$.a', NumericGreaterThanEquals: 5 }], + [stepfunctions.Condition.numberGreaterThanEqualsJsonPath('$.a', '$.b'), { Variable: '$.a', NumericGreaterThanEqualsPath: '$.b' }], + ]; + + + for (const [cond, expected] of cases) { + assertRendersTo(cond, expected); + } + }), + test('Exercise type conditions', () => { + const cases: Array<[stepfunctions.Condition, object]> = [ + [stepfunctions.Condition.isString('$.a'), { Variable: '$.a', IsString: true }], + [stepfunctions.Condition.isNotString('$.a'), { Variable: '$.a', IsString: false }], + [stepfunctions.Condition.isNumeric('$.a'), { Variable: '$.a', IsNumeric: true }], + [stepfunctions.Condition.isNotNumeric('$.a'), { Variable: '$.a', IsNumeric: false }], + [stepfunctions.Condition.isBoolean('$.a'), { Variable: '$.a', IsBoolean: true }], + [stepfunctions.Condition.isNotBoolean('$.a'), { Variable: '$.a', IsBoolean: false }], + [stepfunctions.Condition.isTimestamp('$.a'), { Variable: '$.a', IsTimestamp: true }], + [stepfunctions.Condition.isNotTimestamp('$.a'), { Variable: '$.a', IsTimestamp: false }], + ]; + + for (const [cond, expected] of cases) { + assertRendersTo(cond, expected); + } + }), + test('Exercise timestamp conditions', () => { + const cases: Array<[stepfunctions.Condition, object]> = [ + [stepfunctions.Condition.timestampEquals('$.a', 'timestamp'), { Variable: '$.a', TimestampEquals: 'timestamp' }], + [stepfunctions.Condition.timestampEqualsJsonPath('$.a', '$.b'), { Variable: '$.a', TimestampEqualsPath: '$.b' }], + [stepfunctions.Condition.timestampLessThan('$.a', 'timestamp'), { Variable: '$.a', TimestampLessThan: 'timestamp' }], + [stepfunctions.Condition.timestampLessThanJsonPath('$.a', '$.b'), { Variable: '$.a', TimestampLessThanPath: '$.b' }], + [stepfunctions.Condition.timestampGreaterThan('$.a', 'timestamp'), { Variable: '$.a', TimestampGreaterThan: 'timestamp' }], + [stepfunctions.Condition.timestampGreaterThanJsonPath('$.a', '$.b'), { Variable: '$.a', TimestampGreaterThanPath: '$.b' }], + [stepfunctions.Condition.timestampLessThanEquals('$.a', 'timestamp'), { Variable: '$.a', TimestampLessThanEquals: 'timestamp' }], + [stepfunctions.Condition.timestampLessThanEqualsJsonPath('$.a', '$.b'), { Variable: '$.a', TimestampLessThanEqualsPath: '$.b' }], + [stepfunctions.Condition.timestampGreaterThanEquals('$.a', 'timestamp'), { Variable: '$.a', TimestampGreaterThanEquals: 'timestamp' }], + [stepfunctions.Condition.timestampGreaterThanEqualsJsonPath('$.a', '$.b'), { Variable: '$.a', TimestampGreaterThanEqualsPath: '$.b' }], + ]; + + for (const [cond, expected] of cases) { + assertRendersTo(cond, expected); + } + }), + + test('Exercise other conditions', () => { + const cases: Array<[stepfunctions.Condition, object]> = [ + [stepfunctions.Condition.booleanEqualsJsonPath('$.a', '$.b'), { Variable: '$.a', BooleanEqualsPath: '$.b' }], + [stepfunctions.Condition.booleanEquals('$.a', true), { Variable: '$.a', BooleanEquals: true }], + [stepfunctions.Condition.isPresent('$.a'), { Variable: '$.a', IsPresent: true }], + [stepfunctions.Condition.isNotPresent('$.a'), { Variable: '$.a', IsPresent: false }], + [stepfunctions.Condition.stringMatches('$.a', 'foo'), { Variable: '$.a', StringMatches: 'foo' }], + ]; + for (const [cond, expected] of cases) { assertRendersTo(cond, expected); } diff --git a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts index c7fe4d0a09464..1a74dd14f5108 100644 --- a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts +++ b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts @@ -552,12 +552,12 @@ export class CfnInclude extends core.CfnElement { // fail early for resource attributes we don't support yet const knownAttributes = [ - 'Type', 'Properties', 'Condition', 'DependsOn', 'Metadata', + 'Type', 'Properties', 'Condition', 'DependsOn', 'Metadata', 'Version', 'CreationPolicy', 'UpdatePolicy', 'DeletionPolicy', 'UpdateReplacePolicy', ]; for (const attribute of Object.keys(resourceAttributes)) { if (!knownAttributes.includes(attribute)) { - throw new Error(`The ${attribute} resource attribute is not supported by cloudformation-include yet. ` + + throw new Error(`The '${attribute}' resource attribute is not supported by cloudformation-include yet. ` + 'Either remove it from the template, or use the CdkInclude class from the core package instead.'); } } diff --git a/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts b/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts index 17bf6b9a3261e..5c901dc3f1bf2 100644 --- a/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts @@ -80,7 +80,7 @@ describe('CDK Include', () => { test('throws a validation exception when encountering an unrecognized resource attribute', () => { expect(() => { includeTestTemplate(stack, 'non-existent-resource-attribute.json'); - }).toThrow(/The NonExistentResourceAttribute resource attribute is not supported by cloudformation-include yet/); + }).toThrow(/The 'NonExistentResourceAttribute' resource attribute is not supported by cloudformation-include yet/); }); test("throws a validation exception when encountering a Ref-erence to a template element that doesn't exist", () => { diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/custom-resource-with-attributes.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/custom-resource-with-attributes.json index b99e4cc2c0eea..716224153dbb9 100644 --- a/packages/@aws-cdk/cloudformation-include/test/test-templates/custom-resource-with-attributes.json +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/custom-resource-with-attributes.json @@ -13,6 +13,7 @@ "CustomBucket": { "Type": "AWS::MyService::Custom", "Condition": "AlwaysFalseCond", + "Version": "1.0", "Metadata": { "Object1": "Value1", "Object2": "Value2" diff --git a/packages/@aws-cdk/core/lib/cfn-parse.ts b/packages/@aws-cdk/core/lib/cfn-parse.ts index 39d1352b52e7d..7a18378556b9d 100644 --- a/packages/@aws-cdk/core/lib/cfn-parse.ts +++ b/packages/@aws-cdk/core/lib/cfn-parse.ts @@ -284,6 +284,7 @@ export class CfnParser { cfnOptions.updatePolicy = this.parseUpdatePolicy(resourceAttributes.UpdatePolicy); cfnOptions.deletionPolicy = this.parseDeletionPolicy(resourceAttributes.DeletionPolicy); cfnOptions.updateReplacePolicy = this.parseDeletionPolicy(resourceAttributes.UpdateReplacePolicy); + cfnOptions.version = this.parseValue(resourceAttributes.Version); cfnOptions.metadata = this.parseValue(resourceAttributes.Metadata); // handle Condition diff --git a/packages/@aws-cdk/core/lib/cfn-resource.ts b/packages/@aws-cdk/core/lib/cfn-resource.ts index 9af3aa5c16a21..fcb1c95eaf5bb 100644 --- a/packages/@aws-cdk/core/lib/cfn-resource.ts +++ b/packages/@aws-cdk/core/lib/cfn-resource.ts @@ -300,6 +300,7 @@ export class CfnResource extends CfnRefElement { UpdatePolicy: capitalizePropertyNames(this, this.cfnOptions.updatePolicy), UpdateReplacePolicy: capitalizePropertyNames(this, this.cfnOptions.updateReplacePolicy), DeletionPolicy: capitalizePropertyNames(this, this.cfnOptions.deletionPolicy), + Version: this.cfnOptions.version, Metadata: ignoreEmpty(this.cfnOptions.metadata), Condition: this.cfnOptions.condition && this.cfnOptions.condition.logicalId, }, props => { @@ -429,6 +430,14 @@ export interface ICfnResourceOptions { */ updateReplacePolicy?: CfnDeletionPolicy; + /** + * The version of this resource. + * Used only for custom CloudFormation resources. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cfn-customresource.html + */ + version?: string; + /** * Metadata associated with the CloudFormation resource. This is not the same as the construct metadata which can be added * using construct.addMetadata(), but would not appear in the CloudFormation template automatically. diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index 8a7f89911a6a6..2893ae1127b54 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -69,7 +69,8 @@ async function parseCommandLineArguments() { .option('exclusively', { type: 'boolean', alias: 'e', desc: 'Only synthesize requested stacks, don\'t include dependencies' })) .command('bootstrap [ENVIRONMENTS..]', 'Deploys the CDK toolkit stack into an AWS environment', yargs => yargs .option('bootstrap-bucket-name', { type: 'string', alias: ['b', 'toolkit-bucket-name'], desc: 'The name of the CDK toolkit bucket; bucket will be created and must not exist', default: undefined }) - .option('bootstrap-kms-key-id', { type: 'string', desc: 'AWS KMS master key ID used for the SSE-KMS encryption', default: undefined }) + .option('bootstrap-kms-key-id', { type: 'string', desc: 'AWS KMS master key ID used for the SSE-KMS encryption', default: undefined, conflicts: 'bootstrap-customer-key' }) + .option('bootstrap-customer-key', { type: 'boolean', desc: 'Create a Customer Master Key (CMK) for the bootstrap bucket (you will be charged but can customize permissions, modern bootstrapping only)', default: undefined, conflicts: 'bootstrap-kms-key-id' }) .option('qualifier', { type: 'string', desc: 'Unique string to distinguish multiple bootstrap stacks', default: undefined }) .option('public-access-block-configuration', { type: 'boolean', desc: 'Block public access configuration on CDK toolkit bucket (enabled by default) ', default: undefined }) .option('tags', { type: 'array', alias: 't', desc: 'Tags to add for the stack (KEY=VALUE)', nargs: 1, requiresArg: true, default: [] }) @@ -271,6 +272,7 @@ async function initCommandLine() { parameters: { bucketName: configuration.settings.get(['toolkitBucket', 'bucketName']), kmsKeyId: configuration.settings.get(['toolkitBucket', 'kmsKeyId']), + createCustomerMasterKey: args.bootstrapCustomerKey, qualifier: args.qualifier, publicAccessBlockConfiguration: args.publicAccessBlockConfiguration, trustedAccounts: arrayFromYargs(args.trust), diff --git a/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts b/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts index f160df6a6f2e7..c8b2e74138ecb 100644 --- a/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts +++ b/packages/aws-cdk/lib/api/bootstrap/bootstrap-environment.ts @@ -25,7 +25,7 @@ export class Bootstrapper { case 'legacy': return this.legacyBootstrap(environment, sdkProvider, options); case 'default': - return this.defaultBootstrap(environment, sdkProvider, options); + return this.modernBootstrap(environment, sdkProvider, options); case 'custom': return this.customBootstrap(environment, sdkProvider, options); } @@ -45,13 +45,16 @@ export class Bootstrapper { const params = options.parameters ?? {}; if (params.trustedAccounts?.length) { - throw new Error('--trust can only be passed for the new bootstrap experience.'); + throw new Error('--trust can only be passed for the modern bootstrap experience.'); } if (params.cloudFormationExecutionPolicies?.length) { - throw new Error('--cloudformation-execution-policies can only be passed for the new bootstrap experience.'); + throw new Error('--cloudformation-execution-policies can only be passed for the modern bootstrap experience.'); + } + if (params.createCustomerMasterKey !== undefined) { + throw new Error('--bootstrap-customer-key can only be passed for the modern bootstrap experience.'); } if (params.qualifier) { - throw new Error('--qualifier can only be passed for the new bootstrap experience.'); + throw new Error('--qualifier can only be passed for the modern bootstrap experience.'); } const current = await BootstrapStack.lookup(sdkProvider, environment, options.toolkitStackName); @@ -66,7 +69,7 @@ export class Bootstrapper { * * @experimental */ - private async defaultBootstrap( + private async modernBootstrap( environment: cxapi.Environment, sdkProvider: SdkProvider, options: BootstrapEnvironmentOptions = {}): Promise { @@ -77,6 +80,10 @@ export class Bootstrapper { const current = await BootstrapStack.lookup(sdkProvider, environment, options.toolkitStackName); + if (params.createCustomerMasterKey !== undefined && params.kmsKeyId) { + throw new Error('You cannot pass \'--bootstrap-kms-key-id\' and \'--bootstrap-customer-key\' together. Specify one or the other'); + } + // If people re-bootstrap, existing parameter values are reused so that people don't accidentally change the configuration // on their bootstrap stack (this happens automatically in deployStack). However, to do proper validation on the // combined arguments (such that if --trust has been given, --cloudformation-execution-policies is necessary as well) @@ -93,7 +100,20 @@ export class Bootstrapper { if (cloudFormationExecutionPolicies.length === 0) { throw new Error('Please pass \'--cloudformation-execution-policies\' to specify deployment permissions. Try a managed policy of the form \'arn:aws:iam::aws:policy/\'.'); } - // Remind people what the current settings are + + // * If an ARN is given, that ARN. Otherwise: + // * '-' if customerKey = false + // * '' if customerKey = true + // * if customerKey is also not given + // * undefined if we already had a value in place (reusing what we had) + // * '-' if this is the first time we're deploying this stack (or upgrading from old to new bootstrap) + const currentKmsKeyId = current.parameters.FileAssetsBucketKmsKeyId; + const kmsKeyId = params.kmsKeyId ?? + (params.createCustomerMasterKey === true ? CREATE_NEW_KEY : + params.createCustomerMasterKey === false || currentKmsKeyId === undefined ? USE_AWS_MANAGED_KEY : + undefined); + + // Remind people what we settled on info(`Trusted accounts: ${trustedAccounts.length > 0 ? trustedAccounts.join(', ') : '(none)'}`); info(`Execution policies: ${cloudFormationExecutionPolicies.join(', ')}`); @@ -101,7 +121,7 @@ export class Bootstrapper { bootstrapTemplate, { FileAssetsBucketName: params.bucketName, - FileAssetsBucketKmsKeyId: params.kmsKeyId, + FileAssetsBucketKmsKeyId: kmsKeyId, // Empty array becomes empty string TrustedAccounts: trustedAccounts.join(','), CloudFormationExecutionPolicies: cloudFormationExecutionPolicies.join(','), @@ -124,7 +144,7 @@ export class Bootstrapper { if (version === 0) { return this.legacyBootstrap(environment, sdkProvider, options); } else { - return this.defaultBootstrap(environment, sdkProvider, options); + return this.modernBootstrap(environment, sdkProvider, options); } } @@ -139,3 +159,13 @@ export class Bootstrapper { } } } + +/** + * Magic parameter value that will cause the bootstrap-template.yml to NOT create a CMK but use the default keyo + */ +const USE_AWS_MANAGED_KEY = 'AWS_MANAGED_KEY'; + +/** + * Magic parameter value that will cause the bootstrap-template.yml to create a CMK + */ +const CREATE_NEW_KEY = ''; \ No newline at end of file diff --git a/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts b/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts index f1d00107d1421..010e5603f1400 100644 --- a/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts +++ b/packages/aws-cdk/lib/api/bootstrap/bootstrap-props.ts @@ -55,10 +55,20 @@ export interface BootstrappingParameters { /** * The ID of an existing KMS key to be used for encrypting items in the bucket. * - * @default - the default KMS key for S3 will be used. + * @default - use the default KMS key or create a custom one */ readonly kmsKeyId?: string; + /** + * Whether or not to create a new customer master key (CMK) + * + * Only applies to modern bootstrapping. Legacy bootstrapping will never create + * a CMK, only use the default S3 key. + * + * @default false + */ + readonly createCustomerMasterKey?: boolean; + /** * The list of AWS account IDs that are trusted to deploy into the environment being bootstrapped. * diff --git a/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml b/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml index feeee078030d6..0a5a19c0cb271 100644 --- a/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml +++ b/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml @@ -16,8 +16,8 @@ Parameters: Default: '' Type: String FileAssetsBucketKmsKeyId: - Description: Custom KMS key ID to use for encrypting file assets (by default a - KMS key will be automatically defined) + Description: Empty to create a new key (default), 'AWS_MANAGED_KEY' to use a managed + S3 key, or the ID/ARN of an existing key. Default: '' Type: String ContainerAssetsRepositoryName: @@ -61,6 +61,10 @@ Conditions: Fn::Equals: - '' - Ref: FileAssetsBucketKmsKeyId + UseAwsManagedKey: + Fn::Equals: + - 'AWS_MANAGED_KEY' + - Ref: FileAssetsBucketKmsKeyId HasCustomContainerAssetsRepositoryName: Fn::Not: - Fn::Equals: @@ -150,7 +154,10 @@ Resources: Fn::If: - CreateNewKey - Fn::Sub: "${FileAssetsBucketEncryptionKey.Arn}" - - Fn::Sub: "${FileAssetsBucketKmsKeyId}" + - Fn::If: + - UseAwsManagedKey + - Ref: AWS::NoValue + - Fn::Sub: "${FileAssetsBucketKmsKeyId}" PublicAccessBlockConfiguration: Fn::If: - UsePublicAccessBlockConfiguration diff --git a/packages/aws-cdk/lib/api/util/cloudformation.ts b/packages/aws-cdk/lib/api/util/cloudformation.ts index 54c8fe8055045..4c75b88c445c0 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation.ts @@ -377,7 +377,7 @@ export class StackParameters { this._changes = true; } - if (key in updates && updates[key]) { + if (key in updates && updates[key] !== undefined) { this.apiParameters.push({ ParameterKey: key, ParameterValue: updates[key] }); // If the updated value is different than the current value, this will lead to a change diff --git a/packages/aws-cdk/test/api/bootstrap.test.ts b/packages/aws-cdk/test/api/bootstrap.test.ts index bda570d3cbe05..47bf4adaa2f00 100644 --- a/packages/aws-cdk/test/api/bootstrap.test.ts +++ b/packages/aws-cdk/test/api/bootstrap.test.ts @@ -158,7 +158,7 @@ test('passing trusted accounts to the old bootstrapping results in an error', as }, })) .rejects - .toThrow('--trust can only be passed for the new bootstrap experience.'); + .toThrow('--trust can only be passed for the modern bootstrap experience.'); }); test('passing CFN execution policies to the old bootstrapping results in an error', async () => { @@ -169,7 +169,7 @@ test('passing CFN execution policies to the old bootstrapping results in an erro }, })) .rejects - .toThrow('--cloudformation-execution-policies can only be passed for the new bootstrap experience.'); + .toThrow('--cloudformation-execution-policies can only be passed for the modern bootstrap experience.'); }); test('even if the bootstrap stack is in a rollback state, can still retry bootstrapping it', async () => { diff --git a/packages/aws-cdk/test/api/bootstrap2.test.ts b/packages/aws-cdk/test/api/bootstrap2.test.ts index 99edb18f5d033..77ec30243fe1f 100644 --- a/packages/aws-cdk/test/api/bootstrap2.test.ts +++ b/packages/aws-cdk/test/api/bootstrap2.test.ts @@ -28,6 +28,10 @@ describe('Bootstrapping v2', () => { mockTheToolkitInfo = undefined; }); + afterEach(() => { + mockDeployStack.mockClear(); + }); + test('passes the bucket name as a CFN parameter', async () => { await bootstrapper.bootstrapEnvironment(env, sdk, { parameters: { @@ -137,73 +141,130 @@ describe('Bootstrapping v2', () => { ]); }); - test('stack is not termination protected by default', async () => { - await bootstrapper.bootstrapEnvironment(env, sdk, { - parameters: { - cloudFormationExecutionPolicies: ['arn:policy'], - }, + describe('termination protection', () => { + test('stack is not termination protected by default', async () => { + await bootstrapper.bootstrapEnvironment(env, sdk, { + parameters: { + cloudFormationExecutionPolicies: ['arn:policy'], + }, + }); + + expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ + stack: expect.objectContaining({ + terminationProtection: false, + }), + })); }); - expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ - stack: expect.objectContaining({ - terminationProtection: false, - }), - })); - }); - - test('stack is termination protected when option is set', async () => { - await bootstrapper.bootstrapEnvironment(env, sdk, { - terminationProtection: true, - parameters: { - cloudFormationExecutionPolicies: ['arn:policy'], - }, - }); - - expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ - stack: expect.objectContaining({ + test('stack is termination protected when option is set', async () => { + await bootstrapper.bootstrapEnvironment(env, sdk, { terminationProtection: true, - }), - })); - }); - - test('termination protection is left alone when option is not given', async () => { - mockTheToolkitInfo = mockToolkitInfo({ - EnableTerminationProtection: true, + parameters: { + cloudFormationExecutionPolicies: ['arn:policy'], + }, + }); + + expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ + stack: expect.objectContaining({ + terminationProtection: true, + }), + })); }); - await bootstrapper.bootstrapEnvironment(env, sdk, { - parameters: { - cloudFormationExecutionPolicies: ['arn:policy'], - }, + test('termination protection is left alone when option is not given', async () => { + mockTheToolkitInfo = mockToolkitInfo({ + EnableTerminationProtection: true, + }); + + await bootstrapper.bootstrapEnvironment(env, sdk, { + parameters: { + cloudFormationExecutionPolicies: ['arn:policy'], + }, + }); + + expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ + stack: expect.objectContaining({ + terminationProtection: true, + }), + })); }); - expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ - stack: expect.objectContaining({ - terminationProtection: true, - }), - })); - }); + test('termination protection can be switched off', async () => { + mockTheToolkitInfo = mockToolkitInfo({ + EnableTerminationProtection: true, + }); - test('termination protection can be switched off', async () => { - mockTheToolkitInfo = mockToolkitInfo({ - EnableTerminationProtection: true, + await bootstrapper.bootstrapEnvironment(env, sdk, { + terminationProtection: false, + parameters: { + cloudFormationExecutionPolicies: ['arn:policy'], + }, + }); + + expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ + stack: expect.objectContaining({ + terminationProtection: false, + }), + })); }); + }); - await bootstrapper.bootstrapEnvironment(env, sdk, { - terminationProtection: false, - parameters: { - cloudFormationExecutionPolicies: ['arn:policy'], - }, + describe('KMS key', () => { + test.each([ + // Default case + [undefined, 'AWS_MANAGED_KEY'], + // Create a new key + [true, ''], + // Don't create a new key + [false, 'AWS_MANAGED_KEY'], + ])('(new stack) createCustomerMasterKey=%p => parameter becomes %p ', async (createCustomerMasterKey, paramKeyId) => { + // GIVEN: no existing stack + + // WHEN + await bootstrapper.bootstrapEnvironment(env, sdk, { + parameters: { + createCustomerMasterKey, + cloudFormationExecutionPolicies: ['arn:booh'], + }, + }); + + // THEN + expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ + parameters: expect.objectContaining({ + FileAssetsBucketKmsKeyId: paramKeyId, + }), + })); }); - expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ - stack: expect.objectContaining({ - terminationProtection: false, - }), - })); - }); - - afterEach(() => { - mockDeployStack.mockClear(); + test.each([ + // Old bootstrap stack being upgraded to new one + [undefined, undefined, 'AWS_MANAGED_KEY'], + // There is a value, user doesn't request a change + ['arn:aws:key', undefined, undefined], + // Switch off existing key + ['arn:aws:key', false, 'AWS_MANAGED_KEY'], + // Switch on existing key + ['AWS_MANAGED_KEY', true, ''], + ])('(upgrading) current param %p, createCustomerMasterKey=%p => parameter becomes %p ', async (currentKeyId, createCustomerMasterKey, paramKeyId) => { + // GIVEN + mockTheToolkitInfo = mockToolkitInfo({ + Parameters: currentKeyId ? [{ ParameterKey: 'FileAssetsBucketKmsKeyId', ParameterValue: currentKeyId }] : undefined, + }); + + // WHEN + await bootstrapper.bootstrapEnvironment(env, sdk, { + parameters: { + createCustomerMasterKey, + cloudFormationExecutionPolicies: ['arn:booh'], + }, + }); + + // THEN + expect(mockDeployStack).toHaveBeenCalledWith(expect.objectContaining({ + parameters: expect.objectContaining({ + FileAssetsBucketKmsKeyId: paramKeyId, + }), + })); + }); }); }); \ No newline at end of file diff --git a/packages/aws-cdk/test/util/cloudformation.test.ts b/packages/aws-cdk/test/util/cloudformation.test.ts index ac27720d7747b..ca7b2afc570ca 100644 --- a/packages/aws-cdk/test/util/cloudformation.test.ts +++ b/packages/aws-cdk/test/util/cloudformation.test.ts @@ -86,6 +86,18 @@ test('if a parameter is retrieved from SSM, the parameters always count as chang expect(params.diff({ Foo: '/Some/Key' }, { Foo: '/Some/Key' }).changed).toEqual(true); }); +test('empty string is a valid update value', () => { + const params = TemplateParameters.fromTemplate({ + Parameters: { + Foo: { Type: 'String', Default: 'Foo' }, + }, + }); + + expect(params.diff({ Foo: '' }, { Foo: 'ThisIsOld' }).apiParameters).toEqual([ + { ParameterKey: 'Foo', ParameterValue: '' }, + ]); +}); + test('unknown parameter in overrides, pass it anyway', () => { // Not sure if we really want this. It seems like it would be nice // to not pass parameters that aren't expected, given that CFN will