Skip to content

Commit

Permalink
feat(aws-ec2): signal, download and execute helpers for UserData (#6029)
Browse files Browse the repository at this point in the history
User Data objects currently only supports adding commands by providing the full command as a string.  This commit hopes to address this by adding the following functionality:
* On Exit Commands - Both bash and powershell have the concepts of trap functions which can be used to force a function to run when a an exception is run.  Using this we are able to set up a script block that will always run at the end of the script.
* add Signal Command - Using the above on Exit commands we are able to make it so the User data will send a signal to a specific resource (eg. Instance/Auto scaling group) with the results of the last command.
* Download S3 File Command - This adds commands to download the specified file using the aws cli on linux and AWS powershell  utility on windows
* Execute File Command - This adds commands to ensure that the specified file is executable then executes the file with specified arguments.

Fixes #623
  • Loading branch information
grbartel authored Feb 27, 2020
1 parent 15435a6 commit ee8f169
Show file tree
Hide file tree
Showing 3 changed files with 430 additions and 2 deletions.
21 changes: 21 additions & 0 deletions packages/@aws-cdk/aws-ec2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
```
175 changes: 173 additions & 2 deletions packages/@aws-cdk/aws-ec2/lib/user-data.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { IBucket } from "@aws-cdk/aws-s3";
import { CfnElement, Resource, Stack } from "@aws-cdk/core";
import { OperatingSystemType } from "./machine-image";

/**
Expand All @@ -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
*/
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -85,11 +198,53 @@ class WindowsUserData extends UserData {
this.lines.push(...commands);
}

public addOnExitCommands(...commands: string[]) {
this.onExitLines.push(...commands);
}

public render(): string {
return `<powershell>${this.lines.join('\n')}</powershell>`;
return `<powershell>${
[...(this.renderOnExitLines()),
...this.lines,
...( this.onExitLines.length > 0 ? ['throw "Success"'] : [] )
].join('\n')
}</powershell>`;
}

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[] = [];

Expand All @@ -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.");
}
}
Loading

0 comments on commit ee8f169

Please sign in to comment.