diff --git a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts index 2ea670b9c394d..4a1be5cc195ce 100644 --- a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts +++ b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts @@ -160,4 +160,4 @@ export function isSuperObject(superObj: any, pattern: any, errors: string[] = [] errors.push(inspection.failureReason); } return ret; -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-autoscaling/README.md b/packages/@aws-cdk/aws-autoscaling/README.md index 44331a3b5b1c3..651030d644a52 100644 --- a/packages/@aws-cdk/aws-autoscaling/README.md +++ b/packages/@aws-cdk/aws-autoscaling/README.md @@ -1,4 +1,5 @@ ## Amazon EC2 Auto Scaling Construct Library + --- @@ -11,8 +12,6 @@ This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project. -### Fleet - ### Auto Scaling Group An `AutoScalingGroup` represents a number of instances on which you run your code. You @@ -224,6 +223,39 @@ autoScalingGroup.scaleOnSchedule('AllowDownscalingAtNight', { }); ``` +### Configuring Instances using CloudFormation Init + +It is possible to use the CloudFormation Init mechanism to configure the +instances in the AutoScalingGroup. You can write files to it, run commands, +start services, etc. See the documentation of +[AWS::CloudFormation::Init](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-init.html) +and the documentation of CDK's `aws-ec2` library for more information. + +When you specify a CloudFormation Init configuration for an AutoScalingGroup: + +* you *must* also specify `signals` to configure how long CloudFormation + should wait for the instances to successfully configure themselves. +* you *should* also specify an `updatePolicy` to configure how instances + should be updated when the AutoScalingGroup is updated (for example, + when the AMI is updated). + +Here's an example of using CloudFormation Init to write a file to the +instance hosts on startup: + +```ts +new autoscaling.AutoScalingGroup(this, 'ASG', { + // ... + + init: ec2.CloudFormationInit.fromElements( + ec2.InitFile.fromString('/etc/my_instance', 'This got written during instance startup'), + ), + signals: autoscaling.Signals.waitForAll({ + timeout: Duration.minutes(10), + }), + updatePolicy: autoscaling.UpdatePolicy.rollingUpdate(), +}); +``` + ### Allowing Connections See the documentation of the `@aws-cdk/aws-ec2` package for more information @@ -241,7 +273,6 @@ To disable detailed instance monitoring, specify `instanceMonitoring` property for the `AutoscalingGroup` resource as `Monitoring.BASIC`. Otherwise detailed monitoring will be enabled. - ### Future work - [ ] CloudWatch Events (impossible to add currently as the AutoScalingGroup ARN is diff --git a/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts b/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts index 04c63308cc2aa..d4a4ad4d1cd30 100644 --- a/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts +++ b/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts @@ -6,8 +6,9 @@ import * as iam from '@aws-cdk/aws-iam'; import * as sns from '@aws-cdk/aws-sns'; import { - CfnAutoScalingRollingUpdate, Construct, Duration, Fn, IResource, Lazy, PhysicalName, Resource, Stack, - Tag, Tokenization, withResolved, + CfnAutoScalingRollingUpdate, CfnCreationPolicy, CfnUpdatePolicy, Construct, + Duration, Fn, IResource, Lazy, PhysicalName, Resource, Stack, Tag, + Tokenization, withResolved, } from '@aws-cdk/core'; import { CfnAutoScalingGroup, CfnAutoScalingGroupProps, CfnLaunchConfiguration } from './autoscaling.generated'; import { BasicLifecycleHookProps, LifecycleHook } from './lifecycle-hook'; @@ -114,6 +115,7 @@ export interface CommonAutoScalingGroupProps { * is done and only new instances are launched with the new config. * * @default UpdateType.None + * @deprecated Use `updatePolicy` instead */ readonly updateType?: UpdateType; @@ -123,6 +125,7 @@ export interface CommonAutoScalingGroupProps { * Only used if updateType == UpdateType.RollingUpdate. * * @default - RollingUpdateConfiguration with defaults. + * @deprecated Use `updatePolicy` instead */ readonly rollingUpdateConfiguration?: RollingUpdateConfiguration; @@ -133,6 +136,7 @@ export interface CommonAutoScalingGroupProps { * many instances must signal success for the update to succeed. * * @default minSuccessfulInstancesPercent + * @deprecated Use `signals` instead */ readonly replacingUpdateMinSuccessfulInstancesPercent?: number; @@ -152,7 +156,8 @@ export interface CommonAutoScalingGroupProps { /** * How many ResourceSignal calls CloudFormation expects before the resource is considered created * - * @default 1 + * @default 1 if resourceSignalTimeout is set, 0 otherwise + * @deprecated Use `signals` instead. */ readonly resourceSignalCount?: number; @@ -161,7 +166,8 @@ export interface CommonAutoScalingGroupProps { * * The maximum value is 43200 (12 hours). * - * @default Duration.minutes(5) + * @default Duration.minutes(5) if resourceSignalCount is set, N/A otherwise + * @deprecated Use `signals` instead. */ readonly resourceSignalTimeout?: Duration; @@ -235,13 +241,48 @@ export interface CommonAutoScalingGroupProps { */ readonly instanceMonitoring?: Monitoring; + /** + * Configure waiting for signals during deployment + * + * 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. + * + * Without waiting for signals, the CloudFormation deployment will proceed as + * soon as the AutoScalingGroup has been created or updated but before the + * instances in the group have been started. + * + * For example, to have instances wait for an Elastic Load Balancing health check before + * they signal success, add a health-check verification by using the + * cfn-init helper script. For an example, see the verify_instance_health + * command in the Auto Scaling rolling updates sample template: + * + * https://github.com/awslabs/aws-cloudformation-templates/blob/master/aws/services/AutoScaling/AutoScalingRollingUpdates.yaml + * + * @default - Do not wait for signals + */ + readonly signals?: Signals; + + /** + * What to do when an AutoScalingGroup's instance configuration is changed + * + * This is applied when any of the settings on the ASG are changed that + * affect how the instances should be created (VPC, instance type, startup + * scripts, etc.). It indicates how the existing instances should be + * replaced with new instances matching the new config. By default, nothing + * is done and only new instances are launched with the new config. + * + * @default - Do not restart existing instances + */ + readonly updatePolicy?: UpdatePolicy; + /** * The name of the Auto Scaling group. This name must be unique per Region per account. * * @default - Auto generated by CloudFormation */ readonly autoScalingGroupName?: string; - } /** @@ -294,6 +335,263 @@ export interface AutoScalingGroupProps extends CommonAutoScalingGroupProps { * @default A role will automatically be created, it can be accessed via the `role` property */ readonly role?: iam.IRole; + + /** + * Apply the given CloudFormation Init configuration to the instances in the AutoScalingGroup at startup + * + * If you specify `init`, you must also specify `signals` to configure + * the number of instances to wait for and the timeout for waiting for the + * init process. + * + * @default - no CloudFormation init + */ + readonly init?: ec2.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; +} + +/** + * Configure whether the AutoScalingGroup waits for signals + * + * If you do configure waiting for signals, you should make sure the instances + * invoke `cfn-signal` somewhere in their UserData to signal that they have + * started up (either successfully or unsuccessfully). + * + * Signals are used both during intial creation and subsequent updates. + */ +export abstract class Signals { + /** + * Wait for the desiredCapacity of the AutoScalingGroup amount of signals to have been received + * + * If no desiredCapacity has been configured, wait for minCapacity signals intead. + * + * This number is used during initial creation and during replacing updates. + * During rolling updates, all updated instances must send a signal. + */ + public static waitForAll(options: SignalsOptions = {}): Signals { + validatePercentage(options.minSuccessPercentage); + return new class extends Signals { + public renderCreationPolicy(renderOptions: RenderSignalsOptions): CfnCreationPolicy { + return this.doRender(options, renderOptions.desiredCapacity ?? renderOptions.minCapacity); + } + }(); + } + + /** + * Wait for the minCapacity of the AutoScalingGroup amount of signals to have been received + * + * This number is used during initial creation and during replacing updates. + * During rolling updates, all updated instances must send a signal. + */ + public static waitForMinCapacity(options: SignalsOptions = {}): Signals { + validatePercentage(options.minSuccessPercentage); + return new class extends Signals { + public renderCreationPolicy(renderOptions: RenderSignalsOptions): CfnCreationPolicy { + return this.doRender(options, renderOptions.minCapacity); + } + }(); + } + + /** + * Wait for a specific amount of signals to have been received + * + * You should send one signal per instance, so this represents the number of + * instances to wait for. + * + * This number is used during initial creation and during replacing updates. + * During rolling updates, all updated instances must send a signal. + */ + public static waitForCount(count: number, options: SignalsOptions = {}): Signals { + validatePercentage(options.minSuccessPercentage); + return new class extends Signals { + public renderCreationPolicy(): CfnCreationPolicy { + return this.doRender(options, count); + } + }(); + } + + /** + * Render the ASG's CreationPolicy + */ + public abstract renderCreationPolicy(renderOptions: RenderSignalsOptions): CfnCreationPolicy; + + /** + * Helper to render the actual creation policy, as the logic between them is quite similar + */ + protected doRender(options: SignalsOptions, count?: number): CfnCreationPolicy { + const minSuccessfulInstancesPercent = validatePercentage(options.minSuccessPercentage); + return { + ...options.minSuccessPercentage !== undefined ? { autoScalingCreationPolicy: { minSuccessfulInstancesPercent } } : { }, + resourceSignal: { + count, + timeout: options.timeout?.toIsoString(), + }, + }; + } + +} + +/** + * Input for Signals.renderCreationPolicy + */ +export interface RenderSignalsOptions { + /** + * The desiredCapacity of the ASG + * + * @default - desired capacity not configured + */ + readonly desiredCapacity?: number; + + /** + * The minSize of the ASG + * + * @default - minCapacity not configured + */ + readonly minCapacity?: number; +} + +/** + * Customization options for Signal handling + */ +export interface SignalsOptions { + /** + * The percentage of signals that need to be successful + * + * If this number is less than 100, a percentage of signals may be failure + * signals while still succeeding the creation or update in CloudFormation. + * + * @default 100 + */ + readonly minSuccessPercentage?: number; + + /** + * How long to wait for the signals to be sent + * + * This should reflect how long it takes your instances to start up + * (including instance start time and instance initialization time). + * + * @default Duration.minutes(5) + */ + readonly timeout?: Duration; +} + +/** + * How existing instances should be update + */ +export abstract class UpdatePolicy { + /** + * Create a new AutoScalingGroup and switch over to it + */ + public static replacingUpdate(): UpdatePolicy { + return new class extends UpdatePolicy { + public renderUpdatePolicy(): CfnUpdatePolicy { + return { + autoScalingReplacingUpdate: { willReplace: true }, + }; + } + }(); + } + + /** + * Replace the instances in the AutoScalingGroup one by one + */ + public static rollingUpdate(options: RollingUpdateOptions = {}): UpdatePolicy { + const minSuccessPercentage = validatePercentage(options.minSuccessPercentage); + + return new class extends UpdatePolicy { + public renderUpdatePolicy(renderOptions: RenderUpdateOptions): CfnUpdatePolicy { + return { + autoScalingRollingUpdate: { + maxBatchSize: options.maxBatchSize, + minInstancesInService: options.minInstancesInService, + suspendProcesses: options.suspendProcesses ?? DEFAULT_SUSPEND_PROCESSES, + minSuccessfulInstancesPercent: + minSuccessPercentage ?? renderOptions.creationPolicy?.autoScalingCreationPolicy?.minSuccessfulInstancesPercent, + waitOnResourceSignals: options.waitOnResourceSignals ?? renderOptions.creationPolicy?.resourceSignal !== undefined ? true : undefined, + pauseTime: options.pauseTime?.toIsoString() ?? renderOptions.creationPolicy?.resourceSignal?.timeout, + }, + }; + } + }(); + } + + /** + * Render the ASG's CreationPolicy + */ + public abstract renderUpdatePolicy(renderOptions: RenderUpdateOptions): CfnUpdatePolicy; +} + +/** + * Options for rendering UpdatePolicy + */ +export interface RenderUpdateOptions { + /** + * The Creation Policy already created + * + * @default - no CreationPolicy configured + */ + readonly creationPolicy?: CfnCreationPolicy; +} + +/** + * Options for customizing the rolling update + */ +export interface RollingUpdateOptions { + /** + * The maximum number of instances that AWS CloudFormation updates at once. + * + * This number affects the speed of the replacement. + * + * @default 1 + */ + readonly maxBatchSize?: number; + + /** + * The minimum number of instances that must be in service before more instances are replaced. + * + * This number affects the speed of the replacement. + * + * @default 0 + */ + readonly minInstancesInService?: number; + + /** + * Specifies the Auto Scaling processes to suspend during a stack update. + * + * Suspending processes prevents Auto Scaling from interfering with a stack + * update. + * + * @default HealthCheck, ReplaceUnhealthy, AZRebalance, AlarmNotification, ScheduledActions. + */ + readonly suspendProcesses?: ScalingProcess[]; + + /** + * Specifies whether the Auto Scaling group waits on signals from new instances during an update. + * + * @default true if you configured `signals` on the AutoScalingGroup, false otherwise + */ + readonly waitOnResourceSignals?: boolean; + + /** + * The pause time after making a change to a batch of instances. + * + * @default - The `timeout` configured for `signals` on the AutoScalingGroup + */ + readonly pauseTime?: Duration; + + /** + * The percentage of instances that must signal success for the update to succeed. + * + * @default - The `minSuccessPercentage` configured for `signals` on the AutoScalingGroup + */ + readonly minSuccessPercentage?: number; } abstract class AutoScalingGroupBase extends Resource implements IAutoScalingGroup { @@ -492,6 +790,10 @@ export class AutoScalingGroup extends AutoScalingGroupBase implements physicalName: props.autoScalingGroupName, }); + if (props.initOptions && !props.init) { + throw new Error('Setting \'initOptions\' requires that \'init\' is also set'); + } + this.securityGroup = props.securityGroup || new ec2.SecurityGroup(this, 'InstanceSecurityGroup', { vpc: props.vpc, allowAllOutbound: props.allowAllOutbound !== false, @@ -615,7 +917,10 @@ export class AutoScalingGroup extends AutoScalingGroupBase implements }); this.node.defaultChild = this.autoScalingGroup; - this.applyUpdatePolicies(props); + this.applyUpdatePolicies(props, { desiredCapacity, minCapacity }); + if (props.init) { + this.applyCloudFormationInit(props.init, props.initOptions); + } this.spotPrice = props.spotPrice; } @@ -679,10 +984,79 @@ export class AutoScalingGroup extends AutoScalingGroupBase implements this.role.addToPolicy(statement); } + /** + * Use a CloudFormation Init configuration at instance startup + * + * This does the following: + * + * - Attaches the CloudFormation Init metadata to the AutoScalingGroup resource. + * - Add commands to the UserData to run `cfn-init` and `cfn-signal`. + * - Update the instance's CreationPolicy to wait for `cfn-init` to finish + * before reporting success. + */ + public applyCloudFormationInit(init: ec2.CloudFormationInit, options: ApplyCloudFormationInitOptions = {}) { + if (!this.autoScalingGroup.cfnOptions.creationPolicy?.resourceSignal) { + throw new Error('When applying CloudFormationInit, you must also configure signals by supplying \'signals\' at instantiation time.'); + } + + const platform = this.osType === ec2.OperatingSystemType.WINDOWS ? ec2.InitPlatform.WINDOWS : ec2.InitPlatform.LINUX; + init.attach(this.autoScalingGroup, { + platform, + instanceRole: this.role, + userData: this.userData, + configSets: options.configSets, + embedFingerprint: options.embedFingerprint, + printLog: options.printLog, + ignoreFailures: options.ignoreFailures, + }); + } + /** * Apply CloudFormation update policies for the AutoScalingGroup */ - private applyUpdatePolicies(props: AutoScalingGroupProps) { + private applyUpdatePolicies(props: AutoScalingGroupProps, signalOptions: RenderSignalsOptions) { + // Make sure people are not using the old and new properties together + const oldProps: Array = [ + 'updateType', + 'rollingUpdateConfiguration', + 'resourceSignalCount', + 'resourceSignalTimeout', + 'replacingUpdateMinSuccessfulInstancesPercent', + ]; + for (const prop of oldProps) { + if ((props.signals || props.updatePolicy) && props[prop] !== undefined) { + throw new Error(`Cannot set 'signals'/'updatePolicy' and '${prop}' together. Prefer 'signals'/'updatePolicy'`); + } + } + + if (props.signals || props.updatePolicy) { + this.applyNewSignalUpdatePolicies(props, signalOptions); + } else { + this.applyLegacySignalUpdatePolicies(props); + } + + // This the following is technically part of the "update policy" but it's also a completely + // separate aspect of rolling/replacing update, so it's just its own top-level property. + // Default is 'true' because that's what you're most likely to want + if (props.ignoreUnmodifiedSizeProperties !== false) { + this.autoScalingGroup.cfnOptions.updatePolicy = { + ...this.autoScalingGroup.cfnOptions.updatePolicy, + autoScalingScheduledAction: { ignoreUnmodifiedGroupSizeProperties: true }, + }; + } + } + + /** + * Use 'signals' and 'updatePolicy' to determine the creation and update policies + */ + private applyNewSignalUpdatePolicies(props: AutoScalingGroupProps, signalOptions: RenderSignalsOptions) { + this.autoScalingGroup.cfnOptions.creationPolicy = props.signals?.renderCreationPolicy(signalOptions); + this.autoScalingGroup.cfnOptions.updatePolicy = props.updatePolicy?.renderUpdatePolicy({ + creationPolicy: this.autoScalingGroup.cfnOptions.creationPolicy, + }); + } + + private applyLegacySignalUpdatePolicies(props: AutoScalingGroupProps) { if (props.updateType === UpdateType.REPLACING_UPDATE) { this.autoScalingGroup.cfnOptions.updatePolicy = { ...this.autoScalingGroup.cfnOptions.updatePolicy, @@ -711,14 +1085,6 @@ export class AutoScalingGroup extends AutoScalingGroupBase implements }; } - // undefined is treated as 'true' - if (props.ignoreUnmodifiedSizeProperties !== false) { - this.autoScalingGroup.cfnOptions.updatePolicy = { - ...this.autoScalingGroup.cfnOptions.updatePolicy, - autoScalingScheduledAction: { ignoreUnmodifiedGroupSizeProperties: true }, - }; - } - if (props.resourceSignalCount !== undefined || props.resourceSignalTimeout !== undefined) { this.autoScalingGroup.cfnOptions.creationPolicy = { ...this.autoScalingGroup.cfnOptions.creationPolicy, @@ -744,6 +1110,8 @@ export class AutoScalingGroup extends AutoScalingGroupBase implements /** * The type of update to perform on instances in this AutoScalingGroup + * + * @deprecated Use UpdatePolicy instead */ export enum UpdateType { /** @@ -930,6 +1298,11 @@ export enum ScalingProcess { ADD_TO_LOAD_BALANCER = 'AddToLoadBalancer' } +// Recommended list of processes to suspend from here: +// https://aws.amazon.com/premiumsupport/knowledge-center/auto-scaling-group-rolling-updates/ +const DEFAULT_SUSPEND_PROCESSES = [ScalingProcess.HEALTH_CHECK, ScalingProcess.REPLACE_UNHEALTHY, ScalingProcess.AZ_REBALANCE, + ScalingProcess.ALARM_NOTIFICATION, ScalingProcess.SCHEDULED_ACTIONS]; + /** * EC2 Heath check options */ @@ -998,11 +1371,7 @@ function renderRollingUpdateConfig(config: RollingUpdateConfiguration = {}): Cfn minSuccessfulInstancesPercent: validatePercentage(config.minSuccessfulInstancesPercent), waitOnResourceSignals, pauseTime: pauseTime && pauseTime.toISOString(), - suspendProcesses: config.suspendProcesses !== undefined ? config.suspendProcesses : - // Recommended list of processes to suspend from here: - // https://aws.amazon.com/premiumsupport/knowledge-center/auto-scaling-group-rolling-updates/ - [ScalingProcess.HEALTH_CHECK, ScalingProcess.REPLACE_UNHEALTHY, ScalingProcess.AZ_REBALANCE, - ScalingProcess.ALARM_NOTIFICATION, ScalingProcess.SCHEDULED_ACTIONS], + suspendProcesses: config.suspendProcesses ?? DEFAULT_SUSPEND_PROCESSES, }; } @@ -1146,3 +1515,58 @@ function synthesizeBlockDeviceMappings(construct: Construct, blockDevices: Block }; }); } + +/** + * Options for applying CloudFormation init to an instance or instance group + */ +export interface ApplyCloudFormationInitOptions { + /** + * ConfigSet to activate + * + * @default ['default'] + */ + readonly configSets?: string[]; + + /** + * 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 and + * instances will be replaced (given an UpdatePolicy has been configured on + * the AutoScalingGroup). + * + * If `false`, no such hash will be embedded, and if the CloudFormation Init + * config changes nothing will happen to the running instances. 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-autoscaling/test/cfn-init.test.ts b/packages/@aws-cdk/aws-autoscaling/test/cfn-init.test.ts new file mode 100644 index 0000000000000..f4ea3907b642c --- /dev/null +++ b/packages/@aws-cdk/aws-autoscaling/test/cfn-init.test.ts @@ -0,0 +1,219 @@ +import { arrayWith, expect, haveResourceLike, ResourcePart } from '@aws-cdk/assert'; +import * as ec2 from '@aws-cdk/aws-ec2'; +import { Duration, Stack } from '@aws-cdk/core'; +import * as autoscaling from '../lib'; + +interface BaseProps { + vpc: ec2.Vpc; + machineImage: ec2.IMachineImage; + instanceType: ec2.InstanceType; + desiredCapacity: number; + minCapacity: number; +} + +let stack: Stack; +let vpc: ec2.Vpc; +let baseProps: BaseProps; + +beforeEach(() => { + stack = new Stack(); + vpc = new ec2.Vpc(stack, 'Vpc'); + + baseProps = { + vpc, + machineImage: new ec2.AmazonLinuxImage(), + instanceType: ec2.InstanceType.of(ec2.InstanceClass.M4, ec2.InstanceSize.MICRO), + desiredCapacity: 5, + minCapacity: 2, + }; +}); + +test('Signals.waitForAll uses desiredCapacity if available', () => { + // WHEN + new autoscaling.AutoScalingGroup(stack, 'Asg', { + ...baseProps, + signals: autoscaling.Signals.waitForAll(), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + CreationPolicy: { + ResourceSignal: { + Count: 5, + }, + }, + }, ResourcePart.CompleteDefinition)); +}); + +test('Signals.waitForAll uses minCapacity if desiredCapacity is not available', () => { + // WHEN + new autoscaling.AutoScalingGroup(stack, 'Asg', { + ...baseProps, + desiredCapacity: undefined, + signals: autoscaling.Signals.waitForAll(), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + CreationPolicy: { + ResourceSignal: { + Count: 2, + }, + }, + }, ResourcePart.CompleteDefinition)); +}); + +test('Signals.waitForMinCapacity uses minCapacity', () => { + // WHEN + new autoscaling.AutoScalingGroup(stack, 'Asg', { + ...baseProps, + signals: autoscaling.Signals.waitForMinCapacity(), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + CreationPolicy: { + ResourceSignal: { + Count: 2, + }, + }, + }, ResourcePart.CompleteDefinition)); +}); + +test('Signals.waitForCount uses given number', () => { + // WHEN + new autoscaling.AutoScalingGroup(stack, 'Asg', { + ...baseProps, + signals: autoscaling.Signals.waitForCount(10), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + CreationPolicy: { + ResourceSignal: { + Count: 10, + }, + }, + }, ResourcePart.CompleteDefinition)); +}); + +test('UpdatePolicy.rollingUpdate() still correctly inserts IgnoreUnmodifiedGroupSizeProperties', () => { + // WHEN + new autoscaling.AutoScalingGroup(stack, 'Asg', { + ...baseProps, + updatePolicy: autoscaling.UpdatePolicy.rollingUpdate(), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + UpdatePolicy: { + AutoScalingScheduledAction: { + IgnoreUnmodifiedGroupSizeProperties: true, + }, + }, + }, ResourcePart.CompleteDefinition)); +}); + +test('UpdatePolicy.rollingUpdate() with Signals uses those defaults', () => { + // WHEN + new autoscaling.AutoScalingGroup(stack, 'Asg', { + ...baseProps, + signals: autoscaling.Signals.waitForCount(10, { + minSuccessPercentage: 50, + timeout: Duration.minutes(30), + }), + updatePolicy: autoscaling.UpdatePolicy.rollingUpdate(), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + CreationPolicy: { + AutoScalingCreationPolicy: { + MinSuccessfulInstancesPercent: 50, + }, + ResourceSignal: { + Count: 10, + Timeout: 'PT30M', + }, + }, + UpdatePolicy: { + AutoScalingRollingUpdate: { + MinSuccessfulInstancesPercent: 50, + PauseTime: 'PT30M', + WaitOnResourceSignals: true, + }, + }, + }, ResourcePart.CompleteDefinition)); +}); + +test('UpdatePolicy.rollingUpdate() without Signals', () => { + // WHEN + new autoscaling.AutoScalingGroup(stack, 'Asg', { + ...baseProps, + updatePolicy: autoscaling.UpdatePolicy.rollingUpdate(), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + UpdatePolicy: { + AutoScalingRollingUpdate: { + }, + }, + }, ResourcePart.CompleteDefinition)); +}); + +test('UpdatePolicy.replacingUpdate() renders correct UpdatePolicy', () => { + // WHEN + new autoscaling.AutoScalingGroup(stack, 'Asg', { + ...baseProps, + updatePolicy: autoscaling.UpdatePolicy.replacingUpdate(), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AutoScaling::AutoScalingGroup', { + UpdatePolicy: { + AutoScalingReplacingUpdate: { + WillReplace: true, + }, + }, + }, ResourcePart.CompleteDefinition)); +}); + +test('Using init config in ASG leads to correct UserData and permissions', () => { + // WHEN + new autoscaling.AutoScalingGroup(stack, 'Asg', { + ...baseProps, + init: ec2.CloudFormationInit.fromElements( + ec2.InitCommand.shellCommand('echo hihi'), + ), + signals: autoscaling.Signals.waitForAll(), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::AutoScaling::LaunchConfiguration', { + UserData: { + 'Fn::Base64': { + 'Fn::Join': [ '', [ + '#!/bin/bash\n# fingerprint: 6661ddee2afda062\n(\n set +e\n /opt/aws/bin/cfn-init -v --region ', + { Ref: 'AWS::Region' }, + ' --stack ', + { Ref: 'AWS::StackName' }, + ' --resource AsgASGD1D7B4E2 -c default\n /opt/aws/bin/cfn-signal -e $? --region ', + { Ref: 'AWS::Region' }, + ' --stack ', + { Ref: 'AWS::StackName' }, + ' --resource AsgASGD1D7B4E2\n cat /var/log/cfn-init.log >&2\n)', + ]], + }, + }, + })); + expect(stack).to(haveResourceLike('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith({ + Action: [ 'cloudformation:DescribeStackResource', 'cloudformation:SignalResource' ], + Effect: 'Allow', + Resource: { Ref: 'AWS::StackId' }, + }), + }, + })); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/README.md b/packages/@aws-cdk/aws-ec2/README.md index 49cf5949caa9c..e7f1f03836355 100644 --- a/packages/@aws-cdk/aws-ec2/README.md +++ b/packages/@aws-cdk/aws-ec2/README.md @@ -50,8 +50,8 @@ distinguishes three different subnet types: 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 @@ -438,7 +438,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): @@ -491,14 +493,14 @@ 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 @@ -528,7 +530,7 @@ 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. @@ -540,7 +542,7 @@ 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 @@ -551,7 +553,87 @@ 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', { + // Showing the most complex setup, if you have simpler requirements + // you can use `CloudFormationInit.fromElements()`. + init: ec2.CloudFormationInit.fromConfigSets({ + configSets: { + // Applies the configs below in this order + default: ['yumPreinstall', 'config'], + }, + configs: { + yumPreinstall: new ec2.InitConfig([ + // Install an Amazon Linux package using yum + ec2.InitPackage.yum('git'), + ]), + config: new ec2.InitConfig([ + // Create a JSON file from tokens (can also create other files) + ec2.InitFile.fromObject('/etc/stack.json', { + stackId: stack.stackId, + stackName: stack.stackName, + region: stack.region, + }), + + // Create a group and user + ec2.InitGroup.fromName('my-group'), + ec2.InitUser.fromName('my-user'), + + // Install an RPM from the internet + ec2.InitPackage.rpm('http://mirrors.ukfast.co.uk/sites/dl.fedoraproject.org/pub/epel/8/Everything/x86_64/Packages/r/rubygem-git-1.5.0-2.el8.noarch.rpm'), + ]), + }, + }), + 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), + }, + + // Optional but recommended: starts a new instance with the new config + // if the config changes. + userDataCausesReplacement: true, +}); +``` + +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 writes a config file for nginx, extracts an archive to the root directory, and then +restarts nginx so that it picks up the new config and files: + +```ts +const handle = new ec2.InitServiceRestartHandle(); + +ec2.CloudFormationInit.fromElements( + ec2.InitFile.fromString('/etc/nginx/nginx.conf', '...', { serviceRestartHandles: [handle] }), + ec2.InitSource.fromBucket('/var/www/html', myBucket, 'html.zip', { 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/) @@ -586,7 +668,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 @@ -609,7 +691,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. @@ -633,7 +715,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: @@ -650,7 +732,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 diff --git a/packages/@aws-cdk/aws-ec2/adr/ec2-001-cfn-init-object-model.md b/packages/@aws-cdk/aws-ec2/adr/ec2-001-cfn-init-object-model.md new file mode 100644 index 0000000000000..747dab8d89e9c --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/adr/ec2-001-cfn-init-object-model.md @@ -0,0 +1,193 @@ +EC2-001: cfn-init object model +============================== + +Status +------ + +Active + +Context +------- + +Config sections in [`AWS::CloudFormation::Init` +blocks](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-init.html) +can be one of commands, files, groups, packages, services, sources and users. + +Each one of specified as a map of `{ key: { ...details... } }`. + +For some config types the **key** is significant (such as which file to +write), for others is it not and is merely used for ordering. Some config +types have a strong need for case classes, others are fine as a collection +of data and can be modeled by structs. Some should admit Assets +in an easy way which requires a `bind` pattern for (a) binding an `Asset` object +to the construct tree and (b) granting permissions. + +We'd like to give users the option to leave out keys that are unnecessary, +and have the API be consistent between the different config types. + +Let's look at the different config types, in order of complexity. + +* **group** - key is semantic, mapping group name => group id. +* **user** - key is semantic, mapping user name => (uid, homedir, groups) +* **command** - key is not semantic (just id+list order). Has 2 variants, specify + command as shell string or as argv array. 5 additional customization + options. +* **source** - key is semantic, mapping directory name => url of ZIP. Should admit assets, + so needs a class. +* **package** - depends on type of package (and platform). Case classes make this nicer. + - rpm, msi: key is not semantic, argument is a location for the archive. + - yum, apt, rubygems, python: key is the package name, argument are optional version number(s) +* **service** - key is semantic. Select service to either disables, enables, and/or optionally restarts services depending + on other things cfn-init does (like touching files). Needs to encode dependencies on other configuration options. + Has 2 fields that seem like it makes no sense for them to have different boolean values. +* **file** - key is semantic, indicating the file to create. Allows many diferent use cases which are + best served by case classes, and assets need a class. + - create file from literal string + - create file from base64 encoded string + - create a symlink from a literal string + - create file from URL download + - create JSON file from object + - (cdk specific) read file from disk and make inline file from it, mirroring executable bit + - (cdk specific) read file from disk and make asset from it, mirroring executable bit + - (cdk specific) I guess read symlink from disk but this seems less valuable + - on all of these we can configure owner, group, mode, mustache template substitution + +File sources from URLs also allow authentication, which is a good feature to support eventually. +Easiest to do if we have classes for them. + +It seems that couple of configs definitely *require* classes, and it would be weird to mix +structs and classes for the different config types. That means we use classes for all of them. + +It would also be weird to mix factory functions and `new` for instantiation. Since we definitely +need case classes, we will use factory functions for all of them. + +Services need to be able to encode references to other config sections, so it's tempting to use +object references for that. However, generally doing so is annoying because it turns what was once +a self-contained declarative expression into one in which subexpressions have to be assigned to variables +so that they can be referenced in 2 places. Assuming that people are generally used to encoding these +dependencies by file name, directory name, package name and command key, we can just keep the string +references. + +How do we makes it possible to define configs and configsets. + +We can keep the namespacing nice and flat and reduce the repetition by taking a list of `InitElement`, +subclassed by all the various init config types, so that we don't have to: + +```ts +files: [ + InitFile.from(...), +], +packages: [ + InitPackage.from(...), +], +``` + +Instead we can go: + +```ts +elements: [ + InitFile.from(...), + InitPackage.from(...), +], +``` + +For configsets, a single config is the common use case: + +{ + config: { + files: { + '/bla.txt': { + ... + } + }, + packages: { + sysvinit: { + nginx: { + ... + } + } + } + } +} + + + + +```ts +CloudFormationInit.simpleConfig( + InitFile.from(...), + InitPackage.from(...), + InitFile.from(...), + InitCommand.shellScript(...), + InitCommand.argvCommand(...), +) + +CloudFormationInit.simpleConfig({ + files: [ + InitFile.from(...), + ], + packages: [ + InitPackage.from(...), + ], +}) +``` + +For more complex cases, we can do: + +```ts +CloudFormationInit.withConfigSets({ + configSets: { + 'default': ['cs1', 'cs2'], + 'reverse': ['cs2', 'cs1'], + }, + configs: { + nested: { + files: [...], + packages: [...] + }, + flatList: [ ... ], + } +]) + +init.addConfigSet(); +init.addConfigKey('bla', new ConfigKey(...)); +init.addKeyToConfigSet('default', 'bla'); +``` + +CloudFormationInitConfigSet({ + + configs: Record = { + nested: new CloudFormationInitConfig({ ... }) + } +}) + +Config implements IConfigSet +ConfigSet implements IConfigSet + + +CloudFormationInit.fromConfigKeys([]) -> { configSet: { default: ['a', 'b', 'c', ... }} +CloudFormationInit.withConfigSets({ configSet: { default: ['a', 'b', 'c', ) + +configKey.append(rhs): ConfigKey + + +class EnableCfnHup extends ConfigKey { + constructor() { + super([ + InitFile... + InitFile.. + InitService... + ]); + } +} + +const myHup = new EnableCfnHup(); +myHup.add(...); + + +class BonesCfnInit { + public readonly afterIsengard: ConfigKey; + public readonly sharedConfig: ConfigKey; +} + +bonesInit.afterIsengard.add(InitFile(...)); \ 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..617425973e2c3 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/cfn-init-elements.ts @@ -0,0 +1,1017 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as s3_assets from '@aws-cdk/aws-s3-assets'; +import { Construct, Duration } from '@aws-cdk/core'; +import * as fs from 'fs'; + +/** + * Defines whether this Init template will is being rendered for Windows or Linux-based systems. + */ +export enum InitPlatform { + /** + * Render the config for a Windows platform + */ + WINDOWS = 'WINDOWS', + + /** + * Render the config for a Linux platform + */ + LINUX = 'LINUX', +} + +/** + * The type of the init element. + */ +export enum InitElementType { + /** + * A package + */ + PACKAGE = 'PACKAGE', + + /** + * A group + */ + GROUP = 'GROUP', + + /** + * A user + */ + USER = 'USER', + + /** + * A source + */ + SOURCE = 'SOURCE', + + /** + * A file + */ + FILE = 'FILE', + + /** + * A command + */ + COMMAND = 'COMMAND', + + /** + * A service + */ + SERVICE = 'SERVICE', +} + +/** + * An object that represents reasons to restart an InitService + * + * Pass an instance of this object to all the `InitFile`, `InitCommand`, + * `InitSource` and `InitPackage` objects that you want to restart + * a service, and finally to the `InitService` itself as well. + */ +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 + */ + public addCommand(key: string) { + return this.commands.push(key); + } + + /** + * Add a file key to the restart set + */ + public addFile(key: string) { + return this.files.push(key); + } + + /** + * Add a source key to the restart set + */ + public addSource(key: string) { + return this.sources.push(key); + } + + /** + * Add a package key to the restart set + */ + 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 + */ + 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), + }; + } +} + +/** + * Context information passed when an InitElement is being consumed + */ +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. + */ +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; +} + +/** + * Base class for all CloudFormation Init elements + */ +export abstract class InitElement { + + /** + * Returns the init element type for this element. + */ + public abstract readonly elementType: InitElementType; + + /** + * 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. + */ + public abstract bind(options: InitBindOptions): InitElementConfig; + +} + +/** + * Options for InitCommand + */ +export interface InitCommandOptions { + /** + * Identifier key for this command + * + * You can use this to order commands. + * + * @default - Automatically generated + */ + 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 this command + */ + readonly test?: string; + + /** + * Continue running if this command fails + * + * @default false + */ + readonly ignoreErrors?: boolean; + + /** + * Specifies how long to wait (in seconds) after a command has finished in case the command causes a reboot. + * + * A value of "forever" directs cfn-init to exit and resume only after the + * reboot is complete. Set this value to 0 if you do not want to wait for + * every command. + * + * For Windows systems only. + * + * @default Duration.seconds(60) + */ + readonly waitAfterCompletion?: Duration; + + /** + * Restart the given service(s) after this command has run + * + * @default - Do not restart any service + */ + readonly serviceRestartHandles?: InitServiceRestartHandle[]; +} + +/** + * Command to execute on the instance + */ +export class InitCommand extends InitElement { + /** + * Run a shell command + * + * You must escape the string appropriately. + */ + 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; + + protected constructor(private readonly command: any, private readonly options: InitCommandOptions) { + super(); + + if (typeof this.command !== 'string' && !(Array.isArray(command) && command.every(s => typeof s === 'string'))) { + throw new Error('\'command\' must be either a string or an array of strings'); + } + } + + 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.test, + ignoreErrors: this.options.ignoreErrors, + waitAfterCompletion: this.options.waitAfterCompletion?.toSeconds(), + }, + }, + }; + } + +} + +/** + * The content can be either inline in the template or the content can be + * pulled from a URL. + */ +export interface InitFileOptions { + /** + * The name of the owning group for this file. + * + * Not supported for Windows systems. + * + * @default 'root' + */ + readonly group?: string; + + /** + * The name of the owning user for this file. + * + * Not supported for Windows systems. + * + * @default 'root' + */ + readonly owner?: string; + + /** + * A six-digit octal value representing the mode for this file. + * + * Use the first three digits for symlinks and the last three digits for + * setting permissions. To create a symlink, specify 120xxx, where xxx + * defines the permissions of the target file. To specify permissions for a + * file, use the last three digits, such as 000644. + * + * Not supported for Windows systems. + * + * @default '000644' + */ + readonly mode?: string; + + /** + * True if the inlined content (from a string or file) should be treated as base64 encoded. + * Only applicable for inlined string and file content. + * + * @default false + */ + readonly base64Encoded?: boolean; + + /** + * Restart the given service after this file has been written + * + * @default - Do not restart any service + */ + readonly serviceRestartHandles?: InitServiceRestartHandle[]; +} + +/** + * Additional options for creating an InitFile from an asset. + */ +export interface InitFileAssetOptions extends InitFileOptions, s3_assets.AssetOptions { +} + +/** + * Create files on the EC2 instance. + */ +export abstract class InitFile extends InitElement { + + /** + * Use a literal string as the file content + */ + public static fromString(fileName: string, content: string, options: InitFileOptions = {}): InitFile { + return new class extends InitFile { + protected doBind(bindOptions: InitBindOptions) { + return { + config: this.standardConfig(options, bindOptions.platform, { + content, + encoding: this.options.base64Encoded ? 'base64' : 'plain', + }), + }; + } + }(fileName, options); + } + + /** + * Write a symlink with the given symlink target + */ + public static symlink(fileName: string, target: string, options: InitFileOptions = {}): InitFile { + const { mode, ...otherOptions } = options; + if (mode && mode.slice(0, 3) !== '120') { + throw new Error('File mode for symlinks must begin with 120XXX'); + } + return InitFile.fromString(fileName, target, { mode: (mode || '120644'), ...otherOptions }); + } + + /** + * Use a JSON-compatible object as the file content, write it to a JSON file. + * + * May contain tokens. + */ + public static fromObject(fileName: string, obj: Record, options: InitFileOptions = {}): InitFile { + return new class extends InitFile { + protected doBind(bindOptions: InitBindOptions) { + return { + config: this.standardConfig(options, bindOptions.platform, { + content: obj, + }), + }; + } + }(fileName, options); + } + + /** + * Read a file from disk and use its contents + * + * The file will be embedded in the template, so care should be taken to not + * exceed the template size. + * + * If options.base64encoded is set to true, this will base64-encode the file's contents. + */ + public static fromFileInline(targetFileName: string, sourceFileName: string, options: InitFileOptions = {}): InitFile { + const encoding = options.base64Encoded ? 'base64' : 'utf8'; + const fileContents = fs.readFileSync(sourceFileName).toString(encoding); + return InitFile.fromString(targetFileName, fileContents, options); + } + + /** + * Download from a URL at instance startup time + */ + public static fromUrl(fileName: string, url: string, options: InitFileOptions = {}): InitFile { + return new class extends InitFile { + protected doBind(bindOptions: InitBindOptions) { + return { + config: this.standardConfig(options, bindOptions.platform, { + source: url, + }), + }; + } + }(fileName, options); + } + + /** + * Download a file from an S3 bucket + */ + public static fromS3Object(fileName: string, bucket: s3.IBucket, key: string, options: InitFileOptions = {}): InitFile { + return new class extends InitFile { + protected doBind(bindOptions: InitBindOptions) { + bucket.grantRead(bindOptions.instanceRole, key); + return { + config: this.standardConfig(options, bindOptions.platform, { + source: bucket.urlForObject(key), + }), + authentication: standardS3Auth(bindOptions.instanceRole, bucket.bucketName), + }; + } + }(fileName, options); + } + + /** + * Create an asset from the given file and use that + * + * This is appropriate for files that are too large to embed into the template. + */ + public static fromAsset(targetFileName: string, path: string, options: InitFileAssetOptions = {}): InitFile { + return new class extends InitFile { + protected doBind(bindOptions: InitBindOptions) { + const asset = new s3_assets.Asset(bindOptions.scope, `${targetFileName}Asset`, { + path, + ...options, + }); + asset.grantRead(bindOptions.instanceRole); + + return { + config: this.standardConfig(options, bindOptions.platform, { + source: asset.httpUrl, + }), + authentication: standardS3Auth(bindOptions.instanceRole, asset.s3BucketName), + }; + } + }(targetFileName, options); + } + + /** + * Use a file from an asset at instance startup time + */ + public static fromExistingAsset(targetFileName: string, asset: s3_assets.Asset, options: InitFileOptions = {}): InitFile { + return new class extends InitFile { + protected doBind(bindOptions: InitBindOptions) { + asset.grantRead(bindOptions.instanceRole); + return { + config: this.standardConfig(options, bindOptions.platform, { + source: asset.httpUrl, + }), + authentication: standardS3Auth(bindOptions.instanceRole, asset.s3BucketName), + }; + } + }(targetFileName, options); + } + + public readonly elementType = InitElementType.FILE; + + protected constructor(private readonly fileName: string, private readonly options: InitFileOptions) { + super(); + } + + public bind(bindOptions: InitBindOptions): InitElementConfig { + for (const handle of this.options.serviceRestartHandles ?? []) { + handle.addFile(this.fileName); + } + + return this.doBind(bindOptions); + } + + /** + * Perform the actual bind and render + * + * This is in a second method so the superclass can guarantee that + * the common work of registering into serviceHandles cannot be forgotten. + */ + protected abstract doBind(options: InitBindOptions): InitElementConfig; + + /** + * Render the standard config block, given content vars + */ + protected standardConfig(fileOptions: InitFileOptions, platform: InitPlatform, contentVars: Record): Record { + if (platform === InitPlatform.WINDOWS) { + if (fileOptions.group || fileOptions.owner || fileOptions.mode) { + throw new Error('Owner, group, and mode options not supported for Windows.'); + } + return {}; + } + + return { + [this.fileName]: { + ...contentVars, + mode: fileOptions.mode || '000644', + owner: fileOptions.owner || 'root', + group: fileOptions.group || 'root', + }, + }; + } +} + +/** + * Create Linux/UNIX groups and to assign group IDs. + * + * Not supported for Windows systems. + */ +export class InitGroup extends InitElement { + + /** + * Map a group name to a group id + */ + public static fromName(groupName: string, groupId?: number): InitGroup { + return new InitGroup(groupName, groupId); + } + + public readonly elementType = InitElementType.GROUP; + + protected constructor(private groupName: string, private groupId?: number) { + super(); + } + + public bind(options: InitBindOptions): InitElementConfig { + if (options.platform === InitPlatform.WINDOWS) { + throw new Error('Init groups are not supported on Windows'); + } + + return { + config: { + [this.groupName]: this.groupId !== undefined ? { gid: this.groupId } : {}, + }, + }; + } + +} + +/** + * Optional parameters used when creating a user + */ +export interface InitUserOptions { + /** + * The user's home directory. + * + * @default assigned by the OS + */ + readonly homeDir?: string; + + /** + * A user ID. The creation process fails if the user name exists with a different user ID. + * If the user ID is already assigned to an existing user the operating system may + * reject the creation request. + * + * @default assigned by the OS + */ + readonly userId?: number; + + /** + * A list of group names. The user will be added to each group in the list. + * + * @default the user is not associated with any groups. + */ + readonly groups?: string[]; +} + +/** + * Create Linux/UNIX users and to assign user IDs. + * + * Users are created as non-interactive system users with a shell of + * /sbin/nologin. This is by design and cannot be modified. + * + * Not supported for Windows systems. + */ +export class InitUser extends InitElement { + /** + * Map a user name to a user id + */ + public static fromName(userName: string, options: InitUserOptions = {}): InitUser { + return new InitUser(userName, options); + } + + public readonly elementType = InitElementType.USER; + + protected constructor(private readonly userName: string, private readonly userOptions: InitUserOptions) { + super(); + } + + public bind(options: InitBindOptions): InitElementConfig { + if (options.platform === InitPlatform.WINDOWS) { + throw new Error('Init users are not supported on Windows'); + } + + return { + config: { + [this.userName]: { + uid: this.userOptions.userId, + groups: this.userOptions.groups, + homeDir: this.userOptions.homeDir, + }, + }, + }; + } +} + +/** + * Options for InitPackage.rpm/InitPackage.msi + */ +export interface LocationPackageOptions { + /** + * Identifier key for this package + * + * You can use this to order package installs. + * + * @default - Automatically generated + */ + readonly key?: string; + + /** + * Restart the given service after this command has run + * + * @default - Do not restart any service + */ + readonly serviceRestartHandles?: InitServiceRestartHandle[]; +} + +/** + * Options for InitPackage.yum/apt/rubyGem/python + */ +export interface NamedPackageOptions { + /** + * Specify the versions to install + * + * @default - Install the latest version + */ + readonly version?: string[]; + + /** + * Restart the given services after this command has run + * + * @default - Do not restart any service + */ + readonly serviceRestartHandles?: InitServiceRestartHandle[]; +} + +/** + * A package to be installed during cfn-init time + */ +export class InitPackage extends InitElement { + /** + * Install an RPM from an HTTP URL or a location on disk + */ + public static rpm(location: string, options: LocationPackageOptions = {}): InitPackage { + return new InitPackage('rpm', [location], options.key, options.serviceRestartHandles); + } + + /** + * Install a package using Yum + */ + public static yum(packageName: string, options: NamedPackageOptions = {}): InitPackage { + return new InitPackage('yum', options.version ?? [], packageName, options.serviceRestartHandles); + } + + /** + * Install a package from RubyGems + */ + public static rubyGem(gemName: string, options: NamedPackageOptions = {}): InitPackage { + return new InitPackage('rubygems', options.version ?? [], gemName, options.serviceRestartHandles); + } + + /** + * Install a package from PyPI + */ + public static python(packageName: string, options: NamedPackageOptions = {}): InitPackage { + return new InitPackage('python', options.version ?? [], packageName, options.serviceRestartHandles); + } + + /** + * Install a package using APT + */ + public static apt(packageName: string, options: NamedPackageOptions = {}): InitPackage { + return new InitPackage('apt', options.version ?? [], packageName, options.serviceRestartHandles); + } + + /** + * Install an MSI package from an HTTP URL or a location on disk + */ + public static msi(location: string, options: LocationPackageOptions = {}): InitPackage { + // The MSI package version must be a string, not an array. + return new class extends InitPackage { + protected renderPackageVersions() { return location; } + }('msi', [location], options.key, options.serviceRestartHandles); + } + + public readonly elementType = InitElementType.PACKAGE; + + protected constructor( + private readonly type: string, + private readonly versions: string[], + private readonly packageName?: string, + private readonly serviceHandles?: InitServiceRestartHandle[], + ) { + super(); + } + + public bind(options: InitBindOptions): InitElementConfig { + if ((this.type === 'msi') !== (options.platform === InitPlatform.WINDOWS)) { + if (this.type === 'msi') { + throw new Error('MSI installers are only supported on Windows systems.'); + } else { + throw new Error('Windows only supports the MSI package type'); + } + } + + if (!this.packageName && !['rpm', 'msi'].includes(this.type)) { + throw new Error('Package name must be specified for all package types besides RPM and MSI.'); + } + + const packageName = this.packageName || `${options.index}`.padStart(3, '0'); + + for (const handle of this.serviceHandles ?? []) { + handle.addPackage(this.type, packageName); + } + + return { + config: { + [this.type]: { + [packageName]: this.renderPackageVersions(), + }, + }, + }; + } + + protected renderPackageVersions(): any { + return this.versions; + } +} + +/** + * 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 }); + } + + /** + * Create a service restart definition from the given options, not imposing any defaults. + * + * @param serviceName the name of the service to restart + * @param options service options + */ + public static fromOptions(serviceName: string, options: InitServiceOptions = {}): InitService { + return new InitService(serviceName, options); + } + + public readonly elementType = InitElementType.SERVICE; + + protected constructor(private readonly serviceName: string, private readonly serviceOptions: InitServiceOptions) { + super(); + } + + 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(), + }, + }, + }, + }; + } + +} + +/** + * Additional options for an InitSource + */ +export interface InitSourceOptions { + + /** + * Restart the given services after this archive has been extracted + * + * @default - Do not restart any service + */ + readonly serviceRestartHandles?: InitServiceRestartHandle[]; +} + +/** + * Additional options for an InitSource that builds an asset from local files. + */ +export interface InitSourceAssetOptions extends InitSourceOptions, s3_assets.AssetOptions { + +} + +/** + * Extract an archive into a directory + */ +export abstract class InitSource extends InitElement { + /** + * Retrieve a URL and extract it into the given directory + */ + public static fromUrl(targetDirectory: string, url: string, options: InitSourceOptions = {}): InitSource { + return new class extends InitSource { + protected doBind() { + return { + config: { [this.targetDirectory]: url }, + }; + } + }(targetDirectory, options.serviceRestartHandles); + } + + /** + * Extract a GitHub branch into a given directory + */ + public static fromGitHub(targetDirectory: string, owner: string, repo: string, refSpec?: string, options: InitSourceOptions = {}): InitSource { + return InitSource.fromUrl(targetDirectory, `https://github.com/${owner}/${repo}/tarball/${refSpec ?? 'master'}`, options); + } + + /** + * Extract an archive stored in an S3 bucket into the given directory + */ + public static fromS3Object(targetDirectory: string, bucket: s3.IBucket, key: string, options: InitSourceOptions = {}): InitSource { + return new class extends InitSource { + protected doBind(bindOptions: InitBindOptions) { + bucket.grantRead(bindOptions.instanceRole, key); + + return { + config: { [this.targetDirectory]: bucket.urlForObject(key) }, + authentication: standardS3Auth(bindOptions.instanceRole, bucket.bucketName), + }; + } + }(targetDirectory, options.serviceRestartHandles); + } + + /** + * Create an InitSource from an asset created from the given path. + */ + public static fromAsset(targetDirectory: string, path: string, options: InitSourceAssetOptions = {}): InitSource { + return new class extends InitSource { + protected doBind(bindOptions: InitBindOptions) { + const asset = new s3_assets.Asset(bindOptions.scope, `${targetDirectory}Asset`, { + path, + ...bindOptions, + }); + asset.grantRead(bindOptions.instanceRole); + + return { + config: { [this.targetDirectory]: asset.httpUrl }, + authentication: standardS3Auth(bindOptions.instanceRole, asset.s3BucketName), + }; + } + }(targetDirectory, options.serviceRestartHandles); + } + + /** + * Extract a directory from an existing directory asset. + */ + public static fromExistingAsset(targetDirectory: string, asset: s3_assets.Asset, options: InitSourceOptions = {}): InitSource { + return new class extends InitSource { + protected doBind(bindOptions: InitBindOptions) { + asset.grantRead(bindOptions.instanceRole); + + return { + config: { [this.targetDirectory]: asset.httpUrl }, + authentication: standardS3Auth(bindOptions.instanceRole, asset.s3BucketName), + }; + } + }(targetDirectory, options.serviceRestartHandles); + } + + public readonly elementType = InitElementType.SOURCE; + + protected constructor(private readonly targetDirectory: string, private readonly serviceHandles?: InitServiceRestartHandle[]) { + super(); + } + + public bind(options: InitBindOptions): InitElementConfig { + for (const handle of this.serviceHandles ?? []) { + handle.addSource(this.targetDirectory); + } + + // Delegate actual bind to subclasses + return this.doBind(options); + } + + /** + * Perform the actual bind and render + * + * This is in a second method so the superclass can guarantee that + * the common work of registering into serviceHandles cannot be forgotten. + */ + protected abstract doBind(options: InitBindOptions): InitElementConfig; +} + +/** + * Render a standard S3 auth block for use in AWS::CloudFormation::Authentication + * + * This block is the same every time (modulo bucket name), so it has the same + * key every time so the blocks are merged into one in the final render. + */ +function standardS3Auth(role: iam.IRole, bucketName: string) { + return { + S3AccessCreds: { + type: 'S3', + roleName: role.roleName, + buckets: [bucketName], + }, + }; +} 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..4b03e8a01113c --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/lib/cfn-init.ts @@ -0,0 +1,360 @@ +import * as iam from '@aws-cdk/aws-iam'; +import { Aws, CfnResource, Construct } from '@aws-cdk/core'; +import * as crypto from 'crypto'; +import { InitBindOptions, InitElement, InitElementConfig, InitElementType, InitPlatform } from './cfn-init-elements'; +import { UserData } from './user-data'; + +/** + * 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 = {}; + + protected 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, you shouldn't need to use this API directly. Instead, + * use `instance.applyCloudFormationInit()` or + * `autoScalingGroup.applyCloudFormationInit()`. + */ + 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), + }; + } + +} + +/** + * 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; +} + +/** + * 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 get 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. + */ + 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..2936ab2d5e5a9 100644 --- a/packages/@aws-cdk/aws-ec2/lib/instance.ts +++ b/packages/@aws-cdk/aws-ec2/lib/instance.ts @@ -1,6 +1,9 @@ import * as iam from '@aws-cdk/aws-iam'; +import * as crypto from 'crypto'; -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 { InitPlatform } from './cfn-init-elements'; import { Connections, IConnectable } from './connections'; import { CfnInstance } from './ec2.generated'; import { InstanceType } from './instance-types'; @@ -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 false + */ + 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; } /** @@ -253,10 +292,16 @@ export class Instance extends Resource implements IInstance { private readonly securityGroup: ISecurityGroup; private readonly securityGroups: ISecurityGroup[] = []; + private readonly originalLogicalId: string; + private readonly userDataReplacement: boolean; 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 +379,13 @@ 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); + this.originalLogicalId = Stack.of(this).getLogicalId(this.instance); + this.userDataReplacement = props.userDataCausesReplacement ?? false; } /** @@ -361,6 +412,59 @@ 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. + */ + public 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. + */ + public 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(), + }, + }; + } + + protected prepare() { + if (this.userDataReplacement) { + const md5 = crypto.createHash('md5'); + md5.update(this.userData.render()); + this.instance.overrideLogicalId(this.originalLogicalId + md5.digest('hex').substr(0, 16)); + } + super.prepare(); + } + /** * Apply CloudFormation update policies for the instance */ @@ -375,3 +479,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; +} \ No newline at end of file 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/asset-fixture/data.txt b/packages/@aws-cdk/aws-ec2/test/asset-fixture/data.txt new file mode 100644 index 0000000000000..386264aca616f --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/asset-fixture/data.txt @@ -0,0 +1 @@ +A file to be bundled as an asset. \ No newline at end of file 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..fc541e8c71f12 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/cfn-init-element.test.ts @@ -0,0 +1,667 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as s3 from '@aws-cdk/aws-s3'; +import { App, Duration, Stack } from '@aws-cdk/core'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as ec2 from '../lib'; + +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, ec2.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, ec2.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', + test: 'test -d /home/myUser', + ignoreErrors: false, + waitAfterCompletion: Duration.hours(2), + }); + + // WHEN + const rendered = getElementConfig(command, ec2.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', + test: 'test -d /home/myUser', + ignoreErrors: false, + waitAfterCompletion: Duration.hours(2), + }); + + // WHEN + const rendered = getElementConfig(command, ec2.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', + test: 'test -d /home/myUser', + ignoreErrors: false, + waitAfterCompletion: Duration.hours(2), + }); + + // THEN + expect(() => { + command.bind(defaultOptions(ec2.InitPlatform.LINUX)); + }).toThrow(/'waitAfterCompletion' is only valid for Windows/); + }); + +}); + +describe('InitFile', () => { + + test('fromString creates inline content', () => { + // GIVEN + const file = ec2.InitFile.fromString('/tmp/foo', 'My content'); + + // WHEN + const rendered = getElementConfig(file, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + '/tmp/foo': { + content: 'My content', + encoding: 'plain', + owner: 'root', + group: 'root', + mode: '000644', + }, + }); + }); + + test('fromString creates inline content from base64-encoded content', () => { + // GIVEN + const file = ec2.InitFile.fromString('/tmp/foo', Buffer.from('Hello').toString('base64'), { + base64Encoded: true, + }); + + // WHEN + const rendered = getElementConfig(file, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + '/tmp/foo': { + content: 'SGVsbG8=', + encoding: 'base64', + owner: 'root', + group: 'root', + mode: '000644', + }, + }); + }); + + test('mode, user, group settings not allowed for Windows', () => { + // GIVEN + const file = ec2.InitFile.fromString('/tmp/foo', 'My content', { + group: 'root', + owner: 'root', + mode: '000644', + }); + + // WHEN + expect(() => { + file.bind(defaultOptions(ec2.InitPlatform.WINDOWS)); + }).toThrow('Owner, group, and mode options not supported for Windows.'); + }); + + test('symlink throws an error if mode is set incorrectly', () => { + expect(() => { + ec2.InitFile.symlink('/tmp/foo', '/tmp/bar', { + mode: '000644', + }); + }).toThrow('File mode for symlinks must begin with 120XXX'); + }); + + test('symlink sets mode is not set', () => { + // GIVEN + const file = ec2.InitFile.symlink('/tmp/foo', '/tmp/bar'); + + // WHEN + const rendered = getElementConfig(file, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + '/tmp/foo': { + content: '/tmp/bar', + encoding: 'plain', + owner: 'root', + group: 'root', + mode: '120644', + }, + }); + }); + + test('fromFileInline renders the file contents inline', () => { + // GIVEN + const tmpFilePath = createTmpFileWithContent('Hello World!'); + const file = ec2.InitFile.fromFileInline('/tmp/foo', tmpFilePath); + + // WHEN + const rendered = getElementConfig(file, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + '/tmp/foo': { + content: 'Hello World!', + encoding: 'plain', + owner: 'root', + group: 'root', + mode: '000644', + }, + }); + }); + + test('fromObject renders the contents inline as an object', () => { + // GIVEN + const content = { + version: '1234', + secretsFile: '/tmp/secrets', + }; + const file = ec2.InitFile.fromObject('/tmp/foo', content); + + // WHEN + const rendered = getElementConfig(file, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + '/tmp/foo': { + content: { + version: '1234', + secretsFile: '/tmp/secrets', + }, + owner: 'root', + group: 'root', + mode: '000644', + }, + }); + }); + + test('fromFileInline respects the base64 flag', () => { + // GIVEN + const tmpFilePath = createTmpFileWithContent('Hello'); + const file = ec2.InitFile.fromFileInline('/tmp/foo', tmpFilePath, { + base64Encoded: true, + }); + + // WHEN + const rendered = getElementConfig(file, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + '/tmp/foo': { + content: 'SGVsbG8=', + encoding: 'base64', + owner: 'root', + group: 'root', + mode: '000644', + }, + }); + }); + + test('fromUrl uses the provided URL as a source', () => { + // GIVEN + const file = ec2.InitFile.fromUrl('/tmp/foo', 'https://aws.amazon.com/'); + + // WHEN + const rendered = getElementConfig(file, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + '/tmp/foo': { + source: 'https://aws.amazon.com/', + owner: 'root', + group: 'root', + mode: '000644', + }, + }); + }); + +}); + +describe('InitGroup', () => { + + test('renders without a group id', () => { + // GIVEN + const group = ec2.InitGroup.fromName('amazon'); + + // WHEN + const rendered = getElementConfig(group, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ amazon: {} }); + }); + + test('renders with a group id', () => { + // GIVEN + const group = ec2.InitGroup.fromName('amazon', 42); + + // WHEN + const rendered = getElementConfig(group, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ amazon: { gid: 42 } }); + }); + + test('groups are not supported for Windows', () => { + // GIVEN + const group = ec2.InitGroup.fromName('amazon'); + + // WHEN + expect(() => { + group.bind(defaultOptions(ec2.InitPlatform.WINDOWS)); + }).toThrow('Init groups are not supported on Windows'); + }); + +}); + +describe('InitUser', () => { + + test('fromName accepts just a name to create a user', () => { + // GIVEN + const group = ec2.InitUser.fromName('sysuser1'); + + // WHEN + const rendered = getElementConfig(group, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ sysuser1: {} }); + }); + + test('renders with all options present', () => { + // GIVEN + const group = ec2.InitUser.fromName('sysuser1', { + userId: 42, + homeDir: '/home/sysuser1', + groups: ['amazon'], + }); + + // WHEN + const rendered = getElementConfig(group, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + sysuser1: { + uid: 42, + homeDir: '/home/sysuser1', + groups: ['amazon'], + }, + }); + }); + + test('users are not supported for Windows', () => { + // GIVEN + const group = ec2.InitUser.fromName('sysuser1'); + + // WHEN + expect(() => { + group.bind(defaultOptions(ec2.InitPlatform.WINDOWS)); + }).toThrow('Init users are not supported on Windows'); + }); + +}); + +describe('InitPackage', () => { + + test('rpm auto-generates a name if none is provided', () => { + // GIVEN + const pkg = ec2.InitPackage.rpm('https://example.com/rpm/mypkg.rpm'); + + // WHEN + const rendered = getElementConfig(pkg, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + rpm: { + '000': ['https://example.com/rpm/mypkg.rpm'], + }, + }); + }); + + test('rpm uses name if provided', () => { + // GIVEN + const pkg = ec2.InitPackage.rpm('https://example.com/rpm/mypkg.rpm', { key: 'myPkg' }); + + // WHEN + const rendered = getElementConfig(pkg, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + rpm: { + myPkg: ['https://example.com/rpm/mypkg.rpm'], + }, + }); + }); + + test('rpm is not supported for Windows', () => { + // GIVEN + const pkg = ec2.InitPackage.rpm('https://example.com/rpm/mypkg.rpm'); + + // THEN + expect(() => { + pkg.bind(defaultOptions(ec2.InitPlatform.WINDOWS)); + }).toThrow('Windows only supports the MSI package type'); + }); + + test.each([ + ['yum', ec2.InitPackage.yum], + ['rubygems', ec2.InitPackage.rubyGem], + ['python', ec2.InitPackage.python], + ['apt', ec2.InitPackage.apt], + ])('%s accepts a package without versions', (pkgType, fn) => { + // GIVEN + const pkg = fn('httpd'); + + // WHEN + const rendered = getElementConfig(pkg, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + [pkgType]: { httpd: [] }, + }); + }, + ); + + test.each([ + ['yum', ec2.InitPackage.yum], + ['rubygems', ec2.InitPackage.rubyGem], + ['python', ec2.InitPackage.python], + ['apt', ec2.InitPackage.apt], + ])('%s accepts a package with versions', (pkgType, fn) => { + // GIVEN + const pkg = fn('httpd', { version: ['1.0', '2.0'] }); + + // WHEN + const rendered = getElementConfig(pkg, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + [pkgType]: { httpd: ['1.0', '2.0'] }, + }); + }, + ); + + test.each([ + ['yum', ec2.InitPackage.yum], + ['rubygems', ec2.InitPackage.rubyGem], + ['python', ec2.InitPackage.python], + ['apt', ec2.InitPackage.apt], + ])('%s is not supported on Windows', (_pkgType, fn) => { + // GIVEN + const pkg = fn('httpd'); + + expect(() => { + pkg.bind(defaultOptions(ec2.InitPlatform.WINDOWS)); + }).toThrow('Windows only supports the MSI package type'); + }, + ); + + test('msi auto-generates a name if none is provided', () => { + // GIVEN + const pkg = ec2.InitPackage.msi('https://example.com/rpm/mypkg.msi'); + + // WHEN + const rendered = getElementConfig(pkg, ec2.InitPlatform.WINDOWS); + + // THEN + expect(rendered).toEqual({ + msi: { + '000': 'https://example.com/rpm/mypkg.msi', + }, + }); + }); + + test('msi uses name if provided', () => { + // GIVEN + const pkg = ec2.InitPackage.msi('https://example.com/rpm/mypkg.msi', { key: 'myPkg' }); + + // WHEN + const rendered = getElementConfig(pkg, ec2.InitPlatform.WINDOWS); + + // THEN + expect(rendered).toEqual({ + msi: { + myPkg: 'https://example.com/rpm/mypkg.msi', + }, + }); + }); + + test('msi is not supported for Linux', () => { + // GIVEN + const pkg = ec2.InitPackage.msi('https://example.com/rpm/mypkg.msi'); + + // THEN + expect(() => { + pkg.bind(defaultOptions(ec2.InitPlatform.LINUX)); + }).toThrow('MSI installers are only supported on Windows systems.'); + }); + +}); + +describe('InitService', () => { + + test.each([ + ['Linux', 'sysvinit', ec2.InitPlatform.LINUX], + ['Windows', 'windows', ec2.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', ec2.InitPlatform.LINUX], + ['Windows', 'windows', ec2.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, + }, + }); + }); + + test.each([ + ['Linux', 'sysvinit', ec2.InitPlatform.LINUX], + ['Windows', 'windows', ec2.InitPlatform.WINDOWS], + ])('fromOptions renders all options for %s', (_platform, key, platform) => { + // GIVEN + const restartHandle = new ec2.InitServiceRestartHandle(); + restartHandle.addFile('/etc/my.cnf'); + restartHandle.addSource('/tmp/foo'); + restartHandle.addPackage('yum', 'httpd'); + restartHandle.addCommand('cmd_000'); + + const service = ec2.InitService.fromOptions('httpd', { + enabled: true, + ensureRunning: true, + serviceRestartHandle: restartHandle, + }); + + // WHEN + const rendered = service.bind(defaultOptions(platform)).config; + + // THEN + expect(rendered[key]).toBeDefined(); + expect(rendered[key]).toEqual({ + httpd: { + enabled: true, + ensureRunning: true, + files: ['/etc/my.cnf'], + sources: ['/tmp/foo'], + packages: { yum: ['httpd'] }, + commands: ['cmd_000'], + }, + }); + }); + +}); + +describe('InitSource', () => { + + test('fromUrl renders correctly', () => { + // GIVEN + const source = ec2.InitSource.fromUrl('/tmp/foo', 'https://example.com/archive.zip'); + + // WHEN + const rendered = getElementConfig(source, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + '/tmp/foo': 'https://example.com/archive.zip', + }); + }); + + test('fromGitHub builds a path to the tarball', () => { + // GIVEN + const source = ec2.InitSource.fromGitHub('/tmp/foo', 'aws', 'aws-cdk', 'master'); + + // WHEN + const rendered = getElementConfig(source, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + '/tmp/foo': 'https://github.com/aws/aws-cdk/tarball/master', + }); + }); + + test('fromGitHub defaults to master if refspec is omitted', () => { + // GIVEN + const source = ec2.InitSource.fromGitHub('/tmp/foo', 'aws', 'aws-cdk'); + + // WHEN + const rendered = getElementConfig(source, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + '/tmp/foo': 'https://github.com/aws/aws-cdk/tarball/master', + }); + }); + + test('fromS3Object uses object URL', () => { + // GIVEN + const bucket = s3.Bucket.fromBucketName(stack, 'bucket', 'MyBucket'); + const source = ec2.InitSource.fromS3Object('/tmp/foo', bucket, 'myKey'); + + // WHEN + const rendered = getElementConfig(source, ec2.InitPlatform.LINUX); + + // THEN + expect(rendered).toEqual({ + '/tmp/foo': expect.stringContaining('/MyBucket/myKey'), + }); + }); + +}); + +function getElementConfig(element: ec2.InitElement, platform: ec2.InitPlatform) { + return element.bind(defaultOptions(platform)).config; +} + +function defaultOptions(platform: ec2.InitPlatform) { + return { + scope: stack, + index: 0, + platform, + instanceRole: new iam.Role(stack, 'InstanceRole', { + assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), + }), + }; +} + +function createTmpFileWithContent(content: string): string { + const suffix = crypto.randomBytes(4).toString('hex'); + const fileName = path.join(os.tmpdir(), `cfn-init-element-test-${suffix}`); + fs.writeFileSync(fileName, content); + return fileName; +} \ No newline at end of file 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..3a4d84d3c8ee0 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/cfn-init.test.ts @@ -0,0 +1,514 @@ +import { arrayWith, ResourcePart, stringLike } from '@aws-cdk/assert'; +import '@aws-cdk/assert/jest'; +import * as iam from '@aws-cdk/aws-iam'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as s3_assets from '@aws-cdk/aws-s3-assets'; +import { App, Aws, CfnResource, Stack } from '@aws-cdk/core'; +import * as path from 'path'; +import * as ec2 from '../lib'; + +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.InitFile.fromString('/etc/my.cnf', '[mysql]\ngo_fast=true', { serviceRestartHandles: [handle] }), + ec2.InitSource.fromUrl('/tmp/foo', 'https://amazon.com/foo.zip', { serviceRestartHandles: [handle] }), + ec2.InitPackage.yum('httpd', { serviceRestartHandles: [handle] }), + 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'], + files: ['/etc/my.cnf'], + packages: { + yum: ['httpd'], + }, + sources: ['/tmp/foo'], + }, + }, + }, + })); +}); + +test('CloudFormationInit can be added to after instantiation', () => { + // GIVEN + const config = new ec2.InitConfig([]); + const init = ec2.CloudFormationInit.fromConfig(config); + + // WHEN + config.add(ec2.InitFile.fromString('/the/file', 'hasContents')); + init.attach(resource, linuxOptions()); + + // THEN + expectMetadataLike({ + 'AWS::CloudFormation::Init': { + config: { + files: { + '/the/file': { content: 'hasContents' }, + }, + }, + }, + }); +}); + +test('empty configs are not rendered', () => { + // GIVEN + const config1 = new ec2.InitConfig([]); + const config2 = new ec2.InitConfig([ + ec2.InitFile.fromString('/the/file', 'hasContents'), + ]); + + // 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: { + files: { + '/the/file': { content: 'hasContents' }, + }, + }, + }, + }); +}); + +describe('userdata', () => { + let simpleInit: ec2.CloudFormationInit; + beforeEach(() => { + simpleInit = ec2.CloudFormationInit.fromElements( + ec2.InitFile.fromString('/the/file', 'hasContents'), + ); + }); + + 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: ec2.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')); + }); +}); + +const ASSET_STATEMENT = { + Action: [ 's3:GetObject*', 's3:GetBucket*', 's3:List*' ], + Effect: 'Allow', + Resource: [ + { + 'Fn::Join': ['', [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':s3:::', + { Ref: stringLike('AssetParameter*S3Bucket*') }, + ]], + }, + { + 'Fn::Join': ['', [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':s3:::', + { Ref: stringLike('AssetParameter*S3Bucket*') }, + '/*', + ]], + }, + ], +}; + +describe('assets n buckets', () => { + + test.each([ + ['Existing'], + [''], + ])('InitFile.from%sAsset', (existing: string) => { + // GIVEN + const asset = new s3_assets.Asset(stack, 'Asset', { path: __filename}); + const init = ec2.CloudFormationInit.fromElements( + existing + ? ec2.InitFile.fromExistingAsset('/etc/fun.js', asset) + : ec2.InitFile.fromAsset('/etc/fun.js', __filename), + ); + + // WHEN + init.attach(resource, linuxOptions()); + + // THEN + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith(ASSET_STATEMENT), + Version: '2012-10-17', + }, + }); + expectMetadataLike({ + 'AWS::CloudFormation::Init': { + config: { + files: { + '/etc/fun.js': { + source: { + 'Fn::Join': ['', [ + 'https://s3.testregion.', + { Ref: 'AWS::URLSuffix' }, + '/', + { Ref: stringLike('AssetParameters*') }, + '/', + { 'Fn::Select': [0, { 'Fn::Split': ['||', { Ref: stringLike('AssetParameters*') }] }] }, + { 'Fn::Select': [1, { 'Fn::Split': ['||', { Ref: stringLike('AssetParameters*') }] }] }, + ]], + }, + }, + }, + }, + }, + 'AWS::CloudFormation::Authentication': { + S3AccessCreds: { + type: 'S3', + roleName: { Ref: 'InstanceRole3CCE2F1D' }, + buckets: [ + { Ref: stringLike('AssetParameters*S3Bucket*') }, + ], + }, + }, + }); + }); + + test.each([ + ['Existing'], + [''], + ])('InitSource.from%sAsset', (existing: string) => { + // GIVEN + const asset = new s3_assets.Asset(stack, 'Asset', { path: path.join(__dirname, 'asset-fixture') }); + const init = ec2.CloudFormationInit.fromElements( + existing + ? ec2.InitSource.fromExistingAsset('/etc/fun', asset) + : ec2.InitSource.fromAsset('/etc/fun', path.join(__dirname, 'asset-fixture') ), + ); + + // WHEN + init.attach(resource, linuxOptions()); + + // THEN + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith(ASSET_STATEMENT), + Version: '2012-10-17', + }, + }); + expectMetadataLike({ + 'AWS::CloudFormation::Init': { + config: { + sources: { + '/etc/fun': { + 'Fn::Join': ['', [ + 'https://s3.testregion.', + { Ref: 'AWS::URLSuffix' }, + '/', + { Ref: stringLike('AssetParameters*') }, + '/', + { 'Fn::Select': [0, { 'Fn::Split': ['||', { Ref: stringLike('AssetParameters*') }] }] }, + { 'Fn::Select': [1, { 'Fn::Split': ['||', { Ref: stringLike('AssetParameters*') }] }] }, + ]], + }, + }, + }, + }, + 'AWS::CloudFormation::Authentication': { + S3AccessCreds: { + type: 'S3', + roleName: { Ref: 'InstanceRole3CCE2F1D' }, + buckets: [ + { Ref: stringLike('AssetParameters*S3Bucket*') }, + ], + }, + }, + }); + }); + + test('InitFile.fromS3Object', () => { + const bucket = s3.Bucket.fromBucketName(stack, 'Bucket', 'my-bucket'); + const init = ec2.CloudFormationInit.fromElements( + ec2.InitFile.fromS3Object('/etc/fun.js', bucket, 'file.js'), + ); + + // WHEN + init.attach(resource, linuxOptions()); + + // THEN + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith({ + Action: ['s3:GetObject*', 's3:GetBucket*', 's3:List*'], + Effect: 'Allow', + Resource: [ + { 'Fn::Join': [ '', [ 'arn:', { Ref: 'AWS::Partition' }, ':s3:::my-bucket' ] ] }, + { 'Fn::Join': [ '', [ 'arn:', { Ref: 'AWS::Partition' }, ':s3:::my-bucket/file.js' ] ] }, + ], + }), + Version: '2012-10-17', + }, + }); + expectMetadataLike({ + 'AWS::CloudFormation::Init': { + config: { + files: { + '/etc/fun.js': { + source: { 'Fn::Join': [ '', [ 'https://s3.testregion.', { Ref: 'AWS::URLSuffix' }, '/my-bucket/file.js' ] ] }, + }, + }, + }, + }, + 'AWS::CloudFormation::Authentication': { + S3AccessCreds: { + type: 'S3', + roleName: { Ref: 'InstanceRole3CCE2F1D' }, + buckets: [ 'my-bucket' ] , + }, + }, + }); + }); + + test('InitSource.fromS3Object', () => { + const bucket = s3.Bucket.fromBucketName(stack, 'Bucket', 'my-bucket'); + const init = ec2.CloudFormationInit.fromElements( + ec2.InitSource.fromS3Object('/etc/fun', bucket, 'file.zip'), + ); + + // WHEN + init.attach(resource, linuxOptions()); + + // THEN + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: arrayWith({ + Action: ['s3:GetObject*', 's3:GetBucket*', 's3:List*'], + Effect: 'Allow', + Resource: [ + { 'Fn::Join': [ '', [ 'arn:', { Ref: 'AWS::Partition' }, ':s3:::my-bucket' ] ] }, + { 'Fn::Join': [ '', [ 'arn:', { Ref: 'AWS::Partition' }, ':s3:::my-bucket/file.zip' ] ] }, + ], + }), + Version: '2012-10-17', + }, + }); + expectMetadataLike({ + 'AWS::CloudFormation::Init': { + config: { + sources: { + '/etc/fun': { 'Fn::Join': [ '', [ 'https://s3.testregion.', { Ref: 'AWS::URLSuffix' }, '/my-bucket/file.zip' ] ] }, + }, + }, + }, + 'AWS::CloudFormation::Authentication': { + S3AccessCreds: { + type: 'S3', + roleName: { Ref: 'InstanceRole3CCE2F1D' }, + buckets: [ 'my-bucket' ] , + }, + }, + }); + }); + + test('no duplication of bucket names when using multiple assets', () => { + // GIVEN + const init = ec2.CloudFormationInit.fromElements( + ec2.InitFile.fromAsset('/etc/fun.js', __filename), + ec2.InitSource.fromAsset('/etc/fun', path.join(__dirname, 'asset-fixture') ), + ); + + // WHEN + init.attach(resource, linuxOptions()); + + // THEN + expectMetadataLike({ + 'AWS::CloudFormation::Authentication': { + S3AccessCreds: { + type: 'S3', + roleName: { Ref: 'InstanceRole3CCE2F1D' }, + buckets: [ + { Ref: stringLike('AssetParameters*S3Bucket*') }, + ], + }, + }, + }); + }); + + test('multiple buckets appear in the same auth block', () => { + // GIVEN + const bucket = s3.Bucket.fromBucketName(stack, 'Bucket', 'my-bucket'); + const init = ec2.CloudFormationInit.fromElements( + ec2.InitFile.fromS3Object('/etc/fun.js', bucket, 'file.js'), + ec2.InitSource.fromAsset('/etc/fun', path.join(__dirname, 'asset-fixture') ), + ); + + // WHEN + init.attach(resource, linuxOptions()); + + // THEN + expectMetadataLike({ + 'AWS::CloudFormation::Authentication': { + S3AccessCreds: { + type: 'S3', + roleName: { Ref: 'InstanceRole3CCE2F1D' }, + buckets: arrayWith( + { Ref: stringLike('AssetParameters*S3Bucket*') }, + 'my-bucket', + ), + }, + }, + }); + }); +}); + +function linuxOptions() { + return { + platform: ec2.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..705ecd67ebf29 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 { Duration, 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, InitFile, 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,79 @@ nodeunitShim({ test.done(); }, }); + +test('can add resource signal wait', () => { + // GIVEN + const stack = new Stack(); + const vpc = new Vpc(stack, 'VPC'); + const instance = new Instance(stack, 'Instance', { + vpc, + machineImage: new AmazonLinuxImage(), + instanceType: InstanceType.of(InstanceClass.T3, InstanceSize.LARGE), + }); + + // WHEN + instance.waitForResourceSignal(Duration.minutes(5)); + + // THEN + cdkExpect(stack).to(haveResource('AWS::EC2::Instance', { + CreationPolicy: { + ResourceSignal: { + Count: 1, + Timeout: 'PT5M', + }, + }, + }, ResourcePart.CompleteDefinition)); +}); + +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'), + InitFile.fromString('/etc/my.cnf', '[mysql]\nw00t=true'), + ), + }); + + // 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..62a7f59bb1894 --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/integ.instance-init.expected.json @@ -0,0 +1,322 @@ +{ + "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": [ + "s3:GetObject*", + "s3:GetBucket*", + "s3:List*" + ], + "Effect": "Allow", + "Resource": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "AssetParametersf8a1af398dac2fad92eeea4fb7620be1c4f504e23e3bfcd859fbb5744187930bS3Bucket597083AB" + } + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":s3:::", + { + "Ref": "AssetParametersf8a1af398dac2fad92eeea4fb7620be1c4f504e23e3bfcd859fbb5744187930bS3Bucket597083AB" + }, + "/*" + ] + ] + } + ] + }, + { + "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" + } + ] + } + }, + "Instance255F3526524bcb6d7e0439c4c": { + "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: ab7f06cf7eda4e4a\n(\n set +e\n /opt/aws/bin/cfn-init -v --region ", + { + "Ref": "AWS::Region" + }, + " --stack ", + { + "Ref": "AWS::StackName" + }, + " --resource Instance255F3526524bcb6d7e0439c4c -c default\n /opt/aws/bin/cfn-signal -e $? --region ", + { + "Ref": "AWS::Region" + }, + " --stack ", + { + "Ref": "AWS::StackName" + }, + " --resource Instance255F3526524bcb6d7e0439c4c\n cat /var/log/cfn-init.log >&2\n)" + ] + ] + } + } + }, + "DependsOn": [ + "Instance2InstanceRoleDefaultPolicy610B37CD", + "Instance2InstanceRole03DD7CB2" + ], + "CreationPolicy": { + "ResourceSignal": { + "Count": 1, + "Timeout": "PT30M" + } + }, + "Metadata": { + "AWS::CloudFormation::Init": { + "configSets": { + "default": [ + "yumPreinstall", + "config" + ] + }, + "yumPreinstall": { + "packages": { + "yum": { + "git": [] + } + } + }, + "config": { + "groups": { + "group1": {}, + "group2": { + "gid": 42 + } + }, + "users": { + "sysuser1": { + "groups": [ + "group1", + "group2" + ], + "homeDir": "/home/sysuser1-custom" + }, + "sysuser2": {} + }, + "sources": { + "/tmp/sourceDir": { + "Fn::Join": [ + "", + [ + "https://s3.test-region.", + { + "Ref": "AWS::URLSuffix" + }, + "/", + { + "Ref": "AssetParametersf8a1af398dac2fad92eeea4fb7620be1c4f504e23e3bfcd859fbb5744187930bS3Bucket597083AB" + }, + "/", + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersf8a1af398dac2fad92eeea4fb7620be1c4f504e23e3bfcd859fbb5744187930bS3VersionKey89F61A12" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersf8a1af398dac2fad92eeea4fb7620be1c4f504e23e3bfcd859fbb5744187930bS3VersionKey89F61A12" + } + ] + } + ] + } + ] + ] + } + }, + "files": { + "/tmp/file2": { + "content": { + "stackId": { + "Ref": "AWS::StackId" + }, + "stackName": "integ-init", + "region": "test-region" + }, + "mode": "000644", + "owner": "root", + "group": "root" + } + } + } + }, + "AWS::CloudFormation::Authentication": { + "S3AccessCreds": { + "type": "S3", + "roleName": { + "Ref": "Instance2InstanceRole03DD7CB2" + }, + "buckets": [ + { + "Ref": "AssetParametersf8a1af398dac2fad92eeea4fb7620be1c4f504e23e3bfcd859fbb5744187930bS3Bucket597083AB" + } + ] + } + } + } + } + }, + "Parameters": { + "SsmParameterValueawsserviceamiamazonlinuxlatestamznamihvmx8664gp2C96584B6F00A464EAD1953AFF4B05118Parameter": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-gp2" + }, + "AssetParametersf8a1af398dac2fad92eeea4fb7620be1c4f504e23e3bfcd859fbb5744187930bS3Bucket597083AB": { + "Type": "String", + "Description": "S3 bucket for asset \"f8a1af398dac2fad92eeea4fb7620be1c4f504e23e3bfcd859fbb5744187930b\"" + }, + "AssetParametersf8a1af398dac2fad92eeea4fb7620be1c4f504e23e3bfcd859fbb5744187930bS3VersionKey89F61A12": { + "Type": "String", + "Description": "S3 key for asset version \"f8a1af398dac2fad92eeea4fb7620be1c4f504e23e3bfcd859fbb5744187930b\"" + }, + "AssetParametersf8a1af398dac2fad92eeea4fb7620be1c4f504e23e3bfcd859fbb5744187930bArtifactHash088925E9": { + "Type": "String", + "Description": "Artifact hash for asset \"f8a1af398dac2fad92eeea4fb7620be1c4f504e23e3bfcd859fbb5744187930b\"" + } + } +} \ 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..cbcff5ef871da --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/integ.instance-init.ts @@ -0,0 +1,56 @@ +#!/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.fromConfigSets({ + configSets: { + default: ['yumPreinstall', 'config'], + }, + configs: { + yumPreinstall: new ec2.InitConfig([ + ec2.InitPackage.yum('git'), + ]), + config: new ec2.InitConfig([ + ec2.InitFile.fromObject('/tmp/file2', { + stackId: stack.stackId, + stackName: stack.stackName, + region: stack.region, + }), + ec2.InitGroup.fromName('group1'), + ec2.InitGroup.fromName('group2', 42), + ec2.InitUser.fromName('sysuser1', { + groups: ['group1', 'group2'], + homeDir: '/home/sysuser1-custom', + }), + ec2.InitUser.fromName('sysuser2'), + ec2.InitSource.fromAsset('/tmp/sourceDir', tmpDir), + ]), + }, + }), +}); + +app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/cfn-resource.ts b/packages/@aws-cdk/core/lib/cfn-resource.ts index 68c34c52bce1d..ffb337ccd8dfb 100644 --- a/packages/@aws-cdk/core/lib/cfn-resource.ts +++ b/packages/@aws-cdk/core/lib/cfn-resource.ts @@ -93,9 +93,7 @@ export class CfnResource extends CfnRefElement { // path in the CloudFormation template, so it will be possible to trace // back to the actual construct path. if (this.node.tryGetContext(cxapi.PATH_METADATA_ENABLE_CONTEXT)) { - this.cfnOptions.metadata = { - [cxapi.PATH_METADATA_KEY]: this.node.path, - }; + this.addMetadata(cxapi.PATH_METADATA_KEY, this.node.path); } } @@ -238,6 +236,21 @@ export class CfnResource extends CfnRefElement { addDependency(this, target, `"${this.node.path}" depends on "${target.node.path}"`); } + /** + * Add a value to the CloudFormation Resource Metadata + * + * Note that this is a different set of metadata from CDK node metadata; this + * metadata ends up in the stack template under the resource, whereas CDK + * node metadata ends up in the Cloud Assembly. + */ + public addMetadata(key: string, value: any) { + if (!this.cfnOptions.metadata) { + this.cfnOptions.metadata = {}; + } + + this.cfnOptions.metadata[key] = value; + } + /** * @returns a string representation of this resource */ diff --git a/packages/@aws-cdk/core/lib/duration.ts b/packages/@aws-cdk/core/lib/duration.ts index 440760b2eda05..9039b6a6ee936 100644 --- a/packages/@aws-cdk/core/lib/duration.ts +++ b/packages/@aws-cdk/core/lib/duration.ts @@ -100,6 +100,15 @@ export class Duration { this.unit = unit; } + /** + * Add two Durations together + */ + public plus(rhs: Duration): Duration { + const targetUnit = finestUnit(this.unit, rhs.unit); + const total = convert(this.amount, this.unit, targetUnit, {}) + convert(rhs.amount, rhs.unit, targetUnit, {}); + return new Duration(total, targetUnit); + } + /** * Return the total number of milliseconds in this Duration * @@ -285,3 +294,10 @@ function convert(amount: number, fromUnit: TimeUnit, toUnit: TimeUnit, { integra } return value; } + +/** + * Return the time unit with highest granularity + */ +function finestUnit(a: TimeUnit, b: TimeUnit) { + return a.inMillis < b.inMillis ? a : b; +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/test/test.cfn-resource.ts b/packages/@aws-cdk/core/test/test.cfn-resource.ts index dec09b5c96084..41d360296ec1d 100644 --- a/packages/@aws-cdk/core/test/test.cfn-resource.ts +++ b/packages/@aws-cdk/core/test/test.cfn-resource.ts @@ -73,4 +73,28 @@ export = nodeunit.testCase({ test.done(); }, + + 'can add metadata'(test: nodeunit.Test) { + // GIVEN + const app = new core.App(); + const stack = new core.Stack(app, 'TestStack'); + const resource = new core.CfnResource(stack, 'DefaultResource', { type: 'Test::Resource::Fake' }); + + // WHEN + resource.addMetadata('Beep', 'Boop'); + + // THEN + test.deepEqual(app.synth().getStackByName(stack.stackName).template, { + Resources: { + DefaultResource: { + Type: 'Test::Resource::Fake', + Metadata: { + Beep: 'Boop', + }, + }, + }, + }); + + test.done(); + }, }); diff --git a/packages/@aws-cdk/core/test/test.duration.ts b/packages/@aws-cdk/core/test/test.duration.ts index ff9a44a5c21f1..7746591249cab 100644 --- a/packages/@aws-cdk/core/test/test.duration.ts +++ b/packages/@aws-cdk/core/test/test.duration.ts @@ -147,6 +147,13 @@ export = nodeunit.testCase({ test.done(); }, + + 'add two durations'(test: nodeunit.Test) { + test.equal(Duration.minutes(1).plus(Duration.seconds(30)).toSeconds(), Duration.seconds(90).toSeconds()); + test.equal(Duration.minutes(1).plus(Duration.seconds(30)).toMinutes({ integral: false }), Duration.seconds(90).toMinutes({ integral: false })); + + test.done(); + }, }); function floatEqual(test: nodeunit.Test, actual: number, expected: number) { diff --git a/tools/cdk-integ-tools/bin/cdk-integ.ts b/tools/cdk-integ-tools/bin/cdk-integ.ts index 522aaf750ff67..311c3be33c437 100644 --- a/tools/cdk-integ-tools/bin/cdk-integ.ts +++ b/tools/cdk-integ-tools/bin/cdk-integ.ts @@ -9,6 +9,7 @@ async function main() { const argv = yargs .usage('Usage: cdk-integ [TEST...]') .option('list', { type: 'boolean', default: false, desc: 'List tests instead of running them' }) + .option('deploy', { type: 'boolean', default: true, desc: 'Deploy the test before writing the new output' }) .option('clean', { type: 'boolean', default: true, desc: 'Skips stack clean up after test is completed (use --no-clean to negate)' }) .option('verbose', { type: 'boolean', default: false, alias: 'v', desc: 'Verbose logs' }) .option('dry-run', { type: 'boolean', default: false, desc: 'do not actually deploy the stack. just update the snapshot (not recommended!)' }) diff --git a/yarn.lock b/yarn.lock index 3ce1a4a22f0d6..f2c35ff76b94e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6702,6 +6702,14 @@ merge2@^1.2.3: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.3.0.tgz#5b366ee83b2f1582c48f87e47cf1a9352103ca81" integrity sha512-2j4DAdlBOkiSZIsaXk4mTE3sRS02yBHAtfy127xRV3bQUFqXkjHCHLW6Scv7DwNRbIWNHH8zpnz9zMaKXIdvYw== +micromatch@4.x, micromatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" + integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== + dependencies: + braces "^3.0.1" + picomatch "^2.0.5" + micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" @@ -6721,14 +6729,6 @@ micromatch@^3.1.10, micromatch@^3.1.4: snapdragon "^0.8.1" to-regex "^3.0.2" -micromatch@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" - integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== - dependencies: - braces "^3.0.1" - picomatch "^2.0.5" - mime-db@1.43.0: version "1.43.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58" @@ -9495,6 +9495,22 @@ trivial-deferred@^1.0.1: resolved "https://registry.yarnpkg.com/trivial-deferred/-/trivial-deferred-1.0.1.tgz#376d4d29d951d6368a6f7a0ae85c2f4d5e0658f3" integrity sha1-N21NKdlR1jaKb3oK6FwvTV4GWPM= +ts-jest@^25.3.1: + version "25.5.1" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-25.5.1.tgz#2913afd08f28385d54f2f4e828be4d261f4337c7" + integrity sha512-kHEUlZMK8fn8vkxDjwbHlxXRB9dHYpyzqKIGDNxbzs+Rz+ssNDSDNusEK8Fk/sDd4xE6iKoQLfFkFVaskmTJyw== + dependencies: + bs-logger "0.x" + buffer-from "1.x" + fast-json-stable-stringify "2.x" + json5 "2.x" + lodash.memoize "4.x" + make-error "1.x" + micromatch "4.x" + mkdirp "0.x" + semver "6.x" + yargs-parser "18.x" + ts-jest@^26.1.2: version "26.1.2" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.1.2.tgz#dd2e832ffae9cb803361483b6a3010a6413dc475"