Skip to content

Commit

Permalink
feat: allow multi region stackset deployments with file assets (#325)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
josh-demuth authored Feb 6, 2024
1 parent d43db71 commit 497227d
Show file tree
Hide file tree
Showing 11 changed files with 498 additions and 366 deletions.
52 changes: 38 additions & 14 deletions API.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand Down
104 changes: 56 additions & 48 deletions src/stackset-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,67 +15,90 @@ 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.
*
* 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');
}

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}`;
Expand All @@ -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');
}
Expand All @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -181,7 +190,6 @@ export class StackSetStack extends Stack {
fileName: this.templateFile,
}).httpUrl;


fs.writeFileSync(path.join(session.assembly.outdir, this.templateFile), cfn);
}
}
Expand Down
9 changes: 5 additions & 4 deletions src/stackset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -470,7 +470,6 @@ export interface StackSetProps {
*/
readonly managedExecution?: boolean;


/**
*
*/
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion test/integ.stack-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@
}
}
},
"3316336371755296c837ee9ccebfa13d33bb330deeed8025659b8f987db2bac9": {
"8a79245976356195e252a35c4adeb67d13403b4aa4797878ffeb1cbdbf6b1e10": {
"source": {
"path": "integstacksetassettestassetstacksetB1BE16AD.stackset.template.json",
"packaging": "file"
},
"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}"
}
}
Expand Down
Loading

0 comments on commit 497227d

Please sign in to comment.