Skip to content

Commit f50d10b

Browse files
committed
Refactor code:
- remove `MultipartContentType` - remove `MultipartUserDataPartWrapperOptions` - remove `IMultipart` - rename `MultipartUserDataPart` -> `MultipartBody` - other removals - restructure other classes - moved part rendering to part class - set default separator to hard codeded string - added validation of boundry
1 parent a013138 commit f50d10b

5 files changed

+233
-134
lines changed

packages/@aws-cdk/aws-ec2/README.md

+39-3
Original file line numberDiff line numberDiff line change
@@ -981,11 +981,47 @@ instance.userData.addExecuteFileCommand({
981981
asset.grantRead( instance.role );
982982
```
983983

984+
### Multipart user data
985+
984986
In addition to above the `MultipartUserData` can be used to change instance startup behavior. Multipart user data are composed
985-
from separate parts forming archive. The moment, and behavior of each part can be controlled with `Content-Type`, and it's wider
986-
than executing shell scripts.
987+
from separate parts forming archive. The most common parts are scripts executed during instance set-up. However, there are other
988+
kinds, too.
989+
990+
The advantage of multipart archive is in flexibility when it's needed to add additional parts or to use specialized parts to
991+
fine tune instance startup. Some services (like AWS Batch) supports only `MultipartUserData`.
992+
993+
The parts can be executed at different moment of instance start-up and can server different purposes. This is controlled by `contentType` property.
994+
995+
However, most common parts are script parts which can be created by `MultipartUserData.fromUserData`, and which have `contentType` `text/x-shellscript; charset="utf-8"`.
996+
997+
998+
In order to create archive the `MultipartUserData` has to be instantiated. Than user can add parts to multipart archive using `addPart`. The `MultipartBody` contains methods supporting creation of body parts.
999+
1000+
If the custom parts is required, it can be created using `MultipartUserData.fromRawBody`, in this case full control over content type,
1001+
transfer encoding, and body properties is given to the user.
1002+
1003+
Below is an example for creating multipart user data with single body part responsible for installing `awscli`
9871004

988-
Some services (like AWS Batch) allows only `MultipartUserData`.
1005+
```ts
1006+
const bootHookConf = ec2.UserData.forLinux();
1007+
bootHookConf.addCommands('cloud-init-per once docker_options echo \'OPTIONS="${OPTIONS} --storage-opt dm.basesize=40G"\' >> /etc/sysconfig/docker');
1008+
1009+
const setupCommands = ec2.UserData.forLinux();
1010+
setupCommands.addCommands('sudo yum install awscli && echo Packages installed らと > /var/tmp/setup');
1011+
1012+
const multipartUserData = new ec2.MultipartUserData();
1013+
// The docker has to be configured at early stage, so content type is overridden to boothook
1014+
multipartUserData.addPart(ec2.MultipartBody.fromUserData(bootHookConf, 'text/cloud-boothook; charset="us-ascii"'));
1015+
// Execute the rest of setup
1016+
multipartUserData.addPart(ec2.MultipartBody.fromUserData(setupCommands));
1017+
1018+
new ec2.LaunchTemplate(stack, '', {
1019+
userData: multipartUserData,
1020+
blockDevices: [
1021+
// Block device configuration rest
1022+
]
1023+
});
1024+
```
9891025

9901026
For more information see
9911027
[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)

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

+102-112
Original file line numberDiff line numberDiff line change
@@ -278,34 +278,28 @@ class CustomUserData extends UserData {
278278
}
279279

280280
/**
281-
* Suggested content types, however any value is allowed.
281+
* Options when creating `MultipartBody`.
282282
*/
283-
export type MultipartContentType = 'text/x-shellscript; charset="utf-8"' | 'text/cloud-boothook; charset="utf-8"' | string;
284-
285-
/**
286-
* Options when creating `MultipartUserDataPart`.
287-
*/
288-
export interface MultipartUserDataPartOptions {
283+
export interface MultipartBodyOptions {
289284

290285
/**
291286
* `Content-Type` header of this part.
292287
*
293-
* For Linux shell scripts use `text/x-shellscript`
288+
* Some examples of content types:
289+
* * `text/x-shellscript; charset="utf-8"` (shell script)
290+
* * `text/cloud-boothook; charset="utf-8"` (shell script executed during boot phase)
291+
*
292+
* For Linux shell scripts use `text/x-shellscript`.
294293
*/
295-
readonly contentType: MultipartContentType;
294+
readonly contentType: string;
296295

297296
/**
298297
* `Content-Transfer-Encoding` header specifying part encoding.
299298
*
300299
* @default undefined - don't add this header
301300
*/
302301
readonly transferEncoding?: string;
303-
}
304302

305-
/**
306-
* Options when creating `MultipartUserDataPart`.
307-
*/
308-
export interface MultipartUserDataPartOptionsWithBody extends MultipartUserDataPartOptions {
309303
/**
310304
* The body of message.
311305
*
@@ -314,66 +308,32 @@ export interface MultipartUserDataPartOptionsWithBody extends MultipartUserDataP
314308
readonly body?: string,
315309
}
316310

317-
/**
318-
* Options when creating `MultipartUserDataPartWrapper`.
319-
*/
320-
export interface MultipartUserDataPartWrapperOptions {
321-
/**
322-
* `Content-Type` header of this part.
323-
*
324-
* For Linux shell scripts typically it's `text/x-shellscript`.
325-
*
326-
* @default 'text/x-shellscript; charset="utf-8"'
327-
*/
328-
readonly contentType?: MultipartContentType;
329-
}
330-
331-
/**
332-
* Interface representing part of `MultipartUserData` user data.
333-
*/
334-
export interface IMultipart {
335-
/**
336-
* The body of this MIME part.
337-
*/
338-
readonly body: string | undefined;
339-
340-
/**
341-
* `Content-Type` header of this part.
342-
*/
343-
readonly contentType: string;
344-
345-
/**
346-
* `Content-Transfer-Encoding` header specifying part encoding.
347-
*
348-
* @default undefined - don't add this header
349-
*/
350-
readonly transferEncoding?: string;
351-
}
352-
353311
/**
354312
* The base class for all classes which can be used as {@link MultipartUserData}.
355313
*/
356-
export abstract class MultipartUserDataPart implements IMultipart {
314+
export abstract class MultipartBody {
357315

358316
/**
359-
* Constructs the new `MultipartUserDataPart` wrapping existing `UserData`. Modification to `UserData` are reflected
317+
* Constructs the new `MultipartBody` wrapping existing `UserData`. Modification to `UserData` are reflected
360318
* in subsequent renders of the part.
361319
*
362-
* For more information about content types see `MultipartUserDataPartOptionsWithBody`
320+
* For more information about content types see {@link MultipartBodyOptions.contentType}.
321+
*
322+
* @param userData user data to wrap into body part
323+
* @param contentType optional content type, if default one should not be used
363324
*/
364-
public static fromUserData(userData: UserData, opts?: MultipartUserDataPartWrapperOptions): MultipartUserDataPart {
365-
opts = opts || {};
366-
return new MultipartUserDataPartWrapper(userData, opts);
325+
public static fromUserData(userData: UserData, contentType?: string): MultipartBody {
326+
return new MultipartBodyUserDataWrapper(userData, contentType);
367327
}
368328

369329
/**
370-
* Constructs the raw `MultipartUserDataPart` using specified body, content type and transfer encoding.
330+
* Constructs the raw `MultipartBody` using specified body, content type and transfer encoding.
371331
*
372332
* When transfer encoding is specified (typically as Base64), it's caller responsibility to convert body to
373333
* Base64 either by wrapping with `Fn.base64` or by converting it by other converters.
374334
*/
375-
public static fromRawBody(opts: MultipartUserDataPartOptionsWithBody): MultipartUserDataPart {
376-
return new MultipartUserDataPartRaw(opts);
335+
public static fromRawBody(opts: MultipartBodyOptions): MultipartBody {
336+
return new MultipartBodyRaw(opts);
377337
}
378338

379339
protected static readonly DEFAULT_CONTENT_TYPE = 'text/x-shellscript; charset="utf-8"';
@@ -382,51 +342,76 @@ export abstract class MultipartUserDataPart implements IMultipart {
382342
public abstract get body(): string | undefined;
383343

384344
/** `Content-Type` header of this part */
385-
public readonly contentType: string;
345+
public abstract get contentType(): string;
386346

387347
/**
388348
* `Content-Transfer-Encoding` header specifying part encoding.
389349
*
390350
* @default undefined - don't add this header
391351
*/
392-
public readonly transferEncoding?: string;
352+
public abstract get transferEncoding(): string | undefined;
393353

394-
public constructor(props: MultipartUserDataPartOptions) {
395-
this.contentType = props.contentType;
396-
this.transferEncoding = props.transferEncoding;
354+
public constructor() {
355+
}
356+
357+
/**
358+
* Render body part as the string.
359+
*
360+
* Subclasses should not add leading nor trailing new line characters (\r \n)
361+
*/
362+
public renderBodyPart(): string {
363+
const result: string[] = [];
364+
365+
result.push(`Content-Type: ${this.contentType}`);
366+
367+
if (this.transferEncoding != null) {
368+
result.push(`Content-Transfer-Encoding: ${this.transferEncoding}`);
369+
}
370+
// One line free after separator
371+
result.push('');
372+
373+
if (this.body != null) {
374+
result.push(this.body);
375+
// The new line added after join will be consumed by encapsulating or closing boundary
376+
}
377+
378+
return result.join('\n');
397379
}
398380
}
399381

400382
/**
401383
* The raw part of multi-part user data, which can be added to {@link MultipartUserData}.
402384
*/
403-
class MultipartUserDataPartRaw extends MultipartUserDataPart {
404-
private _body : string | undefined;
385+
class MultipartBodyRaw extends MultipartBody {
386+
public readonly body: string | undefined;
387+
public readonly contentType: string;
388+
public readonly transferEncoding: string | undefined;
405389

406-
public constructor(props: MultipartUserDataPartOptionsWithBody) {
407-
super(props);
408-
this._body = props.body;
409-
}
390+
public constructor(props: MultipartBodyOptions) {
391+
super();
410392

411-
public get body(): string | undefined {
412-
return this._body;
393+
this.body = props.body;
394+
this.contentType = props.contentType;
413395
}
414396
}
415397

416398
/**
417399
* Wrapper for `UserData`.
418400
*/
419-
class MultipartUserDataPartWrapper extends MultipartUserDataPart {
420-
public constructor(public readonly userData: UserData, opts: MultipartUserDataPartWrapperOptions) {
421-
super({
422-
contentType: opts.contentType || MultipartUserDataPart.DEFAULT_CONTENT_TYPE,
423-
// Force Base64 in case userData will contain UTF-8 characters
424-
transferEncoding: 'base64',
425-
});
401+
class MultipartBodyUserDataWrapper extends MultipartBody {
402+
403+
public readonly contentType: string;
404+
public readonly transferEncoding: string | undefined;
405+
406+
public constructor(public readonly userData: UserData, contentType?: string) {
407+
super();
408+
409+
this.contentType = contentType || MultipartBody.DEFAULT_CONTENT_TYPE;
410+
this.transferEncoding = 'base64';
426411
}
427412

428413
public get body(): string {
429-
// Wrap rendered user data with Base64 function, in case data contains tokens
414+
// Wrap rendered user data with Base64 function, in case data contains non ASCII characters
430415
return Fn.base64(this.userData.render());
431416
}
432417
}
@@ -438,9 +423,11 @@ export interface MultipartUserDataOptions {
438423
/**
439424
* The string used to separate parts in multipart user data archive (it's like MIME boundary).
440425
*
441-
* This string should contain [a-zA-Z0-9] characters only, and should not be present in any part, or in text content of archive.
426+
* This string should contain [a-zA-Z0-9()+,-./:=?] characters only, and should not be present in any part, or in text content of archive.
427+
*
428+
* @default `+AWS+CDK+User+Data+Separator==`
442429
*/
443-
readonly partsSeparator: string;
430+
readonly partsSeparator?: string;
444431
}
445432

446433
/**
@@ -452,63 +439,66 @@ export interface MultipartUserDataOptions {
452439
*/
453440
export class MultipartUserData extends UserData {
454441
private static readonly USE_PART_ERROR = 'MultipartUserData does not support this operation. Please add part using addPart.';
442+
private static readonly BOUNDRY_PATTERN = '[^a-zA-Z0-9()+,-./:=?]';
455443

456-
private parts: IMultipart[] = [];
444+
private parts: MultipartBody[] = [];
457445

458446
private opts: MultipartUserDataOptions;
459447

460-
constructor(opts: MultipartUserDataOptions) {
448+
constructor(opts?: MultipartUserDataOptions) {
461449
super();
462450

451+
let partsSeparator: string;
452+
453+
// Validate separator
454+
if (opts?.partsSeparator != null) {
455+
if (new RegExp(MultipartUserData.BOUNDRY_PATTERN).test(opts!.partsSeparator)) {
456+
throw new Error(`Invalid characters in separator. Separator has to match pattern ${MultipartUserData.BOUNDRY_PATTERN}`);
457+
} else {
458+
partsSeparator = opts!.partsSeparator;
459+
}
460+
} else {
461+
partsSeparator = '+AWS+CDK+User+Data+Separator==';
462+
}
463+
463464
this.opts = {
464-
...opts,
465+
partsSeparator: partsSeparator,
465466
};
466467
}
467-
/**
468-
* Adds existing `UserData`. Modification to `UserData` are reflected in subsequent renders of the part.
469-
*
470-
* For more information about content types see `MultipartUserDataPartOptionsWithBody`
471-
*/
472-
public addUserDataPart(userData: UserData, opts?: MultipartUserDataPartWrapperOptions): this {
473-
this.parts.push(MultipartUserDataPart.fromUserData(userData, opts));
474-
475-
return this;
476-
}
477468

478469
/**
479-
* Adds the 'raw' part using provided options.
470+
* Adds a part to the list of parts.
480471
*/
481-
public addPart(opts: MultipartUserDataPartOptionsWithBody): this {
482-
this.parts.push(MultipartUserDataPart.fromRawBody(opts));
472+
public addPart(part: MultipartBody): this {
473+
this.parts.push(part);
483474

484475
return this;
485476
}
486477

487478
public render(): string {
488479
const boundary = this.opts.partsSeparator;
489-
490480
// Now build final MIME archive - there are few changes from MIME message which are accepted by cloud-init:
491481
// - MIME RFC uses CRLF to separate lines - cloud-init is fine with LF \n only
492-
var resultArchive = `Content-Type: multipart/mixed; boundary="${boundary}"\n`;
493-
resultArchive = resultArchive + 'MIME-Version: 1.0\n';
482+
// Note: new lines matters, matters a lot.
483+
var resultArchive = new Array<string>();
484+
resultArchive.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
485+
resultArchive.push('MIME-Version: 1.0');
486+
487+
// Add new line, the next one will be boundary (encapsulating or closing)
488+
// so this line will count into it.
489+
resultArchive.push('');
494490

495491
// Add parts - each part starts with boundary
496492
this.parts.forEach(part => {
497-
resultArchive = resultArchive + '\n--' + boundary + '\n' + 'Content-Type: ' + part.contentType + '\n';
498-
499-
if (part.transferEncoding != null) {
500-
resultArchive = resultArchive + `Content-Transfer-Encoding: ${part.transferEncoding}\n`;
501-
}
502-
503-
if (part.body != null) {
504-
resultArchive = resultArchive + '\n' + part.body;
505-
}
493+
resultArchive.push(`--${boundary}`);
494+
resultArchive.push(part.renderBodyPart());
506495
});
507496

508497
// Add closing boundary
509-
resultArchive = resultArchive + `\n--${boundary}--\n`;
498+
resultArchive.push(`--${boundary}--`);
499+
resultArchive.push(''); // Force new line at the end
510500

511-
return resultArchive;
501+
return resultArchive.join('\n');
512502
}
513503

514504
public addS3DownloadCommand(_params: S3DownloadOptions): string {

0 commit comments

Comments
 (0)