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(); }