From ed94c5ef1b9dd3042128b0e0c5bb14b3d9c7d497 Mon Sep 17 00:00:00 2001 From: Radoslaw Smogura Date: Mon, 8 Mar 2021 17:33:22 +0100 Subject: [PATCH] feat(ec2): multipart user data (#11843) 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 #8315 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-ec2/README.md | 45 ++ packages/@aws-cdk/aws-ec2/lib/user-data.ts | 256 ++++++- ....instance-multipart-userdata.expected.json | 694 ++++++++++++++++++ .../test/integ.instance-multipart-userdata.ts | 70 ++ .../@aws-cdk/aws-ec2/test/userdata.test.ts | 97 +++ 5 files changed, 1161 insertions(+), 1 deletion(-) create mode 100644 packages/@aws-cdk/aws-ec2/test/integ.instance-multipart-userdata.expected.json create mode 100644 packages/@aws-cdk/aws-ec2/test/integ.instance-multipart-userdata.ts diff --git a/packages/@aws-cdk/aws-ec2/README.md b/packages/@aws-cdk/aws-ec2/README.md index 49fff4b5c4f63..90b5b4be9cfb8 100644 --- a/packages/@aws-cdk/aws-ec2/README.md +++ b/packages/@aws-cdk/aws-ec2/README.md @@ -981,6 +981,51 @@ instance.userData.addExecuteFileCommand({ asset.grantRead( instance.role ); ``` +### Multipart user data + +In addition, to above the `MultipartUserData` can be used to change instance startup behavior. Multipart user data are composed +from separate parts forming archive. The most common parts are scripts executed during instance set-up. However, there are other +kinds, too. + +The advantage of multipart archive is in flexibility when it's needed to add additional parts or to use specialized parts to +fine tune instance startup. Some services (like AWS Batch) supports only `MultipartUserData`. + +The parts can be executed at different moment of instance start-up and can serve a different purposes. This is controlled by `contentType` property. +For common scripts, `text/x-shellscript; charset="utf-8"` can be used as content type. + +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. + +If the very custom part is required, it can be created using `MultipartUserData.fromRawBody`, in this case full control over content type, +transfer encoding, and body properties is given to the user. + +Below is an example for creating multipart user data with single body part responsible for installing `awscli` and configuring maximum size +of storage used by Docker containers: + +```ts +const bootHookConf = ec2.UserData.forLinux(); +bootHookConf.addCommands('cloud-init-per once docker_options echo \'OPTIONS="${OPTIONS} --storage-opt dm.basesize=40G"\' >> /etc/sysconfig/docker'); + +const setupCommands = ec2.UserData.forLinux(); +setupCommands.addCommands('sudo yum install awscli && echo Packages installed らと > /var/tmp/setup'); + +const multipartUserData = new ec2.MultipartUserData(); +// The docker has to be configured at early stage, so content type is overridden to boothook +multipartUserData.addPart(ec2.MultipartBody.fromUserData(bootHookConf, 'text/cloud-boothook; charset="us-ascii"')); +// Execute the rest of setup +multipartUserData.addPart(ec2.MultipartBody.fromUserData(setupCommands)); + +new ec2.LaunchTemplate(stack, '', { + userData: multipartUserData, + blockDevices: [ + // Block device configuration rest + ] +}); +``` + +For more information see +[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) + + ## Importing existing subnet To import an existing Subnet, call `Subnet.fromSubnetAttributes()` or diff --git a/packages/@aws-cdk/aws-ec2/lib/user-data.ts b/packages/@aws-cdk/aws-ec2/lib/user-data.ts index 20061bd609636..418b6d671846d 100644 --- a/packages/@aws-cdk/aws-ec2/lib/user-data.ts +++ b/packages/@aws-cdk/aws-ec2/lib/user-data.ts @@ -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,257 @@ 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 - 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 {@link 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 {@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); + } + + 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 {@link 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 {@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.'; + 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.parts.push(part); + } + + /** + * Adds a multipart part based on a UserData object + * + * This is the same as calling: + * + * ```ts + * multiPart.addPart(MultipartBody.fromUserData(userData, contentType)); + * ``` + */ + public addUserDataPart(userData: UserData, contentType?: string) { + this.addPart(MultipartBody.fromUserData(userData, contentType)); + } + + 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(); + 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 { + 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); + } +} diff --git a/packages/@aws-cdk/aws-ec2/test/integ.instance-multipart-userdata.expected.json b/packages/@aws-cdk/aws-ec2/test/integ.instance-multipart-userdata.expected.json new file mode 100644 index 0000000000000..371a30e7456f6 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/integ.instance-multipart-userdata.expected.json @@ -0,0 +1,694 @@ +{ + "Resources": { + "VPCB9E5F0B4": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC" + } + ] + } + }, + "VPCPublicSubnet1SubnetB4246D30": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet1RouteTableFEE4B781": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet1RouteTableAssociation0B0896DC": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet1RouteTableFEE4B781" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + } + } + }, + "VPCPublicSubnet1DefaultRoute91CEF279": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet1RouteTableFEE4B781" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet1EIP6AD938E8": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet1NATGatewayE0556630": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet1EIP6AD938E8", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet2Subnet74179F39": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.32.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet2RouteTable6F1A15F1": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet2RouteTableAssociation5A808732": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet2RouteTable6F1A15F1" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + } + } + }, + "VPCPublicSubnet2DefaultRouteB7481BBA": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet2RouteTable6F1A15F1" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet2EIP4947BC00": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet2NATGateway3C070193": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet2EIP4947BC00", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet3Subnet631C5E25": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.64.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPublicSubnet3RouteTable98AE0E14": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPublicSubnet3RouteTableAssociation427FE0C6": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet3RouteTable98AE0E14" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet3Subnet631C5E25" + } + } + }, + "VPCPublicSubnet3DefaultRouteA0D29D46": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet3RouteTable98AE0E14" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet3EIPAD4BC883": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPublicSubnet3NATGatewayD3048F5C": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet3EIPAD4BC883", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet3Subnet631C5E25" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPrivateSubnet1Subnet8BCA10E0": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.96.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PrivateSubnet1" + } + ] + } + }, + "VPCPrivateSubnet1RouteTableBE8A6027": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PrivateSubnet1" + } + ] + } + }, + "VPCPrivateSubnet1RouteTableAssociation347902D1": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet1RouteTableBE8A6027" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" + } + } + }, + "VPCPrivateSubnet1DefaultRouteAE1D6490": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet1RouteTableBE8A6027" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet1NATGatewayE0556630" + } + } + }, + "VPCPrivateSubnet2SubnetCFCDAA7A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PrivateSubnet2" + } + ] + } + }, + "VPCPrivateSubnet2RouteTable0A19E10E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PrivateSubnet2" + } + ] + } + }, + "VPCPrivateSubnet2RouteTableAssociation0C73D413": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet2RouteTable0A19E10E" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet2SubnetCFCDAA7A" + } + } + }, + "VPCPrivateSubnet2DefaultRouteF4F5CFD2": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet2RouteTable0A19E10E" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet2NATGateway3C070193" + } + } + }, + "VPCPrivateSubnet3Subnet3EDCD457": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.160.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PrivateSubnet3" + } + ] + } + }, + "VPCPrivateSubnet3RouteTable192186F8": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC/PrivateSubnet3" + } + ] + } + }, + "VPCPrivateSubnet3RouteTableAssociationC28D144E": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet3RouteTable192186F8" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet3Subnet3EDCD457" + } + } + }, + "VPCPrivateSubnet3DefaultRoute27F311AE": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet3RouteTable192186F8" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet3NATGatewayD3048F5C" + } + } + }, + "VPCIGWB7E252D3": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/VPC" + } + ] + } + }, + "VPCVPCGW99B986DC": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "InternetGatewayId": { + "Ref": "VPCIGWB7E252D3" + } + } + }, + "InstanceInstanceSecurityGroupF0E2D5BE": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "TestStackMultipartUserData/Instance/InstanceSecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "SecurityGroupIngress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "from 0.0.0.0/0:ICMP Type 8", + "FromPort": 8, + "IpProtocol": "icmp", + "ToPort": -1 + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/Instance" + } + ], + "VpcId": { + "Ref": "VPCB9E5F0B4" + } + } + }, + "InstanceInstanceRoleE9785DE5": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "ec2.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/Instance" + } + ] + } + }, + "InstanceInstanceRoleDefaultPolicy4ACE9290": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ssm:*", + "ssmmessages:*", + "ec2messages:GetMessages" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "InstanceInstanceRoleDefaultPolicy4ACE9290", + "Roles": [ + { + "Ref": "InstanceInstanceRoleE9785DE5" + } + ] + } + }, + "InstanceInstanceProfileAB5AEF02": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "InstanceInstanceRoleE9785DE5" + } + ] + } + }, + "InstanceC1063A87": { + "Type": "AWS::EC2::Instance", + "Properties": { + "AvailabilityZone": "test-region-1a", + "IamInstanceProfile": { + "Ref": "InstanceInstanceProfileAB5AEF02" + }, + "ImageId": { + "Ref": "SsmParameterValueawsserviceamiamazonlinuxlatestamzn2amihvmx8664gp2C96584B6F00A464EAD1953AFF4B05118Parameter" + }, + "InstanceType": "t3.nano", + "SecurityGroupIds": [ + { + "Fn::GetAtt": [ + "InstanceInstanceSecurityGroupF0E2D5BE", + "GroupId" + ] + } + ], + "SubnetId": { + "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" + }, + "Tags": [ + { + "Key": "Name", + "Value": "TestStackMultipartUserData/Instance" + } + ], + "UserData": { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "Content-Type: multipart/mixed; boundary=\"+AWS+CDK+User+Data+Separator==\"\nMIME-Version: 1.0\n\n--+AWS+CDK+User+Data+Separator==\nContent-Type: text/x-shellscript; charset=\"utf-8\"\nContent-Transfer-Encoding: base64\n\n", + { + "Fn::Base64": "#!/bin/bash\necho 大らと > /var/tmp/echo1\ncp /var/tmp/echo1 /var/tmp/echo1-copy" + }, + "\n--+AWS+CDK+User+Data+Separator==\nContent-Type: text/x-shellscript; charset=\"utf-8\"\nContent-Transfer-Encoding: base64\n\n", + { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "#!/bin/bash\necho 大らと ", + { + "Ref": "VPCB9E5F0B4" + }, + " > /var/tmp/echo2" + ] + ] + } + }, + "\n--+AWS+CDK+User+Data+Separator==\nContent-Type: text/cloud-boothook\nContent-Transfer-Encoding: base64\n\n", + { + "Fn::Base64": "#!/bin/bash\necho \"Boothook2\" > /var/tmp/boothook\ncloud-init-per once docker_options echo 'OPTIONS=\"${OPTIONS} --storage-opt dm.basesize=20G\"' >> /etc/sysconfig/docker" + }, + "\n--+AWS+CDK+User+Data+Separator==\nContent-Type: text/x-shellscript\n\necho \"RawPart\" > /var/tmp/rawPart1\n--+AWS+CDK+User+Data+Separator==\nContent-Type: text/x-shellscript\n\necho \"RawPart ", + { + "Ref": "VPCB9E5F0B4" + }, + "\" > /var/tmp/rawPart2\n--+AWS+CDK+User+Data+Separator==\nContent-Type: text/x-shellscript\n\ncp $0 /var/tmp/upstart # Should be one line file no new line at the end and beginning\n--+AWS+CDK+User+Data+Separator==--\n" + ] + ] + } + } + }, + "DependsOn": [ + "InstanceInstanceRoleDefaultPolicy4ACE9290", + "InstanceInstanceRoleE9785DE5" + ] + } + }, + "Parameters": { + "SsmParameterValueawsserviceamiamazonlinuxlatestamzn2amihvmx8664gp2C96584B6F00A464EAD1953AFF4B05118Parameter": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/test/integ.instance-multipart-userdata.ts b/packages/@aws-cdk/aws-ec2/test/integ.instance-multipart-userdata.ts new file mode 100644 index 0000000000000..9038166b93e39 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/integ.instance-multipart-userdata.ts @@ -0,0 +1,70 @@ +/// !cdk-integ * +import { PolicyStatement } from '@aws-cdk/aws-iam'; +import * as cdk from '@aws-cdk/core'; +import * as ec2 from '../lib'; + +const app = new cdk.App(); + +class TestStack extends cdk.Stack { + constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const vpc = new ec2.Vpc(this, 'VPC'); + + // Here we test default separator as probably most useful + const multipartUserData = new ec2.MultipartUserData(); + + const userData1 = ec2.UserData.forLinux(); + userData1.addCommands('echo 大らと > /var/tmp/echo1'); + userData1.addCommands('cp /var/tmp/echo1 /var/tmp/echo1-copy'); + + const userData2 = ec2.UserData.forLinux(); + userData2.addCommands(`echo 大らと ${vpc.vpcId} > /var/tmp/echo2`); + + const rawPart1 = ec2.MultipartBody.fromRawBody({ + contentType: 'text/x-shellscript', + body: 'echo "RawPart" > /var/tmp/rawPart1', + }); + + const rawPart2 = ec2.MultipartBody.fromRawBody({ + contentType: 'text/x-shellscript', + body: `echo "RawPart ${vpc.vpcId}" > /var/tmp/rawPart2`, + }); + + const bootHook = ec2.UserData.forLinux(); + bootHook.addCommands( + 'echo "Boothook2" > /var/tmp/boothook', + 'cloud-init-per once docker_options echo \'OPTIONS="${OPTIONS} --storage-opt dm.basesize=20G"\' >> /etc/sysconfig/docker', + ); + + multipartUserData.addPart(ec2.MultipartBody.fromUserData(userData1)); + multipartUserData.addPart(ec2.MultipartBody.fromUserData(userData2)); + multipartUserData.addPart(ec2.MultipartBody.fromUserData(bootHook, 'text/cloud-boothook')); + + const rawPart3 = ec2.MultipartBody.fromRawBody({ + contentType: 'text/x-shellscript', + body: 'cp $0 /var/tmp/upstart # Should be one line file no new line at the end and beginning', + }); + multipartUserData.addPart(rawPart1); + multipartUserData.addPart(rawPart2); + multipartUserData.addPart(rawPart3); + + const instance = new ec2.Instance(this, 'Instance', { + vpc, + instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.NANO), + machineImage: new ec2.AmazonLinuxImage({ generation: ec2.AmazonLinuxGeneration.AMAZON_LINUX_2 }), + userData: multipartUserData, + }); + + instance.addToRolePolicy(new PolicyStatement({ + actions: ['ssm:*', 'ssmmessages:*', 'ec2messages:GetMessages'], + resources: ['*'], + })); + + instance.connections.allowFromAnyIpv4(ec2.Port.icmpPing()); + } +} + +new TestStack(app, 'TestStackMultipartUserData'); + +app.synth(); diff --git a/packages/@aws-cdk/aws-ec2/test/userdata.test.ts b/packages/@aws-cdk/aws-ec2/test/userdata.test.ts index 883794bd5c585..26493962cbbb8 100644 --- a/packages/@aws-cdk/aws-ec2/test/userdata.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/userdata.test.ts @@ -273,4 +273,101 @@ nodeunitShim({ test.done(); }, + 'Linux user rendering multipart headers'(test: Test) { + // GIVEN + const stack = new Stack(); + const linuxUserData = ec2.UserData.forLinux(); + linuxUserData.addCommands('echo "Hello world"'); + + // WHEN + const defaultRender1 = ec2.MultipartBody.fromUserData(linuxUserData); + const defaultRender2 = ec2.MultipartBody.fromUserData(linuxUserData, 'text/cloud-boothook; charset=\"utf-8\"'); + + // THEN + expect(stack.resolve(defaultRender1.renderBodyPart())).toEqual([ + 'Content-Type: text/x-shellscript; charset=\"utf-8\"', + 'Content-Transfer-Encoding: base64', + '', + { 'Fn::Base64': '#!/bin/bash\necho \"Hello world\"' }, + ]); + expect(stack.resolve(defaultRender2.renderBodyPart())).toEqual([ + 'Content-Type: text/cloud-boothook; charset=\"utf-8\"', + 'Content-Transfer-Encoding: base64', + '', + { 'Fn::Base64': '#!/bin/bash\necho \"Hello world\"' }, + ]); + + test.done(); + }, + + 'Default parts separator used, if not specified'(test: Test) { + // GIVEN + const multipart = new ec2.MultipartUserData(); + + multipart.addPart(ec2.MultipartBody.fromRawBody({ + contentType: 'CT', + })); + + // WHEN + const out = multipart.render(); + + // WHEN + test.equals(out, [ + 'Content-Type: multipart/mixed; boundary=\"+AWS+CDK+User+Data+Separator==\"', + 'MIME-Version: 1.0', + '', + '--+AWS+CDK+User+Data+Separator==', + 'Content-Type: CT', + '', + '--+AWS+CDK+User+Data+Separator==--', + '', + ].join('\n')); + + test.done(); + }, + + 'Non-default parts separator used, if not specified'(test: Test) { + // GIVEN + const multipart = new ec2.MultipartUserData({ + partsSeparator: '//', + }); + + multipart.addPart(ec2.MultipartBody.fromRawBody({ + contentType: 'CT', + })); + + // WHEN + const out = multipart.render(); + + // WHEN + test.equals(out, [ + 'Content-Type: multipart/mixed; boundary=\"//\"', + 'MIME-Version: 1.0', + '', + '--//', + 'Content-Type: CT', + '', + '--//--', + '', + ].join('\n')); + + test.done(); + }, + + 'Multipart separator validation'(test: Test) { + // Happy path + new ec2.MultipartUserData(); + new ec2.MultipartUserData({ + partsSeparator: 'a-zA-Z0-9()+,-./:=?', + }); + + [' ', '\n', '\r', '[', ']', '<', '>', '違う'].forEach(s => test.throws(() => { + new ec2.MultipartUserData({ + partsSeparator: s, + }); + }, /Invalid characters in separator/)); + + test.done(); + }, + });