Skip to content

Commit ceaac3a

Browse files
authored
feat(servicecatalog): Add Product Stack Asset Support (#22857)
Currently Assets are not supported in Product Stacks. Service Catalog has an unique use case where assets need to be shared cross account and sharing the entire CDK asset bucket is not ideal. Users can either create their own ProductStackAssetBucket or have one automatically generated for them based on their account Id and region. By using S3 Deployments we able to copy the assets to that bucket and share it when a portfolio is shared in Service Catalog. More details can be found here: #20690. Closes #20690 RFC: aws/aws-cdk-rfcs#458 ---- ### All Submissions: * [X ] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [X] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [X] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [X ] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* ---- Co-authored-by: Theron Mansilla[[imanolympic](https://github.com/imanolympic)]
1 parent 6975a7e commit ceaac3a

File tree

36 files changed

+2752
-270
lines changed

36 files changed

+2752
-270
lines changed

packages/@aws-cdk/aws-servicecatalog/README.md

+103
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ enables organizations to create and manage catalogs of products for their end us
2222
- [Product](#product)
2323
- [Creating a product from a local asset](#creating-a-product-from-local-asset)
2424
- [Creating a product from a stack](#creating-a-product-from-a-stack)
25+
- [Using Assets in your Product Stack](#using-aseets-in-your-product-stack)
2526
- [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)
2627
- [Adding a product to a portfolio](#adding-a-product-to-a-portfolio)
2728
- [TagOptions](#tag-options)
@@ -185,6 +186,108 @@ const product = new servicecatalog.CloudFormationProduct(this, 'Product', {
185186
});
186187
```
187188

189+
### Using Assets in your Product Stack
190+
191+
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.
192+
In this case, you must provide a S3 Bucket with a bucketName to store your assets.
193+
194+
```ts
195+
import * as lambda from '@aws-cdk/aws-lambda';
196+
import * as cdk from '@aws-cdk/core';
197+
import { Bucket } from "@aws-cdk/aws-s3";
198+
199+
class LambdaProduct extends servicecatalog.ProductStack {
200+
constructor(scope: Construct, id: string) {
201+
super(scope, id);
202+
203+
new lambda.Function(this, 'LambdaProduct', {
204+
runtime: lambda.Runtime.PYTHON_3_9,
205+
code: lambda.Code.fromAsset("./assets"),
206+
handler: 'index.handler'
207+
});
208+
}
209+
}
210+
211+
const userDefinedBucket = new Bucket(this, `UserDefinedBucket`, {
212+
bucketName: 'user-defined-bucket-for-product-stack-assets',
213+
});
214+
215+
const product = new servicecatalog.CloudFormationProduct(this, 'Product', {
216+
productName: "My Product",
217+
owner: "Product Owner",
218+
productVersions: [
219+
{
220+
productVersionName: "v1",
221+
cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(new LambdaProduct(this, 'LambdaFunctionProduct', {
222+
assetBucket: userDefinedBucket,
223+
})),
224+
},
225+
],
226+
});
227+
```
228+
229+
When a product containing an asset is shared with a spoke account, the corresponding asset bucket
230+
will automatically grant read permissions to the spoke account.
231+
Note, it is not recommended using a referenced bucket as permissions cannot be added from CDK.
232+
In this case, it will be your responsibility to grant read permissions for the asset bucket to
233+
the spoke account.
234+
If you want to provide your own bucket policy or scope down your bucket policy further to only allow
235+
reads from a specific launch role, refer to the following example policy:
236+
237+
```ts
238+
new iam.PolicyStatement({
239+
actions: [
240+
's3:GetObject*',
241+
's3:GetBucket*',
242+
's3:List*', ],
243+
effect: iam.Effect.ALLOW,
244+
resources: [
245+
bucket.bucketArn,
246+
bucket.arnForObjects('*'),
247+
],
248+
principals: [
249+
new iam.ArnPrincipal(cdk.Stack.of(this).formatArn({
250+
service: 'iam',
251+
region: '',
252+
sharedAccount,
253+
resource: 'role',
254+
resourceName: launchRoleName,
255+
}))
256+
],
257+
conditions: {
258+
'ForAnyValue:StringEquals': {
259+
'aws:CalledVia': ['cloudformation.amazonaws.com'],
260+
},
261+
'Bool': {
262+
'aws:ViaAWSService': true,
263+
},
264+
},
265+
});
266+
```
267+
268+
Furthermore, in order for a spoke account to provision a product with an asset, the role launching
269+
the product needs permissions to read from the asset bucket.
270+
We recommend you utilize a launch role with permissions to read from the asset bucket.
271+
For example your launch role would need to include at least the following policy:
272+
273+
```json
274+
{
275+
"Statement": [
276+
{
277+
"Effect": "Allow",
278+
"Action": [
279+
"s3:GetObject"
280+
],
281+
"Resource": "*"
282+
}
283+
]
284+
}
285+
```
286+
287+
Please refer to [Set launch role](#set-launch-role) for additional details about launch roles.
288+
See [Launch Constraint](https://docs.aws.amazon.com/servicecatalog/latest/adminguide/constraints-launch.html) documentation
289+
to understand the permissions that launch roles need.
290+
188291
### Creating a Product from a stack with a history of previous versions
189292

190293
The default behavior of Service Catalog is to overwrite each product version upon deployment.

packages/@aws-cdk/aws-servicecatalog/lib/cloudformation-template.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { IBucket } from '@aws-cdk/aws-s3';
12
import * as s3_assets from '@aws-cdk/aws-s3-assets';
23
import { Construct } from 'constructs';
34
import { hashValues } from './private/util';
@@ -46,9 +47,16 @@ export abstract class CloudFormationTemplate {
4647
*/
4748
export interface CloudFormationTemplateConfig {
4849
/**
49-
* The http url of the template in S3.
50-
*/
50+
* The http url of the template in S3.
51+
*/
5152
readonly httpUrl: string;
53+
54+
/**
55+
* The S3 bucket containing product stack assets.
56+
* @default - None - no assets are used in this product
57+
*/
58+
readonly assetBucket?: IBucket;
59+
5260
}
5361

5462
/**
@@ -108,6 +116,7 @@ class CloudFormationProductStackTemplate extends CloudFormationTemplate {
108116
public bind(_scope: Construct): CloudFormationTemplateConfig {
109117
return {
110118
httpUrl: this.productStack._getTemplateUrl(),
119+
assetBucket: this.productStack._getAssetBucket(),
111120
};
112121
}
113122
}

packages/@aws-cdk/aws-servicecatalog/lib/portfolio.ts

+33-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import * as iam from '@aws-cdk/aws-iam';
2+
import { IBucket } from '@aws-cdk/aws-s3';
23
import * as sns from '@aws-cdk/aws-sns';
34
import * as cdk from '@aws-cdk/core';
4-
import { Construct } from 'constructs';
5+
import { Construct, IConstruct } from 'constructs';
56
import { MessageLanguage } from './common';
67
import {
78
CloudFormationRuleConstraintOptions, CommonConstraintOptions,
@@ -105,7 +106,7 @@ export interface IPortfolio extends cdk.IResource {
105106
* @param product A service catalog product.
106107
* @param options options for the constraint.
107108
*/
108-
constrainCloudFormationParameters(product:IProduct, options: CloudFormationRuleConstraintOptions): void;
109+
constrainCloudFormationParameters(product: IProduct, options: CloudFormationRuleConstraintOptions): void;
109110

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

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

171174
public addProduct(product: IProduct): void {
175+
if (product.assetBuckets) {
176+
for (const bucket of product.assetBuckets) {
177+
this.assetBuckets.add(bucket);
178+
}
179+
}
172180
AssociationManager.associateProductWithPortfolio(this, product, undefined);
173181
}
174182

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

248+
/**
249+
* Gives access to Asset Buckets to Shared Accounts.
250+
*
251+
*/
252+
protected addBucketPermissionsToSharedAccounts() {
253+
if (this.sharedAccounts.length > 0) {
254+
for (const bucket of this.assetBuckets) {
255+
bucket.grantRead(new iam.CompositePrincipal(...this.sharedAccounts.map(account => new iam.AccountPrincipal(account))),
256+
);
257+
}
258+
}
259+
}
260+
239261
/**
240262
* Create a unique id based off the L1 CfnPortfolio or the arn of an imported portfolio.
241263
*/
@@ -336,6 +358,15 @@ export class Portfolio extends PortfolioBase {
336358
if (props.tagOptions !== undefined) {
337359
this.associateTagOptions(props.tagOptions);
338360
}
361+
362+
const portfolioNodeId = this.node.id;
363+
cdk.Aspects.of(this).add({
364+
visit(c: IConstruct) {
365+
if (c.node.id === portfolioNodeId) {
366+
(c as Portfolio).addBucketPermissionsToSharedAccounts();
367+
};
368+
},
369+
});
339370
}
340371

341372
protected generateUniqueHash(value: string): string {

packages/@aws-cdk/aws-servicecatalog/lib/private/product-stack-synthesizer.ts

+55-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,66 @@
1+
import { CfnBucket, IBucket } from '@aws-cdk/aws-s3';
2+
import { BucketDeployment, Source } from '@aws-cdk/aws-s3-deployment';
13
import * as cdk from '@aws-cdk/core';
4+
import { ProductStack } from '../product-stack';
25

36
/**
47
* Deployment environment for an AWS Service Catalog product stack.
58
*
69
* Interoperates with the StackSynthesizer of the parent stack.
710
*/
811
export class ProductStackSynthesizer extends cdk.StackSynthesizer {
9-
public addFileAsset(_asset: cdk.FileAssetSource): cdk.FileAssetLocation {
10-
throw new Error('Service Catalog Product Stacks cannot use Assets');
12+
private readonly assetBucket?: IBucket;
13+
private bucketDeployment?: BucketDeployment;
14+
15+
constructor(assetBucket?: IBucket) {
16+
super();
17+
this.assetBucket = assetBucket;
18+
}
19+
20+
public addFileAsset(asset: cdk.FileAssetSource): cdk.FileAssetLocation {
21+
if (!this.assetBucket) {
22+
throw new Error('An Asset Bucket must be provided to use Assets');
23+
}
24+
const outdir = cdk.App.of(this.boundStack)?.outdir ?? 'cdk.out';
25+
const assetPath = `./${outdir}/${asset.fileName}`;
26+
if (!this.bucketDeployment) {
27+
const parentStack = (this.boundStack as ProductStack)._getParentStack();
28+
if (!cdk.Resource.isOwnedResource(this.assetBucket)) {
29+
cdk.Annotations.of(parentStack).addWarning('[WARNING] Bucket Policy Permissions cannot be added to' +
30+
' referenced Bucket. Please make sure your bucket has the correct permissions');
31+
}
32+
this.bucketDeployment = new BucketDeployment(parentStack, 'AssetsBucketDeployment', {
33+
sources: [Source.asset(assetPath)],
34+
destinationBucket: this.assetBucket,
35+
extract: false,
36+
prune: false,
37+
});
38+
} else {
39+
this.bucketDeployment.addSource(Source.asset(assetPath));
40+
}
41+
42+
const physicalName = this.physicalNameOfBucket(this.assetBucket);
43+
44+
const bucketName = physicalName;
45+
const s3Filename = asset.fileName?.split('.')[1] + '.zip';
46+
const objectKey = `${s3Filename}`;
47+
const s3ObjectUrl = `s3://${bucketName}/${objectKey}`;
48+
const httpUrl = `https://s3.${bucketName}/${objectKey}`;
49+
50+
return { bucketName, objectKey, httpUrl, s3ObjectUrl, s3Url: httpUrl };
51+
}
52+
53+
private physicalNameOfBucket(bucket: IBucket) {
54+
let resolvedName;
55+
if (cdk.Resource.isOwnedResource(bucket)) {
56+
resolvedName = cdk.Stack.of(bucket).resolve((bucket.node.defaultChild as CfnBucket).bucketName);
57+
} else {
58+
resolvedName = bucket.bucketName;
59+
}
60+
if (resolvedName === undefined) {
61+
throw new Error('A bucketName must be provided to use Assets');
62+
}
63+
return resolvedName;
1164
}
1265

1366
public addDockerImageAsset(_asset: cdk.DockerImageAssetSource): cdk.DockerImageAssetLocation {

packages/@aws-cdk/aws-servicecatalog/lib/product-stack.ts

+36-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
import * as crypto from 'crypto';
22
import * as fs from 'fs';
33
import * as path from 'path';
4+
import { IBucket } from '@aws-cdk/aws-s3';
45
import * as cdk from '@aws-cdk/core';
56
import { Construct } from 'constructs';
67
import { ProductStackSynthesizer } from './private/product-stack-synthesizer';
78
import { ProductStackHistory } from './product-stack-history';
89

10+
/**
11+
* Product stack props.
12+
*/
13+
export interface ProductStackProps {
14+
/**
15+
* A Bucket can be passed to store assets, enabling ProductStack Asset support
16+
* @default No Bucket provided and Assets will not be supported.
17+
*/
18+
readonly assetBucket?: IBucket;
19+
}
20+
921
/**
1022
* A Service Catalog product stack, which is similar in form to a Cloudformation nested stack.
1123
* You can add the resources to this stack that you want to define for your service catalog product.
@@ -21,15 +33,19 @@ export class ProductStack extends cdk.Stack {
2133
private _templateUrl?: string;
2234
private _parentStack: cdk.Stack;
2335

24-
constructor(scope: Construct, id: string) {
36+
private assetBucket?: IBucket;
37+
38+
constructor(scope: Construct, id: string, props: ProductStackProps = {}) {
2539
super(scope, id, {
26-
synthesizer: new ProductStackSynthesizer(),
40+
synthesizer: new ProductStackSynthesizer(props.assetBucket),
2741
});
2842

2943
this._parentStack = findParentStack(scope);
3044

3145
// this is the file name of the synthesized template file within the cloud assembly
3246
this.templateFile = `${cdk.Names.uniqueId(this)}.product.template.json`;
47+
48+
this.assetBucket = props.assetBucket;
3349
}
3450

3551
/**
@@ -50,6 +66,24 @@ export class ProductStack extends cdk.Stack {
5066
return cdk.Lazy.uncachedString({ produce: () => this._templateUrl });
5167
}
5268

69+
/**
70+
* Fetch the asset bucket.
71+
*
72+
* @internal
73+
*/
74+
public _getAssetBucket(): IBucket | undefined {
75+
return this.assetBucket;
76+
}
77+
78+
/**
79+
* Fetch the parent Stack.
80+
*
81+
* @internal
82+
*/
83+
public _getParentStack(): cdk.Stack {
84+
return this._parentStack;
85+
}
86+
5387
/**
5488
* Synthesize the product stack template, overrides the `super` class method.
5589
*

0 commit comments

Comments
 (0)