From 11e2fb6f8e6f744a9c5b7a30b95cec8871e2e22c Mon Sep 17 00:00:00 2001 From: akash1810 Date: Fri, 13 Sep 2024 14:17:14 +0100 Subject: [PATCH] test(experimental-ec2-pattern): Ensure only a horizontally scaling ASG is adjusted --- .../__snapshots__/ec2-app.test.ts.snap | 4 +- src/experimental/patterns/ec2-app.test.ts | 93 +++++++++++++++++++ src/experimental/patterns/ec2-app.ts | 80 +++++++++++----- 3 files changed, 152 insertions(+), 25 deletions(-) diff --git a/src/experimental/patterns/__snapshots__/ec2-app.test.ts.snap b/src/experimental/patterns/__snapshots__/ec2-app.test.ts.snap index fc3644419..1f383c074 100644 --- a/src/experimental/patterns/__snapshots__/ec2-app.test.ts.snap +++ b/src/experimental/patterns/__snapshots__/ec2-app.test.ts.snap @@ -71,7 +71,7 @@ exports[`The GuEc2AppExperimental pattern matches the snapshot 1`] = ` }, }, "Resources": { - "AsgReplacingUpdatePolicy78CF34D5": { + "AsgRollingUpdatePolicy2A1DDC6F": { "Properties": { "PolicyDocument": { "Statement": [ @@ -90,7 +90,7 @@ exports[`The GuEc2AppExperimental pattern matches the snapshot 1`] = ` ], "Version": "2012-10-17", }, - "PolicyName": "AsgReplacingUpdatePolicy78CF34D5", + "PolicyName": "AsgRollingUpdatePolicy2A1DDC6F", "Roles": [ { "Ref": "InstanceRoleTestguec2appC325BE42", diff --git a/src/experimental/patterns/ec2-app.test.ts b/src/experimental/patterns/ec2-app.test.ts index f67d071fd..5eae6792b 100644 --- a/src/experimental/patterns/ec2-app.test.ts +++ b/src/experimental/patterns/ec2-app.test.ts @@ -203,4 +203,97 @@ describe("The GuEc2AppExperimental pattern", () => { }, }); }); + + it("should only adjust properties of a horizontally scaling service", () => { + const cdkApp = new App(); + const stack = new GuStack(cdkApp, "test", { + stack: "test-stack", + stage: "TEST", + }); + + const scalingApp = "my-scaling-app"; + const { autoScalingGroup } = new GuEc2AppExperimental(stack, { + ...initialProps(stack, scalingApp), + scaling: { + minimumInstances: 5, + }, + }); + autoScalingGroup.scaleOnRequestCount("ScaleOnRequests", { + targetRequestsPerMinute: 100, + }); + + const staticApp = "my-static-app"; + new GuEc2AppExperimental(stack, initialProps(stack, staticApp)); + + /* + We're ultimately testing an `Aspect`, which appear to run only at synth time. + As a work-around, synth the `App`, then perform assertions on the resulting template. + + See also: https://github.com/aws/aws-cdk/issues/29047. + */ + const { artifacts } = cdkApp.synth(); + const cfnStack = artifacts.find((_): _ is CloudFormationStackArtifact => _ instanceof CloudFormationStackArtifact); + + if (!cfnStack) { + throw new Error("Unable to locate a CloudFormationStackArtifact"); + } + + const template = Template.fromJSON(cfnStack.template as Record); + + /* + The scaling ASG should: + - Not have `DesiredCapacity` set + - Have `MinInstancesInService` set via a CFN Parameter + */ + const parameterName = `MinInstancesInServiceFor${scalingApp.replaceAll("-", "")}`; + template.hasParameter(parameterName, { + Type: "Number", + Default: 5, + MaxValue: 9, // (min * 2) - 1 + }); + template.hasResource("AWS::AutoScaling::AutoScalingGroup", { + Properties: { + MinSize: "5", + MaxSize: "10", + DesiredCapacity: Match.absent(), + Tags: Match.arrayWith([{ Key: "App", Value: scalingApp, PropagateAtLaunch: true }]), + }, + UpdatePolicy: { + AutoScalingRollingUpdate: { + MaxBatchSize: 10, + SuspendProcesses: ["AlarmNotification"], + MinSuccessfulInstancesPercent: 100, + WaitOnResourceSignals: true, + PauseTime: "PT5M", + MinInstancesInService: { + Ref: parameterName, + }, + }, + }, + }); + + /* + The static ASG should: + - Have `DesiredCapacity` set explicitly + - Have `MinInstancesInService` set explicitly + */ + template.hasResource("AWS::AutoScaling::AutoScalingGroup", { + Properties: { + MinSize: "1", + MaxSize: "2", + DesiredCapacity: "1", + Tags: Match.arrayWith([{ Key: "App", Value: staticApp, PropagateAtLaunch: true }]), + }, + UpdatePolicy: { + AutoScalingRollingUpdate: { + MaxBatchSize: 2, + SuspendProcesses: ["AlarmNotification"], + MinSuccessfulInstancesPercent: 100, + WaitOnResourceSignals: true, + PauseTime: "PT5M", + MinInstancesInService: 1, + }, + }, + }); + }); }); diff --git a/src/experimental/patterns/ec2-app.ts b/src/experimental/patterns/ec2-app.ts index 4ed98804d..eeebb9bf8 100644 --- a/src/experimental/patterns/ec2-app.ts +++ b/src/experimental/patterns/ec2-app.ts @@ -1,4 +1,4 @@ -import type { IAspect } from "aws-cdk-lib"; +import type { IAspect, Stack } from "aws-cdk-lib"; import { Aspects, CfnParameter, Duration } from "aws-cdk-lib"; import type { CfnAutoScalingGroup } from "aws-cdk-lib/aws-autoscaling"; import { CfnScalingPolicy, ScalingProcess, UpdatePolicy } from "aws-cdk-lib/aws-autoscaling"; @@ -8,8 +8,24 @@ import { GuAutoScalingGroup } from "../../constructs/autoscaling"; import { GuStack } from "../../constructs/core"; import type { GuEc2AppProps } from "../../patterns"; import { GuEc2App } from "../../patterns"; +import { isSingletonPresentInStack } from "../../utils/singleton"; class HorizontallyScalingDeploymentProperties implements IAspect { + public readonly stack: Stack; + private static instance: HorizontallyScalingDeploymentProperties | undefined; + + private constructor(scope: GuStack) { + this.stack = scope; + } + + public static getInstance(stack: GuStack): HorizontallyScalingDeploymentProperties { + if (!this.instance || !isSingletonPresentInStack(stack, this.instance)) { + this.instance = new HorizontallyScalingDeploymentProperties(stack); + } + + return this.instance; + } + public visit(construct: IConstruct) { if (construct instanceof CfnScalingPolicy) { const { node } = construct; @@ -60,6 +76,44 @@ class HorizontallyScalingDeploymentProperties implements IAspect { } } +class AsgRollingUpdatePolicy extends Policy { + private static instance: AsgRollingUpdatePolicy | undefined; + + private constructor(scope: GuStack) { + const { stackId } = scope; + + super(scope, "AsgRollingUpdatePolicy", { + statements: [ + // Allow usage of command `cfn-signal`. + new PolicyStatement({ + actions: ["cloudformation:SignalResource"], + effect: Effect.ALLOW, + resources: [stackId], + }), + + /* + Allow usage of command `aws elbv2 describe-target-health`. + AWS Elastic Load Balancing does not support resource based policies, so the resource has to be `*` (any) here. + See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-services-that-work-with-iam.html + */ + new PolicyStatement({ + actions: ["elasticloadbalancing:DescribeTargetHealth"], + effect: Effect.ALLOW, + resources: ["*"], + }), + ], + }); + } + + public static getInstance(stack: GuStack): AsgRollingUpdatePolicy { + if (!this.instance || !isSingletonPresentInStack(stack, this.instance)) { + this.instance = new AsgRollingUpdatePolicy(stack); + } + + return this.instance; + } +} + export interface GuEc2AppExperimentalProps extends Omit {} /** @@ -158,27 +212,7 @@ export class GuEc2AppExperimental extends GuEc2App { }, }; - new Policy(scope, "AsgReplacingUpdatePolicy", { - statements: [ - // Allow usage of command `cfn-signal`. - new PolicyStatement({ - actions: ["cloudformation:SignalResource"], - effect: Effect.ALLOW, - resources: [stackId], - }), - - /* - Allow usage of command `aws elbv2 describe-target-health`. - AWS Elastic Load Balancing does not support resource based policies, so the resource has to be `*` (any) here. - See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-services-that-work-with-iam.html - */ - new PolicyStatement({ - actions: ["elasticloadbalancing:DescribeTargetHealth"], - effect: Effect.ALLOW, - resources: ["*"], - }), - ], - }).attachToRole(role); + AsgRollingUpdatePolicy.getInstance(scope).attachToRole(role); /* `ec2metadata` is available via `cloud-utils` installed on all Canonical Ubuntu AMIs. @@ -222,6 +256,6 @@ export class GuEc2AppExperimental extends GuEc2App { `, ); - Aspects.of(scope).add(new HorizontallyScalingDeploymentProperties()); + Aspects.of(scope).add(HorizontallyScalingDeploymentProperties.getInstance(scope)); } }