packages/aws-cdk-lib/aws-ec2/lib/user-data.ts (293 lines of code) (raw):
import { OperatingSystemType } from './machine-image';
import { IBucket } from '../../aws-s3';
import { Fn, Resource, Stack, CfnResource, UnscopedValidationError } from '../../core';
/**
* Options when constructing UserData for Linux
*/
export interface LinuxUserDataOptions {
/**
* Shebang for the UserData script
*
* @default "#!/bin/bash"
*/
readonly shebang?: string;
}
/**
* Options when constructing UserData for Windows
*/
export interface WindowsUserDataOptions {
/**
* Set to true to set this userdata to persist through an instance reboot; allowing
* it to run on every instance start.
* By default, UserData is run only once during the first instance launch.
*
* For more information, see:
* https://aws.amazon.com/premiumsupport/knowledge-center/execute-user-data-ec2/
* https://docs.aws.amazon.com/AWSEC2/latest/WindowsGuide/ec2-windows-user-data.html#user-data-scripts
*
* @default false
*/
readonly persist?: boolean;
}
/**
* 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;
/**
* The region of the S3 Bucket (needed for access via VPC Gateway)
* @default none
*/
readonly region?: 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
*/
export abstract class UserData {
/**
* Create a userdata object for Linux hosts
*/
public static forLinux(options: LinuxUserDataOptions = {}): UserData {
return new LinuxUserData(options);
}
/**
* Create a userdata object for Windows hosts
*/
public static forWindows(options: WindowsUserDataOptions = {}): UserData {
return new WindowsUserData(options);
}
/**
* Create a userdata object with custom content
*/
public static custom(content: string): UserData {
const userData = new CustomUserData();
userData.addCommands(content);
return userData;
}
public static forOperatingSystem(os: OperatingSystemType): UserData {
switch (os) {
case OperatingSystemType.LINUX: return UserData.forLinux();
case OperatingSystemType.WINDOWS: return UserData.forWindows();
case OperatingSystemType.UNKNOWN: throw new UnscopedValidationError('Cannot determine UserData for unknown operating system type');
}
}
/**
* Add one or more commands to the user data
*/
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();
}
public addCommands(...commands: string[]) {
this.lines.push(...commands);
}
public addOnExitCommands(...commands: string[]) {
this.onExitLines.push(...commands);
}
public render(): string {
const shebang = this.props.shebang ?? '#!/bin/bash';
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}'` + (params.region !== undefined ? ` --region ${params.region}` : ''),
);
return localPath;
}
public addExecuteFileCommand( params: ExecuteFileOptions): void {
this.addCommands(
'set -e',
`chmod +x '${params.filePath}'`,
`'${params.filePath}' ${params.arguments ?? ''}`.trim(),
);
}
public addSignalOnExitCommand( resource: Resource ): void {
const stack = Stack.of(resource);
const resourceID = (resource.node.defaultChild as CfnResource).logicalId;
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(private readonly props: WindowsUserDataOptions = {}) {
super();
}
public addCommands(...commands: string[]) {
this.lines.push(...commands);
}
public addOnExitCommands(...commands: string[]) {
this.onExitLines.push(...commands);
}
public render(): string {
return `<powershell>${
[...(this.renderOnExitLines()),
...this.lines,
...( this.onExitLines.length > 0 ? ['throw "Success"'] : [] )].join('\n')
}</powershell>${(this.props.persist ?? false) ? '<persist>true</persist>' : ''}`;
}
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` + (params.region !== undefined ? ` -Region ${params.region}` : ''),
);
return localPath;
}
public addExecuteFileCommand( params: ExecuteFileOptions): void {
this.addCommands(
`&'${params.filePath}' ${params.arguments ?? ''}`.trim(),
`if (!$?) { Write-Error 'Failed to execute the file "${params.filePath}"' -ErrorAction Stop }`,
);
}
public addSignalOnExitCommand( resource: Resource ): void {
const stack = Stack.of(resource);
const resourceID = (resource.node.defaultChild as CfnResource).logicalId;
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[] = [];
constructor() {
super();
}
public addCommands(...commands: string[]) {
this.lines.push(...commands);
}
public addOnExitCommands(): void {
throw new UnscopedValidationError('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 UnscopedValidationError('CustomUserData does not support addS3DownloadCommand, use UserData.forLinux() or UserData.forWindows() instead.');
}
public addExecuteFileCommand(): void {
throw new UnscopedValidationError('CustomUserData does not support addExecuteFileCommand, use UserData.forLinux() or UserData.forWindows() instead.');
}
public addSignalOnExitCommand(): void {
throw new UnscopedValidationError('CustomUserData does not support addSignalOnExitCommand, use UserData.forLinux() or UserData.forWindows() instead.');
}
}
/**
* Options when creating `MultipartBody`.
*/
export interface MultipartBodyOptions {
/**
* `Content-Type` header of this part.
*
* Some examples of content types:
* * `text/x-shellscript; charset="utf-8"` (shell script)
* * `text/cloud-boothook; charset="utf-8"` (shell script executed during boot phase)
*
* For Linux shell scripts use `text/x-shellscript`.
*/
readonly contentType: string;
/**
* `Content-Transfer-Encoding` header specifying part encoding.
*
* @default undefined - body is not encoded
*/
readonly transferEncoding?: string;
/**
* The body of message.
*
* @default undefined - body will not be added to part
*/
readonly body?: string;
}
/**
* The base class for all classes which can be used as `MultipartUserData`.
*/
export abstract class MultipartBody {
/**
* Content type for shell scripts
*/
public static readonly SHELL_SCRIPT = 'text/x-shellscript; charset="utf-8"';
/**
* Content type for boot hooks
*/
public static readonly CLOUD_BOOTHOOK = 'text/cloud-boothook; charset="utf-8"';
/**
* Constructs the new `MultipartBody` wrapping existing `UserData`. Modification to `UserData` are reflected
* in subsequent renders of the part.
*
* For more information about content types see `MultipartBodyOptions.contentType`.
*
* @param userData user data to wrap into body part
* @param contentType optional content type, if default one should not be used
*/
public static fromUserData(userData: UserData, contentType?: string): MultipartBody {
return new MultipartBodyUserDataWrapper(userData, contentType);
}
/**
* Constructs the raw `MultipartBody` using specified body, content type and transfer encoding.
*
* When transfer encoding is specified (typically as Base64), it's caller responsibility to convert body to
* Base64 either by wrapping with `Fn.base64` or by converting it by other converters.
*/
public static fromRawBody(opts: MultipartBodyOptions): MultipartBody {
return new MultipartBodyRaw(opts);
}
public constructor() {
}
/**
* Render body part as the string.
*
* Subclasses should not add leading nor trailing new line characters (\r \n)
*/
public abstract renderBodyPart(): string[];
}
/**
* The raw part of multi-part user data, which can be added to `MultipartUserData`.
*/
class MultipartBodyRaw extends MultipartBody {
public constructor(private readonly props: MultipartBodyOptions) {
super();
}
/**
* Render body part as the string.
*/
public renderBodyPart(): string[] {
const result: string[] = [];
result.push(`Content-Type: ${this.props.contentType}`);
if (this.props.transferEncoding != null) {
result.push(`Content-Transfer-Encoding: ${this.props.transferEncoding}`);
}
// One line free after separator
result.push('');
if (this.props.body != null) {
result.push(this.props.body);
// The new line added after join will be consumed by encapsulating or closing boundary
}
return result;
}
}
/**
* Wrapper for `UserData`.
*/
class MultipartBodyUserDataWrapper extends MultipartBody {
private readonly contentType: string;
public constructor(private readonly userData: UserData, contentType?: string) {
super();
this.contentType = contentType || MultipartBody.SHELL_SCRIPT;
}
/**
* Render body part as the string.
*/
public renderBodyPart(): string[] {
const result: string[] = [];
result.push(`Content-Type: ${this.contentType}`);
result.push('Content-Transfer-Encoding: base64');
result.push('');
result.push(Fn.base64(this.userData.render()));
return result;
}
}
/**
* Options for creating `MultipartUserData`
*/
export interface MultipartUserDataOptions {
/**
* The string used to separate parts in multipart user data archive (it's like MIME boundary).
*
* This string should contain [a-zA-Z0-9()+,-./:=?] characters only, and should not be present in any part, or in text content of archive.
*
* @default `+AWS+CDK+User+Data+Separator==`
*/
readonly partsSeparator?: string;
}
/**
* Mime multipart user data.
*
* This class represents MIME multipart user data, as described in.
* [Specifying Multiple User Data Blocks Using a MIME Multi Part Archive](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/bootstrap_container_instance.html#multi-part_user_data)
*
*/
export class MultipartUserData extends UserData {
private static readonly USE_PART_ERROR = 'MultipartUserData only supports this operation if it has a default UserData. Call addUserDataPart with makeDefault=true.';
private static readonly BOUNDRY_PATTERN = '[^a-zA-Z0-9()+,-./:=?]';
private parts: MultipartBody[] = [];
private opts: MultipartUserDataOptions;
private defaultUserData?: UserData;
constructor(opts?: MultipartUserDataOptions) {
super();
let partsSeparator: string;
// Validate separator
if (opts?.partsSeparator != null) {
if (new RegExp(MultipartUserData.BOUNDRY_PATTERN).test(opts!.partsSeparator)) {
throw new UnscopedValidationError(`Invalid characters in separator. Separator has to match pattern ${MultipartUserData.BOUNDRY_PATTERN}`);
} else {
partsSeparator = opts!.partsSeparator;
}
} else {
partsSeparator = '+AWS+CDK+User+Data+Separator==';
}
this.opts = {
partsSeparator: partsSeparator,
};
}
/**
* Adds a part to the list of parts.
*/
public addPart(part: MultipartBody) {
this.parts.push(part);
}
/**
* Adds a multipart part based on a UserData object.
*
* If `makeDefault` is true, then the UserData added by this method
* will also be the target of calls to the `add*Command` methods on
* this MultipartUserData object.
*
* If `makeDefault` is false, then this is the same as calling:
*
* ```ts
* declare const multiPart: ec2.MultipartUserData;
* declare const userData: ec2.UserData;
* declare const contentType: string;
*
* multiPart.addPart(ec2.MultipartBody.fromUserData(userData, contentType));
* ```
*
* An undefined `makeDefault` defaults to either:
* - `true` if no default UserData has been set yet; or
* - `false` if there is no default UserData set.
*/
public addUserDataPart(userData: UserData, contentType?: string, makeDefault?: boolean) {
this.addPart(MultipartBody.fromUserData(userData, contentType));
makeDefault = makeDefault ?? (this.defaultUserData === undefined ? true : false);
if (makeDefault) {
this.defaultUserData = userData;
}
}
public render(): string {
const boundary = this.opts.partsSeparator;
// Now build final MIME archive - there are few changes from MIME message which are accepted by cloud-init:
// - MIME RFC uses CRLF to separate lines - cloud-init is fine with LF \n only
// Note: new lines matters, matters a lot.
var resultArchive = new Array<string>();
resultArchive.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
resultArchive.push('MIME-Version: 1.0');
// Add new line, the next one will be boundary (encapsulating or closing)
// so this line will count into it.
resultArchive.push('');
// Add parts - each part starts with boundary
this.parts.forEach(part => {
resultArchive.push(`--${boundary}`);
resultArchive.push(...part.renderBodyPart());
});
// Add closing boundary
resultArchive.push(`--${boundary}--`);
resultArchive.push(''); // Force new line at the end
return resultArchive.join('\n');
}
public addS3DownloadCommand(params: S3DownloadOptions): string {
if (this.defaultUserData) {
return this.defaultUserData.addS3DownloadCommand(params);
} else {
throw new UnscopedValidationError(MultipartUserData.USE_PART_ERROR);
}
}
public addExecuteFileCommand(params: ExecuteFileOptions): void {
if (this.defaultUserData) {
this.defaultUserData.addExecuteFileCommand(params);
} else {
throw new UnscopedValidationError(MultipartUserData.USE_PART_ERROR);
}
}
public addSignalOnExitCommand(resource: Resource): void {
if (this.defaultUserData) {
this.defaultUserData.addSignalOnExitCommand(resource);
} else {
throw new UnscopedValidationError(MultipartUserData.USE_PART_ERROR);
}
}
public addCommands(...commands: string[]): void {
if (this.defaultUserData) {
this.defaultUserData.addCommands(...commands);
} else {
throw new UnscopedValidationError(MultipartUserData.USE_PART_ERROR);
}
}
public addOnExitCommands(...commands: string[]): void {
if (this.defaultUserData) {
this.defaultUserData.addOnExitCommands(...commands);
} else {
throw new UnscopedValidationError(MultipartUserData.USE_PART_ERROR);
}
}
}