From f8d0ef550df07e43aeab35dde4406c92f7551ed0 Mon Sep 17 00:00:00 2001 From: arcrank Date: Tue, 2 Nov 2021 14:30:17 -0400 Subject: [PATCH] feat(servicecatalog): allow creating a CFN Product Version with CDK code (#17144) Add ability to define a product version entirely within CDK as opposed to referencing templates or local assets. The service catalog `ProductStack` is similar to `NestedStacks` that do not deploy themselves but rather are referenced by the parent stacks. The resources defined in your product are added to the product stack like any other cdk app. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* Co-authored-by: Dillon Ponzo --- .../@aws-cdk/aws-servicecatalog/README.md | 39 +++++- .../lib/cloudformation-template.ts | 26 ++++ .../@aws-cdk/aws-servicecatalog/lib/index.ts | 1 + .../lib/private/product-stack-synthesizer.ts | 34 ++++++ .../aws-servicecatalog/lib/product-stack.ts | 77 ++++++++++++ .../@aws-cdk/aws-servicecatalog/package.json | 3 +- .../rosetta/basic-portfolio.ts-fixture | 6 +- .../test/integ.product.expected.json | 114 ++++++++++++++++++ .../aws-servicecatalog/test/integ.product.ts | 15 +++ .../test/product-stack.test.ts | 88 ++++++++++++++ .../aws-servicecatalog/test/product.test.ts | 88 ++++++++++++++ 11 files changed, 485 insertions(+), 6 deletions(-) create mode 100644 packages/@aws-cdk/aws-servicecatalog/lib/private/product-stack-synthesizer.ts create mode 100644 packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts create mode 100644 packages/@aws-cdk/aws-servicecatalog/test/product-stack.test.ts diff --git a/packages/@aws-cdk/aws-servicecatalog/README.md b/packages/@aws-cdk/aws-servicecatalog/README.md index 4bb6885c601eb..2d7694d3e84b6 100644 --- a/packages/@aws-cdk/aws-servicecatalog/README.md +++ b/packages/@aws-cdk/aws-servicecatalog/README.md @@ -30,6 +30,8 @@ enables organizations to create and manage catalogs of products for their end us - [Granting access to a portfolio](#granting-access-to-a-portfolio) - [Sharing a portfolio with another AWS account](#sharing-a-portfolio-with-another-aws-account) - [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) - [Adding a product to a portfolio](#adding-a-product-to-a-portfolio) - [TagOptions](#tag-options) - [Constraints](#constraints) @@ -125,10 +127,12 @@ const product = new servicecatalog.CloudFormationProduct(this, 'MyFirstProduct', cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromUrl( 'https://raw.githubusercontent.com/awslabs/aws-cloudformation-templates/master/aws/services/ServiceCatalog/Product.yaml'), }, - ] + ], }); ``` +### Creating a product from a local asset + A `CloudFormationProduct` can also be created using a Cloudformation template from an Asset. Assets are files that are uploaded to an S3 Bucket before deployment. `CloudFormationTemplate.fromAsset` can be utilized to create a Product by passing the path to a local template file on your disk: @@ -149,7 +153,38 @@ const product = new servicecatalog.CloudFormationProduct(this, 'MyFirstProduct', productVersionName: "v2", cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromAsset(path.join(__dirname, 'development-environment.template.json')), }, - ] + ], +}); +``` + +### Creating a product from a stack + +You can define a service catalog `CloudFormationProduct` entirely within CDK using a service catalog `ProductStack`. +A separate child stack for your product is created and you can add resources like you would for any other CDK stack, +such as an S3 Bucket, IAM roles, and EC2 instances. This stack is passed in as a product version to your +product. This will not create a separate stack during deployment. + +```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 product = new servicecatalog.CloudFormationProduct(this, 'MyFirstProduct', { + productName: "My Product", + owner: "Product Owner", + productVersions: [ + { + productVersionName: "v1", + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new S3BucketProduct(this, 'S3BucketProduct')), + }, + ], }); ``` diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts b/packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts index be0cb9adf2022..4086db6655fda 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts @@ -1,5 +1,6 @@ import * as s3_assets from '@aws-cdk/aws-s3-assets'; import { hashValues } from './private/util'; +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 @@ -26,6 +27,13 @@ export abstract class CloudFormationTemplate { return new CloudFormationAssetTemplate(path, options); } + /** + * Creates a product with the resources defined in the given product stack. + */ + public static fromProductStack(productStack: ProductStack): CloudFormationTemplate { + return new CloudFormationProductStackTemplate(productStack); + } + /** * Called when the product is initialized to allow this object to bind * to the stack, add resources and have fun. @@ -88,3 +96,21 @@ class CloudFormationAssetTemplate extends CloudFormationTemplate { }; } } + +/** + * Template from a CDK defined product stack. + */ +class CloudFormationProductStackTemplate extends CloudFormationTemplate { + /** + * @param stack A service catalog product stack. + */ + constructor(public readonly productStack: ProductStack) { + super(); + } + + public bind(_scope: Construct): CloudFormationTemplateConfig { + return { + httpUrl: this.productStack._getTemplateUrl(), + }; + } +} diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/index.ts b/packages/@aws-cdk/aws-servicecatalog/lib/index.ts index 8a7b0f0ff9ac6..cc26e880fdd2e 100644 --- a/packages/@aws-cdk/aws-servicecatalog/lib/index.ts +++ b/packages/@aws-cdk/aws-servicecatalog/lib/index.ts @@ -3,6 +3,7 @@ export * from './constraints'; export * from './cloudformation-template'; export * from './portfolio'; export * from './product'; +export * from './product-stack'; export * from './tag-options'; // AWS::ServiceCatalog CloudFormation Resources: diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/private/product-stack-synthesizer.ts b/packages/@aws-cdk/aws-servicecatalog/lib/private/product-stack-synthesizer.ts new file mode 100644 index 0000000000000..4840a7e756fe5 --- /dev/null +++ b/packages/@aws-cdk/aws-servicecatalog/lib/private/product-stack-synthesizer.ts @@ -0,0 +1,34 @@ +import * as cdk from '@aws-cdk/core'; + +/** + * Deployment environment for an AWS Service Catalog product stack. + * + * Interoperates with the StackSynthesizer of the parent stack. + */ +export class ProductStackSynthesizer extends cdk.StackSynthesizer { + private stack?: cdk.Stack; + + public bind(stack: cdk.Stack): void { + if (this.stack !== undefined) { + throw new Error('A Stack Synthesizer can only be bound once, create a new instance to use with a different Stack'); + } + this.stack = stack; + } + + public addFileAsset(_asset: cdk.FileAssetSource): cdk.FileAssetLocation { + throw new Error('Service Catalog Product Stacks cannot use Assets'); + } + + public addDockerImageAsset(_asset: cdk.DockerImageAssetSource): cdk.DockerImageAssetLocation { + throw new Error('Service Catalog Product Stacks cannot use Assets'); + } + + public synthesize(session: cdk.ISynthesisSession): void { + if (!this.stack) { + throw new Error('You must call bindStack() first'); + } + // Synthesize the template, but don't emit as a cloud assembly artifact. + // It will be registered as an S3 asset of its parent instead. + this.synthesizeStackTemplate(this.stack, session); + } +} diff --git a/packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts b/packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts new file mode 100644 index 0000000000000..b96224a8b2c60 --- /dev/null +++ b/packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts @@ -0,0 +1,77 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as cdk from '@aws-cdk/core'; +import { ProductStackSynthesizer } from './private/product-stack-synthesizer'; + +// 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 } from 'constructs'; + +/** + * A Service Catalog product stack, which is similar in form to a Cloudformation nested stack. + * You can add the resources to this stack that you want to define for your service catalog product. + * + * This stack will not be treated as an independent deployment + * artifact (won't be listed in "cdk list" or deployable through "cdk deploy"), + * but rather only synthesized as a template and uploaded as an asset to S3. + * + */ +export class ProductStack extends cdk.Stack { + public readonly templateFile: string; + private _templateUrl?: string; + private _parentStack: cdk.Stack; + + constructor(scope: Construct, id: string) { + super(scope, id, { + synthesizer: new ProductStackSynthesizer(), + }); + + this._parentStack = findParentStack(scope); + + // this is the file name of the synthesized template file within the cloud assembly + this.templateFile = `${cdk.Names.uniqueId(this)}.product.template.json`; + } + + /** + * Fetch the template URL. + * + * @internal + */ + public _getTemplateUrl(): string { + return cdk.Lazy.uncachedString({ produce: () => this._templateUrl }); + } + + /** + * Synthesize the product stack template, overrides the `super` class method. + * + * Defines an asset at the parent stack which represents the template of this + * product stack. + * + * @internal + */ + public _synthesizeTemplate(session: cdk.ISynthesisSession): void { + const cfn = JSON.stringify(this._toCloudFormation(), undefined, 2); + const templateHash = crypto.createHash('sha256').update(cfn).digest('hex'); + + this._templateUrl = this._parentStack.synthesizer.addFileAsset({ + packaging: cdk.FileAssetPackaging.FILE, + sourceHash: templateHash, + fileName: this.templateFile, + }).httpUrl; + + fs.writeFileSync(path.join(session.assembly.outdir, this.templateFile), cfn); + } +} + +/** + * Validates the scope for a product stack, which must be defined within the scope of another `Stack`. + */ +function findParentStack(scope: Construct): cdk.Stack { + try { + const parentStack = cdk.Stack.of(scope); + return parentStack as cdk.Stack; + } catch (e) { + throw new Error('Product stacks must be defined within scope of another non-product stack'); + } +} diff --git a/packages/@aws-cdk/aws-servicecatalog/package.json b/packages/@aws-cdk/aws-servicecatalog/package.json index de3bfcfb667a5..3d96fbe55edcc 100644 --- a/packages/@aws-cdk/aws-servicecatalog/package.json +++ b/packages/@aws-cdk/aws-servicecatalog/package.json @@ -104,7 +104,8 @@ "resource-attribute:@aws-cdk/aws-servicecatalog.CloudFormationProduct.cloudFormationProductProvisioningArtifactNames", "props-physical-name:@aws-cdk/aws-servicecatalog.CloudFormationProductProps", "resource-attribute:@aws-cdk/aws-servicecatalog.Portfolio.portfolioName", - "props-physical-name:@aws-cdk/aws-servicecatalog.PortfolioProps" + "props-physical-name:@aws-cdk/aws-servicecatalog.PortfolioProps", + "props-physical-name:@aws-cdk/aws-servicecatalog.ProductStack" ] }, "maturity": "experimental", diff --git a/packages/@aws-cdk/aws-servicecatalog/rosetta/basic-portfolio.ts-fixture b/packages/@aws-cdk/aws-servicecatalog/rosetta/basic-portfolio.ts-fixture index d8925e6645aa7..3029872ea1f0d 100644 --- a/packages/@aws-cdk/aws-servicecatalog/rosetta/basic-portfolio.ts-fixture +++ b/packages/@aws-cdk/aws-servicecatalog/rosetta/basic-portfolio.ts-fixture @@ -1,9 +1,9 @@ // Fixture with packages imported, but nothing else -import { Construct, Stack } from '@aws-cdk/core'; +import * as cdk from '@aws-cdk/core'; import * as servicecatalog from '@aws-cdk/aws-servicecatalog'; -class Fixture extends Stack { - constructor(scope: Construct, id: string) { +class Fixture extends cdk.Stack { + constructor(scope: cdk.Construct, id: string) { super(scope, id); const portfolio = new servicecatalog.Portfolio(this, "MyFirstPortfolio", { diff --git a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json index 786fdcad0f8fc..f54d640e1d0ca 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json @@ -113,6 +113,108 @@ ] } } + }, + { + "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" + } + ] + } + ] + } + ] + ] + } + } + }, + { + "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" + } + ] + } + ] + } + ] + ] + } + } } ] } @@ -142,6 +244,18 @@ "AssetParameters6412a5f4524c6b41d26fbeee226c68c2dad735393940a51008d77e6f8b1038f5ArtifactHashDC26AFAC": { "Type": "String", "Description": "Artifact hash for asset \"6412a5f4524c6b41d26fbeee226c68c2dad735393940a51008d77e6f8b1038f5\"" + }, + "AssetParametersdd2d087eeb6ede1d2a9166639ccbde7bd1b10eef9ba2b4cb3d9855faa4fe8c1fS3BucketB4751C98": { + "Type": "String", + "Description": "S3 bucket for asset \"dd2d087eeb6ede1d2a9166639ccbde7bd1b10eef9ba2b4cb3d9855faa4fe8c1f\"" + }, + "AssetParametersdd2d087eeb6ede1d2a9166639ccbde7bd1b10eef9ba2b4cb3d9855faa4fe8c1fS3VersionKeyEB38C6F9": { + "Type": "String", + "Description": "S3 key for asset version \"dd2d087eeb6ede1d2a9166639ccbde7bd1b10eef9ba2b4cb3d9855faa4fe8c1f\"" + }, + "AssetParametersdd2d087eeb6ede1d2a9166639ccbde7bd1b10eef9ba2b4cb3d9855faa4fe8c1fArtifactHash5C1F9228": { + "Type": "String", + "Description": "Artifact hash for asset \"dd2d087eeb6ede1d2a9166639ccbde7bd1b10eef9ba2b4cb3d9855faa4fe8c1f\"" } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts index 5c4192cc9b25c..7a88c98a466d1 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts +++ b/packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts @@ -1,10 +1,19 @@ 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'; const app = new cdk.App(); const stack = new cdk.Stack(app, 'integ-servicecatalog-product'); +class TestProductStack extends servicecatalog.ProductStack { + constructor(scope: any, id: string) { + super(scope, id); + + new sns.Topic(this, 'TopicProduct'); + } +} + new servicecatalog.CloudFormationProduct(stack, 'TestProduct', { productName: 'testProduct', owner: 'testOwner', @@ -20,6 +29,12 @@ new servicecatalog.CloudFormationProduct(stack, 'TestProduct', { { cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromAsset(path.join(__dirname, 'product2.template.json')), }, + { + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new TestProductStack(stack, 'SNSTopicProduct1')), + }, + { + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new TestProductStack(stack, 'SNSTopicProduct2')), + }, ], }); diff --git a/packages/@aws-cdk/aws-servicecatalog/test/product-stack.test.ts b/packages/@aws-cdk/aws-servicecatalog/test/product-stack.test.ts new file mode 100644 index 0000000000000..e024421d724dc --- /dev/null +++ b/packages/@aws-cdk/aws-servicecatalog/test/product-stack.test.ts @@ -0,0 +1,88 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as s3_assets from '@aws-cdk/aws-s3-assets'; +import * as sns from '@aws-cdk/aws-sns'; +import * as cdk from '@aws-cdk/core'; +import * as servicecatalog from '../lib'; + +/* eslint-disable quote-props */ +describe('ProductStack', () => { + test('fails to add asset to a product stack', () => { + // GIVEN + const app = new cdk.App(); + const mainStack = new cdk.Stack(app, 'MyStack'); + const productStack = new servicecatalog.ProductStack(mainStack, 'MyProductStack'); + + // THEN + expect(() => { + new s3_assets.Asset(productStack, 'testAsset', { + path: path.join(__dirname, 'product1.template.json'), + }); + }).toThrow(/Service Catalog Product Stacks cannot use Assets/); + }), + + test('fails if defined at root', () => { + // GIVEN + const app = new cdk.App(); + + // THEN + expect(() => { + new servicecatalog.ProductStack(app, 'ProductStack'); + }).toThrow(/must be defined within scope of another non-product stack/); + }), + + test('fails if defined without a parent stack', () => { + // GIVEN + const app = new cdk.App(); + const group = new cdk.Construct(app, 'group'); + + // THEN + expect(() => { + new servicecatalog.ProductStack(group, 'ProductStack'); + }).toThrow(/must be defined within scope of another non-product stack/); + }), + + test('can be defined as a direct child or an indirect child of a Stack', () => { + // GIVEN + const parent = new cdk.Stack(); + + // THEN + expect(() => { + new servicecatalog.ProductStack(parent, 'direct'); + }).not.toThrow(); + }); + + test('product stack is not synthesized as a stack artifact into the assembly', () => { + // GIVEN + const app = new cdk.App(); + const parentStack = new cdk.Stack(app, 'ParentStack'); + new servicecatalog.ProductStack(parentStack, 'ProductStack'); + + // WHEN + const assembly = app.synth(); + + // THEN + expect(assembly.artifacts.length).toEqual(2); + }); + + test('the template of the product stack is synthesized into the cloud assembly', () => { + // GIVEN + const app = new cdk.App(); + const parent = new cdk.Stack(app, 'ParentStack'); + const productStack = new servicecatalog.ProductStack(parent, 'ProductStack'); + new sns.Topic(productStack, 'SNSTopicProduct'); + + // WHEN + const assembly = app.synth(); + + // THEN + const template = JSON.parse(fs.readFileSync(path.join(assembly.directory, productStack.templateFile), 'utf-8')); + expect(template).toEqual({ + Resources: { + SNSTopicProduct20605D98: { + Type: 'AWS::SNS::Topic', + }, + }, + }); + }); +}); diff --git a/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts b/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts index f3e485b30e951..f399a79dcdb83 100644 --- a/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts +++ b/packages/@aws-cdk/aws-servicecatalog/test/product.test.ts @@ -1,5 +1,6 @@ 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'; @@ -101,6 +102,93 @@ describe('Product', () => { expect(synthesized.assets.length).toEqual(2); }), + test('product test from product stack', () => { + const productStack = new servicecatalog.ProductStack(stack, 'ProductStack'); + + new sns.Topic(productStack, 'SNSTopicProductStack'); + + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + productVersionName: 'v1', + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(productStack), + }, + ], + }); + + 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'); + }), + + test('multiple product versions from product stack', () => { + const productStackVersion1 = new servicecatalog.ProductStack(stack, 'ProductStackV1'); + const productStackVersion2 = new servicecatalog.ProductStack(stack, 'ProductStackV2'); + + new sns.Topic(productStackVersion1, 'SNSTopicProductStack1'); + + new sns.Topic(productStackVersion2, 'SNSTopicProductStack2', { + displayName: 'a test', + }); + + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + productVersionName: 'v1', + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(productStackVersion1), + }, + { + productVersionName: 'v2', + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(productStackVersion2), + }, + ], + }); + + const assembly = app.synth(); + + expect(assembly.stacks[0].assets.length).toBe(2); + expect(assembly.stacks[0].assets[0].path).toEqual('ProductStackV1.product.template.json'); + expect(assembly.stacks[0].assets[1].path).toEqual('ProductStackV2.product.template.json'); + }), + + test('identical product versions from product stack creates one asset', () => { + class TestProductStack extends servicecatalog.ProductStack { + constructor(scope: any, id: string) { + super(scope, id); + + new sns.Topic(this, 'TopicProduct'); + } + } + + new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { + productName: 'testProduct', + owner: 'testOwner', + productVersions: [ + { + productVersionName: 'v1', + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new TestProductStack(stack, 'v1')), + }, + { + productVersionName: 'v2', + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new TestProductStack(stack, 'v2')), + }, + { + productVersionName: 'v3', + cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new TestProductStack(stack, 'v3')), + }, + ], + }); + + const assembly = app.synth(); + + expect(assembly.stacks[0].assets.length).toBe(1); + }), + test('product test from multiple sources', () => { new servicecatalog.CloudFormationProduct(stack, 'MyProduct', { productName: 'testProduct',