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: allow multi region stackset deployments with file assets #325

Merged
merged 7 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
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
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
Loading