diff --git a/packages/@aws-cdk/aws-s3-deployment/README.md b/packages/@aws-cdk/aws-s3-deployment/README.md index b9048b820e0f8..8e445f13bb67b 100644 --- a/packages/@aws-cdk/aws-s3-deployment/README.md +++ b/packages/@aws-cdk/aws-s3-deployment/README.md @@ -61,6 +61,20 @@ new ConstructThatReadsFromTheBucket(this, 'Consumer', { }); ``` +It is also possible to add additional sources using the `addSource` method. + +```ts +declare const websiteBucket: s3.IBucket; + +const deployment = new s3deploy.BucketDeployment(this, 'DeployWebsite', { + sources: [s3deploy.Source.asset('./website-dist')], + destinationBucket: websiteBucket, + destinationKeyPrefix: 'web/static', // optional prefix in destination bucket +}); + +deployment.addSource(s3deploy.Source.asset('./another-asset')); +``` + ## Supported sources The following source types are supported for bucket deployments: diff --git a/packages/@aws-cdk/aws-s3-deployment/lib/bucket-deployment.ts b/packages/@aws-cdk/aws-s3-deployment/lib/bucket-deployment.ts index 209cc46e87f64..35e9819a2b334 100644 --- a/packages/@aws-cdk/aws-s3-deployment/lib/bucket-deployment.ts +++ b/packages/@aws-cdk/aws-s3-deployment/lib/bucket-deployment.ts @@ -253,6 +253,8 @@ export class BucketDeployment extends Construct { private readonly cr: cdk.CustomResource; private _deployedBucket?: s3.IBucket; private requestDestinationArn: boolean = false; + private readonly sources: SourceConfig[]; + private readonly handlerRole: iam.IRole; constructor(scope: Construct, id: string, props: BucketDeploymentProps) { super(scope, id); @@ -327,8 +329,9 @@ export class BucketDeployment extends Construct { const handlerRole = handler.role; if (!handlerRole) { throw new Error('lambda.SingletonFunction should have created a Role'); } + this.handlerRole = handlerRole; - const sources: SourceConfig[] = props.sources.map((source: ISource) => source.bind(this, { handlerRole })); + this.sources = props.sources.map((source: ISource) => source.bind(this, { handlerRole: this.handlerRole })); props.destinationBucket.grantReadWrite(handler); if (props.accessControl) { @@ -342,24 +345,35 @@ export class BucketDeployment extends Construct { })); } - // to avoid redundant stack updates, only include "SourceMarkers" if one of - // the sources actually has markers. - const hasMarkers = sources.some(source => source.markers); - // Markers are not replaced if zip sources are not extracted, so throw an error // if extraction is not wanted and sources have markers. - if (hasMarkers && props.extract == false) { - throw new Error('Some sources are incompatible with extract=false; sources with deploy-time values (such as \'snsTopic.topicArn\') must be extracted.'); - } + const _this = this; + this.node.addValidation({ + validate(): string[] { + if (_this.sources.some(source => source.markers) && props.extract == false) { + return ['Some sources are incompatible with extract=false; sources with deploy-time values (such as \'snsTopic.topicArn\') must be extracted.']; + } + return []; + }, + }); const crUniqueId = `CustomResource${this.renderUniqueId(props.memoryLimit, props.ephemeralStorageSize, props.vpc)}`; this.cr = new cdk.CustomResource(this, crUniqueId, { serviceToken: handler.functionArn, resourceType: 'Custom::CDKBucketDeployment', properties: { - SourceBucketNames: sources.map(source => source.bucket.bucketName), - SourceObjectKeys: sources.map(source => source.zipObjectKey), - SourceMarkers: hasMarkers ? sources.map(source => source.markers ?? {}) : undefined, + SourceBucketNames: cdk.Lazy.list({ produce: () => this.sources.map(source => source.bucket.bucketName) }), + SourceObjectKeys: cdk.Lazy.list({ produce: () => this.sources.map(source => source.zipObjectKey) }), + SourceMarkers: cdk.Lazy.any({ + produce: () => { + return this.sources.reduce((acc, source) => { + if (source.markers) { + acc.push(source.markers); + } + return acc; + }, [] as Array>); + }, + }, { omitEmptyArray: true }), DestinationBucketName: props.destinationBucket.bucketName, DestinationBucketKeyPrefix: props.destinationKeyPrefix, RetainOnDelete: props.retainOnDelete, @@ -465,6 +479,22 @@ export class BucketDeployment extends Construct { return objectKeys; } + /** + * Add an additional source to the bucket deployment + * + * @example + * declare const websiteBucket: s3.IBucket; + * const deployment = new s3deploy.BucketDeployment(this, 'Deployment', { + * sources: [s3deploy.Source.asset('./website-dist')], + * destinationBucket: websiteBucket, + * }); + * + * deployment.addSource(s3deploy.Source.asset('./another-asset')); + */ + public addSource(source: ISource): void { + this.sources.push(source.bind(this, { handlerRole: this.handlerRole })); + } + private renderUniqueId(memoryLimit?: number, ephemeralStorageSize?: cdk.Size, vpc?: ec2.IVpc) { let uuid = ''; diff --git a/packages/@aws-cdk/aws-s3-deployment/test/bucket-deployment.test.ts b/packages/@aws-cdk/aws-s3-deployment/test/bucket-deployment.test.ts index c153cfc7ff64f..d3312d9b58a49 100644 --- a/packages/@aws-cdk/aws-s3-deployment/test/bucket-deployment.test.ts +++ b/packages/@aws-cdk/aws-s3-deployment/test/bucket-deployment.test.ts @@ -997,14 +997,15 @@ test('given a source with markers and extract is false, BucketDeployment throws }, }, }); + new s3deploy.BucketDeployment(stack, 'Deploy', { + sources: [file], + destinationBucket: bucket, + extract: false, + }); // THEN expect(() => { - new s3deploy.BucketDeployment(stack, 'Deploy', { - sources: [file], - destinationBucket: bucket, - extract: false, - }); + Template.fromStack(stack); }).toThrow('Some sources are incompatible with extract=false; sources with deploy-time values (such as \'snsTopic.topicArn\') must be extracted.'); }); @@ -1360,6 +1361,29 @@ test('Source.jsonData() can be used to create a file with a JSON object', () => }); }); +test('can add sources with addSource', () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'Test'); + const bucket = new s3.Bucket(stack, 'Bucket'); + const deployment = new s3deploy.BucketDeployment(stack, 'Deploy', { + sources: [s3deploy.Source.data('my/path.txt', 'helloWorld')], + destinationBucket: bucket, + }); + deployment.addSource(s3deploy.Source.data('my/other/path.txt', 'hello world')); + + const result = app.synth(); + const content = readDataFile(result, 'my/path.txt'); + const content2 = readDataFile(result, 'my/other/path.txt'); + expect(content).toStrictEqual('helloWorld'); + expect(content2).toStrictEqual('hello world'); + Template.fromStack(stack).hasResourceProperties('Custom::CDKBucketDeployment', { + SourceMarkers: [ + {}, + {}, + ], + }); +}); + function readDataFile(casm: cxapi.CloudAssembly, relativePath: string): string { const assetDirs = readdirSync(casm.directory).filter(f => f.startsWith('asset.')); diff --git a/packages/@aws-cdk/aws-s3-deployment/test/integ.bucket-deployment-data.ts b/packages/@aws-cdk/aws-s3-deployment/test/integ.bucket-deployment-data.ts index 2f9781bf38036..3cdf333cded63 100644 --- a/packages/@aws-cdk/aws-s3-deployment/test/integ.bucket-deployment-data.ts +++ b/packages/@aws-cdk/aws-s3-deployment/test/integ.bucket-deployment-data.ts @@ -10,13 +10,14 @@ const file1 = Source.data('file1.txt', 'boom'); const file2 = Source.data('path/to/file2.txt', `bam! ${bucket.bucketName}`); const file3 = Source.jsonData('my/config.json', { website_url: bucket.bucketWebsiteUrl }); -new BucketDeployment(stack, 'DeployMeHere', { +const deployment = new BucketDeployment(stack, 'DeployMeHere', { destinationBucket: bucket, - sources: [file1, file2, file3], + sources: [file1, file2], destinationKeyPrefix: 'deploy/here/', retainOnDelete: false, // default is true, which will block the integration test cleanup }); +deployment.addSource(file3); new CfnOutput(stack, 'BucketName', { value: bucket.bucketName }); -app.synth(); \ No newline at end of file +app.synth();