diff --git a/packages/@aws-cdk/aws-ec2/README.md b/packages/@aws-cdk/aws-ec2/README.md
index abd36271a73f3..c4ff1c03f1939 100644
--- a/packages/@aws-cdk/aws-ec2/README.md
+++ b/packages/@aws-cdk/aws-ec2/README.md
@@ -556,3 +556,24 @@ new ec2.FlowLog(this, 'FlowLog', {
destination: ec2.FlowLogDestination.toS3(bucket)
});
```
+
+## User Data
+User data enables you to run a script when your instances start up. In order to configure these scripts you can add commands directly to the script
+ or you can use the UserData's convenience functions to aid in the creation of your script.
+
+A user data could be configured to run a script found in an asset through the following:
+```ts
+const asset = new Asset(this, 'Asset', {path: path.join(__dirname, 'configure.sh')});
+const instance = new ec2.Instance(this, 'Instance', {
+ // ...
+ });
+const localPath = instance.userData.addS3DownloadCommand({
+ bucket:asset.bucket,
+ bucketKey:asset.s3ObjectKey,
+});
+instance.userData.addExecuteFileCommand({
+ filePath:localPath,
+ arguments: '--verbose -y'
+});
+asset.grantRead( instance.role );
+```
\ No newline at end of file
diff --git a/packages/@aws-cdk/aws-ec2/lib/user-data.ts b/packages/@aws-cdk/aws-ec2/lib/user-data.ts
index b637e181c8104..aecab8cd0e3ec 100644
--- a/packages/@aws-cdk/aws-ec2/lib/user-data.ts
+++ b/packages/@aws-cdk/aws-ec2/lib/user-data.ts
@@ -1,3 +1,5 @@
+import { IBucket } from "@aws-cdk/aws-s3";
+import { CfnElement, Resource, Stack } from "@aws-cdk/core";
import { OperatingSystemType } from "./machine-image";
/**
@@ -12,6 +14,50 @@ export interface LinuxUserDataOptions {
readonly shebang?: string;
}
+/**
+ * Options when downloading files from S3
+ */
+export interface S3DownloadOptions {
+
+ /**
+ * Name of the S3 bucket to download from
+ */
+ readonly bucket: IBucket;
+
+ /**
+ * The key of the file to download
+ */
+ readonly bucketKey: string;
+
+ /**
+ * The name of the local file.
+ *
+ * @default Linux - /tmp/bucketKey
+ * Windows - %TEMP%/bucketKey
+ */
+ readonly localFile?: string;
+
+}
+
+/**
+ * Options when executing a file.
+ */
+export interface ExecuteFileOptions {
+
+ /**
+ * The path to the file.
+ */
+ readonly filePath: string;
+
+ /**
+ * The arguments to be passed to the file.
+ *
+ * @default No arguments are passed to the file.
+ */
+ readonly arguments?: string;
+
+}
+
/**
* Instance User Data
*/
@@ -51,14 +97,41 @@ export abstract class UserData {
*/
public abstract addCommands(...commands: string[]): void;
+ /**
+ * Add one or more commands to the user data that will run when the script exits.
+ */
+ public abstract addOnExitCommands(...commands: string[]): void;
+
/**
* Render the UserData for use in a construct
*/
public abstract render(): string;
+
+ /**
+ * Adds commands to download a file from S3
+ *
+ * @returns: The local path that the file will be downloaded to
+ */
+ public abstract addS3DownloadCommand(params: S3DownloadOptions): string;
+
+ /**
+ * Adds commands to execute a file
+ */
+ public abstract addExecuteFileCommand( params: ExecuteFileOptions): void;
+
+ /**
+ * Adds a command which will send a cfn-signal when the user data script ends
+ */
+ public abstract addSignalOnExitCommand( resource: Resource ): void;
+
}
+/**
+ * Linux Instance User Data
+ */
class LinuxUserData extends UserData {
private readonly lines: string[] = [];
+ private readonly onExitLines: string[] = [];
constructor(private readonly props: LinuxUserDataOptions = {}) {
super();
@@ -68,14 +141,54 @@ class LinuxUserData extends UserData {
this.lines.push(...commands);
}
+ public addOnExitCommands(...commands: string[]) {
+ this.onExitLines.push(...commands);
+ }
+
public render(): string {
const shebang = this.props.shebang !== undefined ? this.props.shebang : '#!/bin/bash';
- return [shebang, ...this.lines].join('\n');
+ return [shebang, ...(this.renderOnExitLines()), ...this.lines].join('\n');
+ }
+
+ public addS3DownloadCommand(params: S3DownloadOptions): string {
+ const s3Path = `s3://${params.bucket.bucketName}/${params.bucketKey}`;
+ const localPath = ( params.localFile && params.localFile.length !== 0 ) ? params.localFile : `/tmp/${ params.bucketKey }`;
+ this.addCommands(
+ `mkdir -p $(dirname '${localPath}')`,
+ `aws s3 cp '${s3Path}' '${localPath}'`
+ );
+
+ return localPath;
+ }
+
+ public addExecuteFileCommand( params: ExecuteFileOptions): void {
+ this.addCommands(
+ `set -e`,
+ `chmod +x '${params.filePath}'`,
+ `'${params.filePath}' ${params.arguments}`
+ );
+ }
+
+ public addSignalOnExitCommand( resource: Resource ): void {
+ const stack = Stack.of(resource);
+ const resourceID = stack.getLogicalId(resource.node.defaultChild as CfnElement);
+ this.addOnExitCommands(`/opt/aws/bin/cfn-signal --stack ${stack.stackName} --resource ${resourceID} --region ${stack.region} -e $exitCode || echo 'Failed to send Cloudformation Signal'`);
+ }
+
+ private renderOnExitLines(): string[] {
+ if ( this.onExitLines.length > 0 ) {
+ return [ 'function exitTrap(){', 'exitCode=$?', ...this.onExitLines, '}', 'trap exitTrap EXIT' ];
+ }
+ return [];
}
}
+/**
+ * Windows Instance User Data
+ */
class WindowsUserData extends UserData {
private readonly lines: string[] = [];
+ private readonly onExitLines: string[] = [];
constructor() {
super();
@@ -85,11 +198,53 @@ class WindowsUserData extends UserData {
this.lines.push(...commands);
}
+ public addOnExitCommands(...commands: string[]) {
+ this.onExitLines.push(...commands);
+ }
+
public render(): string {
- return `${this.lines.join('\n')}`;
+ return `${
+ [...(this.renderOnExitLines()),
+ ...this.lines,
+ ...( this.onExitLines.length > 0 ? ['throw "Success"'] : [] )
+ ].join('\n')
+ }`;
+ }
+
+ public addS3DownloadCommand(params: S3DownloadOptions): string {
+ const localPath = ( params.localFile && params.localFile.length !== 0 ) ? params.localFile : `C:/temp/${ params.bucketKey }`;
+ this.addCommands(
+ `mkdir (Split-Path -Path '${localPath}' ) -ea 0`,
+ `Read-S3Object -BucketName '${params.bucket.bucketName}' -key '${params.bucketKey}' -file '${localPath}' -ErrorAction Stop`
+ );
+ return localPath;
+ }
+
+ public addExecuteFileCommand( params: ExecuteFileOptions): void {
+ this.addCommands(
+ `&'${params.filePath}' ${params.arguments}`,
+ `if (!$?) { Write-Error 'Failed to execute the file "${params.filePath}"' -ErrorAction Stop }`
+ );
+ }
+
+ public addSignalOnExitCommand( resource: Resource ): void {
+ const stack = Stack.of(resource);
+ const resourceID = stack.getLogicalId(resource.node.defaultChild as CfnElement);
+
+ this.addOnExitCommands(`cfn-signal --stack ${stack.stackName} --resource ${resourceID} --region ${stack.region} --success ($success.ToString().ToLower())`);
+ }
+
+ private renderOnExitLines(): string[] {
+ if ( this.onExitLines.length > 0 ) {
+ return ['trap {', '$success=($PSItem.Exception.Message -eq "Success")', ...this.onExitLines, 'break', '}'];
+ }
+ return [];
}
}
+/**
+ * Custom Instance User Data
+ */
class CustomUserData extends UserData {
private readonly lines: string[] = [];
@@ -101,7 +256,23 @@ class CustomUserData extends UserData {
this.lines.push(...commands);
}
+ public addOnExitCommands(): void {
+ throw new Error("CustomUserData does not support addOnExitCommands, use UserData.forLinux() or UserData.forWindows() instead.");
+ }
+
public render(): string {
return this.lines.join('\n');
}
+
+ public addS3DownloadCommand(): string {
+ throw new Error("CustomUserData does not support addS3DownloadCommand, use UserData.forLinux() or UserData.forWindows() instead.");
+ }
+
+ public addExecuteFileCommand(): void {
+ throw new Error("CustomUserData does not support addExecuteFileCommand, use UserData.forLinux() or UserData.forWindows() instead.");
+ }
+
+ public addSignalOnExitCommand(): void {
+ throw new Error("CustomUserData does not support addSignalOnExitCommand, use UserData.forLinux() or UserData.forWindows() instead.");
+ }
}
\ No newline at end of file
diff --git a/packages/@aws-cdk/aws-ec2/test/test.userdata.ts b/packages/@aws-cdk/aws-ec2/test/test.userdata.ts
index 48ba004fd56dd..83ea9cf2f0b18 100644
--- a/packages/@aws-cdk/aws-ec2/test/test.userdata.ts
+++ b/packages/@aws-cdk/aws-ec2/test/test.userdata.ts
@@ -1,4 +1,6 @@
+import { Bucket } from '@aws-cdk/aws-s3';
import { Test } from 'nodeunit';
+import { Stack } from "../../core/lib";
import * as ec2 from '../lib';
export = {
@@ -14,6 +16,99 @@ export = {
test.equals(rendered, 'command1\ncommand2');
test.done();
},
+ 'can create Windows user data with commands on exit'(test: Test) {
+ // GIVEN
+ const userData = ec2.UserData.forWindows();
+
+ // WHEN
+ userData.addCommands('command1', 'command2');
+ userData.addOnExitCommands('onexit1', 'onexit2');
+
+ // THEN
+ const rendered = userData.render();
+ test.equals(rendered, 'trap {\n' +
+ '$success=($PSItem.Exception.Message -eq "Success")\n' +
+ 'onexit1\n' +
+ 'onexit2\n' +
+ 'break\n' +
+ '}\n' +
+ 'command1\n' +
+ 'command2\n' +
+ 'throw "Success"');
+ test.done();
+ },
+ 'can create Windows with Signal Command'(test: Test) {
+ // GIVEN
+ const stack = new Stack();
+ const resource = new ec2.Vpc(stack, 'RESOURCE');
+ const userData = ec2.UserData.forWindows();
+
+ // WHEN
+ userData.addSignalOnExitCommand( resource );
+ userData.addCommands("command1");
+
+ // THEN
+ const rendered = userData.render();
+
+ test.equals(rendered, 'trap {\n' +
+ '$success=($PSItem.Exception.Message -eq "Success")\n' +
+ 'cfn-signal --stack Stack --resource RESOURCE1989552F --region ${Token[AWS::Region.4]} --success ($success.ToString().ToLower())\n' +
+ 'break\n' +
+ '}\n' +
+ 'command1\n' +
+ 'throw "Success"'
+ );
+ test.done();
+ },
+ 'can windows userdata download S3 files'(test: Test) {
+ // GIVEN
+ const stack = new Stack();
+ const userData = ec2.UserData.forWindows();
+ const bucket = Bucket.fromBucketName( stack, "testBucket", "test" );
+ const bucket2 = Bucket.fromBucketName( stack, "testBucket2", "test2" );
+
+ // WHEN
+ userData.addS3DownloadCommand({
+ bucket,
+ bucketKey: "filename.bat"
+ } );
+ userData.addS3DownloadCommand({
+ bucket: bucket2,
+ bucketKey: "filename2.bat",
+ localFile: "c:\\test\\location\\otherScript.bat"
+ } );
+
+ // THEN
+ const rendered = userData.render();
+ test.equals(rendered, 'mkdir (Split-Path -Path \'C:/temp/filename.bat\' ) -ea 0\n' +
+ 'Read-S3Object -BucketName \'test\' -key \'filename.bat\' -file \'C:/temp/filename.bat\' -ErrorAction Stop\n' +
+ 'mkdir (Split-Path -Path \'c:\\test\\location\\otherScript.bat\' ) -ea 0\n' +
+ 'Read-S3Object -BucketName \'test2\' -key \'filename2.bat\' -file \'c:\\test\\location\\otherScript.bat\' -ErrorAction Stop'
+ );
+ test.done();
+ },
+ 'can windows userdata execute files'(test: Test) {
+ // GIVEN
+ const userData = ec2.UserData.forWindows();
+
+ // WHEN
+ userData.addExecuteFileCommand({
+ filePath: "C:\\test\\filename.bat",
+ } );
+ userData.addExecuteFileCommand({
+ filePath: "C:\\test\\filename2.bat",
+ arguments: "arg1 arg2 -arg $variable"
+ } );
+
+ // THEN
+ const rendered = userData.render();
+ test.equals(rendered, '&\'C:\\test\\filename.bat\' undefined\n' +
+ 'if (!$?) { Write-Error \'Failed to execute the file "C:\\test\\filename.bat"\' -ErrorAction Stop }\n' +
+ '&\'C:\\test\\filename2.bat\' arg1 arg2 -arg $variable\n' +
+ 'if (!$?) { Write-Error \'Failed to execute the file "C:\\test\\filename2.bat"\' -ErrorAction Stop }'
+ );
+ test.done();
+ },
'can create Linux user data'(test: Test) {
// GIVEN
@@ -26,6 +121,101 @@ export = {
test.equals(rendered, '#!/bin/bash\ncommand1\ncommand2');
test.done();
},
+ 'can create Linux user data with commands on exit'(test: Test) {
+ // GIVEN
+ const userData = ec2.UserData.forLinux();
+
+ // WHEN
+ userData.addCommands('command1', 'command2');
+ userData.addOnExitCommands('onexit1', 'onexit2');
+
+ // THEN
+ const rendered = userData.render();
+ test.equals(rendered, '#!/bin/bash\n' +
+ 'function exitTrap(){\n' +
+ 'exitCode=$?\n' +
+ 'onexit1\n' +
+ 'onexit2\n' +
+ '}\n' +
+ 'trap exitTrap EXIT\n' +
+ 'command1\n' +
+ 'command2');
+ test.done();
+ },
+ 'can create Linux with Signal Command'(test: Test) {
+ // GIVEN
+ const stack = new Stack();
+ const resource = new ec2.Vpc(stack, 'RESOURCE');
+
+ // WHEN
+ const userData = ec2.UserData.forLinux();
+ userData.addCommands("command1");
+ userData.addSignalOnExitCommand( resource );
+
+ // THEN
+ const rendered = userData.render();
+ test.equals(rendered, '#!/bin/bash\n' +
+ 'function exitTrap(){\n' +
+ 'exitCode=$?\n' +
+ '/opt/aws/bin/cfn-signal --stack Stack --resource RESOURCE1989552F --region ${Token[AWS::Region.4]} -e $exitCode || echo \'Failed to send Cloudformation Signal\'\n' +
+ '}\n' +
+ 'trap exitTrap EXIT\n' +
+ 'command1');
+ test.done();
+ },
+ 'can linux userdata download S3 files'(test: Test) {
+ // GIVEN
+ const stack = new Stack();
+ const userData = ec2.UserData.forLinux();
+ const bucket = Bucket.fromBucketName( stack, "testBucket", "test" );
+ const bucket2 = Bucket.fromBucketName( stack, "testBucket2", "test2" );
+
+ // WHEN
+ userData.addS3DownloadCommand({
+ bucket,
+ bucketKey: "filename.sh"
+ } );
+ userData.addS3DownloadCommand({
+ bucket: bucket2,
+ bucketKey: "filename2.sh",
+ localFile: "c:\\test\\location\\otherScript.sh"
+ } );
+
+ // THEN
+ const rendered = userData.render();
+ test.equals(rendered, '#!/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\''
+ );
+ test.done();
+ },
+ 'can linux userdata execute files'(test: Test) {
+ // GIVEN
+ const userData = ec2.UserData.forLinux();
+
+ // WHEN
+ userData.addExecuteFileCommand({
+ filePath: "/tmp/filename.sh",
+ } );
+ userData.addExecuteFileCommand({
+ filePath: "/test/filename2.sh",
+ arguments: "arg1 arg2 -arg $variable"
+ } );
+
+ // THEN
+ const rendered = userData.render();
+ test.equals(rendered, '#!/bin/bash\n' +
+ 'set -e\n' +
+ 'chmod +x \'/tmp/filename.sh\'\n' +
+ '\'/tmp/filename.sh\' undefined\n' +
+ 'set -e\n' +
+ 'chmod +x \'/test/filename2.sh\'\n' +
+ '\'/test/filename2.sh\' arg1 arg2 -arg $variable'
+ );
+ test.done();
+ },
'can create Custom user data'(test: Test) {
// GIVEN
@@ -37,4 +227,50 @@ export = {
test.equals(rendered, 'Some\nmultiline\ncontent');
test.done();
},
+ 'Custom user data throws when adding on exit commands'(test: Test) {
+ // GIVEN
+ // WHEN
+ const userData = ec2.UserData.custom("");
+
+ // THEN
+ test.throws(() => userData.addOnExitCommands( "a command goes here" ));
+ test.done();
+ },
+ 'Custom user data throws when adding signal command'(test: Test) {
+ // GIVEN
+ const stack = new Stack();
+ const resource = new ec2.Vpc(stack, 'RESOURCE');
+
+ // WHEN
+ const userData = ec2.UserData.custom("");
+
+ // THEN
+ test.throws(() => userData.addSignalOnExitCommand( resource ));
+ test.done();
+ },
+ 'Custom user data throws when downloading file'(test: Test) {
+ // GIVEN
+ const stack = new Stack();
+ const userData = ec2.UserData.custom("");
+ const bucket = Bucket.fromBucketName( stack, "testBucket", "test" );
+ // WHEN
+ // THEN
+ test.throws(() => userData.addS3DownloadCommand({
+ bucket,
+ bucketKey: "filename.sh"
+ } ));
+ test.done();
+ },
+ 'Custom user data throws when executing file'(test: Test) {
+ // GIVEN
+ const userData = ec2.UserData.custom("");
+ // WHEN
+ // THEN
+ test.throws(() =>
+ userData.addExecuteFileCommand({
+ filePath: "/tmp/filename.sh",
+ } ));
+ test.done();
+ },
+
};