diff --git a/packages/@aws-cdk/aws-lambda-destinations/test/integ.destinations.ts b/packages/@aws-cdk/aws-lambda-destinations/test/integ.destinations.ts index 61009f180e53d..5dd67661acbb8 100644 --- a/packages/@aws-cdk/aws-lambda-destinations/test/integ.destinations.ts +++ b/packages/@aws-cdk/aws-lambda-destinations/test/integ.destinations.ts @@ -73,13 +73,13 @@ const integ = new IntegTest(app, 'Destinations', { testCases: [stack], }); -integ.assert.invokeFunction({ +integ.assertions.invokeFunction({ functionName: stack.fn.functionName, invocationType: InvocationType.EVENT, payload: JSON.stringify({ status: 'OK' }), }); -const message = integ.assert.awsApiCall('SQS', 'receiveMessage', { +const message = integ.assertions.awsApiCall('SQS', 'receiveMessage', { QueueUrl: stack.queue.queueUrl, WaitTimeSeconds: 20, }); diff --git a/packages/@aws-cdk/aws-lambda/lib/function.ts b/packages/@aws-cdk/aws-lambda/lib/function.ts index 68002c22c5b4b..ba6ddcf414f82 100644 --- a/packages/@aws-cdk/aws-lambda/lib/function.ts +++ b/packages/@aws-cdk/aws-lambda/lib/function.ts @@ -668,6 +668,12 @@ export class Function extends FunctionBase { } } + if (props.description && !Token.isUnresolved(props.description)) { + if (props.description.length > 256) { + throw new Error(`Function description can not be longer than 256 characters but has ${props.description.length} characters.`); + } + } + const managedPolicies = new Array(); // the arn is in the form of - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole @@ -1115,7 +1121,7 @@ Environment variables can be marked for removal when used in Lambda@Edge by sett for (const subnetId of subnetIds) { if (publicSubnetIds.has(subnetId) && !allowPublicSubnet) { throw new Error('Lambda Functions in a public subnet can NOT access the internet. ' + - 'If you are aware of this limitation and would still like to place the function int a public subnet, set `allowPublicSubnet` to true'); + 'If you are aware of this limitation and would still like to place the function in a public subnet, set `allowPublicSubnet` to true'); } } diff --git a/packages/@aws-cdk/aws-lambda/test/function.test.ts b/packages/@aws-cdk/aws-lambda/test/function.test.ts index 78f17b5f55394..187fa061b2e19 100644 --- a/packages/@aws-cdk/aws-lambda/test/function.test.ts +++ b/packages/@aws-cdk/aws-lambda/test/function.test.ts @@ -2648,6 +2648,31 @@ describe('function', () => { }).not.toThrow(); }); + test('Error when function description is longer than 256 chars', () => { + const stack = new cdk.Stack(); + expect(() => new lambda.Function(stack, 'MyFunction', { + code: lambda.Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'index.handler', + description: 'a'.repeat(257), + })).toThrow(/Function description can not be longer than 256 characters/); + }); + + test('No error when function name is Tokenized and Unresolved', () => { + const stack = new cdk.Stack(); + expect(() => { + const realFunctionDescription = 'a'.repeat(257); + const tokenizedFunctionDescription = cdk.Token.asString(new cdk.Intrinsic(realFunctionDescription)); + + new lambda.Function(stack, 'foo', { + code: new lambda.InlineCode('foo'), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_14_X, + description: tokenizedFunctionDescription, + }); + }).not.toThrow(); + }); + describe('FunctionUrl', () => { test('addFunctionUrl creates a function url with default options', () => { // GIVEN diff --git a/packages/@aws-cdk/aws-lambda/test/integ.bundling.ts b/packages/@aws-cdk/aws-lambda/test/integ.bundling.ts index 30e28c8ef5fe7..8549134ca9e5b 100644 --- a/packages/@aws-cdk/aws-lambda/test/integ.bundling.ts +++ b/packages/@aws-cdk/aws-lambda/test/integ.bundling.ts @@ -47,10 +47,10 @@ const integ = new IntegTest(app, 'Bundling', { stackUpdateWorkflow: false, }); -const invoke = integ.assert.invokeFunction({ +const invoke = integ.assertions.invokeFunction({ functionName: stack.functionName, }); -invoke.assert(ExpectedResult.objectLike({ +invoke.expect(ExpectedResult.objectLike({ Payload: '200', })); app.synth(); diff --git a/packages/@aws-cdk/aws-s3/README.md b/packages/@aws-cdk/aws-s3/README.md index ddf1f9a729d48..c2f87f1186a3c 100644 --- a/packages/@aws-cdk/aws-s3/README.md +++ b/packages/@aws-cdk/aws-s3/README.md @@ -522,7 +522,7 @@ by deploying with CDK version `1.126.0` or later **before** switching this value ```ts const bucket = new s3.Bucket(this, 'MyBucket', { - transferAcceleration: true, + transferAcceleration: true, }); ``` @@ -530,7 +530,7 @@ To access the bucket that is enabled for Transfer Acceleration, you must use a s ```ts const bucket = new s3.Bucket(this, 'MyBucket', { - transferAcceleration: true, + transferAcceleration: true, }); bucket.transferAccelerationUrlForObject('objectname'); ``` @@ -540,14 +540,14 @@ bucket.transferAccelerationUrlForObject('objectname'); [Intelligent Tiering](https://docs.aws.amazon.com/AmazonS3/latest/userguide/intelligent-tiering.html) can be configured to automatically move files to glacier: ```ts - new s3.Bucket(this, 'MyBucket', { - intelligentTieringConfigurations: [{ - name: 'foo', - prefix: 'folder/name', - archiveAccessTierTime: cdk.Duration.days(90), - deepArchiveAccessTierTime: cdk.Duration.days(180), - tags: [{key: 'tagname', value: 'tagvalue'}] - }], +new s3.Bucket(this, 'MyBucket', { + intelligentTieringConfigurations: [{ + name: 'foo', + prefix: 'folder/name', + archiveAccessTierTime: cdk.Duration.days(90), + deepArchiveAccessTierTime: cdk.Duration.days(180), + tags: [{key: 'tagname', value: 'tagvalue'}] + }], }); ``` diff --git a/packages/@aws-cdk/aws-servicecatalog/.gitignore b/packages/@aws-cdk/aws-servicecatalog/.gitignore index 6d05bba61dfa7..7c868a499059a 100644 --- a/packages/@aws-cdk/aws-servicecatalog/.gitignore +++ b/packages/@aws-cdk/aws-servicecatalog/.gitignore @@ -3,6 +3,7 @@ *.d.ts tsconfig.json node_modules +product-stack-snapshots *.generated.ts dist .jsii diff --git a/packages/@aws-cdk/aws-servicecatalog/README.md b/packages/@aws-cdk/aws-servicecatalog/README.md index b589408b01210..1d37e5deb615c 100644 --- a/packages/@aws-cdk/aws-servicecatalog/README.md +++ b/packages/@aws-cdk/aws-servicecatalog/README.md @@ -22,6 +22,7 @@ enables organizations to create and manage catalogs of products for their end us - [Product](#product) - [Creating a product from a local asset](#creating-a-product-from-local-asset) - [Creating a product from a stack](#creating-a-product-from-a-stack) + - [Creating a Product from a stack with a history of previous versions](#creating-a-product-from-a-stack-with-a-history-of-all-previous-versions) - [Adding a product to a portfolio](#adding-a-product-to-a-portfolio) - [TagOptions](#tag-options) - [Constraints](#constraints) @@ -184,6 +185,105 @@ const product = new servicecatalog.CloudFormationProduct(this, 'Product', { }); ``` +### Creating a Product from a stack with a history of previous versions + +The default behavior of Service Catalog is to overwrite each product version upon deployment. +This applies to Product Stacks as well, where only the latest changes to your Product Stack will +be deployed. +To keep a history of the revisions of a ProductStack available in Service Catalog, +you would need to define a ProductStack for each historical copy. + +You can instead create a `ProductStackHistory` to maintain snapshots of all previous versions. +The `ProductStackHistory` can be created by passing the base `productStack`, +a `currentVersionName` for your current version and a `locked` boolean. +The `locked` boolean which when set to true will prevent your `currentVersionName` +from being overwritten when there is an existing snapshot for that version. + +```ts +import * as s3 from '@aws-cdk/aws-s3'; +import * as cdk from '@aws-cdk/core'; + +class S3BucketProduct extends servicecatalog.ProductStack { + constructor(scope: cdk.Construct, id: string) { + super(scope, id); + + new s3.Bucket(this, 'BucketProduct'); + } +} + +const productStackHistory = new servicecatalog.ProductStackHistory(this, 'ProductStackHistory', { + productStack: new S3BucketProduct(this, 'S3BucketProduct'), + currentVersionName: 'v1', + currentVersionLocked: true +}); +``` + +We can deploy the current version `v1` by using `productStackHistory.currentVersion()` + +```ts +import * as s3 from '@aws-cdk/aws-s3'; +import * as cdk from '@aws-cdk/core'; + +class S3BucketProduct extends servicecatalog.ProductStack { + constructor(scope: cdk.Construct, id: string) { + super(scope, id); + + new s3.Bucket(this, 'BucketProductV2'); + } +} + +const productStackHistory = new servicecatalog.ProductStackHistory(this, 'ProductStackHistory', { + productStack: new S3BucketProduct(this, 'S3BucketProduct'), + currentVersionName: 'v2', + currentVersionLocked: true +}); + +const product = new servicecatalog.CloudFormationProduct(this, 'MyFirstProduct', { + productName: "My Product", + owner: "Product Owner", + productVersions: [ + productStackHistory.currentVersion(), + ], +}); +``` + +Using `ProductStackHistory` all deployed templates for the ProductStack will be written to disk, +so that they will still be available in the future as the definition of the `ProductStack` subclass changes over time. +**It is very important** that you commit these old versions to source control as these versions +determine whether a version has already been deployed and can also be deployed themselves. + +After using `ProductStackHistory` to deploy version `v1` of your `ProductStack`, we +make changes to the `ProductStack` and update the `currentVersionName` to `v2`. +We still want our `v1` version to still be deployed, so we reference it by calling `productStackHistory.versionFromSnapshot('v1')`. + +```ts +import * as s3 from '@aws-cdk/aws-s3'; +import * as cdk from '@aws-cdk/core'; + +class S3BucketProduct extends servicecatalog.ProductStack { + constructor(scope: cdk.Construct, id: string) { + super(scope, id); + + new s3.Bucket(this, 'BucketProductV2'); + } +} + +const productStackHistory = new servicecatalog.ProductStackHistory(this, 'ProductStackHistory', { + productStack: new S3BucketProduct(this, 'S3BucketProduct'), + currentVersionName: 'v2', + currentVersionLocked: true +}); + +const product = new servicecatalog.CloudFormationProduct(this, 'MyFirstProduct', { + productName: "My Product", + owner: "Product Owner", + productVersions: [ + productStackHistory.currentVersion(), + productStackHistory.versionFromSnapshot('v1') + ], +}); +``` + ### Adding a product to a portfolio You add products to a portfolio to organize and distribute your catalog at scale. Adding a product to a portfolio creates an association, diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts b/packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts index 4086db6655fda..b9d3830807ff5 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts @@ -102,8 +102,8 @@ class CloudFormationAssetTemplate extends CloudFormationTemplate { */ class CloudFormationProductStackTemplate extends CloudFormationTemplate { /** - * @param stack A service catalog product stack. - */ + * @param productStack A service catalog product stack. + */ constructor(public readonly productStack: ProductStack) { super(); } diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/common.ts b/packages/@aws-cdk/aws-servicecatalog/lib/common.ts index 4f207be273867..50a921bac658b 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/common.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/common.ts @@ -1,3 +1,8 @@ +/** + * Constant for the default directory to store ProductStack snapshots. + */ +export const DEFAULT_PRODUCT_STACK_SNAPSHOT_DIRECTORY = 'product-stack-snapshots'; + /** * The language code. * Used for error and logging messages for end users. @@ -18,4 +23,4 @@ export enum MessageLanguage { * Chinese */ ZH = 'zh' -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/index.ts b/packages/@aws-cdk/aws-servicecatalog/lib/index.ts index cc26e880fdd2e..334177bca33a2 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/index.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/index.ts @@ -4,6 +4,7 @@ export * from './cloudformation-template'; export * from './portfolio'; export * from './product'; export * from './product-stack'; +export * from './product-stack-history'; export * from './tag-options'; // AWS::ServiceCatalog CloudFormation Resources: diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/product-stack-history.ts b/packages/@aws-cdk/aws-servicecatalog/lib/product-stack-history.ts new file mode 100644 index 0000000000000..3fea4fea668d0 --- /dev/null +++ b/packages/@aws-cdk/aws-servicecatalog/lib/product-stack-history.ts @@ -0,0 +1,118 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { Names } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CloudFormationTemplate } from './cloudformation-template'; +import { DEFAULT_PRODUCT_STACK_SNAPSHOT_DIRECTORY } from './common'; +import { CloudFormationProductVersion } from './product'; +import { ProductStack } from './product-stack'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Properties for a ProductStackHistory. + */ +export interface ProductStackHistoryProps { + /** + * The ProductStack whose history will be retained as a snapshot + */ + readonly productStack: ProductStack; + + /** + * The current version name of the ProductStack. + */ + readonly currentVersionName: string; + + /** + * If this is set to true, the ProductStack will not be overwritten if a snapshot is found for the currentVersionName. + */ + readonly currentVersionLocked: boolean + + /** + * The description of the product version + * @default - No description provided + */ + readonly description?: string; + + /** + * Whether the specified product template will be validated by CloudFormation. + * If turned off, an invalid template configuration can be stored. + * @default true + */ + readonly validateTemplate?: boolean; + + /** + * The directory where template snapshots will be stored + * @default 'product-stack-snapshots' + */ + readonly directory?: string +} + +/** + * A Construct that contains a Service Catalog product stack with its previous deployments maintained. + */ +export class ProductStackHistory extends CoreConstruct { + private readonly props: ProductStackHistoryProps + constructor(scope: Construct, id: string, props: ProductStackHistoryProps) { + super(scope, id); + props.productStack._setParentProductStackHistory(this); + this.props = props; + } + + /** + * Retains product stack template as a snapshot when deployed and + * retrieves a CloudFormationProductVersion for the current product version. + */ + public currentVersion() : CloudFormationProductVersion { + return { + cloudFormationTemplate: CloudFormationTemplate.fromProductStack(this.props.productStack), + productVersionName: this.props.currentVersionName, + description: this.props.description, + }; + } + + /** + * Retrieves a CloudFormationProductVersion from a previously deployed productVersionName. + */ + public versionFromSnapshot(productVersionName: string) : CloudFormationProductVersion { + const productStackSnapshotDirectory = this.props.directory || DEFAULT_PRODUCT_STACK_SNAPSHOT_DIRECTORY; + const templateFileKey = `${Names.uniqueId(this)}.${this.props.productStack.artifactId}.${productVersionName}.product.template.json`; + const templateFilePath = path.join(productStackSnapshotDirectory, templateFileKey); + if (!fs.existsSync(templateFilePath)) { + throw new Error(`Template ${templateFileKey} cannot be found in ${productStackSnapshotDirectory}`); + } + return { + cloudFormationTemplate: CloudFormationTemplate.fromAsset(templateFilePath), + productVersionName: productVersionName, + description: this.props.description, + }; + } + + /** + * Writes current template generated from Product Stack to a snapshot directory. + * + * @internal + */ + public _writeTemplateToSnapshot(cfn: string) { + const productStackSnapshotDirectory = this.props.directory || DEFAULT_PRODUCT_STACK_SNAPSHOT_DIRECTORY; + if (!fs.existsSync(productStackSnapshotDirectory)) { + fs.mkdirSync(productStackSnapshotDirectory); + } + const templateFileKey = `${Names.uniqueId(this)}.${this.props.productStack.artifactId}.${this.props.currentVersionName}.product.template.json`; + const templateFilePath = path.join(productStackSnapshotDirectory, templateFileKey); + if (fs.existsSync(templateFilePath)) { + const previousCfn = fs.readFileSync(templateFilePath).toString(); + if (previousCfn !== cfn && this.props.currentVersionLocked) { + throw new Error(`Template has changed for ProductStack Version ${this.props.currentVersionName}. + ${this.props.currentVersionName} already exist in ${productStackSnapshotDirectory}. + Since locked has been set to ${this.props.currentVersionLocked}, + Either update the currentVersionName to deploy a new version or deploy the existing ProductStack snapshot. + If ${this.props.currentVersionName} was unintentionally synthesized and not deployed, + delete the corresponding version from ${productStackSnapshotDirectory} and redeploy.`); + } + } + fs.writeFileSync(templateFilePath, cfn); + } +} diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts b/packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts index b96224a8b2c60..5e6d1d64a15eb 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as cdk from '@aws-cdk/core'; import { ProductStackSynthesizer } from './private/product-stack-synthesizer'; +import { ProductStackHistory } from './product-stack-history'; // keep this import separate from other imports to reduce chance for merge conflicts with v2-main // eslint-disable-next-line no-duplicate-imports, import/order @@ -19,6 +20,7 @@ import { Construct } from 'constructs'; */ export class ProductStack extends cdk.Stack { public readonly templateFile: string; + private _parentProductStackHistory?: ProductStackHistory; private _templateUrl?: string; private _parentStack: cdk.Stack; @@ -33,6 +35,15 @@ export class ProductStack extends cdk.Stack { this.templateFile = `${cdk.Names.uniqueId(this)}.product.template.json`; } + /** + * Set the parent product stack history + * + * @internal + */ + public _setParentProductStackHistory(parentProductStackHistory: ProductStackHistory) { + return this._parentProductStackHistory = parentProductStackHistory; + } + /** * Fetch the template URL. * @@ -60,6 +71,10 @@ export class ProductStack extends cdk.Stack { fileName: this.templateFile, }).httpUrl; + if (this._parentProductStackHistory) { + this._parentProductStackHistory._writeTemplateToSnapshot(cfn); + } + fs.writeFileSync(path.join(session.assembly.outdir, this.templateFile), cfn); } } diff --git a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts index 22429b3ddbf83..1bd5d03ea260d 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import * as sns from '@aws-cdk/aws-sns'; import * as cdk from '@aws-cdk/core'; import * as servicecatalog from '../lib'; +import { ProductStackHistory } from '../lib'; const app = new cdk.App(); const stack = new cdk.Stack(app, 'integ-servicecatalog-product'); @@ -14,6 +15,12 @@ class TestProductStack extends servicecatalog.ProductStack { } } +const productStackHistory = new ProductStackHistory(stack, 'ProductStackHistory', { + productStack: new TestProductStack(stack, 'SNSTopicProduct3'), + currentVersionName: 'v1', + currentVersionLocked: true, +}); + const product = new servicecatalog.CloudFormationProduct(stack, 'TestProduct', { productName: 'testProduct', owner: 'testOwner', @@ -35,6 +42,7 @@ const product = new servicecatalog.CloudFormationProduct(stack, 'TestProduct', { { cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new TestProductStack(stack, 'SNSTopicProduct2')), }, + productStackHistory.currentVersion(), ], }); diff --git a/packages/@aws-cdk/aws-servicecatalog/test/portfolio.integ.snapshot/cdk.out b/packages/@aws-cdk/aws-servicecatalog/test/portfolio.integ.snapshot/cdk.out index 90bef2e09ad39..2efc89439fab8 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/portfolio.integ.snapshot/cdk.out +++ b/packages/@aws-cdk/aws-servicecatalog/test/portfolio.integ.snapshot/cdk.out @@ -1 +1 @@ -{"version":"17.0.0"} \ No newline at end of file +{"version":"18.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-servicecatalog/test/portfolio.integ.snapshot/integ.json b/packages/@aws-cdk/aws-servicecatalog/test/portfolio.integ.snapshot/integ.json index ab86cddb1a16a..09a045531f14f 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/portfolio.integ.snapshot/integ.json +++ b/packages/@aws-cdk/aws-servicecatalog/test/portfolio.integ.snapshot/integ.json @@ -1,7 +1,7 @@ { "version": "18.0.0", "testCases": { - "aws-servicecatalog/test/integ.portfolio": { + "integ.portfolio": { "stacks": [ "integ-servicecatalog-portfolio" ], diff --git a/packages/@aws-cdk/aws-servicecatalog/test/portfolio.integ.snapshot/manifest.json b/packages/@aws-cdk/aws-servicecatalog/test/portfolio.integ.snapshot/manifest.json index 881a78a773602..9abbe12a0c3aa 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/portfolio.integ.snapshot/manifest.json +++ b/packages/@aws-cdk/aws-servicecatalog/test/portfolio.integ.snapshot/manifest.json @@ -1,5 +1,5 @@ { - "version": "17.0.0", + "version": "18.0.0", "artifacts": { "Tree": { "type": "cdk:tree", diff --git a/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/cdk.out b/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/cdk.out index 90bef2e09ad39..2efc89439fab8 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/cdk.out +++ b/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/cdk.out @@ -1 +1 @@ -{"version":"17.0.0"} \ No newline at end of file +{"version":"18.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/integ-servicecatalog-product.template.json b/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/integ-servicecatalog-product.template.json index 9c84dac237948..11578cd12197b 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/integ-servicecatalog-product.template.json +++ b/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/integ-servicecatalog-product.template.json @@ -215,6 +215,58 @@ ] } } + }, + { + "DisableTemplateValidation": false, + "Info": { + "LoadTemplateFromURL": { + "Fn::Join": [ + "", + [ + "https://s3.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "AssetParametersdd2d087eeb6ede1d2a9166639ccbde7bd1b10eef9ba2b4cb3d9855faa4fe8c1fS3BucketB4751C98" + }, + "/", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersdd2d087eeb6ede1d2a9166639ccbde7bd1b10eef9ba2b4cb3d9855faa4fe8c1fS3VersionKeyEB38C6F9" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersdd2d087eeb6ede1d2a9166639ccbde7bd1b10eef9ba2b4cb3d9855faa4fe8c1fS3VersionKeyEB38C6F9" + } + ] + } + ] + } + ] + ] + } + }, + "Name": "v1" } ] } diff --git a/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/integ.json b/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/integ.json index dea60ee2c2037..5403ef9e941d5 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/integ.json +++ b/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/integ.json @@ -1,7 +1,7 @@ { "version": "18.0.0", "testCases": { - "aws-servicecatalog/test/integ.product": { + "integ.product": { "stacks": [ "integ-servicecatalog-product" ], diff --git a/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/integservicecatalogproductSNSTopicProduct3B51CF591.product.template.json b/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/integservicecatalogproductSNSTopicProduct3B51CF591.product.template.json new file mode 100644 index 0000000000000..2f2f4704a22ad --- /dev/null +++ b/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/integservicecatalogproductSNSTopicProduct3B51CF591.product.template.json @@ -0,0 +1,7 @@ +{ + "Resources": { + "TopicProductD757E287": { + "Type": "AWS::SNS::Topic" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/manifest.json b/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/manifest.json index 42433db5fab13..c8d093eaf8a0c 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/manifest.json +++ b/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/manifest.json @@ -1,5 +1,5 @@ { - "version": "17.0.0", + "version": "18.0.0", "artifacts": { "Tree": { "type": "cdk:tree", @@ -43,7 +43,7 @@ { "type": "aws:cdk:asset", "data": { - "path": "integservicecatalogproductSNSTopicProduct1B8D03934.product.template.json", + "path": "integservicecatalogproductSNSTopicProduct3B51CF591.product.template.json", "id": "dd2d087eeb6ede1d2a9166639ccbde7bd1b10eef9ba2b4cb3d9855faa4fe8c1f", "packaging": "file", "sourceHash": "dd2d087eeb6ede1d2a9166639ccbde7bd1b10eef9ba2b4cb3d9855faa4fe8c1f", diff --git a/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/tree.json b/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/tree.json index 77bff059f780b..7916b0fe8b481 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/tree.json +++ b/packages/@aws-cdk/aws-servicecatalog/test/product.integ.snapshot/tree.json @@ -16,6 +16,46 @@ "id": "integ-servicecatalog-product", "path": "integ-servicecatalog-product", "children": { + "SNSTopicProduct3": { + "id": "SNSTopicProduct3", + "path": "integ-servicecatalog-product/SNSTopicProduct3", + "children": { + "TopicProduct": { + "id": "TopicProduct", + "path": "integ-servicecatalog-product/SNSTopicProduct3/TopicProduct", + "children": { + "Resource": { + "id": "Resource", + "path": "integ-servicecatalog-product/SNSTopicProduct3/TopicProduct/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::SNS::Topic", + "aws:cdk:cloudformation:props": {} + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-sns.CfnTopic", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-sns.Topic", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-servicecatalog.ProductStack", + "version": "0.0.0" + } + }, + "ProductStackHistory": { + "id": "ProductStackHistory", + "path": "integ-servicecatalog-product/ProductStackHistory", + "constructInfo": { + "fqn": "@aws-cdk/aws-servicecatalog.ProductStackHistory", + "version": "0.0.0" + } + }, "SNSTopicProduct1": { "id": "SNSTopicProduct1", "path": "integ-servicecatalog-product/SNSTopicProduct1", @@ -84,13 +124,13 @@ "id": "TestProduct", "path": "integ-servicecatalog-product/TestProduct", "children": { - "Template36f44329de2f": { - "id": "Template36f44329de2f", - "path": "integ-servicecatalog-product/TestProduct/Template36f44329de2f", + "Template70cd971a7303": { + "id": "Template70cd971a7303", + "path": "integ-servicecatalog-product/TestProduct/Template70cd971a7303", "children": { "Stage": { "id": "Stage", - "path": "integ-servicecatalog-product/TestProduct/Template36f44329de2f/Stage", + "path": "integ-servicecatalog-product/TestProduct/Template70cd971a7303/Stage", "constructInfo": { "fqn": "@aws-cdk/core.AssetStaging", "version": "0.0.0" @@ -98,7 +138,7 @@ }, "AssetBucket": { "id": "AssetBucket", - "path": "integ-servicecatalog-product/TestProduct/Template36f44329de2f/AssetBucket", + "path": "integ-servicecatalog-product/TestProduct/Template70cd971a7303/AssetBucket", "constructInfo": { "fqn": "@aws-cdk/aws-s3.BucketBase", "version": "0.0.0" @@ -110,13 +150,13 @@ "version": "0.0.0" } }, - "Template3253587567ef": { - "id": "Template3253587567ef", - "path": "integ-servicecatalog-product/TestProduct/Template3253587567ef", + "Template3b1445e4244b": { + "id": "Template3b1445e4244b", + "path": "integ-servicecatalog-product/TestProduct/Template3b1445e4244b", "children": { "Stage": { "id": "Stage", - "path": "integ-servicecatalog-product/TestProduct/Template3253587567ef/Stage", + "path": "integ-servicecatalog-product/TestProduct/Template3b1445e4244b/Stage", "constructInfo": { "fqn": "@aws-cdk/core.AssetStaging", "version": "0.0.0" @@ -124,7 +164,7 @@ }, "AssetBucket": { "id": "AssetBucket", - "path": "integ-servicecatalog-product/TestProduct/Template3253587567ef/AssetBucket", + "path": "integ-servicecatalog-product/TestProduct/Template3b1445e4244b/AssetBucket", "constructInfo": { "fqn": "@aws-cdk/aws-s3.BucketBase", "version": "0.0.0" @@ -260,6 +300,11 @@ { "disableTemplateValidation": false, "info": {} + }, + { + "name": "v1", + "disableTemplateValidation": false, + "info": {} } ] } diff --git a/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts b/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts index d27e18a1c3358..20cf4ef1f8fb9 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts +++ b/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts @@ -1,8 +1,11 @@ +import * as fs from 'fs'; import * as path from 'path'; import { Match, Template } from '@aws-cdk/assertions'; import * as sns from '@aws-cdk/aws-sns'; import * as cdk from '@aws-cdk/core'; import * as servicecatalog from '../lib'; +import { DEFAULT_PRODUCT_STACK_SNAPSHOT_DIRECTORY } from '../lib'; +import { ProductStackHistory } from '../lib/product-stack-history'; /* eslint-disable quote-props */ describe('Product', () => { @@ -189,6 +192,130 @@ describe('Product', () => { expect(assembly.stacks[0].assets.length).toBe(1); }), + test('product test from product stack history', () => { + const productStack = new servicecatalog.ProductStack(stack, 'ProductStack'); + + const productStackHistory = new ProductStackHistory(stack, 'MyProductStackHistory', { + productStack: productStack, + currentVersionName: 'v1', + currentVersionLocked: false, + }); + + new sns.Topic(productStack, 'SNSTopicProductStack'); + + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + productStackHistory.currentVersion(), + ], + }); + + const assembly = app.synth(); + expect(assembly.artifacts.length).toEqual(2); + expect(assembly.stacks[0].assets.length).toBe(1); + expect(assembly.stacks[0].assets[0].path).toEqual('ProductStack.product.template.json'); + + const expectedTemplateFileKey = 'MyProductStackHistory.ProductStack.v1.product.template.json'; + const snapshotExists = fs.existsSync(path.join(DEFAULT_PRODUCT_STACK_SNAPSHOT_DIRECTORY, expectedTemplateFileKey)); + expect(snapshotExists).toBe(true); + }), + + test('fails product test from product stack when template changes and locked', () => { + const productStack = new servicecatalog.ProductStack(stack, 'ProductStack'); + + const productStackHistory = new ProductStackHistory(stack, 'MyProductStackHistory', { + productStack: productStack, + currentVersionName: 'v1', + currentVersionLocked: true, + }); + + new sns.Topic(productStack, 'SNSTopicProductStack2'); + + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + productStackHistory.currentVersion(), + ], + }); + expect(() => { + app.synth(); + }).toThrowError('Template has changed for ProductStack Version v1'); + }), + + test('product test from product stack history when template changes and unlocked', () => { + const productStack = new servicecatalog.ProductStack(stack, 'ProductStack'); + + const productStackHistory = new ProductStackHistory(stack, 'MyProductStackHistory', { + productStack: productStack, + currentVersionName: 'v1', + currentVersionLocked: false, + }); + + new sns.Topic(productStack, 'SNSTopicProductStack2'); + + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + productStackHistory.currentVersion(), + ], + }); + + const assembly = app.synth(); + expect(assembly.artifacts.length).toEqual(2); + expect(assembly.stacks[0].assets.length).toBe(1); + expect(assembly.stacks[0].assets[0].path).toEqual('ProductStack.product.template.json'); + + const expectedTemplateFileKey = 'MyProductStackHistory.ProductStack.v1.product.template.json'; + const snapshotExists = fs.existsSync(path.join(DEFAULT_PRODUCT_STACK_SNAPSHOT_DIRECTORY, expectedTemplateFileKey)); + expect(snapshotExists).toBe(true); + }), + + test('product test from product stack history snapshot', () => { + const productStack = new servicecatalog.ProductStack(stack, 'ProductStack'); + + const productStackHistory = new ProductStackHistory(stack, 'MyProductStackHistory', { + productStack: productStack, + currentVersionName: 'v2', + currentVersionLocked: false, + }); + + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + productStackHistory.versionFromSnapshot('v1'), + ], + }); + + const assembly = app.synth(); + expect(assembly.artifacts.length).toEqual(2); + expect(assembly.stacks[0].assets.length).toBe(2); + expect(assembly.stacks[0].assets[0].path).toEqual('asset.434625edc7e017d93f388b5f116c2ebcf11a38457cfb89a9b00d4e551c0bf731.json'); + }), + + test('fails product from product stack history snapshot not found', () => { + const productStack = new servicecatalog.ProductStack(stack, 'ProductStack'); + + const productStackHistory = new ProductStackHistory(stack, 'MyProductStackHistory', { + productStack: productStack, + currentVersionName: 'v2', + currentVersionLocked: false, + }); + + expect(() => { + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + productStackHistory.versionFromSnapshot('v3'), + ], + }); + }).toThrowError('Template MyProductStackHistory.ProductStack.v3.product.template.json cannot be found in product-stack-snapshots'); + }), + test('product test from multiple sources', () => { new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { productName: 'testProduct', diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/eventbridge/integ.put-events.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/test/eventbridge/integ.put-events.ts index 1c6ee34a3341b..d72ecfb494415 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/test/eventbridge/integ.put-events.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/eventbridge/integ.put-events.ts @@ -46,17 +46,17 @@ const testCase = new IntegTest(app, 'PutEvents', { }); // Start an execution -const start = testCase.assert.awsApiCall('StepFunctions', 'startExecution', { +const start = testCase.assertions.awsApiCall('StepFunctions', 'startExecution', { stateMachineArn: sm.stateMachineArn, }); // describe the results of the execution -const describe = testCase.assert.awsApiCall('StepFunctions', 'describeExecution', { +const describe = testCase.assertions.awsApiCall('StepFunctions', 'describeExecution', { executionArn: start.getAttString('executionArn'), }); // assert the results -describe.assert(ExpectedResult.objectLike({ +describe.expect(ExpectedResult.objectLike({ status: 'SUCCEEDED', })); diff --git a/packages/@aws-cdk/core/lib/nested-stack.ts b/packages/@aws-cdk/core/lib/nested-stack.ts index e1248d69888aa..4859ae0894489 100644 --- a/packages/@aws-cdk/core/lib/nested-stack.ts +++ b/packages/@aws-cdk/core/lib/nested-stack.ts @@ -140,6 +140,7 @@ export class NestedStack extends Stack { this.resource.applyRemovalPolicy(props.removalPolicy ?? RemovalPolicy.DESTROY); this.nestedStackResource = this.resource; + this.node.defaultChild = this.resource; // context-aware stack name: if resolved from within this stack, return AWS::StackName // if resolved from the outer stack, use the { Ref } of the AWS::CloudFormation::Stack resource diff --git a/packages/@aws-cdk/core/test/nested-stack.test.ts b/packages/@aws-cdk/core/test/nested-stack.test.ts new file mode 100644 index 0000000000000..b1508e8bf148f --- /dev/null +++ b/packages/@aws-cdk/core/test/nested-stack.test.ts @@ -0,0 +1,25 @@ +import { + Stack, NestedStack, CfnStack, +} from '../lib'; +import { toCloudFormation } from './util'; + +describe('nested-stack', () => { + test('a nested-stack has a defaultChild', () => { + const stack = new Stack(); + var nestedStack = new NestedStack(stack, 'MyNestedStack'); + var cfn_nestedStack = (nestedStack.node.defaultChild) as CfnStack; + cfn_nestedStack.addPropertyOverride('TemplateURL', 'http://my-url.com'); + expect(toCloudFormation(stack)).toEqual({ + Resources: { + MyNestedStackNestedStackMyNestedStackNestedStackResource9C617903: { + DeletionPolicy: 'Delete', + Properties: { + TemplateURL: 'http://my-url.com', + }, + Type: 'AWS::CloudFormation::Stack', + UpdateReplacePolicy: 'Delete', + }, + }, + }); + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/integ-tests/README.md b/packages/@aws-cdk/integ-tests/README.md index 0e8fc9b1ca501..414f21e5a61fd 100644 --- a/packages/@aws-cdk/integ-tests/README.md +++ b/packages/@aws-cdk/integ-tests/README.md @@ -177,41 +177,50 @@ new IntegTest(app, 'Integ', { testCases: [stackUnderTest, testCaseWithAssets] }) This library also provides a utility to make assertions against the infrastructure that the integration test deploys. -The easiest way to do this is to create a `TestCase` and then access the `DeployAssert` that is automatically created. +There are two main scenarios in which assertions are created. + +- Part of an integration test using `integ-runner` + +In this case you would create an integration test using the `IntegTest` construct and then make assertions using the `assert` property. +You should **not** utilize the assertion constructs directly, but should instead use the `methods` on `IntegTest.assert`. ```ts declare const app: App; declare const stack: Stack; const integ = new IntegTest(app, 'Integ', { testCases: [stack] }); -integ.assert.awsApiCall('S3', 'getObject'); +integ.assertions.awsApiCall('S3', 'getObject'); ``` -### DeployAssert - -Assertions are created by using the `DeployAssert` construct. This construct creates it's own `Stack` separate from -any stacks that you create as part of your integration tests. This `Stack` is treated differently from other stacks -by the `integ-runner` tool. For example, this stack will not be diffed by the `integ-runner`. +- Part of a normal CDK deployment -Any assertions that you create should be created in the scope of `DeployAssert`. For example, +In this case you may be using assertions as part of a normal CDK deployment in order to make an assertion on the infrastructure +before the deployment is considered successful. In this case you can utilize the assertions constructs directly. ```ts -declare const app: App; +declare const myAppStack: Stack; -const assert = new DeployAssert(app); -new AwsApiCall(assert, 'GetObject', { +new AwsApiCall(myAppStack, 'GetObject', { service: 'S3', api: 'getObject', }); ``` +### DeployAssert + +Assertions are created by using the `DeployAssert` construct. This construct creates it's own `Stack` separate from +any stacks that you create as part of your integration tests. This `Stack` is treated differently from other stacks +by the `integ-runner` tool. For example, this stack will not be diffed by the `integ-runner`. + `DeployAssert` also provides utilities to register your own assertions. ```ts declare const myCustomResource: CustomResource; +declare const stack: Stack; declare const app: App; -const assert = new DeployAssert(app); -assert.assert( + +const integ = new IntegTest(app, 'Integ', { testCases: [stack] }); +integ.assertions.expect( 'CustomAssertion', ExpectedResult.objectLike({ foo: 'bar' }), ActualResult.fromCustomResource(myCustomResource, 'data'), @@ -228,12 +237,12 @@ AWS API call to receive some data. This library does this by utilizing CloudForm which means that CloudFormation will call out to a Lambda Function which will use the AWS JavaScript SDK to make the API call. -This can be done by using the class directory: +This can be done by using the class directory (in the case of a normal deployment): ```ts -declare const assert: DeployAssert; +declare const stack: Stack; -new AwsApiCall(assert, 'MyAssertion', { +new AwsApiCall(stack, 'MyAssertion', { service: 'SQS', api: 'receiveMessage', parameters: { @@ -242,12 +251,15 @@ new AwsApiCall(assert, 'MyAssertion', { }); ``` -Or by using the `awsApiCall` method on `DeployAssert`: +Or by using the `awsApiCall` method on `DeployAssert` (when writing integration tests): ```ts declare const app: App; -const assert = new DeployAssert(app); -assert.awsApiCall('SQS', 'receiveMessage', { +declare const stack: Stack; +const integ = new IntegTest(app, 'Integ', { + testCases: [stack], +}); +integ.assertions.awsApiCall('SQS', 'receiveMessage', { QueueUrl: 'url', }); ``` @@ -270,32 +282,29 @@ const integ = new IntegTest(app, 'Integ', { testCases: [stack], }); -integ.assert.invokeFunction({ +integ.assertions.invokeFunction({ functionName: fn.functionName, invocationType: InvocationType.EVENT, payload: JSON.stringify({ status: 'OK' }), }); -const message = integ.assert.awsApiCall('SQS', 'receiveMessage', { +const message = integ.assertions.awsApiCall('SQS', 'receiveMessage', { QueueUrl: queue.queueUrl, WaitTimeSeconds: 20, }); -new EqualsAssertion(integ.assert, 'ReceiveMessage', { - actual: ActualResult.fromAwsApiCall(message, 'Messages.0.Body'), - expected: ExpectedResult.objectLike({ - requestContext: { - condition: 'Success', - }, - requestPayload: { - status: 'OK', - }, - responseContext: { - statusCode: 200, - }, - responsePayload: 'success', - }), -}); +message.assertAtPath('Messages.0.Body', ExpectedResult.objectLike({ + requestContext: { + condition: 'Success', + }, + requestPayload: { + status: 'OK', + }, + responseContext: { + statusCode: 200, + }, + responsePayload: 'success', +})); ``` #### Match @@ -305,9 +314,8 @@ can be used to construct the `ExpectedResult`. ```ts declare const message: AwsApiCall; -declare const assert: DeployAssert; -message.assert(ExpectedResult.objectLike({ +message.expect(ExpectedResult.objectLike({ Messages: Match.arrayWith([ { Body: { @@ -336,10 +344,10 @@ const integ = new IntegTest(app, 'IntegTest', { testCases: [stack], }); -const invoke = integ.assert.invokeFunction({ +const invoke = integ.assertions.invokeFunction({ functionName: lambdaFunction.functionName, }); -invoke.assert(ExpectedResult.objectLike({ +invoke.expect(ExpectedResult.objectLike({ Payload: '200', })); ``` @@ -359,17 +367,17 @@ const testCase = new IntegTest(app, 'IntegTest', { }); // Start an execution -const start = testCase.assert.awsApiCall('StepFunctions', 'startExecution', { +const start = testCase.assertions.awsApiCall('StepFunctions', 'startExecution', { stateMachineArn: sm.stateMachineArn, }); // describe the results of the execution -const describe = testCase.assert.awsApiCall('StepFunctions', 'describeExecution', { +const describe = testCase.assertions.awsApiCall('StepFunctions', 'describeExecution', { executionArn: start.getAttString('executionArn'), }); // assert the results -describe.assert(ExpectedResult.objectLike({ +describe.expect(ExpectedResult.objectLike({ status: 'SUCCEEDED', })); ``` diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/common.ts b/packages/@aws-cdk/integ-tests/lib/assertions/common.ts index 6e4fadf5a0388..6daa9e510133c 100644 --- a/packages/@aws-cdk/integ-tests/lib/assertions/common.ts +++ b/packages/@aws-cdk/integ-tests/lib/assertions/common.ts @@ -1,5 +1,6 @@ import { CustomResource } from '@aws-cdk/core'; -import { AwsApiCall } from './sdk'; +import { IAwsApiCall } from './sdk'; + /** * Represents the "actual" results to compare */ @@ -16,7 +17,7 @@ export abstract class ActualResult { /** * Get the actual results from a AwsApiCall */ - public static fromAwsApiCall(query: AwsApiCall, attribute: string): ActualResult { + public static fromAwsApiCall(query: IAwsApiCall, attribute: string): ActualResult { return { result: query.getAttString(attribute), }; diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/deploy-assert.ts b/packages/@aws-cdk/integ-tests/lib/assertions/deploy-assert.ts deleted file mode 100644 index 24bbfd6789fbf..0000000000000 --- a/packages/@aws-cdk/integ-tests/lib/assertions/deploy-assert.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Stack } from '@aws-cdk/core'; -import { Construct, IConstruct, Node } from 'constructs'; -import { EqualsAssertion } from './assertions'; -import { ExpectedResult, ActualResult } from './common'; -import { md5hash } from './private/hash'; -import { AwsApiCall, LambdaInvokeFunction, LambdaInvokeFunctionProps } from './sdk'; - -const DEPLOY_ASSERT_SYMBOL = Symbol.for('@aws-cdk/integ-tests.DeployAssert'); - - -// keep this import separate from other imports to reduce chance for merge conflicts with v2-main -// eslint-disable-next-line no-duplicate-imports, import/order -import { Construct as CoreConstruct } from '@aws-cdk/core'; - -/** - * Options for DeployAssert - */ -export interface DeployAssertProps { } - -/** - * Construct that allows for registering a list of assertions - * that should be performed on a construct - */ -export class DeployAssert extends CoreConstruct { - - /** - * Returns whether the construct is a DeployAssert construct - */ - public static isDeployAssert(x: any): x is DeployAssert { - return x !== null && typeof(x) === 'object' && DEPLOY_ASSERT_SYMBOL in x; - } - - /** - * Finds a DeployAssert construct in the given scope - */ - public static of(construct: IConstruct): DeployAssert { - const scopes = Node.of(Node.of(construct).root).findAll(); - const deployAssert = scopes.find(s => DeployAssert.isDeployAssert(s)); - if (!deployAssert) { - throw new Error('No DeployAssert construct found in scopes'); - } - return deployAssert as DeployAssert; - } - - constructor(scope: Construct) { - /** - * Normally we would not want to do a scope swapparoo like this - * but in this case this it allows us to provide a better experience - * for the user. This allows DeployAssert to be created _not_ in the - * scope of a Stack. DeployAssert is treated like a Stack, but doesn't - * exose any of the stack functionality (the methods that the user sees - * are just DeployAssert methods and not any Stack methods). So you can do - * something like this, which you would not normally be allowed to do - * - * const deployAssert = new DeployAssert(app); - * new AwsApiCall(deployAssert, 'AwsApiCall', {...}); - */ - scope = new Stack(scope, 'DeployAssert'); - super(scope, 'Default'); - - Object.defineProperty(this, DEPLOY_ASSERT_SYMBOL, { value: true }); - } - - /** - * Query AWS using JavaScript SDK V2 API calls. This can be used to either - * trigger an action or to return a result that can then be asserted against - * an expected value - * - * @example - * declare const app: App; - * const assert = new DeployAssert(app); - * assert.awsApiCall('SQS', 'sendMessage', { - * QueueUrl: 'url', - * MessageBody: 'hello', - * }); - * const message = assert.awsApiCall('SQS', 'receiveMessage', { - * QueueUrl: 'url', - * }); - * message.assert(ExpectedResult.objectLike({ - * Messages: [{ Body: 'hello' }], - * })); - */ - public awsApiCall(service: string, api: string, parameters?: any): AwsApiCall { - return new AwsApiCall(this, `AwsApiCall${service}${api}`, { - api, - service, - parameters, - }); - } - - /** - * Invoke a lambda function and return the response which can be asserted - * - * @example - * declare const app: App; - * const assert = new DeployAssert(app); - * const invoke = assert.invokeFunction({ - * functionName: 'my-function', - * }); - * invoke.assert(ExpectedResult.objectLike({ - * Payload: '200', - * })); - */ - public invokeFunction(props: LambdaInvokeFunctionProps): LambdaInvokeFunction { - const hash = md5hash(Stack.of(this).resolve(props)); - return new LambdaInvokeFunction(this, `LambdaInvoke${hash}`, props); - } - - /** - * Assert that the ExpectedResult is equal - * to the ActualResult - * - * @example - * declare const deployAssert: DeployAssert; - * declare const apiCall: AwsApiCall; - * deployAssert.assert( - * 'invoke', - * ExpectedResult.objectLike({ Payload: 'OK' }), - * ActualResult.fromAwsApiCall(apiCall, 'Body'), - * ); - */ - public assert(id: string, expected: ExpectedResult, actual: ActualResult): void { - new EqualsAssertion(this, `EqualsAssertion${id}`, { - expected, - actual, - }); - } -} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/index.ts b/packages/@aws-cdk/integ-tests/lib/assertions/index.ts index 3a9defd954be9..6622ddabcb560 100644 --- a/packages/@aws-cdk/integ-tests/lib/assertions/index.ts +++ b/packages/@aws-cdk/integ-tests/lib/assertions/index.ts @@ -1,6 +1,6 @@ -export * from './assertions'; +export * from './types'; export * from './sdk'; -export * from './deploy-assert'; +export * from './assertions'; export * from './providers'; export * from './common'; export * from './match'; diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/private/deploy-assert.ts b/packages/@aws-cdk/integ-tests/lib/assertions/private/deploy-assert.ts new file mode 100644 index 0000000000000..1ff091978e7c5 --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/private/deploy-assert.ts @@ -0,0 +1,76 @@ +import { Stack } from '@aws-cdk/core'; +import { Construct, IConstruct, Node } from 'constructs'; +import { EqualsAssertion } from '../assertions'; +import { ExpectedResult, ActualResult } from '../common'; +import { md5hash } from '../private/hash'; +import { AwsApiCall, LambdaInvokeFunction, IAwsApiCall, LambdaInvokeFunctionProps } from '../sdk'; +import { IDeployAssert } from '../types'; + + +const DEPLOY_ASSERT_SYMBOL = Symbol.for('@aws-cdk/integ-tests.DeployAssert'); + + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Options for DeployAssert + */ +export interface DeployAssertProps { } + +/** + * Construct that allows for registering a list of assertions + * that should be performed on a construct + */ +export class DeployAssert extends CoreConstruct implements IDeployAssert { + + /** + * Returns whether the construct is a DeployAssert construct + */ + public static isDeployAssert(x: any): x is DeployAssert { + return x !== null && typeof(x) === 'object' && DEPLOY_ASSERT_SYMBOL in x; + } + + /** + * Finds a DeployAssert construct in the given scope + */ + public static of(construct: IConstruct): DeployAssert { + const scopes = Node.of(Node.of(construct).root).findAll(); + const deployAssert = scopes.find(s => DeployAssert.isDeployAssert(s)); + if (!deployAssert) { + throw new Error('No DeployAssert construct found in scopes'); + } + return deployAssert as DeployAssert; + } + + public scope: Stack; + + constructor(scope: Construct) { + super(scope, 'Default'); + + this.scope = new Stack(scope, 'DeployAssert'); + + Object.defineProperty(this, DEPLOY_ASSERT_SYMBOL, { value: true }); + } + + public awsApiCall(service: string, api: string, parameters?: any): IAwsApiCall { + return new AwsApiCall(this.scope, `AwsApiCall${service}${api}`, { + api, + service, + parameters, + }); + } + + public invokeFunction(props: LambdaInvokeFunctionProps): IAwsApiCall { + const hash = md5hash(this.scope.resolve(props)); + return new LambdaInvokeFunction(this.scope, `LambdaInvoke${hash}`, props); + } + + public expect(id: string, expected: ExpectedResult, actual: ActualResult): void { + new EqualsAssertion(this.scope, `EqualsAssertion${id}`, { + expected, + actual, + }); + } +} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/index.ts b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/index.ts index 78a47c83be1ef..72ca3544cb66d 100644 --- a/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/index.ts +++ b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/index.ts @@ -1,5 +1,4 @@ import { AssertionHandler } from './assertion'; -import { ResultsCollectionHandler } from './results'; import { AwsApiCallHandler } from './sdk'; import * as types from './types'; @@ -14,7 +13,6 @@ function createResourceHandler(event: AWSLambda.CloudFormationCustomResourceEven } switch (event.ResourceType) { case types.ASSERT_RESOURCE_TYPE: return new AssertionHandler(event, context); - case types.RESULTS_RESOURCE_TYPE: return new ResultsCollectionHandler(event, context); default: throw new Error(`Unsupported resource type "${event.ResourceType}`); } diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/results.ts b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/results.ts deleted file mode 100644 index 784ff68a05ab6..0000000000000 --- a/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/results.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { CustomResourceHandler } from './base'; -import { ResultsCollectionRequest, ResultsCollectionResult } from './types'; - -export class ResultsCollectionHandler extends CustomResourceHandler { - protected async processEvent(request: ResultsCollectionRequest): Promise { - const reduced: string = request.assertionResults.reduce((agg, result, idx) => { - const msg = result.status === 'pass' ? 'pass' : `fail - ${result.message}`; - return `${agg}\nTest${idx}: ${msg}`; - }, '').trim(); - return { message: reduced }; - } -} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/types.ts b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/types.ts index ae9f545476dac..68bd63202afe8 100644 --- a/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/types.ts +++ b/packages/@aws-cdk/integ-tests/lib/assertions/providers/lambda-handler/types.ts @@ -2,7 +2,6 @@ // Kept in a separate file for sharing between the handler and the provider constructs. export const ASSERT_RESOURCE_TYPE = 'Custom::DeployAssert@AssertEquals'; -export const RESULTS_RESOURCE_TYPE = 'Custom::DeployAssert@ResultsCollection'; export const SDK_RESOURCE_TYPE_PREFIX = 'Custom::DeployAssert@SdkCall'; /** @@ -155,24 +154,3 @@ export interface AssertionResultData { */ readonly message?: string; } - -/** - * Represents a collection of assertion request results - */ -export interface ResultsCollectionRequest { - /** - * The results of all the assertions that have been - * registered - */ - readonly assertionResults: AssertionResultData[]; -} - -/** - * The result of a results request - */ -export interface ResultsCollectionResult { - /** - * A message containing the results of the assertion - */ - readonly message: string; -} diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/sdk.ts b/packages/@aws-cdk/integ-tests/lib/assertions/sdk.ts index b176c13456f37..443554b5c38f7 100644 --- a/packages/@aws-cdk/integ-tests/lib/assertions/sdk.ts +++ b/packages/@aws-cdk/integ-tests/lib/assertions/sdk.ts @@ -4,10 +4,84 @@ import { EqualsAssertion } from './assertions'; import { ExpectedResult, ActualResult } from './common'; import { AssertionsProvider, SDK_RESOURCE_TYPE_PREFIX } from './providers'; +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { IConstruct } from '@aws-cdk/core'; + // keep this import separate from other imports to reduce chance for merge conflicts with v2-main // eslint-disable-next-line no-duplicate-imports, import/order import { Construct as CoreConstruct } from '@aws-cdk/core'; +/** + * Interface for creating a custom resource that will perform + * an API call using the AWS SDK + */ +export interface IAwsApiCall extends IConstruct { + /** + * Returns the value of an attribute of the custom resource of an arbitrary + * type. Attributes are returned from the custom resource provider through the + * `Data` map where the key is the attribute name. + * + * @param attributeName the name of the attribute + * @returns a token for `Fn::GetAtt`. Use `Token.asXxx` to encode the returned `Reference` as a specific type or + * use the convenience `getAttString` for string attributes. + */ + getAtt(attributeName: string): Reference; + + /** + * Returns the value of an attribute of the custom resource of type string. + * Attributes are returned from the custom resource provider through the + * `Data` map where the key is the attribute name. + * + * @param attributeName the name of the attribute + * @returns a token for `Fn::GetAtt` encoded as a string. + */ + getAttString(attributeName: string): string; + + /** + * Assert that the ExpectedResult is equal + * to the result of the AwsApiCall + * + * @example + * declare const integ: IntegTest; + * const invoke = integ.assertions.invokeFunction({ + * functionName: 'my-func', + * }); + * invoke.expect(ExpectedResult.objectLike({ Payload: 'OK' })); + */ + expect(expected: ExpectedResult): void; + + /** + * Assert that the ExpectedResult is equal + * to the result of the AwsApiCall at the given path. + * + * For example the SQS.receiveMessage api response would look + * like: + * + * If you wanted to assert the value of `Body` you could do + * + * @example + * const actual = { + * Messages: [{ + * MessageId: '', + * ReceiptHandle: '', + * MD5OfBody: '', + * Body: 'hello', + * Attributes: {}, + * MD5OfMessageAttributes: {}, + * MessageAttributes: {} + * }] + * }; + * + * + * declare const integ: IntegTest; + * const message = integ.assertions.awsApiCall('SQS', 'receiveMessage'); + * + * message.assertAtPath('Messages.0.Body', ExpectedResult.stringLikeRegexp('hello')); + */ + assertAtPath(path: string, expected: ExpectedResult): void; +} + /** * Options to perform an AWS JavaScript V2 API call */ @@ -39,7 +113,7 @@ export interface AwsApiCallProps extends AwsApiCallOptions {} * Construct that creates a custom resource that will perform * a query using the AWS SDK */ -export class AwsApiCall extends CoreConstruct { +export class AwsApiCall extends CoreConstruct implements IAwsApiCall { private readonly sdkCallResource: CustomResource; private flattenResponse: string = 'false'; private readonly name: string; @@ -69,82 +143,23 @@ export class AwsApiCall extends CoreConstruct { this.sdkCallResource.node.addDependency(this.provider); } - /** - * Returns the value of an attribute of the custom resource of an arbitrary - * type. Attributes are returned from the custom resource provider through the - * `Data` map where the key is the attribute name. - * - * @param attributeName the name of the attribute - * @returns a token for `Fn::GetAtt`. Use `Token.asXxx` to encode the returned `Reference` as a specific type or - * use the convenience `getAttString` for string attributes. - */ public getAtt(attributeName: string): Reference { this.flattenResponse = 'true'; return this.sdkCallResource.getAtt(`apiCallResponse.${attributeName}`); } - /** - * Returns the value of an attribute of the custom resource of type string. - * Attributes are returned from the custom resource provider through the - * `Data` map where the key is the attribute name. - * - * @param attributeName the name of the attribute - * @returns a token for `Fn::GetAtt` encoded as a string. - */ public getAttString(attributeName: string): string { this.flattenResponse = 'true'; return this.sdkCallResource.getAttString(`apiCallResponse.${attributeName}`); } - /** - * Assert that the ExpectedResult is equal - * to the result of the AwsApiCall - * - * @example - * declare const assert: DeployAssert; - * const invoke = new LambdaInvokeFunction(assert, 'Invoke', { - * functionName: 'my-func', - * }); - * invoke.assert(ExpectedResult.objectLike({ Payload: 'OK' })); - */ - public assert(expected: ExpectedResult): void { + public expect(expected: ExpectedResult): void { new EqualsAssertion(this, `AssertEquals${this.name}`, { expected, actual: ActualResult.fromCustomResource(this.sdkCallResource, 'apiCallResponse'), }); } - /** - * Assert that the ExpectedResult is equal - * to the result of the AwsApiCall at the given path. - * - * For example the SQS.receiveMessage api response would look - * like: - * - * If you wanted to assert the value of `Body` you could do - * - * @example - * const actual = { - * Messages: [{ - * MessageId: '', - * ReceiptHandle: '', - * MD5OfBody: '', - * Body: 'hello', - * Attributes: {}, - * MD5OfMessageAttributes: {}, - * MessageAttributes: {} - * }] - * }; - * - * - * declare const assert: DeployAssert; - * const message = new AwsApiCall(assert, 'ReceiveMessage', { - * service: 'SQS', - * api: 'receiveMessage' - * }); - * - * message.assertAtPath('Messages.0.Body', ExpectedResult.stringLikeRegexp('hello')); - */ public assertAtPath(path: string, expected: ExpectedResult): void { new EqualsAssertion(this, `AssertEquals${this.name}`, { expected, diff --git a/packages/@aws-cdk/integ-tests/lib/assertions/types.ts b/packages/@aws-cdk/integ-tests/lib/assertions/types.ts new file mode 100644 index 0000000000000..7c5dd185aa058 --- /dev/null +++ b/packages/@aws-cdk/integ-tests/lib/assertions/types.ts @@ -0,0 +1,60 @@ +import { ExpectedResult, ActualResult } from './common'; +import { IAwsApiCall, LambdaInvokeFunctionProps } from './sdk'; + +/** + * Interface that allows for registering a list of assertions + * that should be performed on a construct. This is only necessary + * when writing integration tests. + */ +export interface IDeployAssert { + /** + * Query AWS using JavaScript SDK V2 API calls. This can be used to either + * trigger an action or to return a result that can then be asserted against + * an expected value + * + * @example + * declare const app: App; + * declare const integ: IntegTest; + * integ.assertions.awsApiCall('SQS', 'sendMessage', { + * QueueUrl: 'url', + * MessageBody: 'hello', + * }); + * const message = integ.assertions.awsApiCall('SQS', 'receiveMessage', { + * QueueUrl: 'url', + * }); + * message.expect(ExpectedResult.objectLike({ + * Messages: [{ Body: 'hello' }], + * })); + */ + awsApiCall(service: string, api: string, parameters?: any): IAwsApiCall; + + /** + * Invoke a lambda function and return the response which can be asserted + * + * @example + * declare const app: App; + * declare const integ: IntegTest; + * const invoke = integ.assertions.invokeFunction({ + * functionName: 'my-function', + * }); + * invoke.expect(ExpectedResult.objectLike({ + * Payload: '200', + * })); + */ + invokeFunction(props: LambdaInvokeFunctionProps): IAwsApiCall; + + /** + * Assert that the ExpectedResult is equal + * to the ActualResult + * + * @example + * declare const integ: IntegTest; + * declare const apiCall: AwsApiCall; + * integ.assertions.expect( + * 'invoke', + * ExpectedResult.objectLike({ Payload: 'OK' }), + * ActualResult.fromAwsApiCall(apiCall, 'Body'), + * ); + */ + expect(id: string, expected: ExpectedResult, actual: ActualResult): void; +} diff --git a/packages/@aws-cdk/integ-tests/lib/test-case.ts b/packages/@aws-cdk/integ-tests/lib/test-case.ts index de701bb63d24a..a2b7436481a89 100644 --- a/packages/@aws-cdk/integ-tests/lib/test-case.ts +++ b/packages/@aws-cdk/integ-tests/lib/test-case.ts @@ -1,7 +1,8 @@ import { IntegManifest, Manifest, TestCase, TestOptions } from '@aws-cdk/cloud-assembly-schema'; import { attachCustomSynthesis, Stack, ISynthesisSession, StackProps } from '@aws-cdk/core'; import { Construct } from 'constructs'; -import { DeployAssert } from './assertions'; +import { IDeployAssert } from './assertions'; +import { DeployAssert } from './assertions/private/deploy-assert'; import { IntegManifestSynthesizer } from './manifest-synthesizer'; const TEST_CASE_STACK_SYMBOL = Symbol.for('@aws-cdk/integ-tests.IntegTestCaseStack'); @@ -31,12 +32,15 @@ export class IntegTestCase extends CoreConstruct { /** * Make assertions on resources in this test case */ - public readonly assert: DeployAssert; + public readonly assertions: IDeployAssert; + + private readonly _assert: DeployAssert; constructor(scope: Construct, id: string, private readonly props: IntegTestCaseProps) { super(scope, id); - this.assert = new DeployAssert(this); + this._assert = new DeployAssert(this); + this.assertions = this._assert; } /** @@ -53,7 +57,7 @@ export class IntegTestCase extends CoreConstruct { private toTestCase(props: IntegTestCaseProps): TestCase { return { ...props, - assertionStack: Stack.of(this.assert).artifactId, + assertionStack: this._assert.scope.artifactId, stacks: props.stacks.map(s => s.artifactId), }; } @@ -83,7 +87,7 @@ export class IntegTestCaseStack extends Stack { /** * Make assertions on resources in this test case */ - public readonly assert: DeployAssert; + public readonly assertions: IDeployAssert; /** * The underlying IntegTestCase that is created @@ -97,7 +101,7 @@ export class IntegTestCaseStack extends Stack { Object.defineProperty(this, TEST_CASE_STACK_SYMBOL, { value: true }); // TODO: should we only have a single DeployAssert per test? - this.assert = new DeployAssert(this); + this.assertions = new DeployAssert(this); this._testCase = new IntegTestCase(this, `${id}TestCase`, { ...props, stacks: [this], @@ -124,7 +128,7 @@ export class IntegTest extends CoreConstruct { /** * Make assertions on resources in this test case */ - public readonly assert: DeployAssert; + public readonly assertions: IDeployAssert; private readonly testCases: IntegTestCase[]; constructor(scope: Construct, id: string, props: IntegTestProps) { super(scope, id); @@ -138,7 +142,7 @@ export class IntegTest extends CoreConstruct { cdkCommandOptions: props.cdkCommandOptions, stackUpdateWorkflow: props.stackUpdateWorkflow, }); - this.assert = defaultTestCase.assert; + this.assertions = defaultTestCase.assertions; this.testCases = [ defaultTestCase, diff --git a/packages/@aws-cdk/integ-tests/rosetta/default.ts-fixture b/packages/@aws-cdk/integ-tests/rosetta/default.ts-fixture index b9b4f3740b427..e85bf5884afdc 100644 --- a/packages/@aws-cdk/integ-tests/rosetta/default.ts-fixture +++ b/packages/@aws-cdk/integ-tests/rosetta/default.ts-fixture @@ -3,7 +3,6 @@ import { IntegTestCase, IntegTest, IntegTestCaseStack, - DeployAssert, AwsApiCall, EqualsAssertion, ActualResult, diff --git a/packages/@aws-cdk/integ-tests/test/assertions/deploy-assert.test.ts b/packages/@aws-cdk/integ-tests/test/assertions/deploy-assert.test.ts index 847086ed66f7a..5a287200e9fca 100644 --- a/packages/@aws-cdk/integ-tests/test/assertions/deploy-assert.test.ts +++ b/packages/@aws-cdk/integ-tests/test/assertions/deploy-assert.test.ts @@ -1,12 +1,13 @@ import { Template } from '@aws-cdk/assertions'; import { App, Stack } from '@aws-cdk/core'; -import { DeployAssert, LogType, InvocationType, ExpectedResult, ActualResult } from '../../lib/assertions'; +import { LogType, InvocationType, ExpectedResult, ActualResult } from '../../lib/assertions'; +import { DeployAssert } from '../../lib/assertions/private/deploy-assert'; describe('DeployAssert', () => { test('of', () => { const app = new App(); - const stack = new Stack(app); + const stack = new Stack(app, 'TestStack'); new DeployAssert(app); expect(() => { DeployAssert.of(stack); @@ -15,7 +16,7 @@ describe('DeployAssert', () => { test('throws if no DeployAssert', () => { const app = new App(); - const stack = new Stack(app); + const stack = new Stack(app, 'TestStack'); expect(() => { DeployAssert.of(stack); }).toThrow(/No DeployAssert construct found in scopes/); @@ -43,7 +44,7 @@ describe('DeployAssert', () => { }); // THEN - const template = Template.fromStack(Stack.of(deployAssert)); + const template = Template.fromStack(deployAssert.scope); template.hasResourceProperties('Custom::DeployAssert@SdkCallLambdainvoke', { service: 'Lambda', api: 'invoke', @@ -65,14 +66,14 @@ describe('DeployAssert', () => { const query = deplossert.awsApiCall('MyService', 'MyApi'); // WHEN - deplossert.assert( + deplossert.expect( 'MyAssertion', ExpectedResult.stringLikeRegexp('foo'), ActualResult.fromAwsApiCall(query, 'att'), ); // THEN - const template = Template.fromStack(Stack.of(deplossert)); + const template = Template.fromStack(deplossert.scope); template.hasResourceProperties('Custom::DeployAssert@AssertEquals', { expected: JSON.stringify({ $StringLike: 'foo' }), actual: { @@ -91,14 +92,14 @@ describe('DeployAssert', () => { const query = deplossert.awsApiCall('MyService', 'MyApi'); // WHEN - deplossert.assert( + deplossert.expect( 'MyAssertion', ExpectedResult.objectLike({ foo: 'bar' }), ActualResult.fromAwsApiCall(query, 'att'), ); // THEN - const template = Template.fromStack(Stack.of(deplossert)); + const template = Template.fromStack(deplossert.scope); template.hasResourceProperties('Custom::DeployAssert@AssertEquals', { expected: JSON.stringify({ $ObjectLike: { foo: 'bar' } }), actual: { @@ -122,7 +123,7 @@ describe('DeployAssert', () => { // THEN - Template.fromStack(Stack.of(deplossert)).hasResourceProperties('Custom::DeployAssert@SdkCallMyServiceMyApi', { + Template.fromStack(deplossert.scope).hasResourceProperties('Custom::DeployAssert@SdkCallMyServiceMyApi', { api: 'MyApi', service: 'MyService', }); @@ -139,7 +140,7 @@ describe('DeployAssert', () => { // THEN - const template = Template.fromStack(Stack.of(deplossert)); + const template = Template.fromStack(deplossert.scope); template.resourceCountIs('AWS::Lambda::Function', 1); template.resourceCountIs('Custom::DeployAssert@SdkCallMyServiceMyApi1', 1); template.resourceCountIs('Custom::DeployAssert@SdkCallMyServiceMyApi2', 1); diff --git a/packages/@aws-cdk/integ-tests/test/assertions/sdk.test.ts b/packages/@aws-cdk/integ-tests/test/assertions/sdk.test.ts index 31f1bd5068a4b..d8d3d70ec1694 100644 --- a/packages/@aws-cdk/integ-tests/test/assertions/sdk.test.ts +++ b/packages/@aws-cdk/integ-tests/test/assertions/sdk.test.ts @@ -1,6 +1,7 @@ import { Template, Match } from '@aws-cdk/assertions'; -import { App, Stack, CfnOutput } from '@aws-cdk/core'; -import { DeployAssert, AwsApiCall, LambdaInvokeFunction, LogType, InvocationType, ExpectedResult } from '../../lib/assertions'; +import { App, CfnOutput } from '@aws-cdk/core'; +import { LogType, InvocationType, ExpectedResult } from '../../lib/assertions'; +import { DeployAssert } from '../../lib/assertions/private/deploy-assert'; describe('AwsApiCall', () => { test('default', () => { @@ -9,13 +10,10 @@ describe('AwsApiCall', () => { const deplossert = new DeployAssert(app); // WHEN - new AwsApiCall(deplossert, 'AwsApiCall', { - service: 'MyService', - api: 'MyApi', - }); + deplossert.awsApiCall('MyService', 'MyApi'); // THEN - const template = Template.fromStack(Stack.of(deplossert)); + const template = Template.fromStack(deplossert.scope); template.resourceCountIs('AWS::Lambda::Function', 1); template.hasResourceProperties('Custom::DeployAssert@SdkCallMyServiceMyApi', { service: 'MyService', @@ -30,17 +28,13 @@ describe('AwsApiCall', () => { const deplossert = new DeployAssert(app); // WHEN - new AwsApiCall(deplossert, 'AwsApiCall', { - service: 'MyService', - api: 'MyApi', - parameters: { - param1: 'val1', - param2: 2, - }, + deplossert.awsApiCall('MyService', 'MyApi', { + param1: 'val1', + param2: 2, }); // THEN - const template = Template.fromStack(Stack.of(deplossert)); + const template = Template.fromStack(deplossert.scope); template.resourceCountIs('AWS::Lambda::Function', 1); template.hasResourceProperties('Custom::DeployAssert@SdkCallMyServiceMyApi', { service: 'MyService', @@ -59,21 +53,18 @@ describe('AwsApiCall', () => { const deplossert = new DeployAssert(app); // WHEN - const query = new AwsApiCall(deplossert, 'AwsApiCall', { - service: 'MyService', - api: 'MyApi', - }); + const query = deplossert.awsApiCall('MyService', 'MyApi'); - new CfnOutput(deplossert, 'GetAttString', { + new CfnOutput(deplossert.scope, 'GetAttString', { value: query.getAttString('att'), }).overrideLogicalId('GetAtt'); // THEN - const template = Template.fromStack(Stack.of(deplossert)); + const template = Template.fromStack(deplossert.scope); template.hasOutput('GetAtt', { Value: { 'Fn::GetAtt': [ - 'AwsApiCall', + 'AwsApiCallMyServiceMyApi', 'apiCallResponse.att', ], }, @@ -85,27 +76,25 @@ describe('AwsApiCall', () => { flattenResponse: 'true', }); }); + test('getAtt', () => { // GIVEN const app = new App(); const deplossert = new DeployAssert(app); // WHEN - const query = new AwsApiCall(deplossert, 'AwsApiCall', { - service: 'MyService', - api: 'MyApi', - }); + const query = deplossert.awsApiCall('MyService', 'MyApi'); - new CfnOutput(deplossert, 'GetAttString', { + new CfnOutput(deplossert.scope, 'GetAttString', { value: query.getAtt('att').toString(), }).overrideLogicalId('GetAtt'); // THEN - const template = Template.fromStack(Stack.of(deplossert)); + const template = Template.fromStack(deplossert.scope); template.hasOutput('GetAtt', { Value: { 'Fn::GetAtt': [ - 'AwsApiCall', + 'AwsApiCallMyServiceMyApi', 'apiCallResponse.att', ], }, @@ -117,7 +106,6 @@ describe('AwsApiCall', () => { flattenResponse: 'true', }); }); - }); describe('assertEqual', () => { @@ -127,19 +115,16 @@ describe('AwsApiCall', () => { const deplossert = new DeployAssert(app); // WHEN - const query = new AwsApiCall(deplossert, 'AwsApiCall', { - service: 'MyService', - api: 'MyApi', - }); - query.assert(ExpectedResult.exact({ foo: 'bar' })); + const query = deplossert.awsApiCall('MyService', 'MyApi'); + query.expect(ExpectedResult.exact({ foo: 'bar' })); // THEN - const template = Template.fromStack(Stack.of(deplossert)); + const template = Template.fromStack(deplossert.scope); template.hasResourceProperties('Custom::DeployAssert@AssertEquals', { expected: JSON.stringify({ $Exact: { foo: 'bar' } }), actual: { 'Fn::GetAtt': [ - 'AwsApiCall', + 'AwsApiCallMyServiceMyApi', 'apiCallResponse', ], }, @@ -152,19 +137,16 @@ describe('AwsApiCall', () => { const deplossert = new DeployAssert(app); // WHEN - const query = new AwsApiCall(deplossert, 'AwsApiCall', { - service: 'MyService', - api: 'MyApi', - }); - query.assert(ExpectedResult.objectLike({ foo: 'bar' })); + const query = deplossert.awsApiCall('MyService', 'MyApi'); + query.expect(ExpectedResult.objectLike({ foo: 'bar' })); // THEN - const template = Template.fromStack(Stack.of(deplossert)); + const template = Template.fromStack(deplossert.scope); template.hasResourceProperties('Custom::DeployAssert@AssertEquals', { expected: JSON.stringify({ $ObjectLike: { foo: 'bar' } }), actual: { 'Fn::GetAtt': [ - 'AwsApiCall', + 'AwsApiCallMyServiceMyApi', 'apiCallResponse', ], }, @@ -177,19 +159,16 @@ describe('AwsApiCall', () => { const deplossert = new DeployAssert(app); // WHEN - const query = new AwsApiCall(deplossert, 'AwsApiCall', { - service: 'MyService', - api: 'MyApi', - }); - query.assert(ExpectedResult.exact('bar')); + const query = deplossert.awsApiCall('MyService', 'MyApi'); + query.expect(ExpectedResult.exact('bar')); // THEN - const template = Template.fromStack(Stack.of(deplossert)); + const template = Template.fromStack(deplossert.scope); template.hasResourceProperties('Custom::DeployAssert@AssertEquals', { expected: JSON.stringify({ $Exact: 'bar' }), actual: { 'Fn::GetAtt': [ - 'AwsApiCall', + 'AwsApiCallMyServiceMyApi', 'apiCallResponse', ], }, @@ -203,14 +182,14 @@ describe('AwsApiCall', () => { const app = new App(); const deplossert = new DeployAssert(app); - new LambdaInvokeFunction(deplossert, 'Invoke', { + deplossert.invokeFunction({ functionName: 'my-func', logType: LogType.TAIL, payload: JSON.stringify({ key: 'val' }), invocationType: InvocationType.EVENT, }); - const template = Template.fromStack(Stack.of(deplossert)); + const template = Template.fromStack(deplossert.scope); template.hasResourceProperties('Custom::DeployAssert@SdkCallLambdainvoke', { service: 'Lambda', api: 'invoke', diff --git a/packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline-source.ts b/packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline-source.ts index ef5f7479c66b5..0fe05412d297a 100644 --- a/packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline-source.ts +++ b/packages/@aws-cdk/pipelines/lib/codepipeline/codepipeline-source.ts @@ -38,6 +38,9 @@ export abstract class CodePipelineSource extends Step implements ICodePipelineAc * * * **repo** - to read the repository * * **admin:repo_hook** - if you plan to use webhooks (true by default) + * + * If you need access to symlinks or the repository history, use a source of type + * `connection` instead. */ public static gitHub(repoString: string, branch: string, props: GitHubSourceOptions = {}): CodePipelineSource { return new GitHubSource(repoString, branch, props); @@ -92,6 +95,9 @@ export abstract class CodePipelineSource extends Step implements ICodePipelineAc * }); * ``` * + * If you need access to symlinks or the repository history, be sure to set + * `codeBuildCloneOutput`. + * * @param repoString A string that encodes owner and repository separated by a slash (e.g. 'owner/repo'). * @param branch The branch to use. * @param props The source properties, including the connection ARN. @@ -105,6 +111,10 @@ export abstract class CodePipelineSource extends Step implements ICodePipelineAc /** * Returns a CodeCommit source. * + * If you need access to symlinks or the repository history, be sure to set + * `codeBuildCloneOutput`. + * + * * @param repository The CodeCommit repository. * @param branch The branch to use. * @param props The source properties. @@ -360,12 +370,12 @@ export interface ConnectionSourceOptions { // long URL in @see /** - * Whether the output should be the contents of the repository - * (which is the default), - * or a link that allows CodeBuild to clone the repository before building. + * If this is set, the next CodeBuild job clones the repository (instead of CodePipeline downloading the files). + * + * This provides access to repository history, and retains symlinks (symlinks would otherwise be + * removed by CodePipeline). * - * **Note**: if this option is true, - * then only CodeBuild actions can use the resulting {@link output}. + * **Note**: if this option is true, only CodeBuild jobs can use the output artifact. * * @default false * @see https://docs.aws.amazon.com/codepipeline/latest/userguide/action-reference-CodestarConnectionSource.html#action-reference-CodestarConnectionSource-config @@ -435,12 +445,12 @@ export interface CodeCommitSourceOptions { readonly eventRole?: iam.IRole; /** - * Whether the output should be the contents of the repository - * (which is the default), - * or a link that allows CodeBuild to clone the repository before building. + * If this is set, the next CodeBuild job clones the repository (instead of CodePipeline downloading the files). + * + * This provides access to repository history, and retains symlinks (symlinks would otherwise be + * removed by CodePipeline). * - * **Note**: if this option is true, - * then only CodeBuild actions can use the resulting {@link output}. + * **Note**: if this option is true, only CodeBuild jobs can use the output artifact. * * @default false * @see https://docs.aws.amazon.com/codepipeline/latest/userguide/action-reference-CodeCommit.html diff --git a/packages/aws-cdk/test/integ/common/jest-test.bash b/packages/aws-cdk/test/integ/common/jest-test.bash index 65ad889c2860a..b29ca90036d38 100755 --- a/packages/aws-cdk/test/integ/common/jest-test.bash +++ b/packages/aws-cdk/test/integ/common/jest-test.bash @@ -11,5 +11,8 @@ function invokeJest() { # This must --runInBand because parallelism is arranged for inside the tests # themselves and they must run in the same process in order to coordinate to # make sure no 2 tests use the same region at the same time. - npx jest --runInBand --verbose "$@" + # + # Jest is run in a weird way because npx started (NPM 8?) to change directory + # into 'package.json' root, which we don't want here. + $(npx which jest) --runInBand --verbose "$@" }