Skip to content

Commit

Permalink
feat(ec2): Implement UserData methods in MultipartUserData
Browse files Browse the repository at this point in the history
  • Loading branch information
Daniel Neilson committed Apr 23, 2021
1 parent f93d940 commit a59bce7
Show file tree
Hide file tree
Showing 3 changed files with 340 additions and 11 deletions.
17 changes: 17 additions & 0 deletions packages/@aws-cdk/aws-ec2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,23 @@ new ec2.LaunchTemplate(stack, '', {
For more information see
[Specifying Multiple User Data Blocks Using a MIME Multi Part Archive](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/bootstrap_container_instance.html#multi-part_user_data)

#### Using add*Command on MultipartUserData

To use the `add*Command` methods, that are inherited from the `UserData` interface, on `MultipartUserData` you must add a part
to the `MultipartUserData` and designate it as the reciever for these methods. This is accomplished by using the `addUserDataPartForCommands()`
method on `MultipartUserData`:

```ts
const multipartUserData = new ec2.MultipartUserData();
const commandsUserData = ec2.UserData.forLinux();
multipartUserData.addUserDataPartForCommands(commandsUserData);

// Adding commands to the multipartUserData adds them to commandsUserData, and vice-versa.
multipartUserData.addCommands('touch /root/multi.txt');
commandsUserData.addCommands('touch /root/userdata.txt');
```

When used on an EC2 instance, the above `multipartUserData` will create both `multi.txt` and `userdata.txt` in `/root`.

## Importing existing subnet

Expand Down
54 changes: 43 additions & 11 deletions packages/@aws-cdk/aws-ec2/lib/user-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,13 +436,15 @@ export interface MultipartUserDataOptions {
*
*/
export class MultipartUserData extends UserData {
private static readonly USE_PART_ERROR = 'MultipartUserData does not support this operation. Please add part using addPart.';
private static readonly USE_PART_ERROR = 'MultipartUserData does not support this operation. Please use addUserDataPartForCommands().';
private static readonly BOUNDRY_PATTERN = '[^a-zA-Z0-9()+,-./:=?]';

private parts: MultipartBody[] = [];

private opts: MultipartUserDataOptions;

private defaultUserData?: UserData;

constructor(opts?: MultipartUserDataOptions) {
super();

Expand Down Expand Up @@ -484,6 +486,16 @@ export class MultipartUserData extends UserData {
this.addPart(MultipartBody.fromUserData(userData, contentType));
}

/**
* Adds a multipart part based on a UserData object. This UserData is always added
* as a shell script. The UserData added by this method will also be the target
* of calls to the `add*Command` methods on this MultipartUserData object.
*/
public addUserDataPartForCommands(userData: UserData) {
this.addUserDataPart(userData, MultipartBody.SHELL_SCRIPT);
this.defaultUserData = userData;
}

public render(): string {
const boundary = this.opts.partsSeparator;
// Now build final MIME archive - there are few changes from MIME message which are accepted by cloud-init:
Expand All @@ -510,23 +522,43 @@ export class MultipartUserData extends UserData {
return resultArchive.join('\n');
}

public addS3DownloadCommand(_params: S3DownloadOptions): string {
throw new Error(MultipartUserData.USE_PART_ERROR);
public addS3DownloadCommand(params: S3DownloadOptions): string {
if (this.defaultUserData) {
return this.defaultUserData.addS3DownloadCommand(params);
} else {
throw new Error(MultipartUserData.USE_PART_ERROR);
}
}

public addExecuteFileCommand(_params: ExecuteFileOptions): void {
throw new Error(MultipartUserData.USE_PART_ERROR);
public addExecuteFileCommand(params: ExecuteFileOptions): void {
if (this.defaultUserData) {
this.defaultUserData.addExecuteFileCommand(params);
} else {
throw new Error(MultipartUserData.USE_PART_ERROR);
}
}

public addSignalOnExitCommand(_resource: Resource): void {
throw new Error(MultipartUserData.USE_PART_ERROR);
public addSignalOnExitCommand(resource: Resource): void {
if (this.defaultUserData) {
this.defaultUserData.addSignalOnExitCommand(resource);
} else {
throw new Error(MultipartUserData.USE_PART_ERROR);
}
}

public addCommands(..._commands: string[]): void {
throw new Error(MultipartUserData.USE_PART_ERROR);
public addCommands(...commands: string[]): void {
if (this.defaultUserData) {
this.defaultUserData.addCommands(...commands);
} else {
throw new Error(MultipartUserData.USE_PART_ERROR);
}
}

public addOnExitCommands(..._commands: string[]): void {
throw new Error(MultipartUserData.USE_PART_ERROR);
public addOnExitCommands(...commands: string[]): void {
if (this.defaultUserData) {
this.defaultUserData.addOnExitCommands(...commands);
} else {
throw new Error(MultipartUserData.USE_PART_ERROR);
}
}
}
280 changes: 280 additions & 0 deletions packages/@aws-cdk/aws-ec2/test/userdata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,4 +370,284 @@ nodeunitShim({
test.done();
},

'Multipart user data throws when adding on exit commands'(test: Test) {
// GIVEN
// WHEN
const userData = new ec2.MultipartUserData();

// THEN
test.throws(() => userData.addOnExitCommands( 'a command goes here' ));
test.done();
},
'Multipart user data throws when adding signal command'(test: Test) {
// GIVEN
const stack = new Stack();
const resource = new ec2.Vpc(stack, 'RESOURCE');

// WHEN
const userData = new ec2.MultipartUserData();

// THEN
test.throws(() => userData.addSignalOnExitCommand( resource ));
test.done();
},
'Multipart user data throws when downloading file'(test: Test) {
// GIVEN
const stack = new Stack();
const userData = new ec2.MultipartUserData();
const bucket = Bucket.fromBucketName( stack, 'testBucket', 'test' );
// WHEN
// THEN
test.throws(() => userData.addS3DownloadCommand({
bucket,
bucketKey: 'filename.sh',
} ));
test.done();
},
'Multipart user data throws when executing file'(test: Test) {
// GIVEN
const userData = new ec2.MultipartUserData();

// WHEN
// THEN
test.throws(() =>
userData.addExecuteFileCommand({
filePath: '/tmp/filename.sh',
} ));
test.done();
},

'can add commands to Multipart user data'(test: Test) {
// GIVEN
const stack = new Stack();
const innerUserData = ec2.UserData.forLinux();
const userData = new ec2.MultipartUserData();

// WHEN
userData.addUserDataPartForCommands(innerUserData);
userData.addCommands('command1', 'command2');

// THEN
const expectedInner = '#!/bin/bash\ncommand1\ncommand2';
const rendered = innerUserData.render();
test.equals(rendered, expectedInner);
const out = stack.resolve(userData.render());
test.equals(out, {
'Fn::Join': [
'',
[
[
'Content-Type: multipart/mixed; boundary="+AWS+CDK+User+Data+Separator=="',
'MIME-Version: 1.0',
'',
'--+AWS+CDK+User+Data+Separator==',
'Content-Type: text/x-shellscript; charset="utf-8"',
'Content-Transfer-Encoding: base64',
'',
'',
].join('\n'),
{
'Fn::Base64': expectedInner,
},
'\n--+AWS+CDK+User+Data+Separator==--\n',
],
],
});
test.done();
},
'can add commands on exit to Multipart user data'(test: Test) {
// GIVEN
const stack = new Stack();
const innerUserData = ec2.UserData.forLinux();
const userData = new ec2.MultipartUserData();

// WHEN
userData.addUserDataPartForCommands(innerUserData);
userData.addCommands('command1', 'command2');
userData.addOnExitCommands('onexit1', 'onexit2');

// THEN
const expectedInner = '#!/bin/bash\n' +
'function exitTrap(){\n' +
'exitCode=$?\n' +
'onexit1\n' +
'onexit2\n' +
'}\n' +
'trap exitTrap EXIT\n' +
'command1\n' +
'command2';
const rendered = stack.resolve(innerUserData.render());
test.equals(rendered, expectedInner);
const out = stack.resolve(userData.render());
test.equals(out, {
'Fn::Join': [
'',
[
[
'Content-Type: multipart/mixed; boundary="+AWS+CDK+User+Data+Separator=="',
'MIME-Version: 1.0',
'',
'--+AWS+CDK+User+Data+Separator==',
'Content-Type: text/x-shellscript; charset="utf-8"',
'Content-Transfer-Encoding: base64',
'',
'',
].join('\n'),
{
'Fn::Base64': expectedInner,
},
'\n--+AWS+CDK+User+Data+Separator==--\n',
],
],
});
test.done();
},
'can add Signal Command to Multipart user data'(test: Test) {
// GIVEN
const stack = new Stack();
const resource = new ec2.Vpc(stack, 'RESOURCE');
const innerUserData = ec2.UserData.forLinux();
const userData = new ec2.MultipartUserData();

// WHEN
userData.addUserDataPartForCommands(innerUserData);
userData.addCommands('command1');
userData.addSignalOnExitCommand( resource );

// THEN
const expectedInner = stack.resolve('#!/bin/bash\n' +
'function exitTrap(){\n' +
'exitCode=$?\n' +
`/opt/aws/bin/cfn-signal --stack Default --resource RESOURCE1989552F --region ${Aws.REGION} -e $exitCode || echo \'Failed to send Cloudformation Signal\'\n` +
'}\n' +
'trap exitTrap EXIT\n' +
'command1');
const rendered = stack.resolve(innerUserData.render());
test.equals(rendered, expectedInner);
const out = stack.resolve(userData.render());
test.equals(out, {
'Fn::Join': [
'',
[
[
'Content-Type: multipart/mixed; boundary="+AWS+CDK+User+Data+Separator=="',
'MIME-Version: 1.0',
'',
'--+AWS+CDK+User+Data+Separator==',
'Content-Type: text/x-shellscript; charset="utf-8"',
'Content-Transfer-Encoding: base64',
'',
'',
].join('\n'),
{
'Fn::Base64': expectedInner,
},
'\n--+AWS+CDK+User+Data+Separator==--\n',
],
],
});
test.done();
},
'can add download S3 files to Multipart user data'(test: Test) {
// GIVEN
const stack = new Stack();
const innerUserData = ec2.UserData.forLinux();
const userData = new ec2.MultipartUserData();
const bucket = Bucket.fromBucketName( stack, 'testBucket', 'test' );
const bucket2 = Bucket.fromBucketName( stack, 'testBucket2', 'test2' );

// WHEN
userData.addUserDataPartForCommands(innerUserData);
userData.addS3DownloadCommand({
bucket,
bucketKey: 'filename.sh',
} );
userData.addS3DownloadCommand({
bucket: bucket2,
bucketKey: 'filename2.sh',
localFile: 'c:\\test\\location\\otherScript.sh',
} );

// THEN
const expectedInner = '#!/bin/bash\n' +
'mkdir -p $(dirname \'/tmp/filename.sh\')\n' +
'aws s3 cp \'s3://test/filename.sh\' \'/tmp/filename.sh\'\n' +
'mkdir -p $(dirname \'c:\\test\\location\\otherScript.sh\')\n' +
'aws s3 cp \'s3://test2/filename2.sh\' \'c:\\test\\location\\otherScript.sh\'';
const rendered = stack.resolve(innerUserData.render());
test.equals(rendered, expectedInner);
const out = stack.resolve(userData.render());
test.equals(out, {
'Fn::Join': [
'',
[
[
'Content-Type: multipart/mixed; boundary="+AWS+CDK+User+Data+Separator=="',
'MIME-Version: 1.0',
'',
'--+AWS+CDK+User+Data+Separator==',
'Content-Type: text/x-shellscript; charset="utf-8"',
'Content-Transfer-Encoding: base64',
'',
'',
].join('\n'),
{
'Fn::Base64': expectedInner,
},
'\n--+AWS+CDK+User+Data+Separator==--\n',
],
],
});
test.done();
},
'can add execute files to Multipart user data'(test: Test) {
// GIVEN
const stack = new Stack();
const innerUserData = ec2.UserData.forLinux();
const userData = new ec2.MultipartUserData();

// WHEN
userData.addUserDataPartForCommands(innerUserData);
userData.addExecuteFileCommand({
filePath: '/tmp/filename.sh',
} );
userData.addExecuteFileCommand({
filePath: '/test/filename2.sh',
arguments: 'arg1 arg2 -arg $variable',
} );

// THEN
const expectedInner = '#!/bin/bash\n' +
'set -e\n' +
'chmod +x \'/tmp/filename.sh\'\n' +
'\'/tmp/filename.sh\'\n' +
'set -e\n' +
'chmod +x \'/test/filename2.sh\'\n' +
'\'/test/filename2.sh\' arg1 arg2 -arg $variable';
const rendered = stack.resolve(innerUserData.render());
test.equals(rendered, expectedInner);
const out = stack.resolve(userData.render());
test.equals(out, {
'Fn::Join': [
'',
[
[
'Content-Type: multipart/mixed; boundary="+AWS+CDK+User+Data+Separator=="',
'MIME-Version: 1.0',
'',
'--+AWS+CDK+User+Data+Separator==',
'Content-Type: text/x-shellscript; charset="utf-8"',
'Content-Transfer-Encoding: base64',
'',
'',
].join('\n'),
{
'Fn::Base64': expectedInner,
},
'\n--+AWS+CDK+User+Data+Separator==--\n',
],
],
});
test.done();
},
});

0 comments on commit a59bce7

Please sign in to comment.