Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(servicecatalog): allow creating a CFN Product Version with CDK code #17144

Merged
merged 24 commits into from
Nov 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
arcrank marked this conversation as resolved.
Show resolved Hide resolved

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);
skinny85 marked this conversation as resolved.
Show resolved Hide resolved
}
}
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;
skinny85 marked this conversation as resolved.
Show resolved Hide resolved
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