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): Add Product Stack Asset Support #22143

Closed
wants to merge 4 commits into from
Closed
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
67 changes: 67 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/README.md
Original file line number Diff line number Diff line change
@@ -185,6 +185,73 @@ const product = new servicecatalog.CloudFormationProduct(this, 'Product', {
});
```

You can reference assets in a Product Stack. For example, we can add a handler to a Lambda function or a S3 Asset directly from a local asset file.
In this case, a S3 Bucket will automatically be generated for each ProductStack to store your assets based on your environment accountId and region.

```ts
import * as lambda from '@aws-cdk/aws-lambda';
import * as cdk from '@aws-cdk/core';

class LambdaProduct extends servicecatalog.ProductStack {
constructor(scope: Construct, id: string) {
super(scope, id);

new lambda.Function(this, 'LambdaProduct', {
runtime: lambda.Runtime.PYTHON_3_9,
code: lambda.Code.fromAsset("./assets"),
handler: 'index.handler'
});
}
}

const product = new servicecatalog.CloudFormationProduct(this, 'Product', {
productName: "My Product",
owner: "Product Owner",
productVersions: [
{
productVersionName: "v1",
cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new LambdaProduct(this, 'LambdaFunctionProduct')),
},
],
});
```

You can also create your own ProductStackAssetBucket to store assets, allowing you to provide one bucket for multiple Product Stacks.

```ts
import * as lambda from '@aws-cdk/aws-lambda';
import * as cdk from '@aws-cdk/core';

class LambdaProduct extends servicecatalog.ProductStack {
constructor(scope: Construct, id: string) {
super(scope, id);

new lambda.Function(this, 'LambdaProduct', {
runtime: lambda.Runtime.PYTHON_3_9,
code: lambda.Code.fromAsset("./assets"),
handler: 'index.handler'
});
}
}

const userDefinedBucket = new ProductStackAssetBucket(this, `UserDefinedBucket`, {
assetBucketName: 'user-defined-bucket-for-product-stack-assets',
});

const product = new servicecatalog.CloudFormationProduct(this, 'Product', {
productName: "My Product",
owner: "Product Owner",
productVersions: [
{
productVersionName: "v1",
cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new LambdaProduct(this, 'LambdaFunctionProduct', {
assetBucket: userDefinedBucket,
})),
},
],
});
```

### 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.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { IBucket } from '@aws-cdk/aws-s3';
import * as s3_assets from '@aws-cdk/aws-s3-assets';
import { Construct } from 'constructs';
import { hashValues } from './private/util';
@@ -46,9 +47,16 @@ export abstract class CloudFormationTemplate {
*/
export interface CloudFormationTemplateConfig {
/**
* The http url of the template in S3.
*/
* The http url of the template in S3.
*/
readonly httpUrl: string;

/**
* The S3 bucket containing product stack assets.
* @default - None - no assets are used in this product
*/
readonly assetBucket?: IBucket;

}

/**
@@ -108,6 +116,7 @@ class CloudFormationProductStackTemplate extends CloudFormationTemplate {
public bind(_scope: Construct): CloudFormationTemplateConfig {
return {
httpUrl: this.productStack._getTemplateUrl(),
assetBucket: this.productStack._getAssetBucket(),
};
}
}
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/index.ts
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ export * from './cloudformation-template';
export * from './portfolio';
export * from './product';
export * from './product-stack';
export * from './product-stack-asset-bucket';
export * from './product-stack-history';
export * from './tag-options';

34 changes: 32 additions & 2 deletions packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as iam from '@aws-cdk/aws-iam';
import { IBucket } from '@aws-cdk/aws-s3';
import * as sns from '@aws-cdk/aws-sns';
import * as cdk from '@aws-cdk/core';
import { Construct } from 'constructs';
import { Construct, IConstruct } from 'constructs';
import { MessageLanguage } from './common';
import {
CloudFormationRuleConstraintOptions, CommonConstraintOptions,
@@ -105,7 +106,7 @@ export interface IPortfolio extends cdk.IResource {
* @param product A service catalog product.
* @param options options for the constraint.
*/
constrainCloudFormationParameters(product:IProduct, options: CloudFormationRuleConstraintOptions): void;
constrainCloudFormationParameters(product: IProduct, options: CloudFormationRuleConstraintOptions): void;

/**
* Force users to assume a certain role when launching a product.
@@ -155,6 +156,8 @@ abstract class PortfolioBase extends cdk.Resource implements IPortfolio {
public abstract readonly portfolioArn: string;
public abstract readonly portfolioId: string;
private readonly associatedPrincipals: Set<string> = new Set();
private readonly assetBuckets: Set<IBucket> = new Set<IBucket>();
private readonly sharedAccounts: string[] = [];

public giveAccessToRole(role: iam.IRole): void {
this.associatePrincipal(role.roleArn, role.node.addr);
@@ -169,11 +172,17 @@ abstract class PortfolioBase extends cdk.Resource implements IPortfolio {
}

public addProduct(product: IProduct): void {
if (product.assetBuckets) {
for (const bucket of product.assetBuckets) {
this.assetBuckets.add(bucket);
}
}
AssociationManager.associateProductWithPortfolio(this, product, undefined);
}

public shareWithAccount(accountId: string, options: PortfolioShareOptions = {}): void {
const hashId = this.generateUniqueHash(accountId);
this.sharedAccounts.push(accountId);
new CfnPortfolioShare(this, `PortfolioShare${hashId}`, {
portfolioId: this.portfolioId,
accountId: accountId,
@@ -236,6 +245,19 @@ abstract class PortfolioBase extends cdk.Resource implements IPortfolio {
}
}

/**
* Gives access to Asset Buckets to Shared Accounts.
*
*/
protected addBucketPermissionsToSharedAccounts() {
if (this.sharedAccounts.length > 0) {
for (const bucket of this.assetBuckets) {
bucket.grantRead(new iam.CompositePrincipal(...this.sharedAccounts.map(account => new iam.AccountPrincipal(account))),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How are you granting access on the other side? The IAM role that gets from these buckets needs
access to be granted access to the bucket in its princial policy.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in my other comment, but there is not much more we can do. We made it as easy as possible but we don't have access to the accounts it is being shared with, it is up to the Admin to configure these spoke accounts.

);
}
}
}

/**
* Create a unique id based off the L1 CfnPortfolio or the arn of an imported portfolio.
*/
@@ -336,6 +358,14 @@ export class Portfolio extends PortfolioBase {
if (props.tagOptions !== undefined) {
this.associateTagOptions(props.tagOptions);
}

cdk.Aspects.of(this).add({
visit(c: IConstruct) {
if (c instanceof Portfolio) {
c.addBucketPermissionsToSharedAccounts();
};
},
});
}

protected generateUniqueHash(value: string): string {
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { BlockPublicAccess, BucketEncryption, IBucket } from '@aws-cdk/aws-s3';
import * as cdk from '@aws-cdk/core';
import { ProductStack } from '../product-stack';
import { ProductStackAssetBucket } from '../product-stack-asset-bucket';
import { hashValues } from './util';

/**
* Deployment environment for an AWS Service Catalog product stack.
@@ -7,6 +11,12 @@ import * as cdk from '@aws-cdk/core';
*/
export class ProductStackSynthesizer extends cdk.StackSynthesizer {
private stack?: cdk.Stack;
private assetBucket?: IBucket;

constructor(assetBucket?: IBucket) {
super();
this.assetBucket = assetBucket;
}

public bind(stack: cdk.Stack): void {
if (this.stack !== undefined) {
@@ -15,8 +25,31 @@ export class ProductStackSynthesizer extends cdk.StackSynthesizer {
this.stack = stack;
}

public addFileAsset(_asset: cdk.FileAssetSource): cdk.FileAssetLocation {
throw new Error('Service Catalog Product Stacks cannot use Assets');
public addFileAsset(asset: cdk.FileAssetSource): cdk.FileAssetLocation {
if (!this.stack) {
throw new Error('You must call bindStack() first');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this message will reach the user. Do you mean to tell your user to call bindStack()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the error we throw in our synthesize method as well. I'm not sure about this, I don't think this is an error the user can act on regardless. I don't think we can ever reach this state, so maybe this error message is just for our own sanity.

}

if (!this.assetBucket) {
const parentStack = (this.stack as ProductStack)._getParentStack();
this.assetBucket = new ProductStackAssetBucket(parentStack, `ProductStackAssetBucket${hashValues(this.stack.stackName)}`, {
bucketName: (this.stack as ProductStack)._generateBucketName(),
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
encryption: BucketEncryption.KMS,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
}

(this.stack as ProductStack)._setAssetBucket(this.assetBucket);
(this.assetBucket as ProductStackAssetBucket)._addAsset(asset);

const bucketName = this.assetBucket.bucketName;
const s3Filename = asset.fileName?.split('.')[1] + '.zip';
const objectKey = `${s3Filename}`;
const s3ObjectUrl = `s3://${bucketName}/${objectKey}`;
const httpUrl = `https://s3.${bucketName}/${objectKey}`;

return { bucketName, objectKey, httpUrl, s3ObjectUrl, s3Url: httpUrl };
}

public addDockerImageAsset(_asset: cdk.DockerImageAssetSource): cdk.DockerImageAssetLocation {
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Bucket, BucketProps } from '@aws-cdk/aws-s3';
import { BucketDeployment, ISource, Source } from '@aws-cdk/aws-s3-deployment';
import * as cdk from '@aws-cdk/core';
import { Construct, IConstruct } from 'constructs';

/**
* Product stack asset bucket props.
*/
export interface ProductStackAssetBucketProps extends BucketProps {
}

/**
* A Service Catalog product stack asset bucket, which is similar in form to an Amazon S3 bucket.
* You can store multiple product stack assets and collectively deploy them to S3.
*/
export class ProductStackAssetBucket extends Bucket {
private readonly assets: ISource[];

constructor(scope: Construct, id: string, props: ProductStackAssetBucketProps = {}) {
super(scope, id, props);

if (props.bucketName == undefined) {
throw new Error('BucketName must be defined for assetBucket');
}

this.assets = [];

cdk.Aspects.of(this).add({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the reasoning behind using cdk.Aspects to run deploy assets? not questioning, just wondering.

Copy link
Contributor Author

@wanjacki wanjacki Sep 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be done without Aspects I believe, but Aspects fits the use case and allows us to cleanly implement our use case as well as abstract the additional code to the new product-stack-asset-bucket construct rather than modifying and adding additional code to product-stack.

visit(c: IConstruct) {
if (c instanceof ProductStackAssetBucket) {
c.deployAssets();
};
},
});
}

/**
* Asset are prepared for bulk deployment to S3.
* @internal
*/
public _addAsset(asset: cdk.FileAssetSource): void {
const assetPath = './cdk.out/' + asset.fileName;
this.assets.push(Source.asset(assetPath));
}

/**
* Deploy all assets to S3.
*/
private deployAssets() {
if (this.assets.length > 0) {
new BucketDeployment(this, 'AssetsBucketDeployment', {
sources: this.assets,
destinationBucket: this,
extract: false,
prune: false,
});
}
}
}
64 changes: 62 additions & 2 deletions packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { IBucket } from '@aws-cdk/aws-s3';
import * as cdk from '@aws-cdk/core';
import { Construct } from 'constructs';
import { ProductStackSynthesizer } from './private/product-stack-synthesizer';
import { hashValues } from './private/util';
import { ProductStackHistory } from './product-stack-history';

/**
* Product stack props.
*/
export interface ProductStackProps {
kaizencc marked this conversation as resolved.
Show resolved Hide resolved
/**
* A ProductStackAssetBucket will be generated per Product Stack to store assets.
* A ProductStackAssetBucket can be passed to store assets, enabling a single ProductStackAssetBucket
* to store assets for multiple Product Stacks if needed.
*
* @default - None only generated if needed
*/
readonly assetBucket?: IBucket;
}

/**
* 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.
@@ -20,10 +36,11 @@ export class ProductStack extends cdk.Stack {
private _parentProductStackHistory?: ProductStackHistory;
private _templateUrl?: string;
private _parentStack: cdk.Stack;
private assetBucket?: IBucket;

constructor(scope: Construct, id: string) {
constructor(scope: Construct, id: string, props: ProductStackProps = {}) {
super(scope, id, {
synthesizer: new ProductStackSynthesizer(),
synthesizer: new ProductStackSynthesizer(props.assetBucket),
});

this._parentStack = findParentStack(scope);
@@ -50,6 +67,49 @@ export class ProductStack extends cdk.Stack {
return cdk.Lazy.uncachedString({ produce: () => this._templateUrl });
}

/**
* Generate unique name for S3 bucket.
*
* @internal
*/
public _generateBucketName(): string {
const accountId = this._parentStack.account;
const region = this._parentStack.region;
if (cdk.Token.isUnresolved(accountId)) {
throw new Error('CDK Account ID must be defined in the application environment');
}
if (cdk.Token.isUnresolved(region)) {
throw new Error('CDK Region must be defined in the application environment');
}
return `product-stack-asset-bucket-${accountId}-${region}-${hashValues(this._parentStack.stackId)}`;
}

/**
* Fetch the asset bucket.
*
* @internal
*/
public _getAssetBucket(): IBucket | undefined {
return this.assetBucket;
}

/**
* Fetch the parent stack.
*
* @internal
*/
public _getParentStack(): cdk.Stack {
return this._parentStack;
}

/**
* Set the asset bucket.
*
* @internal
*/
public _setAssetBucket(assetBucket: IBucket) {
this.assetBucket = assetBucket;
}
/**
* Synthesize the product stack template, overrides the `super` class method.
*
16 changes: 16 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/lib/product.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { IBucket } from '@aws-cdk/aws-s3';
import { ArnFormat, IResource, Resource, Stack } from '@aws-cdk/core';
import { Construct } from 'constructs';
import { CloudFormationTemplate } from './cloudformation-template';
@@ -23,6 +24,13 @@ export interface IProduct extends IResource {
*/
readonly productId: string;

/**
* The asset buckets of a product created via product stack.
* @attribute
* @default - Empty - no assets are used in this product
*/
readonly assetBuckets?: IBucket[];

/**
* Associate Tag Options.
* A TagOption is a key-value pair managed in AWS Service Catalog.
@@ -171,6 +179,11 @@ export abstract class Product extends ProductBase {
export class CloudFormationProduct extends Product {
public readonly productArn: string;
public readonly productId: string;
/**
* The asset bucket of a product created via product stack.
* @default - Empty - no assets are used in this product
*/
public readonly assetBuckets: IBucket[] = [];

constructor(scope: Construct, id: string, props: CloudFormationProductProps) {
super(scope, id);
@@ -206,6 +219,9 @@ export class CloudFormationProduct extends Product {
props: CloudFormationProductProps): CfnCloudFormationProduct.ProvisioningArtifactPropertiesProperty[] {
return props.productVersions.map(productVersion => {
const template = productVersion.cloudFormationTemplate.bind(this);
if (template.assetBucket) {
this.assetBuckets.push(template.assetBucket);
}
InputValidator.validateUrl(this.node.path, 'provisioning template url', template.httpUrl);
return {
name: productVersion.productVersionName,
4 changes: 4 additions & 0 deletions packages/@aws-cdk/aws-servicecatalog/package.json
Original file line number Diff line number Diff line change
@@ -89,15 +89,19 @@
},
"dependencies": {
"@aws-cdk/aws-iam": "0.0.0",
"@aws-cdk/aws-s3": "0.0.0",
"@aws-cdk/aws-s3-assets": "0.0.0",
"@aws-cdk/aws-s3-deployment": "0.0.0",
"@aws-cdk/aws-sns": "0.0.0",
"@aws-cdk/core": "0.0.0",
"constructs": "^10.0.0"
},
"homepage": "https://github.com/aws/aws-cdk",
"peerDependencies": {
"@aws-cdk/aws-iam": "0.0.0",
"@aws-cdk/aws-s3": "0.0.0",
"@aws-cdk/aws-s3-assets": "0.0.0",
"@aws-cdk/aws-s3-deployment": "0.0.0",
"@aws-cdk/aws-sns": "0.0.0",
"@aws-cdk/core": "0.0.0",
"constructs": "^10.0.0"
25 changes: 24 additions & 1 deletion packages/@aws-cdk/aws-servicecatalog/test/integ.product.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as path from 'path';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This integration test only tests half of the scenario. It confirms that we can publish to the
bucket, but how do we know that we can then deploy one of these products?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same can be said about any of the other existing integration test. There exist validation in Service Catalog to validate the template before it can be created but it can't check everything. We can't provision the product in CDK code and even then trying to provision it would probably fall in the scope of a provision-product construct.
I'm not sure if is the products job to check if a Product is provisionable, Service Catalog allows us to create a Product regardless of if its deployable or not. We also do our best in unit test to check the generated template is as expected.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is different though than existing functionality. In this PR we are adding functionality outside of service catalog. This PR is completely useless if the consumer cannot access the assets in the bucket and we have no test to assert that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey Cory, I refactored ProductStackAssetBucket to extend S3.Bucket as suggested as well as implemented the other comments.
I also added additional unit test to check Bucket Name and Bucket Policy Permissions are correct in template when shared.
If we can catch that the bucket is correct and the permissions are correct then the product should be deployable and the bucket should be acessable. If this is still not enough, please advise what what other test we can add (I don't think provisioning it is an option).

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';
import { ProductStackHistory } from '../lib';
import { ProductStackAssetBucket, ProductStackHistory, ProductStackProps } from '../lib';

const app = new cdk.App();
const stack = new cdk.Stack(app, 'integ-servicecatalog-product');
@@ -15,12 +16,28 @@ class TestProductStack extends servicecatalog.ProductStack {
}
}

class TestAssetProductStack extends servicecatalog.ProductStack {
constructor(scope: any, id: string, props?: ProductStackProps) {
super(scope, id, props);

new s3_assets.Asset(this, 'testAsset', {
path: path.join(__dirname, 'products.template.zip'),
});
}
}

const productStackHistory = new ProductStackHistory(stack, 'ProductStackHistory', {
productStack: new TestProductStack(stack, 'SNSTopicProduct3'),
currentVersionName: 'v1',
currentVersionLocked: true,
});

const testAssetBucket = new ProductStackAssetBucket(stack, 'TestAssetBucket', {
bucketName: 'product-stack-asset-bucket-12345678-test-region',
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});

const product = new servicecatalog.CloudFormationProduct(stack, 'TestProduct', {
productName: 'testProduct',
owner: 'testOwner',
@@ -42,6 +59,12 @@ const product = new servicecatalog.CloudFormationProduct(stack, 'TestProduct', {
{
cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new TestProductStack(stack, 'SNSTopicProduct2')),
},
{
validateTemplate: false,
cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new TestAssetProductStack(stack, 'S3AssetProduct', {
assetBucket: testAssetBucket,
})),
},
productStackHistory.currentVersion(),
],
});
61 changes: 60 additions & 1 deletion packages/@aws-cdk/aws-servicecatalog/test/portfolio.test.ts
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import * as iam from '@aws-cdk/aws-iam';
import * as sns from '@aws-cdk/aws-sns';
import * as cdk from '@aws-cdk/core';
import * as servicecatalog from '../lib';
import { ProductStackAssetBucket } from '../lib';

describe('Portfolio', () => {
let stack: cdk.Stack;
@@ -13,7 +14,9 @@ describe('Portfolio', () => {
'@aws-cdk/core:newStyleStackSynthesis': false,
},
});
stack = new cdk.Stack(app);
stack = new cdk.Stack(app, 'Default', {
env: { account: '12345678', region: 'test-region' },
});
});

describe('portfolio creation and importing', () => {
@@ -194,6 +197,62 @@ describe('Portfolio', () => {
});
}),

test('portfolio share with assets', () => {
const assetBucket = new ProductStackAssetBucket(stack, 'MyProductStackAssetBucket', {
bucketName: 'test-asset-bucket',
});

const productStack = new servicecatalog.ProductStack(stack, 'MyProductStack', {
});

const product = new servicecatalog.CloudFormationProduct(stack, 'MyProduct', {
productName: 'testProduct',
owner: 'testOwner',
productVersions: [
{
productVersionName: 'v1',
cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(productStack),
},
],
});

product.assetBuckets.push(assetBucket);

const shareAccountId = '012345678901';

portfolio.addProduct(product);
portfolio.shareWithAccount(shareAccountId);

Template.fromStack(stack).hasResourceProperties('AWS::ServiceCatalog::PortfolioShare', {
AccountId: shareAccountId,
});
Template.fromStack(stack).hasResourceProperties('AWS::S3::Bucket', {
BucketName: 'test-asset-bucket',
});
Template.fromStack(stack).hasResourceProperties('AWS::S3::BucketPolicy', {
PolicyDocument: {
Statement: [{
Effect: 'Allow',
Action: ['s3:GetObject*', 's3:GetBucket*', 's3:List*'],
Principal: {
AWS: {
'Fn::Join': [
'',
[
'arn:',
{
Ref: 'AWS::Partition',
},
':iam::012345678901:root',
],
],
},
},
}],
},
});
}),

test('portfolio share with share tagOptions', () => {
const shareAccountId = '012345678901';

Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Template } from '@aws-cdk/assertions';
import * as cdk from '@aws-cdk/core';
import { FileAssetSource } from '@aws-cdk/core';
import { ProductStackAssetBucket } from '../lib';

describe('ProductStackAssetBucket', () => {
let app: cdk.App;
let stack: cdk.Stack;

beforeEach(() => {
app = new cdk.App();
stack = new cdk.Stack(app, 'Stack', {
env: { account: '12345678', region: 'test-region' },
});
});

test('default ProductStackAssetBucket creation', () => {
// WHEN
new ProductStackAssetBucket(stack, 'MyProductStackAssetBucket', {
bucketName: 'test-asset-bucket',
});

// THEN
Template.fromStack(stack).hasResourceProperties('AWS::S3::Bucket', {
BucketName: 'test-asset-bucket',
});
}),

test('default ProductStackAssetBucket creation missing bucketname', () => {
// WHEN
expect(() => {
new ProductStackAssetBucket(stack, 'MyProductStackAssetBucket');
}).toThrow('BucketName must be defined for assetBucket');

// THEN
Template.fromStack(stack).hasResourceProperties('AWS::S3::Bucket', {});
}),

test('ProductStackAssetBucket without assets avoids bucket deployment', () => {
// WHEN
new ProductStackAssetBucket(stack, 'MyProductStackAssetBucket', {
bucketName: 'test-asset-bucket',
});

// THEN
Template.fromStack(stack).resourceCountIs('Custom::CDKBucketDeployment', 0);
}),

test('ProductStackAssetBucket with assets creates bucket deployment', () => {
// GIVEN
const assetBucket = new ProductStackAssetBucket(stack, 'MyProductStackAssetBucket', {
bucketName: 'test-asset-bucket',
});

const asset = {
packaging: 'zip',
sourceHash: '3be8ad230b47f23554e7098c40e6e4f58ffc7c0cdddbf0da8c8cc105d6d25f2d',
fileName: '../test/cdk.out/asset.3be8ad230b47f23554e7098c40e6e4f58ffc7c0cdddbf0da8c8cc105d6d25f2d.zip',
} as FileAssetSource;

// WHEN
assetBucket._addAsset(asset);

// THEN
Template.fromStack(stack).resourceCountIs('Custom::CDKBucketDeployment', 1);
});
});
50 changes: 43 additions & 7 deletions packages/@aws-cdk/aws-servicecatalog/test/product-stack.test.ts
Original file line number Diff line number Diff line change
@@ -5,23 +5,59 @@ import * as sns from '@aws-cdk/aws-sns';
import * as cdk from '@aws-cdk/core';
import { Construct } from 'constructs';
import * as servicecatalog from '../lib';
import { ProductStackAssetBucket } from '../lib';

/* eslint-disable quote-props */
describe('ProductStack', () => {
test('fails to add asset to a product stack', () => {
test('Asset bucket undefined in product stack without assets', () => {
// GIVEN
const app = new cdk.App();
const mainStack = new cdk.Stack(app, 'MyStack');
const mainStack = new cdk.Stack(app, 'MyStack', {
env: { account: '12345678', region: 'test-region' },
});
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/);
expect(productStack._getAssetBucket()).toBeUndefined();
}),

test('Asset bucket defined in product stack with assets', () => {
// GIVEN
const app = new cdk.App();
const mainStack = new cdk.Stack(app, 'MyStack', {
env: { account: '12345678', region: 'test-region' },
});
const productStack = new servicecatalog.ProductStack(mainStack, 'MyProductStack');

// WHEN
new s3_assets.Asset(productStack, 'testAsset', {
path: path.join(__dirname, 'product1.template.json'),
});

// THEN
expect(productStack._getAssetBucket()).toBeDefined();
});

test('Used defined Asset bucket in product stack with assets', () => {
// GIVEN
const app = new cdk.App();
const mainStack = new cdk.Stack(app, 'MyStack');
const testAssetBucket = new ProductStackAssetBucket(mainStack, 'TestAssetBucket', {
bucketName: 'test-asset-bucket',
});
const productStack = new servicecatalog.ProductStack(mainStack, 'MyProductStack', {
assetBucket: testAssetBucket,
});

// WHEN
new s3_assets.Asset(productStack, 'testAsset', {
path: path.join(__dirname, 'product1.template.json'),
});

// THEN
expect(productStack._getAssetBucket()).toBeDefined();
});

test('fails if defined at root', () => {
// GIVEN
const app = new cdk.App();

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export declare function handler(event: AWSLambda.CloudFormationCustomResourceEvent): Promise<void>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { S3 } from 'aws-sdk';

const AUTO_DELETE_OBJECTS_TAG = 'aws-cdk:auto-delete-objects';

const s3 = new S3();

export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) {
switch (event.RequestType) {
case 'Create':
return;
case 'Update':
return onUpdate(event);
case 'Delete':
return onDelete(event.ResourceProperties?.BucketName);
}
}

async function onUpdate(event: AWSLambda.CloudFormationCustomResourceEvent) {
const updateEvent = event as AWSLambda.CloudFormationCustomResourceUpdateEvent;
const oldBucketName = updateEvent.OldResourceProperties?.BucketName;
const newBucketName = updateEvent.ResourceProperties?.BucketName;
const bucketNameHasChanged = newBucketName != null && oldBucketName != null && newBucketName !== oldBucketName;

/* If the name of the bucket has changed, CloudFormation will try to delete the bucket
and create a new one with the new name. So we have to delete the contents of the
bucket so that this operation does not fail. */
if (bucketNameHasChanged) {
return onDelete(oldBucketName);
}
}

/**
* Recursively delete all items in the bucket
*
* @param bucketName the bucket name
*/
async function emptyBucket(bucketName: string) {
const listedObjects = await s3.listObjectVersions({ Bucket: bucketName }).promise();
const contents = [...listedObjects.Versions ?? [], ...listedObjects.DeleteMarkers ?? []];
if (contents.length === 0) {
return;
}

const records = contents.map((record: any) => ({ Key: record.Key, VersionId: record.VersionId }));
await s3.deleteObjects({ Bucket: bucketName, Delete: { Objects: records } }).promise();

if (listedObjects?.IsTruncated) {
await emptyBucket(bucketName);
}
}

async function onDelete(bucketName?: string) {
if (!bucketName) {
throw new Error('No BucketName was provided.');
}
if (!await isBucketTaggedForDeletion(bucketName)) {
process.stdout.write(`Bucket does not have '${AUTO_DELETE_OBJECTS_TAG}' tag, skipping cleaning.\n`);
return;
}
try {
await emptyBucket(bucketName);
} catch (e) {
if (e.code !== 'NoSuchBucket') {
throw e;
}
// Bucket doesn't exist. Ignoring
}
}

/**
* The bucket will only be tagged for deletion if it's being deleted in the same
* deployment as this Custom Resource.
*
* If the Custom Resource is every deleted before the bucket, it must be because
* `autoDeleteObjects` has been switched to false, in which case the tag would have
* been removed before we get to this Delete event.
*/
async function isBucketTaggedForDeletion(bucketName: string) {
const response = await s3.getBucketTagging({ Bucket: bucketName }).promise();
return response.TagSet.some(tag => tag.Key === AUTO_DELETE_OBJECTS_TAG && tag.Value === 'true');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
import contextlib
import json
import logging
import os
import shutil
import subprocess
import tempfile
from urllib.request import Request, urlopen
from uuid import uuid4
from zipfile import ZipFile

import boto3

logger = logging.getLogger()
logger.setLevel(logging.INFO)

cloudfront = boto3.client('cloudfront')
s3 = boto3.client('s3')

CFN_SUCCESS = "SUCCESS"
CFN_FAILED = "FAILED"
ENV_KEY_MOUNT_PATH = "MOUNT_PATH"
ENV_KEY_SKIP_CLEANUP = "SKIP_CLEANUP"

CUSTOM_RESOURCE_OWNER_TAG = "aws-cdk:cr-owned"

def handler(event, context):

def cfn_error(message=None):
logger.error("| cfn_error: %s" % message)
cfn_send(event, context, CFN_FAILED, reason=message)

try:
# We are not logging ResponseURL as this is a pre-signed S3 URL, and could be used to tamper
# with the response CloudFormation sees from this Custom Resource execution.
logger.info({ key:value for (key, value) in event.items() if key != 'ResponseURL'})

# cloudformation request type (create/update/delete)
request_type = event['RequestType']

# extract resource properties
props = event['ResourceProperties']
old_props = event.get('OldResourceProperties', {})
physical_id = event.get('PhysicalResourceId', None)

try:
source_bucket_names = props['SourceBucketNames']
source_object_keys = props['SourceObjectKeys']
source_markers = props.get('SourceMarkers', None)
dest_bucket_name = props['DestinationBucketName']
dest_bucket_prefix = props.get('DestinationBucketKeyPrefix', '')
extract = props.get('Extract', 'true') == 'true'
retain_on_delete = props.get('RetainOnDelete', "true") == "true"
distribution_id = props.get('DistributionId', '')
user_metadata = props.get('UserMetadata', {})
system_metadata = props.get('SystemMetadata', {})
prune = props.get('Prune', 'true').lower() == 'true'
exclude = props.get('Exclude', [])
include = props.get('Include', [])

# backwards compatibility - if "SourceMarkers" is not specified,
# assume all sources have an empty market map
if source_markers is None:
source_markers = [{} for i in range(len(source_bucket_names))]

default_distribution_path = dest_bucket_prefix
if not default_distribution_path.endswith("/"):
default_distribution_path += "/"
if not default_distribution_path.startswith("/"):
default_distribution_path = "/" + default_distribution_path
default_distribution_path += "*"

distribution_paths = props.get('DistributionPaths', [default_distribution_path])
except KeyError as e:
cfn_error("missing request resource property %s. props: %s" % (str(e), props))
return

# treat "/" as if no prefix was specified
if dest_bucket_prefix == "/":
dest_bucket_prefix = ""

s3_source_zips = list(map(lambda name, key: "s3://%s/%s" % (name, key), source_bucket_names, source_object_keys))
s3_dest = "s3://%s/%s" % (dest_bucket_name, dest_bucket_prefix)
old_s3_dest = "s3://%s/%s" % (old_props.get("DestinationBucketName", ""), old_props.get("DestinationBucketKeyPrefix", ""))


# obviously this is not
if old_s3_dest == "s3:///":
old_s3_dest = None

logger.info("| s3_dest: %s" % s3_dest)
logger.info("| old_s3_dest: %s" % old_s3_dest)

# if we are creating a new resource, allocate a physical id for it
# otherwise, we expect physical id to be relayed by cloudformation
if request_type == "Create":
physical_id = "aws.cdk.s3deployment.%s" % str(uuid4())
else:
if not physical_id:
cfn_error("invalid request: request type is '%s' but 'PhysicalResourceId' is not defined" % request_type)
return

# delete or create/update (only if "retain_on_delete" is false)
if request_type == "Delete" and not retain_on_delete:
if not bucket_owned(dest_bucket_name, dest_bucket_prefix):
aws_command("s3", "rm", s3_dest, "--recursive")

# if we are updating without retention and the destination changed, delete first
if request_type == "Update" and not retain_on_delete and old_s3_dest != s3_dest:
if not old_s3_dest:
logger.warn("cannot delete old resource without old resource properties")
return

aws_command("s3", "rm", old_s3_dest, "--recursive")

if request_type == "Update" or request_type == "Create":
s3_deploy(s3_source_zips, s3_dest, user_metadata, system_metadata, prune, exclude, include, source_markers, extract)

if distribution_id:
cloudfront_invalidate(distribution_id, distribution_paths)

cfn_send(event, context, CFN_SUCCESS, physicalResourceId=physical_id, responseData={
# Passing through the ARN sequences dependencees on the deployment
'DestinationBucketArn': props.get('DestinationBucketArn'),
'SourceObjectKeys': props.get('SourceObjectKeys'),
})
except KeyError as e:
cfn_error("invalid request. Missing key %s" % str(e))
except Exception as e:
logger.exception(e)
cfn_error(str(e))

#---------------------------------------------------------------------------------------------------
# populate all files from s3_source_zips to a destination bucket
def s3_deploy(s3_source_zips, s3_dest, user_metadata, system_metadata, prune, exclude, include, source_markers, extract):
# list lengths are equal
if len(s3_source_zips) != len(source_markers):
raise Exception("'source_markers' and 's3_source_zips' must be the same length")

# create a temporary working directory in /tmp or if enabled an attached efs volume
if ENV_KEY_MOUNT_PATH in os.environ:
workdir = os.getenv(ENV_KEY_MOUNT_PATH) + "/" + str(uuid4())
os.mkdir(workdir)
else:
workdir = tempfile.mkdtemp()

logger.info("| workdir: %s" % workdir)

# create a directory into which we extract the contents of the zip file
contents_dir=os.path.join(workdir, 'contents')
os.mkdir(contents_dir)

try:
# download the archive from the source and extract to "contents"
for i in range(len(s3_source_zips)):
s3_source_zip = s3_source_zips[i]
markers = source_markers[i]

if extract:
archive=os.path.join(workdir, str(uuid4()))
logger.info("archive: %s" % archive)
aws_command("s3", "cp", s3_source_zip, archive)
logger.info("| extracting archive to: %s\n" % contents_dir)
logger.info("| markers: %s" % markers)
extract_and_replace_markers(archive, contents_dir, markers)
else:
logger.info("| copying archive to: %s\n" % contents_dir)
aws_command("s3", "cp", s3_source_zip, contents_dir)

# sync from "contents" to destination

s3_command = ["s3", "sync"]

if prune:
s3_command.append("--delete")

if exclude:
for filter in exclude:
s3_command.extend(["--exclude", filter])

if include:
for filter in include:
s3_command.extend(["--include", filter])

s3_command.extend([contents_dir, s3_dest])
s3_command.extend(create_metadata_args(user_metadata, system_metadata))
aws_command(*s3_command)
finally:
if not os.getenv(ENV_KEY_SKIP_CLEANUP):
shutil.rmtree(workdir)

#---------------------------------------------------------------------------------------------------
# invalidate files in the CloudFront distribution edge caches
def cloudfront_invalidate(distribution_id, distribution_paths):
invalidation_resp = cloudfront.create_invalidation(
DistributionId=distribution_id,
InvalidationBatch={
'Paths': {
'Quantity': len(distribution_paths),
'Items': distribution_paths
},
'CallerReference': str(uuid4()),
})
# by default, will wait up to 10 minutes
cloudfront.get_waiter('invalidation_completed').wait(
DistributionId=distribution_id,
Id=invalidation_resp['Invalidation']['Id'])

#---------------------------------------------------------------------------------------------------
# set metadata
def create_metadata_args(raw_user_metadata, raw_system_metadata):
if len(raw_user_metadata) == 0 and len(raw_system_metadata) == 0:
return []

format_system_metadata_key = lambda k: k.lower()
format_user_metadata_key = lambda k: k.lower()

system_metadata = { format_system_metadata_key(k): v for k, v in raw_system_metadata.items() }
user_metadata = { format_user_metadata_key(k): v for k, v in raw_user_metadata.items() }

flatten = lambda l: [item for sublist in l for item in sublist]
system_args = flatten([[f"--{k}", v] for k, v in system_metadata.items()])
user_args = ["--metadata", json.dumps(user_metadata, separators=(',', ':'))] if len(user_metadata) > 0 else []

return system_args + user_args + ["--metadata-directive", "REPLACE"]

#---------------------------------------------------------------------------------------------------
# executes an "aws" cli command
def aws_command(*args):
aws="/opt/awscli/aws" # from AwsCliLayer
logger.info("| aws %s" % ' '.join(args))
subprocess.check_call([aws] + list(args))

#---------------------------------------------------------------------------------------------------
# sends a response to cloudformation
def cfn_send(event, context, responseStatus, responseData={}, physicalResourceId=None, noEcho=False, reason=None):

responseUrl = event['ResponseURL']
logger.info(responseUrl)

responseBody = {}
responseBody['Status'] = responseStatus
responseBody['Reason'] = reason or ('See the details in CloudWatch Log Stream: ' + context.log_stream_name)
responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name
responseBody['StackId'] = event['StackId']
responseBody['RequestId'] = event['RequestId']
responseBody['LogicalResourceId'] = event['LogicalResourceId']
responseBody['NoEcho'] = noEcho
responseBody['Data'] = responseData

body = json.dumps(responseBody)
logger.info("| response body:\n" + body)

headers = {
'content-type' : '',
'content-length' : str(len(body))
}

try:
request = Request(responseUrl, method='PUT', data=bytes(body.encode('utf-8')), headers=headers)
with contextlib.closing(urlopen(request)) as response:
logger.info("| status code: " + response.reason)
except Exception as e:
logger.error("| unable to send response to CloudFormation")
logger.exception(e)


#---------------------------------------------------------------------------------------------------
# check if bucket is owned by a custom resource
# if it is then we don't want to delete content
def bucket_owned(bucketName, keyPrefix):
tag = CUSTOM_RESOURCE_OWNER_TAG
if keyPrefix != "":
tag = tag + ':' + keyPrefix
try:
request = s3.get_bucket_tagging(
Bucket=bucketName,
)
return any((x["Key"].startswith(tag)) for x in request["TagSet"])
except Exception as e:
logger.info("| error getting tags from bucket")
logger.exception(e)
return False

# extract archive and replace markers in output files
def extract_and_replace_markers(archive, contents_dir, markers):
with ZipFile(archive, "r") as zip:
zip.extractall(contents_dir)

# replace markers for this source
for file in zip.namelist():
file_path = os.path.join(contents_dir, file)
if os.path.isdir(file_path): continue
replace_markers(file_path, markers)

def replace_markers(filename, markers):
# convert the dict of string markers to binary markers
replace_tokens = dict([(k.encode('utf-8'), v.encode('utf-8')) for k, v in markers.items()])

outfile = filename + '.new'
with open(filename, 'rb') as fi, open(outfile, 'wb') as fo:
for line in fi:
for token in replace_tokens:
line = line.replace(token, replace_tokens[token])
fo.write(line)

# # delete the original file and rename the new one to the original
os.remove(filename)
os.rename(outfile, filename)
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version":"20.0.0"}
{"version":"21.0.0"}
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
{
"version": "20.0.0",
"version": "21.0.0",
"files": {
"60767da3831353fede3cfe92efef10580a600592dec8ccbb06c051e95b9c1b26": {
"source": {
"path": "asset.60767da3831353fede3cfe92efef10580a600592dec8ccbb06c051e95b9c1b26",
"packaging": "zip"
},
"destinations": {
"current_account-current_region": {
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
"objectKey": "60767da3831353fede3cfe92efef10580a600592dec8ccbb06c051e95b9c1b26.zip",
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
}
}
},
"b59f768286e16b69628bb23b9c1a1f07300a24101b8979d8e2a94ff1ab03d09e": {
"source": {
"path": "asset.b59f768286e16b69628bb23b9c1a1f07300a24101b8979d8e2a94ff1ab03d09e.json",
@@ -27,6 +40,45 @@
}
}
},
"c409e6c5845f1f349df8cd84e160bf6f1c35d2b060b63e1f032f9bd39d4542cc": {
"source": {
"path": "asset.c409e6c5845f1f349df8cd84e160bf6f1c35d2b060b63e1f032f9bd39d4542cc.zip",
"packaging": "file"
},
"destinations": {
"current_account-current_region": {
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
"objectKey": "c409e6c5845f1f349df8cd84e160bf6f1c35d2b060b63e1f032f9bd39d4542cc.zip",
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
}
}
},
"6ddcf10002539818a9256eff3fb2b22aa09298d8f946e26ba121c175a600c44e": {
"source": {
"path": "asset.6ddcf10002539818a9256eff3fb2b22aa09298d8f946e26ba121c175a600c44e",
"packaging": "zip"
},
"destinations": {
"current_account-current_region": {
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
"objectKey": "6ddcf10002539818a9256eff3fb2b22aa09298d8f946e26ba121c175a600c44e.zip",
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
}
}
},
"95c924c84f5d023be4edee540cb2cb401a49f115d01ed403b288f6cb412771df": {
"source": {
"path": "asset.95c924c84f5d023be4edee540cb2cb401a49f115d01ed403b288f6cb412771df.zip",
"packaging": "file"
},
"destinations": {
"current_account-current_region": {
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
"objectKey": "95c924c84f5d023be4edee540cb2cb401a49f115d01ed403b288f6cb412771df.zip",
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
}
}
},
"dd2d087eeb6ede1d2a9166639ccbde7bd1b10eef9ba2b4cb3d9855faa4fe8c1f": {
"source": {
"path": "integservicecatalogproductSNSTopicProduct24C7C16DA.product.template.json",
@@ -40,15 +92,28 @@
}
}
},
"055a185452c29b6ca4df318d9059b86b1bd73d14c95904474dde4df581580c4a": {
"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a": {
"source": {
"path": "integservicecatalogproductS3AssetProductCED6E119.product.template.json",
"packaging": "file"
},
"destinations": {
"current_account-current_region": {
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
"objectKey": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a.json",
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
}
}
},
"b77c382808de52abe7ba0a539e4252f21e8051e818093107fececca32558b03c": {
"source": {
"path": "integ-servicecatalog-product.template.json",
"packaging": "file"
},
"destinations": {
"current_account-current_region": {
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
"objectKey": "055a185452c29b6ca4df318d9059b86b1bd73d14c95904474dde4df581580c4a.json",
"objectKey": "b77c382808de52abe7ba0a539e4252f21e8051e818093107fececca32558b03c.json",
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,185 @@
{
"Resources": {
"TestAssetBucket9434EFAE": {
"Type": "AWS::S3::Bucket",
"Properties": {
"BucketName": "product-stack-asset-bucket-12345678-test-region",
"Tags": [
{
"Key": "aws-cdk:auto-delete-objects",
"Value": "true"
}
]
},
"UpdateReplacePolicy": "Delete",
"DeletionPolicy": "Delete"
},
"TestAssetBucketPolicy62167ACB": {
"Type": "AWS::S3::BucketPolicy",
"Properties": {
"Bucket": {
"Ref": "TestAssetBucket9434EFAE"
},
"PolicyDocument": {
"Statement": [
{
"Action": [
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:List*"
],
"Effect": "Allow",
"Principal": {
"AWS": {
"Fn::GetAtt": [
"CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
"Arn"
]
}
},
"Resource": [
{
"Fn::GetAtt": [
"TestAssetBucket9434EFAE",
"Arn"
]
},
{
"Fn::Join": [
"",
[
{
"Fn::GetAtt": [
"TestAssetBucket9434EFAE",
"Arn"
]
},
"/*"
]
]
}
]
}
],
"Version": "2012-10-17"
}
}
},
"TestAssetBucketAutoDeleteObjectsCustomResource5A0F8F22": {
"Type": "Custom::S3AutoDeleteObjects",
"Properties": {
"ServiceToken": {
"Fn::GetAtt": [
"CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F",
"Arn"
]
},
"BucketName": {
"Ref": "TestAssetBucket9434EFAE"
}
},
"DependsOn": [
"TestAssetBucketPolicy62167ACB"
],
"UpdateReplacePolicy": "Delete",
"DeletionPolicy": "Delete"
},
"TestAssetBucketAssetsBucketDeploymentAwsCliLayerACBB7455": {
"Type": "AWS::Lambda::LayerVersion",
"Properties": {
"Content": {
"S3Bucket": {
"Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}"
},
"S3Key": "c409e6c5845f1f349df8cd84e160bf6f1c35d2b060b63e1f032f9bd39d4542cc.zip"
},
"Description": "/opt/awscli/aws"
}
},
"TestAssetBucketAssetsBucketDeploymentCustomResource258ED7DF": {
"Type": "Custom::CDKBucketDeployment",
"Properties": {
"ServiceToken": {
"Fn::GetAtt": [
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536",
"Arn"
]
},
"SourceBucketNames": [
{
"Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}"
}
],
"SourceObjectKeys": [
"95c924c84f5d023be4edee540cb2cb401a49f115d01ed403b288f6cb412771df.zip"
],
"DestinationBucketName": {
"Ref": "TestAssetBucket9434EFAE"
},
"Extract": false,
"Prune": false
},
"UpdateReplacePolicy": "Delete",
"DeletionPolicy": "Delete"
},
"CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
}
}
]
},
"ManagedPolicyArns": [
{
"Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
]
}
},
"CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": {
"Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}"
},
"S3Key": "60767da3831353fede3cfe92efef10580a600592dec8ccbb06c051e95b9c1b26.zip"
},
"Timeout": 900,
"MemorySize": 128,
"Handler": "__entrypoint__.handler",
"Role": {
"Fn::GetAtt": [
"CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
"Arn"
]
},
"Runtime": "nodejs14.x",
"Description": {
"Fn::Join": [
"",
[
"Lambda function for auto-deleting objects in ",
{
"Ref": "TestAssetBucket9434EFAE"
},
" S3 bucket."
]
]
}
},
"DependsOn": [
"CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092"
]
},
"TestProduct7606930B": {
"Type": "AWS::ServiceCatalog::CloudFormationProduct",
"Properties": {
@@ -44,6 +224,14 @@
}
}
},
{
"DisableTemplateValidation": true,
"Info": {
"LoadTemplateFromURL": {
"Fn::Sub": "https://s3.${AWS::Region}.${AWS::URLSuffix}/cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a.json"
}
}
},
{
"DisableTemplateValidation": false,
"Info": {
@@ -112,6 +300,160 @@
"Value": "value1",
"Active": true
}
},
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Statement": [
{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
}
}
],
"Version": "2012-10-17"
},
"ManagedPolicyArns": [
{
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
]
]
}
]
}
},
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF": {
"Type": "AWS::IAM::Policy",
"Properties": {
"PolicyDocument": {
"Statement": [
{
"Action": [
"s3:GetBucket*",
"s3:GetObject*",
"s3:List*"
],
"Effect": "Allow",
"Resource": [
{
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":s3:::",
{
"Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}"
},
"/*"
]
]
},
{
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":s3:::",
{
"Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}"
}
]
]
}
]
},
{
"Action": [
"s3:Abort*",
"s3:DeleteObject*",
"s3:GetBucket*",
"s3:GetObject*",
"s3:List*",
"s3:PutObject",
"s3:PutObjectLegalHold",
"s3:PutObjectRetention",
"s3:PutObjectTagging",
"s3:PutObjectVersionTagging"
],
"Effect": "Allow",
"Resource": [
{
"Fn::GetAtt": [
"TestAssetBucket9434EFAE",
"Arn"
]
},
{
"Fn::Join": [
"",
[
{
"Fn::GetAtt": [
"TestAssetBucket9434EFAE",
"Arn"
]
},
"/*"
]
]
}
]
}
],
"Version": "2012-10-17"
},
"PolicyName": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF",
"Roles": [
{
"Ref": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265"
}
]
}
},
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": {
"Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}"
},
"S3Key": "6ddcf10002539818a9256eff3fb2b22aa09298d8f946e26ba121c175a600c44e.zip"
},
"Role": {
"Fn::GetAtt": [
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265",
"Arn"
]
},
"Handler": "index.handler",
"Layers": [
{
"Ref": "TestAssetBucketAssetsBucketDeploymentAwsCliLayerACBB7455"
}
],
"Runtime": "python3.9",
"Timeout": 900
},
"DependsOn": [
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF",
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265"
]
}
},
"Parameters": {
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "20.0.0",
"version": "21.0.0",
"testCases": {
"integ.product": {
"stacks": [
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "20.0.0",
"version": "21.0.0",
"artifacts": {
"Tree": {
"type": "cdk:tree",
@@ -23,7 +23,7 @@
"validateOnSynth": false,
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}",
"cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}",
"stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/055a185452c29b6ca4df318d9059b86b1bd73d14c95904474dde4df581580c4a.json",
"stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/b77c382808de52abe7ba0a539e4252f21e8051e818093107fececca32558b03c.json",
"requiresBootstrapStackVersion": 6,
"bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version",
"additionalDependencies": [
@@ -39,6 +39,54 @@
"integ-servicecatalog-product.assets"
],
"metadata": {
"/integ-servicecatalog-product/TestAssetBucket": [
{
"type": "aws:cdk:warning",
"data": "We detected an Aspect was added via another Aspect, and will not be applied"
}
],
"/integ-servicecatalog-product/TestAssetBucket/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "TestAssetBucket9434EFAE"
}
],
"/integ-servicecatalog-product/TestAssetBucket/Policy/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "TestAssetBucketPolicy62167ACB"
}
],
"/integ-servicecatalog-product/TestAssetBucket/AutoDeleteObjectsCustomResource/Default": [
{
"type": "aws:cdk:logicalId",
"data": "TestAssetBucketAutoDeleteObjectsCustomResource5A0F8F22"
}
],
"/integ-servicecatalog-product/TestAssetBucket/AssetsBucketDeployment/AwsCliLayer/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "TestAssetBucketAssetsBucketDeploymentAwsCliLayerACBB7455"
}
],
"/integ-servicecatalog-product/TestAssetBucket/AssetsBucketDeployment/CustomResource/Default": [
{
"type": "aws:cdk:logicalId",
"data": "TestAssetBucketAssetsBucketDeploymentCustomResource258ED7DF"
}
],
"/integ-servicecatalog-product/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role": [
{
"type": "aws:cdk:logicalId",
"data": "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092"
}
],
"/integ-servicecatalog-product/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler": [
{
"type": "aws:cdk:logicalId",
"data": "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F"
}
],
"/integ-servicecatalog-product/TestProduct/Resource": [
{
"type": "aws:cdk:logicalId",
@@ -81,6 +129,24 @@
"data": "TagOptionsa260cbbd99c416C40F73"
}
],
"/integ-servicecatalog-product/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265"
}
],
"/integ-servicecatalog-product/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/DefaultPolicy/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF"
}
],
"/integ-servicecatalog-product/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536"
}
],
"/integ-servicecatalog-product/BootstrapVersion": [
{
"type": "aws:cdk:logicalId",

Large diffs are not rendered by default.

Binary file not shown.