Skip to content

Commit

Permalink
feat(servicecatalog): allow creating a CFN Product Version with CDK c…
Browse files Browse the repository at this point in the history
…ode (aws#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 <dponzo18@gmail.com>
  • Loading branch information
2 people authored and TikiTDO committed Feb 21, 2022
1 parent d5287e4 commit cadeed3
Show file tree
Hide file tree
Showing 11 changed files with 485 additions and 6 deletions.
39 changes: 37 additions & 2 deletions packages/@aws-cdk/aws-servicecatalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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')),
},
],
});
```

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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(),
};
}
}
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
77 changes: 77 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
3 changes: 2 additions & 1 deletion packages/@aws-cdk/aws-servicecatalog/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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", {
Expand Down
114 changes: 114 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/test/integ.product.expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
]
}
]
]
}
}
}
]
}
Expand Down Expand Up @@ -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\""
}
}
}
Loading

0 comments on commit cadeed3

Please sign in to comment.