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(); + }, + });