diff --git a/CHANGELOG.md b/CHANGELOG.md index df38cbd61a42e..23cce398a8d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,68 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [1.62.0](https://github.com/aws/aws-cdk/compare/v1.61.1...v1.62.0) (2020-09-03) + + +### ⚠ BREAKING CHANGES TO EXPERIMENTAL FEATURES + +* **eks:** when importing EKS clusters using `eks.Cluster.fromClusterAttributes`, the `clusterArn` attribute is not supported anymore, and will always be derived from `clusterName`. +* **eks**: Only a single `eks.Cluster` is allowed per CloudFormation stack. +* **eks**: The `securityGroups` attribute of `ClusterAttributes` is now `securityGroupIds`. +* **cli**: `--qualifier` must be alphanumeric and not longer than 10 characters when bootstrapping using `newStyleStackSynthesis`. + +### Features + +* **appsync:** support Input Types for code-first approach ([#10024](https://github.com/aws/aws-cdk/issues/10024)) ([3f80ae6](https://github.com/aws/aws-cdk/commit/3f80ae6c7886c1bac1cefa5f613962e17a34cc54)) +* **appsync:** support query & mutation generation for code-first approach ([#9992](https://github.com/aws/aws-cdk/issues/9992)) ([1ed119e](https://github.com/aws/aws-cdk/commit/1ed119e2cdbc37666616f6666b0edb12c2c9ea89)), closes [#9308](https://github.com/aws/aws-cdk/issues/9308) [#9310](https://github.com/aws/aws-cdk/issues/9310) +* **aws-chatbot:** Support L2 construct for SlackChannelConfiguration of chatbot. ([#9702](https://github.com/aws/aws-cdk/issues/9702)) ([05f5e62](https://github.com/aws/aws-cdk/commit/05f5e621d82bc4c32fba954820276e8c40381d9b)), closes [#9679](https://github.com/aws/aws-cdk/issues/9679) +* **bootstrap:** customizable bootstrap template ([#9886](https://github.com/aws/aws-cdk/issues/9886)) ([2596ef7](https://github.com/aws/aws-cdk/commit/2596ef7a99c8eeba79609d60144842f5d33fdf9b)), closes [#9256](https://github.com/aws/aws-cdk/issues/9256) [#8724](https://github.com/aws/aws-cdk/issues/8724) [#3684](https://github.com/aws/aws-cdk/issues/3684) [#1528](https://github.com/aws/aws-cdk/issues/1528) [#9681](https://github.com/aws/aws-cdk/issues/9681) +* **cli:** control progress output style with --progress=bar|events ([#9623](https://github.com/aws/aws-cdk/issues/9623)) ([56de5e1](https://github.com/aws/aws-cdk/commit/56de5e15e52768a5c63c02e7101b95a95f7cbc94)), closes [#8696](https://github.com/aws/aws-cdk/issues/8696) +* **cloudfront:** import existing CloudFrontWebDistributions ([#10007](https://github.com/aws/aws-cdk/issues/10007)) ([ff33b54](https://github.com/aws/aws-cdk/commit/ff33b5416116fd23cf160078bf53651096bde284)), closes [#5607](https://github.com/aws/aws-cdk/issues/5607) +* **cloudfront:** support includeBody for Lambda@Edge ([#10008](https://github.com/aws/aws-cdk/issues/10008)) ([9ffb268](https://github.com/aws/aws-cdk/commit/9ffb2682c167fe92e302bc322d60b9ae37de934a)), closes [#7085](https://github.com/aws/aws-cdk/issues/7085) +* **ecs:** bottlerocket support ([#10097](https://github.com/aws/aws-cdk/issues/10097)) ([088abec](https://github.com/aws/aws-cdk/commit/088abec6513d8ae665a3a10bee5c5b5fe61a48b9)), closes [#10085](https://github.com/aws/aws-cdk/issues/10085) +* **eks:** kubectl layer customization ([#10090](https://github.com/aws/aws-cdk/issues/10090)) ([0aa7ada](https://github.com/aws/aws-cdk/commit/0aa7adac958fb7997b64eba8c7fc3008e8557480)), closes [#7992](https://github.com/aws/aws-cdk/issues/7992) +* **eks:** support adding k8s resources to imported clusters ([#9802](https://github.com/aws/aws-cdk/issues/9802)) ([4439481](https://github.com/aws/aws-cdk/commit/443948164e09aaa81c094c013b32aa1f67b69570)), closes [#5383](https://github.com/aws/aws-cdk/issues/5383) +* **logs:** specify log group's region for LogRetention ([#9804](https://github.com/aws/aws-cdk/issues/9804)) ([0ccbc5d](https://github.com/aws/aws-cdk/commit/0ccbc5dfe5c841ec821ac98ac219e98984237cba)) +* **pipelines:** `SimpleSynthAction` takes array of build commands ([#10152](https://github.com/aws/aws-cdk/issues/10152)) ([44fcb4e](https://github.com/aws/aws-cdk/commit/44fcb4e65219b48aa9e186d1d6c10ca632e9658d)), closes [#9357](https://github.com/aws/aws-cdk/issues/9357) +* **pipelines:** add control over underlying CodePipeline ([#10148](https://github.com/aws/aws-cdk/issues/10148)) ([41531b5](https://github.com/aws/aws-cdk/commit/41531b57ae1b19087399018b063da45356bf07bb)), closes [#9021](https://github.com/aws/aws-cdk/issues/9021) +* **rds:** add support for joining instance to domain ([#9943](https://github.com/aws/aws-cdk/issues/9943)) ([f2d77d1](https://github.com/aws/aws-cdk/commit/f2d77d16d62e80d23c200ea94e4181660d953ca2)), closes [#9869](https://github.com/aws/aws-cdk/issues/9869) +* **rds:** custom security groups for OptionGroups ([ea1072d](https://github.com/aws/aws-cdk/commit/ea1072d3baa50d8a722795557765360286195b79)), closes [#9240](https://github.com/aws/aws-cdk/issues/9240) +* **rds:** custom security groups for OptionGroups ([#10011](https://github.com/aws/aws-cdk/issues/10011)) ([5738dc1](https://github.com/aws/aws-cdk/commit/5738dc17025355e3f94edc4af242253ebb3409f6)), closes [#9240](https://github.com/aws/aws-cdk/issues/9240) +* **rds:** performance insights for DatabaseCluster instances ([#10092](https://github.com/aws/aws-cdk/issues/10092)) ([9c1b0c1](https://github.com/aws/aws-cdk/commit/9c1b0c1b27ba4680a1e15cbd6a30a8f10dfe6313)), closes [#7957](https://github.com/aws/aws-cdk/issues/7957) +* **rds:** rename DatabaseInstanceNewProps.vpcPlacement to vpcSubnets ([#10093](https://github.com/aws/aws-cdk/issues/10093)) ([ec423ef](https://github.com/aws/aws-cdk/commit/ec423eff18809173a01d0c15e02ed4f042061310)), closes [#9776](https://github.com/aws/aws-cdk/issues/9776) +* **elasticloadbalancingv2:** convenience method for ALB redirects ([#9913](https://github.com/aws/aws-cdk/issues/9913)) ([5bed08a](https://github.com/aws/aws-cdk/commit/5bed08a30880652a5113245bd455228bd8bf32a2)) + + +### Bug Fixes + +* **apigateway:** burst and rate limits are set to unlimited when configured to 0 ([#10088](https://github.com/aws/aws-cdk/issues/10088)) ([96f1772](https://github.com/aws/aws-cdk/commit/96f1772ab861015f24703a1315538d37ae9529ad)), closes [#10071](https://github.com/aws/aws-cdk/issues/10071) +* **appsync:** `GraphQLApi.UserPoolConfig` requires `DefaultAction` ([#10031](https://github.com/aws/aws-cdk/issues/10031)) ([6114045](https://github.com/aws/aws-cdk/commit/6114045a4861efc7364f94490b734df5cf019726)), closes [#10028](https://github.com/aws/aws-cdk/issues/10028) +* **aws-elasticloadbalancingv2:** fix load balancer deletion protection to properly update when set to false ([#9986](https://github.com/aws/aws-cdk/issues/9986)) ([a65dd19](https://github.com/aws/aws-cdk/commit/a65dd190b0856db7880177910d4096a799791ee1)) +* **aws-sns:** enable topic encryption with cross account keys ([#10056](https://github.com/aws/aws-cdk/issues/10056)) ([327b72a](https://github.com/aws/aws-cdk/commit/327b72a0f4778318a937a069a5169c2174179dc0)), closes [#10055](https://github.com/aws/aws-cdk/issues/10055) +* **aws-stepfunctions-tasks:** missing permission to get build status ([#10081](https://github.com/aws/aws-cdk/issues/10081)) ([cbdd084](https://github.com/aws/aws-cdk/commit/cbdd084d7b3eb92a311da48c279b5423e1ae22a2)), closes [#8043](https://github.com/aws/aws-cdk/issues/8043) +* **aws-stepfunctions-tasks:** SageMaker create training job has incorrect property name for AttributeNames ([#10026](https://github.com/aws/aws-cdk/issues/10026)) ([ba51ea3](https://github.com/aws/aws-cdk/commit/ba51ea34e5b3f3c3cf337754d339f724b395211e)), closes [#10014](https://github.com/aws/aws-cdk/issues/10014) +* **cfn-include:** allow Conditions to reference Mappings in their definitions ([#10105](https://github.com/aws/aws-cdk/issues/10105)) ([aa2068f](https://github.com/aws/aws-cdk/commit/aa2068f0d560de5737bd0a3df8089f8af2128e09)), closes [#10099](https://github.com/aws/aws-cdk/issues/10099) +* **cfn-include:** allow parameters to be replaced across nested stacks ([#9842](https://github.com/aws/aws-cdk/issues/9842)) ([9ea8d5c](https://github.com/aws/aws-cdk/commit/9ea8d5c2d638bdf1f5bc63be197ecefc775d6539)), closes [#9838](https://github.com/aws/aws-cdk/issues/9838) +* **cli:** AssumeRole profiles require a [default] profile ([#10032](https://github.com/aws/aws-cdk/issues/10032)) ([95c0332](https://github.com/aws/aws-cdk/commit/95c0332395d1203e8b00fda153fe08e70d0387c5)), closes [#9937](https://github.com/aws/aws-cdk/issues/9937) +* **cli:** bootstrapping qualifier length not validated ([#10121](https://github.com/aws/aws-cdk/issues/10121)) ([e069263](https://github.com/aws/aws-cdk/commit/e0692636571eec76068e4cec0a87f13fc292fea0)), closes [#9255](https://github.com/aws/aws-cdk/issues/9255) +* **cli:** Linux browser not supported for `cdk docs` ([#9549](https://github.com/aws/aws-cdk/issues/9549)) ([663913f](https://github.com/aws/aws-cdk/commit/663913f061f0fa3e2bed11b8cea763b12a3061f2)), closes [#2847](https://github.com/aws/aws-cdk/issues/2847) +* **cli:** re-bootstrapping loses previous configuration ([#10120](https://github.com/aws/aws-cdk/issues/10120)) ([4e5829a](https://github.com/aws/aws-cdk/commit/4e5829ac5bb55533435772c3f2f294394ab2c973)), closes [#10091](https://github.com/aws/aws-cdk/issues/10091) +* **cli:** unable to upgrade new style bootstrap to version ([#10030](https://github.com/aws/aws-cdk/issues/10030)) ([c5bb55c](https://github.com/aws/aws-cdk/commit/c5bb55c37c03597139522e0bb42f094c1f6b647e)), closes [#10016](https://github.com/aws/aws-cdk/issues/10016) +* **cloudfront:** Distribution does not add edgelambda trust policy ([#10006](https://github.com/aws/aws-cdk/issues/10006)) ([9098e29](https://github.com/aws/aws-cdk/commit/9098e295826c09ef568bb8fc03c217ce8a15b822)), closes [#9998](https://github.com/aws/aws-cdk/issues/9998) +* **custom-resources:** buffers returned by AwsCustomResource are unusable ([#9977](https://github.com/aws/aws-cdk/issues/9977)) ([7f351ff](https://github.com/aws/aws-cdk/commit/7f351ffeee30e1a2451e9b456c0d0a21002397da)), closes [#9969](https://github.com/aws/aws-cdk/issues/9969) [#10017](https://github.com/aws/aws-cdk/issues/10017) +* **eks:** creating a `ServiceAccount` in a different stack than the `Cluster` creates circular dependency between the two stacks ([#9701](https://github.com/aws/aws-cdk/issues/9701)) ([1e96ebc](https://github.com/aws/aws-cdk/commit/1e96ebc29e1db251a1dc1e046c302943e7556c9a)), closes [40aws-cdk/aws-eks/lib/service-account.ts#L81-L95](https://github.com/40aws-cdk/aws-eks/lib/service-account.ts/issues/L81-L95) [40aws-cdk/aws-eks/lib/cluster.ts#L914-L923](https://github.com/40aws-cdk/aws-eks/lib/cluster.ts/issues/L914-L923) [40aws-cdk/aws-eks/lib/cluster.ts#L907-L909](https://github.com/40aws-cdk/aws-eks/lib/cluster.ts/issues/L907-L909) +* **eks:** README.md grammar ([#10072](https://github.com/aws/aws-cdk/issues/10072)) ([454cdc6](https://github.com/aws/aws-cdk/commit/454cdc6106bee1ec23e8e9f390c03ebf6fcf2957)) +* **elbv2:** add protocol to AddNetworkTargetsProps ([#10054](https://github.com/aws/aws-cdk/issues/10054)) ([c7c00e7](https://github.com/aws/aws-cdk/commit/c7c00e73e5e9be5b5fa65394f80eb5fb47fe4153)), closes [aws/aws-cdk#10044](https://github.com/aws/aws-cdk/issues/10044) +* **elbv2:** consider default protocol when validating redirectHTTP ([#10100](https://github.com/aws/aws-cdk/issues/10100)) ([9e4c6d2](https://github.com/aws/aws-cdk/commit/9e4c6d22890125328d26923e44c4885ae7daecbf)) +* **glue:** tables not including classification ([#9923](https://github.com/aws/aws-cdk/issues/9923)) ([61b45f3](https://github.com/aws/aws-cdk/commit/61b45f30f8aefef8e8989b597d4cf32ea731f324)), closes [#9902](https://github.com/aws/aws-cdk/issues/9902) +* **lamba:** Add Java 8 Corretto Runtime support ([77f9703](https://github.com/aws/aws-cdk/commit/77f97039221981aea980b583e56ac88ed854a8e4)) +* **lambda:** grantInvoke fails for imported IAM identities ([#9957](https://github.com/aws/aws-cdk/issues/9957)) ([d748f44](https://github.com/aws/aws-cdk/commit/d748f4400e28fcb0933df6c57df36740381deff3)), closes [#9883](https://github.com/aws/aws-cdk/issues/9883) +* **lambda-nodejs:** cannot stat error with jsx/tsx handler ([#9958](https://github.com/aws/aws-cdk/issues/9958)) ([25cfc18](https://github.com/aws/aws-cdk/commit/25cfc18f116e9ae3861de52af9f81fcec8454ae2)) +* **lambda-python:** allowPublicSubnet and filesystem not supported ([#10022](https://github.com/aws/aws-cdk/issues/10022)) ([745922a](https://github.com/aws/aws-cdk/commit/745922aa5a5a0195869830b54d7e529bec83e37c)), closes [#10018](https://github.com/aws/aws-cdk/issues/10018) [#10027](https://github.com/aws/aws-cdk/issues/10027) +* **redshift:** single-node clusters fail with node count error ([#9961](https://github.com/aws/aws-cdk/issues/9961)) ([2cd3ea2](https://github.com/aws/aws-cdk/commit/2cd3ea21a92e624c3d07f7f4cb46391ef33e756e)), closes [#9856](https://github.com/aws/aws-cdk/issues/9856) +* **route53:** value is too long error for TXT records ([#9984](https://github.com/aws/aws-cdk/issues/9984)) ([fd4be21](https://github.com/aws/aws-cdk/commit/fd4be21112ecd0cf3fd7ddfe005968507a5d18d4)), closes [#8244](https://github.com/aws/aws-cdk/issues/8244) + ## [1.61.1](https://github.com/aws/aws-cdk/compare/v1.61.0...v1.61.1) (2020-08-28) diff --git a/lerna.json b/lerna.json index 87746feb847f4..55e11b15eb0cf 100644 --- a/lerna.json +++ b/lerna.json @@ -10,5 +10,5 @@ "tools/*" ], "rejectCycles": "true", - "version": "1.61.1" + "version": "1.62.0" } diff --git a/packages/@aws-cdk/aws-appsync/lib/private.ts b/packages/@aws-cdk/aws-appsync/lib/private.ts index 981076113be6e..f31ab439bccc8 100644 --- a/packages/@aws-cdk/aws-appsync/lib/private.ts +++ b/packages/@aws-cdk/aws-appsync/lib/private.ts @@ -233,5 +233,5 @@ function generateDirectives(options: generateDirectivesOptions): string { // reduce over all directives and get string version of the directive // pass in the auth modes for checks to happen on compile time return options.directives.reduce((acc, directive) => - `${acc}${directive.toString(options.modes)}${options.delimiter ?? ' '}`, ' ').slice(0, -1); + `${acc}${directive._bindToAuthModes(options.modes).toString()}${options.delimiter ?? ' '}`, ' ').slice(0, -1); } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-appsync/lib/schema-base.ts b/packages/@aws-cdk/aws-appsync/lib/schema-base.ts index 628cbf80af8c3..96e537a17d815 100644 --- a/packages/@aws-cdk/aws-appsync/lib/schema-base.ts +++ b/packages/@aws-cdk/aws-appsync/lib/schema-base.ts @@ -1,4 +1,4 @@ -import { AuthorizationType } from './graphqlapi'; +import { AuthorizationType, GraphqlApi } from './graphqlapi'; import { Resolver } from './resolver'; import { ResolvableFieldOptions, BaseTypeOptions, GraphqlType } from './schema-field'; import { InterfaceType } from './schema-intermediate'; @@ -67,6 +67,8 @@ export interface IField { * Generate the directives for this field * * @param modes the authorization modes of the graphql api + * + * @default - no authorization modes */ directivesToString(modes?: AuthorizationType[]): string } @@ -135,7 +137,16 @@ export interface IIntermediateType { * * @default - no intermediate type */ - readonly intermediateType?: InterfaceType; + readonly intermediateType?: IIntermediateType; + + /** + * Method called when the stringifying Intermediate Types for schema generation + * + * @param api The binding GraphQL Api [disable-awslint:ref-via-interface] + * + * @internal + */ + _bindToGraphqlApi(api: GraphqlApi): IIntermediateType; /** * Create an GraphQL Type representing this Intermediate Type @@ -149,15 +160,11 @@ export interface IIntermediateType { /** * Generate the string of this object type - * - * @param modes the authorization modes for the graphql api */ - toString(modes?: AuthorizationType[]): string; + toString(): string; /** * Add a field to this Intermediate Type - * - * @param options - the options to add a field */ addField(options: AddFieldOptions): void; } @@ -221,6 +228,11 @@ export class Directive { */ private statement: string; + /** + * the authorization modes for this intermediate type + */ + protected modes?: AuthorizationType[]; + private readonly mode?: AuthorizationType; private constructor(statement: string, mode?: AuthorizationType) { @@ -228,15 +240,26 @@ export class Directive { this.mode = mode; } + /** + * Method called when the stringifying Directive for schema generation + * + * @param modes the authorization modes + * + * @internal + */ + public _bindToAuthModes(modes?: AuthorizationType[]): Directive { + this.modes = modes; + return this; + } + /** * Generate the directive statement - * @param modes the authorization modes of the graphql api */ - public toString(modes?: AuthorizationType[]): string { - if (modes && this.mode && !modes.some((mode) => mode === this.mode)) { + public toString(): string { + if (this.modes && this.mode && !this.modes.some((mode) => mode === this.mode)) { throw new Error(`No Authorization Type ${this.mode} declared in GraphQL Api.`); } - if (this.mode === AuthorizationType.USER_POOL && modes && modes.length > 1) { + if (this.mode === AuthorizationType.USER_POOL && this.modes && this.modes.length > 1) { this.statement = this.statement.replace('@aws_auth', '@aws_cognito_user_pools'); } return this.statement; diff --git a/packages/@aws-cdk/aws-appsync/lib/schema-field.ts b/packages/@aws-cdk/aws-appsync/lib/schema-field.ts index 4b5f8c0f3af62..ed7c8790818db 100644 --- a/packages/@aws-cdk/aws-appsync/lib/schema-field.ts +++ b/packages/@aws-cdk/aws-appsync/lib/schema-field.ts @@ -399,7 +399,7 @@ export class Field extends GraphqlType implements IField { public directivesToString(modes?: AuthorizationType[]): string { if (!this.fieldOptions || !this.fieldOptions.directives) { return ''; } return this.fieldOptions.directives.reduce((acc, directive) => - `${acc}${directive.toString(modes)} `, '\n ').slice(0, -1); + `${acc}${directive._bindToAuthModes(modes).toString()} `, '\n ').slice(0, -1); } } diff --git a/packages/@aws-cdk/aws-appsync/lib/schema-intermediate.ts b/packages/@aws-cdk/aws-appsync/lib/schema-intermediate.ts index 4f30e506b6406..a0cb9c7274ba4 100644 --- a/packages/@aws-cdk/aws-appsync/lib/schema-intermediate.ts +++ b/packages/@aws-cdk/aws-appsync/lib/schema-intermediate.ts @@ -1,4 +1,4 @@ -import { AuthorizationType } from './graphqlapi'; +import { AuthorizationType, GraphqlApi } from './graphqlapi'; import { shapeAddition } from './private'; import { Resolver } from './resolver'; import { Directive, IField, IIntermediateType, AddFieldOptions } from './schema-base'; @@ -47,6 +47,10 @@ export class InterfaceType implements IIntermediateType { * @default - no directives */ public readonly directives?: Directive[]; + /** + * the authorization modes for this intermediate type + */ + protected modes?: AuthorizationType[]; public constructor(name: string, props: IntermediateTypeProps) { this.name = name; @@ -54,6 +58,16 @@ export class InterfaceType implements IIntermediateType { this.directives = props.directives; } + /** + * Method called when the stringifying Intermediate Types for schema generation + * + * @internal + */ + public _bindToGraphqlApi(api: GraphqlApi): IIntermediateType { + this.modes = api.modes; + return this; + } + /** * Create an GraphQL Type representing this Intermediate Type * @@ -74,16 +88,16 @@ export class InterfaceType implements IIntermediateType { /** * Generate the string of this object type */ - public toString(modes?: AuthorizationType[]): string { + public toString(): string { return shapeAddition({ prefix: 'interface', name: this.name, directives: this.directives, fields: Object.keys(this.definition).map((key) => { const field = this.definition[key]; - return `${key}${field.argsToString()}: ${field.toString()}${field.directivesToString(modes)}`; + return `${key}${field.argsToString()}: ${field.toString()}${field.directivesToString(this.modes)}`; }), - modes, + modes: this.modes, }); } @@ -155,7 +169,6 @@ export class ObjectType extends InterfaceType implements IIntermediateType { }); } - /** * Add a field to this Object Type. * @@ -174,7 +187,7 @@ export class ObjectType extends InterfaceType implements IIntermediateType { /** * Generate the string of this object type */ - public toString(modes?: AuthorizationType[]): string { + public toString(): string { return shapeAddition({ prefix: 'type', name: this.name, @@ -182,9 +195,9 @@ export class ObjectType extends InterfaceType implements IIntermediateType { directives: this.directives, fields: Object.keys(this.definition).map((key) => { const field = this.definition[key]; - return `${key}${field.argsToString()}: ${field.toString()}${field.directivesToString(modes)}`; + return `${key}${field.argsToString()}: ${field.toString()}${field.directivesToString(this.modes)}`; }), - modes, + modes: this.modes, }); } @@ -219,6 +232,10 @@ export class InputType implements IIntermediateType { * the attributes of this type */ public readonly definition: { [key: string]: IField }; + /** + * the authorization modes for this intermediate type + */ + protected modes?: AuthorizationType[]; public constructor(name: string, props: IntermediateTypeProps) { this.name = name; @@ -242,15 +259,26 @@ export class InputType implements IIntermediateType { }); } + /** + * Method called when the stringifying Intermediate Types for schema generation + * + * @internal + */ + public _bindToGraphqlApi(api: GraphqlApi): IIntermediateType { + this.modes = api.modes; + return this; + } + /** * Generate the string of this input type */ - public toString(_modes?: AuthorizationType[]): string { + public toString(): string { return shapeAddition({ prefix: 'input', name: this.name, fields: Object.keys(this.definition).map((key) => `${key}${this.definition[key].argsToString()}: ${this.definition[key].toString()}`), + modes: this.modes, }); } diff --git a/packages/@aws-cdk/aws-appsync/lib/schema.ts b/packages/@aws-cdk/aws-appsync/lib/schema.ts index 53b77ea408b8e..e1b7d763b8ad7 100644 --- a/packages/@aws-cdk/aws-appsync/lib/schema.ts +++ b/packages/@aws-cdk/aws-appsync/lib/schema.ts @@ -81,7 +81,7 @@ export class Schema { apiId: api.apiId, definition: this.mode === SchemaMode.CODE ? Lazy.stringValue({ - produce: () => this.types.reduce((acc, type) => { return `${acc}${type.toString(api.modes)}\n`; }, + produce: () => this.types.reduce((acc, type) => { return `${acc}${type._bindToGraphqlApi(api).toString()}\n`; }, `${this.declareSchema()}${this.definition}`), }) : this.definition, diff --git a/packages/@aws-cdk/aws-chatbot/README.md b/packages/@aws-cdk/aws-chatbot/README.md index 83d6afcef7773..b45609ebc066f 100644 --- a/packages/@aws-cdk/aws-chatbot/README.md +++ b/packages/@aws-cdk/aws-chatbot/README.md @@ -35,3 +35,19 @@ slackChannel.addToPrincipalPolicy(new iam.PolicyStatement({ resources: ['arn:aws:s3:::abc/xyz/123.txt'], })); ``` + +### Log Group + +Slack channel configuration automatically create a log group with the name `/aws/chatbot/` in `us-east-1` upon first execution with +log data set to never expire. + +The `logRetention` property can be used to set a different expiration period. A log group will be created if not already exists. +If the log group already exists, it's expiration will be configured to the value specified in this construct (never expire, by default). + +By default, CDK uses the AWS SDK retry options when interacting with the log group. The `logRetentionRetryOptions` property +allows you to customize the maximum number of retries and base backoff duration. + +*Note* that, if `logRetention` is set, a [CloudFormation custom +resource](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-cfn-customresource.html) is added +to the stack that pre-creates the log group as part of the stack deployment, if it already doesn't exist, and sets the +correct log retention period (never expire, by default). diff --git a/packages/@aws-cdk/aws-chatbot/lib/slack-channel-configuration.ts b/packages/@aws-cdk/aws-chatbot/lib/slack-channel-configuration.ts index d7746c6b8d679..e686d5b8b3209 100644 --- a/packages/@aws-cdk/aws-chatbot/lib/slack-channel-configuration.ts +++ b/packages/@aws-cdk/aws-chatbot/lib/slack-channel-configuration.ts @@ -1,4 +1,6 @@ +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; import * as iam from '@aws-cdk/aws-iam'; +import * as logs from '@aws-cdk/aws-logs'; import * as sns from '@aws-cdk/aws-sns'; import * as cdk from '@aws-cdk/core'; import { CfnSlackChannelConfiguration } from './chatbot.generated'; @@ -52,6 +54,31 @@ export interface SlackChannelConfigurationProps { * @default LoggingLevel.NONE */ readonly loggingLevel?: LoggingLevel; + + /** + * The number of days log events are kept in CloudWatch Logs. When updating + * this property, unsetting it doesn't remove the log retention policy. To + * remove the retention policy, set the value to `INFINITE`. + * + * @default logs.RetentionDays.INFINITE + */ + readonly logRetention?: logs.RetentionDays; + + /** + * The IAM role for the Lambda function associated with the custom resource + * that sets the retention policy. + * + * @default - A new role is created. + */ + readonly logRetentionRole?: iam.IRole; + + /** + * When log retention is specified, a custom resource attempts to create the CloudWatch log group. + * These options control the retry policy when interacting with CloudWatch APIs. + * + * @default - Default AWS SDK retry options. + */ + readonly logRetentionRetryOptions?: logs.LogRetentionRetryOptions; } /** @@ -104,6 +131,11 @@ export interface ISlackChannelConfiguration extends cdk.IResource, iam.IGrantabl * Adds a statement to the IAM role. */ addToRolePolicy(statement: iam.PolicyStatement): void; + + /** + * Return the given named metric for this SlackChannelConfiguration + */ + metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric; } /** @@ -129,6 +161,23 @@ abstract class SlackChannelConfigurationBase extends cdk.Resource implements ISl this.role.addToPrincipalPolicy(statement); } + + /** + * Return the given named metric for this SlackChannelConfiguration + */ + public metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { + // AWS Chatbot publishes metrics to us-east-1 regardless of stack region + // https://docs.aws.amazon.com/chatbot/latest/adminguide/monitoring-cloudwatch.html + return new cloudwatch.Metric({ + namespace: 'AWS/Chatbot', + region: 'us-east-1', + dimensions: { + ConfigurationName: this.slackChannelConfigurationName, + }, + metricName, + ...props, + }); + } } /** @@ -180,6 +229,20 @@ export class SlackChannelConfiguration extends SlackChannelConfigurationBase { return new Import(scope, id); } + /** + * Return the given named metric for All SlackChannelConfigurations + */ + public static metricAll(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric { + // AWS Chatbot publishes metrics to us-east-1 regardless of stack region + // https://docs.aws.amazon.com/chatbot/latest/adminguide/monitoring-cloudwatch.html + return new cloudwatch.Metric({ + namespace: 'AWS/Chatbot', + region: 'us-east-1', + metricName, + ...props, + }); + } + readonly slackChannelConfigurationArn: string; readonly slackChannelConfigurationName: string; @@ -208,6 +271,18 @@ export class SlackChannelConfiguration extends SlackChannelConfigurationBase { loggingLevel: props.loggingLevel?.toString(), }); + // Log retention + // AWS Chatbot publishes logs to us-east-1 regardless of stack region https://docs.aws.amazon.com/chatbot/latest/adminguide/cloudwatch-logs.html + if (props.logRetention) { + new logs.LogRetention(this, 'LogRetention', { + logGroupName: `/aws/chatbot/${props.slackChannelConfigurationName}`, + retention: props.logRetention, + role: props.logRetentionRole, + logGroupRegion: 'us-east-1', + logRetentionRetryOptions: props.logRetentionRetryOptions, + }); + } + this.slackChannelConfigurationArn = configuration.ref; this.slackChannelConfigurationName = props.slackChannelConfigurationName; } diff --git a/packages/@aws-cdk/aws-chatbot/package.json b/packages/@aws-cdk/aws-chatbot/package.json index 2a2acb7013526..59ddd0cd77453 100644 --- a/packages/@aws-cdk/aws-chatbot/package.json +++ b/packages/@aws-cdk/aws-chatbot/package.json @@ -73,13 +73,17 @@ "pkglint": "0.0.0" }, "dependencies": { + "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/aws-sns": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.0.4" }, "peerDependencies": { + "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/aws-sns": "0.0.0", "@aws-cdk/core": "0.0.0", "constructs": "^3.0.4" diff --git a/packages/@aws-cdk/aws-chatbot/test/integ.chatbot-logretention.expected.json b/packages/@aws-cdk/aws-chatbot/test/integ.chatbot-logretention.expected.json new file mode 100644 index 0000000000000..f2e0c5c3edda9 --- /dev/null +++ b/packages/@aws-cdk/aws-chatbot/test/integ.chatbot-logretention.expected.json @@ -0,0 +1,195 @@ +{ + "Resources": { + "MySlackChannelConfigurationRole1D3F23AE": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "chatbot.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "MySlackChannelConfigurationRoleDefaultPolicyE4C1FA62": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:GetObject", + "Effect": "Allow", + "Resource": "arn:aws:s3:::abc/xyz/123.txt" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MySlackChannelConfigurationRoleDefaultPolicyE4C1FA62", + "Roles": [ + { + "Ref": "MySlackChannelConfigurationRole1D3F23AE" + } + ] + } + }, + "MySlackChannelA8E0B56C": { + "Type": "AWS::Chatbot::SlackChannelConfiguration", + "Properties": { + "ConfigurationName": "test-channel", + "IamRoleArn": { + "Fn::GetAtt": [ + "MySlackChannelConfigurationRole1D3F23AE", + "Arn" + ] + }, + "SlackChannelId": "C0187JABUE9", + "SlackWorkspaceId": "T49239U4W", + "LoggingLevel": "NONE" + } + }, + "MySlackChannelLogRetention84AA443F": { + "Type": "Custom::LogRetention", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A", + "Arn" + ] + }, + "LogGroupName": "/aws/chatbot/test-channel", + "RetentionInDays": 30, + "LogGroupRegion": "us-east-1" + } + }, + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:PutRetentionPolicy", + "logs:DeleteRetentionPolicy" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB", + "Roles": [ + { + "Ref": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB" + } + ] + } + }, + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437bS3Bucket48EF98C9" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437bS3VersionKeyF33C73AF" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437bS3VersionKeyF33C73AF" + } + ] + } + ] + } + ] + ] + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB", + "Arn" + ] + }, + "Runtime": "nodejs10.x" + }, + "DependsOn": [ + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB", + "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB" + ] + } + }, + "Parameters": { + "AssetParameters74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437bS3Bucket48EF98C9": { + "Type": "String", + "Description": "S3 bucket for asset \"74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437b\"" + }, + "AssetParameters74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437bS3VersionKeyF33C73AF": { + "Type": "String", + "Description": "S3 key for asset version \"74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437b\"" + }, + "AssetParameters74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437bArtifactHash976CF1BD": { + "Type": "String", + "Description": "Artifact hash for asset \"74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437b\"" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-chatbot/test/integ.chatbot-logretention.ts b/packages/@aws-cdk/aws-chatbot/test/integ.chatbot-logretention.ts new file mode 100644 index 0000000000000..cbb8de485b295 --- /dev/null +++ b/packages/@aws-cdk/aws-chatbot/test/integ.chatbot-logretention.ts @@ -0,0 +1,33 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as logs from '@aws-cdk/aws-logs'; +import * as cdk from '@aws-cdk/core'; +import * as chatbot from '../lib'; + +class ChatbotLogRetentionInteg extends cdk.Stack { + constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const slackChannel = new chatbot.SlackChannelConfiguration(this, 'MySlackChannel', { + slackChannelConfigurationName: 'test-channel', + slackWorkspaceId: 'T49239U4W', // modify to your slack workspace id + slackChannelId: 'C0187JABUE9', // modify to your slack channel id + loggingLevel: chatbot.LoggingLevel.NONE, + logRetention: logs.RetentionDays.ONE_MONTH, + }); + + slackChannel.addToRolePolicy(new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 's3:GetObject', + ], + resources: ['arn:aws:s3:::abc/xyz/123.txt'], + })); + } +} + +const app = new cdk.App(); + +new ChatbotLogRetentionInteg(app, 'ChatbotLogRetentionInteg'); + +app.synth(); + diff --git a/packages/@aws-cdk/aws-chatbot/test/slack-channel-configuration.test.ts b/packages/@aws-cdk/aws-chatbot/test/slack-channel-configuration.test.ts index 3cf1189d9fee2..de5a5da9a63c1 100644 --- a/packages/@aws-cdk/aws-chatbot/test/slack-channel-configuration.test.ts +++ b/packages/@aws-cdk/aws-chatbot/test/slack-channel-configuration.test.ts @@ -1,5 +1,8 @@ import '@aws-cdk/assert/jest'; +import { ABSENT } from '@aws-cdk/assert'; +import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; import * as iam from '@aws-cdk/aws-iam'; +import * as logs from '@aws-cdk/aws-logs'; import * as sns from '@aws-cdk/aws-sns'; import * as cdk from '@aws-cdk/core'; import * as chatbot from '../lib'; @@ -138,6 +141,83 @@ describe('SlackChannelConfiguration', () => { }); }); + test('specifying log retention', () => { + new chatbot.SlackChannelConfiguration(stack, 'MySlackChannel', { + slackWorkspaceId: 'ABC123', + slackChannelId: 'DEF456', + slackChannelConfigurationName: 'ConfigurationName', + logRetention: logs.RetentionDays.ONE_MONTH, + }); + + expect(stack).toHaveResourceLike('Custom::LogRetention', { + LogGroupName: '/aws/chatbot/ConfigurationName', + RetentionInDays: 30, + LogGroupRegion: 'us-east-1', + }); + }); + + test('getting configuration metric', () => { + const slackChannel = new chatbot.SlackChannelConfiguration(stack, 'MySlackChannel', { + slackWorkspaceId: 'ABC123', + slackChannelId: 'DEF456', + slackChannelConfigurationName: 'ConfigurationName', + logRetention: logs.RetentionDays.ONE_MONTH, + }); + const metric = slackChannel.metric('MetricName'); + new cloudwatch.Alarm(stack, 'Alarm', { + evaluationPeriods: 1, + threshold: 0, + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, + metric: metric, + }); + + expect(metric).toEqual(new cloudwatch.Metric({ + namespace: 'AWS/Chatbot', + region: 'us-east-1', + dimensions: { + ConfigurationName: 'ConfigurationName', + }, + metricName: 'MetricName', + })); + expect(stack).toHaveResourceLike('AWS::CloudWatch::Alarm', { + Namespace: 'AWS/Chatbot', + MetricName: 'MetricName', + Dimensions: [ + { + Name: 'ConfigurationName', + Value: 'ConfigurationName', + }, + ], + ComparisonOperator: 'GreaterThanThreshold', + EvaluationPeriods: 1, + Threshold: 0, + }); + }); + + test('getting all configurations metric', () => { + const metric = chatbot.SlackChannelConfiguration.metricAll('MetricName'); + new cloudwatch.Alarm(stack, 'Alarm', { + evaluationPeriods: 1, + threshold: 0, + comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, + metric: metric, + }); + + expect(metric).toEqual(new cloudwatch.Metric({ + namespace: 'AWS/Chatbot', + region: 'us-east-1', + metricName: 'MetricName', + })); + expect(stack).toHaveResourceLike('AWS::CloudWatch::Alarm', { + Namespace: 'AWS/Chatbot', + MetricName: 'MetricName', + Dimensions: ABSENT, + ComparisonOperator: 'GreaterThanThreshold', + EvaluationPeriods: 1, + Threshold: 0, + }); + }); + test('added a iam policy to a from slack channel configuration ARN will nothing to do', () => { const imported = chatbot.SlackChannelConfiguration.fromSlackChannelConfigurationArn(stack, 'MySlackChannel', 'arn:aws:chatbot::1234567890:chat-configuration/slack-channel/my-slack'); diff --git a/packages/@aws-cdk/aws-docdb/test/cluster.test.ts b/packages/@aws-cdk/aws-docdb/test/cluster.test.ts index fb26905d1a027..f227293310bf3 100644 --- a/packages/@aws-cdk/aws-docdb/test/cluster.test.ts +++ b/packages/@aws-cdk/aws-docdb/test/cluster.test.ts @@ -580,7 +580,7 @@ describe('DatabaseCluster', () => { expectCDK(stack).to(haveResource('AWS::Serverless::Application', { Location: { ApplicationId: 'arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerMongoDBRotationSingleUser', - SemanticVersion: '1.1.3', + SemanticVersion: '1.1.60', }, Parameters: { endpoint: { @@ -698,7 +698,7 @@ describe('DatabaseCluster', () => { expectCDK(stack).to(haveResource('AWS::Serverless::Application', { Location: { ApplicationId: 'arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerMongoDBRotationMultiUser', - SemanticVersion: '1.1.3', + SemanticVersion: '1.1.60', }, Parameters: { endpoint: { diff --git a/packages/@aws-cdk/aws-rds/README.md b/packages/@aws-cdk/aws-rds/README.md index 1d0bfbe9b947a..fb0890c336402 100644 --- a/packages/@aws-cdk/aws-rds/README.md +++ b/packages/@aws-cdk/aws-rds/README.md @@ -62,6 +62,18 @@ By default, the master password will be generated and stored in AWS Secrets Mana Your cluster will be empty by default. To add a default database upon construction, specify the `defaultDatabaseName` attribute. +Use `DatabaseClusterFromSnapshot` to create a cluster from a snapshot: + +```ts +new DatabaseClusterFromSnapshot(stack, 'Database', { + engine: DatabaseClusterEngine.aurora({ version: AuroraEngineVersion.VER_1_22_2 }), + instanceProps: { + vpc, + }, + snapshotIdentifier: 'mySnapshot', +}); +``` + ### Starting an instance database To set up a instance database, define a `DatabaseInstance`. You must diff --git a/packages/@aws-cdk/aws-rds/lib/cluster.ts b/packages/@aws-cdk/aws-rds/lib/cluster.ts index 4ddba423ba8ea..89b98756e0b8b 100644 --- a/packages/@aws-cdk/aws-rds/lib/cluster.ts +++ b/packages/@aws-cdk/aws-rds/lib/cluster.ts @@ -12,12 +12,12 @@ import { Endpoint } from './endpoint'; import { IParameterGroup } from './parameter-group'; import { BackupProps, InstanceProps, Login, PerformanceInsightRetention, RotationMultiUserOptions } from './props'; import { DatabaseProxy, DatabaseProxyOptions, ProxyTarget } from './proxy'; -import { CfnDBCluster, CfnDBInstance, CfnDBSubnetGroup } from './rds.generated'; +import { CfnDBCluster, CfnDBClusterProps, CfnDBInstance, CfnDBSubnetGroup } from './rds.generated'; /** - * Properties for a new database cluster + * Common properties for a new database cluster or cluster from snapshot. */ -export interface DatabaseClusterProps { +interface DatabaseClusterBaseProps { /** * What kind of database to start */ @@ -37,11 +37,6 @@ export interface DatabaseClusterProps { */ readonly instanceProps: InstanceProps; - /** - * Username and password for the administrative user - */ - readonly masterUser: Login; - /** * Backup settings * @@ -90,21 +85,6 @@ export interface DatabaseClusterProps { */ readonly deletionProtection?: boolean; - /** - * Whether to enable storage encryption. - * - * @default - true if storageEncryptionKey is provided, false otherwise - */ - readonly storageEncrypted?: boolean - - /** - * The KMS key for storage encryption. - * If specified, {@link storageEncrypted} will be set to `true`. - * - * @default - if storageEncrypted is true then the default master key, no key otherwise - */ - readonly storageEncryptionKey?: kms.IKey; - /** * A preferred maintenance window day/time range. Should be specified as a range ddd:hh24:mi-ddd:hh24:mi (24H Clock UTC). * @@ -289,87 +269,20 @@ abstract class DatabaseClusterBase extends Resource implements IDatabaseCluster } /** - * Create a clustered database with a given number of instances. - * - * @resource AWS::RDS::DBCluster + * Abstract base for ``DatabaseCluster`` and ``DatabaseClusterFromSnapshot`` */ -export class DatabaseCluster extends DatabaseClusterBase { - /** - * Import an existing DatabaseCluster from properties - */ - public static fromDatabaseClusterAttributes(scope: Construct, id: string, attrs: DatabaseClusterAttributes): IDatabaseCluster { - class Import extends DatabaseClusterBase implements IDatabaseCluster { - public readonly defaultPort = ec2.Port.tcp(attrs.port); - public readonly connections = new ec2.Connections({ - securityGroups: attrs.securityGroups, - defaultPort: this.defaultPort, - }); - public readonly clusterIdentifier = attrs.clusterIdentifier; - public readonly instanceIdentifiers: string[] = []; - public readonly clusterEndpoint = new Endpoint(attrs.clusterEndpointAddress, attrs.port); - public readonly clusterReadEndpoint = new Endpoint(attrs.readerEndpointAddress, attrs.port); - public readonly instanceEndpoints = attrs.instanceEndpointAddresses.map(a => new Endpoint(a, attrs.port)); - } - - return new Import(scope, id); - } +abstract class DatabaseClusterNew extends DatabaseClusterBase { - /** - * Identifier of the cluster - */ - public readonly clusterIdentifier: string; - - /** - * Identifiers of the replicas - */ public readonly instanceIdentifiers: string[] = []; - - /** - * The endpoint to use for read/write operations - */ - public readonly clusterEndpoint: Endpoint; - - /** - * Endpoint to use for load-balanced read-only operations. - */ - public readonly clusterReadEndpoint: Endpoint; - - /** - * Endpoints which address each individual replica. - */ public readonly instanceEndpoints: Endpoint[] = []; - /** - * Access to the network connections - */ - public readonly connections: ec2.Connections; - - /** - * The secret attached to this cluster - */ - public readonly secret?: secretsmanager.ISecret; - - private readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication; - private readonly multiUserRotationApplication: secretsmanager.SecretRotationApplication; - - /** - * The VPC where the DB subnet group is created. - */ - private readonly vpc: ec2.IVpc; + protected readonly newCfnProps: CfnDBClusterProps; + protected readonly subnetGroup: CfnDBSubnetGroup; + protected readonly securityGroups: ec2.ISecurityGroup[]; - /** - * The subnets used by the DB subnet group. - * - * @default - the Vpc default strategy if not specified. - */ - private readonly vpcSubnets?: ec2.SubnetSelection; - - constructor(scope: Construct, id: string, props: DatabaseClusterProps) { + constructor(scope: Construct, id: string, props: DatabaseClusterBaseProps) { super(scope, id); - this.vpc = props.instanceProps.vpc; - this.vpcSubnets = props.instanceProps.vpcSubnets; - const { subnetIds } = props.instanceProps.vpc.selectSubnets(props.instanceProps.vpcSubnets); // Cannot test whether the subnets are in different AZs, but at least we can test the amount. @@ -377,32 +290,21 @@ export class DatabaseCluster extends DatabaseClusterBase { this.node.addError(`Cluster requires at least 2 subnets, got ${subnetIds.length}`); } - const subnetGroup = new CfnDBSubnetGroup(this, 'Subnets', { + this.subnetGroup = new CfnDBSubnetGroup(this, 'Subnets', { dbSubnetGroupDescription: `Subnets for ${id} database`, subnetIds, }); if (props.removalPolicy === RemovalPolicy.RETAIN) { - subnetGroup.applyRemovalPolicy(RemovalPolicy.RETAIN); + this.subnetGroup.applyRemovalPolicy(RemovalPolicy.RETAIN); } - const securityGroups = props.instanceProps.securityGroups ?? [ + this.securityGroups = props.instanceProps.securityGroups ?? [ new ec2.SecurityGroup(this, 'SecurityGroup', { description: 'RDS security group', vpc: props.instanceProps.vpc, }), ]; - let secret: DatabaseSecret | undefined; - if (!props.masterUser.password) { - secret = new DatabaseSecret(this, 'Secret', { - username: props.masterUser.username, - encryptionKey: props.masterUser.encryptionKey, - }); - } - - this.singleUserRotationApplication = props.engine.singleUserRotationApplication; - this.multiUserRotationApplication = props.engine.multiUserRotationApplication; - const clusterAssociatedRoles: CfnDBCluster.DBClusterRoleProperty[] = []; let { s3ImportRole, s3ExportRole } = this.setupS3ImportExport(props); if (s3ImportRole) { @@ -421,44 +323,169 @@ export class DatabaseCluster extends DatabaseClusterBase { const clusterParameterGroup = props.parameterGroup ?? clusterEngineBindConfig.parameterGroup; const clusterParameterGroupConfig = clusterParameterGroup?.bindToCluster({}); - const cluster = new CfnDBCluster(this, 'Resource', { + this.newCfnProps = { // Basic engine: props.engine.engineType, engineVersion: props.engine.engineVersion?.fullVersion, dbClusterIdentifier: props.clusterIdentifier, - dbSubnetGroupName: subnetGroup.ref, - vpcSecurityGroupIds: securityGroups.map(sg => sg.securityGroupId), + dbSubnetGroupName: this.subnetGroup.ref, + vpcSecurityGroupIds: this.securityGroups.map(sg => sg.securityGroupId), port: props.port ?? clusterEngineBindConfig.port, dbClusterParameterGroupName: clusterParameterGroupConfig?.parameterGroupName, associatedRoles: clusterAssociatedRoles.length > 0 ? clusterAssociatedRoles : undefined, deletionProtection: props.deletionProtection, // Admin - masterUsername: secret ? secret.secretValueFromJson('username').toString() : props.masterUser.username, - masterUserPassword: secret - ? secret.secretValueFromJson('password').toString() - : (props.masterUser.password - ? props.masterUser.password.toString() - : undefined), backupRetentionPeriod: props.backup?.retention?.toDays(), preferredBackupWindow: props.backup?.preferredWindow, preferredMaintenanceWindow: props.preferredMaintenanceWindow, databaseName: props.defaultDatabaseName, enableCloudwatchLogsExports: props.cloudwatchLogsExports, - // Encryption - kmsKeyId: props.storageEncryptionKey?.keyArn, - storageEncrypted: props.storageEncryptionKey ? true : props.storageEncrypted, - }); + }; + } + protected setRemovalPolicy(cluster: CfnDBCluster, removalPolicy?: RemovalPolicy) { // if removalPolicy was not specified, // leave it as the default, which is Snapshot - if (props.removalPolicy) { - cluster.applyRemovalPolicy(props.removalPolicy); + if (removalPolicy) { + cluster.applyRemovalPolicy(removalPolicy); } else { // The CFN default makes sense for DeletionPolicy, // but doesn't cover UpdateReplacePolicy. // Fix that here. cluster.cfnOptions.updateReplacePolicy = CfnDeletionPolicy.SNAPSHOT; } + } + + private setupS3ImportExport(props: DatabaseClusterBaseProps): { s3ImportRole?: IRole, s3ExportRole?: IRole } { + let s3ImportRole = props.s3ImportRole; + if (props.s3ImportBuckets && props.s3ImportBuckets.length > 0) { + if (props.s3ImportRole) { + throw new Error('Only one of s3ImportRole or s3ImportBuckets must be specified, not both.'); + } + + s3ImportRole = new Role(this, 'S3ImportRole', { + assumedBy: new ServicePrincipal('rds.amazonaws.com'), + }); + for (const bucket of props.s3ImportBuckets) { + bucket.grantRead(s3ImportRole); + } + } + + let s3ExportRole = props.s3ExportRole; + if (props.s3ExportBuckets && props.s3ExportBuckets.length > 0) { + if (props.s3ExportRole) { + throw new Error('Only one of s3ExportRole or s3ExportBuckets must be specified, not both.'); + } + + s3ExportRole = new Role(this, 'S3ExportRole', { + assumedBy: new ServicePrincipal('rds.amazonaws.com'), + }); + for (const bucket of props.s3ExportBuckets) { + bucket.grantReadWrite(s3ExportRole); + } + } + + return { s3ImportRole, s3ExportRole }; + } +} + +/** + * Properties for a new database cluster + */ +export interface DatabaseClusterProps extends DatabaseClusterBaseProps { + /** + * Username and password for the administrative user + */ + readonly masterUser: Login; + + /** + * Whether to enable storage encryption. + * + * @default - true if storageEncryptionKey is provided, false otherwise + */ + readonly storageEncrypted?: boolean + + /** + * The KMS key for storage encryption. + * If specified, {@link storageEncrypted} will be set to `true`. + * + * @default - if storageEncrypted is true then the default master key, no key otherwise + */ + readonly storageEncryptionKey?: kms.IKey; +} + +/** + * Create a clustered database with a given number of instances. + * + * @resource AWS::RDS::DBCluster + */ +export class DatabaseCluster extends DatabaseClusterNew { + /** + * Import an existing DatabaseCluster from properties + */ + public static fromDatabaseClusterAttributes(scope: Construct, id: string, attrs: DatabaseClusterAttributes): IDatabaseCluster { + class Import extends DatabaseClusterBase implements IDatabaseCluster { + public readonly defaultPort = ec2.Port.tcp(attrs.port); + public readonly connections = new ec2.Connections({ + securityGroups: attrs.securityGroups, + defaultPort: this.defaultPort, + }); + public readonly clusterIdentifier = attrs.clusterIdentifier; + public readonly instanceIdentifiers: string[] = []; + public readonly clusterEndpoint = new Endpoint(attrs.clusterEndpointAddress, attrs.port); + public readonly clusterReadEndpoint = new Endpoint(attrs.readerEndpointAddress, attrs.port); + public readonly instanceEndpoints = attrs.instanceEndpointAddresses.map(a => new Endpoint(a, attrs.port)); + } + + return new Import(scope, id); + } + + public readonly clusterIdentifier: string; + public readonly clusterEndpoint: Endpoint; + public readonly clusterReadEndpoint: Endpoint; + public readonly connections: ec2.Connections; + + /** + * The secret attached to this cluster + */ + public readonly secret?: secretsmanager.ISecret; + + private readonly vpc: ec2.IVpc; + private readonly vpcSubnets?: ec2.SubnetSelection; + + private readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication; + private readonly multiUserRotationApplication: secretsmanager.SecretRotationApplication; + + constructor(scope: Construct, id: string, props: DatabaseClusterProps) { + super(scope, id, props); + + this.vpc = props.instanceProps.vpc; + this.vpcSubnets = props.instanceProps.vpcSubnets; + + this.singleUserRotationApplication = props.engine.singleUserRotationApplication; + this.multiUserRotationApplication = props.engine.multiUserRotationApplication; + + let secret: DatabaseSecret | undefined; + if (!props.masterUser.password) { + secret = new DatabaseSecret(this, 'Secret', { + username: props.masterUser.username, + encryptionKey: props.masterUser.encryptionKey, + }); + } + + const cluster = new CfnDBCluster(this, 'Resource', { + ...this.newCfnProps, + // Admin + masterUsername: secret ? secret.secretValueFromJson('username').toString() : props.masterUser.username, + masterUserPassword: secret + ? secret.secretValueFromJson('password').toString() + : (props.masterUser.password + ? props.masterUser.password.toString() + : undefined), + // Encryption + kmsKeyId: props.storageEncryptionKey?.keyArn, + storageEncrypted: props.storageEncryptionKey ? true : props.storageEncrypted, + }); this.clusterIdentifier = cluster.ref; @@ -466,20 +493,21 @@ export class DatabaseCluster extends DatabaseClusterBase { const portAttribute = Token.asNumber(cluster.attrEndpointPort); this.clusterEndpoint = new Endpoint(cluster.attrEndpointAddress, portAttribute); this.clusterReadEndpoint = new Endpoint(cluster.attrReadEndpointAddress, portAttribute); + this.connections = new ec2.Connections({ + securityGroups: this.securityGroups, + defaultPort: ec2.Port.tcp(this.clusterEndpoint.port), + }); - this.setLogRetention(props); + this.setRemovalPolicy(cluster, props.removalPolicy); if (secret) { this.secret = secret.attach(this); } - this.createInstances(props, cluster, subnetGroup, portAttribute); - - const defaultPort = ec2.Port.tcp(this.clusterEndpoint.port); - this.connections = new ec2.Connections({ securityGroups, defaultPort }); + setLogRetention(this, props); + createInstances(this, props, this.subnetGroup); } - /** * Adds the single user rotation of the master password to this cluster. * @@ -524,131 +552,169 @@ export class DatabaseCluster extends DatabaseClusterBase { target: this, }); } +} - private setupS3ImportExport(props: DatabaseClusterProps): { s3ImportRole?: IRole, s3ExportRole?: IRole } { - let s3ImportRole = props.s3ImportRole; - if (props.s3ImportBuckets && props.s3ImportBuckets.length > 0) { - if (props.s3ImportRole) { - throw new Error('Only one of s3ImportRole or s3ImportBuckets must be specified, not both.'); - } +/** + * Properties for ``DatabaseClusterFromSnapshot`` + */ +export interface DatabaseClusterFromSnapshotProps extends DatabaseClusterBaseProps { + /** + * The identifier for the DB instance snapshot or DB cluster snapshot to restore from. + * You can use either the name or the Amazon Resource Name (ARN) to specify a DB cluster snapshot. + * However, you can use only the ARN to specify a DB instance snapshot. + */ + readonly snapshotIdentifier: string; +} - s3ImportRole = new Role(this, 'S3ImportRole', { - assumedBy: new ServicePrincipal('rds.amazonaws.com'), - }); - for (const bucket of props.s3ImportBuckets) { - bucket.grantRead(s3ImportRole); - } - } +/** + * A database cluster restored from a snapshot. + * + * @resource AWS::RDS::DBInstance + */ +export class DatabaseClusterFromSnapshot extends DatabaseClusterNew { + public readonly clusterIdentifier: string; + public readonly clusterEndpoint: Endpoint; + public readonly clusterReadEndpoint: Endpoint; + public readonly connections: ec2.Connections; - let s3ExportRole = props.s3ExportRole; - if (props.s3ExportBuckets && props.s3ExportBuckets.length > 0) { - if (props.s3ExportRole) { - throw new Error('Only one of s3ExportRole or s3ExportBuckets must be specified, not both.'); - } + constructor(scope: Construct, id: string, props: DatabaseClusterFromSnapshotProps) { + super(scope, id, props); - s3ExportRole = new Role(this, 'S3ExportRole', { - assumedBy: new ServicePrincipal('rds.amazonaws.com'), - }); - for (const bucket of props.s3ExportBuckets) { - bucket.grantReadWrite(s3ExportRole); - } - } + const cluster = new CfnDBCluster(this, 'Resource', { + ...this.newCfnProps, + snapshotIdentifier: props.snapshotIdentifier, + }); - return { s3ImportRole, s3ExportRole }; - } + this.clusterIdentifier = cluster.ref; - private createInstances(props: DatabaseClusterProps, cluster: CfnDBCluster, subnetGroup: CfnDBSubnetGroup, portAttribute: number) { - const instanceCount = props.instances != null ? props.instances : 2; - if (instanceCount < 1) { - throw new Error('At least one instance is required'); - } + // create a number token that represents the port of the cluster + const portAttribute = Token.asNumber(cluster.attrEndpointPort); + this.clusterEndpoint = new Endpoint(cluster.attrEndpointAddress, portAttribute); + this.clusterReadEndpoint = new Endpoint(cluster.attrReadEndpointAddress, portAttribute); + this.connections = new ec2.Connections({ + securityGroups: this.securityGroups, + defaultPort: ec2.Port.tcp(this.clusterEndpoint.port), + }); - const instanceProps = props.instanceProps; - // Get the actual subnet objects so we can depend on internet connectivity. - const internetConnected = instanceProps.vpc.selectSubnets(instanceProps.vpcSubnets).internetConnectivityEstablished; - - let monitoringRole; - if (props.monitoringInterval && props.monitoringInterval.toSeconds()) { - monitoringRole = props.monitoringRole || new Role(this, 'MonitoringRole', { - assumedBy: new ServicePrincipal('monitoring.rds.amazonaws.com'), - managedPolicies: [ - ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonRDSEnhancedMonitoringRole'), - ], - }); + this.setRemovalPolicy(cluster, props.removalPolicy); + + setLogRetention(this, props); + createInstances(this, props, this.subnetGroup); + } +} + +/** + * Sets up CloudWatch log retention if configured. + * A function rather than protected member to prevent exposing ``DatabaseClusterBaseProps``. + */ +function setLogRetention(cluster: DatabaseClusterNew, props: DatabaseClusterBaseProps) { + if (props.cloudwatchLogsExports) { + const unsupportedLogTypes = props.cloudwatchLogsExports.filter(logType => !props.engine.supportedLogTypes.includes(logType)); + if (unsupportedLogTypes.length > 0) { + throw new Error(`Unsupported logs for the current engine type: ${unsupportedLogTypes.join(',')}`); } - const enablePerformanceInsights = instanceProps.enablePerformanceInsights - || instanceProps.performanceInsightRetention !== undefined || instanceProps.performanceInsightEncryptionKey !== undefined; - if (enablePerformanceInsights && instanceProps.enablePerformanceInsights === false) { - throw new Error('`enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set'); + if (props.cloudwatchLogsRetention) { + for (const log of props.cloudwatchLogsExports) { + new logs.LogRetention(cluster, `LogRetention${log}`, { + logGroupName: `/aws/rds/cluster/${cluster.clusterIdentifier}/${log}`, + retention: props.cloudwatchLogsRetention, + role: props.cloudwatchLogsRetentionRole, + }); + } } + } +} - const instanceType = instanceProps.instanceType ?? ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MEDIUM); - const instanceParameterGroupConfig = instanceProps.parameterGroup?.bindToInstance({}); - for (let i = 0; i < instanceCount; i++) { - const instanceIndex = i + 1; - const instanceIdentifier = props.instanceIdentifierBase != null ? `${props.instanceIdentifierBase}${instanceIndex}` : - props.clusterIdentifier != null ? `${props.clusterIdentifier}instance${instanceIndex}` : - undefined; - - const publiclyAccessible = instanceProps.vpcSubnets && instanceProps.vpcSubnets.subnetType === ec2.SubnetType.PUBLIC; - - const instance = new CfnDBInstance(this, `Instance${instanceIndex}`, { - // Link to cluster - engine: props.engine.engineType, - engineVersion: props.engine.engineVersion?.fullVersion, - dbClusterIdentifier: cluster.ref, - dbInstanceIdentifier: instanceIdentifier, - // Instance properties - dbInstanceClass: databaseInstanceType(instanceType), - publiclyAccessible, - enablePerformanceInsights: enablePerformanceInsights || instanceProps.enablePerformanceInsights, // fall back to undefined if not set - performanceInsightsKmsKeyId: instanceProps.performanceInsightEncryptionKey?.keyArn, - performanceInsightsRetentionPeriod: enablePerformanceInsights - ? (instanceProps.performanceInsightRetention || PerformanceInsightRetention.DEFAULT) - : undefined, - // This is already set on the Cluster. Unclear to me whether it should be repeated or not. Better yes. - dbSubnetGroupName: subnetGroup.ref, - dbParameterGroupName: instanceParameterGroupConfig?.parameterGroupName, - monitoringInterval: props.monitoringInterval && props.monitoringInterval.toSeconds(), - monitoringRoleArn: monitoringRole && monitoringRole.roleArn, - }); +/** Output from the createInstances method; used to set instance identifiers and endpoints */ +interface InstanceConfig { + readonly instanceIdentifiers: string[]; + readonly instanceEndpoints: Endpoint[]; +} - // If removalPolicy isn't explicitly set, - // it's Snapshot for Cluster. - // Because of that, in this case, - // we can safely use the CFN default of Delete for DbInstances with dbClusterIdentifier set. - if (props.removalPolicy) { - instance.applyRemovalPolicy(props.removalPolicy); - } +/** + * Creates the instances for the cluster. + * A function rather than a protected method on ``DatabaseClusterNew`` to avoid exposing + * ``DatabaseClusterNew`` and ``DatabaseClusterBaseProps`` in the API. + */ +function createInstances(cluster: DatabaseClusterNew, props: DatabaseClusterBaseProps, subnetGroup: CfnDBSubnetGroup): InstanceConfig { + const instanceCount = props.instances != null ? props.instances : 2; + if (instanceCount < 1) { + throw new Error('At least one instance is required'); + } - // We must have a dependency on the NAT gateway provider here to create - // things in the right order. - instance.node.addDependency(internetConnected); + const instanceIdentifiers: string[] = []; + const instanceEndpoints: Endpoint[] = []; + const portAttribute = cluster.clusterEndpoint.port; + const instanceProps = props.instanceProps; + + // Get the actual subnet objects so we can depend on internet connectivity. + const internetConnected = instanceProps.vpc.selectSubnets(instanceProps.vpcSubnets).internetConnectivityEstablished; + + let monitoringRole; + if (props.monitoringInterval && props.monitoringInterval.toSeconds()) { + monitoringRole = props.monitoringRole || new Role(cluster, 'MonitoringRole', { + assumedBy: new ServicePrincipal('monitoring.rds.amazonaws.com'), + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonRDSEnhancedMonitoringRole'), + ], + }); + } - this.instanceIdentifiers.push(instance.ref); - this.instanceEndpoints.push(new Endpoint(instance.attrEndpointAddress, portAttribute)); - } + const enablePerformanceInsights = instanceProps.enablePerformanceInsights + || instanceProps.performanceInsightRetention !== undefined || instanceProps.performanceInsightEncryptionKey !== undefined; + if (enablePerformanceInsights && instanceProps.enablePerformanceInsights === false) { + throw new Error('`enablePerformanceInsights` disabled, but `performanceInsightRetention` or `performanceInsightEncryptionKey` was set'); } - private setLogRetention(props: DatabaseClusterProps) { - if (props.cloudwatchLogsExports) { - const unsupportedLogTypes = props.cloudwatchLogsExports.filter(logType => !props.engine.supportedLogTypes.includes(logType)); - if (unsupportedLogTypes.length > 0) { - throw new Error(`Unsupported logs for the current engine type: ${unsupportedLogTypes.join(',')}`); - } + const instanceType = instanceProps.instanceType ?? ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MEDIUM); + const instanceParameterGroupConfig = instanceProps.parameterGroup?.bindToInstance({}); + for (let i = 0; i < instanceCount; i++) { + const instanceIndex = i + 1; + const instanceIdentifier = props.instanceIdentifierBase != null ? `${props.instanceIdentifierBase}${instanceIndex}` : + props.clusterIdentifier != null ? `${props.clusterIdentifier}instance${instanceIndex}` : + undefined; - if (props.cloudwatchLogsRetention) { - for (const log of props.cloudwatchLogsExports) { - new logs.LogRetention(this, `LogRetention${log}`, { - logGroupName: `/aws/rds/cluster/${this.clusterIdentifier}/${log}`, - retention: props.cloudwatchLogsRetention, - role: props.cloudwatchLogsRetentionRole, - }); - } - } + const publiclyAccessible = instanceProps.vpcSubnets && instanceProps.vpcSubnets.subnetType === ec2.SubnetType.PUBLIC; + + const instance = new CfnDBInstance(cluster, `Instance${instanceIndex}`, { + // Link to cluster + engine: props.engine.engineType, + engineVersion: props.engine.engineVersion?.fullVersion, + dbClusterIdentifier: cluster.clusterIdentifier, + dbInstanceIdentifier: instanceIdentifier, + // Instance properties + dbInstanceClass: databaseInstanceType(instanceType), + publiclyAccessible, + enablePerformanceInsights: enablePerformanceInsights || instanceProps.enablePerformanceInsights, // fall back to undefined if not set + performanceInsightsKmsKeyId: instanceProps.performanceInsightEncryptionKey?.keyArn, + performanceInsightsRetentionPeriod: enablePerformanceInsights + ? (instanceProps.performanceInsightRetention || PerformanceInsightRetention.DEFAULT) + : undefined, + // This is already set on the Cluster. Unclear to me whether it should be repeated or not. Better yes. + dbSubnetGroupName: subnetGroup.ref, + dbParameterGroupName: instanceParameterGroupConfig?.parameterGroupName, + monitoringInterval: props.monitoringInterval && props.monitoringInterval.toSeconds(), + monitoringRoleArn: monitoringRole && monitoringRole.roleArn, + }); + + // If removalPolicy isn't explicitly set, + // it's Snapshot for Cluster. + // Because of that, in this case, + // we can safely use the CFN default of Delete for DbInstances with dbClusterIdentifier set. + if (props.removalPolicy) { + instance.applyRemovalPolicy(props.removalPolicy); } + + // We must have a dependency on the NAT gateway provider here to create + // things in the right order. + instance.node.addDependency(internetConnected); + + instanceIdentifiers.push(instance.ref); + instanceEndpoints.push(new Endpoint(instance.attrEndpointAddress, portAttribute)); } + + return { instanceEndpoints, instanceIdentifiers }; } /** diff --git a/packages/@aws-cdk/aws-rds/package.json b/packages/@aws-cdk/aws-rds/package.json index 7c65dfad99e40..90a92079eb6e5 100644 --- a/packages/@aws-cdk/aws-rds/package.json +++ b/packages/@aws-cdk/aws-rds/package.json @@ -105,6 +105,7 @@ "exclude": [ "props-physical-name:@aws-cdk/aws-rds.ParameterGroupProps", "props-physical-name:@aws-cdk/aws-rds.DatabaseClusterProps", + "props-physical-name:@aws-cdk/aws-rds.DatabaseClusterFromSnapshotProps", "props-physical-name:@aws-cdk/aws-rds.DatabaseInstanceProps", "props-physical-name:@aws-cdk/aws-rds.DatabaseInstanceFromSnapshotProps", "props-physical-name:@aws-cdk/aws-rds.DatabaseInstanceReadReplicaProps", diff --git a/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.expected.json b/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.expected.json index 348dba3e65ae7..e58745e098767 100644 --- a/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.expected.json +++ b/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.expected.json @@ -765,7 +765,7 @@ "Properties": { "Location": { "ApplicationId": "arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerRDSMySQLRotationSingleUser", - "SemanticVersion": "1.1.3" + "SemanticVersion": "1.1.60" }, "Parameters": { "endpoint": { @@ -812,4 +812,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-rds/test/integ.instance.lit.expected.json b/packages/@aws-cdk/aws-rds/test/integ.instance.lit.expected.json index 9f591e2399a62..53c8c03e0f283 100644 --- a/packages/@aws-cdk/aws-rds/test/integ.instance.lit.expected.json +++ b/packages/@aws-cdk/aws-rds/test/integ.instance.lit.expected.json @@ -813,7 +813,7 @@ "Properties": { "Location": { "ApplicationId": "arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerRDSOracleRotationSingleUser", - "SemanticVersion": "1.1.3" + "SemanticVersion": "1.1.60" }, "Parameters": { "endpoint": { diff --git a/packages/@aws-cdk/aws-rds/test/test.cluster.ts b/packages/@aws-cdk/aws-rds/test/test.cluster.ts index 09637a8deea1c..fa8480e000f5d 100644 --- a/packages/@aws-cdk/aws-rds/test/test.cluster.ts +++ b/packages/@aws-cdk/aws-rds/test/test.cluster.ts @@ -6,7 +6,10 @@ import * as logs from '@aws-cdk/aws-logs'; import * as s3 from '@aws-cdk/aws-s3'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; -import { AuroraMysqlEngineVersion, AuroraPostgresEngineVersion, DatabaseCluster, DatabaseClusterEngine, ParameterGroup, PerformanceInsightRetention } from '../lib'; +import { + AuroraEngineVersion, AuroraMysqlEngineVersion, AuroraPostgresEngineVersion, DatabaseCluster, DatabaseClusterEngine, + DatabaseClusterFromSnapshot, ParameterGroup, PerformanceInsightRetention, +} from '../lib'; export = { 'creating a Cluster also creates 2 DB Instances'(test: Test) { @@ -1310,6 +1313,37 @@ export = { test.done(); }, + + 'create a cluster from a snapshot'(test: Test) { + const stack = testStack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + // WHEN + new DatabaseClusterFromSnapshot(stack, 'Database', { + engine: DatabaseClusterEngine.aurora({ version: AuroraEngineVersion.VER_1_22_2 }), + instanceProps: { + vpc, + }, + snapshotIdentifier: 'mySnapshot', + }); + + // THEN + expect(stack).to(haveResource('AWS::RDS::DBCluster', { + Properties: { + Engine: 'aurora', + EngineVersion: '5.6.mysql_aurora.1.22.2', + DBSubnetGroupName: { Ref: 'DatabaseSubnets56F17B9A' }, + VpcSecurityGroupIds: [{ 'Fn::GetAtt': ['DatabaseSecurityGroup5C91FDCB', 'GroupId'] }], + SnapshotIdentifier: 'mySnapshot', + }, + DeletionPolicy: ABSENT, + UpdateReplacePolicy: 'Snapshot', + }, ResourcePart.CompleteDefinition)); + + expect(stack).to(countResources('AWS::RDS::DBInstance', 2)); + + test.done(); + }, }; function testStack() { diff --git a/packages/@aws-cdk/aws-secretsmanager/README.md b/packages/@aws-cdk/aws-secretsmanager/README.md index 540cc9a7fa0be..e8a511ecef269 100644 --- a/packages/@aws-cdk/aws-secretsmanager/README.md +++ b/packages/@aws-cdk/aws-secretsmanager/README.md @@ -43,7 +43,7 @@ A secret can set `RemovalPolicy`. If it set to `RETAIN`, that removing a secret ### Grant permission to use the secret to a role -You must grant permission to a resource for that resource to be allowed to +You must grant permission to a resource for that resource to be allowed to use a secret. This can be achieved with the `Secret.grantRead` and/or `Secret.grantUpdate` method, depending on your need: @@ -86,6 +86,7 @@ new SecretRotation(this, 'SecretRotation', { secret: mySecret, target: myDatabase, // a Connectable vpc: myVpc, // The VPC where the secret rotation application will be deployed + excludeCharacters: ` ;+%{}` + `@'"\`/\\#`, // A string of characters to never use when generating new passwords. Example is a superset of the characters which will break DMS endpoints and characters which cause problems in BASH scripts. }); ``` diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret-rotation.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret-rotation.ts index a64d65f146d70..6ef72114413cf 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/secret-rotation.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret-rotation.ts @@ -22,84 +22,84 @@ export class SecretRotationApplication { /** * Conducts an AWS SecretsManager secret rotation for RDS MariaDB using the single user rotation scheme */ - public static readonly MARIADB_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSMariaDBRotationSingleUser', '1.1.3'); + public static readonly MARIADB_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSMariaDBRotationSingleUser', '1.1.60'); /** * Conducts an AWS SecretsManager secret rotation for RDS MariaDB using the multi user rotation scheme */ - public static readonly MARIADB_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSMariaDBRotationMultiUser', '1.1.3', { + public static readonly MARIADB_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSMariaDBRotationMultiUser', '1.1.60', { isMultiUser: true, }); /** * Conducts an AWS SecretsManager secret rotation for RDS MySQL using the single user rotation scheme */ - public static readonly MYSQL_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSMySQLRotationSingleUser', '1.1.3'); + public static readonly MYSQL_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSMySQLRotationSingleUser', '1.1.60'); /** * Conducts an AWS SecretsManager secret rotation for RDS MySQL using the multi user rotation scheme */ - public static readonly MYSQL_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSMySQLRotationMultiUser', '1.1.3', { + public static readonly MYSQL_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSMySQLRotationMultiUser', '1.1.60', { isMultiUser: true, }); /** * Conducts an AWS SecretsManager secret rotation for RDS Oracle using the single user rotation scheme */ - public static readonly ORACLE_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSOracleRotationSingleUser', '1.1.3'); + public static readonly ORACLE_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSOracleRotationSingleUser', '1.1.60'); /** * Conducts an AWS SecretsManager secret rotation for RDS Oracle using the multi user rotation scheme */ - public static readonly ORACLE_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSOracleRotationMultiUser', '1.1.3', { + public static readonly ORACLE_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSOracleRotationMultiUser', '1.1.60', { isMultiUser: true, }); /** * Conducts an AWS SecretsManager secret rotation for RDS PostgreSQL using the single user rotation scheme */ - public static readonly POSTGRES_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSPostgreSQLRotationSingleUser', '1.1.3'); + public static readonly POSTGRES_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSPostgreSQLRotationSingleUser', '1.1.60'); /** * Conducts an AWS SecretsManager secret rotation for RDS PostgreSQL using the multi user rotation scheme */ - public static readonly POSTGRES_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSPostgreSQLRotationMultiUser', '1.1.3', { + public static readonly POSTGRES_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSPostgreSQLRotationMultiUser', '1.1.60', { isMultiUser: true, }); /** * Conducts an AWS SecretsManager secret rotation for RDS SQL Server using the single user rotation scheme */ - public static readonly SQLSERVER_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSSQLServerRotationSingleUser', '1.1.3'); + public static readonly SQLSERVER_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSSQLServerRotationSingleUser', '1.1.60'); /** * Conducts an AWS SecretsManager secret rotation for RDS SQL Server using the multi user rotation scheme */ - public static readonly SQLSERVER_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSSQLServerRotationMultiUser', '1.1.3', { + public static readonly SQLSERVER_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSSQLServerRotationMultiUser', '1.1.60', { isMultiUser: true, }); /** * Conducts an AWS SecretsManager secret rotation for Amazon Redshift using the single user rotation scheme */ - public static readonly REDSHIFT_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRedshiftRotationSingleUser', '1.1.3'); + public static readonly REDSHIFT_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRedshiftRotationSingleUser', '1.1.60'); /** * Conducts an AWS SecretsManager secret rotation for Amazon Redshift using the multi user rotation scheme */ - public static readonly REDSHIFT_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRedshiftRotationMultiUser', '1.1.3', { + public static readonly REDSHIFT_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRedshiftRotationMultiUser', '1.1.60', { isMultiUser: true, }); /** * Conducts an AWS SecretsManager secret rotation for MongoDB using the single user rotation scheme */ - public static readonly MONGODB_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerMongoDBRotationSingleUser', '1.1.3'); + public static readonly MONGODB_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerMongoDBRotationSingleUser', '1.1.60'); /** * Conducts an AWS SecretsManager secret rotation for MongoDB using the multi user rotation scheme */ - public static readonly MONGODB_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerMongoDBRotationMultiUser', '1.1.3', { + public static readonly MONGODB_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerMongoDBRotationMultiUser', '1.1.60', { isMultiUser: true, }); @@ -193,6 +193,13 @@ export interface SecretRotationProps { * @default - a new security group is created */ readonly securityGroup?: ec2.ISecurityGroup; + + /** + * Characters which should not appear in the generated password + * + * @default - no additional characters are explicitly excluded + */ + readonly excludeCharacters?: string; } /** @@ -226,6 +233,10 @@ export class SecretRotation extends Construct { vpcSecurityGroupIds: securityGroup.securityGroupId, }; + if (props.excludeCharacters) { + parameters.excludeCharacters = props.excludeCharacters; + } + if (props.secret.encryptionKey) { parameters.kmsKeyArn = props.secret.encryptionKey.keyArn; } diff --git a/packages/@aws-cdk/aws-secretsmanager/test/test.secret-rotation.ts b/packages/@aws-cdk/aws-secretsmanager/test/test.secret-rotation.ts index 463853ad8af08..79351afc059f3 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/test.secret-rotation.ts +++ b/packages/@aws-cdk/aws-secretsmanager/test/test.secret-rotation.ts @@ -14,6 +14,7 @@ export = { defaultPort: ec2.Port.tcp(3306), securityGroups: [new ec2.SecurityGroup(stack, 'SecurityGroup', { vpc })], }); + const excludeCharacters = ' ;+%{}' + '@\'"`/\\#'; // DMS and BASH problem chars // WHEN new secretsmanager.SecretRotation(stack, 'SecretRotation', { @@ -21,6 +22,7 @@ export = { secret, target, vpc, + excludeCharacters: excludeCharacters, }); // THEN @@ -65,7 +67,7 @@ export = { expect(stack).to(haveResource('AWS::Serverless::Application', { Location: { ApplicationId: 'arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerRDSMySQLRotationSingleUser', - SemanticVersion: '1.1.3', + SemanticVersion: '1.1.60', }, Parameters: { endpoint: { @@ -84,6 +86,7 @@ export = { ], }, functionName: 'SecretRotation', + excludeCharacters: excludeCharacters, vpcSecurityGroupIds: { 'Fn::GetAtt': [ 'SecretRotationSecurityGroup9985012B', diff --git a/packages/@aws-cdk/cloudformation-include/README.md b/packages/@aws-cdk/cloudformation-include/README.md index 51bf8a00d4b47..7820a855a35bf 100644 --- a/packages/@aws-cdk/cloudformation-include/README.md +++ b/packages/@aws-cdk/cloudformation-include/README.md @@ -308,3 +308,32 @@ role.addToPolicy(new iam.PolicyStatement({ resources: [cfnBucket.attrArn], })); ``` + +## Vending CloudFormation templates as Constructs + +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: + +```ts +import * as path from 'path'; + +export class MyConstruct extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + + // include a template inside the Construct + new cfn_inc.CfnInclude(this, 'MyConstruct', { + templateFile: path.join(__dirname, 'my-template.json'), + preserveLogicalIds: false, // <--- !!! + }); + } +} +``` + +Notice the `preserveLogicalIds` parameter - +it makes sure the logical IDs of all the included template elements are re-named using CDK's algorithm, +guaranteeing they are unique within your application. +Without that parameter passed, +instantiating `MyConstruct` twice in the same Stack would result in duplicated logical IDs. diff --git a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts index 54e226cbd054e..afd14ef14e0ed 100644 --- a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts +++ b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts @@ -14,6 +14,20 @@ export interface CfnIncludeProps { */ readonly templateFile: string; + /** + * Whether the resources should have the same logical IDs in the resulting CDK template + * as they did in the original CloudFormation template file. + * If you're vending a Construct using an existing CloudFormation template, + * make sure to pass this as `false`. + * + * **Note**: regardless of whether this option is true or false, + * the {@link CfnInclude.getResource} and related methods always uses the original logical ID of the resource/element, + * as specified in the template file. + * + * @default true + */ + readonly preserveLogicalIds?: boolean; + /** * Specifies the template files that define nested stacks that should be included. * @@ -88,8 +102,7 @@ export class CfnInclude extends core.CfnElement { // read the template into a JS object this.template = futils.readYamlSync(props.templateFile); - // ToDo implement preserveLogicalIds=false - this.preserveLogicalIds = true; + this.preserveLogicalIds = props.preserveLogicalIds ?? true; // check if all user specified parameter values exist in the template for (const logicalId of Object.keys(this.parametersToReplace)) { @@ -353,7 +366,7 @@ export class CfnInclude extends core.CfnElement { mapping: cfnParser.parseValue(this.template.Mappings[mappingName]), }); this.mappings[mappingName] = cfnMapping; - cfnMapping.overrideLogicalId(mappingName); + this.overrideLogicalIdIfNeeded(cfnMapping, mappingName); } private createParameter(logicalId: string): void { @@ -384,7 +397,7 @@ export class CfnInclude extends core.CfnElement { noEcho: expression.NoEcho, }); - cfnParameter.overrideLogicalId(logicalId); + this.overrideLogicalIdIfNeeded(cfnParameter, logicalId); this.parameters[logicalId] = cfnParameter; } @@ -411,7 +424,7 @@ export class CfnInclude extends core.CfnElement { assertions: ruleProperties.Assertions, }); this.rules[ruleName] = rule; - rule.overrideLogicalId(ruleName); + this.overrideLogicalIdIfNeeded(rule, ruleName); } private createHook(hookName: string): void { @@ -451,7 +464,7 @@ export class CfnInclude extends core.CfnElement { } } this.hooks[hookName] = hook; - hook.overrideLogicalId(hookName); + this.overrideLogicalIdIfNeeded(hook, hookName); } private createOutput(logicalId: string, scope: core.Construct): void { @@ -489,7 +502,7 @@ export class CfnInclude extends core.CfnElement { })(), }); - cfnOutput.overrideLogicalId(logicalId); + this.overrideLogicalIdIfNeeded(cfnOutput, logicalId); this.outputs[logicalId] = cfnOutput; } @@ -522,8 +535,7 @@ export class CfnInclude extends core.CfnElement { expression: cfnParser.parseValue(this.template.Conditions[conditionName]), }); - // ToDo handle renaming of the logical IDs of the conditions - cfnCondition.overrideLogicalId(conditionName); + this.overrideLogicalIdIfNeeded(cfnCondition, conditionName); this.conditions[conditionName] = cfnCondition; return cfnCondition; } @@ -600,11 +612,7 @@ export class CfnInclude extends core.CfnElement { } } - if (this.preserveLogicalIds) { - // override the logical ID to match the original template - l1Instance.overrideLogicalId(logicalId); - } - + this.overrideLogicalIdIfNeeded(l1Instance, logicalId); this.resources[logicalId] = l1Instance; return l1Instance; } @@ -652,4 +660,10 @@ export class CfnInclude extends core.CfnElement { } return ret; } + + private overrideLogicalIdIfNeeded(element: core.CfnElement, id: string): void { + if (this.preserveLogicalIds) { + element.overrideLogicalId(id); + } + } } 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 190cbf7ac3bb3..783db787093e5 100644 --- a/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/valid-templates.test.ts @@ -114,7 +114,7 @@ describe('CDK Include', () => { ); }); - xtest('correctly changes the logical IDs, including references, if imported with preserveLogicalIds=false', () => { + test('correctly changes the logical IDs, including references, if imported with preserveLogicalIds=false', () => { const cfnTemplate = includeTestTemplate(stack, 'bucket-with-encryption-key.json', { preserveLogicalIds: false, }); @@ -177,6 +177,11 @@ describe('CDK Include', () => { ], }, }, + "Metadata": { + "Object1": "Location1", + "KeyRef": { "Ref": "MyScopeKey7673692F" }, + "KeyArn": { "Fn::GetAtt": ["MyScopeKey7673692F", "Arn"] }, + }, "DeletionPolicy": "Retain", "UpdateReplacePolicy": "Retain", }, @@ -936,7 +941,7 @@ function includeTestTemplate(scope: core.Construct, testTemplate: string, props: return new inc.CfnInclude(scope, 'MyScope', { templateFile: _testTemplateFilePath(testTemplate), parameters: props.parameters, - // preserveLogicalIds: props.preserveLogicalIds, + preserveLogicalIds: props.preserveLogicalIds, }); } diff --git a/packages/@aws-cdk/core/lib/feature-flags.ts b/packages/@aws-cdk/core/lib/feature-flags.ts new file mode 100644 index 0000000000000..924283af30fcc --- /dev/null +++ b/packages/@aws-cdk/core/lib/feature-flags.ts @@ -0,0 +1,29 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { Construct } from '../lib/construct-compat'; + +/** + * Features that are implemented behind a flag in order to preserve backwards + * compatibility for existing apps. The list of flags are available in the + * `@aws-cdk/cx-api` module. + * + * The state of the flag for this application is stored as a CDK context variable. + */ +export class FeatureFlags { + /** + * Inspect feature flags on the construct node's context. + */ + public static of(scope: Construct) { + return new FeatureFlags(scope); + } + + private constructor(private readonly construct: Construct) {} + + /** + * Check whether a feature flag is enabled. If configured, the flag is present in + * the construct node context. Falls back to the defaults defined in the `cx-api` + * module. + */ + public isEnabled(featureFlag: string): boolean | undefined { + return this.construct.node.tryGetContext(featureFlag) ?? cxapi.futureFlagDefault(featureFlag); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/index.ts b/packages/@aws-cdk/core/lib/index.ts index ea37cbcd379fa..2b4f6470cc559 100644 --- a/packages/@aws-cdk/core/lib/index.ts +++ b/packages/@aws-cdk/core/lib/index.ts @@ -60,6 +60,8 @@ export * from './custom-resource-provider'; export * from './cfn-capabilities'; export * from './cloudformation.generated'; +export * from './feature-flags'; + // WARNING: Should not be exported, but currently is because of a bug. See the // class description for more information. export * from './private/intrinsic'; diff --git a/packages/@aws-cdk/core/lib/private/refs.ts b/packages/@aws-cdk/core/lib/private/refs.ts index 6521df69ed585..0fdc5e1bed40f 100644 --- a/packages/@aws-cdk/core/lib/private/refs.ts +++ b/packages/@aws-cdk/core/lib/private/refs.ts @@ -7,6 +7,7 @@ import { CfnElement } from '../cfn-element'; import { CfnOutput } from '../cfn-output'; import { CfnParameter } from '../cfn-parameter'; import { Construct, IConstruct } from '../construct-compat'; +import { FeatureFlags } from '../feature-flags'; import { Reference } from '../reference'; import { IResolvable } from '../resolvable'; import { Stack } from '../stack'; @@ -201,7 +202,7 @@ function getCreateExportsScope(stack: Stack) { } function generateExportName(stackExports: Construct, id: string) { - const stackRelativeExports = stackExports.node.tryGetContext(cxapi.STACK_RELATIVE_EXPORTS_CONTEXT); + const stackRelativeExports = FeatureFlags.of(stackExports).isEnabled(cxapi.STACK_RELATIVE_EXPORTS_CONTEXT); const stack = Stack.of(stackExports); const components = [ diff --git a/packages/@aws-cdk/core/lib/stack.ts b/packages/@aws-cdk/core/lib/stack.ts index de26587a2a861..bfe60415f862d 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -13,6 +13,7 @@ import { CfnResource, TagType } from './cfn-resource'; import { Construct, IConstruct, ISynthesisSession } from './construct-compat'; import { ContextProvider } from './context-provider'; import { Environment } from './environment'; +import { FeatureFlags } from './feature-flags'; import { CLOUDFORMATION_TOKEN_RESOLVER, CloudFormationLang } from './private/cloudformation-lang'; import { LogicalIDs } from './private/logical-id'; import { resolve } from './private/resolve'; @@ -358,14 +359,16 @@ export class Stack extends Construct implements ITaggable { // // Also use the new behavior if we are using the new CI/CD-ready synthesizer; that way // people only have to flip one flag. - // eslint-disable-next-line max-len - this.artifactId = this.node.tryGetContext(cxapi.ENABLE_STACK_NAME_DUPLICATES_CONTEXT) || this.node.tryGetContext(cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT) + const featureFlags = FeatureFlags.of(this); + const stackNameDupeContext = featureFlags.isEnabled(cxapi.ENABLE_STACK_NAME_DUPLICATES_CONTEXT); + const newStyleSynthesisContext = featureFlags.isEnabled(cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT); + this.artifactId = (stackNameDupeContext || newStyleSynthesisContext) ? this.generateStackArtifactId() : this.stackName; this.templateFile = `${this.artifactId}.template.json`; - this.synthesizer = props.synthesizer ?? (this.node.tryGetContext(cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT) + this.synthesizer = props.synthesizer ?? (newStyleSynthesisContext ? new DefaultStackSynthesizer() : new LegacyStackSynthesizer()); this.synthesizer.bind(this); diff --git a/packages/@aws-cdk/core/test/test.feature-flags.ts b/packages/@aws-cdk/core/test/test.feature-flags.ts new file mode 100644 index 0000000000000..abb1723e6a3dc --- /dev/null +++ b/packages/@aws-cdk/core/test/test.feature-flags.ts @@ -0,0 +1,31 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import { Test } from 'nodeunit'; +import { FeatureFlags, Stack } from '../lib'; + +export = { + isEnabled: { + 'returns true when the flag is enabled'(test: Test) { + const stack = new Stack(); + stack.node.setContext(cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT, true); + + const actual = FeatureFlags.of(stack).isEnabled(cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT); + test.equals(actual, true); + test.done(); + }, + + 'falls back to the default'(test: Test) { + const stack = new Stack(); + + test.equals(FeatureFlags.of(stack).isEnabled(cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT), + cxapi.futureFlagDefault(cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT)); + test.done(); + }, + + 'invalid flag'(test: Test) { + const stack = new Stack(); + + test.equals(FeatureFlags.of(stack).isEnabled('non-existent-flag'), undefined); + test.done(); + }, + }, +} \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/lib/features.ts b/packages/@aws-cdk/cx-api/lib/features.ts index 5483e64f92021..0a4ee8b8d02b0 100644 --- a/packages/@aws-cdk/cx-api/lib/features.ts +++ b/packages/@aws-cdk/cx-api/lib/features.ts @@ -63,5 +63,20 @@ export const FUTURE_FLAGS = { [STACK_RELATIVE_EXPORTS_CONTEXT]: 'true', // We will advertise this flag when the feature is complete - // [NEW_STYLE_STACK_SYNTHESIS]: 'true', + // [NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: 'true', }; + +/** + * The set of defaults that should be applied if the feature flag is not + * explicitly configured. + */ +const FUTURE_FLAGS_DEFAULTS: { [key: string]: boolean } = { + [ENABLE_STACK_NAME_DUPLICATES_CONTEXT]: false, + [ENABLE_DIFF_NO_FAIL_CONTEXT]: false, + [STACK_RELATIVE_EXPORTS_CONTEXT]: false, + [NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: false, +}; + +export function futureFlagDefault(flag: string): boolean { + return FUTURE_FLAGS_DEFAULTS[flag]; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cx-api/test/features.test.ts b/packages/@aws-cdk/cx-api/test/features.test.ts new file mode 100644 index 0000000000000..fbff6c236b984 --- /dev/null +++ b/packages/@aws-cdk/cx-api/test/features.test.ts @@ -0,0 +1,7 @@ +import * as feats from '../lib/features'; + +test('all future flags have defaults configured', () => { + Object.keys(feats.FUTURE_FLAGS).forEach(flag => { + expect(typeof(feats.futureFlagDefault(flag))).toEqual('boolean'); + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/pipelines/README.md b/packages/@aws-cdk/pipelines/README.md index 8f19d40253bfb..0fe49601d145a 100644 --- a/packages/@aws-cdk/pipelines/README.md +++ b/packages/@aws-cdk/pipelines/README.md @@ -384,6 +384,34 @@ files from several sources: * Directoy from the source repository * Additional compiled artifacts from the synth step +### Controlling IAM permissions + +IAM permissions can be added to the execution role of a `ShellScriptAction` in +two ways. + +Either pass additional policy statements in the `rolePolicyStatements` property: + +```ts +new ShellScriptAction({ + // ... + rolePolicyStatements: [ + new iam.PolicyStatement({ + actions: ['s3:GetObject'], + resources: ['*'], + }), + ], +})); +``` + +The Action can also be used as a Grantable after having been added to a Pipeline: + +```ts +const action = new ShellScriptAction({ /* ... */ }); +pipeline.addStage('Test').addActions(action); + +bucket.grantRead(action); +``` + #### Additional files from the source repository Bringing in additional files from the source repository is appropriate if the diff --git a/packages/@aws-cdk/pipelines/lib/stage.ts b/packages/@aws-cdk/pipelines/lib/stage.ts index e916d8131c7a2..4f599ae52bf09 100644 --- a/packages/@aws-cdk/pipelines/lib/stage.ts +++ b/packages/@aws-cdk/pipelines/lib/stage.ts @@ -72,6 +72,12 @@ export class CdkStage extends Construct { public addApplication(appStage: Stage, options: AddStageOptions = {}) { const asm = appStage.synth(); + if (asm.stacks.length === 0) { + // If we don't check here, a more puzzling "stage contains no actions" + // error will be thrown come deployment time. + throw new Error(`The given Stage construct ('${appStage.node.path}') should contain at least one Stack`); + } + const sortedTranches = topologicalSort(asm.stacks, stack => stack.id, stack => stack.dependencies.map(d => d.id)); diff --git a/packages/@aws-cdk/pipelines/lib/synths/simple-synth-action.ts b/packages/@aws-cdk/pipelines/lib/synths/simple-synth-action.ts index 62bc8299bdb4b..6a52a22207808 100644 --- a/packages/@aws-cdk/pipelines/lib/synths/simple-synth-action.ts +++ b/packages/@aws-cdk/pipelines/lib/synths/simple-synth-action.ts @@ -3,7 +3,7 @@ import * as codebuild from '@aws-cdk/aws-codebuild'; import * as codepipeline from '@aws-cdk/aws-codepipeline'; import * as codepipeline_actions from '@aws-cdk/aws-codepipeline-actions'; import * as events from '@aws-cdk/aws-events'; -import { PolicyStatement } from '@aws-cdk/aws-iam'; +import * as iam from '@aws-cdk/aws-iam'; import { Construct } from '@aws-cdk/core'; import { cloudAssemblyBuildSpecDir } from '../private/construct-internals'; import { copyEnvironmentVariables, filterEmpty } from './_util'; @@ -86,7 +86,7 @@ export interface SimpleSynthOptions { * * @default - No policy statements added to CodeBuild Project Role */ - readonly rolePolicyStatements?: PolicyStatement[]; + readonly rolePolicyStatements?: iam.PolicyStatement[]; } /** @@ -171,7 +171,7 @@ export interface AdditionalArtifact { /** * A standard synth with a generated buildspec */ -export class SimpleSynthAction implements codepipeline.IAction { +export class SimpleSynthAction implements codepipeline.IAction, iam.IGrantable { /** * Create a standard NPM synth action @@ -205,6 +205,7 @@ export class SimpleSynthAction implements codepipeline.IAction { private _action?: codepipeline_actions.CodeBuildAction; private _actionProperties: codepipeline.ActionProperties; + private _project?: codebuild.IProject; constructor(private readonly props: SimpleSynthActionProps) { // A number of actionProperties get read before bind() is even called (so before we @@ -253,6 +254,16 @@ export class SimpleSynthAction implements codepipeline.IAction { return this._actionProperties; } + /** + * Project generated to run the synth command + */ + public get project(): codebuild.IProject { + if (!this._project) { + throw new Error('Project becomes available after SimpleSynthAction has been bound to a stage'); + } + return this._project; + } + /** * Exists to implement IAction */ @@ -296,6 +307,8 @@ export class SimpleSynthAction implements codepipeline.IAction { }); } + this._project = project; + this._action = new codepipeline_actions.CodeBuildAction({ actionName: this.actionProperties.actionName, input: this.props.sourceArtifact, @@ -339,6 +352,13 @@ export class SimpleSynthAction implements codepipeline.IAction { } } + /** + * The CodeBuild Project's principal + */ + public get grantPrincipal(): iam.IPrincipal { + return this.project.grantPrincipal; + } + /** * Exists to implement IAction */ @@ -411,4 +431,4 @@ export interface StandardYarnSynthOptions extends SimpleSynthOptions { * @default 'npx cdk synth' */ readonly synthCommand?: string; -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/pipelines/lib/validation/shell-script-action.ts b/packages/@aws-cdk/pipelines/lib/validation/shell-script-action.ts index b5d1d820b8a6b..ae4f8367f90eb 100644 --- a/packages/@aws-cdk/pipelines/lib/validation/shell-script-action.ts +++ b/packages/@aws-cdk/pipelines/lib/validation/shell-script-action.ts @@ -2,6 +2,7 @@ import * as codebuild from '@aws-cdk/aws-codebuild'; import * as codepipeline from '@aws-cdk/aws-codepipeline'; import * as codepipeline_actions from '@aws-cdk/aws-codepipeline-actions'; import * as events from '@aws-cdk/aws-events'; +import * as iam from '@aws-cdk/aws-iam'; import { Construct } from '@aws-cdk/core'; import { StackOutput } from '../stage'; @@ -64,12 +65,19 @@ export interface ShellScriptActionProps { * @default 100 */ readonly runOrder?: number; + + /** + * Additional policy statements to add to the execution role + * + * @default - No policy statements + */ + readonly rolePolicyStatements?: iam.PolicyStatement[]; } /** * Validate a revision using shell commands */ -export class ShellScriptAction implements codepipeline.IAction { +export class ShellScriptAction implements codepipeline.IAction, iam.IGrantable { private _project?: codebuild.IProject; private _action?: codepipeline_actions.CodeBuildAction; @@ -99,6 +107,13 @@ export class ShellScriptAction implements codepipeline.IAction { } } + /** + * The CodeBuild Project's principal + */ + public get grantPrincipal(): iam.IPrincipal { + return this.project.grantPrincipal; + } + /** * Exists to implement IAction */ @@ -147,6 +162,9 @@ export class ShellScriptAction implements codepipeline.IAction { }, }), }); + for (const statement of this.props.rolePolicyStatements ?? []) { + this._project.addToRolePolicy(statement); + } this._action = new codepipeline_actions.CodeBuildAction({ actionName: this.props.actionName, diff --git a/packages/@aws-cdk/pipelines/test/builds.test.ts b/packages/@aws-cdk/pipelines/test/builds.test.ts index c270aa176cad9..450edf4849fd0 100644 --- a/packages/@aws-cdk/pipelines/test/builds.test.ts +++ b/packages/@aws-cdk/pipelines/test/builds.test.ts @@ -1,6 +1,7 @@ import { arrayWith, deepObjectLike, encodedJson } from '@aws-cdk/assert'; import '@aws-cdk/assert/jest'; import * as codepipeline from '@aws-cdk/aws-codepipeline'; +import * as s3 from '@aws-cdk/aws-s3'; import { Stack } from '@aws-cdk/core'; import * as cdkp from '../lib'; import { PIPELINE_ENV, TestApp, TestGitHubNpmPipeline } from './testutil'; @@ -176,9 +177,6 @@ test.each([['npm'], ['yarn']])('%s can have its install command overridden', (np test('Standard (NPM) synth can output additional artifacts', () => { // WHEN - sourceArtifact = new codepipeline.Artifact(); - cloudAssemblyArtifact = new codepipeline.Artifact('CloudAsm'); - const addlArtifact = new codepipeline.Artifact('IntegTest'); new TestGitHubNpmPipeline(pipelineStack, 'Cdk', { sourceArtifact, @@ -219,6 +217,32 @@ test('Standard (NPM) synth can output additional artifacts', () => { }); }); +test('SimpleSynthAction is IGrantable', () => { + // GIVEN + const synthAction = cdkp.SimpleSynthAction.standardNpmSynth({ + sourceArtifact, + cloudAssemblyArtifact, + }); + new TestGitHubNpmPipeline(pipelineStack, 'Cdk', { + sourceArtifact, + cloudAssemblyArtifact, + synthAction, + }); + const bucket = new s3.Bucket(pipelineStack, 'Bucket'); + + // WHEN + bucket.grantRead(synthAction); + + // THEN + expect(pipelineStack).toHaveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith(deepObjectLike({ + Action: ['s3:GetObject*', 's3:GetBucket*', 's3:List*'], + })), + }, + }); +}); + function npmYarnBuild(npmYarn: string) { if (npmYarn === 'npm') { return cdkp.SimpleSynthAction.standardNpmSynth; } if (npmYarn === 'yarn') { return cdkp.SimpleSynthAction.standardYarnSynth; } diff --git a/packages/@aws-cdk/pipelines/test/pipeline.test.ts b/packages/@aws-cdk/pipelines/test/pipeline.test.ts index 7719620706cc6..7d4455959a8eb 100644 --- a/packages/@aws-cdk/pipelines/test/pipeline.test.ts +++ b/packages/@aws-cdk/pipelines/test/pipeline.test.ts @@ -42,6 +42,13 @@ test('references stack template in subassembly', () => { }); }); +test('obvious error is thrown when stage contains no stacks', () => { + // WHEN + expect(() => { + pipeline.addApplicationStage(new Stage(app, 'EmptyStage')); + }).toThrow(/should contain at least one Stack/); +}); + test('action has right settings for same-env deployment', () => { // WHEN pipeline.addApplicationStage(new OneStackApp(app, 'Same')); diff --git a/packages/@aws-cdk/pipelines/test/validation.test.ts b/packages/@aws-cdk/pipelines/test/validation.test.ts index 9c311af6c5b47..ae1b44f2671f2 100644 --- a/packages/@aws-cdk/pipelines/test/validation.test.ts +++ b/packages/@aws-cdk/pipelines/test/validation.test.ts @@ -1,6 +1,8 @@ import { anything, arrayWith, deepObjectLike, encodedJson } from '@aws-cdk/assert'; import '@aws-cdk/assert/jest'; import * as codepipeline from '@aws-cdk/aws-codepipeline'; +import * as iam from '@aws-cdk/aws-iam'; +import * as s3 from '@aws-cdk/aws-s3'; import { CfnOutput, Construct, Stack, Stage, StageProps } from '@aws-cdk/core'; import * as cdkp from '../lib'; import { } from './testmatchers'; @@ -173,6 +175,54 @@ test('can use additional files from build', () => { }); }); +test('add policy statements to ShellScriptAction', () => { + // WHEN + pipeline.addStage('Test').addActions(new cdkp.ShellScriptAction({ + actionName: 'Boop', + additionalArtifacts: [integTestArtifact], + commands: ['true'], + rolePolicyStatements: [ + new iam.PolicyStatement({ + actions: ['s3:Banana'], + resources: ['*'], + }), + ], + })); + + // THEN + expect(pipelineStack).toHaveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith(deepObjectLike({ + Action: 's3:Banana', + Resource: '*', + })), + }, + }); +}); + +test('ShellScriptAction is IGrantable', () => { + // GIVEN + const action = new cdkp.ShellScriptAction({ + actionName: 'Boop', + additionalArtifacts: [integTestArtifact], + commands: ['true'], + }); + pipeline.addStage('Test').addActions(action); + const bucket = new s3.Bucket(pipelineStack, 'Bucket'); + + // WHEN + bucket.grantRead(action); + + // THEN + expect(pipelineStack).toHaveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith(deepObjectLike({ + Action: ['s3:GetObject*', 's3:GetBucket*', 's3:List*'], + })), + }, + }); +}); + class AppWithStackOutput extends Stage { public readonly output: CfnOutput; diff --git a/packages/aws-cdk/bin/cdk.ts b/packages/aws-cdk/bin/cdk.ts index e03dc68db94d0..56265748bf3a3 100644 --- a/packages/aws-cdk/bin/cdk.ts +++ b/packages/aws-cdk/bin/cdk.ts @@ -224,13 +224,14 @@ async function initCommandLine() { return await cli.list(args.STACKS, { long: args.long }); case 'diff': + const enableDiffNoFail = isFeatureEnabled(configuration, cxapi.ENABLE_DIFF_NO_FAIL); return await cli.diff({ stackNames: args.STACKS, exclusively: args.exclusively, templatePath: args.template, strict: args.strict, contextLines: args.contextLines, - fail: args.fail || !configuration.context.get(cxapi.ENABLE_DIFF_NO_FAIL), + fail: args.fail || !enableDiffNoFail, }); case 'bootstrap': @@ -241,13 +242,14 @@ async function initCommandLine() { // anticipation of flipping the switch, in user messaging we still call it // "new" bootstrapping. let source: BootstrapSource = { source: 'legacy' }; + const newStyleStackSynthesis = isFeatureEnabled(configuration, cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT); if (args.template) { print(`Using bootstrapping template from ${args.template}`); source = { source: 'custom', templateFile: args.template }; } else if (process.env.CDK_NEW_BOOTSTRAP) { print('CDK_NEW_BOOTSTRAP set, using new-style bootstrapping'); source = { source: 'default' }; - } else if (configuration.context.get(cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT)) { + } else if (newStyleStackSynthesis) { print(`'${cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT}' context set, using new-style bootstrapping`); source = { source: 'default' }; } @@ -336,6 +338,10 @@ async function initCommandLine() { } } +function isFeatureEnabled(configuration: Configuration, featureFlag: string) { + return configuration.context.get(featureFlag) ?? cxapi.futureFlagDefault(featureFlag); +} + /** * Translate a Yargs input array to something that makes more sense in a programming language * model (telling the difference between absence and an empty array)