Skip to content

Commit

Permalink
feat(servicecatalog): ProductStackHistory can retain old ProductStack…
Browse files Browse the repository at this point in the history
… iterations (#20244)

Adding enhancement to ProductStack to allow the specification of a VersioningStrategy.
VersioningStrategy `RetainPreviousVersions` added to save previously deployed ProductStacks templates in a local context directory. These productVersions can then be easily be deployed using the stored templates.

---
*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
wanjacki authored May 24, 2022
1 parent dd4c2b5 commit 1037b8c
Show file tree
Hide file tree
Showing 18 changed files with 499 additions and 20 deletions.
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-servicecatalog/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*.d.ts
tsconfig.json
node_modules
product-stack-snapshots
*.generated.ts
dist
.jsii
Expand Down
100 changes: 100 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
7 changes: 6 additions & 1 deletion packages/@aws-cdk/aws-servicecatalog/lib/common.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -18,4 +23,4 @@ export enum MessageLanguage {
* Chinese
*/
ZH = 'zh'
}
}
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 @@ -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:
Expand Down
118 changes: 118 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/product-stack-history.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
15 changes: 15 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand All @@ -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.
*
Expand Down Expand Up @@ -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);
}
}
Expand Down
8 changes: 8 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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',
Expand All @@ -35,6 +42,7 @@ const product = new servicecatalog.CloudFormationProduct(stack, 'TestProduct', {
{
cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new TestProductStack(stack, 'SNSTopicProduct2')),
},
productStackHistory.currentVersion(),
],
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version":"17.0.0"}
{"version":"18.0.0"}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": "18.0.0",
"testCases": {
"aws-servicecatalog/test/integ.portfolio": {
"integ.portfolio": {
"stacks": [
"integ-servicecatalog-portfolio"
],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "17.0.0",
"version": "18.0.0",
"artifacts": {
"Tree": {
"type": "cdk:tree",
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version":"17.0.0"}
{"version":"18.0.0"}
Loading

0 comments on commit 1037b8c

Please sign in to comment.