Skip to content
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', {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please fix the indentation and other whitespace here to at least be consistent.

Indentation of 2 spaces please, closing curly is not indented.

// ...
});
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not completely obvious what this returns, so that might be worth a @returns annotation.


/**
* 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`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The download also needs to execute under set -e. An easier way to set this is to change the shebang.

Can you change the default shebang to #!/bin/bash -e ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potentially even #!/bin/bash -ex

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if we want to actually to modify the shebang like this since I would consider the following:

  • -e being a potentially breaking change for users. Anyone who was already adding -e manually would be fine but everyone else could run into unepected failures.
  • -x being a potential security risk since unless customers explicitly disabled it we could be printing secrets in the userdata.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a fair concern, but by that logic we can never improve any situation. At least -e should be the default for 99% of users; people who want/need something else because they do custom error handling should be able to deviate from the default if they want to.

I will take this change myself since I also predict it will be a lot of integ test work.

`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