From 7cb8e5e61debd5b1f06293051bbb522f0331fb3c Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Mon, 20 May 2019 17:08:39 +0200 Subject: [PATCH] feat(events): group CW Event Targets in module (#2576) Move new event targets into `@aws-cdk/aws-events-targets` - CodePipeline - EC2 task - StepFunctions StateMachine Invocation payloads are now under the control of the target classes, so that targets for which the payload maps onto API calls (such as ECS, CodeBuild, etc) can specify the API parameters directly as props. `EventTargetInput` is a union class with three variants, allowing serialization of strings, multiline strings and objects. Target inputs support a special type of Token (`EventField`) which can be used to refer to fields from the event, instead of value literals. A number of predefined events and the fields that they have available have been defined, so that accessing them becomes even more convenient (`StageChangeEvent`, `PhaseChangeEvent`, `ReferenceEvent`). To be able to use Tokens to implement EventInput substitution, add a refactoring and more principaled separation of concerns in the Token code. Resolution can now be more easily hooked, CloudFormation-aware string concatenation is implemented using a plugin, and Token callbacks receive an `IResolveContext` which they can use to resolve deeper (and will reuse the same settings that the resolver was started with). We should as good as be able to get rid of `stack.node.resolve()` in the near future. ALSO: - Simplified `LatestDeploymentResource` to use existing logical ID overriding features. - Add an `onEvent()` method to `CloudTrail`. - Fix API misuse in the rendering of a CodePipeline, where Token rendering would lead to stateful side effects. Make Actions render out their region directly, if set, without requiring overrides. - In ECS task `assignPublicIp` now defaults to `undefined`, instead of `DISABLED`, to align with service API. - Fix a bug in Token resolution where Tokens returning fresh `CfnReference` objects upon resolution would fail to be detected during cross-stack reference analysis; `CfnReference` now has static methods that return singleton objects. - Get rid of an extra "resolve" call in `CfnResource.toCloudFormation()`. We can now use `PostProcessToken` to apply the property renames and output validation we were originally doing the resolve for. Fixes #2403, fixes #2404, fixes #2581. BREAKING CHANGES * `@aws-cdk/aws-codepipeline.Pipeline` is no longer an event target itself, use `@aws-cdk/aws-events-targets.CodePipeline` instead. * `@aws-cdk/aws-stepfunctions.StateMachine` is no longer an event target itself, use `@aws-cdk/aws-events-targets.SfnStateMachine` instead. * `@aws-cdk/aws-ecs.Ec2RunTask` has been renamed to `@aws-cdk/aws-events-targets.EcsEc2Task`. * `CloudFormationJSON.stringify()` is now renamed to `CloudFormationLang.toJSON()`. --- .../@aws-cdk/aws-apigateway/lib/deployment.ts | 48 +-- packages/@aws-cdk/aws-cloudtrail/lib/index.ts | 16 + packages/@aws-cdk/aws-cloudtrail/package.json | 2 + .../aws-cloudtrail/test/test.cloudtrail.ts | 34 +- packages/@aws-cdk/aws-codebuild/lib/events.ts | 88 ++++++ packages/@aws-cdk/aws-codebuild/lib/index.ts | 1 + .../@aws-cdk/aws-codebuild/lib/project.ts | 39 ++- .../@aws-cdk/aws-codecommit/lib/events.ts | 66 ++++ packages/@aws-cdk/aws-codecommit/lib/index.ts | 1 + .../@aws-cdk/aws-codecommit/lib/repository.ts | 38 +-- .../test/integ.codecommit-events.ts | 2 +- .../lib/codecommit/source-action.ts | 3 +- .../lib/ecr/source-action.ts | 3 +- .../lib/s3/source-action.ts | 3 +- .../cloudformation/test.pipeline-actions.ts | 8 +- ...g.cfn-template-from-repo.lit.expected.json | 22 +- ...yed-through-codepipeline.lit.expected.json | 20 +- .../test/integ.lambda-pipeline.expected.json | 22 +- ...uild-multiple-inputs-outputs.expected.json | 2 +- .../integ.pipeline-code-commit.expected.json | 22 +- .../integ.pipeline-ecr-source.expected.json | 2 +- .../test/integ.pipeline-events.expected.json | 28 +- .../test/integ.pipeline-events.ts | 13 +- .../@aws-cdk/aws-codepipeline/lib/action.ts | 10 +- .../@aws-cdk/aws-codepipeline/lib/pipeline.ts | 111 +++---- .../@aws-cdk/aws-codepipeline/lib/stage.ts | 7 +- packages/@aws-cdk/aws-ecr/lib/repository.ts | 10 +- packages/@aws-cdk/aws-ecs/README.md | 37 ++- .../aws-ecs/lib/ec2/ec2-event-rule-target.ts | 102 ------ packages/@aws-cdk/aws-ecs/lib/index.ts | 1 - packages/@aws-cdk/aws-ecs/package.json | 2 - .../test/ec2/test.ec2-event-rule-target.ts | 61 ---- .../@aws-cdk/aws-events-targets/.gitignore | 3 +- .../aws-events-targets/lib/codebuild.ts | 35 +-- .../aws-events-targets/lib/codepipeline.ts | 22 ++ .../aws-events-targets/lib/ecs-ec2-task.ts | 125 ++++++++ .../lib/ecs-task-properties.ts | 58 ++++ .../@aws-cdk/aws-events-targets/lib/index.ts | 4 + .../@aws-cdk/aws-events-targets/lib/lambda.ts | 27 +- .../@aws-cdk/aws-events-targets/lib/sns.ts | 20 +- .../aws-events-targets/lib/state-machine.ts | 41 +++ .../@aws-cdk/aws-events-targets/lib/util.ts | 22 ++ .../@aws-cdk/aws-events-targets/package.json | 4 +- .../test/codebuild/codebuild.test.ts | 4 +- .../integ.project-events.expected.json | 120 +++---- .../test/codebuild/integ.project-events.ts | 22 +- .../test/codepipeline/pipeline.test.ts | 81 +++++ .../test/ecs/ec2-event-rule-target.test.ts | 57 ++++ .../test/ecs}/eventhandler-image/Dockerfile | 0 .../test/ecs}/eventhandler-image/index.py | 0 .../ecs}/integ.event-task.lit.expected.json | 16 +- .../test/ecs}/integ.event-task.lit.ts | 33 +- .../test/lambda/integ.events.ts | 4 +- .../test/lambda/lambda.test.ts | 4 +- .../test/sns/integ.sns-event-rule-target.ts | 2 +- .../aws-events-targets/test/sns/sns.test.ts | 4 +- .../test/stepfunctions/statemachine.test.ts | 30 ++ packages/@aws-cdk/aws-events/README.md | 6 +- packages/@aws-cdk/aws-events/lib/index.ts | 2 +- .../@aws-cdk/aws-events/lib/input-options.ts | 52 --- packages/@aws-cdk/aws-events/lib/input.ts | 297 ++++++++++++++++++ packages/@aws-cdk/aws-events/lib/rule-ref.ts | 2 +- packages/@aws-cdk/aws-events/lib/rule.ts | 88 +++--- packages/@aws-cdk/aws-events/lib/target.ts | 43 ++- packages/@aws-cdk/aws-events/package.json | 6 + .../@aws-cdk/aws-events/test/test.input.ts | 110 +++++++ .../@aws-cdk/aws-events/test/test.rule.ts | 289 ++++++----------- .../@aws-cdk/aws-iam/lib/policy-document.ts | 10 +- packages/@aws-cdk/aws-s3/lib/bucket.ts | 8 +- packages/@aws-cdk/aws-s3/test/test.bucket.ts | 13 + .../lib/run-ecs-ec2-task.ts | 2 +- .../lib/run-ecs-task-base.ts | 2 +- .../test/ecs-tasks.test.ts | 1 - .../aws-stepfunctions/lib/state-machine.ts | 29 +- .../test/test.state-machine-resources.ts | 31 +- packages/@aws-cdk/cdk/lib/cfn-concat.ts | 22 -- packages/@aws-cdk/cdk/lib/cfn-condition.ts | 6 +- packages/@aws-cdk/cdk/lib/cfn-element.ts | 11 +- packages/@aws-cdk/cdk/lib/cfn-reference.ts | 60 +++- packages/@aws-cdk/cdk/lib/cfn-resource.ts | 19 +- .../@aws-cdk/cdk/lib/cloudformation-json.ts | 107 ------- .../@aws-cdk/cdk/lib/cloudformation-lang.ts | 137 ++++++++ packages/@aws-cdk/cdk/lib/construct.ts | 9 +- packages/@aws-cdk/cdk/lib/encoding.ts | 139 ++------ packages/@aws-cdk/cdk/lib/fn.ts | 9 +- packages/@aws-cdk/cdk/lib/index.ts | 4 +- packages/@aws-cdk/cdk/lib/options.ts | 56 ---- packages/@aws-cdk/cdk/lib/pseudo.ts | 22 +- packages/@aws-cdk/cdk/lib/resolve.ts | 230 ++++++++++---- packages/@aws-cdk/cdk/lib/string-fragments.ts | 124 ++++++++ packages/@aws-cdk/cdk/lib/token-map.ts | 18 +- packages/@aws-cdk/cdk/lib/token.ts | 30 +- packages/@aws-cdk/cdk/lib/util.ts | 4 +- .../cdk/test/test.cloudformation-json.ts | 60 ++-- packages/@aws-cdk/cdk/test/test.stack.ts | 30 ++ packages/@aws-cdk/cdk/test/test.tokens.ts | 48 ++- tools/cdk-integ-tools/bin/cdk-integ-assert.ts | 2 +- tools/cfn2ts/lib/codegen.ts | 2 +- 98 files changed, 2308 insertions(+), 1394 deletions(-) create mode 100644 packages/@aws-cdk/aws-codebuild/lib/events.ts create mode 100644 packages/@aws-cdk/aws-codecommit/lib/events.ts delete mode 100644 packages/@aws-cdk/aws-ecs/lib/ec2/ec2-event-rule-target.ts delete mode 100644 packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-event-rule-target.ts create mode 100644 packages/@aws-cdk/aws-events-targets/lib/codepipeline.ts create mode 100644 packages/@aws-cdk/aws-events-targets/lib/ecs-ec2-task.ts create mode 100644 packages/@aws-cdk/aws-events-targets/lib/ecs-task-properties.ts create mode 100644 packages/@aws-cdk/aws-events-targets/lib/state-machine.ts create mode 100644 packages/@aws-cdk/aws-events-targets/lib/util.ts create mode 100644 packages/@aws-cdk/aws-events-targets/test/codepipeline/pipeline.test.ts create mode 100644 packages/@aws-cdk/aws-events-targets/test/ecs/ec2-event-rule-target.test.ts rename packages/@aws-cdk/{aws-ecs/test => aws-events-targets/test/ecs}/eventhandler-image/Dockerfile (100%) rename packages/@aws-cdk/{aws-ecs/test => aws-events-targets/test/ecs}/eventhandler-image/index.py (100%) rename packages/@aws-cdk/{aws-ecs/test/ec2 => aws-events-targets/test/ecs}/integ.event-task.lit.expected.json (98%) rename packages/@aws-cdk/{aws-ecs/test/ec2 => aws-events-targets/test/ecs}/integ.event-task.lit.ts (59%) create mode 100644 packages/@aws-cdk/aws-events-targets/test/stepfunctions/statemachine.test.ts delete mode 100644 packages/@aws-cdk/aws-events/lib/input-options.ts create mode 100644 packages/@aws-cdk/aws-events/lib/input.ts create mode 100644 packages/@aws-cdk/aws-events/test/test.input.ts delete mode 100644 packages/@aws-cdk/cdk/lib/cfn-concat.ts delete mode 100644 packages/@aws-cdk/cdk/lib/cloudformation-json.ts create mode 100644 packages/@aws-cdk/cdk/lib/cloudformation-lang.ts delete mode 100644 packages/@aws-cdk/cdk/lib/options.ts create mode 100644 packages/@aws-cdk/cdk/lib/string-fragments.ts diff --git a/packages/@aws-cdk/aws-apigateway/lib/deployment.ts b/packages/@aws-cdk/aws-apigateway/lib/deployment.ts index 65bc57d2dc6e5..7db0158d2b499 100644 --- a/packages/@aws-cdk/aws-apigateway/lib/deployment.ts +++ b/packages/@aws-cdk/aws-apigateway/lib/deployment.ts @@ -91,51 +91,13 @@ export class Deployment extends Resource { } class LatestDeploymentResource extends CfnDeployment { - private originalLogicalId?: string; - private lazyLogicalIdRequired: boolean; - private lazyLogicalId?: string; - private logicalIdToken: Token; private hashComponents = new Array(); + private originalLogicalId: string; constructor(scope: Construct, id: string, props: CfnDeploymentProps) { super(scope, id, props); - // from this point, don't allow accessing logical ID before synthesis - this.lazyLogicalIdRequired = true; - - this.logicalIdToken = new Token(() => this.lazyLogicalId); - } - - /** - * Returns either the original or the custom logical ID of this resource. - */ - public get logicalId() { - if (!this.lazyLogicalIdRequired) { - return this.originalLogicalId!; - } - - return this.logicalIdToken.toString(); - } - - /** - * Sets the logical ID of this resource. - */ - public set logicalId(v: string) { - this.originalLogicalId = v; - } - - /** - * Returns a lazy reference to this resource (evaluated only upon synthesis). - */ - public get ref() { - return new Token(() => ({ Ref: this.lazyLogicalId })).toString(); - } - - /** - * Does nothing. - */ - public set ref(_v: string) { - return; + this.originalLogicalId = this.node.stack.logicalIds.getLogicalId(this); } /** @@ -159,15 +121,13 @@ class LatestDeploymentResource extends CfnDeployment { protected prepare() { // if hash components were added to the deployment, we use them to calculate // a logical ID for the deployment resource. - if (this.hashComponents.length === 0) { - this.lazyLogicalId = this.originalLogicalId; - } else { + if (this.hashComponents.length > 0) { const md5 = crypto.createHash('md5'); this.hashComponents .map(c => this.node.resolve(c)) .forEach(c => md5.update(JSON.stringify(c))); - this.lazyLogicalId = this.originalLogicalId + md5.digest("hex"); + this.overrideLogicalId(this.originalLogicalId + md5.digest("hex")); } super.prepare(); diff --git a/packages/@aws-cdk/aws-cloudtrail/lib/index.ts b/packages/@aws-cdk/aws-cloudtrail/lib/index.ts index d4815860a38af..8eee3364e6ebd 100644 --- a/packages/@aws-cdk/aws-cloudtrail/lib/index.ts +++ b/packages/@aws-cdk/aws-cloudtrail/lib/index.ts @@ -1,3 +1,4 @@ +import events = require('@aws-cdk/aws-events'); import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); import logs = require('@aws-cdk/aws-logs'); @@ -209,6 +210,21 @@ export class Trail extends Resource { }] }); } + + /** + * Create an event rule for when an event is recorded by any trail. + * + * Note that the event doesn't necessarily have to come from this + * trail. Be sure to filter the event properly using an event pattern. + */ + public onEvent(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { + const rule = new events.Rule(this, name, options); + rule.addTarget(target); + rule.addEventPattern({ + detailType: ['AWS API Call via CloudTrail'] + }); + return rule; + } } /** diff --git a/packages/@aws-cdk/aws-cloudtrail/package.json b/packages/@aws-cdk/aws-cloudtrail/package.json index a69debeb4a5a7..9600940726213 100644 --- a/packages/@aws-cdk/aws-cloudtrail/package.json +++ b/packages/@aws-cdk/aws-cloudtrail/package.json @@ -67,6 +67,7 @@ "pkglint": "^0.31.0" }, "dependencies": { + "@aws-cdk/aws-events": "^0.31.0", "@aws-cdk/aws-iam": "^0.31.0", "@aws-cdk/aws-kms": "^0.31.0", "@aws-cdk/aws-logs": "^0.31.0", @@ -75,6 +76,7 @@ }, "homepage": "https://github.com/awslabs/aws-cdk", "peerDependencies": { + "@aws-cdk/aws-events": "^0.31.0", "@aws-cdk/aws-iam": "^0.31.0", "@aws-cdk/aws-kms": "^0.31.0", "@aws-cdk/aws-logs": "^0.31.0", diff --git a/packages/@aws-cdk/aws-cloudtrail/test/test.cloudtrail.ts b/packages/@aws-cdk/aws-cloudtrail/test/test.cloudtrail.ts index 9442c96925cda..facae5fe9003b 100644 --- a/packages/@aws-cdk/aws-cloudtrail/test/test.cloudtrail.ts +++ b/packages/@aws-cdk/aws-cloudtrail/test/test.cloudtrail.ts @@ -189,7 +189,39 @@ export = { test.done(); }, } - } + }, + + 'add an event rule'(test: Test) { + // GIVEN + const stack = getTestStack(); + const trail = new Trail(stack, 'MyAmazingCloudTrail', { managementEvents: ReadWriteType.WriteOnly }); + + // WHEN + trail.onEvent('DoEvents', { + bind: () => ({ + arn: 'arn', + id: 'myid' + }) + }); + + // THEN + expect(stack).to(haveResource('AWS::Events::Rule', { + EventPattern: { + "detail-type": [ + "AWS API Call via CloudTrail" + ] + }, + State: "ENABLED", + Targets: [ + { + Arn: "arn", + Id: "myid" + } + ] + })); + + test.done(); + }, }; function getTestStack(): Stack { diff --git a/packages/@aws-cdk/aws-codebuild/lib/events.ts b/packages/@aws-cdk/aws-codebuild/lib/events.ts new file mode 100644 index 0000000000000..8f781b0462c53 --- /dev/null +++ b/packages/@aws-cdk/aws-codebuild/lib/events.ts @@ -0,0 +1,88 @@ +import events = require('@aws-cdk/aws-events'); + +/** + * Event fields for the CodeBuild "state change" event + * + * @see https://docs.aws.amazon.com/codebuild/latest/userguide/sample-build-notifications.html#sample-build-notifications-ref + */ +export class StateChangeEvent { + /** + * The triggering build's status + */ + public static get buildStatus() { + return events.EventField.fromPath('$.detail.build-status'); + } + + /** + * The triggering build's project name + */ + public static get projectName() { + return events.EventField.fromPath('$.detail.project-name'); + } + + /** + * Return the build id + */ + public static get buildId() { + return events.EventField.fromPath('$.detail.build-id'); + } + + public static get currentPhase() { + return events.EventField.fromPath('$.detail.current-phase'); + } + + private constructor() { + } +} + +/** + * Event fields for the CodeBuild "phase change" event + * + * @see https://docs.aws.amazon.com/codebuild/latest/userguide/sample-build-notifications.html#sample-build-notifications-ref + */ +export class PhaseChangeEvent { + /** + * The triggering build's project name + */ + public static get projectName() { + return events.EventField.fromPath('$.detail.project-name'); + } + + /** + * The triggering build's id + */ + public static get buildId() { + return events.EventField.fromPath('$.detail.build-id'); + } + + /** + * The phase that was just completed + */ + public static get completedPhase() { + return events.EventField.fromPath('$.detail.completed-phase'); + } + + /** + * The status of the completed phase + */ + public static get completedPhaseStatus() { + return events.EventField.fromPath('$.detail.completed-phase-status'); + } + + /** + * The duration of the completed phase + */ + public static get completedPhaseDurationSeconds() { + return events.EventField.fromPath('$.detail.completed-phase-duration-seconds'); + } + + /** + * Whether the build is complete + */ + public static get buildComplete() { + return events.EventField.fromPath('$.detail.build-complete'); + } + + private constructor() { + } +} diff --git a/packages/@aws-cdk/aws-codebuild/lib/index.ts b/packages/@aws-cdk/aws-codebuild/lib/index.ts index 2cb640b7cc9a0..a2394316f82fa 100644 --- a/packages/@aws-cdk/aws-codebuild/lib/index.ts +++ b/packages/@aws-cdk/aws-codebuild/lib/index.ts @@ -1,3 +1,4 @@ +export * from './events'; export * from './pipeline-project'; export * from './project'; export * from './source'; diff --git a/packages/@aws-cdk/aws-codebuild/lib/project.ts b/packages/@aws-cdk/aws-codebuild/lib/project.ts index b75582ec38708..47f2b451b97f0 100644 --- a/packages/@aws-cdk/aws-codebuild/lib/project.ts +++ b/packages/@aws-cdk/aws-codebuild/lib/project.ts @@ -52,9 +52,12 @@ export interface IProject extends IResource, iam.IGrantable { * You can also use the methods `onBuildFailed` and `onBuildSucceeded` to define rules for * these specific state changes. * + * To access fields from the event in the event target input, + * use the static fields on the `StateChangeEvent` class. + * * @see https://docs.aws.amazon.com/codebuild/latest/userguide/sample-build-notifications.html */ - onStateChange(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onStateChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * Defines a CloudWatch event rule that triggers upon phase change of this @@ -62,22 +65,22 @@ export interface IProject extends IResource, iam.IGrantable { * * @see https://docs.aws.amazon.com/codebuild/latest/userguide/sample-build-notifications.html */ - onPhaseChange(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onPhaseChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * Defines an event rule which triggers when a build starts. */ - onBuildStarted(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onBuildStarted(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * Defines an event rule which triggers when a build fails. */ - onBuildFailed(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onBuildFailed(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * Defines an event rule which triggers when a build completes successfully. */ - onBuildSucceeded(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onBuildSucceeded(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * @returns a CloudWatch metric associated with this build project. @@ -174,10 +177,13 @@ abstract class ProjectBase extends Resource implements IProject { * You can also use the methods `onBuildFailed` and `onBuildSucceeded` to define rules for * these specific state changes. * + * To access fields from the event in the event target input, + * use the static fields on the `StateChangeEvent` class. + * * @see https://docs.aws.amazon.com/codebuild/latest/userguide/sample-build-notifications.html */ - public onStateChange(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { - const rule = new events.EventRule(this, name, options); + public onStateChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { + const rule = new events.Rule(this, name, options); rule.addTarget(target); rule.addEventPattern({ source: ['aws.codebuild'], @@ -197,8 +203,8 @@ abstract class ProjectBase extends Resource implements IProject { * * @see https://docs.aws.amazon.com/codebuild/latest/userguide/sample-build-notifications.html */ - public onPhaseChange(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { - const rule = new events.EventRule(this, name, options); + public onPhaseChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { + const rule = new events.Rule(this, name, options); rule.addTarget(target); rule.addEventPattern({ source: ['aws.codebuild'], @@ -214,8 +220,11 @@ abstract class ProjectBase extends Resource implements IProject { /** * Defines an event rule which triggers when a build starts. + * + * To access fields from the event in the event target input, + * use the static fields on the `StateChangeEvent` class. */ - public onBuildStarted(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { + public onBuildStarted(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { const rule = this.onStateChange(name, target, options); rule.addEventPattern({ detail: { @@ -227,8 +236,11 @@ abstract class ProjectBase extends Resource implements IProject { /** * Defines an event rule which triggers when a build fails. + * + * To access fields from the event in the event target input, + * use the static fields on the `StateChangeEvent` class. */ - public onBuildFailed(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { + public onBuildFailed(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { const rule = this.onStateChange(name, target, options); rule.addEventPattern({ detail: { @@ -240,8 +252,11 @@ abstract class ProjectBase extends Resource implements IProject { /** * Defines an event rule which triggers when a build completes successfully. + * + * To access fields from the event in the event target input, + * use the static fields on the `StateChangeEvent` class. */ - public onBuildSucceeded(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { + public onBuildSucceeded(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { const rule = this.onStateChange(name, target, options); rule.addEventPattern({ detail: { diff --git a/packages/@aws-cdk/aws-codecommit/lib/events.ts b/packages/@aws-cdk/aws-codecommit/lib/events.ts new file mode 100644 index 0000000000000..e26842397591b --- /dev/null +++ b/packages/@aws-cdk/aws-codecommit/lib/events.ts @@ -0,0 +1,66 @@ +import events = require('@aws-cdk/aws-events'); + +/** + * Fields of CloudWatch Events that change references + * + * @see https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/EventTypes.html#codebuild_event_type + */ +export class ReferenceEvent { + /** + * The type of reference event + * + * 'referenceCreated', 'referenceUpdated' or 'referenceDeleted' + */ + public static get eventType() { + return events.EventField.fromPath('$.detail.event'); + } + + /** + * Name of the CodeCommit repository + */ + public static get repositoryName() { + return events.EventField.fromPath('$.detail.repositoryName'); + } + + /** + * Id of the CodeCommit repository + */ + public static get repositoryId() { + return events.EventField.fromPath('$.detail.repositoryId'); + } + + /** + * Type of reference changed + * + * 'branch' or 'tag' + */ + public static get referenceType() { + return events.EventField.fromPath('$.detail.referenceType'); + } + + /** + * Name of reference changed (branch or tag name) + */ + public static get referenceName() { + return events.EventField.fromPath('$.detail.referenceName'); + } + + /** + * Full reference name + * + * For example, 'refs/tags/myTag' + */ + public static get referenceFullName() { + return events.EventField.fromPath('$.detail.referenceFullName'); + } + + /** + * Commit id this reference now points to + */ + public static get commitId() { + return events.EventField.fromPath('$.detail.commitId'); + } + + private constructor() { + } +} diff --git a/packages/@aws-cdk/aws-codecommit/lib/index.ts b/packages/@aws-cdk/aws-codecommit/lib/index.ts index 2fa63e2e6ef94..05aa730eb214d 100644 --- a/packages/@aws-cdk/aws-codecommit/lib/index.ts +++ b/packages/@aws-cdk/aws-codecommit/lib/index.ts @@ -1,3 +1,4 @@ +export * from './events'; export * from './repository'; // AWS::CodeCommit CloudFormation Resources: diff --git a/packages/@aws-cdk/aws-codecommit/lib/repository.ts b/packages/@aws-cdk/aws-codecommit/lib/repository.ts index 3578293c72b20..efa5a0afc3910 100644 --- a/packages/@aws-cdk/aws-codecommit/lib/repository.ts +++ b/packages/@aws-cdk/aws-codecommit/lib/repository.ts @@ -31,53 +31,53 @@ export interface IRepository extends IResource { * Defines a CloudWatch event rule which triggers for repository events. Use * `rule.addEventPattern(pattern)` to specify a filter. */ - onEvent(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onEvent(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * Defines a CloudWatch event rule which triggers when a "CodeCommit * Repository State Change" event occurs. */ - onStateChange(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onStateChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * Defines a CloudWatch event rule which triggers when a reference is * created (i.e. a new branch/tag is created) to the repository. */ - onReferenceCreated(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onReferenceCreated(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * Defines a CloudWatch event rule which triggers when a reference is * updated (i.e. a commit is pushed to an existing or new branch) from the repository. */ - onReferenceUpdated(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onReferenceUpdated(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * Defines a CloudWatch event rule which triggers when a reference is * delete (i.e. a branch/tag is deleted) from the repository. */ - onReferenceDeleted(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onReferenceDeleted(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * Defines a CloudWatch event rule which triggers when a pull request state is changed. */ - onPullRequestStateChange(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onPullRequestStateChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * Defines a CloudWatch event rule which triggers when a comment is made on a pull request. */ - onCommentOnPullRequest(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onCommentOnPullRequest(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * Defines a CloudWatch event rule which triggers when a comment is made on a commit. */ - onCommentOnCommit(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onCommentOnCommit(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; /** * Defines a CloudWatch event rule which triggers when a commit is pushed to a branch. * @param target The target of the event * @param branch The branch to monitor. Defaults to all branches. */ - onCommit(name: string, target?: events.IEventRuleTarget, branch?: string): events.EventRule; + onCommit(name: string, target?: events.IRuleTarget, branch?: string): events.Rule; } /** @@ -106,8 +106,8 @@ abstract class RepositoryBase extends Resource implements IRepository { * Defines a CloudWatch event rule which triggers for repository events. Use * `rule.addEventPattern(pattern)` to specify a filter. */ - public onEvent(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { - const rule = new events.EventRule(this, name, options); + public onEvent(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { + const rule = new events.Rule(this, name, options); rule.addEventPattern({ source: [ 'aws.codecommit' ], resources: [ this.repositoryArn ] @@ -120,7 +120,7 @@ abstract class RepositoryBase extends Resource implements IRepository { * Defines a CloudWatch event rule which triggers when a "CodeCommit * Repository State Change" event occurs. */ - public onStateChange(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { + public onStateChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { const rule = this.onEvent(name, target, options); rule.addEventPattern({ detailType: [ 'CodeCommit Repository State Change' ], @@ -132,7 +132,7 @@ abstract class RepositoryBase extends Resource implements IRepository { * Defines a CloudWatch event rule which triggers when a reference is * created (i.e. a new branch/tag is created) to the repository. */ - public onReferenceCreated(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { + public onReferenceCreated(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { const rule = this.onStateChange(name, target, options); rule.addEventPattern({ detail: { event: [ 'referenceCreated' ] } }); return rule; @@ -142,7 +142,7 @@ abstract class RepositoryBase extends Resource implements IRepository { * Defines a CloudWatch event rule which triggers when a reference is * updated (i.e. a commit is pushed to an existing or new branch) from the repository. */ - public onReferenceUpdated(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { + public onReferenceUpdated(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { const rule = this.onStateChange(name, target, options); rule.addEventPattern({ detail: { event: [ 'referenceCreated', 'referenceUpdated' ] } }); return rule; @@ -152,7 +152,7 @@ abstract class RepositoryBase extends Resource implements IRepository { * Defines a CloudWatch event rule which triggers when a reference is * delete (i.e. a branch/tag is deleted) from the repository. */ - public onReferenceDeleted(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { + public onReferenceDeleted(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { const rule = this.onStateChange(name, target, options); rule.addEventPattern({ detail: { event: [ 'referenceDeleted' ] } }); return rule; @@ -161,7 +161,7 @@ abstract class RepositoryBase extends Resource implements IRepository { /** * Defines a CloudWatch event rule which triggers when a pull request state is changed. */ - public onPullRequestStateChange(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { + public onPullRequestStateChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { const rule = this.onEvent(name, target, options); rule.addEventPattern({ detailType: [ 'CodeCommit Pull Request State Change' ] }); return rule; @@ -170,7 +170,7 @@ abstract class RepositoryBase extends Resource implements IRepository { /** * Defines a CloudWatch event rule which triggers when a comment is made on a pull request. */ - public onCommentOnPullRequest(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { + public onCommentOnPullRequest(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { const rule = this.onEvent(name, target, options); rule.addEventPattern({ detailType: [ 'CodeCommit Comment on Pull Request' ] }); return rule; @@ -179,7 +179,7 @@ abstract class RepositoryBase extends Resource implements IRepository { /** * Defines a CloudWatch event rule which triggers when a comment is made on a commit. */ - public onCommentOnCommit(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { + public onCommentOnCommit(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { const rule = this.onEvent(name, target, options); rule.addEventPattern({ detailType: [ 'CodeCommit Comment on Commit' ] }); return rule; @@ -190,7 +190,7 @@ abstract class RepositoryBase extends Resource implements IRepository { * @param target The target of the event * @param branch The branch to monitor. Defaults to all branches. */ - public onCommit(name: string, target?: events.IEventRuleTarget, branch?: string) { + public onCommit(name: string, target?: events.IRuleTarget, branch?: string) { const rule = this.onReferenceUpdated(name, target); if (branch) { rule.addEventPattern({ detail: { referenceName: [ branch ] }}); diff --git a/packages/@aws-cdk/aws-codecommit/test/integ.codecommit-events.ts b/packages/@aws-cdk/aws-codecommit/test/integ.codecommit-events.ts index c5c6666cdae9b..b674b18656260 100644 --- a/packages/@aws-cdk/aws-codecommit/test/integ.codecommit-events.ts +++ b/packages/@aws-cdk/aws-codecommit/test/integ.codecommit-events.ts @@ -11,7 +11,7 @@ const topic = new sns.Topic(stack, 'MyTopic'); // we can't use @aws-cdk/aws-events-targets.SnsTopic here because it will // create a cyclic dependency with codebuild, so we just fake it repo.onReferenceCreated('OnReferenceCreated', { - asEventRuleTarget: () => ({ + bind: () => ({ arn: topic.topicArn, id: 'MyTopic' }) diff --git a/packages/@aws-cdk/aws-codepipeline-actions/lib/codecommit/source-action.ts b/packages/@aws-cdk/aws-codepipeline-actions/lib/codecommit/source-action.ts index ed52a1ed06b03..741a1478446ce 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/lib/codecommit/source-action.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/lib/codecommit/source-action.ts @@ -1,5 +1,6 @@ import codecommit = require('@aws-cdk/aws-codecommit'); import codepipeline = require('@aws-cdk/aws-codepipeline'); +import targets = require('@aws-cdk/aws-events-targets'); import iam = require('@aws-cdk/aws-iam'); import { sourceArtifactBounds } from '../common'; @@ -57,7 +58,7 @@ export class CodeCommitSourceAction extends codepipeline.Action { protected bind(info: codepipeline.ActionBind): void { if (!this.props.pollForSourceChanges) { this.props.repository.onCommit(info.pipeline.node.uniqueId + 'EventRule', - info.pipeline, this.props.branch || 'master'); + new targets.CodePipeline(info.pipeline), this.props.branch || 'master'); } // https://docs.aws.amazon.com/codecommit/latest/userguide/auth-and-access-control-permissions-reference.html#aa-acp diff --git a/packages/@aws-cdk/aws-codepipeline-actions/lib/ecr/source-action.ts b/packages/@aws-cdk/aws-codepipeline-actions/lib/ecr/source-action.ts index cb721bd20884c..e86c3bed59739 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/lib/ecr/source-action.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/lib/ecr/source-action.ts @@ -1,5 +1,6 @@ import codepipeline = require('@aws-cdk/aws-codepipeline'); import ecr = require('@aws-cdk/aws-ecr'); +import targets = require('@aws-cdk/aws-events-targets'); import iam = require('@aws-cdk/aws-iam'); import { sourceArtifactBounds } from '../common'; @@ -55,6 +56,6 @@ export class EcrSourceAction extends codepipeline.Action { .addResource(this.props.repository.repositoryArn)); this.props.repository.onImagePushed(info.pipeline.node.uniqueId + 'SourceEventRule', - info.pipeline, this.props.imageTag); + new targets.CodePipeline(info.pipeline), this.props.imageTag); } } diff --git a/packages/@aws-cdk/aws-codepipeline-actions/lib/s3/source-action.ts b/packages/@aws-cdk/aws-codepipeline-actions/lib/s3/source-action.ts index 16d8aa696b007..88f003dd29732 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/lib/s3/source-action.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/lib/s3/source-action.ts @@ -1,4 +1,5 @@ import codepipeline = require('@aws-cdk/aws-codepipeline'); +import targets = require('@aws-cdk/aws-events-targets'); import s3 = require('@aws-cdk/aws-s3'); import { sourceArtifactBounds } from '../common'; @@ -61,7 +62,7 @@ export class S3SourceAction extends codepipeline.Action { protected bind(info: codepipeline.ActionBind): void { if (this.props.pollForSourceChanges === false) { this.props.bucket.onPutObject(info.pipeline.node.uniqueId + 'SourceEventRule', - info.pipeline, this.props.bucketKey); + new targets.CodePipeline(info.pipeline), this.props.bucketKey); } // pipeline needs permissions to read from the S3 bucket diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.pipeline-actions.ts b/packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.pipeline-actions.ts index 8a4596d6a2e49..656f96b26de24 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.pipeline-actions.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/cloudformation/test.pipeline-actions.ts @@ -313,8 +313,8 @@ class PipelineDouble extends cdk.Construct implements codepipeline.IPipeline { this.role = role; } - public asEventRuleTarget(_ruleArn: string, _ruleUniqueId: string): events.EventRuleTargetProps { - throw new Error('asEventRuleTarget() is unsupported in PipelineDouble'); + public bind(_rule: events.IRule): events.RuleTargetProperties { + throw new Error('asRuleTarget() is unsupported in PipelineDouble'); } public grantBucketRead(_identity?: iam.IGrantable): iam.Grant { @@ -356,8 +356,8 @@ class StageDouble implements codepipeline.IStage { throw new Error('addAction() is not supported on StageDouble'); } - public onStateChange(_name: string, _target?: events.IEventRuleTarget, _options?: events.EventRuleProps): - events.EventRule { + public onStateChange(_name: string, _target?: events.IRuleTarget, _options?: events.RuleProps): + events.Rule { throw new Error('onStateChange() is not supported on StageDouble'); } } diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.cfn-template-from-repo.lit.expected.json b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.cfn-template-from-repo.lit.expected.json index 86d24e18de1be..f80827ddacf12 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.cfn-template-from-repo.lit.expected.json +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.cfn-template-from-repo.lit.expected.json @@ -7,9 +7,8 @@ "Triggers": [] } }, - "PipelineArtifactsBucketEncryptionKey01D58D69" : { + "PipelineArtifactsBucketEncryptionKey01D58D69": { "Type": "AWS::KMS::Key", - "DeletionPolicy": "Retain", "Properties": { "KeyPolicy": { "Statement": [ @@ -71,11 +70,11 @@ ], "Version": "2012-10-17" } - } + }, + "DeletionPolicy": "Retain" }, "PipelineArtifactsBucket22248F97": { "Type": "AWS::S3::Bucket", - "DeletionPolicy": "Retain", "Properties": { "BucketEncryption": { "ServerSideEncryptionConfiguration": [ @@ -92,7 +91,8 @@ } ] } - } + }, + "DeletionPolicy": "Retain" }, "PipelineRoleD68726F7": { "Type": "AWS::IAM::Role", @@ -381,10 +381,6 @@ } ], "ArtifactStore": { - "Location": { - "Ref": "PipelineArtifactsBucket22248F97" - }, - "Type": "S3", "EncryptionKey": { "Id": { "Fn::GetAtt": [ @@ -393,7 +389,11 @@ ] }, "Type": "KMS" - } + }, + "Location": { + "Ref": "PipelineArtifactsBucket22248F97" + }, + "Type": "S3" } }, "DependsOn": [ @@ -450,4 +450,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.lambda-deployed-through-codepipeline.lit.expected.json b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.lambda-deployed-through-codepipeline.lit.expected.json index 241c862e043cc..caed1d048c69b 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.lambda-deployed-through-codepipeline.lit.expected.json +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.lambda-deployed-through-codepipeline.lit.expected.json @@ -2,7 +2,6 @@ "Resources": { "PipelineArtifactsBucketEncryptionKey01D58D69": { "Type": "AWS::KMS::Key", - "DeletionPolicy": "Retain", "Properties": { "KeyPolicy": { "Statement": [ @@ -102,11 +101,11 @@ ], "Version": "2012-10-17" } - } + }, + "DeletionPolicy": "Retain" }, "PipelineArtifactsBucket22248F97": { "Type": "AWS::S3::Bucket", - "DeletionPolicy": "Retain", "Properties": { "BucketEncryption": { "ServerSideEncryptionConfiguration": [ @@ -123,7 +122,8 @@ } ] } - } + }, + "DeletionPolicy": "Retain" }, "PipelineRoleD68726F7": { "Type": "AWS::IAM::Role", @@ -480,10 +480,6 @@ } ], "ArtifactStore": { - "Location": { - "Ref": "PipelineArtifactsBucket22248F97" - }, - "Type": "S3", "EncryptionKey": { "Id": { "Fn::GetAtt": [ @@ -492,7 +488,11 @@ ] }, "Type": "KMS" - } + }, + "Location": { + "Ref": "PipelineArtifactsBucket22248F97" + }, + "Type": "S3" } }, "DependsOn": [ @@ -1109,4 +1109,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.lambda-pipeline.expected.json b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.lambda-pipeline.expected.json index 95e2c143571f5..66b99d6d54d05 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.lambda-pipeline.expected.json +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.lambda-pipeline.expected.json @@ -1,8 +1,7 @@ { "Resources": { - "PipelineArtifactsBucketEncryptionKey01D58D69" : { + "PipelineArtifactsBucketEncryptionKey01D58D69": { "Type": "AWS::KMS::Key", - "DeletionPolicy": "Retain", "Properties": { "KeyPolicy": { "Statement": [ @@ -64,11 +63,11 @@ ], "Version": "2012-10-17" } - } + }, + "DeletionPolicy": "Retain" }, "PipelineArtifactsBucket22248F97": { "Type": "AWS::S3::Bucket", - "DeletionPolicy": "Retain", "Properties": { "BucketEncryption": { "ServerSideEncryptionConfiguration": [ @@ -85,7 +84,8 @@ } ] } - } + }, + "DeletionPolicy": "Retain" }, "PipelineRoleD68726F7": { "Type": "AWS::IAM::Role", @@ -286,10 +286,6 @@ } ], "ArtifactStore": { - "Location": { - "Ref": "PipelineArtifactsBucket22248F97" - }, - "Type": "S3", "EncryptionKey": { "Id": { "Fn::GetAtt": [ @@ -298,7 +294,11 @@ ] }, "Type": "KMS" - } + }, + "Location": { + "Ref": "PipelineArtifactsBucket22248F97" + }, + "Type": "S3" } }, "DependsOn": [ @@ -666,4 +666,4 @@ ] } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-code-build-multiple-inputs-outputs.expected.json b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-code-build-multiple-inputs-outputs.expected.json index 3a89b866241ee..8959ea6c3fd74 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-code-build-multiple-inputs-outputs.expected.json +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-code-build-multiple-inputs-outputs.expected.json @@ -585,4 +585,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-code-commit.expected.json b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-code-commit.expected.json index 9f18a6605a15b..8e3dae70f0b26 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-code-commit.expected.json +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-code-commit.expected.json @@ -72,9 +72,8 @@ ] } }, - "PipelineArtifactsBucketEncryptionKey01D58D69" : { + "PipelineArtifactsBucketEncryptionKey01D58D69": { "Type": "AWS::KMS::Key", - "DeletionPolicy": "Retain", "Properties": { "KeyPolicy": { "Statement": [ @@ -136,11 +135,11 @@ ], "Version": "2012-10-17" } - } + }, + "DeletionPolicy": "Retain" }, "PipelineArtifactsBucket22248F97": { "Type": "AWS::S3::Bucket", - "DeletionPolicy": "Retain", "Properties": { "BucketEncryption": { "ServerSideEncryptionConfiguration": [ @@ -157,7 +156,8 @@ } ] } - } + }, + "DeletionPolicy": "Retain" }, "PipelineRoleD68726F7": { "Type": "AWS::IAM::Role", @@ -327,10 +327,6 @@ } ], "ArtifactStore": { - "Location": { - "Ref": "PipelineArtifactsBucket22248F97" - }, - "Type": "S3", "EncryptionKey": { "Id": { "Fn::GetAtt": [ @@ -339,7 +335,11 @@ ] }, "Type": "KMS" - } + }, + "Location": { + "Ref": "PipelineArtifactsBucket22248F97" + }, + "Type": "S3" } }, "DependsOn": [ @@ -418,4 +418,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-ecr-source.expected.json b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-ecr-source.expected.json index d1257fc3b4c75..9c3df28286ac0 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-ecr-source.expected.json +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-ecr-source.expected.json @@ -286,4 +286,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-events.expected.json b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-events.expected.json index c52c8a8253524..387568106ab8d 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-events.expected.json +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-events.expected.json @@ -1,8 +1,7 @@ { "Resources": { - "MyPipelineArtifactsBucketEncryptionKey8BF0A7F3" : { + "MyPipelineArtifactsBucketEncryptionKey8BF0A7F3": { "Type": "AWS::KMS::Key", - "DeletionPolicy": "Retain", "Properties": { "KeyPolicy": { "Statement": [ @@ -83,11 +82,11 @@ ], "Version": "2012-10-17" } - } + }, + "DeletionPolicy": "Retain" }, "MyPipelineArtifactsBucket727923DD": { "Type": "AWS::S3::Bucket", - "DeletionPolicy": "Retain", "Properties": { "BucketEncryption": { "ServerSideEncryptionConfiguration": [ @@ -104,7 +103,8 @@ } ] } - } + }, + "DeletionPolicy": "Retain" }, "MyPipelineRoleC0D47CA4": { "Type": "AWS::IAM::Role", @@ -301,10 +301,6 @@ } ], "ArtifactStore": { - "Location": { - "Ref": "MyPipelineArtifactsBucket727923DD" - }, - "Type": "S3", "EncryptionKey": { "Id": { "Fn::GetAtt": [ @@ -313,7 +309,11 @@ ] }, "Type": "KMS" - } + }, + "Location": { + "Ref": "MyPipelineArtifactsBucket727923DD" + }, + "Type": "S3" } }, "DependsOn": [ @@ -476,10 +476,10 @@ "Id": "MyTopic", "InputTransformer": { "InputPathsMap": { - "pipeline": "$.detail.pipeline", - "state": "$.detail.state" + "f1": "$.detail.pipeline", + "f2": "$.detail.state" }, - "InputTemplate": "\"Pipeline changed state to \"" + "InputTemplate": "\"Pipeline changed state to \"" } } ] @@ -704,4 +704,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-events.ts b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-events.ts index a79555c7fd883..328bb6d6c293f 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-events.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/integ.pipeline-events.ts @@ -3,6 +3,7 @@ import codebuild = require('@aws-cdk/aws-codebuild'); import codecommit = require('@aws-cdk/aws-codecommit'); import codepipeline = require('@aws-cdk/aws-codepipeline'); +import events = require('@aws-cdk/aws-events'); import targets = require('@aws-cdk/aws-events-targets'); import sns = require('@aws-cdk/aws-sns'); import cdk = require('@aws-cdk/cdk'); @@ -45,13 +46,11 @@ pipeline.addStage({ const topic = new sns.Topic(stack, 'MyTopic'); -pipeline.onStateChange('OnPipelineStateChange').addTarget(new targets.SnsTopic(topic), { - textTemplate: 'Pipeline changed state to ', - pathsMap: { - pipeline: '$.detail.pipeline', - state: '$.detail.state' - } -}); +const eventPipeline = events.EventField.fromPath('$.detail.pipeline'); +const eventState = events.EventField.fromPath('$.detail.state'); +pipeline.onStateChange('OnPipelineStateChange').addTarget(new targets.SnsTopic(topic, { + message: events.RuleTargetInput.fromText(`Pipeline ${eventPipeline} changed state to ${eventState}`), +})); sourceStage.onStateChange('OnSourceStateChange', new targets.SnsTopic(topic)); diff --git a/packages/@aws-cdk/aws-codepipeline/lib/action.ts b/packages/@aws-cdk/aws-codepipeline/lib/action.ts index 0d1e505d3f2cd..e8dabace20812 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/action.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/action.ts @@ -55,10 +55,10 @@ export interface ActionBind { /** * The abstract view of an AWS CodePipeline as required and used by Actions. - * It extends {@link events.IEventRuleTarget}, + * It extends {@link events.IRuleTarget}, * so this interface can be used as a Target for CloudWatch Events. */ -export interface IPipeline extends IResource, events.IEventRuleTarget { +export interface IPipeline extends IResource { /** * The name of the Pipeline. * @@ -99,7 +99,7 @@ export interface IStage { addAction(action: Action): void; - onStateChange(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule; + onStateChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule; } /** @@ -240,8 +240,8 @@ export abstract class Action { } } - public onStateChange(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps) { - const rule = new events.EventRule(this.scope, name, options); + public onStateChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps) { + const rule = new events.Rule(this.scope, name, options); rule.addTarget(target); rule.addEventPattern({ detailType: [ 'CodePipeline Stage Execution State Change' ], diff --git a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts index 151088aa2c747..82662424d94a5 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/pipeline.ts @@ -100,41 +100,9 @@ export interface PipelineProps { abstract class PipelineBase extends Resource implements IPipeline { public abstract pipelineName: string; public abstract pipelineArn: string; - private eventsRole?: iam.Role; public abstract grantBucketRead(identity: iam.IGrantable): iam.Grant; public abstract grantBucketReadWrite(identity: iam.IGrantable): iam.Grant; - /** - * Allows the pipeline to be used as a CloudWatch event rule target. - * - * Usage: - * - * const pipeline = new Pipeline(this, 'MyPipeline'); - * const rule = new EventRule(this, 'MyRule', { schedule: 'rate(1 minute)' }); - * rule.addTarget(pipeline); - * - */ - public asEventRuleTarget(_ruleArn: string, _ruleId: string): events.EventRuleTargetProps { - // the first time the event rule target is retrieved, we define an IAM - // role assumable by the CloudWatch events service which is allowed to - // start the execution of this pipeline. no need to define more than one - // role per pipeline. - if (!this.eventsRole) { - this.eventsRole = new iam.Role(this, 'EventsRole', { - assumedBy: new iam.ServicePrincipal('events.amazonaws.com') - }); - - this.eventsRole.addToPolicy(new iam.PolicyStatement() - .addResource(this.pipelineArn) - .addAction('codepipeline:StartPipelineExecution')); - } - - return { - id: this.node.id, - arn: this.pipelineArn, - roleArn: this.eventsRole.roleArn, - }; - } } /** @@ -210,9 +178,8 @@ export class Pipeline extends PipelineBase { public readonly artifactBucket: s3.IBucket; private readonly stages = new Array(); - private readonly pipelineResource: CfnPipeline; private readonly crossRegionReplicationBuckets: { [region: string]: string }; - private readonly artifactStores: { [region: string]: any }; + private readonly artifactStores: { [region: string]: CfnPipeline.ArtifactStoreProperty }; private readonly _crossRegionScaffoldStacks: { [region: string]: CrossRegionScaffoldStack } = {}; constructor(scope: Construct, id: string, props?: PipelineProps) { @@ -238,8 +205,9 @@ export class Pipeline extends PipelineBase { }); const codePipeline = new CfnPipeline(this, 'Resource', { - artifactStore: new Token(() => this.renderArtifactStore()) as any, - stages: new Token(() => this.renderStages()) as any, + artifactStore: new Token(() => this.renderArtifactStore()), + artifactStores: new Token(() => this.renderArtifactStores()), + stages: new Token(() => this.renderStages()), roleArn: this.role.roleArn, restartExecutionOnUpdate: props && props.restartExecutionOnUpdate, name: props && props.pipelineName, @@ -252,7 +220,6 @@ export class Pipeline extends PipelineBase { this.pipelineName = codePipeline.ref; this.pipelineVersion = codePipeline.pipelineVersion; - this.pipelineResource = codePipeline; this.crossRegionReplicationBuckets = props.crossRegionReplicationBuckets || {}; this.artifactStores = {}; @@ -311,8 +278,8 @@ export class Pipeline extends PipelineBase { * more than a single onStateChange event, you will need to explicitly * specify a name. */ - public onStateChange(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule { - const rule = new events.EventRule(this, name, options); + public onStateChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule { + const rule = new events.Rule(this, name, options); rule.addTarget(target); rule.addEventPattern({ detailType: [ 'CodePipeline Pipeline Execution State Change' ], @@ -413,8 +380,8 @@ export class Pipeline extends PipelineBase { replicationBucket.grantReadWrite(this.role); this.artifactStores[region] = { - Location: replicationBucket.bucketName, - Type: 'S3', + location: replicationBucket.bucketName, + type: 'S3', }; } @@ -525,7 +492,28 @@ export class Pipeline extends PipelineBase { return ret; } - private renderArtifactStore(): CfnPipeline.ArtifactStoreProperty { + private renderArtifactStores(): CfnPipeline.ArtifactStoreMapProperty[] | undefined { + if (!this.crossRegion) { return undefined; } + + // add the Pipeline's artifact store + const primaryStore = this.renderPrimaryArtifactStore(); + this.artifactStores[this.node.stack.requireRegion()] = { + location: primaryStore.location, + type: primaryStore.type, + encryptionKey: primaryStore.encryptionKey, + }; + + return Object.entries(this.artifactStores).map(([region, artifactStore]) => ({ + region, artifactStore + })); + } + + private renderArtifactStore(): CfnPipeline.ArtifactStoreProperty | undefined { + if (this.crossRegion) { return undefined; } + return this.renderPrimaryArtifactStore(); + } + + private renderPrimaryArtifactStore(): CfnPipeline.ArtifactStoreProperty { let encryptionKey: CfnPipeline.EncryptionKeyProperty | undefined; const bucketKey = this.artifactBucket.encryptionKey; if (bucketKey) { @@ -547,41 +535,12 @@ export class Pipeline extends PipelineBase { }; } - private renderStages(): CfnPipeline.StageDeclarationProperty[] { - // handle cross-region CodePipeline overrides here - let crossRegion = false; - this.stages.forEach((stage, i) => { - stage.actions.forEach((action, j) => { - if (action.region) { - crossRegion = true; - this.pipelineResource.addPropertyOverride(`Stages.${i}.Actions.${j}.Region`, action.region); - } - }); - }); - - if (crossRegion) { - // we don't need ArtifactStore in this case - this.pipelineResource.addPropertyDeletionOverride('ArtifactStore'); - - // add the Pipeline's artifact store - const artifactStore = this.renderArtifactStore(); - this.artifactStores[this.node.stack.requireRegion()] = { - Location: artifactStore.location, - Type: artifactStore.type, - EncryptionKey: artifactStore.encryptionKey, - }; - - const artifactStoresProp: any[] = []; - // tslint:disable-next-line:forin - for (const region in this.artifactStores) { - artifactStoresProp.push({ - Region: region, - ArtifactStore: this.artifactStores[region], - }); - } - this.pipelineResource.addPropertyOverride('ArtifactStores', artifactStoresProp); - } + private get crossRegion(): boolean { + return this.stages.some(stage => stage.actions.some(action => action.region !== undefined)); + // this.pipelineResource.addPropertyOverride(`Stages.${i}.Actions.${j}.Region`, action.region); + } + private renderStages(): CfnPipeline.StageDeclarationProperty[] { return this.stages.map(stage => stage.render()); } } diff --git a/packages/@aws-cdk/aws-codepipeline/lib/stage.ts b/packages/@aws-cdk/aws-codepipeline/lib/stage.ts index 6e48b68ea5edc..b3a22f5b78cab 100644 --- a/packages/@aws-cdk/aws-codepipeline/lib/stage.ts +++ b/packages/@aws-cdk/aws-codepipeline/lib/stage.ts @@ -78,8 +78,8 @@ export class Stage implements IStage { this.attachActionToPipeline(action); } - public onStateChange(name: string, target?: events.IEventRuleTarget, options?: events.EventRuleProps): events.EventRule { - const rule = new events.EventRule(this.scope, name, options); + public onStateChange(name: string, target?: events.IRuleTarget, options?: events.RuleProps): events.Rule { + const rule = new events.Rule(this.scope, name, options); rule.addTarget(target); rule.addEventPattern({ detailType: [ 'CodePipeline Stage Execution State Change' ], @@ -135,7 +135,8 @@ export class Stage implements IStage { }, configuration: action.configuration, runOrder: action.runOrder, - roleArn: action.role ? action.role.roleArn : undefined + roleArn: action.role ? action.role.roleArn : undefined, + region: action.region, }; } diff --git a/packages/@aws-cdk/aws-ecr/lib/repository.ts b/packages/@aws-cdk/aws-ecr/lib/repository.ts index 42e1f35cf659b..658c87c15a876 100644 --- a/packages/@aws-cdk/aws-ecr/lib/repository.ts +++ b/packages/@aws-cdk/aws-ecr/lib/repository.ts @@ -62,10 +62,10 @@ export interface IRepository extends IResource { * Defines an AWS CloudWatch event rule that can trigger a target when an image is pushed to this * repository. * @param name The name of the rule - * @param target An IEventRuleTarget to invoke when this event happens (you can add more targets using `addTarget`) + * @param target An IRuleTarget to invoke when this event happens (you can add more targets using `addTarget`) * @param imageTag Only trigger on the specific image tag */ - onImagePushed(name: string, target?: events.IEventRuleTarget, imageTag?: string): events.EventRule; + onImagePushed(name: string, target?: events.IRuleTarget, imageTag?: string): events.Rule; } /** @@ -114,11 +114,11 @@ export abstract class RepositoryBase extends Resource implements IRepository { * Defines an AWS CloudWatch event rule that can trigger a target when an image is pushed to this * repository. * @param name The name of the rule - * @param target An IEventRuleTarget to invoke when this event happens (you can add more targets using `addTarget`) + * @param target An IRuleTarget to invoke when this event happens (you can add more targets using `addTarget`) * @param imageTag Only trigger on the specific image tag */ - public onImagePushed(name: string, target?: events.IEventRuleTarget, imageTag?: string): events.EventRule { - return new events.EventRule(this, name, { + public onImagePushed(name: string, target?: events.IRuleTarget, imageTag?: string): events.Rule { + return new events.Rule(this, name, { targets: target ? [target] : undefined, eventPattern: { source: ['aws.ecr'], diff --git a/packages/@aws-cdk/aws-ecs/README.md b/packages/@aws-cdk/aws-ecs/README.md index 6539c47812830..fc6aff6090040 100644 --- a/packages/@aws-cdk/aws-ecs/README.md +++ b/packages/@aws-cdk/aws-ecs/README.md @@ -285,12 +285,39 @@ you can configure on your instances. ## Integration with CloudWatch Events To start an Amazon ECS task on an Amazon EC2-backed Cluster, instantiate an -`Ec2TaskEventRuleTarget` instead of an `Ec2Service`: +`@aws-cdk/aws-events-targets.EcsEc2Task` instead of an `Ec2Service`: -[example of CloudWatch Events integration](test/ec2/integ.event-task.lit.ts) +```ts +import targets = require('@aws-cdk/aws-events-targets'); + +// Create a Task Definition for the container to start +const taskDefinition = new ecs.Ec2TaskDefinition(this, 'TaskDef'); +taskDefinition.addContainer('TheContainer', { + image: ecs.ContainerImage.fromAsset(this, 'EventImage', { + directory: path.resolve(__dirname, '..', 'eventhandler-image') + }), + memoryLimitMiB: 256, + logging: new ecs.AwsLogDriver(this, 'TaskLogging', { streamPrefix: 'EventDemo' }) +}); -> Note: it is currently not possible to start AWS Fargate tasks in this way. +// An Rule that describes the event trigger (in this case a scheduled run) +const rule = new events.Rule(this, 'Rule', { + scheduleExpression: 'rate(1 minute)', +}); -## Roadmap +// Pass an environment variable to the container 'TheContainer' in the task +rule.addTarget(new targets.EcsEc2Task({ + cluster, + taskDefinition, + taskCount: 1, + containerOverrides: [{ + containerName: 'TheContainer', + environment: [{ + name: 'I_WAS_TRIGGERED', + value: 'From CloudWatch Events' + }] + }] +})); +``` -- [ ] Service Discovery Integration +> Note: it is currently not possible to start AWS Fargate tasks in this way. diff --git a/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-event-rule-target.ts b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-event-rule-target.ts deleted file mode 100644 index 33f2df17307f7..0000000000000 --- a/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-event-rule-target.ts +++ /dev/null @@ -1,102 +0,0 @@ -import events = require ('@aws-cdk/aws-events'); -import iam = require('@aws-cdk/aws-iam'); -import cdk = require('@aws-cdk/cdk'); -import { Compatibility, ITaskDefinition } from '../base/task-definition'; -import { ICluster } from '../cluster'; - -/** - * Properties to define an EC2 Event Task - */ -export interface Ec2EventRuleTargetProps { - /** - * Cluster where service will be deployed - */ - readonly cluster: ICluster; - - /** - * Task Definition of the task that should be started - */ - readonly taskDefinition: ITaskDefinition; - - /** - * How many tasks should be started when this event is triggered - * - * @default 1 - */ - readonly taskCount?: number; -} - -/** - * Start a service on an EC2 cluster - */ -export class Ec2EventRuleTarget extends cdk.Construct implements events.IEventRuleTarget { - private readonly cluster: ICluster; - private readonly taskDefinition: ITaskDefinition; - private readonly taskCount: number; - - constructor(scope: cdk.Construct, id: string, props: Ec2EventRuleTargetProps) { - super(scope, id); - - if (props.taskDefinition.compatibility === Compatibility.Fargate) { - throw new Error('Supplied TaskDefinition is not configured for compatibility with EC2'); - } - - this.cluster = props.cluster; - this.taskDefinition = props.taskDefinition; - this.taskCount = props.taskCount !== undefined ? props.taskCount : 1; - } - - /** - * Allows using containers as target of CloudWatch events - */ - public asEventRuleTarget(_ruleArn: string, _ruleUniqueId: string): events.EventRuleTargetProps { - const role = this.eventsRole; - - role.addToPolicy(new iam.PolicyStatement() - .addAction('ecs:RunTask') - .addResource(this.taskDefinition.taskDefinitionArn) - .addCondition('ArnEquals', { "ecs:cluster": this.cluster.clusterArn })); - - return { - id: this.node.id, - arn: this.cluster.clusterArn, - roleArn: role.roleArn, - ecsParameters: { - taskCount: this.taskCount, - taskDefinitionArn: this.taskDefinition.taskDefinitionArn - } - }; - } - - /** - * Create or get the IAM Role used to start this Task Definition. - * - * We create it under the TaskDefinition object so that if we have multiple EventTargets - * they can reuse the same role. - */ - public get eventsRole(): iam.IRole { - const stack = this.node.stack; - const id = `${this.taskDefinition.node.uniqueId}-EventsRole`; - let role = stack.node.tryFindChild(id) as iam.IRole; - if (role === undefined) { - role = new iam.Role(stack, id, { - assumedBy: new iam.ServicePrincipal('events.amazonaws.com') - }); - } - - return role; - } - - /** - * Prepare the Event Rule Target - */ - protected prepare() { - // If it so happens that a Task Execution Role was created for the TaskDefinition, - // then the CloudWatch Events Role must have permissions to pass it (otherwise it doesn't). - // - // It never needs permissions to the Task Role. - if (this.taskDefinition.executionRole !== undefined) { - this.taskDefinition.executionRole.grantPassRole(this.eventsRole); - } - } -} diff --git a/packages/@aws-cdk/aws-ecs/lib/index.ts b/packages/@aws-cdk/aws-ecs/lib/index.ts index a2a19ba7712ed..189f667461bbd 100644 --- a/packages/@aws-cdk/aws-ecs/lib/index.ts +++ b/packages/@aws-cdk/aws-ecs/lib/index.ts @@ -9,7 +9,6 @@ export * from './placement'; export * from './ec2/ec2-service'; export * from './ec2/ec2-task-definition'; -export * from './ec2/ec2-event-rule-target'; export * from './fargate/fargate-service'; export * from './fargate/fargate-task-definition'; diff --git a/packages/@aws-cdk/aws-ecs/package.json b/packages/@aws-cdk/aws-ecs/package.json index 532d1f041f6d4..7e57cb1ec672d 100644 --- a/packages/@aws-cdk/aws-ecs/package.json +++ b/packages/@aws-cdk/aws-ecs/package.json @@ -77,7 +77,6 @@ "@aws-cdk/aws-ecr": "^0.31.0", "@aws-cdk/aws-elasticloadbalancing": "^0.31.0", "@aws-cdk/aws-elasticloadbalancingv2": "^0.31.0", - "@aws-cdk/aws-events": "^0.31.0", "@aws-cdk/aws-iam": "^0.31.0", "@aws-cdk/aws-lambda": "^0.31.0", "@aws-cdk/aws-logs": "^0.31.0", @@ -100,7 +99,6 @@ "@aws-cdk/aws-ecr": "^0.31.0", "@aws-cdk/aws-elasticloadbalancing": "^0.31.0", "@aws-cdk/aws-elasticloadbalancingv2": "^0.31.0", - "@aws-cdk/aws-events": "^0.31.0", "@aws-cdk/aws-iam": "^0.31.0", "@aws-cdk/aws-lambda": "^0.31.0", "@aws-cdk/aws-logs": "^0.31.0", diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-event-rule-target.ts b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-event-rule-target.ts deleted file mode 100644 index d7f8e5c7ba915..0000000000000 --- a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-event-rule-target.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { expect, haveResource } from '@aws-cdk/assert'; -import ec2 = require('@aws-cdk/aws-ec2'); -import events = require('@aws-cdk/aws-events'); -import cdk = require('@aws-cdk/cdk'); -import { Test } from 'nodeunit'; -import ecs = require('../../lib'); - -export = { - "Can use EC2 taskdef as EventRule target"(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - const vpc = new ec2.Vpc(stack, 'Vpc', { maxAZs: 1 }); - const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); - cluster.addCapacity('DefaultAutoScalingGroup', { - instanceType: new ec2.InstanceType('t2.micro') - }); - - const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef'); - taskDefinition.addContainer('TheContainer', { - image: ecs.ContainerImage.fromRegistry('henk'), - memoryLimitMiB: 256 - }); - - const rule = new events.EventRule(stack, 'Rule', { - scheduleExpression: 'rate(1 minute)', - }); - - // WHEN - const target = new ecs.Ec2EventRuleTarget(stack, 'EventTarget', { - cluster, - taskDefinition, - taskCount: 1 - }); - - rule.addTarget(target, { - jsonTemplate: { - argument: 'hello' - } - }); - - // THEN - expect(stack).to(haveResource('AWS::Events::Rule', { - Targets: [ - { - Arn: { "Fn::GetAtt": ["EcsCluster97242B84", "Arn"] }, - EcsParameters: { - TaskCount: 1, - TaskDefinitionArn: { Ref: "TaskDef54694570" } - }, - Id: "EventTarget", - InputTransformer: { - InputTemplate: "{\"argument\":\"hello\"}" - }, - RoleArn: { "Fn::GetAtt": ["TaskDefEventsRole7BD19E45", "Arn"] } - } - ] - })); - - test.done(); - } -}; diff --git a/packages/@aws-cdk/aws-events-targets/.gitignore b/packages/@aws-cdk/aws-events-targets/.gitignore index 205e21fe7353b..0eb18b3fdfc39 100644 --- a/packages/@aws-cdk/aws-events-targets/.gitignore +++ b/packages/@aws-cdk/aws-events-targets/.gitignore @@ -13,4 +13,5 @@ lib/generated/resources.ts coverage .nycrc .LAST_PACKAGE -*.snk \ No newline at end of file +*.snk +.cdk.staging diff --git a/packages/@aws-cdk/aws-events-targets/lib/codebuild.ts b/packages/@aws-cdk/aws-events-targets/lib/codebuild.ts index c013486673041..e33f647a2362a 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/codebuild.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/codebuild.ts @@ -1,47 +1,26 @@ import codebuild = require('@aws-cdk/aws-codebuild'); import events = require('@aws-cdk/aws-events'); import iam = require('@aws-cdk/aws-iam'); +import { singletonEventRole } from './util'; /** * Start a CodeBuild build when an AWS CloudWatch events rule is triggered. */ -export class CodeBuildProject implements events.IEventRuleTarget { - +export class CodeBuildProject implements events.IRuleTarget { constructor(private readonly project: codebuild.IProject) { - } /** * Allows using build projects as event rule targets. */ - public asEventRuleTarget(_ruleArn: string, _ruleId: string): events.EventRuleTargetProps { + public bind(_rule: events.IRule): events.RuleTargetProperties { return { id: this.project.node.id, arn: this.project.projectArn, - roleArn: this.getCreateRole().roleArn, + role: singletonEventRole(this.project, [new iam.PolicyStatement() + .addAction('codebuild:StartBuild') + .addResource(this.project.projectArn) + ]), }; } - - /** - * Gets or creates an IAM role associated with this CodeBuild project to allow - * CloudWatch Events to start builds for this project. - */ - private getCreateRole() { - const scope = this.project.node.stack; - const id = `@aws-cdk/aws-events-targets.CodeBuildProject:Role:${this.project.node.uniqueId}`; - const exists = scope.node.tryFindChild(id) as iam.Role; - if (exists) { - return exists; - } - - const role = new iam.Role(scope, id, { - assumedBy: new iam.ServicePrincipal('events.amazonaws.com') - }); - - role.addToPolicy(new iam.PolicyStatement() - .addAction('codebuild:StartBuild') - .addResource(this.project.projectArn)); - - return role; - } } diff --git a/packages/@aws-cdk/aws-events-targets/lib/codepipeline.ts b/packages/@aws-cdk/aws-events-targets/lib/codepipeline.ts new file mode 100644 index 0000000000000..e17487c2e487c --- /dev/null +++ b/packages/@aws-cdk/aws-events-targets/lib/codepipeline.ts @@ -0,0 +1,22 @@ +import codepipeline = require('@aws-cdk/aws-codepipeline'); +import events = require('@aws-cdk/aws-events'); +import iam = require('@aws-cdk/aws-iam'); +import { singletonEventRole } from './util'; + + /** + * Allows the pipeline to be used as a CloudWatch event rule target. + */ +export class CodePipeline implements events.IRuleTarget { + constructor(private readonly pipeline: codepipeline.IPipeline) { + } + + public bind(_rule: events.IRule): events.RuleTargetProperties { + return { + id: this.pipeline.node.id, + arn: this.pipeline.pipelineArn, + role: singletonEventRole(this.pipeline, [new iam.PolicyStatement() + .addResource(this.pipeline.pipelineArn) + .addAction('codepipeline:StartPipelineExecution')]) + }; + } +} diff --git a/packages/@aws-cdk/aws-events-targets/lib/ecs-ec2-task.ts b/packages/@aws-cdk/aws-events-targets/lib/ecs-ec2-task.ts new file mode 100644 index 0000000000000..2fd38253fb8dd --- /dev/null +++ b/packages/@aws-cdk/aws-events-targets/lib/ecs-ec2-task.ts @@ -0,0 +1,125 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import ecs = require('@aws-cdk/aws-ecs'); +import events = require ('@aws-cdk/aws-events'); +import iam = require('@aws-cdk/aws-iam'); +import { Construct, Token } from '@aws-cdk/cdk'; +import { ContainerOverride } from './ecs-task-properties'; +import { singletonEventRole } from './util'; + +/** + * Properties to define an EC2 Event Task + */ +export interface EcsEc2TaskProps { + /** + * Cluster where service will be deployed + */ + readonly cluster: ecs.ICluster; + + /** + * Task Definition of the task that should be started + */ + readonly taskDefinition: ecs.TaskDefinition; + + /** + * How many tasks should be started when this event is triggered + * + * @default 1 + */ + readonly taskCount?: number; + + /** + * Container setting overrides + * + * Key is the name of the container to override, value is the + * values you want to override. + */ + readonly containerOverrides?: ContainerOverride[]; + + /** + * In what subnets to place the task's ENIs + * + * (Only applicable in case the TaskDefinition is configured for AwsVpc networking) + * + * @default Private subnets + */ + readonly subnetSelection?: ec2.SubnetSelection; + + /** + * Existing security group to use for the task's ENIs + * + * (Only applicable in case the TaskDefinition is configured for AwsVpc networking) + * + * @default A new security group is created + */ + readonly securityGroup?: ec2.ISecurityGroup; +} + +/** + * Start a service on an EC2 cluster + */ +export class EcsEc2Task implements events.IRuleTarget { + private readonly cluster: ecs.ICluster; + private readonly taskDefinition: ecs.TaskDefinition; + private readonly taskCount: number; + + constructor(private readonly props: EcsEc2TaskProps) { + if (!props.taskDefinition.isEc2Compatible) { + throw new Error('Supplied TaskDefinition is not configured for compatibility with EC2'); + } + + this.cluster = props.cluster; + this.taskDefinition = props.taskDefinition; + this.taskCount = props.taskCount !== undefined ? props.taskCount : 1; + } + + /** + * Allows using containers as target of CloudWatch events + */ + public bind(rule: events.IRule): events.RuleTargetProperties { + const policyStatements = [new iam.PolicyStatement() + .addAction('ecs:RunTask') + .addResource(this.taskDefinition.taskDefinitionArn) + .addCondition('ArnEquals', { "ecs:cluster": this.cluster.clusterArn }) + ]; + + // If it so happens that a Task Execution Role was created for the TaskDefinition, + // then the CloudWatch Events Role must have permissions to pass it (otherwise it doesn't). + // + // It never needs permissions to the Task Role. + if (this.taskDefinition.executionRole !== undefined) { + policyStatements.push(new iam.PolicyStatement() + .addAction('iam:PassRole') + .addResource(this.taskDefinition.executionRole.roleArn)); + } + + return { + id: this.taskDefinition.node.id + ' on ' + this.cluster.node.id, + arn: this.cluster.clusterArn, + role: singletonEventRole(this.taskDefinition, policyStatements), + ecsParameters: { + taskCount: this.taskCount, + taskDefinitionArn: this.taskDefinition.taskDefinitionArn + }, + input: events.RuleTargetInput.fromObject({ + containerOverrides: this.props.containerOverrides, + networkConfiguration: this.renderNetworkConfiguration(rule as events.Rule), + }) + }; + } + + private renderNetworkConfiguration(scope: Construct) { + if (this.props.taskDefinition.networkMode !== ecs.NetworkMode.AwsVpc) { + return undefined; + } + + const subnetSelection = this.props.subnetSelection || { subnetType: ec2.SubnetType.Private }; + const securityGroup = this.props.securityGroup || new ec2.SecurityGroup(scope, 'SecurityGroup', { vpc: this.props.cluster.vpc }); + + return { + awsvpcConfiguration: { + subnets: this.props.cluster.vpc.selectSubnets(subnetSelection).subnetIds, + securityGroups: new Token(() => [securityGroup.securityGroupId]), + } + }; + } +} diff --git a/packages/@aws-cdk/aws-events-targets/lib/ecs-task-properties.ts b/packages/@aws-cdk/aws-events-targets/lib/ecs-task-properties.ts new file mode 100644 index 0000000000000..11deb4cd8d8c5 --- /dev/null +++ b/packages/@aws-cdk/aws-events-targets/lib/ecs-task-properties.ts @@ -0,0 +1,58 @@ +export interface ContainerOverride { + /** + * Name of the container inside the task definition + */ + readonly containerName: string; + + /** + * Command to run inside the container + * + * @default Default command + */ + readonly command?: string[]; + + /** + * Variables to set in the container's environment + */ + readonly environment?: TaskEnvironmentVariable[]; + + /** + * The number of cpu units reserved for the container + * + * @default The default value from the task definition. + */ + readonly cpu?: number; + + /** + * Hard memory limit on the container + * + * @default The default value from the task definition. + */ + readonly memoryLimit?: number; + + /** + * Soft memory limit on the container + * + * @default The default value from the task definition. + */ + readonly memoryReservation?: number; +} + +/** + * An environment variable to be set in the container run as a task + */ +export interface TaskEnvironmentVariable { + /** + * Name for the environment variable + * + * Exactly one of `name` and `namePath` must be specified. + */ + readonly name: string; + + /** + * Value of the environment variable + * + * Exactly one of `value` and `valuePath` must be specified. + */ + readonly value: string; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-events-targets/lib/index.ts b/packages/@aws-cdk/aws-events-targets/lib/index.ts index ee1e2d31b587e..cd31a43468f19 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/index.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/index.ts @@ -1,3 +1,7 @@ +export * from './codepipeline'; export * from './sns'; export * from './codebuild'; export * from './lambda'; +export * from './ecs-task-properties'; +export * from './ecs-ec2-task'; +export * from './state-machine'; diff --git a/packages/@aws-cdk/aws-events-targets/lib/lambda.ts b/packages/@aws-cdk/aws-events-targets/lib/lambda.ts index 4384dd82a12a8..d4e4b88d4fabc 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/lambda.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/lambda.ts @@ -3,14 +3,24 @@ import iam = require('@aws-cdk/aws-iam'); import lambda = require('@aws-cdk/aws-lambda'); /** - * Use an AWS Lambda function as an event rule target. + * Customize the SNS Topic Event Target */ -export class LambdaFunction implements events.IEventRuleTarget { - +export interface LambdaFunctionProps { /** - * @param handler The lambda function + * The event to send to the Lambda + * + * This will be the payload sent to the Lambda Function. + * + * @default the entire CloudWatch event */ - constructor(private readonly handler: lambda.IFunction) { + readonly event?: events.RuleTargetInput; +} + +/** + * Use an AWS Lambda function as an event rule target. + */ +export class LambdaFunction implements events.IRuleTarget { + constructor(private readonly handler: lambda.IFunction, private readonly props: LambdaFunctionProps = {}) { } @@ -18,19 +28,20 @@ export class LambdaFunction implements events.IEventRuleTarget { * Returns a RuleTarget that can be used to trigger this Lambda as a * result from a CloudWatch event. */ - public asEventRuleTarget(ruleArn: string, ruleId: string): events.EventRuleTargetProps { - const permissionId = `AllowEventRule${ruleId}`; + public bind(rule: events.IRule): events.RuleTargetProperties { + const permissionId = `AllowEventRule${rule.node.uniqueId}`; if (!this.handler.node.tryFindChild(permissionId)) { this.handler.addPermission(permissionId, { action: 'lambda:InvokeFunction', principal: new iam.ServicePrincipal('events.amazonaws.com'), - sourceArn: ruleArn + sourceArn: rule.ruleArn }); } return { id: this.handler.node.id, arn: this.handler.functionArn, + input: this.props.event, }; } } diff --git a/packages/@aws-cdk/aws-events-targets/lib/sns.ts b/packages/@aws-cdk/aws-events-targets/lib/sns.ts index cabc8088f2ace..958e245e79abe 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/sns.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/sns.ts @@ -2,6 +2,18 @@ import events = require('@aws-cdk/aws-events'); import iam = require('@aws-cdk/aws-iam'); import sns = require('@aws-cdk/aws-sns'); +/** + * Customize the SNS Topic Event Target + */ +export interface SnsTopicProps { + /** + * The message to send to the topic + * + * @default the entire CloudWatch event + */ + readonly message?: events.RuleTargetInput; +} + /** * Use an SNS topic as a target for AWS CloudWatch event rules. * @@ -12,9 +24,8 @@ import sns = require('@aws-cdk/aws-sns'); * repository.onCommit(new targets.SnsTopic(topic)); * */ -export class SnsTopic implements events.IEventRuleTarget { - constructor(public readonly topic: sns.ITopic) { - +export class SnsTopic implements events.IRuleTarget { + constructor(public readonly topic: sns.ITopic, private readonly props: SnsTopicProps = {}) { } /** @@ -23,13 +34,14 @@ export class SnsTopic implements events.IEventRuleTarget { * * @see https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/resource-based-policies-cwe.html#sns-permissions */ - public asEventRuleTarget(_ruleArn: string, _ruleId: string): events.EventRuleTargetProps { + public bind(_rule: events.IRule): events.RuleTargetProperties { // deduplicated automatically this.topic.grantPublish(new iam.ServicePrincipal('events.amazonaws.com')); return { id: this.topic.node.id, arn: this.topic.topicArn, + input: this.props.message, }; } } diff --git a/packages/@aws-cdk/aws-events-targets/lib/state-machine.ts b/packages/@aws-cdk/aws-events-targets/lib/state-machine.ts new file mode 100644 index 0000000000000..10b1c27d3c4a5 --- /dev/null +++ b/packages/@aws-cdk/aws-events-targets/lib/state-machine.ts @@ -0,0 +1,41 @@ +import events = require('@aws-cdk/aws-events'); +import iam = require('@aws-cdk/aws-iam'); +import sfn = require('@aws-cdk/aws-stepfunctions'); +import { singletonEventRole } from './util'; + +/** + * Customize the Step Functions State Machine target + */ +export interface SfnStateMachineProps { + /** + * The input to the state machine execution + * + * @default the entire CloudWatch event + */ + readonly input?: events.RuleTargetInput; +} + +/** + * Use a StepFunctions state machine as a target for AWS CloudWatch event rules. + */ +export class SfnStateMachine implements events.IRuleTarget { + constructor(public readonly machine: sfn.IStateMachine, private readonly props: SfnStateMachineProps = {}) { + } + + /** + * Returns a properties that are used in an Rule to trigger this State Machine + * + * @see https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/resource-based-policies-cwe.html#sns-permissions + */ + public bind(_rule: events.IRule): events.RuleTargetProperties { + return { + id: this.machine.node.id, + arn: this.machine.stateMachineArn, + role: singletonEventRole(this.machine, [new iam.PolicyStatement() + .addAction('states:StartExecution') + .addResource(this.machine.stateMachineArn) + ]), + input: this.props.input + }; + } +} diff --git a/packages/@aws-cdk/aws-events-targets/lib/util.ts b/packages/@aws-cdk/aws-events-targets/lib/util.ts new file mode 100644 index 0000000000000..1b0215589ff9f --- /dev/null +++ b/packages/@aws-cdk/aws-events-targets/lib/util.ts @@ -0,0 +1,22 @@ +import iam = require('@aws-cdk/aws-iam'); +import { Construct, IConstruct } from "@aws-cdk/cdk"; + +/** + * Obtain the Role for the CloudWatch event + * + * If a role already exists, it will be returned. This ensures that if multiple + * events have the same target, they will share a role. + */ +export function singletonEventRole(scope: IConstruct, policyStatements: iam.PolicyStatement[]): iam.IRole { + const id = 'EventsRole'; + const existing = scope.node.tryFindChild(id) as iam.IRole; + if (existing) { return existing; } + + const role = new iam.Role(scope as Construct, id, { + assumedBy: new iam.ServicePrincipal('events.amazonaws.com') + }); + + policyStatements.forEach(role.addToPolicy.bind(role)); + + return role; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-events-targets/package.json b/packages/@aws-cdk/aws-events-targets/package.json index 169c2ebfda20f..d28c3f9766280 100644 --- a/packages/@aws-cdk/aws-events-targets/package.json +++ b/packages/@aws-cdk/aws-events-targets/package.json @@ -48,7 +48,7 @@ ], "coverageThreshold": { "global": { - "branches": 80, + "branches": 30, "statements": 80 } } @@ -80,6 +80,7 @@ "dependencies": { "@aws-cdk/aws-codebuild": "^0.31.0", "@aws-cdk/aws-codepipeline": "^0.31.0", + "@aws-cdk/aws-ec2": "^0.31.0", "@aws-cdk/aws-ecs": "^0.31.0", "@aws-cdk/aws-events": "^0.31.0", "@aws-cdk/aws-iam": "^0.31.0", @@ -92,6 +93,7 @@ "peerDependencies": { "@aws-cdk/aws-codebuild": "^0.31.0", "@aws-cdk/aws-codepipeline": "^0.31.0", + "@aws-cdk/aws-ec2": "^0.31.0", "@aws-cdk/aws-ecs": "^0.31.0", "@aws-cdk/aws-events": "^0.31.0", "@aws-cdk/aws-iam": "^0.31.0", diff --git a/packages/@aws-cdk/aws-events-targets/test/codebuild/codebuild.test.ts b/packages/@aws-cdk/aws-events-targets/test/codebuild/codebuild.test.ts index c0d06ce165499..adc6bade85faa 100644 --- a/packages/@aws-cdk/aws-events-targets/test/codebuild/codebuild.test.ts +++ b/packages/@aws-cdk/aws-events-targets/test/codebuild/codebuild.test.ts @@ -8,7 +8,7 @@ test('use codebuild project as an eventrule target', () => { // GIVEN const stack = new Stack(); const project = new codebuild.Project(stack, 'MyProject', { source: new codebuild.CodePipelineSource() }); - const rule = new events.EventRule(stack, 'rule', { scheduleExpression: 'rate(1 min)' }); + const rule = new events.Rule(stack, 'Rule', { scheduleExpression: 'rate(1 min)' }); // WHEN rule.addTarget(new targets.CodeBuildProject(project)); @@ -26,7 +26,7 @@ test('use codebuild project as an eventrule target', () => { Id: "MyProject", RoleArn: { "Fn::GetAtt": [ - "awscdkawseventstargetsCodeBuildProjectRoleMyProject02D63D81", + "MyProjectEventsRole5B7D93F5", "Arn" ] } diff --git a/packages/@aws-cdk/aws-events-targets/test/codebuild/integ.project-events.expected.json b/packages/@aws-cdk/aws-events-targets/test/codebuild/integ.project-events.expected.json index f3255283328aa..04a6e3740851e 100644 --- a/packages/@aws-cdk/aws-events-targets/test/codebuild/integ.project-events.expected.json +++ b/packages/@aws-cdk/aws-events-targets/test/codebuild/integ.project-events.expected.json @@ -47,7 +47,7 @@ "Id": "MyProject", "RoleArn": { "Fn::GetAtt": [ - "awscdkawseventstargetsCodeBuildProjectRoleawscdkcodebuildeventsMyProjectEF919B0E11E20F6B", + "MyProjectEventsRole5B7D93F5", "Arn" ] } @@ -59,15 +59,68 @@ "Id": "MyTopic", "InputTransformer": { "InputPathsMap": { - "branch": "$.detail.referenceName", - "repo": "$.detail.repositoryName" + "f1": "$.detail.repositoryName", + "f2": "$.detail.referenceName" }, - "InputTemplate": "\"A commit was pushed to the repository on branch \"" + "InputTemplate": "\"A commit was pushed to the repository on branch \"" } } ] } }, + "MyProjectEventsRole5B7D93F5": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "events.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + } + } + }, + "MyProjectEventsRoleDefaultPolicy397DCBF8": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "codebuild:StartBuild", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "MyProject39F7B0AE", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyProjectEventsRoleDefaultPolicy397DCBF8", + "Roles": [ + { + "Ref": "MyProjectEventsRole5B7D93F5" + } + ] + } + }, "MyProjectRole9BBE5233": { "Type": "AWS::IAM::Role", "Properties": { @@ -263,9 +316,9 @@ "Id": "MyTopic", "InputTransformer": { "InputPathsMap": { - "phase": "$.detail.completed-phase" + "f1": "$.detail.completed-phase" }, - "InputTemplate": "\"Build phase changed to \"" + "InputTemplate": "\"Build phase changed to \"" } } ] @@ -362,59 +415,6 @@ } ] } - }, - "awscdkawseventstargetsCodeBuildProjectRoleawscdkcodebuildeventsMyProjectEF919B0E11E20F6B": { - "Type": "AWS::IAM::Role", - "Properties": { - "AssumeRolePolicyDocument": { - "Statement": [ - { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": { - "Service": { - "Fn::Join": [ - "", - [ - "events.", - { - "Ref": "AWS::URLSuffix" - } - ] - ] - } - } - } - ], - "Version": "2012-10-17" - } - } - }, - "awscdkawseventstargetsCodeBuildProjectRoleawscdkcodebuildeventsMyProjectEF919B0EDefaultPolicy03C827BE": { - "Type": "AWS::IAM::Policy", - "Properties": { - "PolicyDocument": { - "Statement": [ - { - "Action": "codebuild:StartBuild", - "Effect": "Allow", - "Resource": { - "Fn::GetAtt": [ - "MyProject39F7B0AE", - "Arn" - ] - } - } - ], - "Version": "2012-10-17" - }, - "PolicyName": "awscdkawseventstargetsCodeBuildProjectRoleawscdkcodebuildeventsMyProjectEF919B0EDefaultPolicy03C827BE", - "Roles": [ - { - "Ref": "awscdkawseventstargetsCodeBuildProjectRoleawscdkcodebuildeventsMyProjectEF919B0E11E20F6B" - } - ] - } } } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-events-targets/test/codebuild/integ.project-events.ts b/packages/@aws-cdk/aws-events-targets/test/codebuild/integ.project-events.ts index 9f1886ead6fdc..357a3c592ffe0 100644 --- a/packages/@aws-cdk/aws-events-targets/test/codebuild/integ.project-events.ts +++ b/packages/@aws-cdk/aws-events-targets/test/codebuild/integ.project-events.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import codebuild = require('@aws-cdk/aws-codebuild'); import codecommit = require('@aws-cdk/aws-codecommit'); +import events = require('@aws-cdk/aws-events'); import sns = require('@aws-cdk/aws-sns'); import sqs = require('@aws-cdk/aws-sqs'); import cdk = require('@aws-cdk/cdk'); @@ -27,21 +28,16 @@ project.onStateChange('StateChange', new targets.SnsTopic(topic)); // this will send an email with the message "Build phase changed to ". // The phase will be extracted from the "completed-phase" field of the event // details. -project.onPhaseChange('PhaseChange').addTarget(new targets.SnsTopic(topic), { - textTemplate: `Build phase changed to `, - pathsMap: { - phase: '$.detail.completed-phase' - } -}); +project.onPhaseChange('PhaseChange').addTarget(new targets.SnsTopic(topic, { + message: events.RuleTargetInput.fromText(`Build phase changed to ${codebuild.PhaseChangeEvent.completedPhase}`) +})); // trigger a build when a commit is pushed to the repo const onCommitRule = repo.onCommit('OnCommit', new targets.CodeBuildProject(project), 'master'); -onCommitRule.addTarget(new targets.SnsTopic(topic), { - textTemplate: 'A commit was pushed to the repository on branch ', - pathsMap: { - branch: '$.detail.referenceName', - repo: '$.detail.repositoryName' - } -}); +onCommitRule.addTarget(new targets.SnsTopic(topic, { + message: events.RuleTargetInput.fromText( + `A commit was pushed to the repository ${codecommit.ReferenceEvent.repositoryName} on branch ${codecommit.ReferenceEvent.referenceName}` + ) +})); app.run(); diff --git a/packages/@aws-cdk/aws-events-targets/test/codepipeline/pipeline.test.ts b/packages/@aws-cdk/aws-events-targets/test/codepipeline/pipeline.test.ts new file mode 100644 index 0000000000000..38d642de3dbe0 --- /dev/null +++ b/packages/@aws-cdk/aws-events-targets/test/codepipeline/pipeline.test.ts @@ -0,0 +1,81 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import codepipeline = require('@aws-cdk/aws-codepipeline'); +import events = require('@aws-cdk/aws-events'); +import { Stack } from '@aws-cdk/cdk'; +import targets = require('../../lib'); + +test('use codebuild project as an eventrule target', () => { + // GIVEN + const stack = new Stack(); + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline'); + + const srcArtifact = new codepipeline.Artifact('Src'); + const buildArtifact = new codepipeline.Artifact('Bld'); + pipeline.addStage({ + name: 'Source', + actions: [new TestAction({ + actionName: 'Hello', + category: codepipeline.ActionCategory.Source, + provider: 'x', + artifactBounds: { minInputs: 0, maxInputs: 0 , minOutputs: 1, maxOutputs: 1, }, + outputs: [srcArtifact]})] + }); + pipeline.addStage({ + name: 'Build', + actions: [new TestAction({ + actionName: 'Hello', + category: codepipeline.ActionCategory.Build, + provider: 'y', + inputs: [srcArtifact], + outputs: [buildArtifact], + artifactBounds: { minInputs: 1, maxInputs: 1 , minOutputs: 1, maxOutputs: 1, }})] + }); + + const rule = new events.Rule(stack, 'rule', { scheduleExpression: 'rate(1 min)' }); + + // WHEN + rule.addTarget(new targets.CodePipeline(pipeline)); + + const pipelineArn = { + "Fn::Join": [ "", [ + "arn:", + { Ref: "AWS::Partition" }, + ":codepipeline:", + { Ref: "AWS::Region" }, + ":", + { Ref: "AWS::AccountId" }, + ":", + { Ref: "PipelineC660917D" }] + ] + }; + + // THEN + expect(stack).to(haveResource('AWS::Events::Rule', { + Targets: [ + { + Arn: pipelineArn, + Id: "Pipeline", + RoleArn: { "Fn::GetAtt": [ "PipelineEventsRole46BEEA7C", "Arn" ] } + } + ] + })); + + expect(stack).to(haveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: "codepipeline:StartPipelineExecution", + Effect: "Allow", + Resource: pipelineArn, + } + ], + Version: "2012-10-17" + } + })); +}); + +class TestAction extends codepipeline.Action { + protected bind(_info: codepipeline.ActionBind): void { + // void + } +} diff --git a/packages/@aws-cdk/aws-events-targets/test/ecs/ec2-event-rule-target.test.ts b/packages/@aws-cdk/aws-events-targets/test/ecs/ec2-event-rule-target.test.ts new file mode 100644 index 0000000000000..3e9c62822ea55 --- /dev/null +++ b/packages/@aws-cdk/aws-events-targets/test/ecs/ec2-event-rule-target.test.ts @@ -0,0 +1,57 @@ +import '@aws-cdk/assert/jest'; +import ec2 = require('@aws-cdk/aws-ec2'); +import ecs = require('@aws-cdk/aws-ecs'); +import events = require('@aws-cdk/aws-events'); +import cdk = require('@aws-cdk/cdk'); +import targets = require('../../lib'); + +test("Can use EC2 taskdef as EventRule target", () => { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'Vpc', { maxAZs: 1 }); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { + instanceType: new ec2.InstanceType('t2.micro') + }); + + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef'); + taskDefinition.addContainer('TheContainer', { + image: ecs.ContainerImage.fromRegistry('henk'), + memoryLimitMiB: 256 + }); + + const rule = new events.Rule(stack, 'Rule', { + scheduleExpression: 'rate(1 minute)', + }); + + // WHEN + rule.addTarget(new targets.EcsEc2Task({ + cluster, + taskDefinition, + taskCount: 1, + containerOverrides: [{ + containerName: 'TheContainer', + command: ['echo', events.EventField.fromPath('$.detail.event')], + }] + })); + + // THEN + expect(stack).toHaveResourceLike('AWS::Events::Rule', { + Targets: [ + { + Arn: { "Fn::GetAtt": ["EcsCluster97242B84", "Arn"] }, + EcsParameters: { + TaskCount: 1, + TaskDefinitionArn: { Ref: "TaskDef54694570" } + }, + InputTransformer: { + InputPathsMap: { + f1: "$.detail.event" + }, + InputTemplate: "{\"containerOverrides\":[{\"containerName\":\"TheContainer\",\"command\":[\"echo\",]}]}" + }, + RoleArn: { "Fn::GetAtt": ["TaskDefEventsRoleFB3B67B8", "Arn"] } + } + ] + }); +}); diff --git a/packages/@aws-cdk/aws-ecs/test/eventhandler-image/Dockerfile b/packages/@aws-cdk/aws-events-targets/test/ecs/eventhandler-image/Dockerfile similarity index 100% rename from packages/@aws-cdk/aws-ecs/test/eventhandler-image/Dockerfile rename to packages/@aws-cdk/aws-events-targets/test/ecs/eventhandler-image/Dockerfile diff --git a/packages/@aws-cdk/aws-ecs/test/eventhandler-image/index.py b/packages/@aws-cdk/aws-events-targets/test/ecs/eventhandler-image/index.py similarity index 100% rename from packages/@aws-cdk/aws-ecs/test/eventhandler-image/index.py rename to packages/@aws-cdk/aws-events-targets/test/ecs/eventhandler-image/index.py diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.event-task.lit.expected.json b/packages/@aws-cdk/aws-events-targets/test/ecs/integ.event-task.lit.expected.json similarity index 98% rename from packages/@aws-cdk/aws-ecs/test/ec2/integ.event-task.lit.expected.json rename to packages/@aws-cdk/aws-events-targets/test/ecs/integ.event-task.lit.expected.json index 3d06c2ecea088..ed1654619ee50 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/integ.event-task.lit.expected.json +++ b/packages/@aws-cdk/aws-events-targets/test/ecs/integ.event-task.lit.expected.json @@ -1048,13 +1048,11 @@ "Ref": "TaskDef54694570" } }, - "Id": "EventTarget", - "InputTransformer": { - "InputTemplate": "{\"containerOverrides\":[{\"name\":\"TheContainer\",\"environment\":[{\"name\":\"I_WAS_TRIGGERED\",\"value\":\"From CloudWatch Events\"}]}]}" - }, + "Id": "TaskDef-on-EcsCluster", + "Input": "{\"containerOverrides\":[{\"containerName\":\"TheContainer\",\"environment\":[{\"name\":\"I_WAS_TRIGGERED\",\"value\":\"From CloudWatch Events\"}]}]}", "RoleArn": { "Fn::GetAtt": [ - "awsecsintegecsTaskDef8DD0C801EventsRoleC617AC5B", + "TaskDefEventsRoleFB3B67B8", "Arn" ] } @@ -1062,7 +1060,7 @@ ] } }, - "awsecsintegecsTaskDef8DD0C801EventsRoleC617AC5B": { + "TaskDefEventsRoleFB3B67B8": { "Type": "AWS::IAM::Role", "Properties": { "AssumeRolePolicyDocument": { @@ -1089,7 +1087,7 @@ } } }, - "awsecsintegecsTaskDef8DD0C801EventsRoleDefaultPolicy2DFC09DA": { + "TaskDefEventsRoleDefaultPolicyA124E85B": { "Type": "AWS::IAM::Policy", "Properties": { "PolicyDocument": { @@ -1124,10 +1122,10 @@ ], "Version": "2012-10-17" }, - "PolicyName": "awsecsintegecsTaskDef8DD0C801EventsRoleDefaultPolicy2DFC09DA", + "PolicyName": "TaskDefEventsRoleDefaultPolicyA124E85B", "Roles": [ { - "Ref": "awsecsintegecsTaskDef8DD0C801EventsRoleC617AC5B" + "Ref": "TaskDefEventsRoleFB3B67B8" } ] } diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.event-task.lit.ts b/packages/@aws-cdk/aws-events-targets/test/ecs/integ.event-task.lit.ts similarity index 59% rename from packages/@aws-cdk/aws-ecs/test/ec2/integ.event-task.lit.ts rename to packages/@aws-cdk/aws-events-targets/test/ecs/integ.event-task.lit.ts index b59f6953932a0..a759421d8c047 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/integ.event-task.lit.ts +++ b/packages/@aws-cdk/aws-events-targets/test/ecs/integ.event-task.lit.ts @@ -1,7 +1,8 @@ import ec2 = require('@aws-cdk/aws-ec2'); +import ecs = require('@aws-cdk/aws-ecs'); import events = require('@aws-cdk/aws-events'); import cdk = require('@aws-cdk/cdk'); -import ecs = require('../../lib'); +import targets = require('../../lib'); import path = require('path'); @@ -23,33 +24,29 @@ class EventStack extends cdk.Stack { const taskDefinition = new ecs.Ec2TaskDefinition(this, 'TaskDef'); taskDefinition.addContainer('TheContainer', { image: ecs.ContainerImage.fromAsset(this, 'EventImage', { - directory: path.resolve(__dirname, '..', 'eventhandler-image') + directory: path.resolve(__dirname, 'eventhandler-image') }), memoryLimitMiB: 256, logging: new ecs.AwsLogDriver(this, 'TaskLogging', { streamPrefix: 'EventDemo' }) }); - // An EventRule that describes the event trigger (in this case a scheduled run) - const rule = new events.EventRule(this, 'Rule', { + // An Rule that describes the event trigger (in this case a scheduled run) + const rule = new events.Rule(this, 'Rule', { scheduleExpression: 'rate(1 minute)', }); - // Use Ec2TaskEventRuleTarget as the target of the EventRule - const target = new ecs.Ec2EventRuleTarget(this, 'EventTarget', { + // Use EcsEc2Task as the target of the Rule + rule.addTarget(new targets.EcsEc2Task({ cluster, taskDefinition, - taskCount: 1 - }); - - // Pass an environment variable to the container 'TheContainer' in the task - rule.addTarget(target, { - jsonTemplate: JSON.stringify({ - containerOverrides: [{ - name: 'TheContainer', - environment: [{ name: 'I_WAS_TRIGGERED', value: 'From CloudWatch Events' }] - }] - }) - }); + taskCount: 1, + containerOverrides: [{ + containerName: 'TheContainer', + environment: [ + { name: 'I_WAS_TRIGGERED', value: 'From CloudWatch Events' } + ] + }] + })); /// !hide } } diff --git a/packages/@aws-cdk/aws-events-targets/test/lambda/integ.events.ts b/packages/@aws-cdk/aws-events-targets/test/lambda/integ.events.ts index 6ed768e89a61d..28794ae97cc24 100644 --- a/packages/@aws-cdk/aws-events-targets/test/lambda/integ.events.ts +++ b/packages/@aws-cdk/aws-events-targets/test/lambda/integ.events.ts @@ -13,10 +13,10 @@ const fn = new lambda.Function(stack, 'MyFunc', { code: lambda.Code.inline(`exports.handler = ${handler.toString()}`) }); -const timer = new events.EventRule(stack, 'Timer', { scheduleExpression: 'rate(1 minute)' }); +const timer = new events.Rule(stack, 'Timer', { scheduleExpression: 'rate(1 minute)' }); timer.addTarget(new targets.LambdaFunction(fn)); -const timer2 = new events.EventRule(stack, 'Timer2', { scheduleExpression: 'rate(2 minutes)' }); +const timer2 = new events.Rule(stack, 'Timer2', { scheduleExpression: 'rate(2 minutes)' }); timer2.addTarget(new targets.LambdaFunction(fn)); app.run(); diff --git a/packages/@aws-cdk/aws-events-targets/test/lambda/lambda.test.ts b/packages/@aws-cdk/aws-events-targets/test/lambda/lambda.test.ts index e611681c5d648..edef10526eef6 100644 --- a/packages/@aws-cdk/aws-events-targets/test/lambda/lambda.test.ts +++ b/packages/@aws-cdk/aws-events-targets/test/lambda/lambda.test.ts @@ -8,8 +8,8 @@ test('use lambda as an event rule target', () => { // GIVEN const stack = new cdk.Stack(); const fn = newTestLambda(stack); - const rule1 = new events.EventRule(stack, 'Rule', { scheduleExpression: 'rate(1 minute)' }); - const rule2 = new events.EventRule(stack, 'Rule2', { scheduleExpression: 'rate(5 minutes)' }); + const rule1 = new events.Rule(stack, 'Rule', { scheduleExpression: 'rate(1 minute)' }); + const rule2 = new events.Rule(stack, 'Rule2', { scheduleExpression: 'rate(5 minutes)' }); // WHEN rule1.addTarget(new targets.LambdaFunction(fn)); diff --git a/packages/@aws-cdk/aws-events-targets/test/sns/integ.sns-event-rule-target.ts b/packages/@aws-cdk/aws-events-targets/test/sns/integ.sns-event-rule-target.ts index 6a07c8379ffa0..81fe6e95c5a48 100644 --- a/packages/@aws-cdk/aws-events-targets/test/sns/integ.sns-event-rule-target.ts +++ b/packages/@aws-cdk/aws-events-targets/test/sns/integ.sns-event-rule-target.ts @@ -14,7 +14,7 @@ const app = new cdk.App(); const stack = new cdk.Stack(app, 'aws-cdk-sns-event-target'); const topic = new sns.Topic(stack, 'MyTopic'); -const event = new events.EventRule(stack, 'EveryMinute', { +const event = new events.Rule(stack, 'EveryMinute', { scheduleExpression: 'rate(1 minute)' }); diff --git a/packages/@aws-cdk/aws-events-targets/test/sns/sns.test.ts b/packages/@aws-cdk/aws-events-targets/test/sns/sns.test.ts index 6c03182824b17..868255b475e55 100644 --- a/packages/@aws-cdk/aws-events-targets/test/sns/sns.test.ts +++ b/packages/@aws-cdk/aws-events-targets/test/sns/sns.test.ts @@ -8,7 +8,7 @@ test('sns topic as an event rule target', () => { // GIVEN const stack = new Stack(); const topic = new sns.Topic(stack, 'MyTopic'); - const rule = new events.EventRule(stack, 'MyRule', { + const rule = new events.Rule(stack, 'MyRule', { scheduleExpression: 'rate(1 hour)', }); @@ -51,7 +51,7 @@ test('multiple uses of a topic as a target results in a single policy statement' // WHEN for (let i = 0; i < 5; ++i) { - const rule = new events.EventRule(stack, `Rule${i}`, { scheduleExpression: 'rate(1 hour)' }); + const rule = new events.Rule(stack, `Rule${i}`, { scheduleExpression: 'rate(1 hour)' }); rule.addTarget(new targets.SnsTopic(topic)); } diff --git a/packages/@aws-cdk/aws-events-targets/test/stepfunctions/statemachine.test.ts b/packages/@aws-cdk/aws-events-targets/test/stepfunctions/statemachine.test.ts new file mode 100644 index 0000000000000..5e29cd47b046c --- /dev/null +++ b/packages/@aws-cdk/aws-events-targets/test/stepfunctions/statemachine.test.ts @@ -0,0 +1,30 @@ +import '@aws-cdk/assert/jest'; +import events = require('@aws-cdk/aws-events'); +import sfn = require('@aws-cdk/aws-stepfunctions'); +import cdk = require('@aws-cdk/cdk'); +import targets = require('../../lib'); + +test('State machine can be used as Event Rule target', () => { + // GIVEN + const stack = new cdk.Stack(); + const rule = new events.Rule(stack, 'Rule', { + scheduleExpression: 'rate(1 minute)' + }); + const stateMachine = new sfn.StateMachine(stack, 'SM', { + definition: new sfn.Wait(stack, 'Hello', { duration: sfn.WaitDuration.seconds(10) }) + }); + + // WHEN + rule.addTarget(new targets.SfnStateMachine(stateMachine, { + input: events.RuleTargetInput.fromObject({ SomeParam: 'SomeValue' }), + })); + + // THEN + expect(stack).toHaveResourceLike('AWS::Events::Rule', { + Targets: [ + { + Input: "{\"SomeParam\":\"SomeValue\"}" + } + ] + }); +}); diff --git a/packages/@aws-cdk/aws-events/README.md b/packages/@aws-cdk/aws-events/README.md index e89bfdce98166..9ce481c191009 100644 --- a/packages/@aws-cdk/aws-events/README.md +++ b/packages/@aws-cdk/aws-events/README.md @@ -28,14 +28,14 @@ event when the pipeline changes it's state. that are of interest to them. A rule can customize the JSON sent to the target, by passing only certain parts or by overwriting it with a constant. -The `EventRule` construct defines a CloudWatch events rule which monitors an +The `Rule` construct defines a CloudWatch events rule which monitors an event based on an [event pattern](https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/CloudWatchEventsandEventPatterns.html) and invoke __event targets__ when the pattern is matched against a triggered event. Event targets are objects that implement the `IEventTarget` interface. Normally, you will use one of the `source.onXxx(name[, target[, options]]) -> -EventRule` methods on the event source to define an event rule associated with +Rule` methods on the event source to define an event rule associated with the specific activity. You can targets either via props, or add targets using `rule.addTarget`. @@ -65,7 +65,7 @@ onCommitRule.addTarget(topic, { ## Event Targets -The `@aws-cdk/aws-events-targets` module includes classes that implement the `IEventRuleTarget` +The `@aws-cdk/aws-events-targets` module includes classes that implement the `IRuleTarget` interface for various AWS services. The following targets are supported: diff --git a/packages/@aws-cdk/aws-events/lib/index.ts b/packages/@aws-cdk/aws-events/lib/index.ts index c2bd7a23dcb59..81e2633f09a6f 100644 --- a/packages/@aws-cdk/aws-events/lib/index.ts +++ b/packages/@aws-cdk/aws-events/lib/index.ts @@ -1,8 +1,8 @@ +export * from './input'; export * from './rule'; export * from './rule-ref'; export * from './target'; export * from './event-pattern'; -export * from './input-options'; // AWS::Events CloudFormation Resources: export * from './events.generated'; diff --git a/packages/@aws-cdk/aws-events/lib/input-options.ts b/packages/@aws-cdk/aws-events/lib/input-options.ts deleted file mode 100644 index f45cedc13a94e..0000000000000 --- a/packages/@aws-cdk/aws-events/lib/input-options.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Specifies settings that provide custom input to an Amazon CloudWatch Events - * rule target based on certain event data. - * - * @see https://docs.aws.amazon.com/AmazonCloudWatchEvents/latest/APIReference/API_InputTransformer.html - */ -export interface TargetInputTemplate { - /** - * Input template where you can use the values of the keys from - * inputPathsMap to customize the data sent to the target. Enclose each - * InputPathsMaps value in brackets: - * - * The value passed here will be double-quoted to indicate it's a string value. - * This option is mutually exclusive with `jsonTemplate`. - * - * @example - * - * { - * textTemplate: 'Build started', - * pathsMap: { - * buildid: '$.detail.id' - * } - * } - */ - readonly textTemplate?: string; - - /** - * Input template where you can use the values of the keys from - * inputPathsMap to customize the data sent to the target. Enclose each - * InputPathsMaps value in brackets: - * - * This option is mutually exclusive with `textTemplate`. - * - * @example - * - * { - * jsonTemplate: '{ "commands": }' , - * pathsMap: { - * commandsToRun: '$.detail.commands' - * } - * } - * - */ - readonly jsonTemplate?: any; - - /** - * Map of JSON paths to be extracted from the event. These are key-value - * pairs, where each value is a JSON path. You must use JSON dot notation, - * not bracket notation. - */ - readonly pathsMap?: { [key: string]: string }; -} diff --git a/packages/@aws-cdk/aws-events/lib/input.ts b/packages/@aws-cdk/aws-events/lib/input.ts new file mode 100644 index 0000000000000..b3ed4dddb7020 --- /dev/null +++ b/packages/@aws-cdk/aws-events/lib/input.ts @@ -0,0 +1,297 @@ +import { CloudFormationLang, DefaultTokenResolver, IResolveContext, resolve, StringConcat, Token } from '@aws-cdk/cdk'; +import { IRule } from './rule-ref'; + +/** + * The input to send to the event target + */ +export abstract class RuleTargetInput { + /** + * Pass text to the event target + * + * May contain strings returned by EventField.from() to substitute in parts of the + * matched event. + */ + public static fromText(text: string): RuleTargetInput { + return new FieldAwareEventInput(text, InputType.Text); + } + + /** + * Pass text to the event target, splitting on newlines. + * + * This is only useful when passing to a target that does not + * take a single argument. + * + * May contain strings returned by EventField.from() to substitute in parts + * of the matched event. + */ + public static fromMultilineText(text: string): RuleTargetInput { + return new FieldAwareEventInput(text, InputType.Multiline); + } + + /** + * Pass a JSON object to the event target + * + * May contain strings returned by EventField.from() to substitute in parts of the + * matched event. + */ + public static fromObject(obj: any): RuleTargetInput { + return new FieldAwareEventInput(obj, InputType.Object); + } + + /** + * Take the event target input from a path in the event JSON + */ + public static fromEventPath(path: string): RuleTargetInput { + return new LiteralEventInput({ inputPath: path }); + } + + protected constructor() { + } + + /** + * Return the input properties for this input object + */ + public abstract bind(rule: IRule): RuleTargetInputProperties; +} + +/** + * The input properties for an event target + */ +export interface RuleTargetInputProperties { + /** + * Literal input to the target service (must be valid JSON) + */ + readonly input?: string; + + /** + * JsonPath to take input from the input event + */ + readonly inputPath?: string; + + /** + * Input template to insert paths map into + */ + readonly inputTemplate?: string; + + /** + * Paths map to extract values from event and insert into `inputTemplate` + */ + readonly inputPathsMap?: {[key: string]: string}; +} + +/** + * Event Input that is directly derived from the construct + */ +class LiteralEventInput extends RuleTargetInput { + constructor(private readonly props: RuleTargetInputProperties) { + super(); + } + + /** + * Return the input properties for this input object + */ + public bind(_rule: IRule): RuleTargetInputProperties { + return this.props; + } +} + +/** + * Input object that can contain field replacements + * + * Evaluation is done in the bind() method because token resolution + * requires access to the construct tree. + * + * Multiple tokens that use the same path will use the same substitution + * key. + * + * One weird exception: if we're in object context, we MUST skip the quotes + * around the placeholder. I assume this is so once a trivial string replace is + * done later on by CWE, numbers are still numbers. + * + * So in string context: + * + * "this is a string with a " + * + * But in object context: + * + * "{ \"this is the\": }" + * + * To achieve the latter, we postprocess the JSON string to remove the surrounding + * quotes by using a string replace. + */ +class FieldAwareEventInput extends RuleTargetInput { + constructor(private readonly input: any, private readonly inputType: InputType) { + super(); + } + + public bind(rule: IRule): RuleTargetInputProperties { + let fieldCounter = 0; + const pathToKey = new Map(); + const inputPathsMap: {[key: string]: string} = {}; + + function keyForField(f: EventField) { + const existing = pathToKey.get(f.path); + if (existing !== undefined) { return existing; } + + fieldCounter += 1; + const key = f.nameHint || `f${fieldCounter}`; + pathToKey.set(f.path, key); + return key; + } + + const self = this; + + class EventFieldReplacer extends DefaultTokenResolver { + constructor() { + super(new StringConcat()); + } + + public resolveToken(t: Token, _context: IResolveContext) { + if (!isEventField(t)) { return t; } + + const key = keyForField(t); + if (inputPathsMap[key] && inputPathsMap[key] !== t.path) { + throw new Error(`Single key '${key}' is used for two different JSON paths: '${t.path}' and '${inputPathsMap[key]}'`); + } + inputPathsMap[key] = t.path; + + return self.keyPlaceholder(key); + } + } + + let resolved: string; + if (this.inputType === InputType.Multiline) { + // JSONify individual lines + resolved = resolve(this.input, { + scope: rule, + resolver: new EventFieldReplacer() + }); + resolved = resolved.split('\n').map(CloudFormationLang.toJSON).join('\n'); + } else { + resolved = CloudFormationLang.toJSON(resolve(this.input, { + scope: rule, + resolver: new EventFieldReplacer() + })); + } + + if (Object.keys(inputPathsMap).length === 0) { + // Nothing special, just return 'input' + return { input: resolved }; + } + + return { + inputTemplate: this.unquoteKeyPlaceholders(resolved), + inputPathsMap + }; + } + + /** + * Return a template placeholder for the given key + * + * In object scope we'll need to get rid of surrounding quotes later on, so + * return a bracing that's unlikely to occur naturally (like tokens). + */ + private keyPlaceholder(key: string) { + if (this.inputType !== InputType.Object) { return `<${key}>`; } + return UNLIKELY_OPENING_STRING + key + UNLIKELY_CLOSING_STRING; + } + + /** + * Removing surrounding quotes from any object placeholders + * + * Those have been put there by JSON.stringify(), but we need to + * remove them. + */ + private unquoteKeyPlaceholders(sub: string) { + if (this.inputType !== InputType.Object) { return sub; } + + return new Token((ctx: IResolveContext) => + ctx.resolve(sub).replace(OPENING_STRING_REGEX, '<').replace(CLOSING_STRING_REGEX, '>') + ).toString(); + } +} + +const UNLIKELY_OPENING_STRING = '<<${'; +const UNLIKELY_CLOSING_STRING = '}>>'; + +const OPENING_STRING_REGEX = new RegExp(regexQuote('"' + UNLIKELY_OPENING_STRING), 'g'); +const CLOSING_STRING_REGEX = new RegExp(regexQuote(UNLIKELY_CLOSING_STRING + '"'), 'g'); + +/** + * Represents a field in the event pattern + */ +export class EventField extends Token { + /** + * Extract the event ID from the event + */ + public static get eventId(): string { + return this.fromPath('$.id', 'eventId'); + } + + /** + * Extract the detail type from the event + */ + public static get detailType(): string { + return this.fromPath('$.detail-type', 'detailType'); + } + + /** + * Extract the source from the event + */ + public static get source(): string { + return this.fromPath('$.source', 'source'); + } + + /** + * Extract the account from the event + */ + public static get account(): string { + return this.fromPath('$.account', 'account'); + } + + /** + * Extract the time from the event + */ + public static get time(): string { + return this.fromPath('$.time', 'time'); + } + + /** + * Extract the region from the event + */ + public static get region(): string { + return this.fromPath('$.region', 'region'); + } + + /** + * Extract a custom JSON path from the event + */ + public static fromPath(path: string, nameHint?: string): string { + return new EventField(path, nameHint).toString(); + } + + private constructor(public readonly path: string, public readonly nameHint?: string) { + super(() => path); + + Object.defineProperty(this, EVENT_FIELD_SYMBOL, { value: true }); + } +} + +enum InputType { + Object, + Text, + Multiline, +} + +function isEventField(x: any): x is EventField { + return typeof x === 'object' && x !== null && x[EVENT_FIELD_SYMBOL]; +} + +const EVENT_FIELD_SYMBOL = Symbol.for('@aws-cdk/aws-events.EventField'); + +/** + * Quote a string for use in a regex + */ +function regexQuote(s: string) { + return s.replace(/[.?*+^$[\]\\(){}|-]/g, "\\$&"); +} diff --git a/packages/@aws-cdk/aws-events/lib/rule-ref.ts b/packages/@aws-cdk/aws-events/lib/rule-ref.ts index 8a262b8c6ecaa..52455797ae23d 100644 --- a/packages/@aws-cdk/aws-events/lib/rule-ref.ts +++ b/packages/@aws-cdk/aws-events/lib/rule-ref.ts @@ -1,6 +1,6 @@ import { IResource } from '@aws-cdk/cdk'; -export interface IEventRule extends IResource { +export interface IRule extends IResource { /** * The value of the event rule Amazon Resource Name (ARN), such as * arn:aws:events:us-east-2:123456789012:rule/example. diff --git a/packages/@aws-cdk/aws-events/lib/rule.ts b/packages/@aws-cdk/aws-events/lib/rule.ts index ba51df14a2d48..b70e6cdfa2453 100644 --- a/packages/@aws-cdk/aws-events/lib/rule.ts +++ b/packages/@aws-cdk/aws-events/lib/rule.ts @@ -1,12 +1,11 @@ import { Construct, Resource, Token } from '@aws-cdk/cdk'; import { EventPattern } from './event-pattern'; import { CfnRule } from './events.generated'; -import { TargetInputTemplate } from './input-options'; -import { IEventRule } from './rule-ref'; -import { IEventRuleTarget } from './target'; +import { IRule } from './rule-ref'; +import { IRuleTarget } from './target'; import { mergeEventPattern } from './util'; -export interface EventRuleProps { +export interface RuleProps { /** * A description of the rule's purpose. */ @@ -57,7 +56,7 @@ export interface EventRuleProps { * Input will be the full matched event. If you wish to specify custom * target input, use `addTarget(target[, inputOptions])`. */ - readonly targets?: IEventRuleTarget[]; + readonly targets?: IRuleTarget[]; } /** @@ -65,10 +64,10 @@ export interface EventRuleProps { * * @resource AWS::Events::Rule */ -export class EventRule extends Resource implements IEventRule { +export class Rule extends Resource implements IRule { - public static fromEventRuleArn(scope: Construct, id: string, eventRuleArn: string): IEventRule { - class Import extends Resource implements IEventRule { + public static fromEventRuleArn(scope: Construct, id: string, eventRuleArn: string): IRule { + class Import extends Resource implements IRule { public ruleArn = eventRuleArn; } return new Import(scope, id); @@ -80,7 +79,7 @@ export class EventRule extends Resource implements IEventRule { private readonly eventPattern: EventPattern = { }; private scheduleExpression?: string; - constructor(scope: Construct, id: string, props: EventRuleProps = { }) { + constructor(scope: Construct, id: string, props: RuleProps = { }) { super(scope, id); const resource = new CfnRule(this, 'Resource', { @@ -89,7 +88,7 @@ export class EventRule extends Resource implements IEventRule { state: props.enabled == null ? 'ENABLED' : (props.enabled ? 'ENABLED' : 'DISABLED'), scheduleExpression: new Token(() => this.scheduleExpression).toString(), eventPattern: new Token(() => this.renderEventPattern()), - targets: new Token(() => this.renderTargets()) + targets: new Token(() => this.renderTargets()), }); this.ruleArn = resource.ruleArn; @@ -108,54 +107,34 @@ export class EventRule extends Resource implements IEventRule { * * No-op if target is undefined. */ - public addTarget(target?: IEventRuleTarget, inputOptions?: TargetInputTemplate) { + public addTarget(target?: IRuleTarget) { if (!target) { return; } - const self = this; - const targetProps = target.asEventRuleTarget(this.ruleArn, this.node.uniqueId); + const targetProps = target.bind(this); + const id = sanitizeId(targetProps.id); + const inputProps = targetProps.input && targetProps.input.bind(this); // check if a target with this ID already exists - if (this.targets.find(t => t.id === targetProps.id)) { - throw new Error('Duplicate event rule target with ID: ' + targetProps.id); + if (this.targets.find(t => t.id === id)) { + throw new Error('Duplicate event rule target with ID: ' + id); } + const roleArn = targetProps.role ? targetProps.role.roleArn : undefined; + this.targets.push({ - ...targetProps, - inputTransformer: renderTransformer(), + id, + arn: targetProps.arn, + roleArn, + ecsParameters: targetProps.ecsParameters, + kinesisParameters: targetProps.kinesisParameters, + runCommandParameters: targetProps.runCommandParameters, + input: inputProps && inputProps.input, + inputPath: inputProps && inputProps.inputPath, + inputTransformer: inputProps && inputProps.inputTemplate !== undefined ? { + inputTemplate: inputProps.inputTemplate, + inputPathsMap: inputProps.inputPathsMap, + } : undefined, }); - - function renderTransformer(): CfnRule.InputTransformerProperty | undefined { - if (!inputOptions) { - return undefined; - } - - if (inputOptions.jsonTemplate && inputOptions.textTemplate) { - throw new Error('"jsonTemplate" and "textTemplate" are mutually exclusive'); - } - - if (!inputOptions.jsonTemplate && !inputOptions.textTemplate) { - throw new Error('One of "jsonTemplate" or "textTemplate" are required'); - } - - let inputTemplate: any; - - if (inputOptions.jsonTemplate) { - inputTemplate = typeof inputOptions.jsonTemplate === 'string' - ? inputOptions.jsonTemplate - : self.node.stringifyJson(inputOptions.jsonTemplate); - } else { - inputTemplate = typeof(inputOptions.textTemplate) === 'string' - // Newline separated list of JSON-encoded strings - ? inputOptions.textTemplate.split('\n').map(x => self.node.stringifyJson(x)).join('\n') - // Some object, stringify it, then stringify the string for proper escaping - : self.node.stringifyJson(self.node.stringifyJson(inputOptions.textTemplate)); - } - - return { - inputPathsMap: inputOptions.pathsMap, - inputTemplate - }; - } } /** @@ -234,3 +213,12 @@ export class EventRule extends Resource implements IEventRule { return out; } } + +/** + * Sanitize whatever is returned to make a valid ID + * + * Result must match regex [\.\-_A-Za-z0-9]+ + */ +function sanitizeId(id: string) { + return id.replace(/[^\.\-_A-Za-z0-9]/g, '-'); +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-events/lib/target.ts b/packages/@aws-cdk/aws-events/lib/target.ts index f885562103f5c..4c77425fc7865 100644 --- a/packages/@aws-cdk/aws-events/lib/target.ts +++ b/packages/@aws-cdk/aws-events/lib/target.ts @@ -1,6 +1,25 @@ +import iam = require('@aws-cdk/aws-iam'); import { CfnRule } from './events.generated'; +import { RuleTargetInput } from './input'; +import { IRule } from './rule-ref'; -export interface EventRuleTargetProps { +/** + * An abstract target for EventRules. + */ +export interface IRuleTarget { + /** + * Returns the rule target specification. + * NOTE: Do not use the various `inputXxx` options. They can be set in a call to `addTarget`. + * + * @param rule The CloudWatch Event Rule that would trigger this target. + */ + bind(rule: IRule): RuleTargetProperties; +} + +/** + * Properties for an event rule target + */ +export interface RuleTargetProperties { /** * A unique, user-defined identifier for the target. Acceptable values * include alphanumeric characters, periods (.), hyphens (-), and @@ -14,12 +33,9 @@ export interface EventRuleTargetProps { readonly arn: string; /** - * The Amazon Resource Name (ARN) of the AWS Identity and Access Management - * (IAM) role to use for this target when the rule is triggered. If one rule - * triggers multiple targets, you can use a different IAM role for each - * target. + * Role to use to invoke this event target */ - readonly roleArn?: string; + readonly role?: iam.IRole; /** * The Amazon ECS task definition and task count to use, if the event target @@ -39,18 +55,11 @@ export interface EventRuleTargetProps { * Command. */ readonly runCommandParameters?: CfnRule.RunCommandParametersProperty; -} -/** - * An abstract target for EventRules. - */ -export interface IEventRuleTarget { /** - * Returns the rule target specification. - * NOTE: Do not use the various `inputXxx` options. They can be set in a call to `addTarget`. + * What input to send to the event target * - * @param ruleArn The ARN of the CloudWatch Event Rule that would trigger this target. - * @param ruleUniqueId A unique ID for this rule. Can be used to implement idempotency. + * @default the entire event */ - asEventRuleTarget(ruleArn: string, ruleUniqueId: string): EventRuleTargetProps; -} + readonly input?: RuleTargetInput; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-events/package.json b/packages/@aws-cdk/aws-events/package.json index cb6df48a95b88..710cad820a06f 100644 --- a/packages/@aws-cdk/aws-events/package.json +++ b/packages/@aws-cdk/aws-events/package.json @@ -75,5 +75,11 @@ }, "engines": { "node": ">= 8.10.0" + }, + "awslint": { + "exclude": [ + "from-method:@aws-cdk/aws-events.Rule" + ] } + } diff --git a/packages/@aws-cdk/aws-events/test/test.input.ts b/packages/@aws-cdk/aws-events/test/test.input.ts new file mode 100644 index 0000000000000..8ca4b6659d787 --- /dev/null +++ b/packages/@aws-cdk/aws-events/test/test.input.ts @@ -0,0 +1,110 @@ +import { expect, haveResourceLike } from '@aws-cdk/assert'; +import cdk = require('@aws-cdk/cdk'); +import { Stack } from '@aws-cdk/cdk'; +import { Test } from 'nodeunit'; +import { IRuleTarget, RuleTargetInput } from '../lib'; +import { Rule } from '../lib/rule'; + +export = { + 'json template': { + 'can just be a JSON object'(test: Test) { + // GIVEN + const stack = new Stack(); + const rule = new Rule(stack, 'Rule', { + scheduleExpression: 'rate(1 minute)' + }); + + // WHEN + rule.addTarget(new SomeTarget(RuleTargetInput.fromObject({ SomeObject: 'withAValue' }))); + + // THEN + expect(stack).to(haveResourceLike('AWS::Events::Rule', { + Targets: [ + { + Input: "{\"SomeObject\":\"withAValue\"}" + } + ] + })); + test.done(); + }, + }, + + 'text templates': { + 'strings with newlines are serialized to a newline-delimited list of JSON strings'(test: Test) { + // GIVEN + const stack = new Stack(); + const rule = new Rule(stack, 'Rule', { + scheduleExpression: 'rate(1 minute)' + }); + + // WHEN + rule.addTarget(new SomeTarget(RuleTargetInput.fromMultilineText('I have\nmultiple lines'))); + + // THEN + expect(stack).to(haveResourceLike('AWS::Events::Rule', { + Targets: [ + { + Input: "\"I have\"\n\"multiple lines\"" + } + ] + })); + + test.done(); + }, + + 'escaped newlines are not interpreted as newlines'(test: Test) { + // GIVEN + const stack = new Stack(); + const rule = new Rule(stack, 'Rule', { + scheduleExpression: 'rate(1 minute)' + }); + + // WHEN + rule.addTarget(new SomeTarget(RuleTargetInput.fromMultilineText('this is not\\na real newline'))), + + // THEN + expect(stack).to(haveResourceLike('AWS::Events::Rule', { + Targets: [ + { + Input: "\"this is not\\\\na real newline\"" + } + ] + })); + + test.done(); + }, + + 'can use Tokens in text templates'(test: Test) { + // GIVEN + const stack = new Stack(); + const rule = new Rule(stack, 'Rule', { + scheduleExpression: 'rate(1 minute)' + }); + + const world = new cdk.Token(() => 'world'); + + // WHEN + rule.addTarget(new SomeTarget(RuleTargetInput.fromText(`hello ${world}`))); + + // THEN + expect(stack).to(haveResourceLike('AWS::Events::Rule', { + Targets: [ + { + Input: "\"hello world\"" + } + ] + })); + + test.done(); + } + }, +}; + +class SomeTarget implements IRuleTarget { + public constructor(private readonly input: RuleTargetInput) { + } + + public bind() { + return { id: 'T1', arn: 'ARN1', input: this.input }; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-events/test/test.rule.ts b/packages/@aws-cdk/aws-events/test/test.rule.ts index 185c29b4763b3..352bb814d98c5 100644 --- a/packages/@aws-cdk/aws-events/test/test.rule.ts +++ b/packages/@aws-cdk/aws-events/test/test.rule.ts @@ -1,9 +1,11 @@ import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; +import iam = require('@aws-cdk/aws-iam'); +import { ServicePrincipal } from '@aws-cdk/aws-iam'; import cdk = require('@aws-cdk/cdk'); import { Stack } from '@aws-cdk/cdk'; import { Test } from 'nodeunit'; -import { IEventRuleTarget } from '../lib'; -import { EventRule } from '../lib/rule'; +import { EventField, IRule, IRuleTarget, RuleTargetInput } from '../lib'; +import { Rule } from '../lib/rule'; // tslint:disable:object-literal-key-quotes @@ -11,7 +13,7 @@ export = { 'default rule'(test: Test) { const stack = new cdk.Stack(); - new EventRule(stack, 'MyRule', { + new Rule(stack, 'MyRule', { scheduleExpression: 'rate(10 minutes)' }); @@ -34,7 +36,7 @@ export = { const stack = new cdk.Stack(); // WHEN - new EventRule(stack, 'MyRule', { + new Rule(stack, 'MyRule', { ruleName: 'PhysicalName', scheduleExpression: 'rate(10 minutes)' }); @@ -50,7 +52,7 @@ export = { 'eventPattern is rendered properly'(test: Test) { const stack = new cdk.Stack(); - new EventRule(stack, 'MyRule', { + new Rule(stack, 'MyRule', { eventPattern: { account: [ 'account1', 'account2' ], detail: { @@ -94,7 +96,7 @@ export = { 'fails synthesis if neither eventPattern nor scheudleExpression are specified'(test: Test) { const app = new cdk.App(); const stack = new cdk.Stack(app, 'MyStack'); - new EventRule(stack, 'Rule'); + new Rule(stack, 'Rule'); test.throws(() => app.synthesizeStack(stack.name), /Either 'eventPattern' or 'scheduleExpression' must be defined/); test.done(); }, @@ -102,7 +104,7 @@ export = { 'addEventPattern can be used to add filters'(test: Test) { const stack = new cdk.Stack(); - const rule = new EventRule(stack, 'MyRule'); + const rule = new Rule(stack, 'MyRule'); rule.addEventPattern({ account: [ '12345' ], detail: { @@ -154,33 +156,28 @@ export = { 'targets can be added via props or addTarget with input transformer'(test: Test) { const stack = new cdk.Stack(); - const t1: IEventRuleTarget = { - asEventRuleTarget: () => ({ + const t1: IRuleTarget = { + bind: () => ({ id: 'T1', arn: 'ARN1', kinesisParameters: { partitionKeyPath: 'partitionKeyPath' } }) }; - const t2: IEventRuleTarget = { - asEventRuleTarget: () => ({ + const t2: IRuleTarget = { + bind: () => ({ id: 'T2', arn: 'ARN2', - roleArn: 'IAM-ROLE-ARN' + input: RuleTargetInput.fromText(`This is ${EventField.fromPath('$.detail.bla', 'bla')}`), }) }; - const rule = new EventRule(stack, 'EventRule', { + const rule = new Rule(stack, 'EventRule', { targets: [ t1 ], scheduleExpression: 'rate(5 minutes)' }); - rule.addTarget(t2, { - textTemplate: 'This is ', - pathsMap: { - bla: '$.detail.bla' - } - }); + rule.addTarget(t2); expect(stack).toMatch({ "Resources": { @@ -206,7 +203,6 @@ export = { }, "InputTemplate": "\"This is \"" }, - "RoleArn": "IAM-ROLE-ARN" } ] } @@ -218,41 +214,39 @@ export = { 'input template can contain tokens'(test: Test) { const stack = new cdk.Stack(); - const t1: IEventRuleTarget = { - asEventRuleTarget: () => ({ - id: 'T1', arn: 'ARN1', kinesisParameters: { partitionKeyPath: 'partitionKeyPath' } - }) - }; - - const t2: IEventRuleTarget = { asEventRuleTarget: () => ({ id: 'T2', arn: 'ARN2', roleArn: 'IAM-ROLE-ARN' }) }; - const t3: IEventRuleTarget = { asEventRuleTarget: () => ({ id: 'T3', arn: 'ARN3' }) }; - const t4: IEventRuleTarget = { asEventRuleTarget: () => ({ id: 'T4', arn: 'ARN4' }) }; - const rule = new EventRule(stack, 'EventRule', { scheduleExpression: 'rate(1 minute)' }); + const rule = new Rule(stack, 'EventRule', { scheduleExpression: 'rate(1 minute)' }); // a plain string should just be stringified (i.e. double quotes added and escaped) - rule.addTarget(t2, { - textTemplate: 'Hello, "world"' + rule.addTarget({ + bind: () => ({ + id: 'T2', arn: 'ARN2', input: RuleTargetInput.fromText('Hello, "world"') + }) }); // tokens are used here (FnConcat), but this is a text template so we // expect it to be wrapped in double quotes automatically for us. - rule.addTarget(t1, { - textTemplate: cdk.Fn.join('', [ 'a', 'b' ]).toString() + rule.addTarget({ + bind: () => ({ + id: 'T1', arn: 'ARN1', kinesisParameters: { partitionKeyPath: 'partitionKeyPath' }, + input: RuleTargetInput.fromText(cdk.Fn.join('', [ 'a', 'b' ]).toString()), + }) }); // jsonTemplate can be used to format JSON documents with replacements - rule.addTarget(t3, { - jsonTemplate: '{ "foo": }', - pathsMap: { - bar: '$.detail.bar' - } + rule.addTarget({ + bind: () => ({ + id: 'T3', arn: 'ARN3', + input: RuleTargetInput.fromObject({ foo: EventField.fromPath('$.detail.bar') }), + }) }); - // tokens can also used for JSON templates, but that means escaping is - // the responsibility of the user. - rule.addTarget(t4, { - jsonTemplate: cdk.Fn.join(' ', ['"', 'hello', '\"world\"', '"']), + // tokens can also used for JSON templates. + rule.addTarget({ + bind: () => ({ + id: 'T4', arn: 'ARN4', + input: RuleTargetInput.fromText(cdk.Fn.join(' ', ['hello', '"world"']).toString()), + }) }); expect(stack).toMatch({ @@ -264,39 +258,32 @@ export = { "ScheduleExpression": "rate(1 minute)", "Targets": [ { - "Arn": "ARN2", - "Id": "T2", - "InputTransformer": { - "InputTemplate": "\"Hello, \\\"world\\\"\"" - }, - "RoleArn": "IAM-ROLE-ARN" + "Arn": "ARN2", + "Id": "T2", + "Input": '"Hello, \\"world\\""', }, { - "Arn": "ARN1", - "Id": "T1", - "InputTransformer": { - "InputTemplate": "\"ab\"" - }, - "KinesisParameters": { - "PartitionKeyPath": "partitionKeyPath" - } + "Arn": "ARN1", + "Id": "T1", + "Input": "\"ab\"", + "KinesisParameters": { + "PartitionKeyPath": "partitionKeyPath" + } }, { - "Arn": "ARN3", - "Id": "T3", - "InputTransformer": { - "InputPathsMap": { - "bar": "$.detail.bar" - }, - "InputTemplate": "{ \"foo\": }" - } + "Arn": "ARN3", + "Id": "T3", + "InputTransformer": { + "InputPathsMap": { + "f1": "$.detail.bar" + }, + "InputTemplate": "{\"foo\":}" + } }, { - "Arn": "ARN4", - "Id": "T4", - "InputTransformer": { - "InputTemplate": "\" hello \"world\" \"" - } + "Arn": "ARN4", + "Id": "T4", + "Input": '"hello \\"world\\""' } ] } @@ -307,16 +294,49 @@ export = { test.done(); }, + 'target can declare role which will be used'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + const rule = new Rule(stack, 'EventRule', { scheduleExpression: 'rate(1 minute)' }); + + const role = new iam.Role(stack, 'SomeRole', { + assumedBy: new ServicePrincipal('nobody') + }); + + // a plain string should just be stringified (i.e. double quotes added and escaped) + rule.addTarget({ + bind: () => ({ + id: 'T2', + arn: 'ARN2', + role, + }) + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::Events::Rule', { + "Targets": [ + { + "Arn": "ARN2", + "Id": "T2", + "RoleArn": {"Fn::GetAtt": ["SomeRole6DDC54DD", "Arn"]} + } + ] + })); + + test.done(); + }, + 'asEventRuleTarget can use the ruleArn and a uniqueId of the rule'(test: Test) { const stack = new cdk.Stack(); let receivedRuleArn = 'FAIL'; let receivedRuleId = 'FAIL'; - const t1: IEventRuleTarget = { - asEventRuleTarget: (ruleArn: string, ruleId: string) => { - receivedRuleArn = ruleArn; - receivedRuleId = ruleId; + const t1: IRuleTarget = { + bind: (eventRule: IRule) => { + receivedRuleArn = eventRule.ruleArn; + receivedRuleId = eventRule.node.uniqueId; return { id: 'T1', @@ -326,7 +346,7 @@ export = { } }; - const rule = new EventRule(stack, 'EventRule'); + const rule = new Rule(stack, 'EventRule'); rule.addTarget(t1); test.deepEqual(stack.node.resolve(receivedRuleArn), stack.node.resolve(rule.ruleArn)); @@ -339,128 +359,19 @@ export = { const stack = new Stack(); // WHEN - const importedRule = EventRule.fromEventRuleArn(stack, 'ImportedRule', 'arn:of:rule'); + const importedRule = Rule.fromEventRuleArn(stack, 'ImportedRule', 'arn:of:rule'); // THEN test.deepEqual(importedRule.ruleArn, 'arn:of:rule'); test.done(); }, - 'json template': { - 'can just be a JSON object'(test: Test) { - // GIVEN - const stack = new Stack(); - const rule = new EventRule(stack, 'Rule', { - scheduleExpression: 'rate(1 minute)' - }); - - // WHEN - rule.addTarget(new SomeTarget(), { - jsonTemplate: { SomeObject: 'withAValue' }, - }); - - // THEN - expect(stack).to(haveResourceLike('AWS::Events::Rule', { - Targets: [ - { - InputTransformer: { - InputTemplate: "{\"SomeObject\":\"withAValue\"}" - }, - } - ] - })); - test.done(); - }, - }, - - 'text templates': { - 'strings with newlines are serialized to a newline-delimited list of JSON strings'(test: Test) { - // GIVEN - const stack = new Stack(); - const rule = new EventRule(stack, 'Rule', { - scheduleExpression: 'rate(1 minute)' - }); - - // WHEN - rule.addTarget(new SomeTarget(), { - textTemplate: 'I have\nmultiple lines', - }); - - // THEN - expect(stack).to(haveResourceLike('AWS::Events::Rule', { - Targets: [ - { - InputTransformer: { - InputTemplate: "\"I have\"\n\"multiple lines\"" - }, - } - ] - })); - - test.done(); - }, - - 'escaped newlines are not interpreted as newlines'(test: Test) { - // GIVEN - const stack = new Stack(); - const rule = new EventRule(stack, 'Rule', { - scheduleExpression: 'rate(1 minute)' - }); - - // WHEN - rule.addTarget(new SomeTarget(), { - textTemplate: 'this is not\\na real newline', - }); - - // THEN - expect(stack).to(haveResourceLike('AWS::Events::Rule', { - Targets: [ - { - InputTransformer: { - InputTemplate: "\"this is not\\\\na real newline\"" - }, - } - ] - })); - - test.done(); - }, - - 'can use Tokens in text templates'(test: Test) { - // GIVEN - const stack = new Stack(); - const rule = new EventRule(stack, 'Rule', { - scheduleExpression: 'rate(1 minute)' - }); - - const world = new cdk.Token(() => 'world'); - - // WHEN - rule.addTarget(new SomeTarget(), { - textTemplate: `hello ${world}`, - }); - - // THEN - expect(stack).to(haveResourceLike('AWS::Events::Rule', { - Targets: [ - { - InputTransformer: { - InputTemplate: "\"hello world\"" - }, - } - ] - })); - - test.done(); - } - }, - 'rule can be disabled'(test: Test) { // GIVEN const stack = new cdk.Stack(); // WHEN - new EventRule(stack, 'Rule', { + new Rule(stack, 'Rule', { scheduleExpression: 'foom', enabled: false }); @@ -476,7 +387,7 @@ export = { 'fails if multiple targets with the same id are added'(test: Test) { // GIVEN const stack = new cdk.Stack(); - const rule = new EventRule(stack, 'Rule', { + const rule = new Rule(stack, 'Rule', { scheduleExpression: 'foom', enabled: false }); @@ -488,8 +399,8 @@ export = { } }; -class SomeTarget implements IEventRuleTarget { - public asEventRuleTarget() { +class SomeTarget implements IRuleTarget { + public bind() { return { id: 'T1', arn: 'ARN1', kinesisParameters: { partitionKeyPath: 'partitionKeyPath' } }; diff --git a/packages/@aws-cdk/aws-iam/lib/policy-document.ts b/packages/@aws-cdk/aws-iam/lib/policy-document.ts index bbf569f605518..2af30ce541d22 100644 --- a/packages/@aws-cdk/aws-iam/lib/policy-document.ts +++ b/packages/@aws-cdk/aws-iam/lib/policy-document.ts @@ -23,7 +23,7 @@ export class PolicyDocument extends cdk.Token implements cdk.IResolvedValuePostP this._autoAssignSids = true; } - public resolve(_context: cdk.ResolveContext): any { + public resolve(_context: cdk.IResolveContext): any { if (this.isEmpty) { return undefined; } @@ -40,7 +40,7 @@ export class PolicyDocument extends cdk.Token implements cdk.IResolvedValuePostP /** * Removes duplicate statements */ - public postProcess(input: any, _context: cdk.ResolveContext): any { + public postProcess(input: any, _context: cdk.IResolveContext): any { if (!input || !input.Statement) { return input; } @@ -515,7 +515,7 @@ export class PolicyStatement extends cdk.Token { // // Serialization // - public resolve(_context: cdk.ResolveContext): any { + public resolve(_context: cdk.IResolveContext): any { return this.toJson(); } @@ -591,7 +591,7 @@ class StackDependentToken extends cdk.Token { super(); } - public resolve(context: cdk.ResolveContext) { + public resolve(context: cdk.IResolveContext) { return this.fn(context.scope.node.stack); } } @@ -602,7 +602,7 @@ class ServicePrincipalToken extends cdk.Token { super(); } - public resolve(ctx: cdk.ResolveContext) { + public resolve(ctx: cdk.IResolveContext) { const region = this.opts.region || ctx.scope.node.stack.region; const fact = RegionInfo.get(region).servicePrincipal(this.service); return fact || Default.servicePrincipal(this.service, region, ctx.scope.node.stack.urlSuffix); diff --git a/packages/@aws-cdk/aws-s3/lib/bucket.ts b/packages/@aws-cdk/aws-s3/lib/bucket.ts index 0fca2c2e13a64..1774c650827df 100644 --- a/packages/@aws-cdk/aws-s3/lib/bucket.ts +++ b/packages/@aws-cdk/aws-s3/lib/bucket.ts @@ -176,9 +176,9 @@ export interface IBucket extends IResource { * @param name the logical ID of the newly created Event Rule * @param target the optional target of the Event Rule * @param path the optional path inside the Bucket that will be watched for changes - * @returns a new {@link events.EventRule} instance + * @returns a new {@link events.Rule} instance */ - onPutObject(name: string, target?: events.IEventRuleTarget, path?: string): events.EventRule; + onPutObject(name: string, target?: events.IRuleTarget, path?: string): events.Rule; } /** @@ -283,8 +283,8 @@ abstract class BucketBase extends Resource implements IBucket { */ protected abstract disallowPublicAccess?: boolean; - public onPutObject(name: string, target?: events.IEventRuleTarget, path?: string): events.EventRule { - const eventRule = new events.EventRule(this, name, { + public onPutObject(name: string, target?: events.IRuleTarget, path?: string): events.Rule { + const eventRule = new events.Rule(this, name, { eventPattern: { source: [ 'aws.s3', diff --git a/packages/@aws-cdk/aws-s3/test/test.bucket.ts b/packages/@aws-cdk/aws-s3/test/test.bucket.ts index 344d1dfc10da9..4203b96916346 100644 --- a/packages/@aws-cdk/aws-s3/test/test.bucket.ts +++ b/packages/@aws-cdk/aws-s3/test/test.bucket.ts @@ -29,6 +29,19 @@ export = { test.done(); }, + 'CFN properties are type-validated during resolution'(test: Test) { + const stack = new cdk.Stack(); + new s3.Bucket(stack, 'MyBucket', { + bucketName: new cdk.Token(() => 5).toString() // Oh no + }); + + test.throws(() => { + SynthUtils.toCloudFormation(stack); + }, /bucketName: 5 should be a string/); + + test.done(); + }, + 'bucket without encryption'(test: Test) { const stack = new cdk.Stack(); new s3.Bucket(stack, 'MyBucket', { diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/run-ecs-ec2-task.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/run-ecs-ec2-task.ts index 0c07cfc636601..ce32e3f94d4f2 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/run-ecs-ec2-task.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/run-ecs-ec2-task.ts @@ -66,7 +66,7 @@ export class RunEcsEc2Task extends EcsRunTaskBase { }); if (props.taskDefinition.networkMode === ecs.NetworkMode.AwsVpc) { - this.configureAwsVpcNetworking(props.cluster.vpc, false, props.subnets, props.securityGroup); + this.configureAwsVpcNetworking(props.cluster.vpc, undefined, props.subnets, props.securityGroup); } else { // Either None, Bridge or Host networking. Copy SecurityGroup from ASG. validateNoNetworkingProps(props); diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/run-ecs-task-base.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/run-ecs-task-base.ts index 6a4544016cd07..0e87b79c83720 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/run-ecs-task-base.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/run-ecs-task-base.ts @@ -110,7 +110,7 @@ export class EcsRunTaskBase implements ec2.IConnectable, sfn.IStepFunctionsTask this.networkConfiguration = { AwsvpcConfiguration: { - AssignPublicIp: assignPublicIp ? 'ENABLED' : 'DISABLED', + AssignPublicIp: assignPublicIp !== undefined ? (assignPublicIp ? 'ENABLED' : 'DISABLED') : undefined, Subnets: vpc.selectSubnets(subnetSelection).subnetIds, SecurityGroups: new cdk.Token(() => [this.securityGroup!.securityGroupId]), } diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/ecs-tasks.test.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/test/ecs-tasks.test.ts index bfeb535a7d138..90f44b6ab016f 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/test/ecs-tasks.test.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/ecs-tasks.test.ts @@ -57,7 +57,6 @@ test('Running a Fargate Task', () => { LaunchType: "FARGATE", NetworkConfiguration: { AwsvpcConfiguration: { - AssignPublicIp: "DISABLED", SecurityGroups: [{"Fn::GetAtt": ["RunFargateSecurityGroup709740F2", "GroupId"]}], Subnets: [ {Ref: "VpcPrivateSubnet1Subnet536B997A"}, diff --git a/packages/@aws-cdk/aws-stepfunctions/lib/state-machine.ts b/packages/@aws-cdk/aws-stepfunctions/lib/state-machine.ts index fb53a145dbf1c..30dee9d4a9e0b 100644 --- a/packages/@aws-cdk/aws-stepfunctions/lib/state-machine.ts +++ b/packages/@aws-cdk/aws-stepfunctions/lib/state-machine.ts @@ -1,5 +1,4 @@ import cloudwatch = require('@aws-cdk/aws-cloudwatch'); -import events = require('@aws-cdk/aws-events'); import iam = require('@aws-cdk/aws-iam'); import { Construct, IResource, Resource } from '@aws-cdk/cdk'; import { StateGraph } from './state-graph'; @@ -40,7 +39,7 @@ export interface StateMachineProps { /** * Define a StepFunctions State Machine */ -export class StateMachine extends Resource implements IStateMachine, events.IEventRuleTarget { +export class StateMachine extends Resource implements IStateMachine { /** * Import a state machine */ @@ -68,11 +67,6 @@ export class StateMachine extends Resource implements IStateMachine, events.IEve */ public readonly stateMachineArn: string; - /** - * A role used by CloudWatch events to start the State Machine - */ - private eventsRole?: iam.Role; - constructor(scope: Construct, id: string, props: StateMachineProps) { super(scope, id); @@ -104,27 +98,6 @@ export class StateMachine extends Resource implements IStateMachine, events.IEve this.role.addToPolicy(statement); } - /** - * Allows using state machines as event rule targets. - */ - public asEventRuleTarget(_ruleArn: string, _ruleId: string): events.EventRuleTargetProps { - if (!this.eventsRole) { - this.eventsRole = new iam.Role(this, 'EventsRole', { - assumedBy: new iam.ServicePrincipal('events.amazonaws.com') - }); - - this.eventsRole.addToPolicy(new iam.PolicyStatement() - .addAction('states:StartExecution') - .addResource(this.stateMachineArn)); - } - - return { - id: this.node.id, - arn: this.stateMachineArn, - roleArn: this.eventsRole.roleArn, - }; - } - /** * Return the given named metric for this State Machine's executions * diff --git a/packages/@aws-cdk/aws-stepfunctions/test/test.state-machine-resources.ts b/packages/@aws-cdk/aws-stepfunctions/test/test.state-machine-resources.ts index 998428b9f7d02..718937305faa8 100644 --- a/packages/@aws-cdk/aws-stepfunctions/test/test.state-machine-resources.ts +++ b/packages/@aws-cdk/aws-stepfunctions/test/test.state-machine-resources.ts @@ -1,5 +1,4 @@ -import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; -import events = require('@aws-cdk/aws-events'); +import { expect, haveResource } from '@aws-cdk/assert'; import iam = require('@aws-cdk/aws-iam'); import cdk = require('@aws-cdk/cdk'); import { Test } from 'nodeunit'; @@ -130,32 +129,4 @@ export = { test.done(); }, - 'State machine can be used as Event Rule target'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - const rule = new events.EventRule(stack, 'Rule', { - scheduleExpression: 'rate(1 minute)' - }); - const stateMachine = new stepfunctions.StateMachine(stack, 'SM', { - definition: new stepfunctions.Wait(stack, 'Hello', { duration: stepfunctions.WaitDuration.seconds(10) }) - }); - - // WHEN - rule.addTarget(stateMachine, { - jsonTemplate: { SomeParam: 'SomeValue' }, - }); - - // THEN - expect(stack).to(haveResourceLike('AWS::Events::Rule', { - Targets: [ - { - InputTransformer: { - InputTemplate: "{\"SomeParam\":\"SomeValue\"}" - }, - } - ] - })); - - test.done(); - }, }; \ No newline at end of file diff --git a/packages/@aws-cdk/cdk/lib/cfn-concat.ts b/packages/@aws-cdk/cdk/lib/cfn-concat.ts deleted file mode 100644 index fd16ed1046710..0000000000000 --- a/packages/@aws-cdk/cdk/lib/cfn-concat.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Produce a CloudFormation expression to concat two arbitrary expressions when resolving - */ -export function cloudFormationConcat(left: any | undefined, right: any | undefined): any { - if (left === undefined && right === undefined) { return ''; } - - const parts = new Array(); - if (left !== undefined) { parts.push(left); } - if (right !== undefined) { parts.push(right); } - - // Some case analysis to produce minimal expressions - if (parts.length === 1) { return parts[0]; } - if (parts.length === 2 && typeof parts[0] === 'string' && typeof parts[1] === 'string') { - return parts[0] + parts[1]; - } - - // Otherwise return a Join intrinsic (already in the target document language to avoid taking - // circular dependencies on FnJoin & friends) - return { 'Fn::Join': ['', minimalCloudFormationJoin('', parts)] }; -} - -import { minimalCloudFormationJoin } from "./instrinsics"; diff --git a/packages/@aws-cdk/cdk/lib/cfn-condition.ts b/packages/@aws-cdk/cdk/lib/cfn-condition.ts index 275db39e1207b..94714877b53b1 100644 --- a/packages/@aws-cdk/cdk/lib/cfn-condition.ts +++ b/packages/@aws-cdk/cdk/lib/cfn-condition.ts @@ -1,6 +1,6 @@ import { CfnRefElement } from './cfn-element'; import { Construct } from './construct'; -import { ResolveContext } from './token'; +import { IResolveContext } from './token'; export interface CfnConditionProps { readonly expression?: ICfnConditionExpression; @@ -43,7 +43,7 @@ export class CfnCondition extends CfnRefElement implements ICfnConditionExpressi /** * Synthesizes the condition. */ - public resolve(_context: ResolveContext): any { + public resolve(_context: IResolveContext): any { return { Condition: this.logicalId }; } } @@ -76,7 +76,7 @@ export interface ICfnConditionExpression { /** * Returns a JSON node that represents this condition expression */ - resolve(context: ResolveContext): any; + resolve(context: IResolveContext): any; /** * Returns a string token representation of this condition expression, which diff --git a/packages/@aws-cdk/cdk/lib/cfn-element.ts b/packages/@aws-cdk/cdk/lib/cfn-element.ts index 8d728fc07cdf8..1ac6e1c1540c7 100644 --- a/packages/@aws-cdk/cdk/lib/cfn-element.ts +++ b/packages/@aws-cdk/cdk/lib/cfn-element.ts @@ -46,7 +46,7 @@ export abstract class CfnElement extends Construct { this.node.addMetadata(LOGICAL_ID_MD, new (require("./token").Token)(() => this.logicalId), this.constructor); this._logicalId = this.node.stack.logicalIds.getLogicalId(this); - this.logicalId = new Token(() => this._logicalId).toString(); + this.logicalId = new Token(() => this._logicalId, `${notTooLong(this.node.path)}.LogicalID`).toString(); } /** @@ -147,10 +147,15 @@ export abstract class CfnRefElement extends CfnElement { /** * Return a token that will CloudFormation { Ref } this stack element */ - protected get referenceToken(): Token { - return new CfnReference({ Ref: this.logicalId }, 'Ref', this); + public get referenceToken(): Token { + return CfnReference.for(this, 'Ref'); } } +function notTooLong(x: string) { + if (x.length < 100) { return x; } + return x.substr(0, 47) + '...' + x.substr(x.length - 47); +} + import { CfnReference } from "./cfn-reference"; import { findTokens } from "./resolve"; diff --git a/packages/@aws-cdk/cdk/lib/cfn-reference.ts b/packages/@aws-cdk/cdk/lib/cfn-reference.ts index 24cb71b37aba9..4589116757e22 100644 --- a/packages/@aws-cdk/cdk/lib/cfn-reference.ts +++ b/packages/@aws-cdk/cdk/lib/cfn-reference.ts @@ -24,6 +24,55 @@ export class CfnReference extends Reference { return (x as any)[CFN_REFERENCE_SYMBOL] === true; } + /** + * Return the CfnReference for the indicated target + * + * Will make sure that multiple invocations for the same target and intrinsic + * return the same CfnReference. Because CfnReferences accumulate state in + * the prepare() phase (for the purpose of cross-stack references), it's + * important that the state isn't lost if it's lazily created, like so: + * + * new Token(() => new CfnReference(...)) + */ + public static for(target: CfnRefElement, attribute: string) { + return CfnReference.singletonReference(target, attribute, () => { + const cfnInstrinsic = attribute === 'Ref' ? { Ref: target.logicalId } : { 'Fn::GetAtt': [ target.logicalId, attribute ]}; + return new CfnReference(cfnInstrinsic, attribute, target); + }); + } + + /** + * Return a CfnReference that references a pseudo referencd + */ + public static forPseudo(pseudoName: string, scope: Construct) { + return CfnReference.singletonReference(scope, `Pseudo:${pseudoName}`, () => { + const cfnInstrinsic = { Ref: pseudoName }; + return new CfnReference(cfnInstrinsic, pseudoName, scope); + }); + } + + /** + * Static table where we keep singleton CfnReference instances + */ + private static referenceTable = new Map>(); + + /** + * Get or create the table + */ + private static singletonReference(target: Construct, attribKey: string, fresh: () => CfnReference) { + let attribs = CfnReference.referenceTable.get(target); + if (!attribs) { + attribs = new Map(); + CfnReference.referenceTable.set(target, attribs); + } + let ref = attribs.get(attribKey); + if (!ref) { + ref = fresh(); + attribs.set(attribKey, ref); + } + return ref; + } + /** * What stack this Token is pointing to */ @@ -36,9 +85,9 @@ export class CfnReference extends Reference { private readonly originalDisplayName: string; - constructor(value: any, displayName: string, target: Construct) { + private constructor(value: any, displayName: string, target: Construct) { if (typeof(value) === 'function') { - throw new Error('Reference can only hold CloudFormation intrinsics (not a function)'); + throw new Error('Reference can only hold CloudFormation intrinsics (not a function)'); } // prepend scope path to display name super(value, `${target.node.id}.${displayName}`, target); @@ -49,7 +98,7 @@ export class CfnReference extends Reference { Object.defineProperty(this, CFN_REFERENCE_SYMBOL, { value: true }); } - public resolve(context: ResolveContext): any { + public resolve(context: IResolveContext): any { // If we have a special token for this consuming stack, resolve that. Otherwise resolve as if // we are in the same stack. const token = this.replacementTokens.get(context.scope.node.stack); @@ -64,6 +113,7 @@ export class CfnReference extends Reference { * Register a stack this references is being consumed from. */ public consumeFromStack(consumingStack: Stack, consumingConstruct: IConstruct) { + // tslint:disable-next-line:max-line-length if (this.producingStack && this.producingStack !== consumingStack && !this.replacementTokens.has(consumingStack)) { // We're trying to resolve a cross-stack reference consumingStack.addDependency(this.producingStack, `${consumingConstruct.node.path} -> ${this.target.node.path}.${this.originalDisplayName}`); @@ -106,10 +156,10 @@ export class CfnReference extends Reference { // so construct one in-place. return new Token({ 'Fn::ImportValue': output.obtainExportName() }); } - } +import { CfnRefElement } from "./cfn-element"; import { CfnOutput } from "./cfn-output"; import { Construct, IConstruct } from "./construct"; import { Stack } from "./stack"; -import { ResolveContext, Token } from "./token"; +import { IResolveContext, Token } from "./token"; diff --git a/packages/@aws-cdk/cdk/lib/cfn-resource.ts b/packages/@aws-cdk/cdk/lib/cfn-resource.ts index bcf6ba2f380e9..e738818e49194 100644 --- a/packages/@aws-cdk/cdk/lib/cfn-resource.ts +++ b/packages/@aws-cdk/cdk/lib/cfn-resource.ts @@ -133,7 +133,7 @@ export class CfnResource extends CfnRefElement { * @param attributeName The name of the attribute. */ public getAtt(attributeName: string) { - return new CfnReference({ 'Fn::GetAtt': [this.logicalId, attributeName] }, attributeName, this); + return CfnReference.for(this, attributeName); } /** @@ -211,13 +211,13 @@ export class CfnResource extends CfnRefElement { try { // merge property overrides onto properties and then render (and validate). const tags = CfnResource.isTaggable(this) ? this.tags.renderTags() : undefined; - const properties = this.renderProperties(deepMerge( + const properties = deepMerge( this.properties || {}, { tags }, this.untypedPropertyOverrides - )); + ); - return { + const ret = { Resources: { // Post-Resolve operation since otherwise deepMerge is going to mix values into // the Token objects returned by ignoreEmpty. @@ -231,9 +231,14 @@ export class CfnResource extends CfnRefElement { DeletionPolicy: capitalizePropertyNames(this, this.options.deletionPolicy), Metadata: ignoreEmpty(this.options.metadata), Condition: this.options.condition && this.options.condition.logicalId - }, props => deepMerge(props, this.rawOverrides)) + }, props => { + const r = deepMerge(props, this.rawOverrides); + r.Properties = this.renderProperties(r.Properties); + return r; + }) } }; + return ret; } catch (e) { // Change message e.message = `While synthesizing ${this.node.path}: ${e.message}`; @@ -258,6 +263,10 @@ export class CfnResource extends CfnRefElement { protected renderProperties(properties: any): { [key: string]: any } { return properties; } + + protected validateProperties(_properties: any) { + // Nothing + } } export enum TagType { diff --git a/packages/@aws-cdk/cdk/lib/cloudformation-json.ts b/packages/@aws-cdk/cdk/lib/cloudformation-json.ts deleted file mode 100644 index 4f0d79a5d2924..0000000000000 --- a/packages/@aws-cdk/cdk/lib/cloudformation-json.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { IConstruct } from "./construct"; -import { isIntrinsic } from "./instrinsics"; -import { resolve } from "./resolve"; -import { Token } from "./token"; - -/** - * Class for JSON routines that are framework-aware - */ -export class CloudFormationJSON { - /** - * Turn an arbitrary structure potentially containing Tokens into a JSON string. - * - * Returns a Token which will evaluate to CloudFormation expression that - * will be evaluated by CloudFormation to the JSON representation of the - * input structure. - * - * All Tokens substituted in this way must return strings, or the evaluation - * in CloudFormation will fail. - * - * @param obj The object to stringify - * @param context The Construct from which to resolve any Tokens found in the object - */ - public static stringify(obj: any, context: IConstruct): string { - return new Token(() => { - // Resolve inner value first so that if they evaluate to literals, we - // maintain the type (and discard 'undefined's). - // - // Then replace intrinsics with a special subclass of Token that - // overrides toJSON() to the marker string, so if we resolve() the - // strings again it evaluates to the right string. It also - // deep-escapes any strings inside the intrinsic, so that if literal - // strings are used in {Fn::Join} or something, they will end up - // escaped in the final JSON output. - const resolved = resolve(obj, { - scope: context, - prefix: [] - }); - - // We can just directly return this value, since resolve() will be called - // on our return value anyway. - return JSON.stringify(deepReplaceIntrinsics(resolved)); - }).toString(); - - /** - * Recurse into a structure, replace all intrinsics with IntrinsicTokens. - */ - function deepReplaceIntrinsics(x: any): any { - if (x == null) { return x; } - - if (isIntrinsic(x)) { - return wrapIntrinsic(x); - } - - if (Array.isArray(x)) { - return x.map(deepReplaceIntrinsics); - } - - if (typeof x === 'object') { - for (const key of Object.keys(x)) { - x[key] = deepReplaceIntrinsics(x[key]); - } - } - - return x; - } - - function wrapIntrinsic(intrinsic: any): IntrinsicToken { - return new IntrinsicToken(() => deepQuoteStringsForJSON(intrinsic)); - } - } -} - -/** - * Token that also stringifies in the toJSON() operation. - */ -class IntrinsicToken extends Token { - /** - * Special handler that gets called when JSON.stringify() is used. - */ - public toJSON() { - return this.toString(); - } -} - -/** - * Deep escape strings for use in a JSON context - */ -function deepQuoteStringsForJSON(x: any): any { - if (typeof x === 'string') { - // Whenever we escape a string we strip off the outermost quotes - // since we're already in a quoted context. - const stringified = JSON.stringify(x); - return stringified.substring(1, stringified.length - 1); - } - - if (Array.isArray(x)) { - return x.map(deepQuoteStringsForJSON); - } - - if (typeof x === 'object') { - for (const key of Object.keys(x)) { - x[key] = deepQuoteStringsForJSON(x[key]); - } - } - - return x; -} diff --git a/packages/@aws-cdk/cdk/lib/cloudformation-lang.ts b/packages/@aws-cdk/cdk/lib/cloudformation-lang.ts new file mode 100644 index 0000000000000..c505b454a39d2 --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/cloudformation-lang.ts @@ -0,0 +1,137 @@ +import { isIntrinsic, minimalCloudFormationJoin } from "./instrinsics"; +import { DefaultTokenResolver, IFragmentConcatenator, resolve } from "./resolve"; +import { TokenizedStringFragments } from "./string-fragments"; +import { IResolveContext, Token } from "./token"; + +/** + * Routines that know how to do operations at the CloudFormation document language level + */ +export class CloudFormationLang { + /** + * Turn an arbitrary structure potentially containing Tokens into a JSON string. + * + * Returns a Token which will evaluate to CloudFormation expression that + * will be evaluated by CloudFormation to the JSON representation of the + * input structure. + * + * All Tokens substituted in this way must return strings, or the evaluation + * in CloudFormation will fail. + * + * @param obj The object to stringify + */ + public static toJSON(obj: any): string { + // This works in two stages: + // + // First, resolve everything. This gets rid of the lazy evaluations, evaluation + // to the real types of things (for example, would a function return a string, an + // intrinsic, or a number? We have to resolve to know). + // + // We then to through the returned result, identify things that evaluated to + // CloudFormation intrinsics, and re-wrap those in Tokens that have a + // toJSON() method returning their string representation. If we then call + // JSON.stringify() on that result, that gives us essentially the same + // string that we started with, except with the non-token characters quoted. + // + // {"field": "${TOKEN}"} --> {\"field\": \"${TOKEN}\"} + // + // A final resolve() on that string (done by the framework) will yield the string + // we're after. + // + // Resolving and wrapping are done in go using the resolver framework. + class IntrinsincWrapper extends DefaultTokenResolver { + constructor() { + super(CLOUDFORMATION_CONCAT); + } + + public resolveToken(t: Token, context: IResolveContext) { + return wrap(super.resolveToken(t, context)); + } + public resolveString(fragments: TokenizedStringFragments, context: IResolveContext) { + return wrap(super.resolveString(fragments, context)); + } + public resolveList(l: string[], context: IResolveContext) { + return wrap(super.resolveList(l, context)); + } + } + + // We need a ResolveContext to get started so return a Token + return new Token((ctx: IResolveContext) => { + return JSON.stringify(resolve(obj, { + scope: ctx.scope, + resolver: new IntrinsincWrapper() + })); + }).toString(); + + function wrap(value: any): any { + return isIntrinsic(value) ? new IntrinsicToken(() => deepQuoteStringsForJSON(value)) : value; + } + } + + /** + * Produce a CloudFormation expression to concat two arbitrary expressions when resolving + */ + public static concat(left: any | undefined, right: any | undefined): any { + if (left === undefined && right === undefined) { return ''; } + + const parts = new Array(); + if (left !== undefined) { parts.push(left); } + if (right !== undefined) { parts.push(right); } + + // Some case analysis to produce minimal expressions + if (parts.length === 1) { return parts[0]; } + if (parts.length === 2 && typeof parts[0] === 'string' && typeof parts[1] === 'string') { + return parts[0] + parts[1]; + } + + // Otherwise return a Join intrinsic (already in the target document language to avoid taking + // circular dependencies on FnJoin & friends) + return { 'Fn::Join': ['', minimalCloudFormationJoin('', parts)] }; + } +} + +/** + * Token that also stringifies in the toJSON() operation. + */ +class IntrinsicToken extends Token { + /** + * Special handler that gets called when JSON.stringify() is used. + */ + public toJSON() { + return this.toString(); + } +} + +/** + * Deep escape strings for use in a JSON context + */ +function deepQuoteStringsForJSON(x: any): any { + if (typeof x === 'string') { + // Whenever we escape a string we strip off the outermost quotes + // since we're already in a quoted context. + const stringified = JSON.stringify(x); + return stringified.substring(1, stringified.length - 1); + } + + if (Array.isArray(x)) { + return x.map(deepQuoteStringsForJSON); + } + + if (typeof x === 'object') { + for (const key of Object.keys(x)) { + x[key] = deepQuoteStringsForJSON(x[key]); + } + } + + return x; +} + +const CLOUDFORMATION_CONCAT: IFragmentConcatenator = { + join(left: any, right: any) { + return CloudFormationLang.concat(left, right); + } +}; + +/** + * Default Token resolver for CloudFormation templates + */ +export const CLOUDFORMATION_TOKEN_RESOLVER = new DefaultTokenResolver(CLOUDFORMATION_CONCAT); \ No newline at end of file diff --git a/packages/@aws-cdk/cdk/lib/construct.ts b/packages/@aws-cdk/cdk/lib/construct.ts index b553d8c337e9f..c771e05d9e8af 100644 --- a/packages/@aws-cdk/cdk/lib/construct.ts +++ b/packages/@aws-cdk/cdk/lib/construct.ts @@ -1,6 +1,6 @@ import cxapi = require('@aws-cdk/cx-api'); import { IAspect } from './aspect'; -import { CloudFormationJSON } from './cloudformation-json'; +import { CLOUDFORMATION_TOKEN_RESOLVER, CloudFormationLang } from './cloudformation-lang'; import { IDependable } from './dependency'; import { resolve } from './resolve'; import { Token } from './token'; @@ -456,7 +456,8 @@ export class ConstructNode { public resolve(obj: any): any { return resolve(obj, { scope: this.host, - prefix: [] + prefix: [], + resolver: CLOUDFORMATION_TOKEN_RESOLVER, }); } @@ -464,7 +465,7 @@ export class ConstructNode { * Convert an object, potentially containing tokens, to a JSON string */ public stringifyJson(obj: any): string { - return CloudFormationJSON.stringify(obj, this.host).toString(); + return CloudFormationLang.toJSON(obj).toString(); } /** @@ -725,4 +726,4 @@ export interface OutgoingReference { } // Import this _after_ everything else to help node work the classes out in the correct order... -import { Reference } from './reference'; +import { Reference } from './reference'; \ No newline at end of file diff --git a/packages/@aws-cdk/cdk/lib/encoding.ts b/packages/@aws-cdk/cdk/lib/encoding.ts index d5d41a1cab7fd..c4d072e64df65 100644 --- a/packages/@aws-cdk/cdk/lib/encoding.ts +++ b/packages/@aws-cdk/cdk/lib/encoding.ts @@ -1,3 +1,5 @@ +import { IFragmentConcatenator } from "./resolve"; +import { TokenizedStringFragments } from "./string-fragments"; import { RESOLVE_METHOD, Token } from "./token"; // Details for encoding and decoding Tokens into native types; should not be exported @@ -12,23 +14,8 @@ const QUOTED_BEGIN_STRING_TOKEN_MARKER = regexQuote(BEGIN_STRING_TOKEN_MARKER); const QUOTED_BEGIN_LIST_TOKEN_MARKER = regexQuote(BEGIN_LIST_TOKEN_MARKER); const QUOTED_END_TOKEN_MARKER = regexQuote(END_TOKEN_MARKER); -/** - * Interface that Token joiners implement - */ -export interface ITokenJoiner { - /** - * The name of the joiner. - * - * Must be unique per joiner: this value will be used to assert that there - * is exactly only type of joiner in a join operation. - */ - id: string; - - /** - * Return the language intrinsic that will combine the strings in the given engine - */ - join(fragments: any[]): any; -} +const STRING_TOKEN_REGEX = new RegExp(`${QUOTED_BEGIN_STRING_TOKEN_MARKER}([${VALID_KEY_CHARS}]+)${QUOTED_END_TOKEN_MARKER}`, 'g'); +const LIST_TOKEN_REGEX = new RegExp(`${QUOTED_BEGIN_LIST_TOKEN_MARKER}([${VALID_KEY_CHARS}]+)${QUOTED_END_TOKEN_MARKER}`, 'g'); /** * A string with markers in it that can be resolved to external values @@ -38,44 +25,37 @@ export class TokenString { * Returns a `TokenString` for this string. */ public static forStringToken(s: string) { - return new TokenString(s, QUOTED_BEGIN_STRING_TOKEN_MARKER, `[${VALID_KEY_CHARS}]+`, QUOTED_END_TOKEN_MARKER); + return new TokenString(s, STRING_TOKEN_REGEX); } /** * Returns a `TokenString` for this string (must be the first string element of the list) */ public static forListToken(s: string) { - return new TokenString(s, QUOTED_BEGIN_LIST_TOKEN_MARKER, `[${VALID_KEY_CHARS}]+`, QUOTED_END_TOKEN_MARKER); + return new TokenString(s, LIST_TOKEN_REGEX); } - private pattern: string; - - constructor( - private readonly str: string, - quotedBeginMarker: string, - idPattern: string, - quotedEndMarker: string) { - this.pattern = `${quotedBeginMarker}(${idPattern})${quotedEndMarker}`; + constructor(private readonly str: string, private readonly re: RegExp) { } /** * Split string on markers, substituting markers with Tokens */ public split(lookup: (id: string) => Token): TokenizedStringFragments { - const re = new RegExp(this.pattern, 'g'); const ret = new TokenizedStringFragments(); let rest = 0; - let m = re.exec(this.str); + this.re.lastIndex = 0; // Reset + let m = this.re.exec(this.str); while (m) { if (m.index > rest) { ret.addLiteral(this.str.substring(rest, m.index)); } - ret.addUnresolved(lookup(m[1])); + ret.addToken(lookup(m[1])); - rest = re.lastIndex; - m = re.exec(this.str); + rest = this.re.lastIndex; + m = this.re.exec(this.str); } if (rest < this.str.length) { @@ -89,91 +69,11 @@ export class TokenString { * Indicates if this string includes tokens. */ public test(): boolean { - const re = new RegExp(this.pattern, 'g'); - return re.test(this.str); + this.re.lastIndex = 0; // Reset + return this.re.test(this.str); } } -/** - * Result of the split of a string with Tokens - * - * Either a literal part of the string, or an unresolved Token. - */ -type LiteralFragment = { type: 'literal'; lit: any; }; -type UnresolvedFragment = { type: 'unresolved'; token: any; }; -type Fragment = LiteralFragment | UnresolvedFragment; - -/** - * Fragments of a string with markers - */ -class TokenizedStringFragments { - private readonly fragments = new Array(); - - public get length() { - return this.fragments.length; - } - - public get values(): any[] { - return this.fragments.map(f => f.type === 'unresolved' ? f.token : f.lit); - } - - public addLiteral(lit: any) { - this.fragments.push({ type: 'literal', lit }); - } - - public addUnresolved(token: Token) { - this.fragments.push({ type: 'unresolved', token }); - } - - public mapUnresolved(fn: (t: any) => any): TokenizedStringFragments { - const ret = new TokenizedStringFragments(); - - for (const f of this.fragments) { - switch (f.type) { - case 'literal': - ret.addLiteral(f.lit); - break; - case 'unresolved': - const mappedToken = fn(f.token); - - if (unresolved(mappedToken)) { - ret.addUnresolved(mappedToken); - } else { - ret.addLiteral(mappedToken); - } - break; - } - } - - return ret; - } - - /** - * Combine the resolved string fragments using the Tokens to join. - * - * Resolves the result. - */ - public join(concat: ConcatFunc): any { - if (this.fragments.length === 0) { return concat(undefined, undefined); } - - const values = this.fragments.map(fragmentValue); - - while (values.length > 1) { - const prefix = values.splice(0, 2); - values.splice(0, 0, concat(prefix[0], prefix[1])); - } - - return values[0]; - } -} - -/** - * Resolve the value from a single fragment - */ -function fragmentValue(fragment: Fragment): any { - return fragment.type === 'literal' ? fragment.lit : fragment.token; -} - /** * Quote a string for use in a regex */ @@ -182,9 +82,16 @@ function regexQuote(s: string) { } /** - * Function used to concatenate symbols in the target document language + * Concatenator that disregards the input + * + * Can be used when traversing the tokens is important, but the + * result isn't. */ -export type ConcatFunc = (left: any | undefined, right: any | undefined) => any; +export class NullConcat implements IFragmentConcatenator { + public join(_left: any | undefined, _right: any | undefined): any { + return undefined; + } +} export function containsListTokenElement(xs: any[]) { return xs.some(x => typeof(x) === 'string' && TokenString.forListToken(x).test()); diff --git a/packages/@aws-cdk/cdk/lib/fn.ts b/packages/@aws-cdk/cdk/lib/fn.ts index 4bd33656fe3ac..f15c31f0255b9 100644 --- a/packages/@aws-cdk/cdk/lib/fn.ts +++ b/packages/@aws-cdk/cdk/lib/fn.ts @@ -1,7 +1,6 @@ import { ICfnConditionExpression } from './cfn-condition'; import { minimalCloudFormationJoin } from './instrinsics'; -import { resolve } from './resolve'; -import { ResolveContext, Token } from './token'; +import { IResolveContext, Token } from './token'; // tslint:disable:max-line-length @@ -650,7 +649,7 @@ class FnJoin extends Token { this.listOfValues = listOfValues; } - public resolve(context: ResolveContext): any { + public resolve(context: IResolveContext): any { if (Token.isToken(this.listOfValues)) { // This is a list token, don't try to do smart things with it. return { 'Fn::Join': [ this.delimiter, this.listOfValues ] }; @@ -667,10 +666,10 @@ class FnJoin extends Token { * if two concatenated elements are literal strings (not tokens), then pre-concatenate them with the delimiter, to * generate shorter output. */ - private resolveValues(context: ResolveContext) { + private resolveValues(context: IResolveContext) { if (this._resolvedValues) { return this._resolvedValues; } - const resolvedValues = this.listOfValues.map(e => resolve(e, context)); + const resolvedValues = this.listOfValues.map(context.resolve); return this._resolvedValues = minimalCloudFormationJoin(this.delimiter, resolvedValues); } } diff --git a/packages/@aws-cdk/cdk/lib/index.ts b/packages/@aws-cdk/cdk/lib/index.ts index 328863209ef19..c1e5c78c75346 100644 --- a/packages/@aws-cdk/cdk/lib/index.ts +++ b/packages/@aws-cdk/cdk/lib/index.ts @@ -6,8 +6,10 @@ export * from './token'; export * from './token-map'; export * from './tag-manager'; export * from './dependency'; +export * from './resolve'; +export * from './string-fragments'; -export * from './cloudformation-json'; +export * from './cloudformation-lang'; export * from './reference'; export * from './cfn-condition'; export * from './fn'; diff --git a/packages/@aws-cdk/cdk/lib/options.ts b/packages/@aws-cdk/cdk/lib/options.ts deleted file mode 100644 index 8fb5bc90eee16..0000000000000 --- a/packages/@aws-cdk/cdk/lib/options.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Token } from "./token"; - -/** - * Function used to preprocess Tokens before resolving - */ -export type CollectFunc = (token: Token) => void; - -/** - * Global options for resolve() - * - * Because there are many independent calls to resolve(), some losing context, - * we cannot simply pass through options at each individual call. Instead, - * we configure global context at the stack synthesis level. - */ -export class ResolveConfiguration { - private readonly options = new Array(); - - public push(options: ResolveOptions): IOptionsContext { - this.options.push(options); - - return { - pop: () => { - if (this.options.length === 0 || this.options[this.options.length - 1] !== options) { - throw new Error('ResolveConfiguration push/pop mismatch'); - } - this.options.pop(); - } - }; - } - - public get collect(): CollectFunc | undefined { - for (let i = this.options.length - 1; i >= 0; i--) { - const ret = this.options[i].collect; - if (ret !== undefined) { return ret; } - } - return undefined; - } -} - -interface IOptionsContext { - pop(): void; -} - -interface ResolveOptions { - /** - * What function to use to preprocess Tokens before resolving them - */ - collect?: CollectFunc; -} - -const glob = global as any; - -/** - * Singleton instance of resolver options - */ -export const RESOLVE_OPTIONS: ResolveConfiguration = glob.__cdkResolveOptions = glob.__cdkResolveOptions || new ResolveConfiguration(); diff --git a/packages/@aws-cdk/cdk/lib/pseudo.ts b/packages/@aws-cdk/cdk/lib/pseudo.ts index 93578652afd04..2a7d168f9e7d8 100644 --- a/packages/@aws-cdk/cdk/lib/pseudo.ts +++ b/packages/@aws-cdk/cdk/lib/pseudo.ts @@ -66,42 +66,36 @@ export class ScopedAws { } public get accountId(): string { - return new ScopedPseudo(AWS_ACCOUNTID, this.scope).toString(); + return CfnReference.forPseudo(AWS_ACCOUNTID, this.scope).toString(); } public get urlSuffix(): string { - return new ScopedPseudo(AWS_URLSUFFIX, this.scope).toString(); + return CfnReference.forPseudo(AWS_URLSUFFIX, this.scope).toString(); } public get notificationArns(): string[] { - return new ScopedPseudo(AWS_NOTIFICATIONARNS, this.scope).toList(); + return CfnReference.forPseudo(AWS_NOTIFICATIONARNS, this.scope).toList(); } public get partition(): string { - return new ScopedPseudo(AWS_PARTITION, this.scope).toString(); + return CfnReference.forPseudo(AWS_PARTITION, this.scope).toString(); } public get region(): string { - return new ScopedPseudo(AWS_REGION, this.scope).toString(); + return CfnReference.forPseudo(AWS_REGION, this.scope).toString(); } public get stackId(): string { - return new ScopedPseudo(AWS_STACKID, this.scope).toString(); + return CfnReference.forPseudo(AWS_STACKID, this.scope).toString(); } public get stackName(): string { - return new ScopedPseudo(AWS_STACKNAME, this.scope).toString(); - } -} - -class ScopedPseudo extends CfnReference { - constructor(name: string, scope: Construct) { - super({ Ref: name }, name, scope); + return CfnReference.forPseudo(AWS_STACKNAME, this.scope).toString(); } } class UnscopedPseudo extends Token { constructor(name: string) { - super({ Ref: name }, name); + super({ Ref: name }, name); } } \ No newline at end of file diff --git a/packages/@aws-cdk/cdk/lib/resolve.ts b/packages/@aws-cdk/cdk/lib/resolve.ts index dbc957e1b6a1a..0f9bf51a10704 100644 --- a/packages/@aws-cdk/cdk/lib/resolve.ts +++ b/packages/@aws-cdk/cdk/lib/resolve.ts @@ -1,13 +1,27 @@ import { IConstruct } from './construct'; import { containsListTokenElement, TokenString, unresolved } from "./encoding"; -import { RESOLVE_OPTIONS } from "./options"; -import { isResolvedValuePostProcessor, RESOLVE_METHOD, ResolveContext, Token } from "./token"; +import { TokenizedStringFragments } from './string-fragments'; +import { IResolveContext, isResolvedValuePostProcessor, RESOLVE_METHOD, Token } from "./token"; import { TokenMap } from './token-map'; // This file should not be exported to consumers, resolving should happen through Construct.resolve() const tokenMap = TokenMap.instance(); +/** + * Options to the resolve() operation + * + * NOT the same as the ResolveContext; ResolveContext is exposed to Token + * implementors and resolution hooks, whereas this struct is just to bundle + * a number of things that would otherwise be arguments to resolve() in a + * readable way. + */ +export interface IResolveOptions { + scope: IConstruct; + resolver: ITokenResolver; + prefix?: string[]; +} + /** * Resolves an object by evaluating all tokens and removing any undefined or empty objects or arrays. * Values can only be primitives, arrays or tokens. Other objects (i.e. with methods) will be rejected. @@ -15,11 +29,23 @@ const tokenMap = TokenMap.instance(); * @param obj The object to resolve. * @param prefix Prefix key path components for diagnostics. */ -export function resolve(obj: any, context: ResolveContext): any { - const pathName = '/' + context.prefix.join('/'); +export function resolve(obj: any, options: IResolveOptions): any { + const prefix = options.prefix || []; + const pathName = '/' + prefix.join('/'); + + /** + * Make a new resolution context + */ + function makeContext(appendPath?: string): IResolveContext { + const newPrefix = appendPath !== undefined ? prefix.concat([appendPath]) : options.prefix; + return { + scope: options.scope, + resolve(x: any) { return resolve(x, { ...options, prefix: newPrefix }); } + }; + } // protect against cyclic references by limiting depth. - if (context.prefix.length > 200) { + if (prefix.length > 200) { throw new Error('Unable to resolve object tree with circular reference. Path: ' + pathName); } @@ -51,14 +77,19 @@ export function resolve(obj: any, context: ResolveContext): any { // string - potentially replace all stringified Tokens // if (typeof(obj) === 'string') { - return resolveStringTokens(obj, context); + const str = TokenString.forStringToken(obj); + if (str.test()) { + const fragments = str.split(tokenMap.lookupToken.bind(tokenMap)); + return options.resolver.resolveString(fragments, makeContext()); + } + return obj; } // // number - potentially decode Tokenized number // if (typeof(obj) === 'number') { - return resolveNumberToken(obj, context); + return resolveNumberToken(obj, makeContext()); } // @@ -75,11 +106,11 @@ export function resolve(obj: any, context: ResolveContext): any { if (Array.isArray(obj)) { if (containsListTokenElement(obj)) { - return resolveListTokens(obj, context); + return options.resolver.resolveList(obj, makeContext()); } const arr = obj - .map((x, i) => resolve(x, { ...context, prefix: context.prefix.concat(i.toString()) })) + .map((x, i) => makeContext(`${i}`).resolve(x)) .filter(x => typeof(x) !== 'undefined'); return arr; @@ -90,18 +121,7 @@ export function resolve(obj: any, context: ResolveContext): any { // if (unresolved(obj)) { - const collect = RESOLVE_OPTIONS.collect; - if (collect) { collect(obj); } - - const resolved = obj[RESOLVE_METHOD](context); - - let deepResolved = resolve(resolved, context); - - if (isResolvedValuePostProcessor(obj)) { - deepResolved = obj.postProcess(deepResolved, context); - } - - return deepResolved; + return options.resolver.resolveToken(obj, makeContext()); } // @@ -117,12 +137,12 @@ export function resolve(obj: any, context: ResolveContext): any { const result: any = { }; for (const key of Object.keys(obj)) { - const resolvedKey = resolve(key, context); + const resolvedKey = makeContext().resolve(key); if (typeof(resolvedKey) !== 'string') { - throw new Error(`The key "${key}" has been resolved to ${JSON.stringify(resolvedKey)} but must be resolvable to a string`); + throw new Error(`"${key}" is used as the key in a map so must resolve to a string, but it resolves to: ${JSON.stringify(resolvedKey)}`); } - const value = resolve(obj[key], {...context, prefix: context.prefix.concat(key) }); + const value = makeContext(key).resolve(obj[key]); // skip undefined if (typeof(value) === 'undefined') { @@ -136,64 +156,146 @@ export function resolve(obj: any, context: ResolveContext): any { } /** - * Find all Tokens that are used in the given structure + * How to resolve tokens */ -export function findTokens(scope: IConstruct, fn: () => any): Token[] { - const ret = new Array(); - - const options = RESOLVE_OPTIONS.push({ collect: ret.push.bind(ret) }); - try { - resolve(fn(), { - scope, - prefix: [] - }); - } finally { - options.pop(); - } +export interface ITokenResolver { + /** + * Resolve a single token + */ + resolveToken(t: Token, context: IResolveContext): any; + + /** + * Resolve a string with at least one stringified token in it + * + * (May use concatenation) + */ + resolveString(s: TokenizedStringFragments, context: IResolveContext): any; + + /** + * Resolve a tokenized list + */ + resolveList(l: string[], context: IResolveContext): any; +} - return ret; +/** + * Function used to concatenate symbols in the target document language + * + * Interface so it could potentially be exposed over jsii. + */ +export interface IFragmentConcatenator { + /** + * Join the fragment on the left and on the right + */ + join(left: any | undefined, right: any | undefined): any; } /** - * Determine whether an object is a Construct + * Converts all fragments to strings and concats those * - * Not in 'construct.ts' because that would lead to a dependency cycle via 'uniqueid.ts', - * and this is a best-effort protection against a common programming mistake anyway. + * Drops 'undefined's. */ -function isConstruct(x: any): boolean { - return x._children !== undefined && x._metadata !== undefined; +export class StringConcat implements IFragmentConcatenator { + public join(left: any | undefined, right: any | undefined): any { + if (left === undefined) { return right !== undefined ? `${right}` : undefined; } + if (right === undefined) { return `${left}`; } + return `${left}${right}`; + } +} + +/** + * Default resolver implementation + */ +export class DefaultTokenResolver implements ITokenResolver { + constructor(private readonly concat: IFragmentConcatenator) { + } + + /** + * Default Token resolution + * + * Resolve the Token, recurse into whatever it returns, + * then finally post-process it. + */ + public resolveToken(t: Token, context: IResolveContext) { + let resolved = t[RESOLVE_METHOD](context); + + // The token might have returned more values that need resolving, recurse + resolved = context.resolve(resolved); + + if (isResolvedValuePostProcessor(t)) { + resolved = t.postProcess(resolved, context); + } + + return resolved; + } + + /** + * Resolve string fragments to Tokens + */ + public resolveString(fragments: TokenizedStringFragments, context: IResolveContext) { + return fragments.mapTokens({ mapToken: context.resolve }).join(this.concat); + } + + public resolveList(xs: string[], context: IResolveContext) { + // Must be a singleton list token, because concatenation is not allowed. + if (xs.length !== 1) { + throw new Error(`Cannot add elements to list token, got: ${xs}`); + } + + const str = TokenString.forListToken(xs[0]); + const fragments = str.split(tokenMap.lookupToken.bind(tokenMap)); + if (fragments.length !== 1) { + throw new Error(`Cannot concatenate strings in a tokenized string array, got: ${xs[0]}`); + } + + return fragments.mapTokens({ mapToken: context.resolve }).firstValue; + } + } /** - * Replace any Token markers in this string with their resolved values + * Find all Tokens that are used in the given structure */ -function resolveStringTokens(s: string, context: ResolveContext): any { - const str = TokenString.forStringToken(s); - const fragments = str.split(tokenMap.lookupToken.bind(tokenMap)); - // require() here to break cyclic dependencies - const ret = fragments.mapUnresolved(x => resolve(x, context)).join(require('./cfn-concat').cloudFormationConcat); - if (unresolved(ret)) { - return resolve(ret, context); - } - return ret; +export function findTokens(scope: IConstruct, fn: () => any): Token[] { + const resolver = new RememberingTokenResolver(new StringConcat()); + + resolve(fn(), { scope, prefix: [], resolver }); + + return resolver.tokens; } -function resolveListTokens(xs: string[], context: ResolveContext): any { - // Must be a singleton list token, because concatenation is not allowed. - if (xs.length !== 1) { - throw new Error(`Cannot add elements to list token, got: ${xs}`); +/** + * Remember all Tokens encountered while resolving + */ +export class RememberingTokenResolver extends DefaultTokenResolver { + private readonly tokensSeen = new Set(); + + public resolveToken(t: Token, context: IResolveContext) { + this.tokensSeen.add(t); + return super.resolveToken(t, context); } - const str = TokenString.forListToken(xs[0]); - const fragments = str.split(tokenMap.lookupToken.bind(tokenMap)); - if (fragments.length !== 1) { - throw new Error(`Cannot concatenate strings in a tokenized string array, got: ${xs[0]}`); + public resolveString(s: TokenizedStringFragments, context: IResolveContext) { + const ret = super.resolveString(s, context); + return ret; } - return fragments.mapUnresolved(x => resolve(x, context)).values[0]; + + public get tokens(): Token[] { + return Array.from(this.tokensSeen); + } +} + +/** + * Determine whether an object is a Construct + * + * Not in 'construct.ts' because that would lead to a dependency cycle via 'uniqueid.ts', + * and this is a best-effort protection against a common programming mistake anyway. + */ +function isConstruct(x: any): boolean { + return x._children !== undefined && x._metadata !== undefined; } -function resolveNumberToken(x: number, context: ResolveContext): any { +function resolveNumberToken(x: number, context: IResolveContext): any { const token = TokenMap.instance().lookupNumberToken(x); if (token === undefined) { return x; } - return resolve(token, context); + return context.resolve(token); } \ No newline at end of file diff --git a/packages/@aws-cdk/cdk/lib/string-fragments.ts b/packages/@aws-cdk/cdk/lib/string-fragments.ts new file mode 100644 index 0000000000000..9ca8e2057dd96 --- /dev/null +++ b/packages/@aws-cdk/cdk/lib/string-fragments.ts @@ -0,0 +1,124 @@ +import { IFragmentConcatenator } from "./resolve"; +import { Token } from "./token"; + +/** + * Result of the split of a string with Tokens + * + * Either a literal part of the string, or an unresolved Token. + */ +type LiteralFragment = { type: 'literal'; lit: any; }; +type TokenFragment = { type: 'token'; token: Token; }; +type IntrinsicFragment = { type: 'intrinsic'; value: any; }; +type Fragment = LiteralFragment | TokenFragment | IntrinsicFragment; + +/** + * Fragments of a string with markers + */ +export class TokenizedStringFragments { + private readonly fragments = new Array(); + + public get firstToken(): Token | undefined { + const first = this.fragments[0]; + if (first.type === 'token') { return first.token; } + return undefined; + } + + public get firstValue(): any { + return fragmentValue(this.fragments[0]); + } + + public get length() { + return this.fragments.length; + } + + public addLiteral(lit: any) { + this.fragments.push({ type: 'literal', lit }); + } + + public addToken(token: Token) { + this.fragments.push({ type: 'token', token }); + } + + public addIntrinsic(value: any) { + this.fragments.push({ type: 'intrinsic', value }); + } + + public mapTokens(mapper: ITokenMapper): TokenizedStringFragments { + const ret = new TokenizedStringFragments(); + + for (const f of this.fragments) { + switch (f.type) { + case 'literal': + ret.addLiteral(f.lit); + break; + case 'token': + const mapped = mapper.mapToken(f.token); + if (isTokenObject(mapped)) { + ret.addToken(mapped); + } else { + ret.addIntrinsic(mapped); + } + break; + case 'intrinsic': + ret.addIntrinsic(f.value); + break; + } + } + + return ret; + } + + /** + * Combine the string fragments using the given joiner. + * + * If there are any + */ + public join(concat: IFragmentConcatenator): any { + if (this.fragments.length === 0) { return concat.join(undefined, undefined); } + if (this.fragments.length === 1) { return this.firstValue; } + + const values = this.fragments.map(fragmentValue); + + while (values.length > 1) { + const prefix = values.splice(0, 2); + values.splice(0, 0, concat.join(prefix[0], prefix[1])); + } + + return values[0]; + } +} + +/** + * Interface to apply operation to tokens in a string + * + * Interface so it can be exported via jsii. + */ +export interface ITokenMapper { + /** + * Replace a single token + */ + mapToken(t: Token): any; +} + +/** + * Resolve the value from a single fragment + * + * If the fragment is a Token, return the string encoding of the Token. + */ +function fragmentValue(fragment: Fragment): any { + switch (fragment.type) { + case 'literal': return fragment.lit; + case 'token': return fragment.token.toString(); + case 'intrinsic': return fragment.value; + } +} + +/** + * Whether x is literally a Token object + * + * Can't use Token.isToken() because that has been co-opted + * to mean something else. + */ +function isTokenObject(x: any): x is Token { + return typeof(x) === 'object' && x !== null && Token.isToken(x); +} \ No newline at end of file diff --git a/packages/@aws-cdk/cdk/lib/token-map.ts b/packages/@aws-cdk/cdk/lib/token-map.ts index 2db15eeb855d0..9730cf0f063a4 100644 --- a/packages/@aws-cdk/cdk/lib/token-map.ts +++ b/packages/@aws-cdk/cdk/lib/token-map.ts @@ -52,6 +52,18 @@ export class TokenMap { return [`${BEGIN_LIST_TOKEN_MARKER}${key}${END_TOKEN_MARKER}`]; } + /** + * Lookup a token from an encoded value + */ + public tokenFromEncoding(x: any): Token | undefined { + if (typeof 'x' === 'string') { return this.lookupString(x); } + if (Array.isArray(x)) { return this.lookupList(x); } + if (typeof x === 'object' && x !== null && Token.isToken(x)) { + return x as Token; + } + return undefined; + } + /** * Create a unique number representation for this Token and return it */ @@ -68,8 +80,7 @@ export class TokenMap { const str = TokenString.forStringToken(s); const fragments = str.split(this.lookupToken.bind(this)); if (fragments.length === 1) { - const v = fragments.values[0]; - if (typeof v !== 'string') { return v as Token; } + return fragments.firstToken; } return undefined; } @@ -82,8 +93,7 @@ export class TokenMap { const str = TokenString.forListToken(xs[0]); const fragments = str.split(this.lookupToken.bind(this)); if (fragments.length === 1) { - const v = fragments.values[0]; - if (typeof v !== 'string') { return v as Token; } + return fragments.firstToken; } return undefined; } diff --git a/packages/@aws-cdk/cdk/lib/token.ts b/packages/@aws-cdk/cdk/lib/token.ts index f7184115fe840..7d83b4dcc2587 100644 --- a/packages/@aws-cdk/cdk/lib/token.ts +++ b/packages/@aws-cdk/cdk/lib/token.ts @@ -65,10 +65,10 @@ export class Token { /** * @returns The resolved value for this token. */ - public resolve(_context: ResolveContext): any { + public resolve(context: IResolveContext): any { let value = this.valueOrFunction; if (typeof(value) === 'function') { - value = value(); + value = value(context); } return value; @@ -106,8 +106,17 @@ export class Token { * it's not possible to do this properly, so we just throw an error here. */ public toJSON(): any { - // tslint:disable-next-line:max-line-length - throw new Error('JSON.stringify() cannot be applied to structure with a Token in it. Use this.node.stringifyJson() instead.'); + // We can't do the right work here because in case we contain a function, we + // won't know the type of value that function represents (in the simplest + // case, string or number), and we can't know that without an + // IResolveContext to actually do the resolution, which we don't have. + + // We used to throw an error, but since JSON.stringify() is often used in + // error messages to produce a readable representation of an object, if we + // throw here we'll obfuscate that descriptive error with something worse. + // So return a string representation that indicates this thing is a token + // and needs resolving. + return JSON.stringify(``); } /** @@ -162,9 +171,16 @@ export class Token { /** * Current resolution context for tokens */ -export interface ResolveContext { +export interface IResolveContext { + /** + * The scope from which resolution has been initiated + */ readonly scope: IConstruct; - readonly prefix: string[]; + + /** + * Resolve an inner object + */ + resolve(x: any): any; } /** @@ -174,7 +190,7 @@ export interface IResolvedValuePostProcessor { /** * Process the completely resolved value, after full recursion/resolution has happened */ - postProcess(input: any, context: ResolveContext): any; + postProcess(input: any, context: IResolveContext): any; } /** diff --git a/packages/@aws-cdk/cdk/lib/util.ts b/packages/@aws-cdk/cdk/lib/util.ts index ba83bfc00f81b..477f61e9015bd 100644 --- a/packages/@aws-cdk/cdk/lib/util.ts +++ b/packages/@aws-cdk/cdk/lib/util.ts @@ -1,5 +1,5 @@ import { IConstruct } from "./construct"; -import { IResolvedValuePostProcessor, ResolveContext, Token } from "./token"; +import { IResolveContext, IResolvedValuePostProcessor, Token } from "./token"; /** * Given an object, converts all keys to PascalCase given they are currently in camel case. @@ -80,7 +80,7 @@ export class PostResolveToken extends Token implements IResolvedValuePostProcess super(value); } - public postProcess(o: any, _context: ResolveContext): any { + public postProcess(o: any, _context: IResolveContext): any { return this.processor(o); } } diff --git a/packages/@aws-cdk/cdk/test/test.cloudformation-json.ts b/packages/@aws-cdk/cdk/test/test.cloudformation-json.ts index 1686cd6c5d155..64e5239d25142 100644 --- a/packages/@aws-cdk/cdk/test/test.cloudformation-json.ts +++ b/packages/@aws-cdk/cdk/test/test.cloudformation-json.ts @@ -1,20 +1,8 @@ import { Test } from 'nodeunit'; -import { CloudFormationJSON, Fn, Stack, Token } from '../lib'; +import { CloudFormationLang, Fn, Stack, Token } from '../lib'; import { evaluateCFN } from './evaluate-cfn'; export = { - 'plain JSON.stringify() on a Token fails'(test: Test) { - // GIVEN - const token = new Token(() => 'value'); - - // WHEN - test.throws(() => { - JSON.stringify({ token }); - }); - - test.done(); - }, - 'string tokens can be JSONified and JSONification can be reversed'(test: Test) { const stack = new Stack(); @@ -23,7 +11,7 @@ export = { const fido = { name: 'Fido', speaks: token }; // WHEN - const resolved = stack.node.resolve(CloudFormationJSON.stringify(fido, stack)); + const resolved = stack.node.resolve(CloudFormationLang.toJSON(fido)); // THEN test.deepEqual(evaluateCFN(resolved), '{"name":"Fido","speaks":"woof woof"}'); @@ -40,7 +28,7 @@ export = { const fido = { name: 'Fido', speaks: `deep ${token}` }; // WHEN - const resolved = stack.node.resolve(CloudFormationJSON.stringify(fido, stack)); + const resolved = stack.node.resolve(CloudFormationLang.toJSON(fido)); // THEN test.deepEqual(evaluateCFN(resolved), '{"name":"Fido","speaks":"deep woof woof"}'); @@ -49,6 +37,20 @@ export = { test.done(); }, + 'constant string has correct amount of quotes applied'(test: Test) { + const stack = new Stack(); + + const inputString = 'Hello, "world"'; + + // WHEN + const resolved = stack.node.resolve(CloudFormationLang.toJSON(inputString)); + + // THEN + test.deepEqual(evaluateCFN(resolved), JSON.stringify(inputString)); + + test.done(); + }, + 'integer Tokens behave correctly in stringification and JSONification'(test: Test) { // GIVEN const stack = new Stack(); @@ -57,8 +59,8 @@ export = { // WHEN test.equal(evaluateCFN(stack.node.resolve(embedded)), "the number is 1"); - test.equal(evaluateCFN(stack.node.resolve(CloudFormationJSON.stringify({ embedded }, stack))), "{\"embedded\":\"the number is 1\"}"); - test.equal(evaluateCFN(stack.node.resolve(CloudFormationJSON.stringify({ num }, stack))), "{\"num\":1}"); + test.equal(evaluateCFN(stack.node.resolve(CloudFormationLang.toJSON({ embedded }))), "{\"embedded\":\"the number is 1\"}"); + test.equal(evaluateCFN(stack.node.resolve(CloudFormationLang.toJSON({ num }))), "{\"num\":1}"); test.done(); }, @@ -68,7 +70,7 @@ export = { const stack = new Stack(); for (const token of tokensThatResolveTo('pong!')) { // WHEN - const stringified = CloudFormationJSON.stringify(`ping? ${token}`, stack); + const stringified = CloudFormationLang.toJSON(`ping? ${token}`); // THEN test.equal(evaluateCFN(stack.node.resolve(stringified)), '"ping? pong!"'); @@ -83,7 +85,7 @@ export = { const bucketName = new Token({ Ref: 'MyBucket' }); // WHEN - const resolved = stack.node.resolve(CloudFormationJSON.stringify({ theBucket: bucketName }, stack)); + const resolved = stack.node.resolve(CloudFormationLang.toJSON({ theBucket: bucketName })); // THEN const context = {MyBucket: 'TheName'}; @@ -108,7 +110,7 @@ export = { }, })); - const stringified = CloudFormationJSON.stringify(fakeIntrinsics, stack); + const stringified = CloudFormationLang.toJSON(fakeIntrinsics); test.equal(evaluateCFN(stack.node.resolve(stringified)), '{"a":{"Fn::GetArtifactAtt":{"key":"val"}},"b":{"Fn::GetParam":["val1","val2"]}}'); @@ -121,10 +123,10 @@ export = { const token = Fn.join('', [ 'Hello', 'This\nIs', 'Very "cool"' ]); // WHEN - const resolved = stack.node.resolve(CloudFormationJSON.stringify({ + const resolved = stack.node.resolve(CloudFormationLang.toJSON({ literal: 'I can also "contain" quotes', token - }, stack)); + })); // THEN const expected = '{"literal":"I can also \\"contain\\" quotes","token":"HelloThis\\nIsVery \\"cool\\""}'; @@ -140,7 +142,7 @@ export = { const combinedName = Fn.join('', [ 'The bucket name is ', bucketName.toString() ]); // WHEN - const resolved = stack.node.resolve(CloudFormationJSON.stringify({ theBucket: combinedName }, stack)); + const resolved = stack.node.resolve(CloudFormationLang.toJSON({ theBucket: combinedName })); // THEN const context = {MyBucket: 'TheName'}; @@ -155,9 +157,9 @@ export = { const fidoSays = new Token(() => 'woof'); // WHEN - const resolved = stack.node.resolve(CloudFormationJSON.stringify({ + const resolved = stack.node.resolve(CloudFormationLang.toJSON({ information: `Did you know that Fido says: ${fidoSays}` - }, stack)); + })); // THEN test.deepEqual(evaluateCFN(resolved), '{"information":"Did you know that Fido says: woof"}'); @@ -171,9 +173,9 @@ export = { const fidoSays = new Token(() => ({ Ref: 'Something' })); // WHEN - const resolved = stack.node.resolve(CloudFormationJSON.stringify({ + const resolved = stack.node.resolve(CloudFormationLang.toJSON({ information: `Did you know that Fido says: ${fidoSays}` - }, stack)); + })); // THEN const context = {Something: 'woof woof'}; @@ -188,9 +190,9 @@ export = { const fidoSays = new Token(() => '"woof"'); // WHEN - const resolved = stack.node.resolve(CloudFormationJSON.stringify({ + const resolved = stack.node.resolve(CloudFormationLang.toJSON({ information: `Did you know that Fido says: ${fidoSays}` - }, stack)); + })); // THEN test.deepEqual(evaluateCFN(resolved), '{"information":"Did you know that Fido says: \\"woof\\""}'); diff --git a/packages/@aws-cdk/cdk/test/test.stack.ts b/packages/@aws-cdk/cdk/test/test.stack.ts index 8d7e37f85b135..6d4bc86fbd5a6 100644 --- a/packages/@aws-cdk/cdk/test/test.stack.ts +++ b/packages/@aws-cdk/cdk/test/test.stack.ts @@ -188,6 +188,36 @@ export = { test.done(); }, + 'Cross-stack references are detected in resource properties'(test: Test) { + // GIVEN + const app = new App(); + const stack1 = new Stack(app, 'Stack1'); + const resource1 = new CfnResource(stack1, 'Resource', { type: 'BLA' }); + const stack2 = new Stack(app, 'Stack2'); + + // WHEN - used in another resource + new CfnResource(stack2, 'SomeResource', { type: 'AWS::Some::Resource', properties: { + someProperty: new Token(() => resource1.ref), + }}); + + // THEN + // Need to do this manually now, since we're in testing mode. In a normal CDK app, + // this happens as part of app.run(). + app.node.prepareTree(); + + test.deepEqual(stack2._toCloudFormation(), { + Resources: { + SomeResource: { + Type: 'AWS::Some::Resource', + Properties: { + someProperty: { 'Fn::ImportValue': 'Stack1:ExportsOutputRefResource1D5D905A' } + } + } + } + }); + test.done(); + }, + 'cross-stack references in lazy tokens work'(test: Test) { // GIVEN const app = new App(); diff --git a/packages/@aws-cdk/cdk/test/test.tokens.ts b/packages/@aws-cdk/cdk/test/test.tokens.ts index 51ded366e7662..639ef3fa93387 100644 --- a/packages/@aws-cdk/cdk/test/test.tokens.ts +++ b/packages/@aws-cdk/cdk/test/test.tokens.ts @@ -1,5 +1,5 @@ import { Test } from 'nodeunit'; -import { App as Root, Fn, Stack, Token } from '../lib'; +import { App as Root, findTokens, Fn, Stack, Token } from '../lib'; import { createTokenDouble, extractTokenDouble } from '../lib/encoding'; import { TokenMap } from '../lib/token-map'; import { evaluateCFN } from './evaluate-cfn'; @@ -280,6 +280,52 @@ export = { test.done(); }, + 'tokens can be nested in hash keys'(test: Test) { + // GIVEN + const token = new Token(() => new Token(() => new Token(() => 'I am a string'))); + + // WHEN + const s = { + [token.toString()]: `boom ${token}` + }; + + // THEN + test.deepEqual(resolve(s), { 'I am a string': 'boom I am a string' }); + test.done(); + }, + + 'tokens can be nested and concatenated in hash keys'(test: Test) { + // GIVEN + const innerToken = new Token(() => 'toot'); + const token = new Token(() => `${innerToken} the woot`); + + // WHEN + const s = { + [token.toString()]: `boom chicago` + }; + + // THEN + test.deepEqual(resolve(s), { 'toot the woot': 'boom chicago' }); + test.done(); + }, + + 'can find nested tokens in hash keys'(test: Test) { + // GIVEN + const innerToken = new Token(() => 'toot'); + const token = new Token(() => `${innerToken} the woot`); + + // WHEN + const s = { + [token.toString()]: `boom chicago` + }; + + // THEN + const tokens = findTokens(new Stack(), () => s); + test.ok(tokens.some(t => t === innerToken), 'Cannot find innerToken'); + test.ok(tokens.some(t => t === token), 'Cannot find token'); + test.done(); + }, + 'fails if token in a hash key resolves to a non-string'(test: Test) { // GIVEN const token = new Token({ Ref: 'Other' }); diff --git a/tools/cdk-integ-tools/bin/cdk-integ-assert.ts b/tools/cdk-integ-tools/bin/cdk-integ-assert.ts index df68a688598e1..993d3f7d6260c 100644 --- a/tools/cdk-integ-tools/bin/cdk-integ-assert.ts +++ b/tools/cdk-integ-tools/bin/cdk-integ-assert.ts @@ -38,7 +38,7 @@ async function main() { if (failures.length > 0) { // tslint:disable-next-line:max-line-length - throw new Error(`The following integ stacks have changed: ${failures.join(', ')}. Run 'npm run integ' to verify that everything still deploys.`); + throw new Error(`Some stacks have changed. To verify that they still deploy successfully, run: 'npm run integ ${failures.join(' ')}'`); } } diff --git a/tools/cfn2ts/lib/codegen.ts b/tools/cfn2ts/lib/codegen.ts index 80c8089fd5a5b..23203cae0da9c 100644 --- a/tools/cfn2ts/lib/codegen.ts +++ b/tools/cfn2ts/lib/codegen.ts @@ -340,7 +340,7 @@ export default class CodeGenerator { this.code.closeBlock(); this.code.openBlock('protected renderProperties(properties: any): { [key: string]: any } '); - this.code.line(`return ${genspec.cfnMapperName(propsType).fqn}(this.node.resolve(properties));`); + this.code.line(`return ${genspec.cfnMapperName(propsType).fqn}(properties);`); this.code.closeBlock(); }