|
1 | 1 | import { IBucket } from '@aws-cdk/aws-s3';
|
2 |
| -import { CfnElement, Resource, Stack } from '@aws-cdk/core'; |
| 2 | +import { CfnElement, Fn, Resource, Stack } from '@aws-cdk/core'; |
3 | 3 | import { OperatingSystemType } from './machine-image';
|
4 | 4 |
|
5 | 5 | /**
|
@@ -276,3 +276,257 @@ class CustomUserData extends UserData {
|
276 | 276 | throw new Error('CustomUserData does not support addSignalOnExitCommand, use UserData.forLinux() or UserData.forWindows() instead.');
|
277 | 277 | }
|
278 | 278 | }
|
| 279 | + |
| 280 | +/** |
| 281 | + * Options when creating `MultipartBody`. |
| 282 | + */ |
| 283 | +export interface MultipartBodyOptions { |
| 284 | + |
| 285 | + /** |
| 286 | + * `Content-Type` header of this part. |
| 287 | + * |
| 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`. |
| 293 | + */ |
| 294 | + readonly contentType: string; |
| 295 | + |
| 296 | + /** |
| 297 | + * `Content-Transfer-Encoding` header specifying part encoding. |
| 298 | + * |
| 299 | + * @default undefined - body is not encoded |
| 300 | + */ |
| 301 | + readonly transferEncoding?: string; |
| 302 | + |
| 303 | + /** |
| 304 | + * The body of message. |
| 305 | + * |
| 306 | + * @default undefined - body will not be added to part |
| 307 | + */ |
| 308 | + readonly body?: string, |
| 309 | +} |
| 310 | + |
| 311 | +/** |
| 312 | + * The base class for all classes which can be used as {@link MultipartUserData}. |
| 313 | + */ |
| 314 | +export abstract class MultipartBody { |
| 315 | + /** |
| 316 | + * Content type for shell scripts |
| 317 | + */ |
| 318 | + public static readonly SHELL_SCRIPT = 'text/x-shellscript; charset="utf-8"'; |
| 319 | + |
| 320 | + /** |
| 321 | + * Content type for boot hooks |
| 322 | + */ |
| 323 | + public static readonly CLOUD_BOOTHOOK = 'text/cloud-boothook; charset="utf-8"'; |
| 324 | + |
| 325 | + /** |
| 326 | + * Constructs the new `MultipartBody` wrapping existing `UserData`. Modification to `UserData` are reflected |
| 327 | + * in subsequent renders of the part. |
| 328 | + * |
| 329 | + * For more information about content types see {@link MultipartBodyOptions.contentType}. |
| 330 | + * |
| 331 | + * @param userData user data to wrap into body part |
| 332 | + * @param contentType optional content type, if default one should not be used |
| 333 | + */ |
| 334 | + public static fromUserData(userData: UserData, contentType?: string): MultipartBody { |
| 335 | + return new MultipartBodyUserDataWrapper(userData, contentType); |
| 336 | + } |
| 337 | + |
| 338 | + /** |
| 339 | + * Constructs the raw `MultipartBody` using specified body, content type and transfer encoding. |
| 340 | + * |
| 341 | + * When transfer encoding is specified (typically as Base64), it's caller responsibility to convert body to |
| 342 | + * Base64 either by wrapping with `Fn.base64` or by converting it by other converters. |
| 343 | + */ |
| 344 | + public static fromRawBody(opts: MultipartBodyOptions): MultipartBody { |
| 345 | + return new MultipartBodyRaw(opts); |
| 346 | + } |
| 347 | + |
| 348 | + public constructor() { |
| 349 | + } |
| 350 | + |
| 351 | + /** |
| 352 | + * Render body part as the string. |
| 353 | + * |
| 354 | + * Subclasses should not add leading nor trailing new line characters (\r \n) |
| 355 | + */ |
| 356 | + public abstract renderBodyPart(): string[]; |
| 357 | +} |
| 358 | + |
| 359 | +/** |
| 360 | + * The raw part of multi-part user data, which can be added to {@link MultipartUserData}. |
| 361 | + */ |
| 362 | +class MultipartBodyRaw extends MultipartBody { |
| 363 | + public constructor(private readonly props: MultipartBodyOptions) { |
| 364 | + super(); |
| 365 | + } |
| 366 | + |
| 367 | + /** |
| 368 | + * Render body part as the string. |
| 369 | + */ |
| 370 | + public renderBodyPart(): string[] { |
| 371 | + const result: string[] = []; |
| 372 | + |
| 373 | + result.push(`Content-Type: ${this.props.contentType}`); |
| 374 | + |
| 375 | + if (this.props.transferEncoding != null) { |
| 376 | + result.push(`Content-Transfer-Encoding: ${this.props.transferEncoding}`); |
| 377 | + } |
| 378 | + // One line free after separator |
| 379 | + result.push(''); |
| 380 | + |
| 381 | + if (this.props.body != null) { |
| 382 | + result.push(this.props.body); |
| 383 | + // The new line added after join will be consumed by encapsulating or closing boundary |
| 384 | + } |
| 385 | + |
| 386 | + return result; |
| 387 | + } |
| 388 | +} |
| 389 | + |
| 390 | +/** |
| 391 | + * Wrapper for `UserData`. |
| 392 | + */ |
| 393 | +class MultipartBodyUserDataWrapper extends MultipartBody { |
| 394 | + private readonly contentType: string; |
| 395 | + |
| 396 | + public constructor(private readonly userData: UserData, contentType?: string) { |
| 397 | + super(); |
| 398 | + |
| 399 | + this.contentType = contentType || MultipartBody.SHELL_SCRIPT; |
| 400 | + } |
| 401 | + |
| 402 | + /** |
| 403 | + * Render body part as the string. |
| 404 | + */ |
| 405 | + public renderBodyPart(): string[] { |
| 406 | + const result: string[] = []; |
| 407 | + |
| 408 | + result.push(`Content-Type: ${this.contentType}`); |
| 409 | + result.push('Content-Transfer-Encoding: base64'); |
| 410 | + result.push(''); |
| 411 | + result.push(Fn.base64(this.userData.render())); |
| 412 | + |
| 413 | + return result; |
| 414 | + } |
| 415 | +} |
| 416 | + |
| 417 | +/** |
| 418 | + * Options for creating {@link MultipartUserData} |
| 419 | + */ |
| 420 | +export interface MultipartUserDataOptions { |
| 421 | + /** |
| 422 | + * The string used to separate parts in multipart user data archive (it's like MIME boundary). |
| 423 | + * |
| 424 | + * This string should contain [a-zA-Z0-9()+,-./:=?] characters only, and should not be present in any part, or in text content of archive. |
| 425 | + * |
| 426 | + * @default `+AWS+CDK+User+Data+Separator==` |
| 427 | + */ |
| 428 | + readonly partsSeparator?: string; |
| 429 | +} |
| 430 | + |
| 431 | +/** |
| 432 | + * Mime multipart user data. |
| 433 | + * |
| 434 | + * This class represents MIME multipart user data, as described in. |
| 435 | + * [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) |
| 436 | + * |
| 437 | + */ |
| 438 | +export class MultipartUserData extends UserData { |
| 439 | + private static readonly USE_PART_ERROR = 'MultipartUserData does not support this operation. Please add part using addPart.'; |
| 440 | + private static readonly BOUNDRY_PATTERN = '[^a-zA-Z0-9()+,-./:=?]'; |
| 441 | + |
| 442 | + private parts: MultipartBody[] = []; |
| 443 | + |
| 444 | + private opts: MultipartUserDataOptions; |
| 445 | + |
| 446 | + constructor(opts?: MultipartUserDataOptions) { |
| 447 | + super(); |
| 448 | + |
| 449 | + let partsSeparator: string; |
| 450 | + |
| 451 | + // Validate separator |
| 452 | + if (opts?.partsSeparator != null) { |
| 453 | + if (new RegExp(MultipartUserData.BOUNDRY_PATTERN).test(opts!.partsSeparator)) { |
| 454 | + throw new Error(`Invalid characters in separator. Separator has to match pattern ${MultipartUserData.BOUNDRY_PATTERN}`); |
| 455 | + } else { |
| 456 | + partsSeparator = opts!.partsSeparator; |
| 457 | + } |
| 458 | + } else { |
| 459 | + partsSeparator = '+AWS+CDK+User+Data+Separator=='; |
| 460 | + } |
| 461 | + |
| 462 | + this.opts = { |
| 463 | + partsSeparator: partsSeparator, |
| 464 | + }; |
| 465 | + } |
| 466 | + |
| 467 | + /** |
| 468 | + * Adds a part to the list of parts. |
| 469 | + */ |
| 470 | + public addPart(part: MultipartBody) { |
| 471 | + this.parts.push(part); |
| 472 | + } |
| 473 | + |
| 474 | + /** |
| 475 | + * Adds a multipart part based on a UserData object |
| 476 | + * |
| 477 | + * This is the same as calling: |
| 478 | + * |
| 479 | + * ```ts |
| 480 | + * multiPart.addPart(MultipartBody.fromUserData(userData, contentType)); |
| 481 | + * ``` |
| 482 | + */ |
| 483 | + public addUserDataPart(userData: UserData, contentType?: string) { |
| 484 | + this.addPart(MultipartBody.fromUserData(userData, contentType)); |
| 485 | + } |
| 486 | + |
| 487 | + public render(): string { |
| 488 | + const boundary = this.opts.partsSeparator; |
| 489 | + // Now build final MIME archive - there are few changes from MIME message which are accepted by cloud-init: |
| 490 | + // - MIME RFC uses CRLF to separate lines - cloud-init is fine with LF \n only |
| 491 | + // Note: new lines matters, matters a lot. |
| 492 | + var resultArchive = new Array<string>(); |
| 493 | + resultArchive.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); |
| 494 | + resultArchive.push('MIME-Version: 1.0'); |
| 495 | + |
| 496 | + // Add new line, the next one will be boundary (encapsulating or closing) |
| 497 | + // so this line will count into it. |
| 498 | + resultArchive.push(''); |
| 499 | + |
| 500 | + // Add parts - each part starts with boundary |
| 501 | + this.parts.forEach(part => { |
| 502 | + resultArchive.push(`--${boundary}`); |
| 503 | + resultArchive.push(...part.renderBodyPart()); |
| 504 | + }); |
| 505 | + |
| 506 | + // Add closing boundary |
| 507 | + resultArchive.push(`--${boundary}--`); |
| 508 | + resultArchive.push(''); // Force new line at the end |
| 509 | + |
| 510 | + return resultArchive.join('\n'); |
| 511 | + } |
| 512 | + |
| 513 | + public addS3DownloadCommand(_params: S3DownloadOptions): string { |
| 514 | + throw new Error(MultipartUserData.USE_PART_ERROR); |
| 515 | + } |
| 516 | + |
| 517 | + public addExecuteFileCommand(_params: ExecuteFileOptions): void { |
| 518 | + throw new Error(MultipartUserData.USE_PART_ERROR); |
| 519 | + } |
| 520 | + |
| 521 | + public addSignalOnExitCommand(_resource: Resource): void { |
| 522 | + throw new Error(MultipartUserData.USE_PART_ERROR); |
| 523 | + } |
| 524 | + |
| 525 | + public addCommands(..._commands: string[]): void { |
| 526 | + throw new Error(MultipartUserData.USE_PART_ERROR); |
| 527 | + } |
| 528 | + |
| 529 | + public addOnExitCommands(..._commands: string[]): void { |
| 530 | + throw new Error(MultipartUserData.USE_PART_ERROR); |
| 531 | + } |
| 532 | +} |
0 commit comments