From 497227d991e3de36e55f42d231e3f3cb6102905e Mon Sep 17 00:00:00 2001 From: Josh Demuth Date: Tue, 6 Feb 2024 13:33:20 -0500 Subject: [PATCH] feat: allow multi region stackset deployments with file assets (#325) Fixes #159 Added support to use file assets across multiple regions in one stackset. This still requires to pass the asset bucket(s) to the construct. It also uses a bucket "prefix" which the stackset construct will append "-${region}" to the end of the bucket name. For example, all asset buckets across each region will need to have the same prefix like "assetbucket" and will need the region appended at the end "assetbucket-us-east-1" "assetbucket-us-west-2". it is out of scope of the construct to create these buckets at this time. --- API.md | 52 +- README.md | 18 +- src/stackset-stack.ts | 104 ++-- src/stackset.ts | 9 +- test/integ.stack-set.ts | 3 +- .../integ-stackset-asset-test.assets.json | 4 +- .../integ-stackset-asset-test.template.json | 526 +++++++++--------- .../integ.stack-set.ts.snapshot/manifest.json | 4 +- test/integ.stack-set.ts.snapshot/tree.json | 6 +- test/stack-set.test.ts | 96 +++- yarn.lock | 42 +- 11 files changed, 498 insertions(+), 366 deletions(-) diff --git a/API.md b/API.md index 99604e0..44e8f6f 100644 --- a/API.md +++ b/API.md @@ -255,9 +255,9 @@ AWS accounts when they are added or removed from the specified organizational un You can use the StackSet's parent stack to facilitate file assets. Behind the scenes, this is accomplished using the `BucketDeployment` construct from the -`aws_s3_deployment` module. You need to provide a bucket outside the scope of the CDK -managed asset buckets and ensure you have persmissions for the target accounts to pull -the artifacts from the supplied bucket. +`aws_s3_deployment` module. You need to provide a list of buckets outside the scope of the CDK +managed asset buckets and ensure you have permissions for the target accounts to pull +the artifacts from the supplied bucket(s). As a basic example, if using a `serviceManaged` deployment, you just need to give read access to the Organization. You can create the asset bucket in the parent stack, or another @@ -267,7 +267,7 @@ If creating in the parent or sibling stack you could create and export similar t ```ts const bucket = new s3.Bucket(this, "Assets", { - bucketName: "cdkstacket-asset-bucket-xyz", + bucketName: "prefix-us-east-1", }); bucket.addToResourcePolicy( @@ -285,11 +285,17 @@ Then pass as a prop to the StackSet stack: declare const bucket: s3.Bucket; const stack = new Stack(); const stackSetStack = new StackSetStack(stack, 'MyStackSet', { - assetBucket: bucket, + assetBuckets: [bucket], + assetBucketPrefix: "prefix", }); ``` -Then call `new StackSet` as described in the sections above. +To faciliate multi region deployments, there is an assetBucketPrefix property. This +gets added to the region the Stack Set is deployed to. The stack synthesis for +the Stack Set would look for a bucket named `prefix-{Region}` in the example +above. `{Region}` is whatever region you are deploying the Stack Set to as +defined in your target property of the StackSet. You will need to ensure the +bucket name is correct based on what was previously created and then passed in. You can use self-managed StackSet deployments with file assets too but will need to ensure all target accounts roles will have access to the central asset @@ -2054,17 +2060,28 @@ const stackSetStackProps: StackSetStackProps = { ... } | **Name** | **Type** | **Description** | | --- | --- | --- | -| assetBucket | aws-cdk-lib.aws_s3.IBucket | A Bucket can be passed to store assets, enabling StackSetStack Asset support. | +| assetBucketPrefix | string | *No description.* | +| assetBuckets | aws-cdk-lib.aws_s3.IBucket[] | A Bucket can be passed to store assets, enabling StackSetStack Asset support. | --- -##### `assetBucket`Optional +##### `assetBucketPrefix`Optional ```typescript -public readonly assetBucket: IBucket; +public readonly assetBucketPrefix: string; ``` -- *Type:* aws-cdk-lib.aws_s3.IBucket +- *Type:* string + +--- + +##### `assetBuckets`Optional + +```typescript +public readonly assetBuckets: IBucket[]; +``` + +- *Type:* aws-cdk-lib.aws_s3.IBucket[] - *Default:* No Bucket provided and Assets will not be supported. A Bucket can be passed to store assets, enabling StackSetStack Asset support. @@ -2208,18 +2225,25 @@ Interoperates with the StackSynthesizer of the parent stack. ```typescript import { StackSetStackSynthesizer } from 'cdk-stacksets' -new StackSetStackSynthesizer(assetBucket?: IBucket) +new StackSetStackSynthesizer(assetBuckets?: IBucket[], assetBucketPrefix?: string) ``` | **Name** | **Type** | **Description** | | --- | --- | --- | -| assetBucket | aws-cdk-lib.aws_s3.IBucket | *No description.* | +| assetBuckets | aws-cdk-lib.aws_s3.IBucket[] | *No description.* | +| assetBucketPrefix | string | *No description.* | + +--- + +##### `assetBuckets`Optional + +- *Type:* aws-cdk-lib.aws_s3.IBucket[] --- -##### `assetBucket`Optional +##### `assetBucketPrefix`Optional -- *Type:* aws-cdk-lib.aws_s3.IBucket +- *Type:* string --- diff --git a/README.md b/README.md index 233e126..dd83c4c 100644 --- a/README.md +++ b/README.md @@ -255,9 +255,9 @@ AWS accounts when they are added or removed from the specified organizational un You can use the StackSet's parent stack to facilitate file assets. Behind the scenes, this is accomplished using the `BucketDeployment` construct from the -`aws_s3_deployment` module. You need to provide a bucket outside the scope of the CDK -managed asset buckets and ensure you have persmissions for the target accounts to pull -the artifacts from the supplied bucket. +`aws_s3_deployment` module. You need to provide a list of buckets outside the scope of the CDK +managed asset buckets and ensure you have permissions for the target accounts to pull +the artifacts from the supplied bucket(s). As a basic example, if using a `serviceManaged` deployment, you just need to give read access to the Organization. You can create the asset bucket in the parent stack, or another @@ -267,7 +267,7 @@ If creating in the parent or sibling stack you could create and export similar t ```ts const bucket = new s3.Bucket(this, "Assets", { - bucketName: "cdkstacket-asset-bucket-xyz", + bucketName: "prefix-us-east-1", }); bucket.addToResourcePolicy( @@ -285,11 +285,17 @@ Then pass as a prop to the StackSet stack: declare const bucket: s3.Bucket; const stack = new Stack(); const stackSetStack = new StackSetStack(stack, 'MyStackSet', { - assetBucket: bucket, + assetBuckets: [bucket], + assetBucketPrefix: "prefix", }); ``` -Then call `new StackSet` as described in the sections above. +To faciliate multi region deployments, there is an assetBucketPrefix property. This +gets added to the region the Stack Set is deployed to. The stack synthesis for +the Stack Set would look for a bucket named `prefix-{Region}` in the example +above. `{Region}` is whatever region you are deploying the Stack Set to as +defined in your target property of the StackSet. You will need to ensure the +bucket name is correct based on what was previously created and then passed in. You can use self-managed StackSet deployments with file assets too but will need to ensure all target accounts roles will have access to the central asset diff --git a/src/stackset-stack.ts b/src/stackset-stack.ts index d4bf351..1613ab6 100644 --- a/src/stackset-stack.ts +++ b/src/stackset-stack.ts @@ -15,12 +15,18 @@ import { App, Resource, Annotations, + Fn, } from 'aws-cdk-lib'; -import { CfnBucket, IBucket } from 'aws-cdk-lib/aws-s3'; +import { IBucket } from 'aws-cdk-lib/aws-s3'; import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment'; import { Construct } from 'constructs'; -export const fileAssetResourceName = 'StackSetAssetsBucketDeployment'; +export const fileAssetResourceNames: string[] = []; + +interface AssetBucketDeploymentProperties { + assetBucket: IBucket; + bucketDeployment?: BucketDeployment; +} /** * Deployment environment for an AWS StackSet stack. @@ -28,19 +34,29 @@ export const fileAssetResourceName = 'StackSetAssetsBucketDeployment'; * Interoperates with the StackSynthesizer of the parent stack. */ export class StackSetStackSynthesizer extends StackSynthesizer { - private readonly assetBucket?: IBucket; - private bucketDeployment?: BucketDeployment; + private readonly assetBuckets?: IBucket[]; + private readonly assetBucketPrefix?: string; + private bucketDeployments: { [key: string]: AssetBucketDeploymentProperties }; - constructor(assetBucket?: IBucket) { + constructor(assetBuckets?: IBucket[], assetBucketPrefix?: string) { super(); - this.assetBucket = assetBucket; + this.assetBuckets = assetBuckets; + this.assetBucketPrefix = assetBucketPrefix; + this.bucketDeployments = {}; + for (const assetBucket of assetBuckets ?? []) { + this.bucketDeployments[assetBucket.bucketName] = { assetBucket }; + } } public addFileAsset(asset: FileAssetSource): FileAssetLocation { - if (!this.assetBucket) { + if (!this.assetBuckets) { throw new Error('An Asset Bucket must be provided to use File Assets'); } + if (!this.assetBucketPrefix) { + throw new Error('An Asset Bucket Prefix must be provided to use File Assets'); + } + if (!asset.fileName) { throw new Error('Asset filename is undefined'); } @@ -48,34 +64,41 @@ export class StackSetStackSynthesizer extends StackSynthesizer { const outdir = App.of(this.boundStack)?.outdir ?? 'cdk.out'; const assetPath = `${outdir}/${asset.fileName}`; - if (!this.bucketDeployment) { - const parentStack = (this.boundStack as StackSetStack)._getParentStack(); + for (const assetBucket of this.assetBuckets) { + const index = this.assetBuckets.indexOf(assetBucket); + const assetDeployment = this.bucketDeployments[assetBucket.bucketName]; - if (!Resource.isOwnedResource(this.assetBucket)) { - Annotations.of(parentStack).addWarning('[WARNING] Bucket Policy Permissions cannot be added to' + - ' referenced Bucket. Please make sure your bucket has the correct permissions'); - } + if (!assetDeployment.bucketDeployment) { + const parentStack = (this.boundStack as StackSetStack)._getParentStack(); + + if (!Resource.isOwnedResource(assetDeployment.assetBucket)) { + Annotations.of(parentStack).addWarning('[WARNING] Bucket Policy Permissions cannot be added to' + + ' referenced Bucket. Please make sure your bucket has the correct permissions'); + } + + const bucketDeploymentConstructName = `${Names.uniqueId(this.boundStack)}-AssetBucketDeployment-${index}`; + + fileAssetResourceNames.push(bucketDeploymentConstructName); - const bucketDeployment = new BucketDeployment( - parentStack, - fileAssetResourceName, - { - sources: [Source.asset(assetPath)], - destinationBucket: this.assetBucket, - extract: false, - prune: false, - }, - ); - - this.bucketDeployment = bucketDeployment; - - } else { - this.bucketDeployment.addSource(Source.asset(assetPath)); + const bucketDeployment = new BucketDeployment( + parentStack, + bucketDeploymentConstructName, + { + sources: [Source.asset(assetPath)], + destinationBucket: assetDeployment.assetBucket, + extract: false, + prune: false, + }, + ); + + assetDeployment.bucketDeployment = bucketDeployment; + } else { + assetDeployment.bucketDeployment.addSource(Source.asset(assetPath)); + } } - const physicalName = this.physicalNameOfBucket(this.assetBucket); + const bucketName = Fn.join('-', [this.assetBucketPrefix, this.boundStack.region]); - const bucketName = physicalName; const assetFileBaseName = path.basename(asset.fileName); const s3Filename = assetFileBaseName.split('.')[1] + '.zip'; const objectKey = `${s3Filename}`; @@ -85,19 +108,6 @@ export class StackSetStackSynthesizer extends StackSynthesizer { return { bucketName, objectKey, httpUrl, s3ObjectUrl }; } - private physicalNameOfBucket(bucket: IBucket) { - let resolvedName; - if (Resource.isOwnedResource(bucket)) { - resolvedName = Stack.of(bucket).resolve((bucket.node.defaultChild as CfnBucket).bucketName); - } else { - resolvedName = bucket.bucketName; - } - if (resolvedName === undefined) { - throw new Error('A bucketName must be provided to use Assets'); - } - return resolvedName; - } - public addDockerImageAsset(_asset: DockerImageAssetSource): DockerImageAssetLocation { throw new Error('StackSets cannot use Docker Image Assets'); } @@ -117,11 +127,10 @@ export interface StackSetStackProps { * A Bucket can be passed to store assets, enabling StackSetStack Asset support * @default No Bucket provided and Assets will not be supported. */ - readonly assetBucket?: IBucket; - + readonly assetBuckets?: IBucket[]; + readonly assetBucketPrefix?: string; } - /** * A StackSet stack, which is similar to a normal CloudFormation stack with * some differences. @@ -136,7 +145,7 @@ export class StackSetStack extends Stack { private _parentStack: Stack; constructor(scope: Construct, id: string, props: StackSetStackProps = {}) { super(scope, id, { - synthesizer: new StackSetStackSynthesizer(props.assetBucket), + synthesizer: new StackSetStackSynthesizer(props.assetBuckets, props.assetBucketPrefix), }); this._parentStack = findParentStack(scope); @@ -181,7 +190,6 @@ export class StackSetStack extends Stack { fileName: this.templateFile, }).httpUrl; - fs.writeFileSync(path.join(session.assembly.outdir, this.templateFile), cfn); } } diff --git a/src/stackset.ts b/src/stackset.ts index 9eb13af..89a098a 100644 --- a/src/stackset.ts +++ b/src/stackset.ts @@ -7,7 +7,7 @@ import { Resource, } from 'aws-cdk-lib'; import { Construct } from 'constructs'; -import { StackSetStack, fileAssetResourceName } from './stackset-stack'; +import { StackSetStack, fileAssetResourceNames } from './stackset-stack'; /** * Represents a StackSet CloudFormation template @@ -470,7 +470,6 @@ export interface StackSetProps { */ readonly managedExecution?: boolean; - /** * */ @@ -660,8 +659,10 @@ export class StackSet extends Resource implements IStackSet { }); // the file asset bucket deployment needs to complete before the stackset can deploy - const fileAssetResource = scope.node.tryFindChild(fileAssetResourceName); - fileAssetResource && stackSet.node.addDependency(fileAssetResource); + for (const fileAssetResourceName of fileAssetResourceNames) { + const fileAssetResource = scope.node.tryFindChild(fileAssetResourceName); + fileAssetResource && stackSet.node.addDependency(fileAssetResource); + } } public get role(): iam.IRole | undefined { diff --git a/test/integ.stack-set.ts b/test/integ.stack-set.ts index 340c78c..b6e9af9 100644 --- a/test/integ.stack-set.ts +++ b/test/integ.stack-set.ts @@ -137,7 +137,8 @@ class AssetTestCase extends Stack { super(scope, id); const stackSetStack = new LambdaStackSet(this, 'asset-stack-set', { - assetBucket: s3.Bucket.fromBucketName(this, 'AssetBucket', 'integ-assets'), + assetBuckets: [s3.Bucket.fromBucketName(this, 'AssetBucket', 'integ-assets')], + assetBucketPrefix: 'asset-bucket', }); new stacksets.StackSet(this, 'StackSet', { target: stacksets.StackSetTarget.fromAccounts({ diff --git a/test/integ.stack-set.ts.snapshot/integ-stackset-asset-test.assets.json b/test/integ.stack-set.ts.snapshot/integ-stackset-asset-test.assets.json index f86a9dd..16a5773 100644 --- a/test/integ.stack-set.ts.snapshot/integ-stackset-asset-test.assets.json +++ b/test/integ.stack-set.ts.snapshot/integ-stackset-asset-test.assets.json @@ -40,7 +40,7 @@ } } }, - "3316336371755296c837ee9ccebfa13d33bb330deeed8025659b8f987db2bac9": { + "8a79245976356195e252a35c4adeb67d13403b4aa4797878ffeb1cbdbf6b1e10": { "source": { "path": "integstacksetassettestassetstacksetB1BE16AD.stackset.template.json", "packaging": "file" @@ -48,7 +48,7 @@ "destinations": { "current_account-current_region": { "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", - "objectKey": "3316336371755296c837ee9ccebfa13d33bb330deeed8025659b8f987db2bac9.json", + "objectKey": "8a79245976356195e252a35c4adeb67d13403b4aa4797878ffeb1cbdbf6b1e10.json", "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" } } diff --git a/test/integ.stack-set.ts.snapshot/integ-stackset-asset-test.template.json b/test/integ.stack-set.ts.snapshot/integ-stackset-asset-test.template.json index cbadcd2..8412cc1 100644 --- a/test/integ.stack-set.ts.snapshot/integ-stackset-asset-test.template.json +++ b/test/integ.stack-set.ts.snapshot/integ-stackset-asset-test.template.json @@ -1,272 +1,272 @@ { - "Resources": { - "StackSetAssetsBucketDeploymentAwsCliLayerAC4CF89A": { - "Type": "AWS::Lambda::LayerVersion", - "Properties": { - "Content": { - "S3Bucket": { - "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}" - }, - "S3Key": "3fb6287214999ddeafa7cd0e3e58bc5144c8678bb720f3b5e45e8fd32f333eb3.zip" - }, - "Description": "/opt/awscli/aws" - } - }, - "StackSetAssetsBucketDeploymentCustomResource644D06A6": { - "Type": "Custom::CDKBucketDeployment", - "Properties": { - "ServiceToken": { - "Fn::GetAtt": [ - "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536", - "Arn" - ] - }, - "SourceBucketNames": [ - { - "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}" - } - ], - "SourceObjectKeys": [ - "e56263bd51a9cda3a5920a2b978d8827ae857776a6807cbe4ac9b2115dfed690.zip" - ], - "DestinationBucketName": "integ-assets", - "Extract": false, - "Prune": false - }, - "UpdateReplacePolicy": "Delete", - "DeletionPolicy": "Delete" - }, - "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" + "Resources": { + "integstacksetassettestassetstacksetB1BE16ADAssetBucketDeployment0AwsCliLayerC5715512": { + "Type": "AWS::Lambda::LayerVersion", + "Properties": { + "Content": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}" + }, + "S3Key": "3fb6287214999ddeafa7cd0e3e58bc5144c8678bb720f3b5e45e8fd32f333eb3.zip" + }, + "Description": "/opt/awscli/aws" + } }, - ":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}" - }, - "/*" - ] - ] + "integstacksetassettestassetstacksetB1BE16ADAssetBucketDeployment0CustomResource95864C0F": { + "Type": "Custom::CDKBucketDeployment", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C81C01536", + "Arn" + ] + }, + "SourceBucketNames": [ + { + "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}" + } + ], + "SourceObjectKeys": [ + "e56263bd51a9cda3a5920a2b978d8827ae857776a6807cbe4ac9b2115dfed690.zip" + ], + "DestinationBucketName": "integ-assets", + "Extract": false, + "Prune": false + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" }, - { - "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::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":s3:::integ-assets" - ] - ] + "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" + ] + ] + } + ] + } }, - { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":s3:::integ-assets/*" - ] - ] + "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::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::integ-assets" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::integ-assets/*" + ] + ] + } + ] + } + ], + "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": "9eb41a5505d37607ac419321497a4f8c21cf0ee1f9b4a6b29aa04301aea5c7fd.zip" + }, + "Environment": { + "Variables": { + "AWS_CA_BUNDLE": "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" + } + }, + "Handler": "index.handler", + "Layers": [ + { + "Ref": "integstacksetassettestassetstacksetB1BE16ADAssetBucketDeployment0AwsCliLayerC5715512" + } + ], + "Role": { + "Fn::GetAtt": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265", + "Arn" + ] + }, + "Runtime": "python3.9", + "Timeout": 900 + }, + "DependsOn": [ + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF", + "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265" + ] + }, + "StackSet6E6355CF": { + "Type": "AWS::CloudFormation::StackSet", + "Properties": { + "AutoDeployment": { + "Enabled": true, + "RetainStacksOnAccountRemoval": true + }, + "CallAs": "SELF", + "ManagedExecution": { + "Active": true + }, + "PermissionModel": "SERVICE_MANAGED", + "StackInstancesGroup": [ + { + "DeploymentTargets": { + "AccountFilterType": "INTERSECTION", + "Accounts": [ + "12345678" + ] + }, + "Regions": [ + "us-east-1" + ] + } + ], + "StackSetName": "integstacksetassettestStackSet091EC131", + "TemplateURL": { + "Fn::Sub": "https://s3.${AWS::Region}.${AWS::URLSuffix}/cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/8a79245976356195e252a35c4adeb67d13403b4aa4797878ffeb1cbdbf6b1e10.json" + } + }, + "DependsOn": [ + "integstacksetassettestassetstacksetB1BE16ADAssetBucketDeployment0AwsCliLayerC5715512", + "integstacksetassettestassetstacksetB1BE16ADAssetBucketDeployment0CustomResource95864C0F" + ] } - ] - } - ], - "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": "9eb41a5505d37607ac419321497a4f8c21cf0ee1f9b4a6b29aa04301aea5c7fd.zip" }, - "Environment": { - "Variables": { - "AWS_CA_BUNDLE": "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem" - } - }, - "Handler": "index.handler", - "Layers": [ - { - "Ref": "StackSetAssetsBucketDeploymentAwsCliLayerAC4CF89A" - } - ], - "Role": { - "Fn::GetAtt": [ - "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265", - "Arn" - ] - }, - "Runtime": "python3.9", - "Timeout": 900 - }, - "DependsOn": [ - "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF", - "CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265" - ] - }, - "StackSet6E6355CF": { - "Type": "AWS::CloudFormation::StackSet", - "Properties": { - "AutoDeployment": { - "Enabled": true, - "RetainStacksOnAccountRemoval": true - }, - "CallAs": "SELF", - "ManagedExecution": { - "Active": true + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } }, - "PermissionModel": "SERVICE_MANAGED", - "StackInstancesGroup": [ - { - "DeploymentTargets": { - "AccountFilterType": "INTERSECTION", - "Accounts": [ - "12345678" - ] - }, - "Regions": [ - "us-east-1" - ] - } - ], - "StackSetName": "integstacksetassettestStackSet091EC131", - "TemplateURL": { - "Fn::Sub": "https://s3.${AWS::Region}.${AWS::URLSuffix}/cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/3316336371755296c837ee9ccebfa13d33bb330deeed8025659b8f987db2bac9.json" - } - }, - "DependsOn": [ - "StackSetAssetsBucketDeploymentAwsCliLayerAC4CF89A", - "StackSetAssetsBucketDeploymentCustomResource644D06A6" - ] - } - }, - "Parameters": { - "BootstrapVersion": { - "Type": "AWS::SSM::Parameter::Value", - "Default": "/cdk-bootstrap/hnb659fds/version", - "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" - } - }, - "Rules": { - "CheckBootstrapVersion": { - "Assertions": [ - { - "Assert": { - "Fn::Not": [ - { - "Fn::Contains": [ - [ - "1", - "2", - "3", - "4", - "5" - ], - { - "Ref": "BootstrapVersion" - } - ] - } - ] - }, - "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } } - ] - } - } } \ No newline at end of file diff --git a/test/integ.stack-set.ts.snapshot/manifest.json b/test/integ.stack-set.ts.snapshot/manifest.json index 9cf2872..9993de9 100644 --- a/test/integ.stack-set.ts.snapshot/manifest.json +++ b/test/integ.stack-set.ts.snapshot/manifest.json @@ -164,13 +164,13 @@ "/integ-stackset-asset-test/StackSetAssetsBucketDeployment/AwsCliLayer/Resource": [ { "type": "aws:cdk:logicalId", - "data": "StackSetAssetsBucketDeploymentAwsCliLayerAC4CF89A" + "data": "integstacksetassettestassetstacksetB1BE16ADAssetBucketDeployment0AwsCliLayerC5715512" } ], "/integ-stackset-asset-test/StackSetAssetsBucketDeployment/CustomResource/Default": [ { "type": "aws:cdk:logicalId", - "data": "StackSetAssetsBucketDeploymentCustomResource644D06A6" + "data": "integstacksetassettestassetstacksetB1BE16ADAssetBucketDeployment0CustomResource95864C0F" } ], "/integ-stackset-asset-test/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/ServiceRole/Resource": [ diff --git a/test/integ.stack-set.ts.snapshot/tree.json b/test/integ.stack-set.ts.snapshot/tree.json index 8e4fb13..ec67d19 100644 --- a/test/integ.stack-set.ts.snapshot/tree.json +++ b/test/integ.stack-set.ts.snapshot/tree.json @@ -434,7 +434,7 @@ "version": "2.108.0" } }, - "StackSetAssetsBucketDeployment": { + "StackSetAssetsBucketDeployment0": { "id": "StackSetAssetsBucketDeployment", "path": "integ-stackset-asset-test/StackSetAssetsBucketDeployment", "children": { @@ -775,7 +775,7 @@ "handler": "index.handler", "layers": [ { - "Ref": "StackSetAssetsBucketDeploymentAwsCliLayerAC4CF89A" + "Ref": "integstacksetassettestassetstacksetB1BE16ADAssetBucketDeployment0AwsCliLayerC5715512" } ], "role": { @@ -833,7 +833,7 @@ ], "stackSetName": "integstacksetassettestStackSet091EC131", "templateUrl": { - "Fn::Sub": "https://s3.${AWS::Region}.${AWS::URLSuffix}/cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/3316336371755296c837ee9ccebfa13d33bb330deeed8025659b8f987db2bac9.json" + "Fn::Sub": "https://s3.${AWS::Region}.${AWS::URLSuffix}/cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/8a79245976356195e252a35c4adeb67d13403b4aa4797878ffeb1cbdbf6b1e10.json" } } }, diff --git a/test/stack-set.test.ts b/test/stack-set.test.ts index c9d34de..4cc0657 100644 --- a/test/stack-set.test.ts +++ b/test/stack-set.test.ts @@ -1,10 +1,31 @@ +import path from 'path'; import { - App, Stack, + App, Stack, aws_lambda as lambda, aws_s3 as s3, } from 'aws-cdk-lib'; import { Template } from 'aws-cdk-lib/assertions'; +import * as cxapi from 'aws-cdk-lib/cx-api'; +import { Construct } from 'constructs'; import { Capability, DeploymentType, StackSet, StackSetTarget, StackSetTemplate } from '../src/stackset'; -import { StackSetStack } from '../src/stackset-stack'; +import { StackSetStack, StackSetStackProps } from '../src/stackset-stack'; + +class LambdaStackSet extends StackSetStack { + constructor(scope: Construct, id: string, props?: StackSetStackProps) { + super(scope, id, props); + + new lambda.Function(this, 'Lambda', { + runtime: lambda.Runtime.NODEJS_18_X, + handler: 'index.handler', + code: lambda.Code.fromAsset(path.join(__dirname, 'lambda')), + }); + + new lambda.Function(this, 'Lambda2', { + runtime: lambda.Runtime.NODEJS_18_X, + handler: 'index.handler', + code: lambda.Code.fromAsset(path.join(__dirname, 'lambda')), + }); + } +} test('default', () => { const app = new App(); @@ -303,3 +324,74 @@ test('has IAM capabilities', () => { ], }); }); + +test('requires asset bucket to be passed', () => { + const app = new App(); + const stack = new Stack(app); + + expect(() => { + const lambdaStack = new LambdaStackSet(stack, 'LambdaStack'); + new StackSet(stack, 'StackSet', { + target: StackSetTarget.fromAccounts({ + regions: ['us-east-1'], + accounts: ['11111111111'], + }), + template: StackSetTemplate.fromStackSetStack(new StackSetStack(lambdaStack, 'LambdaStack')), + capabilities: [Capability.IAM, Capability.NAMED_IAM], + }); + }).toThrow('An Asset Bucket must be provided to use File Assets'); +}); + +test('test lambda assets with one asset bucket', () => { + const app = new App({ + context: { + [cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT]: true, + }, + }); + const stack = new Stack(app); + const lambdaStack = new LambdaStackSet(stack, 'LambdaStack', { + assetBuckets: [s3.Bucket.fromBucketName(stack, 'AssetBucket', 'integ-assets')], + assetBucketPrefix: 'prefix', + }); + + new StackSet(stack, 'StackSet', { + target: StackSetTarget.fromAccounts({ + regions: ['us-east-1'], + accounts: ['11111111111'], + parameterOverrides: { + Param1: 'Value1', + }, + }), + template: StackSetTemplate.fromStackSetStack(lambdaStack), + capabilities: [Capability.IAM, Capability.NAMED_IAM], + }); + + Template.fromStack(stack).resourceCountIs('Custom::CDKBucketDeployment', 1); +}); + +test('test lambda assets with two asset buckets', () => { + const app = new App({ + context: { + [cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT]: true, + }, + }); + const stack = new Stack(app); + const lambdaStack = new LambdaStackSet(stack, 'LambdaStack', { + assetBuckets: [s3.Bucket.fromBucketName(stack, 'AssetBucket', 'integ-assets'), s3.Bucket.fromBucketName(stack, 'AssetBucket2', 'integ-assets2')], + assetBucketPrefix: 'prefix', + }); + + new StackSet(stack, 'StackSet', { + target: StackSetTarget.fromAccounts({ + regions: ['us-east-1'], + accounts: ['11111111111'], + parameterOverrides: { + Param1: 'Value1', + }, + }), + template: StackSetTemplate.fromStackSetStack(lambdaStack), + capabilities: [Capability.IAM, Capability.NAMED_IAM], + }); + + Template.fromStack(stack).resourceCountIs('Custom::CDKBucketDeployment', 2); +}); diff --git a/yarn.lock b/yarn.lock index 9f38252..97c8bb1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30,21 +30,21 @@ resolved "https://registry.yarnpkg.com/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.0.1.tgz#6dc9b7cdb22ff622a7176141197962360c33e9ac" integrity sha512-DDt4SLdLOwWCjGtltH4VCST7hpOI5DzieuhGZsBpZ+AgJdSI2GCjklCXm0GCTwJG/SolkL5dtQXyUKgg9luBDg== -"@aws-cdk/aws-service-spec@0.0.28": - version "0.0.28" - resolved "https://registry.yarnpkg.com/@aws-cdk/aws-service-spec/-/aws-service-spec-0.0.28.tgz#de4ec81316ca0e794ba895638f9fcc8998969db1" - integrity sha512-Wg0h3sAl/XNrLPT7TXbh1FfWQ8c/ZzuBeL6Njc9LWWd2zTCcCQ7ii3usStzU1ZhTGo2TwAhe4V3hHIA8hwWJVA== +"@aws-cdk/aws-service-spec@0.0.36": + version "0.0.36" + resolved "https://registry.yarnpkg.com/@aws-cdk/aws-service-spec/-/aws-service-spec-0.0.36.tgz#ce62d242c0d7e4bc6527327d9849bae1f7df190c" + integrity sha512-hnhWZntWw/GYAYWdhJJSG+xiPQBcBTl8K9AFZSHzW0NvhJCRJIu/3NfFyPmRqZ1qEkHrLhmagVFbdeXucHnjWw== dependencies: - "@aws-cdk/service-spec-types" "^0.0.28" + "@aws-cdk/service-spec-types" "^0.0.36" "@cdklabs/tskb" "^0.0.3" "@aws-cdk/integ-runner@latest": - version "2.109.0-alpha.0" - resolved "https://registry.yarnpkg.com/@aws-cdk/integ-runner/-/integ-runner-2.109.0-alpha.0.tgz#d9b7e77f9a69233281a170f5c8f386048a3d6ac7" - integrity sha512-Mo+9nXN2m4A0/gHRgQCv0sdEEjZfkfnyyq76DMhg3ajfTSou9gIMEK2tWvmqrU74k9RkIJGlFYZ69W/3K/JVgg== + version "2.116.1-alpha.0" + resolved "https://registry.yarnpkg.com/@aws-cdk/integ-runner/-/integ-runner-2.116.1-alpha.0.tgz#ea9a612d0db858eacd0a3c4df9fa47edf8d7f572" + integrity sha512-PwSpq1ZHdJDvO4ibkRzpZtQLk751PwYfd1inkcL4mEaNs0ceZfguOEYhoEl2HOAi+q92CACZgF/U/QGziTMQ1A== dependencies: - "@aws-cdk/aws-service-spec" "0.0.28" - aws-cdk "2.109.0" + "@aws-cdk/aws-service-spec" "0.0.36" + aws-cdk "2.116.1" optionalDependencies: fsevents "2.3.2" @@ -53,10 +53,10 @@ resolved "https://registry.yarnpkg.com/@aws-cdk/integ-tests-alpha/-/integ-tests-alpha-2.126.0-alpha.0.tgz#b7bb9cebb7d56dc0bbff6c6490b2b39ca3fd8716" integrity sha512-3VpteZKQme+pu3WpH7WGDrSgi+bqTsgvwDpWkJrX/HD3kPtXO9M3UbjMEWdKD1K+UHaRnYTDC+fqlpPZjdLuWg== -"@aws-cdk/service-spec-types@^0.0.28": - version "0.0.28" - resolved "https://registry.yarnpkg.com/@aws-cdk/service-spec-types/-/service-spec-types-0.0.28.tgz#4ca242e453ac2b5f362b352997c95dea9193c203" - integrity sha512-3spBu/o0QZf+adM56McQ/3ZOpw59D9VS8zzcCWcxD6XJMFVdJyI3V3Hpfy1MIIWL25tQKrUwQQvACqmXbkgoTA== +"@aws-cdk/service-spec-types@^0.0.36": + version "0.0.36" + resolved "https://registry.yarnpkg.com/@aws-cdk/service-spec-types/-/service-spec-types-0.0.36.tgz#d4e7c78b246ba6abdd262668c748f91c31c6075d" + integrity sha512-ILHIT+/7boUOJLe3H0doBxIm9k5+I2wNBg+WzJ6GC2IlPPT1R6qax9yZmvxSDfq87dWEpIuc3DDUZsPkWIA/qg== dependencies: "@cdklabs/tskb" "^0.0.3" @@ -1262,10 +1262,10 @@ aws-cdk@2.108.0: optionalDependencies: fsevents "2.3.2" -aws-cdk@2.109.0: - version "2.109.0" - resolved "https://registry.yarnpkg.com/aws-cdk/-/aws-cdk-2.109.0.tgz#048078e151b0b88e3e826103c35cd630cbc0f764" - integrity sha512-e06YlA4HsKZCOdh3ApynZauJ3/224o/q5vOso3Fs5ksLkYhfXREl+O7UmAOZ1Nenq5ADNgccvNwuChQWbNSvSg== +aws-cdk@2.116.1: + version "2.116.1" + resolved "https://registry.yarnpkg.com/aws-cdk/-/aws-cdk-2.116.1.tgz#6790227e6e5015a18efe5fab859a235093a1de7a" + integrity sha512-NBEoLPHHnQxu7QKFf4DlAy737v1Jnsvy6ueM1r8jc/GGr3h0Mm9yD2t4cs3KlcHYM0qNESfQW0SVUMJ5aDCARQ== optionalDependencies: fsevents "2.3.2" @@ -2464,9 +2464,9 @@ fs-extra@^10.1.0: universalify "^2.0.0" fs-extra@^11.1.1: - version "11.1.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d" - integrity sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ== + version "11.2.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" + integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw== dependencies: graceful-fs "^4.2.0" jsonfile "^6.0.1"