Skip to content

Commit e8c7733

Browse files
Radek Smogurarsmogura
Radek Smogura
authored andcommitted
feat(ec2): introduce multipart user data
Add support for multiparat (MIME) user data for Linux environments. This type is more versatile type of user data, and some AWS service (i.e. AWS Batch) requires it in order to customize the launch behaviour. Change was tested in integ environment to check if all user data parts has been executed correctly and with proper charset encoding. fixes aws#8315
1 parent 664133a commit e8c7733

File tree

4 files changed

+963
-2
lines changed

4 files changed

+963
-2
lines changed

packages/@aws-cdk/aws-ec2/lib/user-data.ts

+181-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import * as crypto from 'crypto';
2+
13
import { IBucket } from '@aws-cdk/aws-s3';
2-
import { CfnElement, Resource, Stack } from '@aws-cdk/core';
4+
import { CfnElement, Fn, Resource, Stack } from '@aws-cdk/core';
35
import { OperatingSystemType } from './machine-image';
46

57
/**
@@ -61,7 +63,7 @@ export interface ExecuteFileOptions {
6163
/**
6264
* Instance User Data
6365
*/
64-
export abstract class UserData {
66+
export abstract class UserData implements IMultipartUserDataPartProducer {
6567
/**
6668
* Create a userdata object for Linux hosts
6769
*/
@@ -108,6 +110,13 @@ export abstract class UserData {
108110
*/
109111
public abstract render(): string;
110112

113+
/**
114+
* Render the user data as a part for `MultipartUserData`. Not all subclasses supports this.
115+
*/
116+
public renderAsMimePart(_renderOpts?: MultipartRenderOptions): MutlipartUserDataPart {
117+
throw new Error('This class does not support rendering as MIME part');
118+
}
119+
111120
/**
112121
* Adds commands to download a file from S3
113122
*
@@ -151,6 +160,15 @@ class LinuxUserData extends UserData {
151160
return [shebang, ...(this.renderOnExitLines()), ...this.lines].join('\n');
152161
}
153162

163+
public renderAsMimePart(renderOpts?: MultipartRenderOptions): MutlipartUserDataPart {
164+
const contentType = renderOpts?.contentType || 'text/x-shellscript';
165+
return new MutlipartUserDataPart({
166+
body: Fn.base64(this.render()), // Wrap into base64, to support UTF-8 encoding (actually decoding)
167+
contentType: `${contentType}; charset="utf-8"`,
168+
transferEncoding: 'base64',
169+
});
170+
}
171+
154172
public addS3DownloadCommand(params: S3DownloadOptions): string {
155173
const s3Path = `s3://${params.bucket.bucketName}/${params.bucketKey}`;
156174
const localPath = ( params.localFile && params.localFile.length !== 0 ) ? params.localFile : `/tmp/${ params.bucketKey }`;
@@ -276,3 +294,164 @@ class CustomUserData extends UserData {
276294
throw new Error('CustomUserData does not support addSignalOnExitCommand, use UserData.forLinux() or UserData.forWindows() instead.');
277295
}
278296
}
297+
298+
/**
299+
* Options when creating `MutlipartUserDataPart`.
300+
*/
301+
export interface MutlipartUserDataPartOptions {
302+
/**
303+
* The body of message.
304+
*
305+
* @default undefined - body will not be added to part
306+
*/
307+
readonly body?: string,
308+
309+
/**
310+
* `Content-Type` header of this part.
311+
*
312+
* For Linux shell scripts use `text/x-shellscript`
313+
*/
314+
readonly contentType: string;
315+
316+
/**
317+
* `Content-Transfer-Encoding` header specifing part encoding.
318+
*
319+
* @default undefined - don't add this header
320+
*/
321+
readonly transferEncoding?: string;
322+
}
323+
324+
/**
325+
* The raw part of multip-part user data, which can be added to {@link MultipartUserData}.
326+
*/
327+
export class MutlipartUserDataPart implements IMultipartUserDataPartProducer {
328+
/** The body of this MIME part. */
329+
public readonly body?: string;
330+
331+
/** `Content-Type` header of this part */
332+
public readonly contentType: string;
333+
334+
/**
335+
* `Content-Transfer-Encoding` header specifing part encoding.
336+
*
337+
* @default undefined - don't add this header
338+
*/
339+
readonly transferEncoding?: string;
340+
341+
public constructor(props: MutlipartUserDataPartOptions) {
342+
this.body = props.body;
343+
this.contentType = props.contentType;
344+
this.transferEncoding = props.transferEncoding;
345+
}
346+
347+
renderAsMimePart(_renderOpts?: MultipartRenderOptions): MutlipartUserDataPart {
348+
return this;
349+
}
350+
}
351+
352+
/**
353+
* Render options for parts of multipart user data.
354+
*/
355+
export interface MultipartRenderOptions {
356+
/**
357+
* Can be used to override default content type (without charset part) used when producing
358+
* part by `IMultipartUserDataPartProducer`.
359+
*
360+
* @default undefined - leave content type unchanged
361+
*/
362+
readonly contentType?: string;
363+
}
364+
365+
/**
366+
* Class implementing this interface can produce `MutlipartUserDataPart` and can be added
367+
* to `MultipartUserData`.
368+
*/
369+
export interface IMultipartUserDataPartProducer {
370+
/**
371+
* Creats the `MutlipartUserDataPart.
372+
*/
373+
renderAsMimePart(renderOpts?: MultipartRenderOptions): MutlipartUserDataPart;
374+
}
375+
376+
/**
377+
* Mime multipart user data.
378+
*
379+
* This class represents MIME multipart user data, as described in.
380+
* [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)
381+
*
382+
*/
383+
export class MultipartUserData extends UserData {
384+
private static readonly USE_PART_ERROR = 'MultipartUserData does not support this operation. Please add part using addPart.';
385+
386+
private parts: IMultipartUserDataPartProducer[] = [];
387+
388+
/**
389+
* Adds a class which can producer `MutlipartUserDataPart`. I. e. `UserData.forLinux()`.
390+
*/
391+
public addPart(producer: IMultipartUserDataPartProducer): this {
392+
this.parts.push(producer);
393+
394+
return this;
395+
}
396+
397+
public render(): string {
398+
const renderedParts: MutlipartUserDataPart[] = this.parts.map(producer => producer.renderAsMimePart());
399+
400+
// Hash the message content, it will be used as boundry. The boundry should be
401+
// so much unique not to be in message text, and stable so the text of archive will
402+
// not be changed only due to change of boundry (may cause redeploys of resources)
403+
const hash = crypto.createHash('sha256');
404+
renderedParts.forEach(part => {
405+
hash
406+
.update(part.contentType)
407+
.update(part.body || 'empty-body')
408+
.update(part.transferEncoding || '');
409+
});
410+
hash.update('salt-boundary-rado');
411+
412+
const boundary = '-' + hash.digest('base64') + '-';
413+
414+
// Now build final MIME archive - there are few changes from MIME message which are accepted by cloud-init:
415+
// - MIME RFC uses CRLF to separarte lines - cloud-init is fine with LF \n only
416+
var resultArchive = `Content-Type: multipart/mixed; boundary="${boundary}"\n`;
417+
resultArchive = resultArchive + 'MIME-Version: 1.0\n';
418+
419+
// Add parts - each part starts with boundry
420+
renderedParts.forEach(part => {
421+
resultArchive = resultArchive + '\n--' + boundary + '\n' + 'Content-Type: ' + part.contentType + '\n';
422+
423+
if (part.transferEncoding != null) {
424+
resultArchive = resultArchive + `Content-Transfer-Encoding: ${part.transferEncoding}\n`;
425+
}
426+
427+
if (part.body != null) {
428+
resultArchive = resultArchive + '\n' + part.body;
429+
}
430+
});
431+
432+
// Add closing boundry
433+
resultArchive = resultArchive + `\n--${boundary}--\n`;
434+
435+
return resultArchive;
436+
}
437+
438+
public addS3DownloadCommand(_params: S3DownloadOptions): string {
439+
throw new Error(MultipartUserData.USE_PART_ERROR);
440+
}
441+
442+
public addExecuteFileCommand(_params: ExecuteFileOptions): void {
443+
throw new Error(MultipartUserData.USE_PART_ERROR);
444+
}
445+
446+
public addSignalOnExitCommand(_resource: Resource): void {
447+
throw new Error(MultipartUserData.USE_PART_ERROR);
448+
}
449+
450+
public addCommands(..._commands: string[]): void {
451+
throw new Error(MultipartUserData.USE_PART_ERROR);
452+
}
453+
454+
public addOnExitCommands(..._commands: string[]): void {
455+
throw new Error(MultipartUserData.USE_PART_ERROR);
456+
}
457+
}

0 commit comments

Comments
 (0)