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',