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(); + }, + };