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(s3-deployment): add additional sources with addSource #23321

Merged
merged 2 commits into from Dec 14, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/@aws-cdk/aws-s3-deployment/README.md
Expand Up @@ -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:
Expand Down
52 changes: 41 additions & 11 deletions packages/@aws-cdk/aws-s3-deployment/lib/bucket-deployment.ts
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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<Record<string, any>>);
},
}, { omitEmptyArray: true }),
DestinationBucketName: props.destinationBucket.bucketName,
DestinationBucketKeyPrefix: props.destinationKeyPrefix,
RetainOnDelete: props.retainOnDelete,
Expand Down Expand Up @@ -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 = '';

Expand Down
34 changes: 29 additions & 5 deletions packages/@aws-cdk/aws-s3-deployment/test/bucket-deployment.test.ts
Expand Up @@ -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.');
});

Expand Down Expand Up @@ -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.'));
Expand Down
Expand Up @@ -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();
app.synth();