diff --git a/packages/@aws-cdk/aws-ec2/lib/user-data.ts b/packages/@aws-cdk/aws-ec2/lib/user-data.ts index b637e181c8104..01b5f4304443c 100644 --- a/packages/@aws-cdk/aws-ec2/lib/user-data.ts +++ b/packages/@aws-cdk/aws-ec2/lib/user-data.ts @@ -1,3 +1,4 @@ +import { CfnElement, Resource, Stack } from "@aws-cdk/core"; import { OperatingSystemType } from "./machine-image"; /** @@ -12,6 +13,37 @@ export interface LinuxUserDataOptions { readonly shebang?: string; } +/** + * Options when downloading files from S3 + */ +export interface S3DownloadAndExecuteOptions { + + /** + * Name of the bucket to download from + */ + readonly bucketName: string; + + /** + * The key of the file to download + */ + readonly bucketKey: string; + + /** + * The name of the local file. + * + * @default Linux - ~/bucketKey + * Windows - %TEMP%/bucketKey + */ + readonly localFile?: string; + + /** + * The arguments to be used when executing the file + * + * @default no arguments. + */ + readonly arguments?: string[] +} + /** * Instance User Data */ @@ -51,14 +83,35 @@ 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 a command to download a file from S3 + */ + public abstract addDownloadAndExecuteS3FileCommand(params: S3DownloadAndExecuteOptions): 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[] = []; + private readonly functionsAdded = new Set(); constructor(private readonly props: LinuxUserDataOptions = {}) { super(); @@ -68,14 +121,74 @@ 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 addDownloadAndExecuteS3FileCommand( params: S3DownloadAndExecuteOptions ): void { + if (!this.functionsAdded.has('download_and_execute_s3_file')) { + this.addCommands("download_and_execute_s3_file () {\n" + + "local s3Path=$1;\n" + + "local path=$2;\n" + + "shift;shift;\n" + + "echo \"Downloading file ${s3Path} to ${path}\";\n" + + "mkdir -p $(dirname ${path}) ;\n" + + "aws s3 cp ${s3Path} ${path};\n" + + "if [ $? -ne 0 ]; then exit 1;fi;\n" + + "chmod +x ${path};\n" + + "if [ $? -ne 0 ]; then exit 1;fi;\n" + + "${path} \"$@\"\n" + + "if [ $? -ne 0 ]; then exit 1;fi;\n" + + "}"); + this.functionsAdded.add('download_and_execute_s3_file'); + } + let argumentStr = ""; + if ( params.arguments && params.arguments.length > 0 ) { + argumentStr = params.arguments.map(x => this.posixEscape(x)).join(' '); + } + + const localPath = (params.localFile && params.localFile.length !== 0) ? params.localFile : `/tmp/${ params.bucketKey }`; + + this.addCommands(`download_and_execute_s3_file \"s3://${params.bucketName}/${params.bucketKey}\" \"${localPath}\" ${argumentStr}` ); + } + + 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 []; + } + + /** + * Escape a shell argument for POSIX shells + * + */ + private posixEscape(x: string) { + // Turn ' -> '"'"' + x = x.replace("'", "'\"'\"'"); + return `'${x}'`; + } + } +/** + * Windows Instance User Data + */ class WindowsUserData extends UserData { private readonly lines: string[] = []; + private readonly onExitLines: string[] = []; + private readonly functionsAdded = new Set(); constructor() { super(); @@ -85,13 +198,73 @@ 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 addDownloadAndExecuteS3FileCommand( params: S3DownloadAndExecuteOptions ): void { + if (!this.functionsAdded.has('download_and_execute_s3_file')) { + this.addCommands("function download_and_execute_s3_file{\n" + + "Param(\n" + + " [Parameter(Mandatory=$True)]\n" + + " $bucketName,\n" + + " [Parameter(Mandatory=$True)]\n" + + " $bucketKey,\n" + + " [Parameter(Mandatory=$True)]\n" + + " $localFile,\n" + + " [parameter(mandatory=$false,ValueFromRemainingArguments=$true)]\n" + + " $arguments\n" + + ")\n" + + "mkdir (Split-Path -Path $localFile ) -ea 0\n" + + "Read-S3Object -BucketName $bucketName -key $bucketKey -file $localFile -ErrorAction Stop\n" + + "&\"$localFile\" @arguments\n" + + "if (!$?) { Write-Error 'Failed to execute file' -ErrorAction Stop }\n" + + "}"); + this.functionsAdded.add('download_and_execute_s3_file'); + } + + const args = [ + params.bucketName, + params.bucketKey, + params.localFile || "C:/temp/" + params.bucketKey, + ]; + if ( params.arguments ) { + args.push(...params.arguments); + } + + this.addCommands(`download_and_execute_s3_file ${ args.map(x => `'${x}'` ).join(' ') }` ); + } + + 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[] = []; + private readonly onExitLines: string[] = []; constructor() { super(); @@ -101,7 +274,19 @@ class CustomUserData extends UserData { this.lines.push(...commands); } + public addOnExitCommands(...commands: string[]): void { + this.onExitLines.push(...commands); + } + public render(): string { - return this.lines.join('\n'); + return [...this.lines, ...this.onExitLines].join('\n'); + } + + public addDownloadAndExecuteS3FileCommand(): void { + throw new Error("Method not implemented."); + } + + public addSignalOnExitCommand( ): void { + throw new Error("Method not implemented."); } } \ 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..b340c49f59d44 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.userdata.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.userdata.ts @@ -1,4 +1,5 @@ import { Test } from 'nodeunit'; +import { Stack } from "../../core/lib"; import * as ec2 from '../lib'; export = { @@ -14,6 +15,94 @@ 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 and execute S3 files'(test: Test) { + // GIVEN + const userData = ec2.UserData.forWindows(); + + // WHEN + userData.addDownloadAndExecuteS3FileCommand({ + bucketName: "test", + bucketKey: "filename.bat" + } ); + userData.addDownloadAndExecuteS3FileCommand({ + bucketName: "test2", + bucketKey: "filename2.bat", + localFile: ".\\otherScript.sh" + } ); + userData.addDownloadAndExecuteS3FileCommand({ + bucketName: "test3", + bucketKey: "filename3.bat", + localFile: ".\\thirdScript.sh", + arguments: ["arg1", "arg2"] + } ); + + // THEN + const rendered = userData.render(); + test.equals(rendered, 'function download_and_execute_s3_file{\n' + + 'Param(\n' + + ' [Parameter(Mandatory=$True)]\n' + + ' $bucketName,\n' + + ' [Parameter(Mandatory=$True)]\n' + + ' $bucketKey,\n' + + ' [Parameter(Mandatory=$True)]\n' + + ' $localFile,\n' + + ' [parameter(mandatory=$false,ValueFromRemainingArguments=$true)]\n' + + ' $arguments\n' + + ')\n' + + 'mkdir (Split-Path -Path $localFile ) -ea 0\n' + + 'Read-S3Object -BucketName $bucketName -key $bucketKey -file $localFile -ErrorAction Stop\n' + + '&"$localFile" @arguments\n' + + 'if (!$?) { Write-Error \'Failed to execute file\' -ErrorAction Stop }\n' + + '}\ndownload_and_execute_s3_file \'test\' \'filename.bat\' \'C:/temp/filename.bat\'\n' + + 'download_and_execute_s3_file \'test2\' \'filename2.bat\' \'.\\otherScript.sh\'\n' + + 'download_and_execute_s3_file \'test3\' \'filename3.bat\' \'.\\thirdScript.sh\' \'arg1\' \'arg2\'' + ); + test.done(); + }, 'can create Linux user data'(test: Test) { // GIVEN @@ -26,6 +115,89 @@ 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' + + '}' + + '\ntrap exitTrap EXIT\n' + + 'command1'); + test.done(); + }, + 'can linux userdata download and execute S3 files'(test: Test) { + // GIVEN + const userData = ec2.UserData.forLinux(); + + // WHEN + userData.addDownloadAndExecuteS3FileCommand({ + bucketName: "test", + bucketKey: "filename.sh" } ); + userData.addDownloadAndExecuteS3FileCommand({ + bucketName: "test2", + bucketKey: "filename2.sh", + localFile: "~/otherScript.sh" + } ); + userData.addDownloadAndExecuteS3FileCommand({ + bucketName: "test3", + bucketKey: "filename3.sh", + localFile: "~/thirdScript.sh", + arguments: ["arg1", "arg2"] + } ); + + // THEN + const rendered = userData.render(); + test.equals(rendered, '#!/bin/bash\n' + + 'download_and_execute_s3_file () {\n' + + 'local s3Path=$1;\n' + + 'local path=$2;\n' + + 'shift;shift;\n' + + 'echo "Downloading file ${s3Path} to ${path}";\n' + + 'mkdir -p $(dirname ${path}) ;\n' + + 'aws s3 cp ${s3Path} ${path};\n' + + 'if [ $? -ne 0 ]; then exit 1;fi;\n' + + 'chmod +x ${path};\n' + + 'if [ $? -ne 0 ]; then exit 1;fi;\n' + + '${path} "$@"\n' + + 'if [ $? -ne 0 ]; then exit 1;fi;\n' + + '}\n' + + 'download_and_execute_s3_file "s3://test/filename.sh" "/tmp/filename.sh" \n' + + 'download_and_execute_s3_file "s3://test2/filename2.sh" "~/otherScript.sh" \n' + + 'download_and_execute_s3_file "s3://test3/filename3.sh" "~/thirdScript.sh" \'arg1\' \'arg2\''); + test.done(); + }, 'can create Custom user data'(test: Test) { // GIVEN