-
Notifications
You must be signed in to change notification settings - Fork 4.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(ec2): multipart user data #11843
Changes from 5 commits
e8c7733
b84fa3a
a013138
f50d10b
5338f1d
2563648
bc6e06c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,5 @@ | ||
import { IBucket } from '@aws-cdk/aws-s3'; | ||
import { CfnElement, Resource, Stack } from '@aws-cdk/core'; | ||
import { CfnElement, Fn, Resource, Stack } from '@aws-cdk/core'; | ||
import { OperatingSystemType } from './machine-image'; | ||
|
||
/** | ||
|
@@ -276,3 +276,248 @@ class CustomUserData extends UserData { | |
throw new Error('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 - don't add this header | ||
*/ | ||
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 {@link MultipartUserData}. | ||
*/ | ||
export abstract class MultipartBody { | ||
|
||
/** | ||
* 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 {@link 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); | ||
} | ||
|
||
protected static readonly DEFAULT_CONTENT_TYPE = 'text/x-shellscript; charset="utf-8"'; | ||
|
||
/** The body of this MIME part. */ | ||
public abstract get body(): string | undefined; | ||
|
||
/** `Content-Type` header of this part */ | ||
public abstract get contentType(): string; | ||
|
||
/** | ||
* `Content-Transfer-Encoding` header specifying part encoding. | ||
* | ||
* @default undefined - don't add this header | ||
*/ | ||
public abstract get transferEncoding(): string | undefined; | ||
|
||
public constructor() { | ||
} | ||
|
||
/** | ||
* Render body part as the string. | ||
* | ||
* Subclasses should not add leading nor trailing new line characters (\r \n) | ||
*/ | ||
public renderBodyPart(): string { | ||
const result: string[] = []; | ||
|
||
result.push(`Content-Type: ${this.contentType}`); | ||
|
||
if (this.transferEncoding != null) { | ||
result.push(`Content-Transfer-Encoding: ${this.transferEncoding}`); | ||
} | ||
// One line free after separator | ||
result.push(''); | ||
|
||
if (this.body != null) { | ||
result.push(this.body); | ||
// The new line added after join will be consumed by encapsulating or closing boundary | ||
} | ||
|
||
return result.join('\n'); | ||
} | ||
} | ||
|
||
/** | ||
* The raw part of multi-part user data, which can be added to {@link MultipartUserData}. | ||
*/ | ||
class MultipartBodyRaw extends MultipartBody { | ||
public readonly body: string | undefined; | ||
public readonly contentType: string; | ||
public readonly transferEncoding: string | undefined; | ||
|
||
public constructor(props: MultipartBodyOptions) { | ||
super(); | ||
|
||
this.body = props.body; | ||
this.contentType = props.contentType; | ||
} | ||
} | ||
|
||
/** | ||
* Wrapper for `UserData`. | ||
*/ | ||
class MultipartBodyUserDataWrapper extends MultipartBody { | ||
|
||
public readonly contentType: string; | ||
public readonly transferEncoding: string | undefined; | ||
|
||
public constructor(public readonly userData: UserData, contentType?: string) { | ||
super(); | ||
|
||
this.contentType = contentType || MultipartBody.DEFAULT_CONTENT_TYPE; | ||
this.transferEncoding = 'base64'; | ||
} | ||
|
||
public get body(): string { | ||
// Wrap rendered user data with Base64 function, in case data contains non ASCII characters | ||
return Fn.base64(this.userData.render()); | ||
} | ||
} | ||
|
||
/** | ||
* Options for creating {@link 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 does not support this operation. Please add part using addPart.'; | ||
rsmogura marked this conversation as resolved.
Show resolved
Hide resolved
|
||
private static readonly BOUNDRY_PATTERN = '[^a-zA-Z0-9()+,-./:=?]'; | ||
|
||
private parts: MultipartBody[] = []; | ||
|
||
private opts: MultipartUserDataOptions; | ||
|
||
constructor(opts?: MultipartUserDataOptions) { | ||
super(); | ||
|
||
let partsSeparator: string; | ||
|
||
// Validate separator | ||
if (opts?.partsSeparator != null) { | ||
if (new RegExp(MultipartUserData.BOUNDRY_PATTERN).test(opts!.partsSeparator)) { | ||
throw new Error(`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 { | ||
this.parts.push(part); | ||
|
||
return this; | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why get rid of the (Of course it's not strictly necessary, but it reads nicer than the current alternative) |
||
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think now I understand why cc @fogfish There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @robertd - I think it's not deprecated. You still can use other script parts as the body of archive. However with multiply you may try to use x-include-url Content Type https://cloudinit.readthedocs.io/en/latest/topics/format.html#include-file And I'm glad you did find an answer for your question. |
||
throw new Error(MultipartUserData.USE_PART_ERROR); | ||
} | ||
|
||
public addExecuteFileCommand(_params: ExecuteFileOptions): void { | ||
throw new Error(MultipartUserData.USE_PART_ERROR); | ||
} | ||
|
||
public addSignalOnExitCommand(_resource: Resource): void { | ||
throw new Error(MultipartUserData.USE_PART_ERROR); | ||
} | ||
|
||
public addCommands(..._commands: string[]): void { | ||
throw new Error(MultipartUserData.USE_PART_ERROR); | ||
} | ||
|
||
public addOnExitCommands(..._commands: string[]): void { | ||
throw new Error(MultipartUserData.USE_PART_ERROR); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I'd prefer you don't declare these abstract members, and just implement
renderBodyPart()
twice--once for the two concrete types of classes.It may feel like unnecessary duplication, but the actual amount of duplication won't be that bad and we'll have a good reduction in case analysis too (fewer
if
s).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now I got the point. I thought that main concern was about options interfaces, so I moved towards abstracts getters and template method pattern.
Thanks, that's a good comment.