From d0a3c7eabe4148c7c74ff85099c43ad282176a86 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 22 Aug 2018 10:09:26 +0200 Subject: [PATCH] * feat(aws-autoscaling): update and creation policies (#595) AutoScalingGroups update types can now be configured (rolling and replacing updates), and allow specifying signal counts upon host startup. Fixes #278. OTHER CHANGES - AutoScalingGroup validates constraints between sizes upon construction. Fixes #314. --- .../assert/lib/assertions/have-resource.ts | 51 +++- .../aws-autoscaling/lib/auto-scaling-group.ts | 272 ++++++++++++++++++ .../integ.asg-w-loadbalancer.expected.json | 5 + .../test/test.auto-scaling-group.ts | 100 ++++++- 4 files changed, 415 insertions(+), 13 deletions(-) diff --git a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts index a1564d1c926e7..dcd5d1524ca57 100644 --- a/packages/@aws-cdk/assert/lib/assertions/have-resource.ts +++ b/packages/@aws-cdk/assert/lib/assertions/have-resource.ts @@ -9,16 +9,24 @@ import { StackInspector } from "../inspector"; * - An object, in which case its properties will be compared to those of the actual resource found * - A callable, in which case it will be treated as a predicate that is applied to the Properties of the found resources. */ -export function haveResource(resourceType: string, properties?: any): Assertion { - return new HaveResourceAssertion(resourceType, properties); +export function haveResource(resourceType: string, properties?: any, comparison?: ResourcePart): Assertion { + return new HaveResourceAssertion(resourceType, properties, comparison); } +type PropertyPredicate = (props: any) => boolean; + class HaveResourceAssertion extends Assertion { private inspected: any[] = []; + private readonly part: ResourcePart; + private readonly predicate: PropertyPredicate; constructor(private readonly resourceType: string, - private readonly properties?: any) { + private readonly properties?: any, + part?: ResourcePart) { super(); + + this.predicate = typeof properties === 'function' ? properties : makeSuperObjectPredicate(properties); + this.part = part !== undefined ? part : ResourcePart.Properties; } public assertUsing(inspector: StackInspector): boolean { @@ -27,16 +35,9 @@ class HaveResourceAssertion extends Assertion { if (resource.Type === this.resourceType) { this.inspected.push(resource); - let matches: boolean; - if (typeof this.properties === 'function') { - // If 'properties' is a callable, invoke it - matches = this.properties(resource.Properties); - } else { - // Otherwise treat as property bag that we check superset of - matches = isSuperObject(resource.Properties, this.properties); - } + const propsToCheck = this.part === ResourcePart.Properties ? resource.Properties : resource; - if (matches) { + if (this.predicate(propsToCheck)) { return true; } } @@ -57,6 +58,15 @@ class HaveResourceAssertion extends Assertion { } } +/** + * Make a predicate that checks property superset + */ +function makeSuperObjectPredicate(obj: any) { + return (resourceProps: any) => { + return isSuperObject(resourceProps, obj); + }; +} + /** * Return whether `superObj` is a super-object of `obj`. * @@ -89,3 +99,20 @@ export function isSuperObject(superObj: any, obj: any): boolean { } return superObj === obj; } + +/** + * What part of the resource to compare + */ +export enum ResourcePart { + /** + * Only compare the resource's properties + */ + Properties, + + /** + * Check the entire CloudFormation config + * + * (including UpdateConfig, DependsOn, etc.) + */ + CompleteDefinition +} \ No newline at end of file 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 f1596d5572d9b..34462984f87fe 100644 --- a/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts +++ b/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts @@ -65,6 +65,63 @@ export interface AutoScalingGroupProps { * @default true */ allowAllOutbound?: boolean; + + /** + * 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 UpdateType.None + */ + updateType?: UpdateType; + + /** + * Configuration for rolling updates + * + * Only used if updateType == UpdateType.RollingUpdate. + */ + rollingUpdateConfiguration?: RollingUpdateConfiguration; + + /** + * Configuration for replacing updates. + * + * Only used if updateType == UpdateType.ReplacingUpdate. Specifies how + * many instances must signal success for the update to succeed. + */ + replacingUpdateMinSuccessfulInstancesPercent?: number; + + /** + * If the ASG has scheduled actions, don't reset unchanged group sizes + * + * Only used if the ASG has scheduled actions (which may scale your ASG up + * or down regardless of cdk deployments). If true, the size of the group + * will only be reset if it has been changed in the CDK app. If false, the + * sizes will always be changed back to what they were in the CDK app + * on deployment. + * + * @default true + */ + ignoreUnmodifiedSizeProperties?: boolean; + + /** + * How many ResourceSignal calls CloudFormation expects before the resource is considered created + * + * @default 1 + */ + resourceSignalCount?: number; + + /** + * The length of time to wait for the resourceSignalCount + * + * The maximum value is 43200 (12 hours). + * + * @default 300 (5 minutes) + */ + resourceSignalTimeoutSec?: number; } /** @@ -136,6 +193,10 @@ export class AutoScalingGroup extends cdk.Construct implements ec2.IClassicLoadB const maxSize = props.maxSize || 1; const desiredCapacity = props.desiredCapacity || 1; + if (desiredCapacity < minSize || desiredCapacity > maxSize) { + throw new Error(`Should have minSize (${minSize}) <= desiredCapacity (${desiredCapacity}) <= maxSize (${maxSize})`); + } + const asgProps: cloudformation.AutoScalingGroupResourceProps = { minSize: minSize.toString(), maxSize: maxSize.toString(), @@ -162,6 +223,8 @@ export class AutoScalingGroup extends cdk.Construct implements ec2.IClassicLoadB this.autoScalingGroup = new cloudformation.AutoScalingGroupResource(this, 'ASG', asgProps); this.osType = machineImage.os.type; + + this.applyUpdatePolicies(props); } public attachToClassicLB(loadBalancer: ec2.ClassicLoadBalancer): void { @@ -186,4 +249,213 @@ export class AutoScalingGroup extends cdk.Construct implements ec2.IClassicLoadB public addToRolePolicy(statement: cdk.PolicyStatement) { this.role.addToPolicy(statement); } + + /** + * Apply CloudFormation update policies for the AutoScalingGroup + */ + private applyUpdatePolicies(props: AutoScalingGroupProps) { + if (props.updateType === UpdateType.ReplacingUpdate) { + this.asgUpdatePolicy.autoScalingReplacingUpdate = { willReplace: true }; + + if (props.replacingUpdateMinSuccessfulInstancesPercent !== undefined) { + // Yes, this goes on CreationPolicy, not as a process parameter to ReplacingUpdate. + // It's a little confusing, but the docs seem to explicitly state it will only be used + // during the update? + // + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-attribute-creationpolicy.html + this.asgCreationPolicy.autoScalingCreationPolicy = { + minSuccessfulInstancesPercent: validatePercentage(props.replacingUpdateMinSuccessfulInstancesPercent) + }; + } + } else if (props.updateType === UpdateType.RollingUpdate) { + this.asgUpdatePolicy.autoScalingRollingUpdate = renderRollingUpdateConfig(props.rollingUpdateConfiguration); + } + + // undefined is treated as 'true' + if (props.ignoreUnmodifiedSizeProperties !== false) { + this.asgUpdatePolicy.autoScalingScheduledAction = { ignoreUnmodifiedGroupSizeProperties: true }; + } + + if (props.resourceSignalCount !== undefined || props.resourceSignalTimeoutSec !== undefined) { + this.asgCreationPolicy.resourceSignal = { + count: props.resourceSignalCount, + timeout: props.resourceSignalTimeoutSec !== undefined ? renderIsoDuration(props.resourceSignalTimeoutSec) : undefined, + }; + } + } + + /** + * Create and return the ASG update policy + */ + private get asgUpdatePolicy() { + if (this.autoScalingGroup.options.updatePolicy === undefined) { + this.autoScalingGroup.options.updatePolicy = {}; + } + return this.autoScalingGroup.options.updatePolicy; + } + + /** + * Create and return the ASG creation policy + */ + private get asgCreationPolicy() { + if (this.autoScalingGroup.options.creationPolicy === undefined) { + this.autoScalingGroup.options.creationPolicy = {}; + } + return this.autoScalingGroup.options.creationPolicy; + } +} + +/** + * The type of update to perform on instances in this AutoScalingGroup + */ +export enum UpdateType { + /** + * Don't do anything + */ + None = 'None', + + /** + * Replace the entire AutoScalingGroup + * + * Builds a new AutoScalingGroup first, then delete the old one. + */ + ReplacingUpdate = 'Replace', + + /** + * Replace the instances in the AutoScalingGroup. + */ + RollingUpdate = 'RollingUpdate', +} + +/** + * Additional settings when a rolling update is selected + */ +export interface RollingUpdateConfiguration { + /** + * The maximum number of instances that AWS CloudFormation updates at once. + * + * @default 1 + */ + 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 + */ + minInstancesInService?: number; + + /** + * The percentage of instances that must signal success for an update to succeed. + * + * If an instance doesn't send a signal within the time specified in the + * pauseTime property, AWS CloudFormation assumes that the instance wasn't + * updated. + * + * This number affects the success of the replacement. + * + * If you specify this property, you must also enable the + * waitOnResourceSignals and pauseTime properties. + * + * @default 100 + */ + minSuccessfulInstancesPercent?: number; + + /** + * The pause time after making a change to a batch of instances. + * + * This is intended to give those instances time to start software applications. + * + * Specify PauseTime in the ISO8601 duration format (in the format + * PT#H#M#S, where each # is the number of hours, minutes, and seconds, + * respectively). The maximum PauseTime is one hour (PT1H). + * + * @default 300 if the waitOnResourceSignals property is true, otherwise 0 + */ + pauseTimeSec?: number; + + /** + * Specifies whether the Auto Scaling group waits on signals from new instances during an update. + * + * AWS CloudFormation must receive a signal from each new instance within + * the specified PauseTime before continuing the update. + * + * 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. + * + * @default true if you specified the minSuccessfulInstancesPercent property, false otherwise + */ + waitOnResourceSignals?: boolean; + + /** + * 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. + */ + suspendProcesses?: ScalingProcess[]; +} + +export enum ScalingProcess { + Launch = 'Launch', + Terminate = 'Terminate', + HealthCheck = 'HealthCheck', + ReplaceUnhealthy = 'ReplaceUnhealthy', + AZRebalance = 'AZRebalance', + AlarmNotification = 'AlarmNotification', + ScheduledActions = 'ScheduledActions', + AddToLoadBalancer = 'AddToLoadBalancer' +} + +/** + * Render the rolling update configuration into the appropriate object + */ +function renderRollingUpdateConfig(config: RollingUpdateConfiguration = {}): cdk.AutoScalingRollingUpdate { + const waitOnResourceSignals = config.minSuccessfulInstancesPercent !== undefined ? true : false; + const pauseTimeSec = config.pauseTimeSec !== undefined ? config.pauseTimeSec : (waitOnResourceSignals ? 300 : 0); + + return { + maxBatchSize: config.maxBatchSize, + minInstancesInService: config.minInstancesInService, + minSuccessfulInstancesPercent: validatePercentage(config.minSuccessfulInstancesPercent), + waitOnResourceSignals, + pauseTime: renderIsoDuration(pauseTimeSec), + 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.HealthCheck, ScalingProcess.ReplaceUnhealthy, ScalingProcess.AZRebalance, + ScalingProcess.AlarmNotification, ScalingProcess.ScheduledActions], + }; +} + +/** + * Render a number of seconds to a PTnX string. + */ +function renderIsoDuration(seconds: number): string { + const ret: string[] = []; + + if (seconds >= 3600) { + ret.push(`${Math.floor(seconds / 3600)}H`); + seconds %= 3600; + } + if (seconds >= 60) { + ret.push(`${Math.floor(seconds / 60)}M`); + seconds %= 60; + } + if (seconds > 0) { + ret.push(`${seconds}S`); + } + + return 'PT' + ret.join(''); +} + +function validatePercentage(x?: number): number | undefined { + if (x === undefined || (0 <= x && x <= 100)) { return x; } + throw new Error(`Expected: a percentage 0..100, got: ${x}`); } diff --git a/packages/@aws-cdk/aws-autoscaling/test/integ.asg-w-loadbalancer.expected.json b/packages/@aws-cdk/aws-autoscaling/test/integ.asg-w-loadbalancer.expected.json index 1b57b2f048501..65b6fa4d5ddb6 100644 --- a/packages/@aws-cdk/aws-autoscaling/test/integ.asg-w-loadbalancer.expected.json +++ b/packages/@aws-cdk/aws-autoscaling/test/integ.asg-w-loadbalancer.expected.json @@ -452,6 +452,11 @@ "Ref": "VPCPrivateSubnet3Subnet3EDCD457" } ] + }, + "UpdatePolicy": { + "AutoScalingScheduledAction": { + "IgnoreUnmodifiedGroupSizeProperties": true + } } }, "LBSecurityGroup8A41EA2B": { diff --git a/packages/@aws-cdk/aws-autoscaling/test/test.auto-scaling-group.ts b/packages/@aws-cdk/aws-autoscaling/test/test.auto-scaling-group.ts index 207ab9ffcd817..5deccbfbe8d5b 100644 --- a/packages/@aws-cdk/aws-autoscaling/test/test.auto-scaling-group.ts +++ b/packages/@aws-cdk/aws-autoscaling/test/test.auto-scaling-group.ts @@ -1,4 +1,4 @@ -import { expect } from '@aws-cdk/assert'; +import { expect, haveResource, ResourcePart } from '@aws-cdk/assert'; import ec2 = require('@aws-cdk/aws-ec2'); import cdk = require('@aws-cdk/cdk'); import { Test } from 'nodeunit'; @@ -89,6 +89,11 @@ export = { }, "MyFleetASG88E55886": { "Type": "AWS::AutoScaling::AutoScalingGroup", + "UpdatePolicy": { + "AutoScalingScheduledAction": { + "IgnoreUnmodifiedGroupSizeProperties": true + } + }, "Properties": { "DesiredCapacity": "1", "LaunchConfigurationName": { @@ -216,6 +221,9 @@ export = { }, MyFleetASG88E55886: { Type: "AWS::AutoScaling::AutoScalingGroup", + UpdatePolicy: { + AutoScalingScheduledAction: { IgnoreUnmodifiedGroupSizeProperties: true } + }, Properties: { DesiredCapacity: "1", LaunchConfigurationName: { @@ -234,6 +242,96 @@ export = { test.done(); }, + + 'can configure replacing update'(test: Test) { + // GIVEN + const stack = new cdk.Stack(undefined, 'MyStack', { env: { region: 'us-east-1', account: '1234' }}); + const vpc = mockVpc(stack); + + // WHEN + new autoscaling.AutoScalingGroup(stack, 'MyFleet', { + instanceType: new ec2.InstanceTypePair(ec2.InstanceClass.M4, ec2.InstanceSize.Micro), + machineImage: new ec2.AmazonLinuxImage(), + vpc, + updateType: autoscaling.UpdateType.ReplacingUpdate, + replacingUpdateMinSuccessfulInstancesPercent: 50 + }); + + // THEN + expect(stack).to(haveResource("AWS::AutoScaling::AutoScalingGroup", { + UpdatePolicy: { + AutoScalingReplacingUpdate: { + WillReplace: true + } + }, + CreationPolicy: { + AutoScalingCreationPolicy: { + MinSuccessfulInstancesPercent: 50 + } + } + }, ResourcePart.CompleteDefinition)); + + test.done(); + }, + + 'can configure rolling update'(test: Test) { + // GIVEN + const stack = new cdk.Stack(undefined, 'MyStack', { env: { region: 'us-east-1', account: '1234' }}); + const vpc = mockVpc(stack); + + // WHEN + new autoscaling.AutoScalingGroup(stack, 'MyFleet', { + instanceType: new ec2.InstanceTypePair(ec2.InstanceClass.M4, ec2.InstanceSize.Micro), + machineImage: new ec2.AmazonLinuxImage(), + vpc, + updateType: autoscaling.UpdateType.RollingUpdate, + rollingUpdateConfiguration: { + minSuccessfulInstancesPercent: 50, + pauseTimeSec: 345 + } + }); + + // THEN + expect(stack).to(haveResource("AWS::AutoScaling::AutoScalingGroup", { + UpdatePolicy: { + "AutoScalingRollingUpdate": { + "MinSuccessfulInstancesPercent": 50, + "WaitOnResourceSignals": true, + "PauseTime": "PT5M45S", + "SuspendProcesses": [ "HealthCheck", "ReplaceUnhealthy", "AZRebalance", "AlarmNotification", "ScheduledActions" ] + }, + } + }, ResourcePart.CompleteDefinition)); + + test.done(); + }, + + 'can configure resource signals'(test: Test) { + // GIVEN + const stack = new cdk.Stack(undefined, 'MyStack', { env: { region: 'us-east-1', account: '1234' }}); + const vpc = mockVpc(stack); + + // WHEN + new autoscaling.AutoScalingGroup(stack, 'MyFleet', { + instanceType: new ec2.InstanceTypePair(ec2.InstanceClass.M4, ec2.InstanceSize.Micro), + machineImage: new ec2.AmazonLinuxImage(), + vpc, + resourceSignalCount: 5, + resourceSignalTimeoutSec: 666 + }); + + // THEN + expect(stack).to(haveResource("AWS::AutoScaling::AutoScalingGroup", { + CreationPolicy: { + ResourceSignal: { + Count: 5, + Timeout: 'PT11M6S' + }, + } + }, ResourcePart.CompleteDefinition)); + + test.done(); + }, }; function mockVpc(stack: cdk.Stack) {