From e89dab32e4c211597061a8b4274965a41c101dd8 Mon Sep 17 00:00:00 2001 From: Adam Ruka Date: Tue, 8 Sep 2020 10:20:58 -0700 Subject: [PATCH 01/12] chore(cfn-include): fix typos in IncludeNestedStack (#10227) chore(cfn-include): fix typos in IncludedNestedStack docs ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts index afd14ef14e0ed..14549f452eccb 100644 --- a/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts +++ b/packages/@aws-cdk/cloudformation-include/lib/cfn-include.ts @@ -60,12 +60,12 @@ export interface CfnIncludeProps { */ export interface IncludedNestedStack { /** - * The NestedStack object which respresents the scope of the template. + * The NestedStack object which represents the scope of the template. */ readonly stack: core.NestedStack; /** - * The CfnInclude that respresents the template, which can + * The CfnInclude that represents the template, which can * be used to access Resources and other template elements. */ readonly includedTemplate: CfnInclude; From f945334df339965308e6abb58ebaa0cc82c4c353 Mon Sep 17 00:00:00 2001 From: Bryan Pan Date: Tue, 8 Sep 2020 11:04:10 -0700 Subject: [PATCH 02/12] chore(s3-deployment): separate calls for core.Expiration causes build errors (#10250) The `system metadata is correctly transformed` test fails sporadically due to two separate calls to `core.Expiration`. This causes build errors whenever the first call in `WHEN` occurs a few milliseconds before the second call to `GIVEN. Fixed by making a variable called `Expiration` to hold the expiration. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../aws-s3-deployment/test/bucket-deployment.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/@aws-cdk/aws-s3-deployment/test/bucket-deployment.test.ts b/packages/@aws-cdk/aws-s3-deployment/test/bucket-deployment.test.ts index ae2243150da61..3ba33793ab8b3 100644 --- a/packages/@aws-cdk/aws-s3-deployment/test/bucket-deployment.test.ts +++ b/packages/@aws-cdk/aws-s3-deployment/test/bucket-deployment.test.ts @@ -304,6 +304,7 @@ test('system metadata is correctly transformed', () => { // GIVEN const stack = new cdk.Stack(); const bucket = new s3.Bucket(stack, 'Dest'); + const expiration = cdk.Expiration.after(cdk.Duration.hours(12)); // WHEN new s3deploy.BucketDeployment(stack, 'Deploy', { @@ -318,7 +319,7 @@ test('system metadata is correctly transformed', () => { serverSideEncryptionCustomerAlgorithm: 'rot13', websiteRedirectLocation: 'example', cacheControl: [s3deploy.CacheControl.setPublic(), s3deploy.CacheControl.maxAge(cdk.Duration.hours(1))], - expires: cdk.Expiration.after(cdk.Duration.hours(12)), + expires: expiration, }); // THEN @@ -331,7 +332,7 @@ test('system metadata is correctly transformed', () => { 'sse': 'aws:kms', 'sse-kms-key-id': 'mykey', 'cache-control': 'public, max-age=3600', - 'expires': cdk.Expiration.after(cdk.Duration.hours(12)).date.toUTCString(), + 'expires': expiration.date.toUTCString(), 'sse-c-copy-source': 'rot13', 'website-redirect': 'example', }, From b2cc277971aed36aa03e720b8fea093ef14bd9be Mon Sep 17 00:00:00 2001 From: haruharuharuby Date: Tue, 8 Sep 2020 20:38:56 +0200 Subject: [PATCH 03/12] feat(appsync): add authorization config to the HttpDataSource (#10171) This PR supersedes #9971 ### Description Adding authorization config to HttpDatasource in aws-appsync module. Users will be able to specify the awsIamConfig in httpConfig as parameter of HttpDataSource. Fixes: #9934 ---- --- packages/@aws-cdk/aws-appsync/README.md | 82 ++++++++++++++++++- .../@aws-cdk/aws-appsync/lib/data-source.ts | 30 ++++++- .../aws-appsync/lib/graphqlapi-base.ts | 19 ++++- .../aws-appsync/test/appsync-http.test.ts | 30 ++++++- 4 files changed, 154 insertions(+), 7 deletions(-) diff --git a/packages/@aws-cdk/aws-appsync/README.md b/packages/@aws-cdk/aws-appsync/README.md index a771fb43243fa..5eb93d7e07cfe 100644 --- a/packages/@aws-cdk/aws-appsync/README.md +++ b/packages/@aws-cdk/aws-appsync/README.md @@ -18,8 +18,10 @@ APIs that use GraphQL. ### Example +### DynamoDB + Example of a GraphQL API with `AWS_IAM` authorization resolving into a DynamoDb -backend data source. +backend data source. GraphQL schema file `schema.graphql`: @@ -82,6 +84,83 @@ demoDS.createResolver({ }); ``` +#### HTTP Endpoints +GraphQL schema file `schema.graphql`: + +```gql +type job { + id: String! + version: String! +} + +input DemoInput { + version: String! +} + +type Mutation { + callStepFunction(input: DemoInput!): job +} +``` + +GraphQL request mapping template `request.vtl`: + +``` +{ + "version": "2018-05-29", + "method": "POST", + "resourcePath": "/", + "params": { + "headers": { + "content-type": "application/x-amz-json-1.0", + "x-amz-target":"AWSStepFunctions.StartExecution" + }, + "body": { + "stateMachineArn": "", + "input": "{ \"id\": \"$context.arguments.id\" }" + } + } +} +``` + +GraphQL response mapping template `response.vtl`: + +``` +{ + "id": "${context.result.id}" +} +``` + +CDK stack file `app-stack.ts`: + +```ts +import * as appsync from '@aws-cdk/aws-appsync'; + +const api = new appsync.GraphqlApi(scope, 'api', { + name: 'api', + schema: appsync.Schema.fromFile(join(__dirname, 'schema.graphql')), +}); + +const httpDs = api.addHttpDataSource( + 'ds', + 'https://states.amazonaws.com', + { + name: 'httpDsWithStepF', + description: 'from appsync to StepFunctions Workflow', + authorizationConfig: { + signingRegion: 'us-east-1', + signingServiceName: 'states' + } + } +); + +httpDs.createResolver({ + typeName: 'Mutation', + fieldName: 'callStepFunction', + requestMappingTemplate: MappingTemplate.fromFile('request.vtl'), + responseMappingTemplate: MappingTemplate.fromFile('response.vtl') +}); +``` + ### Schema Every GraphQL Api needs a schema to define the Api. CDK offers `appsync.Schema` @@ -129,7 +208,6 @@ const api = appsync.GraphqlApi(stack, 'api', { ``` ### Imports - Any GraphQL Api that has been created outside the stack can be imported from another stack into your CDK app. Utilizing the `fromXxx` function, you have the ability to add data sources and resolvers through a `IGraphqlApi` interface. diff --git a/packages/@aws-cdk/aws-appsync/lib/data-source.ts b/packages/@aws-cdk/aws-appsync/lib/data-source.ts index 04c617b12e5d0..c67d96562b126 100644 --- a/packages/@aws-cdk/aws-appsync/lib/data-source.ts +++ b/packages/@aws-cdk/aws-appsync/lib/data-source.ts @@ -203,6 +203,21 @@ export class DynamoDbDataSource extends BackedDataSource { } } +/** + * The authorization config in case the HTTP endpoint requires authorization + */ +export interface AwsIamConfig { + /** + * The signing region for AWS IAM authorization + */ + readonly signingRegion: string; + + /** + * The signing service name for AWS IAM authorization + */ + readonly signingServiceName: string; +} + /** * Properties for an AppSync http datasource */ @@ -211,6 +226,14 @@ export interface HttpDataSourceProps extends BaseDataSourceProps { * The http endpoint */ readonly endpoint: string; + + /** + * The authorization config in case the HTTP endpoint requires authorization + * + * @default - none + * + */ + readonly authorizationConfig?: AwsIamConfig; } /** @@ -218,11 +241,16 @@ export interface HttpDataSourceProps extends BaseDataSourceProps { */ export class HttpDataSource extends BaseDataSource { constructor(scope: Construct, id: string, props: HttpDataSourceProps) { + const authorizationConfig = props.authorizationConfig ? { + authorizationType: 'AWS_IAM', + awsIamConfig: props.authorizationConfig, + } : undefined; super(scope, id, props, { + type: 'HTTP', httpConfig: { endpoint: props.endpoint, + authorizationConfig, }, - type: 'HTTP', }); } } diff --git a/packages/@aws-cdk/aws-appsync/lib/graphqlapi-base.ts b/packages/@aws-cdk/aws-appsync/lib/graphqlapi-base.ts index 2f357c142db91..0525b51340fcd 100644 --- a/packages/@aws-cdk/aws-appsync/lib/graphqlapi-base.ts +++ b/packages/@aws-cdk/aws-appsync/lib/graphqlapi-base.ts @@ -1,7 +1,7 @@ import { ITable } from '@aws-cdk/aws-dynamodb'; import { IFunction } from '@aws-cdk/aws-lambda'; import { CfnResource, IResource, Resource } from '@aws-cdk/core'; -import { DynamoDbDataSource, HttpDataSource, LambdaDataSource, NoneDataSource } from './data-source'; +import { DynamoDbDataSource, HttpDataSource, LambdaDataSource, NoneDataSource, AwsIamConfig } from './data-source'; /** * Optional configuration for data sources @@ -22,6 +22,18 @@ export interface DataSourceOptions { readonly description?: string; } +/** + * Optional configuration for Http data sources + */ +export interface HttpDataSourceOptions extends DataSourceOptions { + /** + * The authorization config in case the HTTP endpoint requires authorization + * + * @default - none + */ + readonly authorizationConfig?: AwsIamConfig; +} + /** * Interface for GraphQL */ @@ -67,7 +79,7 @@ export interface IGraphqlApi extends IResource { * @param endpoint The http endpoint * @param options The optional configuration for this data source */ - addHttpDataSource(id: string, endpoint: string, options?: DataSourceOptions): HttpDataSource; + addHttpDataSource(id: string, endpoint: string, options?: HttpDataSourceOptions): HttpDataSource; /** * add a new Lambda data source to this API @@ -140,12 +152,13 @@ export abstract class GraphqlApiBase extends Resource implements IGraphqlApi { * @param endpoint The http endpoint * @param options The optional configuration for this data source */ - public addHttpDataSource(id: string, endpoint: string, options?: DataSourceOptions): HttpDataSource { + public addHttpDataSource(id: string, endpoint: string, options?: HttpDataSourceOptions): HttpDataSource { return new HttpDataSource(this, id, { api: this, endpoint, name: options?.name, description: options?.description, + authorizationConfig: options?.authorizationConfig, }); } diff --git a/packages/@aws-cdk/aws-appsync/test/appsync-http.test.ts b/packages/@aws-cdk/aws-appsync/test/appsync-http.test.ts index 6bc237e0f5c71..fbc9b7bc85c33 100644 --- a/packages/@aws-cdk/aws-appsync/test/appsync-http.test.ts +++ b/packages/@aws-cdk/aws-appsync/test/appsync-http.test.ts @@ -57,6 +57,35 @@ describe('Http Data Source configuration', () => { }); }); + test('appsync configures name, authorizationConfig correctly', () => { + // WHEN + api.addHttpDataSource('ds', endpoint, { + name: 'custom', + description: 'custom description', + authorizationConfig: { + signingRegion: 'us-east-1', + signingServiceName: 'states', + }, + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::AppSync::DataSource', { + Type: 'HTTP', + Name: 'custom', + Description: 'custom description', + HttpConfig: { + Endpoint: endpoint, + AuthorizationConfig: { + AuthorizationType: 'AWS_IAM', + AwsIamConfig: { + SigningRegion: 'us-east-1', + SigningServiceName: 'states', + }, + }, + }, + }); + }); + test('appsync errors when creating multiple http data sources with no configuration', () => { // THEN expect(() => { @@ -97,4 +126,3 @@ describe('adding http data source from imported api', () => { }); }); - From a388d70f38a84195bbe5e580220b5cd21ebde624 Mon Sep 17 00:00:00 2001 From: Adam Ruka Date: Tue, 8 Sep 2020 12:05:10 -0700 Subject: [PATCH 04/12] fix(cfn-include): correctly parse YAML strings in short-form GetAtt (#10197) We weren't recursively parsing the argument of the short-form `Fn::GetAtt` if the arguments to it where given in the string form; which meant, if they were quoted (which is legal in YAML), we would add the quote to the logical ID of the resource, which is obviously incorrect. Fixes #10177 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../cloudformation-include/lib/file-utils.ts | 38 +++++++++++-------- .../test/invalid-templates.test.ts | 8 +++- .../invalid/short-form-get-att-no-dot.yaml | 7 ++++ .../yaml/short-form-get-att.yaml | 4 +- .../test/yaml-templates.test.ts | 2 +- 5 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/short-form-get-att-no-dot.yaml diff --git a/packages/@aws-cdk/cloudformation-include/lib/file-utils.ts b/packages/@aws-cdk/cloudformation-include/lib/file-utils.ts index eca3a7a0110ba..e78a6e46bde8a 100644 --- a/packages/@aws-cdk/cloudformation-include/lib/file-utils.ts +++ b/packages/@aws-cdk/cloudformation-include/lib/file-utils.ts @@ -37,23 +37,29 @@ const shortForms: yaml_types.Schema.CustomTag[] = [ makeTagForCfnIntrinsic('Ref', false), makeTagForCfnIntrinsic('Condition', false), makeTagForCfnIntrinsic('GetAtt', true, (_doc: yaml.Document, cstNode: yaml_cst.CST.Node): any => { - // The position of the leftmost period and opening bracket tell us what syntax is being used - // If no brackets are found, then the dot notation is being used; the leftmost dot separates the - // logical ID from the attribute. - // - // If a bracket is found, then the list notation is being used; if present, the leftmost dot separates the - // logical ID from the attribute. - const firstDot = cstNode.toString().indexOf('.'); - const firstBracket = cstNode.toString().indexOf('['); + const parsedArguments = parseYamlStrWithCfnTags(cstNode.toString().substring('!GetAtt'.length)); - return { - 'Fn::GetAtt': firstDot !== -1 && firstBracket === -1 - ? [ - cstNode.toString().substring('!GetAtt '.length, firstDot), - parseYamlStrWithCfnTags((cstNode.toString().substring(firstDot + 1))), - ] - : parseYamlStrWithCfnTags(cstNode.toString().substring('!GetAtt'.length)), - }; + let value: any; + if (typeof parsedArguments === 'string') { + // if the arguments to !GetAtt are a string, + // the part before the first '.' is the logical ID, + // and the rest is the attribute name + // (which can contain '.') + const firstDot = parsedArguments.indexOf('.'); + if (firstDot === -1) { + throw new Error(`Short-form Fn::GetAtt must contain a '.' in its string argument, got: '${parsedArguments}'`); + } + value = [ + parsedArguments.substring(0, firstDot), + parsedArguments.substring(firstDot + 1), // the + 1 is to skip the actual '.' + ]; + } else { + // this is the form where the arguments to Fn::GetAtt are already an array - + // in this case, nothing more to do + value = parsedArguments; + } + + return { 'Fn::GetAtt': value }; }), ); diff --git a/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts b/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts index ffbfcd81ffcc8..17bf6b9a3261e 100644 --- a/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/invalid-templates.test.ts @@ -125,13 +125,19 @@ describe('CDK Include', () => { }).toThrow(/Element referenced in Fn::Sub expression with logical ID: '' was not found in the template/); }); - test('throws an error when a template supplies an invalid string to a number parameter', () => { + test("throws an exception for a template with a non-number string passed to a property with type 'number'", () => { includeTestTemplate(stack, 'alphabetical-string-passed-to-number.json'); expect(() => { SynthUtils.synthesize(stack); }).toThrow(/"abc" should be a number/); }); + + test('throws an exception for a template with a short-form Fn::GetAtt whose string argument does not contain a dot', () => { + expect(() => { + includeTestTemplate(stack, 'short-form-get-att-no-dot.yaml'); + }).toThrow(/Short-form Fn::GetAtt must contain a '.' in its string argument, got: 'Bucket1Arn'/); + }); }); function includeTestTemplate(scope: core.Construct, testTemplate: string): inc.CfnInclude { diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/short-form-get-att-no-dot.yaml b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/short-form-get-att-no-dot.yaml new file mode 100644 index 0000000000000..d91ae5a5cbcf2 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/invalid/short-form-get-att-no-dot.yaml @@ -0,0 +1,7 @@ +Resources: + Bucket1: + Type: AWS::S3::Bucket + Bucket2: + Type: AWS::S3::Bucket + Metadata: + Bucket1Name: !GetAtt Bucket1Arn diff --git a/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/short-form-get-att.yaml b/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/short-form-get-att.yaml index ede387067361a..146f04045d380 100644 --- a/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/short-form-get-att.yaml +++ b/packages/@aws-cdk/cloudformation-include/test/test-templates/yaml/short-form-get-att.yaml @@ -16,9 +16,9 @@ Resources: Type: AWS::S3::Bucket Properties: BucketName: !GetAtt Bucket0.Arn - AccessControl: !GetAtt [ ELB, SourceSecurityGroup.GroupName ] + AccessControl: !GetAtt [ELB, SourceSecurityGroup.GroupName] Bucket2: Type: AWS::S3::Bucket Properties: BucketName: !GetAtt [ Bucket1, Arn ] - AccessControl: !GetAtt ELB.SourceSecurityGroup.GroupName + AccessControl: !GetAtt 'ELB.SourceSecurityGroup.GroupName' diff --git a/packages/@aws-cdk/cloudformation-include/test/yaml-templates.test.ts b/packages/@aws-cdk/cloudformation-include/test/yaml-templates.test.ts index 99339a064a9e1..84f3fb43ab4bc 100644 --- a/packages/@aws-cdk/cloudformation-include/test/yaml-templates.test.ts +++ b/packages/@aws-cdk/cloudformation-include/test/yaml-templates.test.ts @@ -254,8 +254,8 @@ describe('CDK Include', () => { }); }); - // Note that this yaml template fails validation. It is unclear how to invoke !Transform. test('can ingest a template with the short form !Transform function', () => { + // Note that this yaml template fails validation. It is unclear how to invoke !Transform. includeTestTemplate(stack, 'invalid/short-form-transform.yaml'); expect(stack).toMatchTemplate({ From 40222ef398fd1fb63b3b886624d5bb40562142c6 Mon Sep 17 00:00:00 2001 From: Radoslaw Smogura Date: Tue, 8 Sep 2020 21:29:54 +0200 Subject: [PATCH 05/12] fix(aws-batch): `computeResources` tags are not configured properly (#10209) Use key-value map of tags for `ComputeResources.computeResourcesTags`. Previously used type `Tag` disallowed adding multiple tags. Fixes #7350 BREAKING CHANGE: Changed type of `ComputeResources.computeResourcesTags` from `Tag` to map ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../@aws-cdk/aws-batch/lib/compute-environment.ts | 6 ++++-- .../aws-batch/test/compute-environment.test.ts | 11 ++++++----- .../@aws-cdk/aws-batch/test/integ.batch.expected.json | 5 ++++- packages/@aws-cdk/aws-batch/test/integ.batch.ts | 3 +++ 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/@aws-cdk/aws-batch/lib/compute-environment.ts b/packages/@aws-cdk/aws-batch/lib/compute-environment.ts index 35e56093afd94..5e20606a0e762 100644 --- a/packages/@aws-cdk/aws-batch/lib/compute-environment.ts +++ b/packages/@aws-cdk/aws-batch/lib/compute-environment.ts @@ -1,6 +1,6 @@ import * as ec2 from '@aws-cdk/aws-ec2'; import * as iam from '@aws-cdk/aws-iam'; -import { Construct, IResource, Resource, Stack, Tag } from '@aws-cdk/core'; +import { Construct, IResource, Resource, Stack } from '@aws-cdk/core'; import { CfnComputeEnvironment } from './batch.generated'; /** @@ -210,7 +210,9 @@ export interface ComputeResources { * * @default - no tags will be assigned on compute resources. */ - readonly computeResourcesTags?: Tag; + readonly computeResourcesTags?: { + [key: string]: string + }; } /** diff --git a/packages/@aws-cdk/aws-batch/test/compute-environment.test.ts b/packages/@aws-cdk/aws-batch/test/compute-environment.test.ts index 4013233c32e64..42c958473f1c3 100644 --- a/packages/@aws-cdk/aws-batch/test/compute-environment.test.ts +++ b/packages/@aws-cdk/aws-batch/test/compute-environment.test.ts @@ -161,7 +161,10 @@ describe('Batch Compute Evironment', () => { computeResources: { allocationStrategy: batch.AllocationStrategy.BEST_FIT, vpc, - computeResourcesTags: new cdk.Tag('foo', 'bar'), + computeResourcesTags: { + 'Name': 'AWS Batch Instance - C4OnDemand', + 'Tag Other': 'Has other value', + }, desiredvCpus: 1, ec2KeyPair: 'my-key-pair', image: new ecs.EcsOptimizedAmi({ @@ -244,10 +247,8 @@ describe('Batch Compute Evironment', () => { }, ], Tags: { - key: 'foo', - props: {}, - defaultPriority: 100, - value: 'bar', + 'Name': 'AWS Batch Instance - C4OnDemand', + 'Tag Other': 'Has other value', }, Type: 'EC2', }, diff --git a/packages/@aws-cdk/aws-batch/test/integ.batch.expected.json b/packages/@aws-cdk/aws-batch/test/integ.batch.expected.json index 80d101f22547c..09d021e49bd9d 100644 --- a/packages/@aws-cdk/aws-batch/test/integ.batch.expected.json +++ b/packages/@aws-cdk/aws-batch/test/integ.batch.expected.json @@ -859,6 +859,9 @@ "Ref": "vpcPrivateSubnet3Subnet985AC459" } ], + "Tags": { + "compute-env-tag": "123XYZ" + }, "Type": "EC2" }, "State": "ENABLED" @@ -1351,4 +1354,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-batch/test/integ.batch.ts b/packages/@aws-cdk/aws-batch/test/integ.batch.ts index c92ba719f9d96..4e19da37ca897 100644 --- a/packages/@aws-cdk/aws-batch/test/integ.batch.ts +++ b/packages/@aws-cdk/aws-batch/test/integ.batch.ts @@ -43,6 +43,9 @@ new batch.JobQueue(stack, 'batch-job-queue', { launchTemplate: { launchTemplateName: launchTemplate.launchTemplateName as string, }, + computeResourcesTags: { + 'compute-env-tag': '123XYZ', + }, }, }), order: 2, From 28a9834fb6cbacbd3e0ef97441fa0fb6e45120b1 Mon Sep 17 00:00:00 2001 From: Bryan Pan Date: Tue, 8 Sep 2020 16:59:09 -0700 Subject: [PATCH 06/12] feat(appsync): support union types for code-first approach (#10025) Support `Union Types` for code-first approach. `Union Types` are special types of Intermediate Types in CDK.
Desired GraphQL Union Type ```gql union Search = Human | Droid | Starship ```
The above GraphQL Union Type can be expressed in CDK as the following:
CDK Code ```ts const human = new appsync.ObjectType('Human', { definition: {} }); const droid = new appsync.ObjectType('Droid', { definition: {} }); const starship = new appsync.ObjectType('Starship', { definition: {} }); const search = new appsync.UnionType('Search', { definition: [ human, droid, starship ], }); api.addType(search); ```
---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-appsync/README.md | 29 ++++ .../aws-appsync/lib/schema-intermediate.ts | 108 ++++++++++++- .../test/appsync-union-types.test.ts | 152 ++++++++++++++++++ .../test/integ.graphql-schema.expected.json | 2 +- .../aws-appsync/test/integ.graphql-schema.ts | 6 +- 5 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 packages/@aws-cdk/aws-appsync/test/appsync-union-types.test.ts diff --git a/packages/@aws-cdk/aws-appsync/README.md b/packages/@aws-cdk/aws-appsync/README.md index 5eb93d7e07cfe..f7bd2e9e72055 100644 --- a/packages/@aws-cdk/aws-appsync/README.md +++ b/packages/@aws-cdk/aws-appsync/README.md @@ -564,6 +564,7 @@ Intermediate Types include: - [**Interface Types**](#Interface-Types) - [**Object Types**](#Object-Types) - [**Input Types**](#Input-Types) +- [**Union Types**](#Union-Types) ##### Interface Types @@ -669,6 +670,34 @@ api.addType(review); To learn more about **Input Types**, read the docs [here](https://graphql.org/learn/schema/#input-types). +### Union Types + +**Union Types** are a special type of Intermediate Type. They are similar to +Interface Types, but they cannot specify any common fields between types. + +**Note:** the fields of a union type need to be `Object Types`. In other words, you +can't create a union type out of interfaces, other unions, or inputs. + +```gql +union Search = Human | Droid | Starship +``` + +The above GraphQL Union Type encompasses the Object Types of Human, Droid and Starship. It +can be expressed in CDK as the following: + +```ts +const string = appsync.GraphqlType.string(); +const human = new appsync.ObjectType('Human', { definition: { name: string } }); +const droid = new appsync.ObjectType('Droid', { definition: { name: string } }); +const starship = new appsync.ObjectType('Starship', { definition: { name: string } });); +const search = new appsync.UnionType('Search', { + definition: [ human, droid, starship ], +}); +api.addType(search); +``` + +To learn more about **Union Types**, read the docs [here](https://graphql.org/learn/schema/#union-types). + #### Query Every schema requires a top level Query type. By default, the schema will look diff --git a/packages/@aws-cdk/aws-appsync/lib/schema-intermediate.ts b/packages/@aws-cdk/aws-appsync/lib/schema-intermediate.ts index a0cb9c7274ba4..8fc2c47f94d66 100644 --- a/packages/@aws-cdk/aws-appsync/lib/schema-intermediate.ts +++ b/packages/@aws-cdk/aws-appsync/lib/schema-intermediate.ts @@ -69,12 +69,9 @@ export class InterfaceType implements IIntermediateType { } /** - * Create an GraphQL Type representing this Intermediate Type + * Create a GraphQL Type representing this Intermediate Type * * @param options the options to configure this attribute - * - isList - * - isRequired - * - isRequiredList */ public attribute(options?: BaseTypeOptions): GraphqlType { return GraphqlType.intermediate({ @@ -243,12 +240,9 @@ export class InputType implements IIntermediateType { } /** - * Create an GraphQL Type representing this Input Type + * Create a GraphQL Type representing this Input Type * * @param options the options to configure this attribute - * - isList - * - isRequired - * - isRequiredList */ public attribute(options?: BaseTypeOptions): GraphqlType { return GraphqlType.intermediate({ @@ -296,3 +290,101 @@ export class InputType implements IIntermediateType { this.definition[options.fieldName] = options.field; } } + +/** + * Properties for configuring an Union Type + * + * @param definition - the object types for this union type + * + * @experimental + */ +export interface UnionTypeOptions { + /** + * the object types for this union type + */ + readonly definition: IIntermediateType[]; +} + +/** + * Union Types are abstract types that are similar to Interface Types, + * but they cannot to specify any common fields between types. + * + * Note that fields of a union type need to be object types. In other words, + * you can't create a union type out of interfaces, other unions, or inputs. + * + * @experimental + */ +export class UnionType implements IIntermediateType { + /** + * the name of this type + */ + public readonly name: string; + /** + * the attributes of this type + */ + public readonly definition: { [key: string]: IField }; + /** + * the authorization modes supported by this intermediate type + */ + protected modes?: AuthorizationType[]; + + public constructor(name: string, options: UnionTypeOptions) { + this.name = name; + this.definition = {}; + options.definition.map((def) => this.addField({ field: def.attribute() })); + } + + /** + * Create a GraphQL Type representing this Union Type + * + * @param options the options to configure this attribute + */ + public attribute(options?: BaseTypeOptions): GraphqlType { + return GraphqlType.intermediate({ + isList: options?.isList, + isRequired: options?.isRequired, + isRequiredList: options?.isRequiredList, + intermediateType: this, + }); + } + + /** + * 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 Union type + */ + public toString(): string { + // Return a string that appends all Object Types for this Union Type + // i.e. 'union Example = example1 | example2' + return Object.values(this.definition).reduce((acc, field) => + `${acc} ${field.toString()} |`, `union ${this.name} =`).slice(0, -2); + } + + /** + * Add a field to this Union Type + * + * Input Types must have field options and the IField must be an Object Type. + * + * @param options the options to add a field + */ + public addField(options: AddFieldOptions): void { + if (options.fieldName) { + throw new Error('Union Types cannot be configured with the fieldName option. Use the field option instead.'); + } + if (!options.field) { + throw new Error('Union Types must be configured with the field option.'); + } + if (options.field && !(options.field.intermediateType instanceof ObjectType)) { + throw new Error('Fields for Union Types must be Object Types.'); + } + this.definition[options.field?.toString() + 'id'] = options.field; + } +} diff --git a/packages/@aws-cdk/aws-appsync/test/appsync-union-types.test.ts b/packages/@aws-cdk/aws-appsync/test/appsync-union-types.test.ts new file mode 100644 index 0000000000000..7cf14b9c2f870 --- /dev/null +++ b/packages/@aws-cdk/aws-appsync/test/appsync-union-types.test.ts @@ -0,0 +1,152 @@ +import '@aws-cdk/assert/jest'; +import * as cdk from '@aws-cdk/core'; +import * as appsync from '../lib'; +import * as t from './scalar-type-defintions'; + +const out = 'type Test1 {\n test1: String\n}\ntype Test2 {\n test2: String\n}\nunion UnionTest = Test1 | Test2\n'; +const test1 = new appsync.ObjectType('Test1', { + definition: { test1: t.string }, +}); +const test2 = new appsync.ObjectType('Test2', { + definition: { test2: t.string }, +}); +let stack: cdk.Stack; +let api: appsync.GraphqlApi; +beforeEach(() => { + // GIVEN + stack = new cdk.Stack(); + api = new appsync.GraphqlApi(stack, 'api', { + name: 'api', + }); + api.addType(test1); + api.addType(test2); +}); + +describe('testing Union Type properties', () => { + test('UnionType configures properly', () => { + // WHEN + const union = new appsync.UnionType('UnionTest', { + definition: [test1, test2], + }); + api.addType(union); + // THEN + expect(stack).toHaveResourceLike('AWS::AppSync::GraphQLSchema', { + Definition: `${out}`, + }); + expect(stack).not.toHaveResource('AWS::AppSync::Resolver'); + }); + + test('UnionType can addField', () => { + // WHEN + const union = new appsync.UnionType('UnionTest', { + definition: [test1], + }); + api.addType(union); + union.addField({ field: test2.attribute() }); + + // THEN + expect(stack).toHaveResourceLike('AWS::AppSync::GraphQLSchema', { + Definition: `${out}`, + }); + }); + + test('UnionType errors when addField is configured with fieldName option', () => { + // WHEN + const union = new appsync.UnionType('UnionTest', { + definition: [test1], + }); + api.addType(union); + + // THEN + expect(() => { + union.addField({ fieldName: 'fail', field: test2.attribute() }); + }).toThrowError('Union Types cannot be configured with the fieldName option. Use the field option instead.'); + }); + + test('UnionType errors when addField is not configured with field option', () => { + // WHEN + const union = new appsync.UnionType('UnionTest', { + definition: [test1], + }); + api.addType(union); + + // THEN + expect(() => { + union.addField({}); + }).toThrowError('Union Types must be configured with the field option.'); + }); + + test('UnionType can be a GraphqlType', () => { + // WHEN + const union = new appsync.UnionType('UnionTest', { + definition: [test1, test2], + }); + api.addType(union); + + api.addType(new appsync.ObjectType('Test2', { + definition: { union: union.attribute() }, + })); + + const obj = 'type Test2 {\n union: UnionTest\n}\n'; + + // THEN + expect(stack).toHaveResourceLike('AWS::AppSync::GraphQLSchema', { + Definition: `${out}${obj}`, + }); + }); + + test('appsync errors when addField with Graphql Types', () => { + // WHEN + const test = new appsync.UnionType('Test', { + definition: [], + }); + // THEN + expect(() => { + test.addField({ field: t.string }); + }).toThrowError('Fields for Union Types must be Object Types.'); + }); + + test('appsync errors when addField with Field', () => { + // WHEN + const test = new appsync.UnionType('Test', { + definition: [], + }); + // THEN + expect(() => { + test.addField({ field: new appsync.Field({ returnType: t.string }) }); + }).toThrowError('Fields for Union Types must be Object Types.'); + }); + + test('appsync errors when addField with ResolvableField', () => { + // WHEN + const test = new appsync.UnionType('Test', { + definition: [], + }); + // THEN + expect(() => { + test.addField({ field: new appsync.ResolvableField({ returnType: t.string }) }); + }).toThrowError('Fields for Union Types must be Object Types.'); + }); + + test('appsync errors when addField with Interface Types', () => { + // WHEN + const test = new appsync.UnionType('Test', { + definition: [], + }); + // THEN + expect(() => { + test.addField({ field: new appsync.InterfaceType('break', { definition: {} }).attribute() }); + }).toThrowError('Fields for Union Types must be Object Types.'); + }); + + test('appsync errors when addField with Union Types', () => { + // WHEN + const test = new appsync.UnionType('Test', { + definition: [], + }); + // THEN + expect(() => { + test.addField({ field: test.attribute() }); + }).toThrowError('Fields for Union Types must be Object Types.'); + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.expected.json b/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.expected.json index a1902fb2ad368..e125d669b7574 100644 --- a/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.expected.json +++ b/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.expected.json @@ -16,7 +16,7 @@ "ApiId" ] }, - "Definition": "schema {\n query: Query\n mutation: Mutation\n}\ninterface Node {\n created: String\n edited: String\n id: ID!\n}\ntype Planet {\n name: String\n diameter: Int\n rotationPeriod: Int\n orbitalPeriod: Int\n gravity: String\n population: [String]\n climates: [String]\n terrains: [String]\n surfaceWater: Float\n created: String\n edited: String\n id: ID!\n}\ntype Species implements Node {\n name: String\n classification: String\n designation: String\n averageHeight: Float\n averageLifespan: Int\n eyeColors: [String]\n hairColors: [String]\n skinColors: [String]\n language: String\n homeworld: Planet\n created: String\n edited: String\n id: ID!\n}\ntype Query {\n getPlanets: [Planet]\n}\ntype Mutation {\n addPlanet(name: String diameter: Int rotationPeriod: Int orbitalPeriod: Int gravity: String population: [String] climates: [String] terrains: [String] surfaceWater: Float): Planet\n}\ninput input {\n awesomeInput: String\n}\n" + "Definition": "schema {\n query: Query\n mutation: Mutation\n}\ninterface Node {\n created: String\n edited: String\n id: ID!\n}\ntype Planet {\n name: String\n diameter: Int\n rotationPeriod: Int\n orbitalPeriod: Int\n gravity: String\n population: [String]\n climates: [String]\n terrains: [String]\n surfaceWater: Float\n created: String\n edited: String\n id: ID!\n}\ntype Species implements Node {\n name: String\n classification: String\n designation: String\n averageHeight: Float\n averageLifespan: Int\n eyeColors: [String]\n hairColors: [String]\n skinColors: [String]\n language: String\n homeworld: Planet\n created: String\n edited: String\n id: ID!\n}\ntype Query {\n getPlanets: [Planet]\n}\ntype Mutation {\n addPlanet(name: String diameter: Int rotationPeriod: Int orbitalPeriod: Int gravity: String population: [String] climates: [String] terrains: [String] surfaceWater: Float): Planet\n}\ninput input {\n awesomeInput: String\n}\nunion Union = Species | Planet\n" } }, "codefirstapiDefaultApiKey89863A80": { diff --git a/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.ts b/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.ts index 2e36caec12b2c..f8fb10fe7c734 100644 --- a/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.ts +++ b/packages/@aws-cdk/aws-appsync/test/integ.graphql-schema.ts @@ -48,7 +48,7 @@ const tableDS = api.addDynamoDbDataSource('planets', table); const planet = ObjectType.planet; schema.addType(planet); -api.addType(new appsync.ObjectType('Species', { +const species = api.addType(new appsync.ObjectType('Species', { interfaceTypes: [node], definition: { name: ScalarType.string, @@ -107,4 +107,8 @@ api.addType(new appsync.InputType('input', { definition: { awesomeInput: ScalarType.string }, })); +api.addType(new appsync.UnionType('Union', { + definition: [species, planet], +})); + app.synth(); \ No newline at end of file From a00a4ee162f287b5db45e73051ecdf0e32009def Mon Sep 17 00:00:00 2001 From: William Oldwin Date: Wed, 9 Sep 2020 07:24:10 +0100 Subject: [PATCH 07/12] fix(cli): unable to set termination protection for pipeline stacks (#9938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `cloudformation:UpdateTerminationProtection` permission to the pipeline deployment role so that termination protection can be enabled for pipeline stacks. Currently, creating a pipeline stack with termination protection set to true causes an error: ``` ❌ PipelineStack failed: AccessDenied: User: arn:aws:sts::123456789012:assumed-role/cdk-hnb659fds-deploy-role-123456789012-eu-west-1/aws-cdk-william is not authorized to perform: cloudformation:UpdateTerminationProtection on resource: arn:aws:cloudformation:eu-west-1:123456789012:stack/PipelineStack/dbf8ad70-e5f4-11ea-961d-021e20b443de ``` ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml b/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml index 8e9d40462f3e3..feeee078030d6 100644 --- a/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml +++ b/packages/aws-cdk/lib/api/bootstrap/bootstrap-template.yaml @@ -341,6 +341,7 @@ Resources: - cloudformation:DescribeStackEvents - cloudformation:GetTemplate - cloudformation:DeleteStack + - cloudformation:UpdateTerminationProtection - sts:GetCallerIdentity Resource: "*" Effect: Allow From 1ebf36206b1e6a98a9a708efbe3ba3bfb1d3f05e Mon Sep 17 00:00:00 2001 From: Nick Lynch Date: Wed, 9 Sep 2020 10:00:19 +0100 Subject: [PATCH 08/12] feat(elasticloadbalancingv2): multiple security groups for ALBs (#10244) Provide an `addSecurityGroup` method for ALBs for use cases where multiple security groups are needed. I opted for this approach over adding a new (and redundant) `securityGroups` prop to `ApplicationLoadBalancerProps` to keep the props targeted at the most common use case of a single (or default) group. fixes #5138 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../aws-elasticloadbalancingv2/README.md | 17 ++++++++++++++++ .../lib/alb/application-load-balancer.ts | 16 ++++++++++----- .../test/alb/load-balancer.test.ts | 20 +++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md b/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md index 5aa4968ca33db..93cbb9948b449 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md @@ -1,4 +1,5 @@ ## Amazon Elastic Load Balancing V2 Construct Library + --- @@ -61,6 +62,21 @@ listener.addTargets('ApplicationFleet', { The security groups of the load balancer and the target are automatically updated to allow the network traffic. +One (or more) security groups can be associated with the load balancer; +if a security group isn't provided, one will be automatically created. + +```ts +const securityGroup1 = new ec2.SecurityGroup(stack, 'SecurityGroup1', { vpc }); +const lb = new elbv2.ApplicationLoadBalancer(this, 'LB', { + vpc, + internetFacing: true, + securityGroup: securityGroup1, // Optional - will be automatically created otherwise +}); + +const securityGroup2 = new ec2.SecurityGroup(stack, 'SecurityGroup2', { vpc }); +lb.addSecurityGroup(securityGroup2); +``` + #### Conditions It's possible to route traffic to targets based on conditions in the incoming @@ -320,6 +336,7 @@ public attachToApplicationTargetGroup(targetGroup: ApplicationTargetGroup): Load }; } ``` + `targetType` should be one of `Instance` or `Ip`. If the target can be directly added to the target group, `targetJson` should contain the `id` of the target (either instance ID or IP address depending on the type) and diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts index 77d5f80033cac..a7678a51dfdf5 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts @@ -58,22 +58,21 @@ export class ApplicationLoadBalancer extends BaseLoadBalancer implements IApplic public readonly connections: ec2.Connections; public readonly ipAddressType?: IpAddressType; - private readonly securityGroup: ec2.ISecurityGroup; constructor(scope: Construct, id: string, props: ApplicationLoadBalancerProps) { super(scope, id, props, { type: 'application', - securityGroups: Lazy.listValue({ produce: () => [this.securityGroup.securityGroupId] }), + securityGroups: Lazy.listValue({ produce: () => this.connections.securityGroups.map(sg => sg.securityGroupId) }), ipAddressType: props.ipAddressType, }); this.ipAddressType = props.ipAddressType ?? IpAddressType.IPV4; - this.securityGroup = props.securityGroup || new ec2.SecurityGroup(this, 'SecurityGroup', { + const securityGroups = [props.securityGroup || new ec2.SecurityGroup(this, 'SecurityGroup', { vpc: props.vpc, description: `Automatically created Security Group for ELB ${this.node.uniqueId}`, allowAllOutbound: false, - }); - this.connections = new ec2.Connections({ securityGroups: [this.securityGroup] }); + })]; + this.connections = new ec2.Connections({ securityGroups }); if (props.http2Enabled === false) { this.setAttribute('routing.http2.enabled', 'false'); } if (props.idleTimeout !== undefined) { this.setAttribute('idle_timeout.timeout_seconds', props.idleTimeout.toSeconds().toString()); } @@ -107,6 +106,13 @@ export class ApplicationLoadBalancer extends BaseLoadBalancer implements IApplic }); } + /** + * Add a security group to this load balancer + */ + public addSecurityGroup(securityGroup: ec2.ISecurityGroup) { + this.connections.addSecurityGroup(securityGroup); + } + /** * Return the given named metric for this Application Load Balancer * diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/load-balancer.test.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/load-balancer.test.ts index 4ec206e8d9616..41063924e863c 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/load-balancer.test.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/load-balancer.test.ts @@ -314,4 +314,24 @@ describe('tests', () => { const listener = alb.addListener('Listener', { port: 80 }); expect(() => listener.addTargets('Targets', { port: 8080 })).not.toThrow(); }); + + test.only('can add secondary security groups', () => { + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'Stack'); + + const alb = new elbv2.ApplicationLoadBalancer(stack, 'LB', { + vpc, + securityGroup: new ec2.SecurityGroup(stack, 'SecurityGroup1', { vpc }), + }); + alb.addSecurityGroup(new ec2.SecurityGroup(stack, 'SecurityGroup2', { vpc })); + + // THEN + expect(stack).toHaveResource('AWS::ElasticLoadBalancingV2::LoadBalancer', { + SecurityGroups: [ + { 'Fn::GetAtt': ['SecurityGroup1F554B36F', 'GroupId'] }, + { 'Fn::GetAtt': ['SecurityGroup23BE86BB7', 'GroupId'] }, + ], + Type: 'application', + }); + }); }); From 39350a52688bd7d5896eb0ca5acd89559514ce51 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 9 Sep 2020 11:25:57 +0200 Subject: [PATCH 09/12] chore(cli): refactor integ tests to be isolated (#10260) Isolate the CLI integ tests from all global state; no shared state via `beforeEach()` statements anymore, no global variables, every test gets a fresh fixture in a unique directory. This is in preparation of making them run in parallel, but right now the behavior is still the same. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../aws-cdk/test/integ/cli/aws-helpers.ts | 173 ++++---- .../test/integ/cli/bootstrapping.integtest.ts | 144 +++---- .../aws-cdk/test/integ/cli/cdk-helpers.ts | 284 +++++++------ .../aws-cdk/test/integ/cli/cli.integtest.ts | 378 +++++++++--------- packages/aws-cdk/test/integ/cli/corking.ts | 46 +-- .../aws-cdk/test/integ/cli/test-helpers.ts | 30 +- 6 files changed, 533 insertions(+), 522 deletions(-) diff --git a/packages/aws-cdk/test/integ/cli/aws-helpers.ts b/packages/aws-cdk/test/integ/cli/aws-helpers.ts index 175b4acbf4634..afbc3758f843f 100644 --- a/packages/aws-cdk/test/integ/cli/aws-helpers.ts +++ b/packages/aws-cdk/test/integ/cli/aws-helpers.ts @@ -1,5 +1,4 @@ import * as AWS from 'aws-sdk'; -import { log } from './cdk-helpers'; interface Env { account: string; @@ -18,13 +17,89 @@ export let testEnv = async (): Promise => { return ret; }; -export const cloudFormation = makeAwsCaller(AWS.CloudFormation); -export const s3 = makeAwsCaller(AWS.S3); -export const ecr = makeAwsCaller(AWS.ECR); -export const sns = makeAwsCaller(AWS.SNS); -export const iam = makeAwsCaller(AWS.IAM); -export const lambda = makeAwsCaller(AWS.Lambda); -export const sts = makeAwsCaller(AWS.STS); +export class AwsClients { + public static async default(output: NodeJS.WritableStream) { + return new AwsClients(await testEnv(), output); + } + + private readonly config = { region: this.env.region, maxRetries: 8, retryDelayOptions: { base: 500 } }; + public readonly cloudFormation = makeAwsCaller(AWS.CloudFormation, this.config); + public readonly s3 = makeAwsCaller(AWS.S3, this.config); + public readonly ecr = makeAwsCaller(AWS.ECR, this.config); + public readonly sns = makeAwsCaller(AWS.SNS, this.config); + public readonly iam = makeAwsCaller(AWS.IAM, this.config); + public readonly lambda = makeAwsCaller(AWS.Lambda, this.config); + public readonly sts = makeAwsCaller(AWS.STS, this.config); + + constructor(private readonly env: Env, private readonly output: NodeJS.WritableStream) { + } + + public async deleteStacks(...stackNames: string[]) { + if (stackNames.length === 0) { return; } + + for (const stackName of stackNames) { + await this.cloudFormation('updateTerminationProtection', { + EnableTerminationProtection: false, + StackName: stackName, + }); + await this.cloudFormation('deleteStack', { + StackName: stackName, + }); + } + + await retry(this.output, `Deleting ${stackNames}`, retry.forSeconds(600), async () => { + for (const stackName of stackNames) { + const status = await this.stackStatus(stackName); + if (status !== undefined && status.endsWith('_FAILED')) { + throw retry.abort(new Error(`'${stackName}' is in state '${status}'`)); + } + if (status !== undefined) { + throw new Error(`Delete of '${stackName}' not complete yet`); + } + } + }); + } + + public async stackStatus(stackName: string): Promise { + try { + return (await this.cloudFormation('describeStacks', { StackName: stackName })).Stacks?.[0].StackStatus; + } catch (e) { + if (isStackMissingError(e)) { return undefined; } + throw e; + } + } + + public async emptyBucket(bucketName: string) { + const objects = await this.s3('listObjects', { Bucket: bucketName }); + const deletes = (objects.Contents || []).map(obj => obj.Key || '').filter(d => !!d); + if (deletes.length === 0) { + return Promise.resolve(); + } + return this.s3('deleteObjects', { + Bucket: bucketName, + Delete: { + Objects: deletes.map(d => ({ Key: d })), + Quiet: false, + }, + }); + } + + public async deleteImageRepository(repositoryName: string) { + await this.ecr('deleteRepository', { repositoryName, force: true }); + } + + public async deleteBucket(bucketName: string) { + try { + await this.emptyBucket(bucketName); + await this.s3('deleteBucket', { + Bucket: bucketName, + }); + } catch (e) { + if (isBucketMissingError(e)) { return; } + throw e; + } + } +} /** * Perform an AWS call from nothing @@ -34,9 +109,8 @@ export const sts = makeAwsCaller(AWS.STS); async function awsCall< A extends AWS.Service, B extends keyof ServiceCalls, ->(ctor: new (config: any) => A, call: B, request: First[B]>): Promise[B]>> { - const env = await testEnv(); - const cfn = new ctor({ region: env.region, maxRetries: 8, retryDelayOptions: { base: 500 } }); +>(ctor: new (config: any) => A, config: any, call: B, request: First[B]>): Promise[B]>> { + const cfn = new ctor(config); const response = cfn[call](request); try { return await response.promise(); @@ -60,9 +134,9 @@ async function awsCall< * } * ``` */ -function makeAwsCaller(ctor: new (config: any) => A) { +function makeAwsCaller(ctor: new (config: any) => A, config: any) { return >(call: B, request: First[B]>): Promise[B]>> => { - return awsCall(ctor, call, request); + return awsCall(ctor, config, call, request); }; } @@ -89,40 +163,6 @@ type AwsCallIO = type First = T extends [any, any] ? T[0] : never; type Second = T extends [any, any] ? T[1] : never; -export async function deleteStacks(...stackNames: string[]) { - if (stackNames.length === 0) { return; } - - for (const stackName of stackNames) { - await cloudFormation('updateTerminationProtection', { - EnableTerminationProtection: false, - StackName: stackName, - }); - await cloudFormation('deleteStack', { - StackName: stackName, - }); - } - - await retry(`Deleting ${stackNames}`, retry.forSeconds(600), async () => { - for (const stackName of stackNames) { - const status = await stackStatus(stackName); - if (status !== undefined && status.endsWith('_FAILED')) { - throw retry.abort(new Error(`'${stackName}' is in state '${status}'`)); - } - if (status !== undefined) { - throw new Error(`Delete of '${stackName}' not complete yet`); - } - } - }); -} - -export async function stackStatus(stackName: string): Promise { - try { - return (await cloudFormation('describeStacks', { StackName: stackName })).Stacks?.[0].StackStatus; - } catch (e) { - if (isStackMissingError(e)) { return undefined; } - throw e; - } -} export function isStackMissingError(e: Error) { return e.message.indexOf('does not exist') > -1; @@ -140,20 +180,20 @@ export function isBucketMissingError(e: Error) { * Exceptions will cause the operation to retry. Use `retry.abort` to annotate an exception * to stop the retry and end in a failure. */ -export async function retry(operation: string, deadline: Date, block: () => Promise): Promise { +export async function retry(output: NodeJS.WritableStream, operation: string, deadline: Date, block: () => Promise): Promise { let i = 0; - log(`💈 ${operation}`); + output.write(`💈 ${operation}\n`); while (true) { try { i++; const ret = await block(); - log(`💈 ${operation}: succeeded after ${i} attempts`); + output.write(`💈 ${operation}: succeeded after ${i} attempts\n`); return ret; } catch (e) { if (e.abort || Date.now() > deadline.getTime( )) { throw new Error(`${operation}: did not succeed after ${i} attempts: ${e}`); } - log(`⏳ ${operation} (${e.message})`); + output.write(`⏳ ${operation} (${e.message})\n`); await sleep(5000); } } @@ -178,37 +218,6 @@ export async function sleep(ms: number) { return new Promise(ok => setTimeout(ok, ms)); } -export async function emptyBucket(bucketName: string) { - const objects = await s3('listObjects', { Bucket: bucketName }); - const deletes = (objects.Contents || []).map(obj => obj.Key || '').filter(d => !!d); - if (deletes.length === 0) { - return Promise.resolve(); - } - return s3('deleteObjects', { - Bucket: bucketName, - Delete: { - Objects: deletes.map(d => ({ Key: d })), - Quiet: false, - }, - }); -} - -export async function deleteImageRepository(repositoryName: string) { - await ecr('deleteRepository', { repositoryName, force: true }); -} - -export async function deleteBucket(bucketName: string) { - try { - await emptyBucket(bucketName); - await s3('deleteBucket', { - Bucket: bucketName, - }); - } catch (e) { - if (isBucketMissingError(e)) { return; } - throw e; - } -} - export function outputFromStack(key: string, stack: AWS.CloudFormation.Stack): string | undefined { return (stack.Outputs ?? []).find(o => o.OutputKey === key)?.OutputValue; } diff --git a/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts b/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts index a250cd5551526..5908640392116 100644 --- a/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts +++ b/packages/aws-cdk/test/integ/cli/bootstrapping.integtest.ts @@ -1,41 +1,26 @@ import * as fs from 'fs'; import * as path from 'path'; -import { cloudFormation } from './aws-helpers'; -import { cdk, cdkDeploy, cleanup, fullStackName, prepareAppFixture, rememberToDeleteBucket, INTEG_TEST_DIR } from './cdk-helpers'; +import { prepareAppFixture, rememberToDeleteBucket, randomString } from './cdk-helpers'; import { integTest } from './test-helpers'; jest.setTimeout(600_000); -const QUALIFIER = randomString().substr(0, 10); +integTest('can bootstrap without execution', prepareAppFixture, async (fixture) => { + const bootstrapStackName = fixture.fullStackName('bootstrap-stack'); -beforeAll(async () => { - await prepareAppFixture(); -}); - -beforeEach(async () => { - await cleanup(); -}); - -afterEach(async () => { - await cleanup(); -}); - -integTest('can bootstrap without execution', async () => { - const bootstrapStackName = fullStackName('bootstrap-stack'); - - await cdk(['bootstrap', + await fixture.cdk(['bootstrap', '--toolkit-stack-name', bootstrapStackName, '--no-execute']); - const resp = await cloudFormation('describeStacks', { + const resp = await fixture.aws.cloudFormation('describeStacks', { StackName: bootstrapStackName, }); expect(resp.Stacks?.[0].StackStatus).toEqual('REVIEW_IN_PROGRESS'); }); -integTest('upgrade legacy bootstrap stack to new bootstrap stack while in use', async () => { - const bootstrapStackName = fullStackName('bootstrap-stack'); +integTest('upgrade legacy bootstrap stack to new bootstrap stack while in use', prepareAppFixture, async (fixture) => { + const bootstrapStackName = fixture.fullStackName('bootstrap-stack'); const legacyBootstrapBucketName = `aws-cdk-bootstrap-integ-test-legacy-bckt-${randomString()}`; const newBootstrapBucketName = `aws-cdk-bootstrap-integ-test-v2-bckt-${randomString()}`; @@ -43,20 +28,20 @@ integTest('upgrade legacy bootstrap stack to new bootstrap stack while in use', rememberToDeleteBucket(newBootstrapBucketName); // This one shouldn't leak if the test succeeds, but let's be safe in case it doesn't // Legacy bootstrap - await cdk(['bootstrap', + await fixture.cdk(['bootstrap', '--toolkit-stack-name', bootstrapStackName, '--bootstrap-bucket-name', legacyBootstrapBucketName]); // Deploy stack that uses file assets - await cdkDeploy('lambda', { + await fixture.cdkDeploy('lambda', { options: ['--toolkit-stack-name', bootstrapStackName], }); // Upgrade bootstrap stack to "new" style - await cdk(['bootstrap', + await fixture.cdk(['bootstrap', '--toolkit-stack-name', bootstrapStackName, '--bootstrap-bucket-name', newBootstrapBucketName, - '--qualifier', QUALIFIER], { + '--qualifier', fixture.qualifier], { modEnv: { CDK_NEW_BOOTSTRAP: '1', }, @@ -64,7 +49,7 @@ integTest('upgrade legacy bootstrap stack to new bootstrap stack while in use', // (Force) deploy stack again // --force to bypass the check which says that the template hasn't changed. - await cdkDeploy('lambda', { + await fixture.cdkDeploy('lambda', { options: [ '--toolkit-stack-name', bootstrapStackName, '--force', @@ -72,12 +57,12 @@ integTest('upgrade legacy bootstrap stack to new bootstrap stack while in use', }); }); -integTest('deploy new style synthesis to new style bootstrap', async () => { - const bootstrapStackName = fullStackName('bootstrap-stack'); +integTest('deploy new style synthesis to new style bootstrap', prepareAppFixture, async (fixture) => { + const bootstrapStackName = fixture.fullStackName('bootstrap-stack'); - await cdk(['bootstrap', + await fixture.cdk(['bootstrap', '--toolkit-stack-name', bootstrapStackName, - '--qualifier', QUALIFIER, + '--qualifier', fixture.qualifier, '--cloudformation-execution-policies', 'arn:aws:iam::aws:policy/AdministratorAccess'], { modEnv: { CDK_NEW_BOOTSTRAP: '1', @@ -85,21 +70,21 @@ integTest('deploy new style synthesis to new style bootstrap', async () => { }); // Deploy stack that uses file assets - await cdkDeploy('lambda', { + await fixture.cdkDeploy('lambda', { options: [ '--toolkit-stack-name', bootstrapStackName, - '--context', `@aws-cdk/core:bootstrapQualifier=${QUALIFIER}`, + '--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`, '--context', '@aws-cdk/core:newStyleStackSynthesis=1', ], }); }); -integTest('deploy new style synthesis to new style bootstrap (with docker image)', async () => { - const bootstrapStackName = fullStackName('bootstrap-stack'); +integTest('deploy new style synthesis to new style bootstrap (with docker image)', prepareAppFixture, async (fixture) => { + const bootstrapStackName = fixture.fullStackName('bootstrap-stack'); - await cdk(['bootstrap', + await fixture.cdk(['bootstrap', '--toolkit-stack-name', bootstrapStackName, - '--qualifier', QUALIFIER, + '--qualifier', fixture.qualifier, '--cloudformation-execution-policies', 'arn:aws:iam::aws:policy/AdministratorAccess'], { modEnv: { CDK_NEW_BOOTSTRAP: '1', @@ -107,21 +92,21 @@ integTest('deploy new style synthesis to new style bootstrap (with docker image) }); // Deploy stack that uses file assets - await cdkDeploy('docker', { + await fixture.cdkDeploy('docker', { options: [ '--toolkit-stack-name', bootstrapStackName, - '--context', `@aws-cdk/core:bootstrapQualifier=${QUALIFIER}`, + '--context', `@aws-cdk/core:bootstrapQualifier=${fixture.qualifier}`, '--context', '@aws-cdk/core:newStyleStackSynthesis=1', ], }); }); -integTest('deploy old style synthesis to new style bootstrap', async () => { - const bootstrapStackName = fullStackName('bootstrap-stack'); +integTest('deploy old style synthesis to new style bootstrap', prepareAppFixture, async (fixture) => { + const bootstrapStackName = fixture.fullStackName('bootstrap-stack'); - await cdk(['bootstrap', + await fixture.cdk(['bootstrap', '--toolkit-stack-name', bootstrapStackName, - '--qualifier', QUALIFIER, + '--qualifier', fixture.qualifier, '--cloudformation-execution-policies', 'arn:aws:iam::aws:policy/AdministratorAccess'], { modEnv: { CDK_NEW_BOOTSTRAP: '1', @@ -129,21 +114,21 @@ integTest('deploy old style synthesis to new style bootstrap', async () => { }); // Deploy stack that uses file assets - await cdkDeploy('lambda', { + await fixture.cdkDeploy('lambda', { options: [ '--toolkit-stack-name', bootstrapStackName, ], }); }); -integTest('deploying new style synthesis to old style bootstrap fails', async () => { - const bootstrapStackName = fullStackName('bootstrap-stack'); +integTest('deploying new style synthesis to old style bootstrap fails', prepareAppFixture, async (fixture) => { + const bootstrapStackName = fixture.fullStackName('bootstrap-stack'); - await cdk(['bootstrap', '--toolkit-stack-name', bootstrapStackName]); + await fixture.cdk(['bootstrap', '--toolkit-stack-name', bootstrapStackName]); // Deploy stack that uses file assets, this fails because the bootstrap stack // is version checked. - await expect(cdkDeploy('lambda', { + await expect(fixture.cdkDeploy('lambda', { options: [ '--toolkit-stack-name', bootstrapStackName, '--context', '@aws-cdk/core:newStyleStackSynthesis=1', @@ -151,34 +136,34 @@ integTest('deploying new style synthesis to old style bootstrap fails', async () })).rejects.toThrow('exited with error'); }); -integTest('can create a legacy bootstrap stack with --public-access-block-configuration=false', async () => { - const bootstrapStackName = fullStackName('bootstrap-stack-1'); +integTest('can create a legacy bootstrap stack with --public-access-block-configuration=false', prepareAppFixture, async (fixture) => { + const bootstrapStackName = fixture.fullStackName('bootstrap-stack-1'); - await cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName, '--public-access-block-configuration', 'false', '--tags', 'Foo=Bar']); + await fixture.cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName, '--public-access-block-configuration', 'false', '--tags', 'Foo=Bar']); - const response = await cloudFormation('describeStacks', { StackName: bootstrapStackName }); + const response = await fixture.aws.cloudFormation('describeStacks', { StackName: bootstrapStackName }); expect(response.Stacks?.[0].Tags).toEqual([ { Key: 'Foo', Value: 'Bar' }, ]); }); -integTest('can create multiple legacy bootstrap stacks', async () => { - const bootstrapStackName1 = fullStackName('bootstrap-stack-1'); - const bootstrapStackName2 = fullStackName('bootstrap-stack-2'); +integTest('can create multiple legacy bootstrap stacks', prepareAppFixture, async (fixture) => { + const bootstrapStackName1 = fixture.fullStackName('bootstrap-stack-1'); + const bootstrapStackName2 = fixture.fullStackName('bootstrap-stack-2'); // deploy two toolkit stacks into the same environment (see #1416) // one with tags - await cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName1, '--tags', 'Foo=Bar']); - await cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName2]); + await fixture.cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName1, '--tags', 'Foo=Bar']); + await fixture.cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName2]); - const response = await cloudFormation('describeStacks', { StackName: bootstrapStackName1 }); + const response = await fixture.aws.cloudFormation('describeStacks', { StackName: bootstrapStackName1 }); expect(response.Stacks?.[0].Tags).toEqual([ { Key: 'Foo', Value: 'Bar' }, ]); }); -integTest('can dump the template, modify and use it to deploy a custom bootstrap stack', async () => { - let template = await cdk(['bootstrap', '--show-template'], { +integTest('can dump the template, modify and use it to deploy a custom bootstrap stack', prepareAppFixture, async (fixture) => { + let template = await fixture.cdk(['bootstrap', '--show-template'], { captureStderr: false, modEnv: { CDK_NEW_BOOTSTRAP: '1', @@ -192,11 +177,11 @@ integTest('can dump the template, modify and use it to deploy a custom bootstrap ' Value: Template got twiddled', ].join('\n'); - const filename = path.join(INTEG_TEST_DIR, `${QUALIFIER}-template.yaml`); + const filename = path.join(fixture.integTestDir, `${fixture.qualifier}-template.yaml`); fs.writeFileSync(filename, template, { encoding: 'utf-8' }); - await cdk(['bootstrap', - '--toolkit-stack-name', fullStackName('bootstrap-stack'), - '--qualifier', QUALIFIER, + await fixture.cdk(['bootstrap', + '--toolkit-stack-name', fixture.fullStackName('bootstrap-stack'), + '--qualifier', fixture.qualifier, '--template', filename, '--cloudformation-execution-policies', 'arn:aws:iam::aws:policy/AdministratorAccess'], { modEnv: { @@ -205,33 +190,28 @@ integTest('can dump the template, modify and use it to deploy a custom bootstrap }); }); -integTest('switch on termination protection, switch is left alone on re-bootstrap', async () => { - const bootstrapStackName = fullStackName('bootstrap-stack'); +integTest('switch on termination protection, switch is left alone on re-bootstrap', prepareAppFixture, async (fixture) => { + const bootstrapStackName = fixture.fullStackName('bootstrap-stack'); - await cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName, + await fixture.cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName, '--termination-protection', 'true', - '--qualifier', QUALIFIER], { modEnv: { CDK_NEW_BOOTSTRAP: '1' } }); - await cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName, '--force'], { modEnv: { CDK_NEW_BOOTSTRAP: '1' } }); + '--qualifier', fixture.qualifier], { modEnv: { CDK_NEW_BOOTSTRAP: '1' } }); + await fixture.cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName, '--force'], { modEnv: { CDK_NEW_BOOTSTRAP: '1' } }); - const response = await cloudFormation('describeStacks', { StackName: bootstrapStackName }); + const response = await fixture.aws.cloudFormation('describeStacks', { StackName: bootstrapStackName }); expect(response.Stacks?.[0].EnableTerminationProtection).toEqual(true); }); -integTest('add tags, left alone on re-bootstrap', async () => { - const bootstrapStackName = fullStackName('bootstrap-stack'); +integTest('add tags, left alone on re-bootstrap', prepareAppFixture, async (fixture) => { + const bootstrapStackName = fixture.fullStackName('bootstrap-stack'); - await cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName, + await fixture.cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName, '--tags', 'Foo=Bar', - '--qualifier', QUALIFIER], { modEnv: { CDK_NEW_BOOTSTRAP: '1' } }); - await cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName, '--force'], { modEnv: { CDK_NEW_BOOTSTRAP: '1' } }); + '--qualifier', fixture.qualifier], { modEnv: { CDK_NEW_BOOTSTRAP: '1' } }); + await fixture.cdk(['bootstrap', '-v', '--toolkit-stack-name', bootstrapStackName, '--force'], { modEnv: { CDK_NEW_BOOTSTRAP: '1' } }); - const response = await cloudFormation('describeStacks', { StackName: bootstrapStackName }); + const response = await fixture.aws.cloudFormation('describeStacks', { StackName: bootstrapStackName }); expect(response.Stacks?.[0].Tags).toEqual([ { Key: 'Foo', Value: 'Bar' }, ]); -}); - -function randomString() { - // Crazy - return Math.random().toString(36).replace(/[^a-z0-9]+/g, ''); -} +}); \ No newline at end of file diff --git a/packages/aws-cdk/test/integ/cli/cdk-helpers.ts b/packages/aws-cdk/test/integ/cli/cdk-helpers.ts index 8314308b16679..5a9afbcf4194c 100644 --- a/packages/aws-cdk/test/integ/cli/cdk-helpers.ts +++ b/packages/aws-cdk/test/integ/cli/cdk-helpers.ts @@ -1,19 +1,9 @@ import * as child_process from 'child_process'; +import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { cloudFormation, deleteBucket, deleteImageRepository, deleteStacks, emptyBucket, outputFromStack, testEnv } from './aws-helpers'; -import { writeOutput } from './corking'; - -// create a unique stack name prefix for this test test run. this is passed -// through an environment variable to app.js so that all stacks use this prefix. -const timestamp = new Date().toISOString().replace(/[^0-9]/g, ''); -export const STACK_NAME_PREFIX = `cdktest-${timestamp}`; -export const INTEG_TEST_DIR = path.join(os.tmpdir(), `cdk-integ-${timestamp}`); - -process.stdout.write('~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n'); -process.stdout.write(` All stacks created by this test run will have the prefix: ${STACK_NAME_PREFIX}\n`); -process.stdout.write(` All tests will run the following directory: ${INTEG_TEST_DIR}\n`); -process.stdout.write('~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n'); +import { outputFromStack, testEnv, AwsClients } from './aws-helpers'; +import { TestEnvironment } from './test-helpers'; export interface ShellOptions extends child_process.SpawnOptions { /** @@ -34,6 +24,11 @@ export interface ShellOptions extends child_process.SpawnOptions { * @default true */ captureStderr?: boolean; + + /** + * Pass output here + */ + output?: NodeJS.WritableStream; } export interface CdkCliOptions extends ShellOptions { @@ -41,60 +36,134 @@ export interface CdkCliOptions extends ShellOptions { neverRequireApproval?: boolean; } -export function log(x: string) { - process.stderr.write(x + '\n'); +/** + * Prepare a target dir byreplicating a source directory + */ +export async function cloneDirectory(source: string, target: string, output?: NodeJS.WritableStream) { + await shell(['rm', '-rf', target], { output }); + await shell(['mkdir', '-p', target], { output }); + await shell(['cp', '-R', source + '/*', target], { output }); } -export async function cdkDeploy(stackNames: string | string[], options: CdkCliOptions = {}) { - stackNames = typeof stackNames === 'string' ? [stackNames] : stackNames; +export class TestFixture { + public readonly qualifier = randomString().substr(0, 10); - const neverRequireApproval = options.neverRequireApproval ?? true; + constructor( + public readonly integTestDir: string, + public readonly stackNamePrefix: string, + public readonly output: NodeJS.WritableStream, + public readonly aws: AwsClients) { + } - return await cdk(['deploy', - ...(neverRequireApproval ? ['--require-approval=never'] : []), // Default to no approval in an unattended test - ...(options.options ?? []), - ...fullStackName(stackNames)], options); -} + public log(s: string) { + this.output.write(`${s}\n`); + } -export async function cdkDestroy(stackNames: string | string[], options: CdkCliOptions = {}) { - stackNames = typeof stackNames === 'string' ? [stackNames] : stackNames; + public async shell(command: string[], options: Omit = {}): Promise { + return await shell(command, { + ...options, + output: this.output, + cwd: this.integTestDir, + }); + } - return await cdk(['destroy', - '-f', // We never want a prompt in an unattended test - ...(options.options ?? []), - ...fullStackName(stackNames)], options); -} + public async cdkDeploy(stackNames: string | string[], options: CdkCliOptions = {}) { + stackNames = typeof stackNames === 'string' ? [stackNames] : stackNames; -export async function cdk(args: string[], options: CdkCliOptions = {}) { - return await shell(['cdk', ...args], { - cwd: INTEG_TEST_DIR, - ...options, - modEnv: { - AWS_REGION: (await testEnv()).region, - AWS_DEFAULT_REGION: (await testEnv()).region, - STACK_NAME_PREFIX, - ...options.modEnv, - }, - }); -} + const neverRequireApproval = options.neverRequireApproval ?? true; -export function fullStackName(stackName: string): string; -export function fullStackName(stackNames: string[]): string[]; -export function fullStackName(stackNames: string | string[]): string | string[] { - if (typeof stackNames === 'string') { - return `${STACK_NAME_PREFIX}-${stackNames}`; - } else { - return stackNames.map(s => `${STACK_NAME_PREFIX}-${s}`); + return this.cdk(['deploy', + ...(neverRequireApproval ? ['--require-approval=never'] : []), // Default to no approval in an unattended test + ...(options.options ?? []), + ...this.fullStackName(stackNames)], options); } -} -/** - * Prepare a target dir byreplicating a source directory - */ -export async function cloneDirectory(source: string, target: string) { - await shell(['rm', '-rf', target]); - await shell(['mkdir', '-p', target]); - await shell(['cp', '-R', source + '/*', target]); + public async cdkDestroy(stackNames: string | string[], options: CdkCliOptions = {}) { + stackNames = typeof stackNames === 'string' ? [stackNames] : stackNames; + + return this.cdk(['destroy', + '-f', // We never want a prompt in an unattended test + ...(options.options ?? []), + ...this.fullStackName(stackNames)], options); + } + + public async cdk(args: string[], options: CdkCliOptions = {}) { + return await this.shell(['cdk', ...args], { + ...options, + modEnv: { + AWS_REGION: (await testEnv()).region, + AWS_DEFAULT_REGION: (await testEnv()).region, + STACK_NAME_PREFIX: this.stackNamePrefix, + ...options.modEnv, + }, + }); + } + + public fullStackName(stackName: string): string; + public fullStackName(stackNames: string[]): string[]; + public fullStackName(stackNames: string | string[]): string | string[] { + if (typeof stackNames === 'string') { + return `${this.stackNamePrefix}-${stackNames}`; + } else { + return stackNames.map(s => `${this.stackNamePrefix}-${s}`); + } + } + + /** + * Cleanup leftover stacks and buckets + */ + public async dispose(success: boolean) { + const stacksToDelete = await this.deleteableStacks(this.stackNamePrefix); + + // Bootstrap stacks have buckets that need to be cleaned + const bucketNames = stacksToDelete.map(stack => outputFromStack('BucketName', stack)).filter(defined); + await Promise.all(bucketNames.map(b => this.aws.emptyBucket(b))); + + // Bootstrap stacks have ECR repositories with images which should be deleted + const imageRepositoryNames = stacksToDelete.map(stack => outputFromStack('ImageRepositoryName', stack)).filter(defined); + await Promise.all(imageRepositoryNames.map(r => this.aws.deleteImageRepository(r))); + + await this.aws.deleteStacks(...stacksToDelete.map(s => s.StackName)); + + // We might have leaked some buckets by upgrading the bootstrap stack. Be + // sure to clean everything. + for (const bucket of bucketsToDelete) { + await this.aws.deleteBucket(bucket); + } + bucketsToDelete = []; + + // If the tests completed successfully, happily delete the fixture + // (otherwise leave it for humans to inspect) + if (success) { + rimraf(this.integTestDir); + } + } + + /** + * Return the stacks starting with our testing prefix that should be deleted + */ + private async deleteableStacks(prefix: string): Promise { + const statusFilter = [ + 'CREATE_IN_PROGRESS', 'CREATE_FAILED', 'CREATE_COMPLETE', + 'ROLLBACK_IN_PROGRESS', 'ROLLBACK_FAILED', 'ROLLBACK_COMPLETE', + 'DELETE_FAILED', + 'UPDATE_IN_PROGRESS', 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS', + 'UPDATE_COMPLETE', 'UPDATE_ROLLBACK_IN_PROGRESS', + 'UPDATE_ROLLBACK_FAILED', + 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS', + 'UPDATE_ROLLBACK_COMPLETE', 'REVIEW_IN_PROGRESS', + 'IMPORT_IN_PROGRESS', 'IMPORT_COMPLETE', + 'IMPORT_ROLLBACK_IN_PROGRESS', 'IMPORT_ROLLBACK_FAILED', + 'IMPORT_ROLLBACK_COMPLETE', + ]; + + const response = await this.aws.cloudFormation('describeStacks', {}); + + return (response.Stacks ?? []) + .filter(s => s.StackName.startsWith(prefix)) + .filter(s => statusFilter.includes(s.StackStatus)) + .filter(s => s.RootId === undefined); // Only delete parent stacks. Nested stacks are deleted in the process + } } /** @@ -103,10 +172,19 @@ export async function cloneDirectory(source: string, target: string) { * If this is done in the main test script, it will be skipped * in the subprocess scripts since the app fixture can just be reused. */ -export async function prepareAppFixture() { - await cloneDirectory(path.join(__dirname, 'app'), INTEG_TEST_DIR); +export async function prepareAppFixture(env: TestEnvironment): Promise { + const randy = randomString(); + const stackNamePrefix = `cdktest-${randy}`; + const integTestDir = path.join(os.tmpdir(), `cdk-integ-${randy}`); + + env.output.write(` Stack prefixes: ${stackNamePrefix}\n`); + env.output.write(` Test directory: ${integTestDir}\n`); + + await cloneDirectory(path.join(__dirname, 'app'), integTestDir, env.output); + + const fixture = new TestFixture(integTestDir, stackNamePrefix, env.output, await AwsClients.default(env.output)); - await shell(['npm', 'install', + await fixture.shell(['npm', 'install', '@aws-cdk/core', '@aws-cdk/aws-sns', '@aws-cdk/aws-iam', @@ -114,59 +192,9 @@ export async function prepareAppFixture() { '@aws-cdk/aws-ssm', '@aws-cdk/aws-ecr-assets', '@aws-cdk/aws-cloudformation', - '@aws-cdk/aws-ec2'], { - cwd: INTEG_TEST_DIR, - }); -} - -/** - * Return the stacks starting with our testing prefix that should be deleted - */ -export async function deleteableStacks(prefix: string): Promise { - const statusFilter = [ - 'CREATE_IN_PROGRESS', 'CREATE_FAILED', 'CREATE_COMPLETE', - 'ROLLBACK_IN_PROGRESS', 'ROLLBACK_FAILED', 'ROLLBACK_COMPLETE', - 'DELETE_FAILED', - 'UPDATE_IN_PROGRESS', 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS', - 'UPDATE_COMPLETE', 'UPDATE_ROLLBACK_IN_PROGRESS', - 'UPDATE_ROLLBACK_FAILED', - 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS', - 'UPDATE_ROLLBACK_COMPLETE', 'REVIEW_IN_PROGRESS', - 'IMPORT_IN_PROGRESS', 'IMPORT_COMPLETE', - 'IMPORT_ROLLBACK_IN_PROGRESS', 'IMPORT_ROLLBACK_FAILED', - 'IMPORT_ROLLBACK_COMPLETE', - ]; - - const response = await cloudFormation('describeStacks', {}); - - return (response.Stacks ?? []) - .filter(s => s.StackName.startsWith(prefix)) - .filter(s => statusFilter.includes(s.StackStatus)) - .filter(s => s.RootId === undefined); // Only delete parent stacks. Nested stacks are deleted in the process -} - -/** - * Cleanup leftover stacks and buckets - */ -export async function cleanup(): Promise { - const stacksToDelete = await deleteableStacks(STACK_NAME_PREFIX); - - // Bootstrap stacks have buckets that need to be cleaned - const bucketNames = stacksToDelete.map(stack => outputFromStack('BucketName', stack)).filter(defined); - await Promise.all(bucketNames.map(emptyBucket)); - - // Bootstrap stacks have ECR repositories with images which should be deleted - const imageRepositoryNames = stacksToDelete.map(stack => outputFromStack('ImageRepositoryName', stack)).filter(defined); - await Promise.all(imageRepositoryNames.map(deleteImageRepository)); + '@aws-cdk/aws-ec2']); - await deleteStacks(...stacksToDelete.map(s => s.StackName)); - - // We might have leaked some buckets by upgrading the bootstrap stack. Be - // sure to clean everything. - for (const bucket of bucketsToDelete) { - await deleteBucket(bucket); - } - bucketsToDelete = []; + return fixture; } /** @@ -179,7 +207,7 @@ export async function shell(command: string[], options: ShellOptions = {}): Prom throw new Error('Use either env or modEnv but not both'); } - log(`💻 ${command.join(' ')}`); + options.output?.write(`💻 ${command.join(' ')}\n`); const env = options.env ?? (options.modEnv ? { ...process.env, ...options.modEnv } : undefined); @@ -196,12 +224,12 @@ export async function shell(command: string[], options: ShellOptions = {}): Prom const stderr = new Array(); child.stdout!.on('data', chunk => { - writeOutput('stdout', chunk); + options.output?.write(chunk); stdout.push(chunk); }); child.stderr!.on('data', chunk => { - writeOutput('stderr', chunk); + options.output?.write(chunk); if (options.captureStderr ?? true) { stderr.push(chunk); } @@ -233,4 +261,30 @@ export function rememberToDeleteBucket(bucketName: string) { function defined(x: A): x is NonNullable { return x !== undefined; +} + +/** + * rm -rf reimplementation, don't want to depend on an NPM package for this + */ +export function rimraf(fsPath: string) { + try { + const isDir = fs.lstatSync(fsPath).isDirectory(); + + if (isDir) { + for (const file of fs.readdirSync(fsPath)) { + rimraf(path.join(fsPath, file)); + } + fs.rmdirSync(fsPath); + } else { + fs.unlinkSync(fsPath); + } + } catch (e) { + // We will survive ENOENT + if (e.code !== 'ENOENT') { throw e; } + } +} + +export function randomString() { + // Crazy + return Math.random().toString(36).replace(/[^a-z0-9]+/g, ''); } \ No newline at end of file diff --git a/packages/aws-cdk/test/integ/cli/cli.integtest.ts b/packages/aws-cdk/test/integ/cli/cli.integtest.ts index b26c4d017705e..c89488db3d03c 100644 --- a/packages/aws-cdk/test/integ/cli/cli.integtest.ts +++ b/packages/aws-cdk/test/integ/cli/cli.integtest.ts @@ -1,135 +1,120 @@ import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { cloudFormation, iam, lambda, retry, sleep, sns, sts, testEnv } from './aws-helpers'; -import { - cdk, cdkDeploy, cdkDestroy, cleanup, cloneDirectory, fullStackName, - INTEG_TEST_DIR, log, prepareAppFixture, shell, STACK_NAME_PREFIX, -} from './cdk-helpers'; +import { retry, sleep, testEnv } from './aws-helpers'; +import { cloneDirectory, prepareAppFixture, shell } from './cdk-helpers'; import { integTest } from './test-helpers'; jest.setTimeout(600 * 1000); -beforeAll(async () => { - await prepareAppFixture(); -}); +integTest('VPC Lookup', prepareAppFixture, async (fixture) => { + fixture.log('Making sure we are clean before starting.'); + await fixture.cdkDestroy('define-vpc', { modEnv: { ENABLE_VPC_TESTING: 'DEFINE' } }); -beforeEach(async () => { - await cleanup(); -}); + fixture.log('Setting up: creating a VPC with known tags'); + await fixture.cdkDeploy('define-vpc', { modEnv: { ENABLE_VPC_TESTING: 'DEFINE' } }); + fixture.log('Setup complete!'); -afterEach(async () => { - await cleanup(); + fixture.log('Verifying we can now import that VPC'); + await fixture.cdkDeploy('import-vpc', { modEnv: { ENABLE_VPC_TESTING: 'IMPORT' } }); }); -integTest('VPC Lookup', async () => { - log('Making sure we are clean before starting.'); - await cdkDestroy('define-vpc', { modEnv: { ENABLE_VPC_TESTING: 'DEFINE' } }); - - log('Setting up: creating a VPC with known tags'); - await cdkDeploy('define-vpc', { modEnv: { ENABLE_VPC_TESTING: 'DEFINE' } }); - log('Setup complete!'); - - log('Verifying we can now import that VPC'); - await cdkDeploy('import-vpc', { modEnv: { ENABLE_VPC_TESTING: 'IMPORT' } }); -}); - -integTest('Two ways of shoing the version', async () => { - const version1 = await cdk(['version']); - const version2 = await cdk(['--version']); +integTest('Two ways of shoing the version', prepareAppFixture, async (fixture) => { + const version1 = await fixture.cdk(['version']); + const version2 = await fixture.cdk(['--version']); expect(version1).toEqual(version2); }); -integTest('Termination protection', async () => { +integTest('Termination protection', prepareAppFixture, async (fixture) => { const stackName = 'termination-protection'; - await cdkDeploy(stackName); + await fixture.cdkDeploy(stackName); // Try a destroy that should fail - await expect(cdkDestroy(stackName)).rejects.toThrow('exited with error'); + await expect(fixture.cdkDestroy(stackName)).rejects.toThrow('exited with error'); // Can update termination protection even though the change set doesn't contain changes - await cdkDeploy(stackName, { modEnv: { TERMINATION_PROTECTION: 'FALSE' } }); - await cdkDestroy(stackName); + await fixture.cdkDeploy(stackName, { modEnv: { TERMINATION_PROTECTION: 'FALSE' } }); + await fixture.cdkDestroy(stackName); }); -integTest('cdk synth', async () => { - await expect(cdk(['synth', fullStackName('test-1')])).resolves.toEqual( +integTest('cdk synth', prepareAppFixture, async (fixture) => { + await expect(fixture.cdk(['synth', fixture.fullStackName('test-1')])).resolves.toEqual( `Resources: topic69831491: Type: AWS::SNS::Topic Metadata: - aws:cdk:path: ${STACK_NAME_PREFIX}-test-1/topic/Resource`); + aws:cdk:path: ${fixture.stackNamePrefix}-test-1/topic/Resource`); - await expect(cdk(['synth', fullStackName('test-2')])).resolves.toEqual( + await expect(fixture.cdk(['synth', fixture.fullStackName('test-2')])).resolves.toEqual( `Resources: topic152D84A37: Type: AWS::SNS::Topic Metadata: - aws:cdk:path: ${STACK_NAME_PREFIX}-test-2/topic1/Resource + aws:cdk:path: ${fixture.stackNamePrefix}-test-2/topic1/Resource topic2A4FB547F: Type: AWS::SNS::Topic Metadata: - aws:cdk:path: ${STACK_NAME_PREFIX}-test-2/topic2/Resource`); + aws:cdk:path: ${fixture.stackNamePrefix}-test-2/topic2/Resource`); }); -integTest('ssm parameter provider error', async () => { - await expect(cdk(['synth', - fullStackName('missing-ssm-parameter'), +integTest('ssm parameter provider error', prepareAppFixture, async (fixture) => { + await expect(fixture.cdk(['synth', + fixture.fullStackName('missing-ssm-parameter'), '-c', 'test:ssm-parameter-name=/does/not/exist'], { allowErrExit: true, })).resolves.toContain('SSM parameter not available in account'); }); -integTest('automatic ordering', async () => { +integTest('automatic ordering', prepareAppFixture, async (fixture) => { // Deploy the consuming stack which will include the producing stack - await cdkDeploy('order-consuming'); + await fixture.cdkDeploy('order-consuming'); // Destroy the providing stack which will include the consuming stack - await cdkDestroy('order-providing'); + await fixture.cdkDestroy('order-providing'); }); -integTest('context setting', async () => { - await fs.writeFile(path.join(INTEG_TEST_DIR, 'cdk.context.json'), JSON.stringify({ +integTest('context setting', prepareAppFixture, async (fixture) => { + await fs.writeFile(path.join(fixture.integTestDir, 'cdk.context.json'), JSON.stringify({ contextkey: 'this is the context value', })); try { - await expect(cdk(['context'])).resolves.toContain('this is the context value'); + await expect(fixture.cdk(['context'])).resolves.toContain('this is the context value'); // Test that deleting the contextkey works - await cdk(['context', '--reset', 'contextkey']); - await expect(cdk(['context'])).resolves.not.toContain('this is the context value'); + await fixture.cdk(['context', '--reset', 'contextkey']); + await expect(fixture.cdk(['context'])).resolves.not.toContain('this is the context value'); // Test that forced delete of the context key does not throw - await cdk(['context', '-f', '--reset', 'contextkey']); + await fixture.cdk(['context', '-f', '--reset', 'contextkey']); } finally { - await fs.unlink(path.join(INTEG_TEST_DIR, 'cdk.context.json')); + await fs.unlink(path.join(fixture.integTestDir, 'cdk.context.json')); } }); -integTest('deploy', async () => { - const stackArn = await cdkDeploy('test-2', { captureStderr: false }); +integTest('deploy', prepareAppFixture, async (fixture) => { + const stackArn = await fixture.cdkDeploy('test-2', { captureStderr: false }); // verify the number of resources in the stack - const response = await cloudFormation('describeStackResources', { + const response = await fixture.aws.cloudFormation('describeStackResources', { StackName: stackArn, }); expect(response.StackResources?.length).toEqual(2); }); -integTest('deploy all', async () => { - const arns = await cdkDeploy('test-*', { captureStderr: false }); +integTest('deploy all', prepareAppFixture, async (fixture) => { + const arns = await fixture.cdkDeploy('test-*', { captureStderr: false }); // verify that we only deployed a single stack (there's a single ARN in the output) expect(arns.split('\n').length).toEqual(2); }); -integTest('nested stack with parameters', async () => { -// STACK_NAME_PREFIX is used in MyTopicParam to allow multiple instances -// of this test to run in parallel, othewise they will attempt to create the same SNS topic. - const stackArn = await cdkDeploy('with-nested-stack-using-parameters', { - options: ['--parameters', 'MyTopicParam=${STACK_NAME_PREFIX}ThereIsNoSpoon'], +integTest('nested stack with parameters', prepareAppFixture, async (fixture) => { + // STACK_NAME_PREFIX is used in MyTopicParam to allow multiple instances + // of this test to run in parallel, othewise they will attempt to create the same SNS topic. + const stackArn = await fixture.cdkDeploy('with-nested-stack-using-parameters', { + options: ['--parameters', `MyTopicParam=${fixture.stackNamePrefix}ThereIsNoSpoon`], captureStderr: false, }); @@ -137,107 +122,107 @@ integTest('nested stack with parameters', async () => { expect(stackArn.split('\n').length).toEqual(1); // verify the number of resources in the stack - const response = await cloudFormation('describeStackResources', { + const response = await fixture.aws.cloudFormation('describeStackResources', { StackName: stackArn, }); expect(response.StackResources?.length).toEqual(1); }); -integTest('deploy without execute', async () => { - const stackArn = await cdkDeploy('test-2', { +integTest('deploy without execute', prepareAppFixture, async (fixture) => { + const stackArn = await fixture.cdkDeploy('test-2', { options: ['--no-execute'], captureStderr: false, }); // verify that we only deployed a single stack (there's a single ARN in the output) expect(stackArn.split('\n').length).toEqual(1); - const response = await cloudFormation('describeStacks', { + const response = await fixture.aws.cloudFormation('describeStacks', { StackName: stackArn, }); expect(response.Stacks?.[0].StackStatus).toEqual('REVIEW_IN_PROGRESS'); }); -integTest('security related changes without a CLI are expected to fail', async () => { +integTest('security related changes without a CLI are expected to fail', prepareAppFixture, async (fixture) => { // redirect /dev/null to stdin, which means there will not be tty attached // since this stack includes security-related changes, the deployment should // immediately fail because we can't confirm the changes const stackName = 'iam-test'; - await expect(cdkDeploy(stackName, { + await expect(fixture.cdkDeploy(stackName, { options: ['<', '/dev/null'], // H4x, this only works because I happen to know we pass shell: true. neverRequireApproval: false, })).rejects.toThrow('exited with error'); // Ensure stack was not deployed - await expect(cloudFormation('describeStacks', { - StackName: fullStackName(stackName), + await expect(fixture.aws.cloudFormation('describeStacks', { + StackName: fixture.fullStackName(stackName), })).rejects.toThrow('does not exist'); }); -integTest('deploy wildcard with outputs', async () => { - const outputsFile = path.join(INTEG_TEST_DIR, 'outputs', 'outputs.json'); +integTest('deploy wildcard with outputs', prepareAppFixture, async (fixture) => { + const outputsFile = path.join(fixture.integTestDir, 'outputs', 'outputs.json'); await fs.mkdir(path.dirname(outputsFile), { recursive: true }); - await cdkDeploy(['outputs-test-*'], { + await fixture.cdkDeploy(['outputs-test-*'], { options: ['--outputs-file', outputsFile], }); const outputs = JSON.parse((await fs.readFile(outputsFile, { encoding: 'utf-8' })).toString()); expect(outputs).toEqual({ - [`${STACK_NAME_PREFIX}-outputs-test-1`]: { - TopicName: `${STACK_NAME_PREFIX}-outputs-test-1MyTopic`, + [`${fixture.stackNamePrefix}-outputs-test-1`]: { + TopicName: `${fixture.stackNamePrefix}-outputs-test-1MyTopic`, }, - [`${STACK_NAME_PREFIX}-outputs-test-2`]: { - TopicName: `${STACK_NAME_PREFIX}-outputs-test-2MyOtherTopic`, + [`${fixture.stackNamePrefix}-outputs-test-2`]: { + TopicName: `${fixture.stackNamePrefix}-outputs-test-2MyOtherTopic`, }, }); }); -integTest('deploy with parameters', async () => { - const stackArn = await cdkDeploy('param-test-1', { +integTest('deploy with parameters', prepareAppFixture, async (fixture) => { + const stackArn = await fixture.cdkDeploy('param-test-1', { options: [ - '--parameters', `TopicNameParam=${STACK_NAME_PREFIX}bazinga`, + '--parameters', `TopicNameParam=${fixture.stackNamePrefix}bazinga`, ], captureStderr: false, }); - const response = await cloudFormation('describeStacks', { + const response = await fixture.aws.cloudFormation('describeStacks', { StackName: stackArn, }); expect(response.Stacks?.[0].Parameters).toEqual([ { ParameterKey: 'TopicNameParam', - ParameterValue: `${STACK_NAME_PREFIX}bazinga`, + ParameterValue: `${fixture.stackNamePrefix}bazinga`, }, ]); }); -integTest('update to stack in ROLLBACK_COMPLETE state will delete stack and create a new one', async () => { +integTest('update to stack in ROLLBACK_COMPLETE state will delete stack and create a new one', prepareAppFixture, async (fixture) => { // GIVEN - await expect(cdkDeploy('param-test-1', { + await expect(fixture.cdkDeploy('param-test-1', { options: [ - '--parameters', `TopicNameParam=${STACK_NAME_PREFIX}@aww`, + '--parameters', `TopicNameParam=${fixture.stackNamePrefix}@aww`, ], captureStderr: false, })).rejects.toThrow('exited with error'); - const response = await cloudFormation('describeStacks', { - StackName: fullStackName('param-test-1'), + const response = await fixture.aws.cloudFormation('describeStacks', { + StackName: fixture.fullStackName('param-test-1'), }); const stackArn = response.Stacks?.[0].StackId; expect(response.Stacks?.[0].StackStatus).toEqual('ROLLBACK_COMPLETE'); // WHEN - const newStackArn = await cdkDeploy('param-test-1', { + const newStackArn = await fixture.cdkDeploy('param-test-1', { options: [ - '--parameters', `TopicNameParam=${STACK_NAME_PREFIX}allgood`, + '--parameters', `TopicNameParam=${fixture.stackNamePrefix}allgood`, ], captureStderr: false, }); - const newStackResponse = await cloudFormation('describeStacks', { + const newStackResponse = await fixture.aws.cloudFormation('describeStacks', { StackName: newStackArn, }); @@ -247,49 +232,49 @@ integTest('update to stack in ROLLBACK_COMPLETE state will delete stack and crea expect(newStackResponse.Stacks?.[0].Parameters).toEqual([ { ParameterKey: 'TopicNameParam', - ParameterValue: `${STACK_NAME_PREFIX}allgood`, + ParameterValue: `${fixture.stackNamePrefix}allgood`, }, ]); }); -integTest('stack in UPDATE_ROLLBACK_COMPLETE state can be updated', async () => { +integTest('stack in UPDATE_ROLLBACK_COMPLETE state can be updated', prepareAppFixture, async (fixture) => { // GIVEN - const stackArn = await cdkDeploy('param-test-1', { + const stackArn = await fixture.cdkDeploy('param-test-1', { options: [ - '--parameters', `TopicNameParam=${STACK_NAME_PREFIX}nice`, + '--parameters', `TopicNameParam=${fixture.stackNamePrefix}nice`, ], captureStderr: false, }); - let response = await cloudFormation('describeStacks', { + let response = await fixture.aws.cloudFormation('describeStacks', { StackName: stackArn, }); expect(response.Stacks?.[0].StackStatus).toEqual('CREATE_COMPLETE'); // bad parameter name with @ will put stack into UPDATE_ROLLBACK_COMPLETE - await expect(cdkDeploy('param-test-1', { + await expect(fixture.cdkDeploy('param-test-1', { options: [ - '--parameters', `TopicNameParam=${STACK_NAME_PREFIX}@aww`, + '--parameters', `TopicNameParam=${fixture.stackNamePrefix}@aww`, ], captureStderr: false, })).rejects.toThrow('exited with error');; - response = await cloudFormation('describeStacks', { + response = await fixture.aws.cloudFormation('describeStacks', { StackName: stackArn, }); expect(response.Stacks?.[0].StackStatus).toEqual('UPDATE_ROLLBACK_COMPLETE'); // WHEN - await cdkDeploy('param-test-1', { + await fixture.cdkDeploy('param-test-1', { options: [ - '--parameters', `TopicNameParam=${STACK_NAME_PREFIX}allgood`, + '--parameters', `TopicNameParam=${fixture.stackNamePrefix}allgood`, ], captureStderr: false, }); - response = await cloudFormation('describeStacks', { + response = await fixture.aws.cloudFormation('describeStacks', { StackName: stackArn, }); @@ -298,27 +283,27 @@ integTest('stack in UPDATE_ROLLBACK_COMPLETE state can be updated', async () => expect(response.Stacks?.[0].Parameters).toEqual([ { ParameterKey: 'TopicNameParam', - ParameterValue: `${STACK_NAME_PREFIX}allgood`, + ParameterValue: `${fixture.stackNamePrefix}allgood`, }, ]); }); -integTest('deploy with wildcard and parameters', async () => { - await cdkDeploy('param-test-*', { +integTest('deploy with wildcard and parameters', prepareAppFixture, async (fixture) => { + await fixture.cdkDeploy('param-test-*', { options: [ - '--parameters', `${STACK_NAME_PREFIX}-param-test-1:TopicNameParam=${STACK_NAME_PREFIX}bazinga`, - '--parameters', `${STACK_NAME_PREFIX}-param-test-2:OtherTopicNameParam=${STACK_NAME_PREFIX}ThatsMySpot`, - '--parameters', `${STACK_NAME_PREFIX}-param-test-3:DisplayNameParam=${STACK_NAME_PREFIX}HeyThere`, - '--parameters', `${STACK_NAME_PREFIX}-param-test-3:OtherDisplayNameParam=${STACK_NAME_PREFIX}AnotherOne`, + '--parameters', `${fixture.stackNamePrefix}-param-test-1:TopicNameParam=${fixture.stackNamePrefix}bazinga`, + '--parameters', `${fixture.stackNamePrefix}-param-test-2:OtherTopicNameParam=${fixture.stackNamePrefix}ThatsMySpot`, + '--parameters', `${fixture.stackNamePrefix}-param-test-3:DisplayNameParam=${fixture.stackNamePrefix}HeyThere`, + '--parameters', `${fixture.stackNamePrefix}-param-test-3:OtherDisplayNameParam=${fixture.stackNamePrefix}AnotherOne`, ], }); }); -integTest('deploy with parameters multi', async () => { - const paramVal1 = `${STACK_NAME_PREFIX}bazinga`; - const paramVal2 = `${STACK_NAME_PREFIX}=jagshemash`; +integTest('deploy with parameters multi', prepareAppFixture, async (fixture) => { + const paramVal1 = `${fixture.stackNamePrefix}bazinga`; + const paramVal2 = `${fixture.stackNamePrefix}=jagshemash`; - const stackArn = await cdkDeploy('param-test-3', { + const stackArn = await fixture.cdkDeploy('param-test-3', { options: [ '--parameters', `DisplayNameParam=${paramVal1}`, '--parameters', `OtherDisplayNameParam=${paramVal2}`, @@ -326,7 +311,7 @@ integTest('deploy with parameters multi', async () => { captureStderr: false, }); - const response = await cloudFormation('describeStacks', { + const response = await fixture.aws.cloudFormation('describeStacks', { StackName: stackArn, }); @@ -342,34 +327,34 @@ integTest('deploy with parameters multi', async () => { ]); }); -integTest('deploy with notification ARN', async () => { - const topicName = `${STACK_NAME_PREFIX}-test-topic`; +integTest('deploy with notification ARN', prepareAppFixture, async (fixture) => { + const topicName = `${fixture.stackNamePrefix}-test-topic`; - const response = await sns('createTopic', { Name: topicName }); + const response = await fixture.aws.sns('createTopic', { Name: topicName }); const topicArn = response.TopicArn!; try { - await cdkDeploy('test-2', { + await fixture.cdkDeploy('test-2', { options: ['--notification-arns', topicArn], }); // verify that the stack we deployed has our notification ARN - const describeResponse = await cloudFormation('describeStacks', { - StackName: fullStackName('test-2'), + const describeResponse = await fixture.aws.cloudFormation('describeStacks', { + StackName: fixture.fullStackName('test-2'), }); expect(describeResponse.Stacks?.[0].NotificationARNs).toEqual([topicArn]); } finally { - await sns('deleteTopic', { + await fixture.aws.sns('deleteTopic', { TopicArn: topicArn, }); } }); -integTest('deploy with role', async () => { - const roleName = `${STACK_NAME_PREFIX}-test-role`; +integTest('deploy with role', prepareAppFixture, async (fixture) => { + const roleName = `${fixture.stackNamePrefix}-test-role`; await deleteRole(); - const createResponse = await iam('createRole', { + const createResponse = await fixture.aws.iam('createRole', { RoleName: roleName, AssumeRolePolicyDocument: JSON.stringify({ Version: '2012-10-17', @@ -379,14 +364,14 @@ integTest('deploy with role', async () => { Effect: 'Allow', }, { Action: 'sts:AssumeRole', - Principal: { AWS: (await sts('getCallerIdentity', {})).Arn }, + Principal: { AWS: (await fixture.aws.sts('getCallerIdentity', {})).Arn }, Effect: 'Allow', }], }), }); const roleArn = createResponse.Role.Arn; try { - await iam('putRolePolicy', { + await fixture.aws.iam('putRolePolicy', { RoleName: roleName, PolicyName: 'DefaultPolicy', PolicyDocument: JSON.stringify({ @@ -399,8 +384,8 @@ integTest('deploy with role', async () => { }), }); - await retry('Trying to assume fresh role', retry.forSeconds(300), async () => { - await sts('assumeRole', { + await retry(fixture.output, 'Trying to assume fresh role', retry.forSeconds(300), async () => { + await fixture.aws.sts('assumeRole', { RoleArn: roleArn, RoleSessionName: 'testing', }); @@ -411,7 +396,7 @@ integTest('deploy with role', async () => { // that doesn't have it yet. await sleep(5000); - await cdkDeploy('test-2', { + await fixture.cdkDeploy('test-2', { options: ['--role-arn', roleArn], }); @@ -419,7 +404,7 @@ integTest('deploy with role', async () => { // // Since roles are sticky, if we delete the role before the stack, subsequent DeleteStack // operations will fail when CloudFormation tries to assume the role that's already gone. - await cdkDestroy('test-2'); + await fixture.cdkDestroy('test-2'); } finally { await deleteRole(); @@ -427,13 +412,13 @@ integTest('deploy with role', async () => { async function deleteRole() { try { - for (const policyName of (await iam('listRolePolicies', { RoleName: roleName })).PolicyNames) { - await iam('deleteRolePolicy', { + for (const policyName of (await fixture.aws.iam('listRolePolicies', { RoleName: roleName })).PolicyNames) { + await fixture.aws.iam('deleteRolePolicy', { RoleName: roleName, PolicyName: policyName, }); } - await iam('deleteRole', { RoleName: roleName }); + await fixture.aws.iam('deleteRole', { RoleName: roleName }); } catch (e) { if (e.message.indexOf('cannot be found') > -1) { return; } throw e; @@ -441,52 +426,52 @@ integTest('deploy with role', async () => { } }); -integTest('cdk diff', async () => { - const diff1 = await cdk(['diff', fullStackName('test-1')]); +integTest('cdk diff', prepareAppFixture, async (fixture) => { + const diff1 = await fixture.cdk(['diff', fixture.fullStackName('test-1')]); expect(diff1).toContain('AWS::SNS::Topic'); - const diff2 = await cdk(['diff', fullStackName('test-2')]); + const diff2 = await fixture.cdk(['diff', fixture.fullStackName('test-2')]); expect(diff2).toContain('AWS::SNS::Topic'); // We can make it fail by passing --fail - await expect(cdk(['diff', '--fail', fullStackName('test-1')])) + await expect(fixture.cdk(['diff', '--fail', fixture.fullStackName('test-1')])) .rejects.toThrow('exited with error'); }); -integTest('cdk diff --fail on multiple stacks exits with error if any of the stacks contains a diff', async () => { +integTest('cdk diff --fail on multiple stacks exits with error if any of the stacks contains a diff', prepareAppFixture, async (fixture) => { // GIVEN - const diff1 = await cdk(['diff', fullStackName('test-1')]); + const diff1 = await fixture.cdk(['diff', fixture.fullStackName('test-1')]); expect(diff1).toContain('AWS::SNS::Topic'); - await cdkDeploy('test-2'); - const diff2 = await cdk(['diff', fullStackName('test-2')]); + await fixture.cdkDeploy('test-2'); + const diff2 = await fixture.cdk(['diff', fixture.fullStackName('test-2')]); expect(diff2).toContain('There were no differences'); // WHEN / THEN - await expect(cdk(['diff', '--fail', fullStackName('test-1'), fullStackName('test-2')])).rejects.toThrow('exited with error'); + await expect(fixture.cdk(['diff', '--fail', fixture.fullStackName('test-1'), fixture.fullStackName('test-2')])).rejects.toThrow('exited with error'); }); -integTest('cdk diff --fail with multiple stack exits with if any of the stacks contains a diff', async () => { +integTest('cdk diff --fail with multiple stack exits with if any of the stacks contains a diff', prepareAppFixture, async (fixture) => { // GIVEN - await cdkDeploy('test-1'); - const diff1 = await cdk(['diff', fullStackName('test-1')]); + await fixture.cdkDeploy('test-1'); + const diff1 = await fixture.cdk(['diff', fixture.fullStackName('test-1')]); expect(diff1).toContain('There were no differences'); - const diff2 = await cdk(['diff', fullStackName('test-2')]); + const diff2 = await fixture.cdk(['diff', fixture.fullStackName('test-2')]); expect(diff2).toContain('AWS::SNS::Topic'); // WHEN / THEN - await expect(cdk(['diff', '--fail', fullStackName('test-1'), fullStackName('test-2')])).rejects.toThrow('exited with error'); + await expect(fixture.cdk(['diff', '--fail', fixture.fullStackName('test-1'), fixture.fullStackName('test-2')])).rejects.toThrow('exited with error'); }); -integTest('deploy stack with docker asset', async () => { - await cdkDeploy('docker'); +integTest('deploy stack with docker asset', prepareAppFixture, async (fixture) => { + await fixture.cdkDeploy('docker'); }); -integTest('deploy and test stack with lambda asset', async () => { - const stackArn = await cdkDeploy('lambda', { captureStderr: false }); +integTest('deploy and test stack with lambda asset', prepareAppFixture, async (fixture) => { + const stackArn = await fixture.cdkDeploy('lambda', { captureStderr: false }); - const response = await cloudFormation('describeStacks', { + const response = await fixture.aws.cloudFormation('describeStacks', { StackName: stackArn, }); const lambdaArn = response.Stacks?.[0].Outputs?.[0].OutputValue; @@ -494,15 +479,15 @@ integTest('deploy and test stack with lambda asset', async () => { throw new Error('Stack did not have expected Lambda ARN output'); } - const output = await lambda('invoke', { + const output = await fixture.aws.lambda('invoke', { FunctionName: lambdaArn, }); expect(JSON.stringify(output.Payload)).toContain('dear asset'); }); -integTest('cdk ls', async () => { - const listing = await cdk(['ls'], { captureStderr: false }); +integTest('cdk ls', prepareAppFixture, async (fixture) => { + const listing = await fixture.cdk(['ls'], { captureStderr: false }); const expectedStacks = [ 'conditional-resource', @@ -527,30 +512,30 @@ integTest('cdk ls', async () => { ]; for (const stack of expectedStacks) { - expect(listing).toContain(fullStackName(stack)); + expect(listing).toContain(fixture.fullStackName(stack)); } }); -integTest('deploy stack without resource', async () => { +integTest('deploy stack without resource', prepareAppFixture, async (fixture) => { // Deploy the stack without resources - await cdkDeploy('conditional-resource', { modEnv: { NO_RESOURCE: 'TRUE' } }); + await fixture.cdkDeploy('conditional-resource', { modEnv: { NO_RESOURCE: 'TRUE' } }); // This should have succeeded but not deployed the stack. - await expect(cloudFormation('describeStacks', { StackName: fullStackName('conditional-resource') })) + await expect(fixture.aws.cloudFormation('describeStacks', { StackName: fixture.fullStackName('conditional-resource') })) .rejects.toThrow('conditional-resource does not exist'); // Deploy the stack with resources - await cdkDeploy('conditional-resource'); + await fixture.cdkDeploy('conditional-resource'); // Then again WITHOUT resources (this should destroy the stack) - await cdkDeploy('conditional-resource', { modEnv: { NO_RESOURCE: 'TRUE' } }); + await fixture.cdkDeploy('conditional-resource', { modEnv: { NO_RESOURCE: 'TRUE' } }); - await expect(cloudFormation('describeStacks', { StackName: fullStackName('conditional-resource') })) + await expect(fixture.aws.cloudFormation('describeStacks', { StackName: fixture.fullStackName('conditional-resource') })) .rejects.toThrow('conditional-resource does not exist'); }); -integTest('IAM diff', async () => { - const output = await cdk(['diff', fullStackName('iam-test')]); +integTest('IAM diff', prepareAppFixture, async (fixture) => { + const output = await fixture.cdk(['diff', fixture.fullStackName('iam-test')]); // Roughly check for a table like this: // @@ -565,48 +550,48 @@ integTest('IAM diff', async () => { expect(output).toContain('ec2.amazonaws.com'); }); -integTest('fast deploy', async () => { +integTest('fast deploy', prepareAppFixture, async (fixture) => { // we are using a stack with a nested stack because CFN will always attempt to // update a nested stack, which will allow us to verify that updates are actually // skipped unless --force is specified. - const stackArn = await cdkDeploy('with-nested-stack', { captureStderr: false }); + const stackArn = await fixture.cdkDeploy('with-nested-stack', { captureStderr: false }); const changeSet1 = await getLatestChangeSet(); // Deploy the same stack again, there should be no new change set created - await cdkDeploy('with-nested-stack'); + await fixture.cdkDeploy('with-nested-stack'); const changeSet2 = await getLatestChangeSet(); expect(changeSet2.ChangeSetId).toEqual(changeSet1.ChangeSetId); // Deploy the stack again with --force, now we should create a changeset - await cdkDeploy('with-nested-stack', { options: ['--force'] }); + await fixture.cdkDeploy('with-nested-stack', { options: ['--force'] }); const changeSet3 = await getLatestChangeSet(); expect(changeSet3.ChangeSetId).not.toEqual(changeSet2.ChangeSetId); // Deploy the stack again with tags, expected to create a new changeset // even though the resources didn't change. - await cdkDeploy('with-nested-stack', { options: ['--tags', 'key=value'] }); + await fixture.cdkDeploy('with-nested-stack', { options: ['--tags', 'key=value'] }); const changeSet4 = await getLatestChangeSet(); expect(changeSet4.ChangeSetId).not.toEqual(changeSet3.ChangeSetId); async function getLatestChangeSet() { - const response = await cloudFormation('describeStacks', { StackName: stackArn }); + const response = await fixture.aws.cloudFormation('describeStacks', { StackName: stackArn }); if (!response.Stacks?.[0]) { throw new Error('Did not get a ChangeSet at all'); } - log(`Found Change Set ${response.Stacks?.[0].ChangeSetId}`); + fixture.log(`Found Change Set ${response.Stacks?.[0].ChangeSetId}`); return response.Stacks?.[0]; } }); -integTest('failed deploy does not hang', async () => { +integTest('failed deploy does not hang', prepareAppFixture, async (fixture) => { // this will hang if we introduce https://github.com/aws/aws-cdk/issues/6403 again. - await expect(cdkDeploy('failed')).rejects.toThrow('exited with error'); + await expect(fixture.cdkDeploy('failed')).rejects.toThrow('exited with error'); }); -integTest('can still load old assemblies', async () => { +integTest('can still load old assemblies', prepareAppFixture, async (fixture) => { const cxAsmDir = path.join(os.tmpdir(), 'cdk-integ-cx'); const testAssembliesDirectory = path.join(__dirname, 'cloud-assemblies'); for (const asmdir of await listChildDirs(testAssembliesDirectory)) { - log(`ASSEMBLY ${asmdir}`); + fixture.log(`ASSEMBLY ${asmdir}`); await cloneDirectory(asmdir, cxAsmDir); // Some files in the asm directory that have a .js extension are @@ -616,6 +601,7 @@ integTest('can still load old assemblies', async () => { const targetName = template.replace(/.js$/, ''); await shell([process.execPath, template, '>', targetName], { cwd: cxAsmDir, + output: fixture.output, modEnv: { TEST_ACCOUNT: (await testEnv()).account, TEST_REGION: (await testEnv()).region, @@ -624,7 +610,7 @@ integTest('can still load old assemblies', async () => { } // Use this directory as a Cloud Assembly - const output = await cdk([ + const output = await fixture.cdk([ '--app', cxAsmDir, '-v', 'synth', @@ -637,48 +623,46 @@ integTest('can still load old assemblies', async () => { } }); -integTest('generating and loading assembly', async () => { +integTest('generating and loading assembly', prepareAppFixture, async (fixture) => { const asmOutputDir = path.join(os.tmpdir(), 'cdk-integ-asm'); - await shell(['rm', '-rf', asmOutputDir]); - - // Make sure our fixture directory is clean - await prepareAppFixture(); + await fixture.shell(['rm', '-rf', asmOutputDir]); // Synthesize a Cloud Assembly tothe default directory (cdk.out) and a specific directory. - await cdk(['synth']); - await cdk(['synth', '--output', asmOutputDir]); + await fixture.cdk(['synth']); + await fixture.cdk(['synth', '--output', asmOutputDir]); // cdk.out in the current directory and the indicated --output should be the same await shell(['diff', 'cdk.out', asmOutputDir], { - cwd: INTEG_TEST_DIR, + output: fixture.output, + cwd: fixture.integTestDir, }); // Check that we can 'ls' the synthesized asm. // Change to some random directory to make sure we're not accidentally loading cdk.json - const list = await cdk(['--app', asmOutputDir, 'ls'], { cwd: os.tmpdir() }); + const list = await fixture.cdk(['--app', asmOutputDir, 'ls'], { cwd: os.tmpdir() }); // Same stacks we know are in the app - expect(list).toContain(`${STACK_NAME_PREFIX}-lambda`); - expect(list).toContain(`${STACK_NAME_PREFIX}-test-1`); - expect(list).toContain(`${STACK_NAME_PREFIX}-test-2`); + expect(list).toContain(`${fixture.stackNamePrefix}-lambda`); + expect(list).toContain(`${fixture.stackNamePrefix}-test-1`); + expect(list).toContain(`${fixture.stackNamePrefix}-test-2`); // Check that we can use '.' and just synth ,the generated asm - const stackTemplate = await cdk(['--app', '.', 'synth', fullStackName('test-2')], { + const stackTemplate = await fixture.cdk(['--app', '.', 'synth', fixture.fullStackName('test-2')], { cwd: asmOutputDir, }); expect(stackTemplate).toContain('topic152D84A37'); // Deploy a Lambda from the copied asm - await cdkDeploy('lambda', { options: ['-a', '.'], cwd: asmOutputDir }); + await fixture.cdkDeploy('lambda', { options: ['-a', '.'], cwd: asmOutputDir }); // Remove (rename) the original custom docker file that was used during synth. // this verifies that the assemly has a copy of it and that the manifest uses // relative paths to reference to it. - const customDockerFile = path.join(INTEG_TEST_DIR, 'docker', 'Dockerfile.Custom'); + const customDockerFile = path.join(fixture.integTestDir, 'docker', 'Dockerfile.Custom'); await fs.rename(customDockerFile, `${customDockerFile}~`); try { // deploy a docker image with custom file without synth (uses assets) - await cdkDeploy('docker-with-custom-file', { options: ['-a', '.'], cwd: asmOutputDir }); + await fixture.cdkDeploy('docker-with-custom-file', { options: ['-a', '.'], cwd: asmOutputDir }); } finally { // Rename back to restore fixture to original state @@ -686,21 +670,17 @@ integTest('generating and loading assembly', async () => { } }); -integTest('templates on disk contain metadata resource, also in nested assemblies', async () => { +integTest('templates on disk contain metadata resource, also in nested assemblies', prepareAppFixture, async (fixture) => { // Synth first, and switch on version reporting because cdk.json is disabling it - await cdk(['synth', '--version-reporting=true']); + await fixture.cdk(['synth', '--version-reporting=true']); // Load template from disk from root assembly - const templateContents = await shell(['cat', 'cdk.out/*-lambda.template.json'], { - cwd: INTEG_TEST_DIR, - }); + const templateContents = await fixture.shell(['cat', 'cdk.out/*-lambda.template.json']); expect(JSON.parse(templateContents).Resources.CDKMetadata).toBeTruthy(); // Load template from nested assembly - const nestedTemplateContents = await shell(['cat', 'cdk.out/assembly-*-stage/*-stage-StackInStage.template.json'], { - cwd: INTEG_TEST_DIR, - }); + const nestedTemplateContents = await fixture.shell(['cat', 'cdk.out/assembly-*-stage/*-stage-StackInStage.template.json']); expect(JSON.parse(nestedTemplateContents).Resources.CDKMetadata).toBeTruthy(); }); diff --git a/packages/aws-cdk/test/integ/cli/corking.ts b/packages/aws-cdk/test/integ/cli/corking.ts index 3b9dbc3e7c206..774dad0570892 100644 --- a/packages/aws-cdk/test/integ/cli/corking.ts +++ b/packages/aws-cdk/test/integ/cli/corking.ts @@ -1,45 +1,21 @@ /** * Routines for corking stdout and stderr */ +import * as stream from 'stream'; -let _corkShellOutput = false; -const _corked = { - stdout: new Array(), - stderr: new Array(), -}; +export class MemoryStream extends stream.Writable { + private parts = new Array(); -function cleanStreams() { - _corked.stdout.splice(0, _corked.stdout.length); - _corked.stderr.splice(0, _corked.stderr.length); -} - -export function corkShellOutput() { - _corkShellOutput = true; - cleanStreams(); -} - -export function writeOutput(stream: 'stdout' | 'stderr', content: Buffer) { - if (_corkShellOutput) { - _corked[stream].push(content); - } else { - process[stream].write(content); + public _write(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + this.parts.push(chunk); + callback(); } -} -async function writeAndFlush(stream: 'stdout' | 'stderr', content: Buffer) { - const flushed = process[stream].write(content); - if (!flushed) { - return new Promise(ok => process[stream].once('drain', ok)); + public buffer() { + return Buffer.concat(this.parts); } -} - -export function uncorkShellOutput() { - _corkShellOutput = false; -} -export async function flushCorkedOutput() { - await writeAndFlush('stdout', Buffer.concat(_corked.stdout)); - await writeAndFlush('stderr', Buffer.concat(_corked.stderr)); - cleanStreams(); + public clear() { + this.parts.splice(0, this.parts.length); + } } - diff --git a/packages/aws-cdk/test/integ/cli/test-helpers.ts b/packages/aws-cdk/test/integ/cli/test-helpers.ts index a8fbc0eb5d04d..f208b8f82032a 100644 --- a/packages/aws-cdk/test/integ/cli/test-helpers.ts +++ b/packages/aws-cdk/test/integ/cli/test-helpers.ts @@ -1,28 +1,40 @@ import * as fs from 'fs'; import * as path from 'path'; -import { corkShellOutput, uncorkShellOutput, flushCorkedOutput } from './corking'; +import { MemoryStream } from './corking'; const SKIP_TESTS = fs.readFileSync(path.join(__dirname, 'skip-tests.txt'), { encoding: 'utf-8' }).split('\n'); +export type TestEnvironment = { + output: NodeJS.WritableStream; +}; + /** * A wrapper for jest's 'test' which takes regression-disabled tests into account and prints a banner */ -export function integTest(name: string, callback: () => A | Promise) { +export function integTest Promise }>(name: string, + before: (env: TestEnvironment) => Promise, + callback: (fixture: F) => Promise) { + const runner = shouldSkip(name) ? test.skip : test; runner(name, async () => { - process.stdout.write('================================================================\n'); - process.stdout.write(`${name}\n`); - process.stdout.write('================================================================\n'); + const output = new MemoryStream(); + + output.write('================================================================\n'); + output.write(`${name}\n`); + output.write('================================================================\n'); + const fixture = await before({ output }); + let success = true; try { - corkShellOutput(); - return await callback(); + return await callback(fixture); } catch (e) { - await flushCorkedOutput(); + process.stderr.write(output.buffer().toString('utf-8')); + process.stderr.write(`${e.toString()}\n`); + success = false; throw e; } finally { - uncorkShellOutput(); + await fixture.dispose(success); } }); } From 61865aaef682be6727d7768213260c7a95d799f8 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 9 Sep 2020 11:53:31 +0200 Subject: [PATCH 10/12] fix(core): DefaultSynthesizer breaks this.node.setContext() on Stack (#10246) Since the `DefaultSynthesizer` recently started adding constructs into the Stack at construction time, that broke `this.node.setContext()` which requires that no children have been added to a construct yet when its context is being modified. To fix this, add the constructs just-in-time just before the stack is being synthesized. In order to give the `DefaultStackSynthesizer` a chance to modify the stack's construct tree before its template is being written out, the Synthesizer is now in full control of the order in which things happen. Change the call tree from: ``` synthesizeTree - stack._synthesizeTemplate - (write template) - this.synthesizer.synthesizeStackArtifacts - (register artifacts) ``` To: ``` synthesizeTree - stack.synthesizer.synthesize - stack._synthesizeTemplate - (write template) - (register artifacts) ``` All APIs involved in this call tree are either `@experimental` or `@internal`. BREAKING CHANGE: custom implementations of `IStackSynthesizer` must now implement `synthesize()` instead of `synthesizeStackArtifacts()`. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- allowed-breaking-changes.txt | 7 ++ .../@aws-cdk/core/lib/private/synthesis.ts | 2 +- .../bootstrapless-synthesizer.ts | 10 +- .../stack-synthesizers/default-synthesizer.ts | 40 +++++-- .../core/lib/stack-synthesizers/index.ts | 1 + .../core/lib/stack-synthesizers/legacy.ts | 15 ++- .../core/lib/stack-synthesizers/nested.ts | 17 ++- .../stack-synthesizers/stack-synthesizer.ts | 109 ++++++++++++++++++ .../core/lib/stack-synthesizers/types.ts | 6 +- packages/@aws-cdk/core/lib/stack.ts | 3 - .../test.new-style-synthesis.ts | 6 +- packages/@aws-cdk/core/test/test.stack.ts | 26 ++++- 12 files changed, 208 insertions(+), 34 deletions(-) create mode 100644 packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts diff --git a/allowed-breaking-changes.txt b/allowed-breaking-changes.txt index 56ecdf3636d0f..dc8b4b8f8c9cd 100644 --- a/allowed-breaking-changes.txt +++ b/allowed-breaking-changes.txt @@ -28,3 +28,10 @@ changed-type:@aws-cdk/aws-codedeploy.ServerDeploymentGroup.autoScalingGroups change-return-type:@aws-cdk/aws-ecs.ContainerDefinition.renderContainerDefinition change-return-type:@aws-cdk/aws-ecs.FirelensLogRouter.renderContainerDefinition change-return-type:@aws-cdk/aws-ecs.LinuxParameters.renderLinuxParameters + +# These were accidentally not marked @experimental +removed:@aws-cdk/core.BootstraplessSynthesizer.synthesizeStackArtifacts +removed:@aws-cdk/core.DefaultStackSynthesizer.synthesizeStackArtifacts +removed:@aws-cdk/core.LegacyStackSynthesizer.synthesizeStackArtifacts +removed:@aws-cdk/core.NestedStackSynthesizer.synthesizeStackArtifacts +removed:@aws-cdk/core.IStackSynthesizer.synthesizeStackArtifacts diff --git a/packages/@aws-cdk/core/lib/private/synthesis.ts b/packages/@aws-cdk/core/lib/private/synthesis.ts index b4d2368ae6c99..c67fa14d5b75e 100644 --- a/packages/@aws-cdk/core/lib/private/synthesis.ts +++ b/packages/@aws-cdk/core/lib/private/synthesis.ts @@ -123,7 +123,7 @@ function synthesizeTree(root: IConstruct, builder: cxapi.CloudAssemblyBuilder) { }; if (construct instanceof Stack) { - construct._synthesizeTemplate(session); + construct.synthesizer.synthesize(session); } else if (construct instanceof TreeMetadata) { construct._synthesizeTree(session); } diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts index 56b71a7a79af9..16ea69c1b2302 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/bootstrapless-synthesizer.ts @@ -1,6 +1,6 @@ import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource } from '../assets'; import { ISynthesisSession } from '../construct-compat'; -import { addStackArtifactToAssembly, assertBound } from './_shared'; +import { assertBound } from './_shared'; import { DefaultStackSynthesizer } from './default-synthesizer'; /** @@ -47,15 +47,17 @@ export class BootstraplessSynthesizer extends DefaultStackSynthesizer { throw new Error('Cannot add assets to a Stack that uses the BootstraplessSynthesizer'); } - public synthesizeStackArtifacts(session: ISynthesisSession): void { + public synthesize(session: ISynthesisSession): void { assertBound(this.stack); + this.synthesizeStackTemplate(this.stack, session); + // do _not_ treat the template as an asset, // because this synthesizer doesn't have a bootstrap bucket to put it in - addStackArtifactToAssembly(session, this.stack, { + this.emitStackArtifact(this.stack, session, { assumeRoleArn: this.deployRoleArn, cloudFormationExecutionRoleArn: this.cloudFormationExecutionRoleArn, requiresBootstrapStackVersion: 1, - }, []); + }); } } diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts index aa19151edc2f5..15f2d96e3b8cd 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/default-synthesizer.ts @@ -9,8 +9,8 @@ import { CfnRule } from '../cfn-rule'; import { ISynthesisSession } from '../construct-compat'; import { Stack } from '../stack'; import { Token } from '../token'; -import { addStackArtifactToAssembly, assertBound, contentHash } from './_shared'; -import { IStackSynthesizer } from './types'; +import { assertBound, contentHash } from './_shared'; +import { StackSynthesizer } from './stack-synthesizer'; export const BOOTSTRAP_QUALIFIER_CONTEXT = '@aws-cdk/core:bootstrapQualifier'; @@ -161,7 +161,7 @@ export interface DefaultStackSynthesizerProps { * * Requires the environment to have been bootstrapped with Bootstrap Stack V2. */ -export class DefaultStackSynthesizer implements IStackSynthesizer { +export class DefaultStackSynthesizer extends StackSynthesizer { /** * Default ARN qualifier */ @@ -209,17 +209,20 @@ export class DefaultStackSynthesizer implements IStackSynthesizer { private _cloudFormationExecutionRoleArn?: string; private fileAssetPublishingRoleArn?: string; private imageAssetPublishingRoleArn?: string; + private qualifier?: string; private readonly files: NonNullable = {}; private readonly dockerImages: NonNullable = {}; constructor(private readonly props: DefaultStackSynthesizerProps = {}) { + super(); } public bind(stack: Stack): void { this._stack = stack; const qualifier = this.props.qualifier ?? stack.node.tryGetContext(BOOTSTRAP_QUALIFIER_CONTEXT) ?? DefaultStackSynthesizer.DEFAULT_QUALIFIER; + this.qualifier = qualifier; // Function to replace placeholders in the input string as much as possible // @@ -244,10 +247,6 @@ export class DefaultStackSynthesizer implements IStackSynthesizer { this.fileAssetPublishingRoleArn = specialize(this.props.fileAssetPublishingRoleArn ?? DefaultStackSynthesizer.DEFAULT_FILE_ASSET_PUBLISHING_ROLE_ARN); this.imageAssetPublishingRoleArn = specialize(this.props.imageAssetPublishingRoleArn ?? DefaultStackSynthesizer.DEFAULT_IMAGE_ASSET_PUBLISHING_ROLE_ARN); /* eslint-enable max-len */ - - if (this.props.generateBootstrapVersionRule ?? true) { - addBootstrapVersionRule(stack, MIN_BOOTSTRAP_STACK_VERSION, qualifier); - } } public addFileAsset(asset: FileAssetSource): FileAssetLocation { @@ -321,20 +320,36 @@ export class DefaultStackSynthesizer implements IStackSynthesizer { }; } - public synthesizeStackArtifacts(session: ISynthesisSession): void { + /** + * Synthesize the associated stack to the session + */ + public synthesize(session: ISynthesisSession): void { assertBound(this.stack); + assertBound(this.qualifier); + + // Must be done here -- if it's done in bind() (called in the Stack's constructor) + // then it will become impossible to set context after that. + // + // If it's done AFTER _synthesizeTemplate(), then the template won't contain the + // right constructs. + if (this.props.generateBootstrapVersionRule ?? true) { + addBootstrapVersionRule(this.stack, MIN_BOOTSTRAP_STACK_VERSION, this.qualifier); + } + + this.synthesizeStackTemplate(this.stack, session); // Add the stack's template to the artifact manifest const templateManifestUrl = this.addStackTemplateToAssetManifest(session); const artifactId = this.writeAssetManifest(session); - addStackArtifactToAssembly(session, this.stack, { + this.emitStackArtifact(this.stack, session, { assumeRoleArn: this._deployRoleArn, cloudFormationExecutionRoleArn: this._cloudFormationExecutionRoleArn, stackTemplateAssetObjectUrl: templateManifestUrl, requiresBootstrapStackVersion: MIN_BOOTSTRAP_STACK_VERSION, - }, [artifactId]); + additionalDependencies: [artifactId], + }); } /** @@ -483,6 +498,11 @@ function stackLocationOrInstrinsics(stack: Stack) { * so we encode this rule into the template in a way that CloudFormation will check it. */ function addBootstrapVersionRule(stack: Stack, requiredVersion: number, qualifier: string) { + // Because of https://github.com/aws/aws-cdk/blob/master/packages/@aws-cdk/assert/lib/synth-utils.ts#L74 + // synthesize() may be called more than once on a stack in unit tests, and the below would break + // if we execute it a second time. Guard against the constructs already existing. + if (stack.node.tryFindChild('BootstrapVersion')) { return; } + const param = new CfnParameter(stack, 'BootstrapVersion', { type: 'AWS::SSM::Parameter::Value', description: 'Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store.', diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/index.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/index.ts index b4ad67384729d..db5f8e4d3f656 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/index.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/index.ts @@ -3,3 +3,4 @@ export * from './default-synthesizer'; export * from './legacy'; export * from './bootstrapless-synthesizer'; export * from './nested'; +export * from './stack-synthesizer'; diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/legacy.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/legacy.ts index e8bbe5317dd3c..bacf1514a8b4b 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/legacy.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/legacy.ts @@ -5,8 +5,8 @@ import { Fn } from '../cfn-fn'; import { Construct, ISynthesisSession } from '../construct-compat'; import { FileAssetParameters } from '../private/asset-parameters'; import { Stack } from '../stack'; -import { addStackArtifactToAssembly, assertBound } from './_shared'; -import { IStackSynthesizer } from './types'; +import { assertBound } from './_shared'; +import { StackSynthesizer } from './stack-synthesizer'; /** * The well-known name for the docker image asset ECR repository. All docker @@ -32,7 +32,7 @@ const ASSETS_ECR_REPOSITORY_NAME_OVERRIDE_CONTEXT_KEY = 'assets-ecr-repository-n * This is the only StackSynthesizer that supports customizing asset behavior * by overriding `Stack.addFileAsset()` and `Stack.addDockerImageAsset()`. */ -export class LegacyStackSynthesizer implements IStackSynthesizer { +export class LegacyStackSynthesizer extends StackSynthesizer { private stack?: Stack; private cycle = false; @@ -94,11 +94,16 @@ export class LegacyStackSynthesizer implements IStackSynthesizer { } } - public synthesizeStackArtifacts(session: ISynthesisSession): void { + /** + * Synthesize the associated stack to the session + */ + public synthesize(session: ISynthesisSession): void { assertBound(this.stack); + this.synthesizeStackTemplate(this.stack, session); + // Just do the default stuff, nothing special - addStackArtifactToAssembly(session, this.stack, {}, []); + this.emitStackArtifact(this.stack, session); } private doAddDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation { diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/nested.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/nested.ts index 8841618823aa9..bc909775fee8a 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/nested.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/nested.ts @@ -1,6 +1,8 @@ import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource } from '../assets'; import { ISynthesisSession } from '../construct-compat'; import { Stack } from '../stack'; +import { assertBound } from './_shared'; +import { StackSynthesizer } from './stack-synthesizer'; import { IStackSynthesizer } from './types'; /** @@ -8,12 +10,15 @@ import { IStackSynthesizer } from './types'; * * Interoperates with the StackSynthesizer of the parent stack. */ -export class NestedStackSynthesizer implements IStackSynthesizer { +export class NestedStackSynthesizer extends StackSynthesizer { + private stack?: Stack; + constructor(private readonly parentDeployment: IStackSynthesizer) { + super(); } - public bind(_stack: Stack): void { - // Nothing to do + public bind(stack: Stack): void { + this.stack = stack; } public addFileAsset(asset: FileAssetSource): FileAssetLocation { @@ -28,8 +33,10 @@ export class NestedStackSynthesizer implements IStackSynthesizer { return this.parentDeployment.addDockerImageAsset(asset); } - public synthesizeStackArtifacts(_session: ISynthesisSession): void { - // Do not emit Nested Stack as a cloud assembly artifact. + public synthesize(session: ISynthesisSession): void { + assertBound(this.stack); + // Synthesize the template, but don't emit as a cloud assembly artifact. // It will be registered as an S3 asset of its parent instead. + this.synthesizeStackTemplate(this.stack, session); } } diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts new file mode 100644 index 0000000000000..fde6ed053059e --- /dev/null +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/stack-synthesizer.ts @@ -0,0 +1,109 @@ +import { DockerImageAssetLocation, DockerImageAssetSource, FileAssetLocation, FileAssetSource } from '../assets'; +import { ISynthesisSession } from '../construct-compat'; +import { Stack } from '../stack'; +import { addStackArtifactToAssembly } from './_shared'; +import { IStackSynthesizer } from './types'; + +/** + * Base class for implementing an IStackSynthesizer + * + * This class needs to exist to provide public surface area for external + * implementations of stack synthesizers. The protected methods give + * access to functions that are otherwise @_internal to the framework + * and could not be accessed by external implementors. + */ +export abstract class StackSynthesizer implements IStackSynthesizer { + /** + * Bind to the stack this environment is going to be used on + * + * Must be called before any of the other methods are called. + */ + public abstract bind(stack: Stack): void; + + /** + * Register a File Asset + * + * Returns the parameters that can be used to refer to the asset inside the template. + */ + public abstract addFileAsset(asset: FileAssetSource): FileAssetLocation; + + /** + * Register a Docker Image Asset + * + * Returns the parameters that can be used to refer to the asset inside the template. + */ + public abstract addDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation; + + /** + * Synthesize the associated stack to the session + */ + public abstract synthesize(session: ISynthesisSession): void; + + /** + * Have the stack write out its template + */ + protected synthesizeStackTemplate(stack: Stack, session: ISynthesisSession): void { + stack._synthesizeTemplate(session); + } + + + /** + * Write the stack artifact to the session + * + * Use default settings to add a CloudFormationStackArtifact artifact to + * the given synthesis session. + */ + protected emitStackArtifact(stack: Stack, session: ISynthesisSession, options: SynthesizeStackArtifactOptions = {}) { + addStackArtifactToAssembly(session, stack, options ?? {}, options.additionalDependencies ?? []); + } +} + +/** + * Stack artifact options + * + * A subset of `cxschema.AwsCloudFormationStackProperties` of optional settings that need to be + * configurable by synthesizers, plus `additionalDependencies`. + */ +export interface SynthesizeStackArtifactOptions { + /** + * Identifiers of additional dependencies + * + * @default - No additional dependencies + */ + readonly additionalDependencies?: string[]; + + /** + * Values for CloudFormation stack parameters that should be passed when the stack is deployed. + * + * @default - No parameters + */ + readonly parameters?: { [id: string]: string }; + + /** + * The role that needs to be assumed to deploy the stack + * + * @default - No role is assumed (current credentials are used) + */ + readonly assumeRoleArn?: string; + + /** + * The role that is passed to CloudFormation to execute the change set + * + * @default - No role is passed (currently assumed role/credentials are used) + */ + readonly cloudFormationExecutionRoleArn?: string; + + /** + * If the stack template has already been included in the asset manifest, its asset URL + * + * @default - Not uploaded yet, upload just before deploying + */ + readonly stackTemplateAssetObjectUrl?: string; + + /** + * Version of bootstrap stack required to deploy this stack + * + * @default - No bootstrap stack required + */ + readonly requiresBootstrapStackVersion?: number; +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/stack-synthesizers/types.ts b/packages/@aws-cdk/core/lib/stack-synthesizers/types.ts index c7f5fce1a7cbf..425aee7b7af5a 100644 --- a/packages/@aws-cdk/core/lib/stack-synthesizers/types.ts +++ b/packages/@aws-cdk/core/lib/stack-synthesizers/types.ts @@ -28,9 +28,7 @@ export interface IStackSynthesizer { addDockerImageAsset(asset: DockerImageAssetSource): DockerImageAssetLocation; /** - * Synthesize all artifacts required for the stack into the session - * - * @experimental + * Synthesize the associated stack to the session */ - synthesizeStackArtifacts(session: ISynthesisSession): void; + synthesize(session: ISynthesisSession): void; } diff --git a/packages/@aws-cdk/core/lib/stack.ts b/packages/@aws-cdk/core/lib/stack.ts index b61d9ee7d7977..2aad06f44c721 100644 --- a/packages/@aws-cdk/core/lib/stack.ts +++ b/packages/@aws-cdk/core/lib/stack.ts @@ -728,9 +728,6 @@ export class Stack extends Construct implements ITaggable { for (const ctx of this._missingContext) { builder.addMissing(ctx); } - - // Delegate adding artifacts to the Synthesizer - this.synthesizer.synthesizeStackArtifacts(session); } /** diff --git a/packages/@aws-cdk/core/test/stack-synthesis/test.new-style-synthesis.ts b/packages/@aws-cdk/core/test/stack-synthesis/test.new-style-synthesis.ts index 2701f057cde6f..8028658b4c7dc 100644 --- a/packages/@aws-cdk/core/test/stack-synthesis/test.new-style-synthesis.ts +++ b/packages/@aws-cdk/core/test/stack-synthesis/test.new-style-synthesis.ts @@ -36,7 +36,7 @@ export = { // THEN -- the S3 url is advertised on the stack artifact const stackArtifact = asm.getStackArtifact('Stack'); - const templateHash = '040a6374d4c48c0db867f1d4f95c69b12d28e69c3b8a9903a1db1ec651dcf480'; + const templateHash = last(stackArtifact.stackTemplateAssetObjectUrl?.split('/')); test.equals(stackArtifact.stackTemplateAssetObjectUrl, `s3://cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}/${templateHash}`); @@ -239,3 +239,7 @@ function readAssetManifest(asm: cxapi.CloudAssembly): cxschema.AssetManifest { return JSON.parse(fs.readFileSync(manifestArtifact.file, { encoding: 'utf-8' })); } + +function last(xs?: A[]): A | undefined { + return xs ? xs[xs.length - 1] : undefined; +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/test/test.stack.ts b/packages/@aws-cdk/core/test/test.stack.ts index bb23145f5bb29..a27a992b5fdfd 100644 --- a/packages/@aws-cdk/core/test/test.stack.ts +++ b/packages/@aws-cdk/core/test/test.stack.ts @@ -2,7 +2,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import { Test } from 'nodeunit'; import { App, CfnCondition, CfnInclude, CfnOutput, CfnParameter, - CfnResource, Construct, Lazy, ScopedAws, Stack, validateString, ISynthesisSession, Tags, + CfnResource, Construct, Lazy, ScopedAws, Stack, validateString, ISynthesisSession, Tags, LegacyStackSynthesizer, DefaultStackSynthesizer, } from '../lib'; import { Intrinsic } from '../lib/private/intrinsic'; import { resolveReferences } from '../lib/private/refs'; @@ -951,6 +951,30 @@ export = { test.ok(called, 'synthesize() not called for Stack'); test.done(); }, + + 'context can be set on a stack using a LegacySynthesizer'(test: Test) { + // WHEN + const stack = new Stack(undefined, undefined, { + synthesizer: new LegacyStackSynthesizer(), + }); + stack.node.setContext('something', 'value'); + + // THEN: no exception + + test.done(); + }, + + 'context can be set on a stack using a DefaultSynthesizer'(test: Test) { + // WHEN + const stack = new Stack(undefined, undefined, { + synthesizer: new DefaultStackSynthesizer(), + }); + stack.node.setContext('something', 'value'); + + // THEN: no exception + + test.done(); + }, }; class StackWithPostProcessor extends Stack { From 562f8913dae7b77a1516a60cc1ff277ac42fb9e0 Mon Sep 17 00:00:00 2001 From: Nick Lynch Date: Wed, 9 Sep 2020 13:19:43 +0100 Subject: [PATCH 11/12] feat(rds): deprecate OracleSE and OracleSE1 engine versions (#10241) Oracle 11.x and the SE and SE1 engines are no longer supported by Oracle (and RDS). As of Sep 1, 2020, no new instances can be launched with these engines (with the license-included license type). Support for bring-your-own-license instances will be removed Oct 1. Also took the opportunity to remove deprecated usages of version-less engines from the README. See https://forums.aws.amazon.com/ann.jspa?annID=7341 for more details. fixes #9249 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-rds/README.md | 54 +++++++------------ .../@aws-cdk/aws-rds/lib/instance-engine.ts | 24 ++++++--- .../test/integ.instance.lit.expected.json | 41 +++++++------- .../aws-rds/test/integ.instance.lit.ts | 8 +-- .../@aws-cdk/aws-rds/test/test.instance.ts | 7 +-- 5 files changed, 65 insertions(+), 69 deletions(-) diff --git a/packages/@aws-cdk/aws-rds/README.md b/packages/@aws-cdk/aws-rds/README.md index 6c94f2349f18d..1a5d4165d7409 100644 --- a/packages/@aws-cdk/aws-rds/README.md +++ b/packages/@aws-cdk/aws-rds/README.md @@ -26,7 +26,7 @@ your instances will be launched privately or publicly: ```ts const cluster = new rds.DatabaseCluster(this, 'Database', { - engine: rds.DatabaseClusterEngine.AURORA, + engine: rds.DatabaseClusterEngine.auroraMysql({ version: rds.AuroraMysqlEngineVersion.VER_2_08_1 }), masterUser: { username: 'clusteradmin' }, @@ -41,22 +41,13 @@ const cluster = new rds.DatabaseCluster(this, 'Database', { }); ``` -To use a specific version of the engine -(which is recommended, in order to avoid surprise updates when RDS add support for a newer version of the engine), -use the static factory methods on `DatabaseClusterEngine`: - -```typescript -new rds.DatabaseCluster(this, 'Database', { - engine: rds.DatabaseClusterEngine.aurora({ - version: rds.AuroraEngineVersion.VER_1_17_9, // different version class for each engine type - }), - ... -}); -``` - If there isn't a constant for the exact version you want to use, all of the `Version` classes have a static `of` method that can be used to create an arbitrary version. +```ts +const customEngineVersion = rds.AuroraMysqlEngineVersion.of('5.7.mysql_aurora.2.08.1'); +``` + By default, the master password will be generated and stored in AWS Secrets Manager with auto-generated description. Your cluster will be empty by default. To add a default database upon construction, specify the @@ -65,8 +56,8 @@ Your cluster will be empty by default. To add a default database upon constructi Use `DatabaseClusterFromSnapshot` to create a cluster from a snapshot: ```ts -new DatabaseClusterFromSnapshot(stack, 'Database', { - engine: DatabaseClusterEngine.aurora({ version: AuroraEngineVersion.VER_1_22_2 }), +new rds.DatabaseClusterFromSnapshot(stack, 'Database', { + engine: rds.DatabaseClusterEngine.aurora({ version: rds.AuroraEngineVersion.VER_1_22_2 }), instanceProps: { vpc, }, @@ -82,9 +73,9 @@ your instances will be launched privately or publicly: ```ts const instance = new rds.DatabaseInstance(this, 'Instance', { - engine: rds.DatabaseInstanceEngine.ORACLE_SE1, + engine: rds.DatabaseInstanceEngine.oracleSe2({ version: rds.OracleEngineVersion.VER_19_0_0_0_2020_04_R1 }), // optional, defaults to m5.large - instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), + instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.SMALL), masterUsername: 'syscdk', vpc, vpcSubnets: { @@ -93,23 +84,14 @@ const instance = new rds.DatabaseInstance(this, 'Instance', { }); ``` -By default, the master password will be generated and stored in AWS Secrets Manager. - -To use a specific version of the engine -(which is recommended, in order to avoid surprise updates when RDS add support for a newer version of the engine), -use the static factory methods on `DatabaseInstanceEngine`: +If there isn't a constant for the exact engine version you want to use, +all of the `Version` classes have a static `of` method that can be used to create an arbitrary version. -```typescript -const instance = new rds.DatabaseInstance(this, 'Instance', { - engine: rds.DatabaseInstanceEngine.oracleSe2({ - version: rds.OracleEngineVersion.VER_19, // different version class for each engine type - }), - ... -}); +```ts +const customEngineVersion = rds.OracleEngineVersion.of('19.0.0.0.ru-2020-04.rur-2020-04.r1', '19'); ``` -If there isn't a constant for the exact version you want to use, -all of the `Version` classes have a static `of` method that can be used to create an arbitrary version. +By default, the master password will be generated and stored in AWS Secrets Manager. To use the storage auto scaling option of RDS you can specify the maximum allocated storage. This is the upper limit to which RDS can automatically scale the storage. More info can be found @@ -118,7 +100,7 @@ Example for max storage configuration: ```ts const instance = new rds.DatabaseInstance(this, 'Instance', { - engine: rds.DatabaseInstanceEngine.ORACLE_SE1, + engine: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_12_3 }), // optional, defaults to m5.large instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), masterUsername: 'syscdk', @@ -133,7 +115,7 @@ a source database respectively: ```ts new rds.DatabaseInstanceFromSnapshot(stack, 'Instance', { snapshotIdentifier: 'my-snapshot', - engine: rds.DatabaseInstanceEngine.POSTGRES, + engine: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_12_3 }), // optional, defaults to m5.large instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.LARGE), vpc, @@ -381,8 +363,8 @@ that are available for a particular Amazon RDS DB instance. const vpc: ec2.IVpc = ...; const securityGroup: ec2.ISecurityGroup = ...; new rds.OptionGroup(stack, 'Options', { - engine: DatabaseInstanceEngine.oracleSe({ - version: OracleLegacyEngineVersion.VER_11_2, + engine: rds.DatabaseInstanceEngine.oracleSe2({ + version: rds.OracleEngineVersion.VER_19, }), configurations: [ { diff --git a/packages/@aws-cdk/aws-rds/lib/instance-engine.ts b/packages/@aws-cdk/aws-rds/lib/instance-engine.ts index df715c9e0387a..5c0062aba2771 100644 --- a/packages/@aws-cdk/aws-rds/lib/instance-engine.ts +++ b/packages/@aws-cdk/aws-rds/lib/instance-engine.ts @@ -507,6 +507,8 @@ class PostgresInstanceEngine extends InstanceEngineBase { * (those returned by {@link DatabaseInstanceEngine.oracleSe} * and {@link DatabaseInstanceEngine.oracleSe1}). * Note: RDS will stop allowing creating new databases with this version in August 2020. + * + * @deprecated instances can no longer be created with these engine versions. See https://forums.aws.amazon.com/ann.jspa?annID=7341 */ export class OracleLegacyEngineVersion { /** Version "11.2" (only a major version, without a specific minor version). */ @@ -710,12 +712,15 @@ interface OracleInstanceEngineProps { /** * Properties for Oracle Standard Edition instance engines. * Used in {@link DatabaseInstanceEngine.oracleSe}. + * + * @deprecated instances can no longer be created with this engine. See https://forums.aws.amazon.com/ann.jspa?annID=7341 */ export interface OracleSeInstanceEngineProps { /** The exact version of the engine to use. */ readonly version: OracleLegacyEngineVersion; } +/** @deprecated instances can no longer be created with this engine. See https://forums.aws.amazon.com/ann.jspa?annID=7341 */ class OracleSeInstanceEngine extends OracleInstanceEngineBase { constructor(version?: OracleLegacyEngineVersion) { super({ @@ -735,12 +740,15 @@ class OracleSeInstanceEngine extends OracleInstanceEngineBase { /** * Properties for Oracle Standard Edition 1 instance engines. * Used in {@link DatabaseInstanceEngine.oracleSe1}. + * + * @deprecated instances can no longer be created with this engine. See https://forums.aws.amazon.com/ann.jspa?annID=7341 */ export interface OracleSe1InstanceEngineProps { /** The exact version of the engine to use. */ readonly version: OracleLegacyEngineVersion; } +/** @deprecated instances can no longer be created with this engine. See https://forums.aws.amazon.com/ann.jspa?annID=7341 */ class OracleSe1InstanceEngine extends OracleInstanceEngineBase { constructor(version?: OracleLegacyEngineVersion) { super({ @@ -1033,16 +1041,14 @@ export class DatabaseInstanceEngine { /** * The unversioned 'oracle-se1' instance engine. * - * @deprecated using unversioned engines is an availability risk. - * We recommend using versioned engines created using the {@link oracleSe1()} method + * @deprecated instances can no longer be created with this engine. See https://forums.aws.amazon.com/ann.jspa?annID=7341 */ public static readonly ORACLE_SE1: IInstanceEngine = new OracleSe1InstanceEngine(); /** * The unversioned 'oracle-se' instance engine. * - * @deprecated using unversioned engines is an availability risk. - * We recommend using versioned engines created using the {@link oracleSe()} method + * @deprecated instances can no longer be created with this engine. See https://forums.aws.amazon.com/ann.jspa?annID=7341 */ public static readonly ORACLE_SE: IInstanceEngine = new OracleSeInstanceEngine(); @@ -1101,12 +1107,18 @@ export class DatabaseInstanceEngine { return new PostgresInstanceEngine(props.version); } - /** Creates a new Oracle Standard Edition instance engine. */ + /** + * Creates a new Oracle Standard Edition instance engine. + * @deprecated instances can no longer be created with this engine. See https://forums.aws.amazon.com/ann.jspa?annID=7341 + */ public static oracleSe(props: OracleSeInstanceEngineProps): IInstanceEngine { return new OracleSeInstanceEngine(props.version); } - /** Creates a new Oracle Standard Edition 1 instance engine. */ + /** + * Creates a new Oracle Standard Edition 1 instance engine. + * @deprecated instances can no longer be created with this engine. See https://forums.aws.amazon.com/ann.jspa?annID=7341 + */ public static oracleSe1(props: OracleSe1InstanceEngineProps): IInstanceEngine { return new OracleSe1InstanceEngine(props.version); } 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 53c8c03e0f283..478b874b4d079 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 @@ -359,8 +359,8 @@ "ParameterGroup5E32DECB": { "Type": "AWS::RDS::DBParameterGroup", "Properties": { - "Description": "Parameter group for oracle-se1-11.2", - "Family": "oracle-se1-11.2", + "Description": "Parameter group for oracle-se2-19", + "Family": "oracle-se2-19", "Parameters": { "open_cursors": "2500" } @@ -394,11 +394,11 @@ "OptionGroupACA43DC1": { "Type": "AWS::RDS::OptionGroup", "Properties": { - "EngineName": "oracle-se1", - "MajorEngineVersion": "11.2", + "EngineName": "oracle-se2", + "MajorEngineVersion": "19", "OptionConfigurations": [ { - "OptionName": "XMLDB" + "OptionName": "LOCATOR" }, { "OptionName": "OEM", @@ -413,7 +413,7 @@ ] } ], - "OptionGroupDescription": "Option group for oracle-se1 11.2" + "OptionGroupDescription": "Option group for oracle-se2 19" } }, "InstanceSubnetGroupF2CBA54F": { @@ -644,7 +644,8 @@ "listener" ], "EnablePerformanceInsights": true, - "Engine": "oracle-se1", + "Engine": "oracle-se2", + "EngineVersion": "19.0.0.0.ru-2020-04.rur-2020-04.r1", "Iops": 1000, "LicenseModel": "bring-your-own-license", "MasterUsername": { @@ -965,9 +966,11 @@ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A": { "Type": "AWS::Lambda::Function", "Properties": { + "Handler": "index.handler", + "Runtime": "nodejs10.x", "Code": { "S3Bucket": { - "Ref": "AssetParameters11aa2ce8971716ca7c8d28d472ab5e937131e78e136d0de8f4997fb11c4de847S3Bucket46EF559D" + "Ref": "AssetParameters74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437bS3Bucket48EF98C9" }, "S3Key": { "Fn::Join": [ @@ -980,7 +983,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters11aa2ce8971716ca7c8d28d472ab5e937131e78e136d0de8f4997fb11c4de847S3VersionKey68B7BF84" + "Ref": "AssetParameters74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437bS3VersionKeyF33C73AF" } ] } @@ -993,7 +996,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters11aa2ce8971716ca7c8d28d472ab5e937131e78e136d0de8f4997fb11c4de847S3VersionKey68B7BF84" + "Ref": "AssetParameters74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437bS3VersionKeyF33C73AF" } ] } @@ -1003,14 +1006,12 @@ ] } }, - "Handler": "index.handler", "Role": { "Fn::GetAtt": [ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB", "Arn" ] - }, - "Runtime": "nodejs10.x" + } }, "DependsOn": [ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB", @@ -1108,17 +1109,17 @@ } }, "Parameters": { - "AssetParameters11aa2ce8971716ca7c8d28d472ab5e937131e78e136d0de8f4997fb11c4de847S3Bucket46EF559D": { + "AssetParameters74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437bS3Bucket48EF98C9": { "Type": "String", - "Description": "S3 bucket for asset \"11aa2ce8971716ca7c8d28d472ab5e937131e78e136d0de8f4997fb11c4de847\"" + "Description": "S3 bucket for asset \"74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437b\"" }, - "AssetParameters11aa2ce8971716ca7c8d28d472ab5e937131e78e136d0de8f4997fb11c4de847S3VersionKey68B7BF84": { + "AssetParameters74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437bS3VersionKeyF33C73AF": { "Type": "String", - "Description": "S3 key for asset version \"11aa2ce8971716ca7c8d28d472ab5e937131e78e136d0de8f4997fb11c4de847\"" + "Description": "S3 key for asset version \"74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437b\"" }, - "AssetParameters11aa2ce8971716ca7c8d28d472ab5e937131e78e136d0de8f4997fb11c4de847ArtifactHash27BA7171": { + "AssetParameters74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437bArtifactHash976CF1BD": { "Type": "String", - "Description": "Artifact hash for asset \"11aa2ce8971716ca7c8d28d472ab5e937131e78e136d0de8f4997fb11c4de847\"" + "Description": "Artifact hash for asset \"74a1cab76f5603c5e27101cb3809d8745c50f708b0f4b497ed0910eb533d437b\"" } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-rds/test/integ.instance.lit.ts b/packages/@aws-cdk/aws-rds/test/integ.instance.lit.ts index f98c65a0950f3..7f36806c35230 100644 --- a/packages/@aws-cdk/aws-rds/test/integ.instance.lit.ts +++ b/packages/@aws-cdk/aws-rds/test/integ.instance.lit.ts @@ -18,7 +18,7 @@ class DatabaseInstanceStack extends cdk.Stack { /// !show // Set open cursors with parameter group const parameterGroup = new rds.ParameterGroup(this, 'ParameterGroup', { - engine: rds.DatabaseInstanceEngine.ORACLE_SE1, + engine: rds.DatabaseInstanceEngine.oracleSe2({ version: rds.OracleEngineVersion.VER_19_0_0_0_2020_04_R1 }), parameters: { open_cursors: '2500', }, @@ -26,10 +26,10 @@ class DatabaseInstanceStack extends cdk.Stack { /// Add XMLDB and OEM with option group const optionGroup = new rds.OptionGroup(this, 'OptionGroup', { - engine: rds.DatabaseInstanceEngine.ORACLE_SE1, + engine: rds.DatabaseInstanceEngine.oracleSe2({ version: rds.OracleEngineVersion.VER_19_0_0_0_2020_04_R1 }), configurations: [ { - name: 'XMLDB', + name: 'LOCATOR', }, { name: 'OEM', @@ -44,7 +44,7 @@ class DatabaseInstanceStack extends cdk.Stack { // Database instance with production values const instance = new rds.DatabaseInstance(this, 'Instance', { - engine: rds.DatabaseInstanceEngine.ORACLE_SE1, + engine: rds.DatabaseInstanceEngine.oracleSe2({ version: rds.OracleEngineVersion.VER_19_0_0_0_2020_04_R1 }), licenseModel: rds.LicenseModel.BRING_YOUR_OWN_LICENSE, instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.MEDIUM), multiAz: true, diff --git a/packages/@aws-cdk/aws-rds/test/test.instance.ts b/packages/@aws-cdk/aws-rds/test/test.instance.ts index 3ba4d67cb1b93..074bb1438b3e2 100644 --- a/packages/@aws-cdk/aws-rds/test/test.instance.ts +++ b/packages/@aws-cdk/aws-rds/test/test.instance.ts @@ -22,7 +22,7 @@ export = { 'create a DB instance'(test: Test) { // WHEN new rds.DatabaseInstance(stack, 'Instance', { - engine: rds.DatabaseInstanceEngine.ORACLE_SE1, + engine: rds.DatabaseInstanceEngine.oracleSe2({ version: rds.OracleEngineVersion.VER_19_0_0_0_2020_04_R1 }), licenseModel: rds.LicenseModel.BRING_YOUR_OWN_LICENSE, instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.MEDIUM), multiAz: true, @@ -64,7 +64,8 @@ export = { 'listener', ], EnablePerformanceInsights: true, - Engine: 'oracle-se1', + Engine: 'oracle-se2', + EngineVersion: '19.0.0.0.ru-2020-04.rur-2020-04.r1', Iops: 1000, LicenseModel: 'bring-your-own-license', MasterUsername: { @@ -197,7 +198,7 @@ export = { 'instance with option and parameter group'(test: Test) { const optionGroup = new rds.OptionGroup(stack, 'OptionGroup', { - engine: rds.DatabaseInstanceEngine.ORACLE_SE1, + engine: rds.DatabaseInstanceEngine.oracleSe2({ version: rds.OracleEngineVersion.VER_19_0_0_0_2020_04_R1 }), configurations: [ { name: 'XMLDB', From effceb5fcbebc197ccd24419b3c7b4664714a526 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 9 Sep 2020 15:46:25 +0200 Subject: [PATCH 12/12] chore: don't have mergify delete branches (#10264) Branches are automatically deleted by GitHub itself, so Mergify doesn't have to do that. In fact, Mergify trying to do it leaves the end result of every closed PR with a failing "cross" icon because the Mergify action to delete the head branch fails. --- .mergify.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index 96cc7bbb7c21f..9be52c560afde 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -17,7 +17,6 @@ pull_request_rules: method: squash strict_method: merge commit_message: title+body - delete_head_branch: {} conditions: - base!=release - -title~=(WIP|wip) @@ -41,7 +40,6 @@ pull_request_rules: method: squash strict_method: merge commit_message: title+body - delete_head_branch: {} conditions: - base!=release - -title~=(WIP|wip) @@ -67,7 +65,6 @@ pull_request_rules: method: merge strict_method: merge commit_message: title+body - delete_head_branch: {} conditions: - -title~=(WIP|wip) - -label~=(blocked|do-not-merge) @@ -115,7 +112,6 @@ pull_request_rules: # It's not dangerous: GitHub branch protection settings prevent merging stale branches. strict: false method: squash - delete_head_branch: {} conditions: - -title~=(WIP|wip) - -label~=(blocked|do-not-merge)