diff --git a/packages/@aws-cdk/cloudformation-include/README.md b/packages/@aws-cdk/cloudformation-include/README.md index 8bddfd07fcf1f..09f87f4b0d27e 100644 --- a/packages/@aws-cdk/cloudformation-include/README.md +++ b/packages/@aws-cdk/cloudformation-include/README.md @@ -134,8 +134,8 @@ param.default = 'MyDefault'; You can also provide values for them when including the template: ```typescript -new inc.CfnInclude(stack, 'includeTemplate', { - templateFile: 'path/to/my/template' +new inc.CfnInclude(this, 'includeTemplate', { + templateFile: 'path/to/my/template', parameters: { 'MyParam': 'my-value', }, @@ -218,6 +218,25 @@ and any changes you make to it will be reflected in the resulting template: output.value = cfnBucket.attrArn; ``` +## Hooks + +If your template uses [Hooks for blue-green deployments](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/blue-green.html), +you can retrieve them from your template: + +```typescript +import * as core from '@aws-cdk/core'; + +const hook: core.CfnHook = cfnTemplate.getHook('MyOutput'); +``` + +The `CfnHook` object can be mutated, +and any changes you make to it will be reflected in the resulting template: + +```typescript +const codeDeployHook = hook as core.CfnCodeDeployBlueGreenHook; +codeDeployHook.serviceRole = myRole.roleArn; +``` + ## Nested Stacks This module also support templates that use [nested stacks](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-nested-stacks.html). @@ -249,10 +268,11 @@ where the child template pointed to by `https://my-s3-template-source.s3.amazona } ``` -You can include both the parent stack and the nested stack in your CDK application as follows: +You can include both the parent stack, +and the nested stack in your CDK application as follows: ```typescript -const parentTemplate = new inc.CfnInclude(stack, 'ParentStack', { +const parentTemplate = new inc.CfnInclude(this, 'ParentStack', { templateFile: 'path/to/my-parent-template.json', nestedStacks: { 'ChildStack': { @@ -270,7 +290,8 @@ const childStack: core.NestedStack = includedChildStack.stack; const childTemplate: cfn_inc.CfnInclude = includedChildStack.includedTemplate; ``` -Now you can reference resources from `ChildStack` and modify them like any other included template: +Now you can reference resources from `ChildStack`, +and modify them like any other included template: ```typescript const cfnBucket = childTemplate.getResource('MyBucket') as s3.CfnBucket; @@ -295,7 +316,7 @@ role.addToPolicy(new iam.PolicyStatement({ In many cases, there are existing CloudFormation templates that are not entire applications, but more like specialized fragments, implementing a particular pattern or best practice. If you have templates like that, -you can use the `CfnInclude` class to vend them as a CDK Constructs: +you can use the `CfnInclude` class to vend them as CDK Constructs: ```ts import * as path from 'path'; diff --git a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts index ebad5a7de6b33..afd14ef14e0ed 100644 --- a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts +++ b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts @@ -86,6 +86,8 @@ export class CfnInclude extends core.CfnElement { private readonly mappings: { [mappingName: string]: core.CfnMapping } = {}; private readonly rules: { [ruleName: string]: core.CfnRule } = {}; private readonly rulesScope: core.Construct; + private readonly hooks: { [hookName: string]: core.CfnHook } = {}; + private readonly hooksScope: core.Construct; private readonly outputs: { [logicalId: string]: core.CfnOutput } = {}; private readonly nestedStacks: { [logicalId: string]: IncludedNestedStack } = {}; private readonly nestedStacksToInclude: { [name: string]: CfnIncludeProps }; @@ -144,6 +146,12 @@ export class CfnInclude extends core.CfnElement { } } + // instantiate the Hooks + this.hooksScope = new core.Construct(this, '$Hooks'); + for (const hookName of Object.keys(this.template.Hooks || {})) { + this.createHook(hookName); + } + const outputScope = new core.Construct(this, '$Ouputs'); for (const logicalId of Object.keys(this.template.Outputs || {})) { this.createOutput(logicalId, outputScope); @@ -263,6 +271,24 @@ export class CfnInclude extends core.CfnElement { return ret; } + /** + * Returns the CfnHook object from the 'Hooks' + * section of the included CloudFormation template with the given logical ID. + * Any modifications performed on the returned object will be reflected in the resulting CDK template. + * + * If a Hook with the given logical ID is not present in the template, + * an exception will be thrown. + * + * @param hookLogicalId the logical ID of the Hook in the included CloudFormation template's 'Hooks' section + */ + public getHook(hookLogicalId: string): core.CfnHook { + const ret = this.hooks[hookLogicalId]; + if (!ret) { + throw new Error(`Hook with logical ID '${hookLogicalId}' was not found in the template`); + } + return ret; + } + /** * Returns the NestedStack with name logicalId. * For a nested stack to be returned by this method, it must be specified in the {@link CfnIncludeProps.nestedStacks} @@ -314,6 +340,7 @@ export class CfnInclude extends core.CfnElement { case 'Resources': case 'Parameters': case 'Rules': + case 'Hooks': case 'Outputs': // these are rendered as a side effect of instantiating the L1s break; @@ -400,6 +427,46 @@ export class CfnInclude extends core.CfnElement { this.overrideLogicalIdIfNeeded(rule, ruleName); } + private createHook(hookName: string): void { + const self = this; + const cfnParser = new cfn_parse.CfnParser({ + finder: { + findResource(lId): core.CfnResource | undefined { + return self.resources[lId]; + }, + findRefTarget(elementName: string): core.CfnElement | undefined { + return self.resources[elementName] ?? self.parameters[elementName]; + }, + findCondition(conditionName: string): core.CfnCondition | undefined { + return self.conditions[conditionName]; + }, + findMapping(mappingName): core.CfnMapping | undefined { + return self.mappings[mappingName]; + }, + }, + parameters: this.parametersToReplace, + }); + const hookAttributes = this.template.Hooks[hookName]; + + let hook: core.CfnHook; + switch (hookAttributes.Type) { + case 'AWS::CodeDeploy::BlueGreen': + hook = (core.CfnCodeDeployBlueGreenHook as any)._fromCloudFormation(this.hooksScope, hookName, hookAttributes, { + parser: cfnParser, + }); + break; + default: { + const hookProperties = cfnParser.parseValue(hookAttributes.Properties) ?? {}; + hook = new core.CfnHook(this.hooksScope, hookName, { + type: hookAttributes.Type, + properties: hookProperties, + }); + } + } + this.hooks[hookName] = hook; + this.overrideLogicalIdIfNeeded(hook, hookName); + } + private createOutput(logicalId: string, scope: core.Construct): void { const self = this; const outputAttributes = new cfn_parse.CfnParser({ diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/hook-code-deploy-blue-green-ecs.json b/packages/@aws-cdk/cloudformation-include/test/test-templates/hook-code-deploy-blue-green-ecs.json new file mode 100644 index 0000000000000..fc4abcab4e1ad --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/hook-code-deploy-blue-green-ecs.json @@ -0,0 +1,95 @@ +{ + "Hooks": { + "RandomHook": { + "Type": "UnknownToday" + }, + "EcsBlueGreenCodeDeployHook": { + "Type": "AWS::CodeDeploy::BlueGreen", + "Properties": { + "ServiceRole": "CodeDeployServiceRoleName", + "Applications": [ + { + "Target": { + "Type": "AWS::ECS::Service", + "LogicalID": "MyService" + }, + "ECSAttributes": { + "TaskDefinitions": [ + "MyTaskDefinition", "MyTaskDefinition" + ], + "TaskSets": [ + "MyTaskSet", "MyTaskSet" + ], + "TrafficRouting": { + "ProdTrafficRoute": { + "Type": "AWS::ElasticLoadBalancingV2::Listener", + "LogicalID": "AlbListener" + }, + "TestTrafficRoute": { + "Type": "AWS::ElasticLoadBalancingV2::Listener", + "LogicalID": "AlbListener" + }, + "TargetGroups": [ + "AlbTargetGroup", "AlbTargetGroup" + ] + } + } + } + ], + "TrafficRoutingConfig": { + "Type": "AllAtOnce", + "TimeBasedCanary": { + "StepPercentage": 1, + "BakeTimeMins": "2" + }, + "TimeBasedLinear": { + "StepPercentage": "3", + "BakeTimeMins": 4 + } + }, + "AdditionalOptions": { + "TerminationWaitTimeInMinutes": 5 + }, + "LifecycleEventHooks": { + "BeforeInstall": "f1", + "AfterInstall": "f2", + "AfterAllowTestTraffic": "f3", + "BeforeAllowTraffic": "f4", + "AfterAllowTraffic": "f5" + } + } + } + }, + "Resources": { + "MyService": { + "Type": "AWS::ECS::Service" + }, + "MyTaskDefinition": { + "Type": "AWS::ECS::TaskDefinition" + }, + "MyTaskSet": { + "Type": "AWS::ECS::TaskSet", + "Properties": { + "Cluster": "my-cluster", + "Service": { "Ref": "MyService" }, + "TaskDefinition": { "Fn::Sub": "${MyTaskDefinition}" } + } + }, + "AlbTargetGroup": { + "Type": "AWS::ElasticLoadBalancingV2::TargetGroup" + }, + "AlbListener": { + "Type": "AWS::ElasticLoadBalancingV2::Listener", + "Properties": { + "Port": 80, + "Protocol": "HTTP", + "DefaultActions": [ + { + "Type": "forward" + } + ], + "LoadBalancerArn": "my-lb" + } + } + } +} diff --git a/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts b/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts index 0ab537e3b326a..783db787093e5 100644 --- a/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts @@ -786,6 +786,24 @@ describe('CDK Include', () => { }).toThrow(/Rule with name 'DoesNotExist' was not found in the template/); }); + test('can ingest a template that contains Hooks, and allows retrieving those Hooks', () => { + const cfnTemplate = includeTestTemplate(stack, 'hook-code-deploy-blue-green-ecs.json'); + const hook = cfnTemplate.getHook('EcsBlueGreenCodeDeployHook'); + + expect(hook).toBeDefined(); + expect(stack).toMatchTemplate( + loadTestFileToJsObject('hook-code-deploy-blue-green-ecs.json'), + ); + }); + + test("throws an exception when attempting to retrieve a Hook that doesn't exist in the template", () => { + const cfnTemplate = includeTestTemplate(stack, 'hook-code-deploy-blue-green-ecs.json'); + + expect(() => { + cfnTemplate.getHook('DoesNotExist'); + }).toThrow(/Hook with logical ID 'DoesNotExist' was not found in the template/); + }); + test('replaces references to parameters with the user-specified values in Resources, Conditions, Metadata, and Options sections', () => { includeTestTemplate(stack, 'parameter-references.json', { parameters: { diff --git a/packages/@aws-cdk/core/lib/cfn-codedeploy-blue-green-hook.ts b/packages/@aws-cdk/core/lib/cfn-codedeploy-blue-green-hook.ts new file mode 100644 index 0000000000000..67f1b1b489fc5 --- /dev/null +++ b/packages/@aws-cdk/core/lib/cfn-codedeploy-blue-green-hook.ts @@ -0,0 +1,513 @@ +import { CfnHook } from './cfn-hook'; +import { FromCloudFormationOptions } from './cfn-parse'; +import { CfnResource } from './cfn-resource'; +import { Construct } from './construct-compat'; + +/** + * The possible types of traffic shifting for the blue-green deployment configuration. + * The type of the {@link CfnTrafficRoutingConfig.type} property. + */ +export enum CfnTrafficRoutingType { + /** + * Switch from blue to green at once. + */ + ALL_AT_ONCE = 'AllAtOnce', + + /** + * Specifies a configuration that shifts traffic from blue to green in two increments. + */ + TIME_BASED_CANARY = 'TimeBasedCanary', + + /** + * Specifies a configuration that shifts traffic from blue to green in equal increments, + * with an equal number of minutes between each increment. + */ + TIME_BASED_LINEAR = 'TimeBasedLinear', +} + +/** + * The traffic routing configuration if {@link CfnTrafficRoutingConfig.type} + * is {@link CfnTrafficRoutingType.TIME_BASED_CANARY}. + */ +export interface CfnTrafficRoutingTimeBasedCanary { + /** + * The percentage of traffic to shift in the first increment of a time-based canary deployment. + * The step percentage must be 14% or greater. + * + * @default 15 + */ + readonly stepPercentage?: number; + + /** + * The number of minutes between the first and second traffic shifts of a time-based canary deployment. + * + * @default 5 + */ + readonly bakeTimeMins?: number; +} + +/** + * The traffic routing configuration if {@link CfnTrafficRoutingConfig.type} + * is {@link CfnTrafficRoutingType.TIME_BASED_LINEAR}. + */ +export interface CfnTrafficRoutingTimeBasedLinear { + /** + * The percentage of traffic that is shifted at the start of each increment of a time-based linear deployment. + * The step percentage must be 14% or greater. + * + * @default 15 + */ + readonly stepPercentage?: number; + + /** + * The number of minutes between the first and second traffic shifts of a time-based linear deployment. + * + * @default 5 + */ + readonly bakeTimeMins?: number; +} + +/** + * Traffic routing configuration settings. + * The type of the {@link CfnCodeDeployBlueGreenHookProps.trafficRoutingConfig} property. + */ +export interface CfnTrafficRoutingConfig { + /** + * The type of traffic shifting used by the blue-green deployment configuration. + */ + readonly type: CfnTrafficRoutingType; + + /** + * The configuration for traffic routing when {@link type} is + * {@link CfnTrafficRoutingType.TIME_BASED_CANARY}. + * + * @default - none + */ + readonly timeBasedCanary?: CfnTrafficRoutingTimeBasedCanary; + + /** + * The configuration for traffic routing when {@link type} is + * {@link CfnTrafficRoutingType.TIME_BASED_LINEAR}. + * + * @default - none + */ + readonly timeBasedLinear?: CfnTrafficRoutingTimeBasedLinear; +} + +/** + * Additional options for the blue/green deployment. + * The type of the {@link CfnCodeDeployBlueGreenHookProps.additionalOptions} property. + */ +export interface CfnCodeDeployBlueGreenAdditionalOptions { + /** + * Specifies time to wait, in minutes, before terminating the blue resources. + * + * @default - 5 minutes + */ + readonly terminationWaitTimeInMinutes?: number; +} + +/** + * Lifecycle events for blue-green deployments. + * The type of the {@link CfnCodeDeployBlueGreenHookProps.lifecycleEventHooks} property. + */ +export interface CfnCodeDeployBlueGreenLifecycleEventHooks { + /** + * Function to use to run tasks before the replacement task set is created. + * + * @default - none + */ + readonly beforeInstall?: string; + + /** + * Function to use to run tasks after the replacement task set is created and one of the target groups is associated with it. + * + * @default - none + */ + readonly afterInstall?: string; + + /** + * Function to use to run tasks after the test listener serves traffic to the replacement task set. + * + * @default - none + */ + readonly afterAllowTestTraffic?: string; + + /** + * Function to use to run tasks after the second target group is associated with the replacement task set, + * but before traffic is shifted to the replacement task set. + * + * @default - none + */ + readonly beforeAllowTraffic?: string; + + /** + * Function to use to run tasks after the second target group serves traffic to the replacement task set. + * + * @default - none + */ + readonly afterAllowTraffic?: string; +} + +/** + * Type of the {@link CfnCodeDeployBlueGreenApplication.target} property. + */ +export interface CfnCodeDeployBlueGreenApplicationTarget { + /** + * The resource type of the target being deployed. + * Right now, the only allowed value is 'AWS::ECS::Service'. + */ + readonly type: string; + + /** + * The logical id of the target resource. + */ + readonly logicalId: string; +} + +/** + * A traffic route, + * representing where the traffic is being directed to. + */ +export interface CfnTrafficRoute { + /** + * The resource type of the route. + * Today, the only allowed value is 'AWS::ElasticLoadBalancingV2::Listener'. + */ + readonly type: string; + + /** + * The logical id of the target resource. + */ + readonly logicalId: string; +} + +/** + * Type of the {@link CfnCodeDeployBlueGreenEcsAttributes.trafficRouting} property. + */ +export interface CfnTrafficRouting { + /** + * The listener to be used by your load balancer to direct traffic to your target groups. + */ + readonly prodTrafficRoute: CfnTrafficRoute; + + /** + * The listener to be used by your load balancer to direct traffic to your target groups. + */ + readonly testTrafficRoute: CfnTrafficRoute; + + /** + * The logical IDs of the blue and green, respectively, + * AWS::ElasticLoadBalancingV2::TargetGroup target groups. + */ + readonly targetGroups: string[]; +} + +/** + * The attributes of the ECS Service being deployed. + * Type of the {@link CfnCodeDeployBlueGreenApplication.ecsAttributes} property. + */ +export interface CfnCodeDeployBlueGreenEcsAttributes { + /** + * The logical IDs of the blue and green, respectively, + * AWS::ECS::TaskDefinition task definitions. + */ + readonly taskDefinitions: string[]; + + /** + * The logical IDs of the blue and green, respectively, + * AWS::ECS::TaskSet task sets. + */ + readonly taskSets: string[]; + + /** + * The traffic routing configuration. + */ + readonly trafficRouting: CfnTrafficRouting; +} + +/** + * The application actually being deployed. + * Type of the {@link CfnCodeDeployBlueGreenHookProps.applications} property. + */ +export interface CfnCodeDeployBlueGreenApplication { + /** + * The target that is being deployed. + */ + readonly target: CfnCodeDeployBlueGreenApplicationTarget; + + /** + * The detailed attributes of the deployed target. + */ + readonly ecsAttributes: CfnCodeDeployBlueGreenEcsAttributes; +} + +/** + * Construction properties of {@link CfnCodeDeployBlueGreenHook}. + */ +export interface CfnCodeDeployBlueGreenHookProps { + /** + * The IAM Role for CloudFormation to use to perform blue-green deployments. + */ + readonly serviceRole: string; + + /** + * Properties of the Amazon ECS applications being deployed. + */ + readonly applications: CfnCodeDeployBlueGreenApplication[]; + + /** + * Traffic routing configuration settings. + * + * @default - time-based canary traffic shifting, with a 15% step percentage and a five minute bake time + */ + readonly trafficRoutingConfig?: CfnTrafficRoutingConfig; + + /** + * Additional options for the blue/green deployment. + * + * @default - no additional options + */ + readonly additionalOptions?: CfnCodeDeployBlueGreenAdditionalOptions; + + /** + * Use lifecycle event hooks to specify a Lambda function that CodeDeploy can call to validate a deployment. + * You can use the same function or a different one for deployment lifecycle events. + * Following completion of the validation tests, + * the Lambda {@link CfnCodeDeployBlueGreenLifecycleEventHooks.afterAllowTraffic} + * function calls back CodeDeploy and delivers a result of 'Succeeded' or 'Failed'. + * + * @default - no lifecycle event hooks + */ + readonly lifecycleEventHooks?: CfnCodeDeployBlueGreenLifecycleEventHooks; +} + +/** + * A CloudFormation Hook for CodeDeploy blue-green ECS deployments. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/blue-green.html#blue-green-template-reference + */ +export class CfnCodeDeployBlueGreenHook extends CfnHook { + /** + * A factory method that creates a new instance of this class from an object + * containing the CloudFormation properties of this resource. + * Used in the @aws-cdk/cloudformation-include module. + * + * @internal + */ + public static _fromCloudFormation(scope: Construct, id: string, hookAttributes: any, + options: FromCloudFormationOptions): CfnCodeDeployBlueGreenHook { + + hookAttributes = hookAttributes || {}; + const hookProperties = options.parser.parseValue(hookAttributes.Properties); + return new CfnCodeDeployBlueGreenHook(scope, id, { + serviceRole: hookProperties?.ServiceRole, + applications: hookProperties?.Applications?.map(applicationFromCloudFormation), + trafficRoutingConfig: { + type: hookProperties?.TrafficRoutingConfig?.Type, + timeBasedCanary: { + stepPercentage: hookProperties?.TrafficRoutingConfig?.TimeBasedCanary?.StepPercentage, + bakeTimeMins: hookProperties?.TrafficRoutingConfig?.TimeBasedCanary?.BakeTimeMins, + }, + timeBasedLinear: { + stepPercentage: hookProperties?.TrafficRoutingConfig?.TimeBasedLinear?.StepPercentage, + bakeTimeMins: hookProperties?.TrafficRoutingConfig?.TimeBasedLinear?.BakeTimeMins, + }, + }, + additionalOptions: { + terminationWaitTimeInMinutes: hookProperties?.AdditionalOptions?.TerminationWaitTimeInMinutes, + }, + lifecycleEventHooks: { + beforeInstall: hookProperties?.LifecycleEventHooks?.BeforeInstall, + afterInstall: hookProperties?.LifecycleEventHooks?.AfterInstall, + afterAllowTestTraffic: hookProperties?.LifecycleEventHooks?.AfterAllowTestTraffic, + beforeAllowTraffic: hookProperties?.LifecycleEventHooks?.BeforeAllowTraffic, + afterAllowTraffic: hookProperties?.LifecycleEventHooks?.AfterAllowTraffic, + }, + }); + + function applicationFromCloudFormation(app: any) { + const target = findResource(app?.Target?.LogicalID); + const taskDefinitions: Array | undefined = app?.ECSAttributes?.TaskDefinitions?.map( + (td: any) => findResource(td)); + const taskSets: Array | undefined = app?.ECSAttributes?.TaskSets?.map( + (ts: any) => findResource(ts)); + const prodTrafficRoute = findResource(app?.ECSAttributes?.TrafficRouting?.ProdTrafficRoute?.LogicalID); + const testTrafficRoute = findResource(app?.ECSAttributes?.TrafficRouting?.TestTrafficRoute?.LogicalID); + const targetGroups: Array | undefined = app?.ECSAttributes?.TrafficRouting?.TargetGroups?.map( + (tg: any) => findResource(tg)); + + return { + target: { + type: app?.Target?.Type, + logicalId: target?.logicalId, + }, + ecsAttributes: { + taskDefinitions: taskDefinitions?.map(td => td?.logicalId), + taskSets: taskSets?.map(ts => ts?.logicalId), + trafficRouting: { + prodTrafficRoute: { + type: app?.ECSAttributes?.TrafficRouting?.ProdTrafficRoute?.Type, + logicalId: prodTrafficRoute?.logicalId, + }, + testTrafficRoute: { + type: app?.ECSAttributes?.TrafficRouting?.TestTrafficRoute?.Type, + logicalId: testTrafficRoute?.logicalId, + }, + targetGroups: targetGroups?.map((tg) => tg?.logicalId), + }, + }, + }; + } + + function findResource(logicalId: string | undefined): CfnResource | undefined { + if (logicalId == null) { + return undefined; + } + const ret = options.parser.finder.findResource(logicalId); + if (!ret) { + throw new Error(`Hook '${id}' references resource '${logicalId}' that was not found in the template`); + } + return ret; + } + } + + private _serviceRole: string; + private _applications: CfnCodeDeployBlueGreenApplication[]; + private _trafficRoutingConfig?: CfnTrafficRoutingConfig; + private _additionalOptions?: CfnCodeDeployBlueGreenAdditionalOptions; + private _lifecycleEventHooks?: CfnCodeDeployBlueGreenLifecycleEventHooks; + + /** + * Creates a new CodeDeploy blue-green ECS Hook. + * + * @param scope the scope to create the hook in (usually the containing Stack object) + * @param id the identifier of the construct - will be used to generate the logical ID of the Hook + * @param props the properties of the Hook + */ + constructor(scope: Construct, id: string, props: CfnCodeDeployBlueGreenHookProps) { + super(scope, id, { + type: 'AWS::CodeDeploy::BlueGreen', + // we render the properties ourselves + }); + + this._serviceRole = props.serviceRole; + this._applications = props.applications; + this._trafficRoutingConfig = props.trafficRoutingConfig; + this._additionalOptions = props.additionalOptions; + this._lifecycleEventHooks = props.lifecycleEventHooks; + } + + /** + * The IAM Role for CloudFormation to use to perform blue-green deployments. + */ + public get serviceRole(): string { + return this._serviceRole; + } + + public set serviceRole(serviceRole: string) { + this._serviceRole = serviceRole; + } + + /** + * Properties of the Amazon ECS applications being deployed. + */ + public get applications(): CfnCodeDeployBlueGreenApplication[] { + return this._applications; + } + + public set applications(value: CfnCodeDeployBlueGreenApplication[]) { + this._applications = value; + } + + /** + * Traffic routing configuration settings. + * + * @default - time-based canary traffic shifting, with a 15% step percentage and a five minute bake time + */ + public get trafficRoutingConfig(): CfnTrafficRoutingConfig | undefined { + return this._trafficRoutingConfig; + } + + public set trafficRoutingConfig(value: CfnTrafficRoutingConfig | undefined) { + this._trafficRoutingConfig = value; + } + + /** + * Additional options for the blue/green deployment. + * + * @default - no additional options + */ + public get additionalOptions(): CfnCodeDeployBlueGreenAdditionalOptions | undefined { + return this._additionalOptions; + } + + public set additionalOptions(value: CfnCodeDeployBlueGreenAdditionalOptions | undefined) { + this._additionalOptions = value; + } + + /** + * Use lifecycle event hooks to specify a Lambda function that CodeDeploy can call to validate a deployment. + * You can use the same function or a different one for deployment lifecycle events. + * Following completion of the validation tests, + * the Lambda {@link CfnCodeDeployBlueGreenLifecycleEventHooks.afterAllowTraffic} + * function calls back CodeDeploy and delivers a result of 'Succeeded' or 'Failed'. + * + * @default - no lifecycle event hooks + */ + public get lifecycleEventHooks(): CfnCodeDeployBlueGreenLifecycleEventHooks | undefined { + return this._lifecycleEventHooks; + } + + public set lifecycleEventHooks(value: CfnCodeDeployBlueGreenLifecycleEventHooks | undefined) { + this._lifecycleEventHooks = value; + } + + protected renderProperties(_props?: { [p: string]: any }): { [p: string]: any } | undefined { + return { + ServiceRole: this.serviceRole, + Applications: this.applications.map((app) => ({ + Target: { + Type: app.target.type, + LogicalID: app.target.logicalId, + }, + ECSAttributes: { + TaskDefinitions: app.ecsAttributes.taskDefinitions, + TaskSets: app.ecsAttributes.taskSets, + TrafficRouting: { + ProdTrafficRoute: { + Type: app.ecsAttributes.trafficRouting.prodTrafficRoute.type, + LogicalID: app.ecsAttributes.trafficRouting.prodTrafficRoute.logicalId, + }, + TestTrafficRoute: { + Type: app.ecsAttributes.trafficRouting.testTrafficRoute.type, + LogicalID: app.ecsAttributes.trafficRouting.testTrafficRoute.logicalId, + }, + TargetGroups: app.ecsAttributes.trafficRouting.targetGroups, + }, + }, + })), + TrafficRoutingConfig: { + Type: this.trafficRoutingConfig?.type, + TimeBasedCanary: { + StepPercentage: this.trafficRoutingConfig?.timeBasedCanary?.stepPercentage, + BakeTimeMins: this.trafficRoutingConfig?.timeBasedCanary?.bakeTimeMins, + }, + TimeBasedLinear: { + StepPercentage: this.trafficRoutingConfig?.timeBasedLinear?.stepPercentage, + BakeTimeMins: this.trafficRoutingConfig?.timeBasedLinear?.bakeTimeMins, + }, + }, + AdditionalOptions: { + TerminationWaitTimeInMinutes: this.additionalOptions?.terminationWaitTimeInMinutes, + }, + LifecycleEventHooks: { + BeforeInstall: this.lifecycleEventHooks?.beforeInstall, + AfterInstall: this.lifecycleEventHooks?.afterInstall, + AfterAllowTestTraffic: this.lifecycleEventHooks?.afterAllowTestTraffic, + BeforeAllowTraffic: this.lifecycleEventHooks?.beforeAllowTraffic, + AfterAllowTraffic: this.lifecycleEventHooks?.afterAllowTraffic, + }, + }; + } +} diff --git a/packages/@aws-cdk/core/lib/cfn-hook.ts b/packages/@aws-cdk/core/lib/cfn-hook.ts new file mode 100644 index 0000000000000..8e83ae4e8da28 --- /dev/null +++ b/packages/@aws-cdk/core/lib/cfn-hook.ts @@ -0,0 +1,60 @@ +import { CfnElement } from './cfn-element'; +import { Construct } from './construct-compat'; +import { ignoreEmpty } from './util'; + +/** + * Construction properties of {@link CfnHook}. + */ +export interface CfnHookProps { + /** + * The type of the hook + * (for example, "AWS::CodeDeploy::BlueGreen"). + */ + readonly type: string; + + /** + * The properties of the hook. + * + * @default - no properties + */ + readonly properties?: { [name: string]: any }; +} + +/** + * Represents a CloudFormation resource. + */ +export class CfnHook extends CfnElement { + /** + * The type of the hook + * (for example, "AWS::CodeDeploy::BlueGreen"). + */ + public readonly type: string; + + private readonly _cfnHookProperties?: { [name: string]: any }; + + /** + * Creates a new Hook object. + */ + constructor(scope: Construct, id: string, props: CfnHookProps) { + super(scope, id); + + this.type = props.type; + this._cfnHookProperties = props.properties; + } + + /** @internal */ + public _toCloudFormation(): object { + return { + Hooks: { + [this.logicalId]: { + Type: this.type, + Properties: ignoreEmpty(this.renderProperties(this._cfnHookProperties)), + }, + }, + }; + } + + protected renderProperties(props?: {[key: string]: any}): { [key: string]: any } | undefined { + return props; + } +} diff --git a/packages/@aws-cdk/core/lib/cfn-parse.ts b/packages/@aws-cdk/core/lib/cfn-parse.ts index 4ae2eb69746ba..746d15c9fd705 100644 --- a/packages/@aws-cdk/core/lib/cfn-parse.ts +++ b/packages/@aws-cdk/core/lib/cfn-parse.ts @@ -268,7 +268,6 @@ export class CfnParser { } public handleAttributes(resource: CfnResource, resourceAttributes: any, logicalId: string): void { - const finder = this.options.finder; const cfnOptions = resource.cfnOptions; cfnOptions.creationPolicy = this.parseCreationPolicy(resourceAttributes.CreationPolicy); @@ -279,7 +278,7 @@ export class CfnParser { // handle Condition if (resourceAttributes.Condition) { - const condition = finder.findCondition(resourceAttributes.Condition); + const condition = this.finder.findCondition(resourceAttributes.Condition); if (!condition) { throw new Error(`Resource '${logicalId}' uses Condition '${resourceAttributes.Condition}' that doesn't exist`); } @@ -291,7 +290,7 @@ export class CfnParser { const dependencies: string[] = Array.isArray(resourceAttributes.DependsOn) ? resourceAttributes.DependsOn : [resourceAttributes.DependsOn]; for (const dep of dependencies) { - const depResource = finder.findResource(dep); + const depResource = this.finder.findResource(dep); if (!depResource) { throw new Error(`Resource '${logicalId}' depends on '${dep}' that doesn't exist`); } @@ -424,6 +423,10 @@ export class CfnParser { return cfnValue; } + public get finder(): ICfnFinder { + return this.options.finder; + } + private parseIfCfnIntrinsic(object: any): any { const key = this.looksLikeCfnIntrinsic(object); switch (key) { @@ -435,7 +438,7 @@ export class CfnParser { if (specialRef) { return specialRef; } else { - const refElement = this.options.finder.findRefTarget(refTarget); + const refElement = this.finder.findRefTarget(refTarget); if (!refElement) { throw new Error(`Element used in Ref expression with logical ID: '${refTarget}' not found`); } @@ -445,7 +448,7 @@ export class CfnParser { case 'Fn::GetAtt': { // Fn::GetAtt takes a 2-element list as its argument const value = object[key]; - const target = this.options.finder.findResource(value[0]); + const target = this.finder.findResource(value[0]); if (!target) { throw new Error(`Resource used in GetAtt expression with logical ID: '${value[0]}' not found`); } @@ -469,7 +472,7 @@ export class CfnParser { case 'Fn::FindInMap': { const value = this.parseValue(object[key]); // the first argument to FindInMap is the mapping name - const mapping = this.options.finder.findMapping(value[0]); + const mapping = this.finder.findMapping(value[0]); if (!mapping) { throw new Error(`Mapping used in FindInMap expression with name '${value[0]}' was not found in the template`); } @@ -503,7 +506,7 @@ export class CfnParser { // Fn::If takes a 3-element list as its argument, // where the first element is the name of a Condition const value = this.parseValue(object[key]); - const condition = this.options.finder.findCondition(value[0]); + const condition = this.finder.findCondition(value[0]); if (!condition) { throw new Error(`Condition '${value[0]}' used in an Fn::If expression does not exist in the template`); } @@ -541,7 +544,7 @@ export class CfnParser { } case 'Condition': { // a reference to a Condition from another Condition - const condition = this.options.finder.findCondition(object[key]); + const condition = this.finder.findCondition(object[key]); if (!condition) { throw new Error(`Referenced Condition with name '${object[key]}' was not found in the template`); } @@ -600,14 +603,14 @@ export class CfnParser { const dotIndex = refTarget.indexOf('.'); const isRef = dotIndex === -1; if (isRef) { - const refElement = this.options.finder.findRefTarget(refTarget); + const refElement = this.finder.findRefTarget(refTarget); if (!refElement) { throw new Error(`Element referenced in Fn::Sub expression with logical ID: '${refTarget}' was not found in the template`); } return leftHalf + CfnReference.for(refElement, 'Ref', true).toString() + this.parseFnSubString(rightHalf, map); } else { const targetId = refTarget.substring(0, dotIndex); - const refResource = this.options.finder.findResource(targetId); + const refResource = this.finder.findResource(targetId); if (!refResource) { throw new Error(`Resource referenced in Fn::Sub expression with logical ID: '${targetId}' was not found in the template`); } @@ -630,7 +633,7 @@ export class CfnParser { // fail here - this substitution is not allowed throw new Error(`Cannot substitute parameter '${parameterName}' used in Fn::ValueOf expression with attribute '${value[1]}'`); } - const param = this.options.finder.findRefTarget(parameterName); + const param = this.finder.findRefTarget(parameterName); if (!param) { throw new Error(`Rule references parameter '${parameterName}' which was not found in the template`); } diff --git a/packages/@aws-cdk/core/lib/index.ts b/packages/@aws-cdk/core/lib/index.ts index 28cae5f1d8e68..2b4f6470cc559 100644 --- a/packages/@aws-cdk/core/lib/index.ts +++ b/packages/@aws-cdk/core/lib/index.ts @@ -13,6 +13,8 @@ export * from './stack-synthesizers'; export * from './reference'; export * from './cfn-condition'; export * from './cfn-fn'; +export * from './cfn-hook'; +export * from './cfn-codedeploy-blue-green-hook'; export * from './cfn-include'; export * from './cfn-mapping'; export * from './cfn-output'; diff --git a/packages/@aws-cdk/core/package.json b/packages/@aws-cdk/core/package.json index e71ef1f3f3841..2b4572467a42d 100644 --- a/packages/@aws-cdk/core/package.json +++ b/packages/@aws-cdk/core/package.json @@ -41,6 +41,10 @@ "duration-prop-type:@aws-cdk/core.ResourceSignal.timeout", "props-no-any:@aws-cdk/core.CfnParameterProps.default", "props-no-cfn-types:@aws-cdk/core.CfnRuleProps.assertions", + "props-no-cfn-types:@aws-cdk/core.CfnCodeDeployBlueGreenHookProps.applications", + "props-no-cfn-types:@aws-cdk/core.CfnCodeDeployBlueGreenHookProps.additionalOptions", + "props-no-cfn-types:@aws-cdk/core.CfnCodeDeployBlueGreenHookProps.lifecycleEventHooks", + "props-no-cfn-types:@aws-cdk/core.CfnCodeDeployBlueGreenHookProps.trafficRoutingConfig", "construct-ctor:@aws-cdk/core.Stack..params[1]", "docs-public-apis:@aws-cdk/core.ScopedAws.urlSuffix", "docs-public-apis:@aws-cdk/core.TagType.NOT_TAGGABLE",