From 014c13a78261b400404819549f6ff25d27b0c51d Mon Sep 17 00:00:00 2001 From: Nick Lynch Date: Thu, 13 Aug 2020 10:36:41 +0100 Subject: [PATCH] feat(ec2): CloudFormation-init support (#9065) > NOTE: This is a reduced version of #8788, which is the full CloudFormation-init support. This has been reduced down to only support instances (not ASGs), and to only support the InitCommand and InitService init elements, rather than the full set. This is to reduce the PR size and encourage a more thorough review. A follow-up review will add the remainder of the elements and auto-scaling group support. Add CloudFormation-init support. The CloudFormation-init metadata is encapsulated in a CloudFormationInit object, and using it automatically renders the UserData to apply it and send a signal to the appropriate CloudFormation resource and adds the permissions required to use cfn-init, cfn-signal and any S3 files/assets to the instance role. On an Instance, using CloudFormation-init automatically adds a ResourceSignal with a default timeout to the instance. Note this currently also includes the same changes as #9063, as this relies on it. #9063 can be independently shipped. ---- *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 | 100 ++++- .../@aws-cdk/aws-ec2/lib/cfn-init-elements.ts | 343 ++++++++++++++++++ packages/@aws-cdk/aws-ec2/lib/cfn-init.ts | 296 +++++++++++++++ packages/@aws-cdk/aws-ec2/lib/index.ts | 2 + packages/@aws-cdk/aws-ec2/lib/instance.ts | 172 ++++++++- .../aws-ec2/lib/private/cfn-init-internal.ts | 140 +++++++ packages/@aws-cdk/aws-ec2/package.json | 5 +- .../aws-ec2/test/cfn-init-element.test.ts | 176 +++++++++ .../@aws-cdk/aws-ec2/test/cfn-init.test.ts | 237 ++++++++++++ .../@aws-cdk/aws-ec2/test/instance.test.ts | 67 +++- .../test/integ.instance-init.expected.json | 181 +++++++++ .../aws-ec2/test/integ.instance-init.ts | 34 ++ 12 files changed, 1726 insertions(+), 27 deletions(-) create mode 100644 packages/@aws-cdk/aws-ec2/lib/cfn-init-elements.ts create mode 100644 packages/@aws-cdk/aws-ec2/lib/cfn-init.ts create mode 100644 packages/@aws-cdk/aws-ec2/lib/private/cfn-init-internal.ts create mode 100644 packages/@aws-cdk/aws-ec2/test/cfn-init-element.test.ts create mode 100644 packages/@aws-cdk/aws-ec2/test/cfn-init.test.ts create mode 100644 packages/@aws-cdk/aws-ec2/test/integ.instance-init.expected.json create mode 100644 packages/@aws-cdk/aws-ec2/test/integ.instance-init.ts diff --git a/packages/@aws-cdk/aws-ec2/README.md b/packages/@aws-cdk/aws-ec2/README.md index 668943296ac4d..e7b8c6a7a227d 100644 --- a/packages/@aws-cdk/aws-ec2/README.md +++ b/packages/@aws-cdk/aws-ec2/README.md @@ -1,4 +1,5 @@ ## Amazon EC2 Construct Library + --- @@ -48,10 +49,9 @@ distinguishes three different subnet types: connected to from other instances in the same VPC. A default VPC configuration will not include isolated subnets, - A default VPC configuration will create public and **private** subnets. However, if -`natGateways:0` **and** `subnetConfiguration` is undefined, default VPC configuration -will create public and **isolated** subnets. See [*Advanced Subnet Configuration*](#advanced-subnet-configuration) +`natGateways:0` **and** `subnetConfiguration` is undefined, default VPC configuration +will create public and **isolated** subnets. See [*Advanced Subnet Configuration*](#advanced-subnet-configuration) below for information on how to change the default subnet configuration. Constructs using the VPC will "launch instances" (or more accurately, create @@ -68,7 +68,6 @@ created by setting the `natGateways` property to a lower value (the default is one NAT gateway per availability zone). Be aware that this may have availability implications for your application. - [Read more about subnets](https://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/VPC_Subnets.html). @@ -280,9 +279,9 @@ const igwId = vpc.internetGatewayId; For a VPC with only `ISOLATED` subnets, this value will be undefined. -This is only supported for VPC's created in the stack - currently you're +This is only supported for VPC's created in the stack - currently you're unable to get the ID for imported VPC's. To do that you'd have to specifically -look up the Internet Gateway by name, which would require knowing the name +look up the Internet Gateway by name, which would require knowing the name beforehand. This can be useful for configuring routing using a combination of gateways: @@ -501,7 +500,9 @@ examples of things you might want to use: > [Runtime Context](https://docs.aws.amazon.com/cdk/latest/guide/context.html) in the CDK > developer guide. -## VPN connections to a VPC +## Special VPC configurations + +### VPN connections to a VPC Create your VPC with VPN connections by specifying the `vpnConnections` props (keys are construct `id`s): @@ -531,6 +532,7 @@ const vpc = new ec2.Vpc(this, 'MyVpc', { ``` VPN connections can then be added: + ```ts fixture=with-vpc vpc.addVpnConnection('Dynamic', { ip: '1.2.3.4' @@ -554,14 +556,15 @@ const vpnConnection = vpc.addVpnConnection('Dynamic', { const state = vpnConnection.metricTunnelState(); ``` -## VPC endpoints +### VPC endpoints + A VPC endpoint enables you to privately connect your VPC to supported AWS services and VPC endpoint services powered by PrivateLink without requiring an internet gateway, NAT device, VPN connection, or AWS Direct Connect connection. Instances in your VPC do not require public IP addresses to communicate with resources in the service. Traffic between your VPC and the other service does not leave the Amazon network. Endpoints are virtual devices. They are horizontally scaled, redundant, and highly available VPC components that allow communication between instances in your VPC and services without imposing availability risks or bandwidth constraints on your network traffic. [example of setting up VPC endpoints](test/integ.vpc-endpoint.lit.ts) -By default, CDK will place a VPC endpoint in one subnet per AZ. If you wish to override the AZs CDK places the VPC endpoint in, +By default, CDK will place a VPC endpoint in one subnet per AZ. If you wish to override the AZs CDK places the VPC endpoint in, use the `subnets` parameter as follows: ```ts @@ -591,7 +594,8 @@ new InterfaceVpcEndpoint(stack, 'VPC Endpoint', { }); ``` -### Security groups for interface VPC endpoints +#### Security groups for interface VPC endpoints + By default, interface VPC endpoints create a new security group and traffic is **not** automatically allowed from the VPC CIDR. @@ -603,7 +607,8 @@ myEndpoint.connections.allowDefaultPortFromAnyIpv4(); Alternatively, existing security groups can be used by specifying the `securityGroups` prop. -## VPC endpoint services +### VPC endpoint services + A VPC endpoint service enables you to expose a Network Load Balancer(s) as a provider service to consumers, who connect to your service over a VPC endpoint. You can restrict access to your service via whitelisted principals (anything that extends ArnPrincipal), and require that new connections be manually accepted. ```ts @@ -614,17 +619,69 @@ new VpcEndpointService(this, 'EndpointService', { }); ``` -## Bastion Hosts +## Instances + +You can use the `Instance` class to start up a single EC2 instance. For production setups, we recommend +you use an `AutoScalingGroup` from the `aws-autoscaling` module instead, as AutoScalingGroups will take +care of restarting your instance if it ever fails. + +### Configuring Instances using CloudFormation Init (cfn-init) + +CloudFormation Init allows you to configure your instances by writing files to them, installing software +packages, starting services and running arbitrary commands. By default, if any of the instance setup +commands throw an error, the deployment will fail and roll back to the previously known good state. +The following documentation also applies to `AutoScalingGroup`s. + +For the full set of capabilities of this system, see the documentation for +[`AWS::CloudFormation::Init`](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-init.html). +Here is an example of applying some configuration to an instance: + +```ts +new ec2.Instance(this, 'Instance', { + init: ec2.CloudFormationInit.fromElements( + ec2.InitCommand.shellCommand('/bin/true'), + ), + initOptions: { + // Optional, which configsets to activate (['default'] by default) + configSets: ['default'], + + // Optional, how long the installation is expected to take (5 minutes by default) + timeout: Duration.minutes(30), + }, +}); +``` + +You can have services restarted after the init process has made changes to the system. +To do that, instantiate an `InitServiceRestartHandle` and pass it to the config elements +that need to trigger the restart and the service itself. For example, the following +config installs nginx through a custom script, and then +restarts nginx so that it picks up the new config and files: + +```ts +const handle = new ec2.InitServiceRestartHandle(); + +ec2.CloudFormationInit.fromElements( + ec2.InitCommand.shellCommand('/usr/bin/custom-nginx-install.sh', { serviceRestartHandles: [handle] }), + ec2.InitService.enable('nginx', { + serviceRestartHandle: handle, + }) +); +``` + +### Bastion Hosts + A bastion host functions as an instance used to access servers and resources in a VPC without open up the complete VPC on a network level. You can use bastion hosts using a standard SSH connection targetting port 22 on the host. As an alternative, you can connect the SSH connection feature of AWS Systems Manager Session Manager, which does not need an opened security group. (https://aws.amazon.com/about-aws/whats-new/2019/07/session-manager-launches-tunneling-support-for-ssh-and-scp/) A default bastion host for use via SSM can be configured like: + ```ts fixture=with-vpc const host = new ec2.BastionHostLinux(this, 'BastionHost', { vpc }); ``` If you want to connect from the internet using SSH, you need to place the host into a public subnet. You can then configure allowed source hosts. + ```ts fixture=with-vpc const host = new ec2.BastionHostLinux(this, 'BastionHost', { vpc, @@ -637,6 +694,7 @@ As there are no SSH public keys deployed on this machine, you need to use [EC2 I with the command `aws ec2-instance-connect send-ssh-public-key` to provide your SSH public key. EBS volume for the bastion host can be encrypted like: + ```ts const host = new ec2.BastionHostLinux(stack, 'BastionHost', { vpc, @@ -649,7 +707,7 @@ EBS volume for the bastion host can be encrypted like: }); ``` -## Block Devices +### Block Devices To add EBS block device mappings, specify the `blockDeviceMappings` property. The follow example sets the EBS-backed root device (`/dev/sda1`) size to 50 GiB, and adds another EBS-backed device mapped to `/dev/sdm` that is 100 GiB in @@ -672,7 +730,7 @@ new ec2.Instance(this, 'Instance', { ``` -## Volumes +### Volumes Whereas a `BlockDeviceVolume` is an EBS volume that is created and destroyed as part of the creation and destruction of a specific instance. A `Volume` is for when you want an EBS volume separate from any particular instance. A `Volume` is an EBS block device that can be attached to, or detached from, any instance at any time. Some types of `Volume`s can also be attached to multiple instances at the same time to allow you to have shared storage between those instances. @@ -696,7 +754,7 @@ const volume = new ec2.Volume(this, 'Volume', { volume.grantAttachVolume(role, [instance]); ``` -### Instances Attaching Volumes to Themselves +#### Instances Attaching Volumes to Themselves If you need to grant an instance the ability to attach/detach an EBS volume to/from itself, then using `grantAttachVolume` and `grantDetachVolume` as outlined above will lead to an unresolvable circular reference between the instance role and the instance. In this case, use `grantAttachVolumeByResourceTag` and `grantDetachVolumeByResourceTag` as follows: @@ -713,7 +771,7 @@ const attachGrant = volume.grantAttachVolumeByResourceTag(instance.grantPrincipa const detachGrant = volume.grantDetachVolumeByResourceTag(instance.grantPrincipal, [instance]); ``` -### Attaching Volumes +#### Attaching Volumes The Amazon EC2 documentation for [Linux Instances](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AmazonEBS.html) and @@ -741,7 +799,8 @@ instance.userData.addCommands( ``` ## VPC Flow Logs -VPC Flow Logs is a feature that enables you to capture information about the IP traffic going to and from network interfaces in your VPC. Flow log data can be published to Amazon CloudWatch Logs and Amazon S3. After you've created a flow log, you can retrieve and view its data in the chosen destination. (https://docs.aws.amazon.com/vpc/latest/userguide/flow-logs.html). + +VPC Flow Logs is a feature that enables you to capture information about the IP traffic going to and from network interfaces in your VPC. Flow log data can be published to Amazon CloudWatch Logs and Amazon S3. After you've created a flow log, you can retrieve and view its data in the chosen destination. (). By default a flow log will be created with CloudWatch Logs as the destination. @@ -752,6 +811,7 @@ new ec2.FlowLog(this, 'FlowLog', { resourceType: ec2.FlowLogResourceType.fromVpc(vpc) }) ``` + Or you can add a Flow Log to a VPC by using the addFlowLog method like this: ```ts @@ -781,6 +841,7 @@ the log group. In the case of an S3 destination, it will create the S3 bucket. If you want to customize any of the destination resources you can provide your own as part of the `destination`. *CloudWatch Logs* + ```ts const logGroup = new logs.LogGroup(this, 'MyCustomLogGroup'); @@ -795,6 +856,7 @@ new ec2.FlowLog(this, 'FlowLog', { ``` *S3* + ```ts const bucket = new s3.Bucket(this, 'MyCustomBucket'); @@ -806,10 +868,12 @@ new ec2.FlowLog(this, 'FlowLog', { ``` ## User Data + User data enables you to run a script when your instances start up. In order to configure these scripts you can add commands directly to the script or you can use the UserData's convenience functions to aid in the creation of your script. A user data could be configured to run a script found in an asset through the following: + ```ts const asset = new Asset(this, 'Asset', {path: path.join(__dirname, 'configure.sh')}); const instance = new ec2.Instance(this, 'Instance', { @@ -846,4 +910,4 @@ const subnet = Subnet.fromSubnetAttributes(this, 'SubnetFromAttributes', { // Supply only subnet id const subnet = Subnet.fromSubnetId(this, 'SubnetFromId', 's-1234'); -``` \ No newline at end of file +``` diff --git a/packages/@aws-cdk/aws-ec2/lib/cfn-init-elements.ts b/packages/@aws-cdk/aws-ec2/lib/cfn-init-elements.ts new file mode 100644 index 0000000000000..13ed6e61d1cb5 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/cfn-init-elements.ts @@ -0,0 +1,343 @@ +import { Duration } from '@aws-cdk/core'; +import { InitBindOptions, InitElementConfig, InitElementType, InitPlatform } from './private/cfn-init-internal'; + +/** + * An object that represents reasons to restart an InitService + * + * Pass an instance of this object to the `InitFile`, `InitCommand`, + * `InitSource` and `InitPackage` objects, and finally to an `InitService` + * itself to cause the actions (files, commands, sources, and packages) + * to trigger a restart of the service. + * + * For example, the following will run a custom command to install Nginx, + * and trigger the nginx service to be restarted after the command has run. + * + * ```ts + * const handle = new ec2.InitServiceRestartHandle(); + * ec2.CloudFormationInit.fromElements( + * ec2.InitCommand.shellCommand('/usr/bin/custom-nginx-install.sh', { serviceRestartHandles: [handle] }), + * ec2.InitService.enable('nginx', { serviceRestartHandle: handle }), + * ); + * ``` + */ +export class InitServiceRestartHandle { + private readonly commands = new Array(); + private readonly files = new Array(); + private readonly sources = new Array(); + private readonly packages: Record = {}; + + /** + * Add a command key to the restart set + * @internal + */ + public _addCommand(key: string) { + return this.commands.push(key); + } + + /** + * Add a file key to the restart set + * @internal + */ + public _addFile(key: string) { + return this.files.push(key); + } + + /** + * Add a source key to the restart set + * @internal + */ + public _addSource(key: string) { + return this.sources.push(key); + } + + /** + * Add a package key to the restart set + * @internal + */ + public _addPackage(packageType: string, key: string) { + if (!this.packages[packageType]) { + this.packages[packageType] = []; + } + this.packages[packageType].push(key); + } + + /** + * Render the restart handles for use in an InitService declaration + * @internal + */ + public _renderRestartHandles(): any { + const nonEmpty = (x: A[]) => x.length > 0 ? x : undefined; + + return { + commands: nonEmpty(this.commands), + files: nonEmpty(this.files), + packages: Object.keys(this.packages).length > 0 ? this.packages : undefined, + sources: nonEmpty(this.sources), + }; + } +} + +/** + * Base class for all CloudFormation Init elements + */ +export abstract class InitElement { + + /** + * Returns the init element type for this element. + */ + public abstract readonly elementType: string; + + /** + * Called when the Init config is being consumed. Renders the CloudFormation + * representation of this init element, and calculates any authentication + * properties needed, if any. + * + * @param options bind options for the element. + * @internal + */ + public abstract _bind(options: InitBindOptions): InitElementConfig; + +} + +/** + * Options for InitCommand + */ +export interface InitCommandOptions { + /** + * Identifier key for this command + * + * Commands are executed in lexicographical order of their key names. + * + * @default - Automatically generated based on index + */ + readonly key?: string; + + /** + * Sets environment variables for the command. + * + * This property overwrites, rather than appends, the existing environment. + * + * @default - Use current environment + */ + readonly env?: Record; + + /** + * The working directory + * + * @default - Use default working directory + */ + readonly cwd?: string; + + /** + * Command to determine whether this command should be run + * + * If the test passes (exits with error code of 0), the command is run. + * + * @default - Always run the command + */ + readonly testCmd?: string; + + /** + * Continue running if this command fails + * + * @default false + */ + readonly ignoreErrors?: boolean; + + /** + * The duration to wait after a command has finished in case the command causes a reboot. + * + * Set this value to `InitCommandWaitDuration.none()` if you do not want to wait for every command; + * `InitCommandWaitDuration.forever()` directs cfn-init to exit and resume only after the reboot is complete. + * + * For Windows systems only. + * + * @default - 60 seconds + */ + readonly waitAfterCompletion?: InitCommandWaitDuration; + + /** + * Restart the given service(s) after this command has run + * + * @default - Do not restart any service + */ + readonly serviceRestartHandles?: InitServiceRestartHandle[]; +} + +/** + * Represents a duration to wait after a command has finished, in case of a reboot (Windows only). + */ +export abstract class InitCommandWaitDuration { + /** Wait for a specified duration after a command. */ + public static of(duration: Duration): InitCommandWaitDuration { + return new class extends InitCommandWaitDuration { + /** @internal */ + public _render() { return duration.toSeconds(); } + }(); + } + + /** Do not wait for this command. */ + public static none(): InitCommandWaitDuration { + return InitCommandWaitDuration.of(Duration.seconds(0)); + } + + /** cfn-init will exit and resume only after a reboot. */ + public static forever(): InitCommandWaitDuration { + return new class extends InitCommandWaitDuration { + /** @internal */ + public _render() { return 'forever'; } + }(); + } + + /** + * Render to a CloudFormation value. + * @internal + */ + public abstract _render(): any; +} + +/** + * Command to execute on the instance + */ +export class InitCommand extends InitElement { + /** + * Run a shell command + * + * Remember that some characters like `&`, `|`, `;`, `>` etc. have special meaning in a shell and + * need to be preceded by a `\` if you want to treat them as part of a filename. + */ + public static shellCommand(shellCommand: string, options: InitCommandOptions = {}): InitCommand { + return new InitCommand([shellCommand], options); + } + + /** + * Run a command from an argv array + * + * You do not need to escape space characters or enclose command parameters in quotes. + */ + public static argvCommand(argv: string[], options: InitCommandOptions = {}): InitCommand { + if (argv.length === 0) { + throw new Error('Cannot define argvCommand with an empty arguments'); + } + return new InitCommand(argv, options); + } + + public readonly elementType = InitElementType.COMMAND.toString(); + + private constructor(private readonly command: string[], private readonly options: InitCommandOptions) { + super(); + } + + /** @internal */ + public _bind(options: InitBindOptions): InitElementConfig { + const commandKey = this.options.key || `${options.index}`.padStart(3, '0'); // 001, 005, etc. + + if (options.platform !== InitPlatform.WINDOWS && this.options.waitAfterCompletion !== undefined) { + throw new Error(`Command '${this.command}': 'waitAfterCompletion' is only valid for Windows systems.`); + } + + for (const handle of this.options.serviceRestartHandles ?? []) { + handle._addCommand(commandKey); + } + + return { + config: { + [commandKey]: { + command: this.command, + env: this.options.env, + cwd: this.options.cwd, + test: this.options.testCmd, + ignoreErrors: this.options.ignoreErrors, + waitAfterCompletion: this.options.waitAfterCompletion?._render(), + }, + }, + }; + } + +} + +/** + * Options for an InitService + */ +export interface InitServiceOptions { + /** + * Enable or disable this service + * + * Set to true to ensure that the service will be started automatically upon boot. + * + * Set to false to ensure that the service will not be started automatically upon boot. + * + * @default - true if used in `InitService.enable()`, no change to service + * state if used in `InitService.fromOptions()`. + */ + readonly enabled?: boolean; + + /** + * Make sure this service is running or not running after cfn-init finishes. + * + * Set to true to ensure that the service is running after cfn-init finishes. + * + * Set to false to ensure that the service is not running after cfn-init finishes. + * + * @default - same value as `enabled`. + */ + readonly ensureRunning?: boolean; + + /** + * Restart service when the actions registered into the restartHandle have been performed + * + * Register actions into the restartHandle by passing it to `InitFile`, `InitCommand`, + * `InitPackage` and `InitSource` objects. + * + * @default - No files trigger restart + */ + readonly serviceRestartHandle?: InitServiceRestartHandle; +} + +/** + * A services that be enabled, disabled or restarted when the instance is launched. + */ +export class InitService extends InitElement { + /** + * Enable and start the given service, optionally restarting it + */ + public static enable(serviceName: string, options: InitServiceOptions = {}): InitService { + const { enabled, ensureRunning, ...otherOptions } = options; + return new InitService(serviceName, { + enabled: enabled ?? true, + ensureRunning: ensureRunning ?? enabled ?? true, + ...otherOptions, + }); + } + + /** + * Disable and stop the given service + */ + public static disable(serviceName: string): InitService { + return new InitService(serviceName, { enabled: false, ensureRunning: false }); + } + + public readonly elementType = InitElementType.SERVICE.toString(); + + private constructor(private readonly serviceName: string, private readonly serviceOptions: InitServiceOptions) { + super(); + } + + /** @internal */ + public _bind(options: InitBindOptions): InitElementConfig { + const serviceManager = options.platform === InitPlatform.LINUX ? 'sysvinit' : 'windows'; + + return { + config: { + [serviceManager]: { + [this.serviceName]: { + enabled: this.serviceOptions.enabled, + ensureRunning: this.serviceOptions.ensureRunning, + ...this.serviceOptions.serviceRestartHandle?._renderRestartHandles(), + }, + }, + }, + }; + } + +} diff --git a/packages/@aws-cdk/aws-ec2/lib/cfn-init.ts b/packages/@aws-cdk/aws-ec2/lib/cfn-init.ts new file mode 100644 index 0000000000000..47985cbf5187c --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/cfn-init.ts @@ -0,0 +1,296 @@ +import * as crypto from 'crypto'; +import * as iam from '@aws-cdk/aws-iam'; +import { Aws, CfnResource, Construct } from '@aws-cdk/core'; +import { InitElement } from './cfn-init-elements'; +import { AttachInitOptions, InitBindOptions, InitElementConfig, InitElementType, InitPlatform } from './private/cfn-init-internal'; + +/** + * A CloudFormation-init configuration + */ +export class CloudFormationInit { + /** + * Build a new config from a set of Init Elements + */ + public static fromElements(...elements: InitElement[]): CloudFormationInit { + return CloudFormationInit.fromConfig(new InitConfig(elements)); + } + + /** + * Use an existing InitConfig object as the default and only config + */ + public static fromConfig(config: InitConfig): CloudFormationInit { + return CloudFormationInit.fromConfigSets({ + configSets: { + default: ['config'], + }, + configs: { config }, + }); + } + + /** + * Build a CloudFormationInit from config sets + */ + public static fromConfigSets(props: ConfigSetProps): CloudFormationInit { + return new CloudFormationInit(props.configSets, props.configs); + } + + private readonly _configSets: Record = {}; + private readonly _configs: Record = {}; + + private constructor(configSets: Record, configs: Record) { + Object.assign(this._configSets, configSets); + Object.assign(this._configs, configs); + } + + /** + * Add a config with the given name to this CloudFormationInit object + */ + public addConfig(configName: string, config: InitConfig) { + if (this._configs[configName]) { + throw new Error(`CloudFormationInit already contains a config named '${configName}'`); + } + this._configs[configName] = config; + } + + /** + * Add a config set with the given name to this CloudFormationInit object + * + * The new configset will reference the given configs in the given order. + */ + public addConfigSet(configSetName: string, configNames: string[] = []) { + if (this._configSets[configSetName]) { + throw new Error(`CloudFormationInit already contains a configSet named '${configSetName}'`); + } + + const unk = configNames.filter(c => !this._configs[c]); + if (unk.length > 0) { + throw new Error(`Unknown configs referenced in definition of '${configSetName}': ${unk}`); + } + + this._configSets[configSetName] = [...configNames]; + } + + /** + * Attach the CloudFormation Init config to the given resource + * + * This method does the following: + * + * - Renders the `AWS::CloudFormation::Init` object to the given resource's + * metadata, potentially adding a `AWS::CloudFormation::Authentication` object + * next to it if required. + * - Updates the instance role policy to be able to call the APIs required for + * `cfn-init` and `cfn-signal` to work, and potentially add permissions to download + * referenced asset and bucket resources. + * - Updates the given UserData with commands to execute the `cfn-init` script. + * + * As an app builder, use `instance.applyCloudFormationInit()` or + * `autoScalingGroup.applyCloudFormationInit()` to trigger this method. + * + * @internal + */ + public _attach(attachedResource: CfnResource, attachOptions: AttachInitOptions) { + // Note: This will not reflect mutations made after attaching. + const bindResult = this.bind(attachedResource.stack, attachOptions); + attachedResource.addMetadata('AWS::CloudFormation::Init', bindResult.configData); + const fingerprint = contentHash(JSON.stringify(bindResult.configData)).substr(0, 16); + + attachOptions.instanceRole.addToPolicy(new iam.PolicyStatement({ + actions: ['cloudformation:DescribeStackResource', 'cloudformation:SignalResource'], + resources: [Aws.STACK_ID], + })); + + if (bindResult.authData) { + attachedResource.addMetadata('AWS::CloudFormation::Authentication', bindResult.authData); + } + + // To identify the resources that have the metadata and where the signal + // needs to be sent, we need { region, stackName, logicalId } + const resourceLocator = `--region ${Aws.REGION} --stack ${Aws.STACK_NAME} --resource ${attachedResource.logicalId}`; + const configSets = (attachOptions.configSets ?? ['default']).join(','); + const printLog = attachOptions.printLog ?? true; + + if (attachOptions.embedFingerprint ?? true) { + // It just so happens that the comment char is '#' for both bash and PowerShell + attachOptions.userData.addCommands(`# fingerprint: ${fingerprint}`); + } + + if (attachOptions.platform === InitPlatform.WINDOWS) { + const errCode = attachOptions.ignoreFailures ? '0' : '$LASTEXITCODE'; + attachOptions.userData.addCommands(...[ + `cfn-init.exe -v ${resourceLocator} -c ${configSets}`, + `cfn-signal.exe -e ${errCode} ${resourceLocator}`, + ...printLog ? ['type C:\\cfn\\log\\cfn-init.log'] : [], + ]); + } else { + const errCode = attachOptions.ignoreFailures ? '0' : '$?'; + attachOptions.userData.addCommands(...[ + // Run a subshell without 'errexit', so we can signal using the exit code of cfn-init + '(', + ' set +e', + ` /opt/aws/bin/cfn-init -v ${resourceLocator} -c ${configSets}`, + ` /opt/aws/bin/cfn-signal -e ${errCode} ${resourceLocator}`, + ...printLog ? [' cat /var/log/cfn-init.log >&2'] : [], + ')', + ]); + } + } + + private bind(scope: Construct, options: AttachInitOptions): { configData: any, authData: any } { + const nonEmptyConfigs = mapValues(this._configs, c => c.isEmpty() ? undefined : c); + + const configNameToBindResult = mapValues(nonEmptyConfigs, c => c._bind(scope, options)); + + return { + configData: { + configSets: mapValues(this._configSets, configNames => configNames.filter(name => nonEmptyConfigs[name] !== undefined)), + ...mapValues(configNameToBindResult, c => c.config), + }, + authData: Object.values(configNameToBindResult).map(c => c.authentication).reduce(deepMerge, undefined), + }; + } + +} + +/** + * A collection of configuration elements + */ +export class InitConfig { + private readonly elements = new Array(); + + constructor(elements: InitElement[]) { + this.add(...elements); + } + + /** + * Whether this configset has elements or not + */ + public isEmpty() { + return this.elements.length === 0; + } + + /** + * Add one or more elements to the config + */ + public add(...elements: InitElement[]) { + this.elements.push(...elements); + } + + /** + * Called when the config is applied to an instance. + * Creates the CloudFormation representation of the Init config and handles any permissions and assets. + * @internal + */ + public _bind(scope: Construct, options: AttachInitOptions): InitElementConfig { + const bindOptions = { + instanceRole: options.instanceRole, + platform: options.platform, + scope, + }; + + const packageConfig = this.bindForType(InitElementType.PACKAGE, bindOptions); + const groupsConfig = this.bindForType(InitElementType.GROUP, bindOptions); + const usersConfig = this.bindForType(InitElementType.USER, bindOptions); + const sourcesConfig = this.bindForType(InitElementType.SOURCE, bindOptions); + const filesConfig = this.bindForType(InitElementType.FILE, bindOptions); + const commandsConfig = this.bindForType(InitElementType.COMMAND, bindOptions); + // Must be last! + const servicesConfig = this.bindForType(InitElementType.SERVICE, bindOptions); + + const authentication = [ packageConfig, groupsConfig, usersConfig, sourcesConfig, filesConfig, commandsConfig, servicesConfig ] + .map(c => c?.authentication) + .reduce(deepMerge, undefined); + + return { + config: { + packages: packageConfig?.config, + groups: groupsConfig?.config, + users: usersConfig?.config, + sources: sourcesConfig?.config, + files: filesConfig?.config, + commands: commandsConfig?.config, + services: servicesConfig?.config, + }, + authentication, + }; + } + + private bindForType(elementType: InitElementType, renderOptions: Omit): InitElementConfig | undefined { + const elements = this.elements.filter(elem => elem.elementType === elementType); + if (elements.length === 0) { return undefined; } + + const bindResults = elements.map((e, index) => e._bind({ index, ...renderOptions })); + + return { + config: bindResults.map(r => r.config).reduce(deepMerge, undefined) ?? {}, + authentication: bindResults.map(r => r.authentication).reduce(deepMerge, undefined), + }; + } +} + +/** + * Options for CloudFormationInit.withConfigSets + */ +export interface ConfigSetProps { + /** + * The definitions of each config set + */ + readonly configSets: Record; + + /** + * The sets of configs to pick from + */ + readonly configs: Record; +} + +/** + * Deep-merge objects and arrays + * + * Treat arrays as sets, removing duplicates. This is acceptable for rendering + * cfn-inits, not applicable elsewhere. + */ +function deepMerge(target?: Record, src?: Record) { + if (target == null) { return src; } + if (src == null) { return target; } + + for (const [key, value] of Object.entries(src)) { + if (Array.isArray(value)) { + if (target[key] && !Array.isArray(target[key])) { + throw new Error(`Trying to merge array [${value}] into a non-array '${target[key]}'`); + } + target[key] = Array.from(new Set([ + ...target[key] ?? [], + ...value, + ])); + continue; + } + if (typeof value === 'object' && value) { + target[key] = deepMerge(target[key] ?? {}, value); + continue; + } + if (value !== undefined) { + target[key] = value; + } + } + + return target; +} + +/** + * Map a function over values of an object + * + * If the mapping function returns undefined, remove the key + */ +function mapValues(xs: Record, fn: (x: A) => B | undefined): Record { + const ret: Record = {}; + for (const [k, v] of Object.entries(xs)) { + const mapped = fn(v); + if (mapped !== undefined) { + ret[k] = mapped; + } + } + return ret; +} + +function contentHash(content: string) { + return crypto.createHash('sha256').update(content).digest('hex'); +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/lib/index.ts b/packages/@aws-cdk/aws-ec2/lib/index.ts index f7afc14ccdd0b..ff7f7131d53e4 100644 --- a/packages/@aws-cdk/aws-ec2/lib/index.ts +++ b/packages/@aws-cdk/aws-ec2/lib/index.ts @@ -1,5 +1,7 @@ export * from './bastion-host'; export * from './connections'; +export * from './cfn-init'; +export * from './cfn-init-elements'; export * from './instance-types'; export * from './instance'; export * from './machine-image'; diff --git a/packages/@aws-cdk/aws-ec2/lib/instance.ts b/packages/@aws-cdk/aws-ec2/lib/instance.ts index 79a6bb262d053..ba619c7667f7e 100644 --- a/packages/@aws-cdk/aws-ec2/lib/instance.ts +++ b/packages/@aws-cdk/aws-ec2/lib/instance.ts @@ -1,10 +1,13 @@ +import * as crypto from 'crypto'; import * as iam from '@aws-cdk/aws-iam'; -import { Construct, Duration, Fn, IResource, Lazy, Resource, Tag } from '@aws-cdk/core'; +import { Construct, Duration, Fn, IResource, Lazy, Resource, Stack, Tag } from '@aws-cdk/core'; +import { CloudFormationInit } from './cfn-init'; import { Connections, IConnectable } from './connections'; import { CfnInstance } from './ec2.generated'; import { InstanceType } from './instance-types'; import { IMachineImage, OperatingSystemType } from './machine-image'; +import { InitPlatform } from './private/cfn-init-internal'; import { ISecurityGroup, SecurityGroup } from './security-group'; import { UserData } from './user-data'; import { BlockDevice, synthesizeBlockDeviceMappings } from './volume'; @@ -137,6 +140,26 @@ export interface InstanceProps { */ readonly userData?: UserData; + /** + * Changes to the UserData force replacement + * + * Depending the EC2 instance type, changing UserData either + * restarts the instance or replaces the instance. + * + * - Instance store-backed instances are replaced. + * - EBS-backed instances are restarted. + * + * By default, restarting does not execute the new UserData so you + * will need a different mechanism to ensure the instance is restarted. + * + * Setting this to `true` will make the instance's Logical ID depend on the + * UserData, which will cause CloudFormation to replace it if the UserData + * changes. + * + * @default - true iff `initOptions` is specified, false otherwise. + */ + readonly userDataCausesReplacement?: boolean; + /** * An IAM role to associate with the instance profile assigned to this Auto Scaling Group. * @@ -190,6 +213,22 @@ export interface InstanceProps { * @default - no association */ readonly privateIpAddress?: string + + /** + * Apply the given CloudFormation Init configuration to the instance at startup + * + * @default - no CloudFormation init + */ + readonly init?: CloudFormationInit; + + /** + * Use the given options for applying CloudFormation Init + * + * Describes the configsets to use and the timeout to wait + * + * @default - default options + */ + readonly initOptions?: ApplyCloudFormationInitOptions; } /** @@ -257,6 +296,10 @@ export class Instance extends Resource implements IInstance { constructor(scope: Construct, id: string, props: InstanceProps) { super(scope, id); + if (props.initOptions && !props.init) { + throw new Error('Setting \'initOptions\' requires that \'init\' is also set'); + } + if (props.securityGroup) { this.securityGroup = props.securityGroup; } else { @@ -334,7 +377,23 @@ export class Instance extends Resource implements IInstance { this.instancePublicDnsName = this.instance.attrPublicDnsName; this.instancePublicIp = this.instance.attrPublicIp; + if (props.init) { + this.applyCloudFormationInit(props.init, props.initOptions); + } + this.applyUpdatePolicies(props); + + // Trigger replacement (via new logical ID) on user data change, if specified or cfn-init is being used. + const originalLogicalId = Stack.of(this).getLogicalId(this.instance); + this.instance.overrideLogicalId(Lazy.stringValue({ produce: () => { + let logicalId = originalLogicalId; + if (props.userDataCausesReplacement ?? props.initOptions) { + const md5 = crypto.createHash('md5'); + md5.update(this.userData.render()); + logicalId += md5.digest('hex').substr(0, 16); + } + return logicalId; + }})); } /** @@ -361,6 +420,50 @@ export class Instance extends Resource implements IInstance { this.role.addToPolicy(statement); } + /** + * Use a CloudFormation Init configuration at instance startup + * + * This does the following: + * + * - Attaches the CloudFormation Init metadata to the Instance resource. + * - Add commands to the instance UserData to run `cfn-init` and `cfn-signal`. + * - Update the instance's CreationPolicy to wait for the `cfn-signal` commands. + */ + private applyCloudFormationInit(init: CloudFormationInit, options: ApplyCloudFormationInitOptions = {}) { + const platform = this.osType === OperatingSystemType.WINDOWS ? InitPlatform.WINDOWS : InitPlatform.LINUX; + init._attach(this.instance, { + platform, + instanceRole: this.role, + userData: this.userData, + configSets: options.configSets, + embedFingerprint: options.embedFingerprint, + printLog: options.printLog, + ignoreFailures: options.ignoreFailures, + }); + this.waitForResourceSignal(options.timeout ?? Duration.minutes(5)); + } + + /** + * Wait for a single additional resource signal + * + * Add 1 to the current ResourceSignal Count and add the given timeout to the current timeout. + * + * Use this to pause the CloudFormation deployment to wait for the instances + * in the AutoScalingGroup to report successful startup during + * creation and updates. The UserData script needs to invoke `cfn-signal` + * with a success or failure code after it is done setting up the instance. + */ + private waitForResourceSignal(timeout: Duration) { + const oldResourceSignal = this.instance.cfnOptions.creationPolicy?.resourceSignal; + this.instance.cfnOptions.creationPolicy = { + ...this.instance.cfnOptions.creationPolicy, + resourceSignal: { + count: (oldResourceSignal?.count ?? 0) + 1, + timeout: (oldResourceSignal?.timeout ? Duration.parse(oldResourceSignal?.timeout).plus(timeout) : timeout).toIsoString(), + }, + }; + } + /** * Apply CloudFormation update policies for the instance */ @@ -375,3 +478,70 @@ export class Instance extends Resource implements IInstance { } } } + +/** + * Options for applying CloudFormation init to an instance or instance group + */ +export interface ApplyCloudFormationInitOptions { + /** + * ConfigSet to activate + * + * @default ['default'] + */ + readonly configSets?: string[]; + + /** + * Timeout waiting for the configuration to be applied + * + * @default Duration.minutes(5) + */ + readonly timeout?: Duration; + + /** + * Force instance replacement by embedding a config fingerprint + * + * If `true` (the default), a hash of the config will be embedded into the + * UserData, so that if the config changes, the UserData changes. + * + * - If the EC2 instance is instance-store backed or + * `userDataCausesReplacement` is set, this will cause the instance to be + * replaced and the new configuration to be applied. + * - If the instance is EBS-backed and `userDataCausesReplacement` is not + * set, the change of UserData will make the instance restart but not be + * replaced, and the configuration will not be applied automatically. + * + * If `false`, no hash will be embedded, and if the CloudFormation Init + * config changes nothing will happen to the running instance. If a + * config update introduces errors, you will not notice until after the + * CloudFormation deployment successfully finishes and the next instance + * fails to launch. + * + * @default true + */ + readonly embedFingerprint?: boolean; + + /** + * Print the results of running cfn-init to the Instance System Log + * + * By default, the output of running cfn-init is written to a log file + * on the instance. Set this to `true` to print it to the System Log + * (visible from the EC2 Console), `false` to not print it. + * + * (Be aware that the system log is refreshed at certain points in + * time of the instance life cycle, and successful execution may + * not always show up). + * + * @default true + */ + readonly printLog?: boolean; + + /** + * Don't fail the instance creation when cfn-init fails + * + * You can use this to prevent CloudFormation from rolling back when + * instances fail to start up, to help in debugging. + * + * @default false + */ + readonly ignoreFailures?: boolean; +} diff --git a/packages/@aws-cdk/aws-ec2/lib/private/cfn-init-internal.ts b/packages/@aws-cdk/aws-ec2/lib/private/cfn-init-internal.ts new file mode 100644 index 0000000000000..ced90167c6101 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/private/cfn-init-internal.ts @@ -0,0 +1,140 @@ +import * as iam from '@aws-cdk/aws-iam'; +import { Construct } from '@aws-cdk/core'; +import { UserData } from '../user-data'; + +/** + * The type of the init element. + */ +export enum InitElementType { + PACKAGE = 'PACKAGE', + GROUP = 'GROUP', + USER = 'USER', + SOURCE = 'SOURCE', + FILE = 'FILE', + COMMAND = 'COMMAND', + SERVICE = 'SERVICE', +} + +/** + * The platform to which the init template applies. + */ +export enum InitPlatform { + WINDOWS = 'WINDOWS', + LINUX = 'LINUX', +} + +/** + * Context information passed when an InitElement is being consumed + * @internal + */ +export interface InitBindOptions { + /** + * Scope in which to define any resources, if necessary. + */ + readonly scope: Construct; + + /** + * Which OS platform (Linux, Windows) the init block will be for. + * Impacts which config types are available and how they are created. + */ + readonly platform: InitPlatform; + + /** + * Ordered index of current element type. Primarily used to auto-generate + * command keys and retain ordering. + */ + readonly index: number; + + /** + * Instance role of the consuming instance or fleet + */ + readonly instanceRole: iam.IRole; +} + +/** + * A return type for a configured InitElement. Both its CloudFormation representation, and any + * additional metadata needed to create the CloudFormation::Init. + * + * Marked internal so as not to leak the underlying L1 representation. + * + * @internal + */ +export interface InitElementConfig { + /** + * The CloudFormation representation of the configuration of an InitElement. + */ + readonly config: Record; + + /** + * Optional authentication blocks to be associated with the Init Config + * + * @default - No authentication associated with the config + */ + readonly authentication?: Record; +} + +/** + * Options for attach a CloudFormationInit to a resource + */ +export interface AttachInitOptions { + /** + * Instance role of the consuming instance or fleet + */ + readonly instanceRole: iam.IRole; + + /** + * OS Platfrom the init config will be used for + */ + readonly platform: InitPlatform; + + /** + * UserData to add commands to + */ + readonly userData: UserData; + + /** + * ConfigSet to activate + * + * @default ['default'] + */ + readonly configSets?: string[]; + + /** + * Whether to embed a hash into the userData + * + * If `true` (the default), a hash of the config will be embedded into the + * UserData, so that if the config changes, the UserData changes and + * the instance will be replaced. + * + * If `false`, no such hash will be embedded, and if the CloudFormation Init + * config changes nothing will happen to the running instance. + * + * @default true + */ + readonly embedFingerprint?: boolean; + + /** + * Print the results of running cfn-init to the Instance System Log + * + * By default, the output of running cfn-init is written to a log file + * on the instance. Set this to `true` to print it to the System Log + * (visible from the EC2 Console), `false` to not print it. + * + * (Be aware that the system log is refreshed at certain points in + * time of the instance life cycle, and successful execution may + * not always show up). + * + * @default true + */ + readonly printLog?: boolean; + + /** + * Don't fail the instance creation when cfn-init fails + * + * You can use this to prevent CloudFormation from rolling back when + * instances fail to start up, to help in debugging. + * + * @default false + */ + readonly ignoreFailures?: boolean; +} diff --git a/packages/@aws-cdk/aws-ec2/package.json b/packages/@aws-cdk/aws-ec2/package.json index ad6075cc5ec77..cae87529d643b 100644 --- a/packages/@aws-cdk/aws-ec2/package.json +++ b/packages/@aws-cdk/aws-ec2/package.json @@ -77,20 +77,23 @@ "@aws-cdk/aws-kms": "0.0.0", "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/aws-ssm": "0.0.0", + "@aws-cdk/cloud-assembly-schema": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", - "@aws-cdk/cloud-assembly-schema": "0.0.0", "@aws-cdk/region-info": "0.0.0", "constructs": "^3.0.2" }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { + "@aws-cdk/assets": "0.0.0", "@aws-cdk/aws-cloudwatch": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-kms": "0.0.0", "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-s3-assets": "0.0.0", "@aws-cdk/aws-ssm": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", diff --git a/packages/@aws-cdk/aws-ec2/test/cfn-init-element.test.ts b/packages/@aws-cdk/aws-ec2/test/cfn-init-element.test.ts new file mode 100644 index 0000000000000..fd713a4173370 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/cfn-init-element.test.ts @@ -0,0 +1,176 @@ +import * as iam from '@aws-cdk/aws-iam'; +import { App, Duration, Stack } from '@aws-cdk/core'; +import * as ec2 from '../lib'; +import { InitPlatform } from '../lib/private/cfn-init-internal'; + +let app: App; +let stack: Stack; + +beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack', { + env: { account: '1234', region: 'testregion' }, + }); +}); + +describe('InitCommand', () => { + + test('throws error on empty argv command', () => { + expect(() => { ec2.InitCommand.argvCommand([]); }).toThrow(); + }); + + test('auto-generates an indexed command key if none is provided', () => { + // GIVEN + const command = ec2.InitCommand.shellCommand('/bin/sh'); + + // WHEN + const rendered = getElementConfig(command, InitPlatform.LINUX); + + // THEN + expect(rendered['000']).toBeDefined(); + }); + + test('renders a minimalist template when no options are defined', () => { + // GIVEN + const command = ec2.InitCommand.shellCommand('/bin/sh'); + + // WHEN + const rendered = getElementConfig(command, InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + '000': { command: ['/bin/sh'] }, + }); + }); + + test('creates a shell command with all provided options', () => { + // GIVEN + const command = ec2.InitCommand.shellCommand('/bin/sh', { + key: 'command_0', + env: { SECRETS_FILE: '/tmp/secrets' }, + cwd: '/home/myUser', + testCmd: 'test -d /home/myUser', + ignoreErrors: false, + waitAfterCompletion: ec2.InitCommandWaitDuration.of(Duration.hours(2)), + }); + + // WHEN + const rendered = getElementConfig(command, InitPlatform.WINDOWS); + + // THEN + expect(rendered).toEqual({ + command_0: { + command: ['/bin/sh'], + env: { SECRETS_FILE: '/tmp/secrets' }, + cwd: '/home/myUser', + test: 'test -d /home/myUser', + ignoreErrors: false, + waitAfterCompletion: 7200, + }, + }); + }); + + test('creates an argv command with all provided options', () => { + // GIVEN + const command = ec2.InitCommand.argvCommand(['/bin/sh', '-c', 'doStuff'], { + key: 'command_0', + env: { SECRETS_FILE: '/tmp/secrets' }, + cwd: '/home/myUser', + testCmd: 'test -d /home/myUser', + ignoreErrors: false, + waitAfterCompletion: ec2.InitCommandWaitDuration.of(Duration.hours(2)), + }); + + // WHEN + const rendered = getElementConfig(command, InitPlatform.WINDOWS); + + // THEN + expect(rendered).toEqual({ + command_0: { + command: ['/bin/sh', '-c', 'doStuff'], + env: { SECRETS_FILE: '/tmp/secrets' }, + cwd: '/home/myUser', + test: 'test -d /home/myUser', + ignoreErrors: false, + waitAfterCompletion: 7200, + }, + }); + }); + + test('errors when waitAfterCompletion is specified for Linux systems', () => { + // GIVEN + const command = ec2.InitCommand.shellCommand('/bin/sh', { + key: 'command_0', + env: { SECRETS_FILE: '/tmp/secrets' }, + cwd: '/home/myUser', + testCmd: 'test -d /home/myUser', + ignoreErrors: false, + waitAfterCompletion: ec2.InitCommandWaitDuration.of(Duration.hours(2)), + }); + + // THEN + expect(() => { + command._bind(defaultOptions(InitPlatform.LINUX)); + }).toThrow(/'waitAfterCompletion' is only valid for Windows/); + }); + +}); + +describe('InitService', () => { + + test.each([ + ['Linux', 'sysvinit', InitPlatform.LINUX], + ['Windows', 'windows', InitPlatform.WINDOWS], + ])('enable always sets enabled and running to true for %s', (_platform, key, platform) => { + // GIVEN + const service = ec2.InitService.enable('httpd'); + + // WHEN + const rendered = service._bind(defaultOptions(platform)).config; + + // THEN + expect(rendered[key]).toBeDefined(); + expect(rendered[key]).toEqual({ + httpd: { + enabled: true, + ensureRunning: true, + }, + }); + }); + + test.each([ + ['Linux', 'sysvinit', InitPlatform.LINUX], + ['Windows', 'windows', InitPlatform.WINDOWS], + ])('disable returns a minimalist disabled service for %s', (_platform, key, platform) => { + // GIVEN + const service = ec2.InitService.disable('httpd'); + + // WHEN + const rendered = service._bind(defaultOptions(platform)).config; + + // THEN + expect(rendered[key]).toBeDefined(); + expect(rendered[key]).toEqual({ + httpd: { + enabled: false, + ensureRunning: false, + }, + }); + }); + +}); + +function getElementConfig(element: ec2.InitElement, platform: InitPlatform) { + return element._bind(defaultOptions(platform)).config; +} + +function defaultOptions(platform: InitPlatform) { + return { + scope: stack, + index: 0, + platform, + instanceRole: new iam.Role(stack, 'InstanceRole', { + assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), + }), + }; +} diff --git a/packages/@aws-cdk/aws-ec2/test/cfn-init.test.ts b/packages/@aws-cdk/aws-ec2/test/cfn-init.test.ts new file mode 100644 index 0000000000000..cee99ad289a5c --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/cfn-init.test.ts @@ -0,0 +1,237 @@ +import { ResourcePart } from '@aws-cdk/assert'; +import '@aws-cdk/assert/jest'; +import * as iam from '@aws-cdk/aws-iam'; +import { App, Aws, CfnResource, Stack } from '@aws-cdk/core'; +import * as ec2 from '../lib'; +import { InitPlatform } from '../lib/private/cfn-init-internal'; + +let app: App; +let stack: Stack; +let instanceRole: iam.Role; +let resource: CfnResource; +let linuxUserData: ec2.UserData; + +beforeEach(() => { + app = new App(); + stack = new Stack(app, 'Stack', { + env: { account: '1234', region: 'testregion' }, + }); + instanceRole = new iam.Role(stack, 'InstanceRole', { + assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), + }); + resource = new CfnResource(stack, 'Resource', { + type: 'CDK::Test::Resource', + }); + linuxUserData = ec2.UserData.forLinux(); +}); + +test('whole config with restart handles', () => { + // WHEN + const handle = new ec2.InitServiceRestartHandle(); + const config = new ec2.InitConfig([ + ec2.InitCommand.argvCommand(['/bin/true'], { serviceRestartHandles: [handle] }), + ec2.InitService.enable('httpd', { serviceRestartHandle: handle }), + ]); + + // THEN + expect(config._bind(stack, linuxOptions()).config).toEqual(expect.objectContaining({ + services: { + sysvinit: { + httpd: { + enabled: true, + ensureRunning: true, + commands: ['000'], + }, + }, + }, + })); +}); + +test('CloudFormationInit can be added to after instantiation', () => { + // GIVEN + const config = new ec2.InitConfig([]); + const init = ec2.CloudFormationInit.fromConfig(config); + + // WHEN + config.add(ec2.InitCommand.argvCommand(['/bin/true'])); + init._attach(resource, linuxOptions()); + + // THEN + expectMetadataLike({ + 'AWS::CloudFormation::Init': { + config: { + commands: { + '000': { command: ['/bin/true'] }, + }, + }, + }, + }); +}); + +test('empty configs are not rendered', () => { + // GIVEN + const config1 = new ec2.InitConfig([]); + const config2 = new ec2.InitConfig([ + ec2.InitCommand.argvCommand(['/bin/true']), + ]); + + // WHEN + const init = ec2.CloudFormationInit.fromConfigSets({ + configSets: { default: ['config2', 'config1'] }, + configs: { config1, config2 }, + }); + init._attach(resource, linuxOptions()); + + // THEN + expectMetadataLike({ + 'AWS::CloudFormation::Init': { + configSets: { + default: ['config2'], + }, + config2: { + commands: { + '000': { command: ['/bin/true'] }, + }, + }, + }, + }); +}); + +describe('userdata', () => { + let simpleInit: ec2.CloudFormationInit; + beforeEach(() => { + simpleInit = ec2.CloudFormationInit.fromElements( + ec2.InitCommand.argvCommand(['/bin/true']), + ); + }); + + test('linux userdata contains right commands', () => { + // WHEN + simpleInit._attach(resource, linuxOptions()); + + // THEN + const lines = linuxUserData.render().split('\n'); + expectLine(lines, cmdArg('cfn-init', `--region ${Aws.REGION}`)); + expectLine(lines, cmdArg('cfn-init', `--stack ${Aws.STACK_NAME}`)); + expectLine(lines, cmdArg('cfn-init', `--resource ${resource.logicalId}`)); + expectLine(lines, cmdArg('cfn-init', '-c default')); + expectLine(lines, cmdArg('cfn-signal', `--region ${Aws.REGION}`)); + expectLine(lines, cmdArg('cfn-signal', `--stack ${Aws.STACK_NAME}`)); + expectLine(lines, cmdArg('cfn-signal', `--resource ${resource.logicalId}`)); + expectLine(lines, cmdArg('cfn-signal', '-e $?')); + expectLine(lines, cmdArg('cat', 'cfn-init.log')); + expectLine(lines, /fingerprint/); + }); + + test('Windows userdata contains right commands', () => { + // WHEN + const windowsUserData = ec2.UserData.forWindows(); + + simpleInit._attach(resource, { + platform: InitPlatform.WINDOWS, + instanceRole, + userData: windowsUserData, + }); + + // THEN + const lines = windowsUserData.render().split('\n'); + expectLine(lines, cmdArg('cfn-init', `--region ${Aws.REGION}`)); + expectLine(lines, cmdArg('cfn-init', `--stack ${Aws.STACK_NAME}`)); + expectLine(lines, cmdArg('cfn-init', `--resource ${resource.logicalId}`)); + expectLine(lines, cmdArg('cfn-init', '-c default')); + expectLine(lines, cmdArg('cfn-signal', `--region ${Aws.REGION}`)); + expectLine(lines, cmdArg('cfn-signal', `--stack ${Aws.STACK_NAME}`)); + expectLine(lines, cmdArg('cfn-signal', `--resource ${resource.logicalId}`)); + expectLine(lines, cmdArg('cfn-signal', '-e $LASTEXITCODE')); + expectLine(lines, cmdArg('type', 'cfn-init.log')); + expectLine(lines, /fingerprint/); + }); + + test('ignoreFailures disables result code reporting', () => { + // WHEN + simpleInit._attach(resource, { + ...linuxOptions(), + ignoreFailures: true, + }); + + // THEN + const lines = linuxUserData.render().split('\n'); + dontExpectLine(lines, cmdArg('cfn-signal', '-e $?')); + expectLine(lines, cmdArg('cfn-signal', '-e 0')); + }); + + test('can disable log printing', () => { + // WHEN + simpleInit._attach(resource, { + ...linuxOptions(), + printLog: false, + }); + + // THEN + const lines = linuxUserData.render().split('\n'); + dontExpectLine(lines, cmdArg('cat', 'cfn-init.log')); + }); + + test('can disable fingerprinting', () => { + // WHEN + simpleInit._attach(resource, { + ...linuxOptions(), + embedFingerprint: false, + }); + + // THEN + const lines = linuxUserData.render().split('\n'); + dontExpectLine(lines, /fingerprint/); + }); + + test('can request multiple different configsets to be used', () => { + // WHEN + simpleInit._attach(resource, { + ...linuxOptions(), + configSets: ['banana', 'peach'], + }); + + // THEN + const lines = linuxUserData.render().split('\n'); + expectLine(lines, cmdArg('cfn-init', '-c banana,peach')); + }); +}); + +function linuxOptions() { + return { + platform: InitPlatform.LINUX, + instanceRole, + userData: linuxUserData, + }; +} + +function expectMetadataLike(pattern: any) { + expect(stack).toHaveResourceLike('CDK::Test::Resource', { + Metadata: pattern, + }, ResourcePart.CompleteDefinition); +} + +function expectLine(lines: string[], re: RegExp) { + for (const line of lines) { + if (re.test(line)) { return; } + } + + throw new Error(`None of the lines matched '${re}': ${lines.join('\n')}`); +} + +function dontExpectLine(lines: string[], re: RegExp) { + try { + expectLine(lines, re); + } catch (e) { + return; + } + throw new Error(`Found unexpected line matching '${re}': ${lines.join('\n')}`); +} + +function cmdArg(command: string, argument: string) { + return new RegExp(`${escapeRegex(command)}(\.exe)? .*${escapeRegex(argument)}`); +} + +function escapeRegex(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/test/instance.test.ts b/packages/@aws-cdk/aws-ec2/test/instance.test.ts index 2a78f3cdf516e..0d3c06e49ceae 100644 --- a/packages/@aws-cdk/aws-ec2/test/instance.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/instance.test.ts @@ -1,9 +1,11 @@ -import { expect, haveResource } from '@aws-cdk/assert'; +import { arrayWith, expect as cdkExpect, haveResource, ResourcePart, stringLike } from '@aws-cdk/assert'; +import '@aws-cdk/assert/jest'; import { StringParameter } from '@aws-cdk/aws-ssm'; import * as cxschema from '@aws-cdk/cloud-assembly-schema'; import { Stack } from '@aws-cdk/core'; import { nodeunitShim, Test } from 'nodeunit-shim'; -import { AmazonLinuxImage, BlockDeviceVolume, EbsDeviceVolumeType, Instance, InstanceClass, InstanceSize, InstanceType, Vpc } from '../lib'; +import { AmazonLinuxImage, BlockDeviceVolume, CloudFormationInit, + EbsDeviceVolumeType, InitCommand, Instance, InstanceClass, InstanceSize, InstanceType, Vpc } from '../lib'; nodeunitShim({ 'instance is created correctly'(test: Test) { @@ -19,7 +21,7 @@ nodeunitShim({ }); // THEN - expect(stack).to(haveResource('AWS::EC2::Instance', { + cdkExpect(stack).to(haveResource('AWS::EC2::Instance', { InstanceType: 't3.large', })); @@ -39,7 +41,7 @@ nodeunitShim({ }); // THEN - expect(stack).to(haveResource('AWS::EC2::Instance', { + cdkExpect(stack).to(haveResource('AWS::EC2::Instance', { InstanceType: 't3.large', SourceDestCheck: false, })); @@ -61,7 +63,7 @@ nodeunitShim({ param.grantRead(instance); // THEN - expect(stack).to(haveResource('AWS::IAM::Policy', { + cdkExpect(stack).to(haveResource('AWS::IAM::Policy', { PolicyDocument: { Statement: [ { @@ -139,7 +141,7 @@ nodeunitShim({ }); // THEN - expect(stack).to(haveResource('AWS::EC2::Instance', { + cdkExpect(stack).to(haveResource('AWS::EC2::Instance', { BlockDeviceMappings: [ { DeviceName: 'ebs', @@ -285,7 +287,7 @@ nodeunitShim({ }); // THEN - expect(stack).to(haveResource('AWS::EC2::Instance', { + cdkExpect(stack).to(haveResource('AWS::EC2::Instance', { InstanceType: 't3.large', PrivateIpAddress: '10.0.0.2', })); @@ -293,3 +295,54 @@ nodeunitShim({ test.done(); }, }); + +test('add CloudFormation Init to instance', () => { + // GIVEN + const stack = new Stack(); + const vpc = new Vpc(stack, 'VPC'); + new Instance(stack, 'Instance', { + vpc, + machineImage: new AmazonLinuxImage(), + instanceType: InstanceType.of(InstanceClass.T3, InstanceSize.LARGE), + init: CloudFormationInit.fromElements( + InitCommand.shellCommand('echo hello'), + ), + }); + + // THEN + expect(stack).toHaveResource('AWS::EC2::Instance', { + UserData: { + 'Fn::Base64': { + 'Fn::Join': [ '', [ + stringLike('#!/bin/bash\n# fingerprint: *\n(\n set +e\n /opt/aws/bin/cfn-init -v --region '), + { Ref: 'AWS::Region' }, + ' --stack ', + { Ref: 'AWS::StackName' }, + ' --resource InstanceC1063A87 -c default\n /opt/aws/bin/cfn-signal -e $? --region ', + { Ref: 'AWS::Region' }, + ' --stack ', + { Ref: 'AWS::StackName' }, + ' --resource InstanceC1063A87\n cat /var/log/cfn-init.log >&2\n)', + ]], + }, + }, + }); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith({ + Action: [ 'cloudformation:DescribeStackResource', 'cloudformation:SignalResource' ], + Effect: 'Allow', + Resource: { Ref: 'AWS::StackId' }, + }), + Version: '2012-10-17', + }, + }); + cdkExpect(stack).to(haveResource('AWS::EC2::Instance', { + CreationPolicy: { + ResourceSignal: { + Count: 1, + Timeout: 'PT5M', + }, + }, + }, ResourcePart.CompleteDefinition)); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/test/integ.instance-init.expected.json b/packages/@aws-cdk/aws-ec2/test/integ.instance-init.expected.json new file mode 100644 index 0000000000000..8a1144ff37fc2 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/integ.instance-init.expected.json @@ -0,0 +1,181 @@ +{ + "Resources": { + "Instance2InstanceSecurityGroupC6129B1D": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "integ-init/Instance2/InstanceSecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "integ-init/Instance2" + } + ], + "VpcId": "vpc-60900905" + } + }, + "Instance2InstanceRole03DD7CB2": { + "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": "integ-init/Instance2" + } + ] + } + }, + "Instance2InstanceRoleDefaultPolicy610B37CD": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "cloudformation:DescribeStackResource", + "cloudformation:SignalResource" + ], + "Effect": "Allow", + "Resource": { + "Ref": "AWS::StackId" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "Instance2InstanceRoleDefaultPolicy610B37CD", + "Roles": [ + { + "Ref": "Instance2InstanceRole03DD7CB2" + } + ] + } + }, + "Instance2InstanceProfile582F915C": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "Instance2InstanceRole03DD7CB2" + } + ] + } + }, + "Instance255F35265e4fe939d8bb9e8f3": { + "Type": "AWS::EC2::Instance", + "Properties": { + "AvailabilityZone": "us-east-1a", + "IamInstanceProfile": { + "Ref": "Instance2InstanceProfile582F915C" + }, + "ImageId": { + "Ref": "SsmParameterValueawsserviceamiamazonlinuxlatestamznamihvmx8664gp2C96584B6F00A464EAD1953AFF4B05118Parameter" + }, + "InstanceType": "t2.micro", + "SecurityGroupIds": [ + { + "Fn::GetAtt": [ + "Instance2InstanceSecurityGroupC6129B1D", + "GroupId" + ] + } + ], + "SubnetId": "subnet-e19455ca", + "Tags": [ + { + "Key": "Name", + "Value": "integ-init/Instance2" + } + ], + "UserData": { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "#!/bin/bash\n# fingerprint: 44c8061df9fadf16\n(\n set +e\n /opt/aws/bin/cfn-init -v --region ", + { + "Ref": "AWS::Region" + }, + " --stack ", + { + "Ref": "AWS::StackName" + }, + " --resource Instance255F35265e4fe939d8bb9e8f3 -c default\n /opt/aws/bin/cfn-signal -e $? --region ", + { + "Ref": "AWS::Region" + }, + " --stack ", + { + "Ref": "AWS::StackName" + }, + " --resource Instance255F35265e4fe939d8bb9e8f3\n cat /var/log/cfn-init.log >&2\n)" + ] + ] + } + } + }, + "DependsOn": [ + "Instance2InstanceRoleDefaultPolicy610B37CD", + "Instance2InstanceRole03DD7CB2" + ], + "CreationPolicy": { + "ResourceSignal": { + "Count": 1, + "Timeout": "PT30M" + } + }, + "Metadata": { + "AWS::CloudFormation::Init": { + "configSets": { + "default": [ + "config" + ] + }, + "config": { + "commands": { + "000": { + "command": [ + "/bin/true" + ] + } + } + } + } + } + } + }, + "Parameters": { + "SsmParameterValueawsserviceamiamazonlinuxlatestamznamihvmx8664gp2C96584B6F00A464EAD1953AFF4B05118Parameter": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-gp2" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/test/integ.instance-init.ts b/packages/@aws-cdk/aws-ec2/test/integ.instance-init.ts new file mode 100644 index 0000000000000..51d5b1bf07534 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/integ.instance-init.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import * as cdk from '@aws-cdk/core'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as ec2 from '../lib'; + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'integ-init', { + env: { + account: process.env.CDK_INTEG_ACCOUNT || process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_INTEG_REGION || process.env.CDK_DEFAULT_REGION, + }, +}); + +const vpc = ec2.Vpc.fromLookup(stack, 'VPC', { isDefault: true }); + +const tmpDir = fs.mkdtempSync('/tmp/cfn-init-test'); +fs.writeFileSync(path.resolve(tmpDir, 'testFile'), 'Hello World!\n'); + +new ec2.Instance(stack, 'Instance2', { + vpc, + vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, + instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.MICRO), + machineImage: new ec2.AmazonLinuxImage(), + userDataCausesReplacement: true, + initOptions: { + timeout: cdk.Duration.minutes(30), + }, + init: ec2.CloudFormationInit.fromElements( + ec2.InitCommand.argvCommand(['/bin/true']), + ), +}); + +app.synth(); \ No newline at end of file