From 0f62f2a03c04748c33d7744aac4aa2a703697979 Mon Sep 17 00:00:00 2001 From: akash1810 Date: Fri, 11 Oct 2024 08:36:49 +0100 Subject: [PATCH] feat: Set `MinInstancesInService` via CFN parameters --- .../AmiCloudFormationParameter.scala | 4 +- .../deployment_type/CloudFormation.scala | 7 +- ...oudFormationDeploymentTypeParameters.scala | 43 +++ .../src/main/scala/magenta/tasks/AWS.scala | 54 ++++ .../scala/magenta/tasks/ChangeSetTasks.scala | 275 +++++++++++------- .../tasks/UpdateCloudFormationTask.scala | 16 +- .../deployment_type/CloudFormationTest.scala | 30 +- .../test/scala/magenta/tasks/ASGTest.scala | 161 ++++++++++ 8 files changed, 461 insertions(+), 129 deletions(-) diff --git a/magenta-lib/src/main/scala/magenta/deployment_type/AmiCloudFormationParameter.scala b/magenta-lib/src/main/scala/magenta/deployment_type/AmiCloudFormationParameter.scala index 311789d1e..a3e614da5 100644 --- a/magenta-lib/src/main/scala/magenta/deployment_type/AmiCloudFormationParameter.scala +++ b/magenta-lib/src/main/scala/magenta/deployment_type/AmiCloudFormationParameter.scala @@ -53,6 +53,7 @@ object AmiCloudFormationParameter } val amiLookupFn = getLatestAmi(pkg, target, reporter, resources.lookup) + val minInServiceParameterMap = getMinInServiceTagRequirements(pkg, target) val unresolvedParameters = new CloudFormationParameters( target = target, @@ -61,7 +62,8 @@ object AmiCloudFormationParameter latestImage = amiLookupFn, // Not expecting any user parameters in this deployment type - userParameters = Map.empty + userParameters = Map.empty, + minInServiceParameterMap = minInServiceParameterMap ) List( diff --git a/magenta-lib/src/main/scala/magenta/deployment_type/CloudFormation.scala b/magenta-lib/src/main/scala/magenta/deployment_type/CloudFormation.scala index 83d13d12e..b5f77111a 100644 --- a/magenta-lib/src/main/scala/magenta/deployment_type/CloudFormation.scala +++ b/magenta-lib/src/main/scala/magenta/deployment_type/CloudFormation.scala @@ -1,6 +1,6 @@ package magenta.deployment_type -import magenta.Loggable +import magenta.{Loggable, Strategy} import magenta.Strategy.{Dangerous, MostlyHarmless} import magenta.artifact.S3Path import magenta.deployment_type.CloudFormationDeploymentTypeParameters._ @@ -148,12 +148,15 @@ class CloudFormation(tagger: BuildTags) val changeSetName = s"${target.stack.name}-${new DateTime().getMillis}" + val minInServiceParameterMap = getMinInServiceTagRequirements(pkg, target) + val unresolvedParameters = new CloudFormationParameters( target, stackTags, userParams, amiParameterMap, - amiLookupFn + amiLookupFn, + minInServiceParameterMap ) val createNewStack = createStackIfAbsent(pkg, target, reporter) diff --git a/magenta-lib/src/main/scala/magenta/deployment_type/CloudFormationDeploymentTypeParameters.scala b/magenta-lib/src/main/scala/magenta/deployment_type/CloudFormationDeploymentTypeParameters.scala index f07273949..b6b73ec6e 100644 --- a/magenta-lib/src/main/scala/magenta/deployment_type/CloudFormationDeploymentTypeParameters.scala +++ b/magenta-lib/src/main/scala/magenta/deployment_type/CloudFormationDeploymentTypeParameters.scala @@ -1,11 +1,15 @@ package magenta.deployment_type +import magenta.deployment_type.CloudFormationDeploymentTypeParameters.CfnParam +import magenta.tasks.ASG +import magenta.tasks.ASG.TagMatch import magenta.tasks.UpdateCloudFormationTask.{ CloudFormationStackLookupStrategy, LookupByName, LookupByTags } import magenta.{DeployReporter, DeployTarget, DeploymentPackage, Lookup} +import software.amazon.awssdk.services.autoscaling.AutoScalingClient import java.time.Duration import java.time.Duration.ofMinutes @@ -104,6 +108,24 @@ trait CloudFormationDeploymentTypeParameters { """.stripMargin ).default(false) + val minInstancesInServiceParameters = Param[Map[CfnParam, TagCriteria]]( + "minInstancesInServiceParameters", + optional = true, + documentation = """Mapping between a CloudFormation parameter controlling the MinInstancesInService property of an ASG UpdatePolicy and the ASG. + | + |For example: + |``` + | minInstancesInServiceParameters: + | MinInstancesInServiceForApi: + | App: my-api + | MinInstancesInServiceForFrontend: + | App: my-frontend + |``` + |This instructs Riff-Raff that the CFN parameter `MinInstancesInServiceForApi` relates to an ASG tagged `App=my-api`. + |Additional requirements of `Stack=`, `Stage=` and `aws:cloudformation:stack-name=` are automatically added. + """.stripMargin + ) + val secondsToWaitForChangeSetCreation: Param[Duration] = Param .waitingSecondsFor( "secondsToWaitForChangeSetCreation", @@ -191,4 +213,25 @@ trait CloudFormationDeploymentTypeParameters { } } } + + def getMinInServiceTagRequirements( + pkg: DeploymentPackage, + target: DeployTarget + ): Map[CfnParam, List[TagMatch]] = { + minInstancesInServiceParameters.get(pkg) match { + case Some(params) => + val stackStageTags = List( + TagMatch("Stack", target.stack.name), + TagMatch("Stage", target.parameters.stage.name) + ) + params.map({ case (cfnParam, tagRequirements) => + cfnParam -> { + tagRequirements + .map({ case (key, value) => TagMatch(key, value) }) + .toList ++ stackStageTags + } + }) + case _ => Map.empty + } + } } diff --git a/magenta-lib/src/main/scala/magenta/tasks/AWS.scala b/magenta-lib/src/main/scala/magenta/tasks/AWS.scala index 95d3ae50d..c53fce2ce 100644 --- a/magenta-lib/src/main/scala/magenta/tasks/AWS.scala +++ b/magenta-lib/src/main/scala/magenta/tasks/AWS.scala @@ -490,6 +490,60 @@ object ASG { ) } + def getMinInstancesInService( + tagRequirements: List[TagMatch], + client: AutoScalingClient, + reporter: DeployReporter + ): Int = { + groupWithTags(tagRequirements, client, reporter) match { + case Some(asg) => + (asg.desiredCapacity, asg.maxSize) match { + case (desired, max) => + val seventyFivePercent: Int = (max * 0.75).toInt + val minInstancesInService = Math.min( + seventyFivePercent, + desired + ) + + if (2 * desired <= max) { + reporter.info( + s""" + |Deploying new instances all at once. + | + |Max=$max. Desired=$desired. + |Setting MinInstancesInService=$minInstancesInService. + |""".stripMargin + ) + } else if (minInstancesInService < desired) { + reporter.warning( + s""" + |Deploying new instances slower than usual. + | + |Max=$max. Desired=$desired. + |Setting MinInstancesInService=$minInstancesInService. + |This is 75% of max capacity, and less than desired capacity, meaning availability will be impacted. + |""".stripMargin + ) + } else { + reporter.warning( + s""" + |Deploying new instances slower than usual. + | + |Max=$max. Desired=$desired. + |Setting MinInstancesInService=$minInstancesInService. + |""".stripMargin + ) + } + + minInstancesInService + } + case _ => + reporter.fail( + s"No autoscaling group found with tags $tagRequirements. Creating a new stack? Initially choose the ${Strategy.Dangerous} strategy." + ) + } + } + def groupWithTags( tagRequirements: List[TagRequirement], client: AutoScalingClient, diff --git a/magenta-lib/src/main/scala/magenta/tasks/ChangeSetTasks.scala b/magenta-lib/src/main/scala/magenta/tasks/ChangeSetTasks.scala index b18176364..7c9d73780 100644 --- a/magenta-lib/src/main/scala/magenta/tasks/ChangeSetTasks.scala +++ b/magenta-lib/src/main/scala/magenta/tasks/ChangeSetTasks.scala @@ -1,6 +1,8 @@ package magenta.tasks import magenta.artifact.S3Path +import magenta.deployment_type.CloudFormationDeploymentTypeParameters.CfnParam +import magenta.tasks.ASG.TagMatch import magenta.tasks.CloudFormationParameters.{ ExistingParameter, InputParameter, @@ -8,6 +10,7 @@ import magenta.tasks.CloudFormationParameters.{ } import magenta.tasks.UpdateCloudFormationTask._ import magenta.{DeployReporter, DeploymentResources, KeyRing, Region} +import software.amazon.awssdk.services.autoscaling.AutoScalingClient import software.amazon.awssdk.services.cloudformation.CloudFormationClient import software.amazon.awssdk.services.cloudformation.model.ChangeSetStatus._ import software.amazon.awssdk.services.cloudformation.model._ @@ -82,53 +85,73 @@ class CreateAmiUpdateChangeSetTask( if (!stopFlag) { CloudFormation.withCfnClient(keyRing, region, resources) { cfnClient => STS.withSTSclient(keyRing, region, resources) { stsClient => - val accountNumber = STS.getAccountNumber(stsClient) + ASG.withAsgClient(keyRing, region, resources) { asgClient => + { + val accountNumber = STS.getAccountNumber(stsClient) - val (stackName, _, existingParameters, currentTags) = - stackLookup.lookup(resources.reporter, cfnClient) + val (stackName, _, existingParameters, currentTags) = + stackLookup.lookup(resources.reporter, cfnClient) - resources.reporter.info( - "Creating Cloudformation change set to update AMI parameters" - ) - resources.reporter.info(s"CloudFormation stack name: $stackName") - resources.reporter.info( - s"Change set name: ${stackLookup.changeSetName}" - ) + resources.reporter.info( + "Creating Cloudformation change set to update AMI parameters" + ) + resources.reporter.info(s"CloudFormation stack name: $stackName") + resources.reporter.info( + s"Change set name: ${stackLookup.changeSetName}" + ) - val maybeExecutionRole = CloudFormation.getExecutionRole(keyRing) - maybeExecutionRole.foreach(role => - resources.reporter.verbose(s"Using execution role: $role") - ) + val maybeExecutionRole = CloudFormation.getExecutionRole(keyRing) + maybeExecutionRole.foreach(role => + resources.reporter.verbose(s"Using execution role: $role") + ) - val parameters = CloudFormationParameters - .resolve( - resources.reporter, - unresolvedParameters, - accountNumber, - existingParameters.map(p => - TemplateParameter(p.key, default = true) - ), - existingParameters - ) - .fold( - resources.reporter.fail(_), - identity - ) - - val awsParameters = convertInputParametersToAwsAndLog( - resources.reporter, - parameters, - existingParameters - ) + val cfnStackNameTag = TagMatch( + "aws:cloudformation:stack-name", + stackName + ) - CloudFormation.createParameterUpdateChangeSet( - client = cfnClient, - changeSetName = stackLookup.changeSetName, - stackName = stackName, - currentStackTags = currentTags, - parameters = awsParameters, - maybeRole = maybeExecutionRole - ) + val minInServiceParameters = + unresolvedParameters.minInServiceParameterMap.map { + case (cfnParam, asgTagRequirements) => + cfnParam -> ASG.getMinInstancesInService( + asgTagRequirements :+ cfnStackNameTag, + asgClient, + resources.reporter + ) + } + + val parameters = CloudFormationParameters + .resolve( + resources.reporter, + unresolvedParameters, + accountNumber, + existingParameters.map(p => + TemplateParameter(p.key, default = true) + ), + existingParameters, + minInServiceParameters + ) + .fold( + resources.reporter.fail(_), + identity + ) + + val awsParameters = convertInputParametersToAwsAndLog( + resources.reporter, + parameters, + existingParameters + ) + + CloudFormation.createParameterUpdateChangeSet( + client = cfnClient, + changeSetName = stackLookup.changeSetName, + stackName = stackName, + currentStackTags = currentTags, + parameters = awsParameters, + maybeRole = maybeExecutionRole + ) + } + } } } } @@ -153,85 +176,115 @@ class CreateChangeSetTask( CloudFormation.withCfnClient(keyRing, region, resources) { cfnClient => S3.withS3client(keyRing, region, resources = resources) { s3Client => STS.withSTSclient(keyRing, region, resources) { stsClient => - val accountNumber = STS.getAccountNumber(stsClient) - - val templateString = templatePath - .fetchContentAsString() - .right - .getOrElse( - resources.reporter.fail( - s"Unable to locate cloudformation template s3://${templatePath.bucket}/${templatePath.key}" + ASG.withAsgClient(keyRing, region, resources) { asgClient => + val accountNumber = STS.getAccountNumber(stsClient) + + val templateString = templatePath + .fetchContentAsString() + .right + .getOrElse( + resources.reporter.fail( + s"Unable to locate cloudformation template s3://${templatePath.bucket}/${templatePath.key}" + ) ) + + val (stackName, changeSetType, existingParameters, currentTags) = + stackLookup.lookup(resources.reporter, cfnClient) + + val template = processTemplate( + stackName, + templateString, + s3Client, + stsClient, + region, + resources.reporter ) + val templateParameters = CloudFormation + .validateTemplate(template, cfnClient) + .parameters + .asScala + .toList + .map(tp => + TemplateParameter( + tp.parameterKey, + Option(tp.defaultValue).isDefined + ) + ) - val (stackName, changeSetType, existingParameters, currentTags) = - stackLookup.lookup(resources.reporter, cfnClient) - - val template = processTemplate( - stackName, - templateString, - s3Client, - stsClient, - region, - resources.reporter - ) - val templateParameters = CloudFormation - .validateTemplate(template, cfnClient) - .parameters - .asScala - .toList - .map(tp => - TemplateParameter( - tp.parameterKey, - Option(tp.defaultValue).isDefined + val minInServiceParameters: Map[CfnParam, Int] = + changeSetType match { + case ChangeSetType.UPDATE => + val cfnStackNameTag = TagMatch( + "aws:cloudformation:stack-name", + stackName + ) + unresolvedParameters.minInServiceParameterMap.map { + case (cfnParam, asgTagRequirements) => + cfnParam -> ASG.getMinInstancesInService( + asgTagRequirements :+ cfnStackNameTag, + asgClient, + resources.reporter + ) + } + case ChangeSetType.CREATE => + resources.reporter.verbose( + s"Creating a new CFN stack; using minInService parameters from template." + ) + Map.empty + case _ => + // This code path should never be reached. See `CloudFormationStackMetadata.getChangeSetType`. + resources.reporter.fail( + s"Unsupported change set type: $changeSetType" + ) + } + + val parameters = CloudFormationParameters + .resolve( + resources.reporter, + unresolvedParameters, + accountNumber, + templateParameters, + existingParameters, + minInServiceParameters + ) + .fold( + resources.reporter.fail(_), + identity ) - ) - val parameters = CloudFormationParameters - .resolve( - resources.reporter, - unresolvedParameters, - accountNumber, - templateParameters, - existingParameters + val awsParameters = + convertInputParametersToAwsAndLog( + resources.reporter, + parameters, + existingParameters + ) + + resources.reporter.info("Creating Cloudformation change set") + resources.reporter.info(s"Stack name: $stackName") + resources.reporter.info( + s"Change set name: ${stackLookup.changeSetName}" ) - .fold( - resources.reporter.fail(_), - identity + + val maybeExecutionRole = CloudFormation.getExecutionRole(keyRing) + maybeExecutionRole.foreach(role => + resources.reporter.verbose(s"Using execution role $role") ) - val awsParameters = - convertInputParametersToAwsAndLog( + val mergedTags = currentTags ++ stackTags + resources.reporter.info("Tags: " + mergedTags.mkString(", ")) + + CloudFormation.createChangeSet( resources.reporter, - parameters, - existingParameters + stackLookup.changeSetName, + changeSetType, + stackName, + Some(mergedTags), + template, + awsParameters, + maybeExecutionRole, + cfnClient ) - - resources.reporter.info("Creating Cloudformation change set") - resources.reporter.info(s"Stack name: $stackName") - resources.reporter.info( - s"Change set name: ${stackLookup.changeSetName}" - ) - - val maybeExecutionRole = CloudFormation.getExecutionRole(keyRing) - maybeExecutionRole.foreach(role => - resources.reporter.verbose(s"Using execution role $role") - ) - - val mergedTags = currentTags ++ stackTags - resources.reporter.info("Tags: " + mergedTags.mkString(", ")) - - CloudFormation.createChangeSet( - resources.reporter, - stackLookup.changeSetName, - changeSetType, - stackName, - Some(mergedTags), - template, - awsParameters, - maybeExecutionRole, - cfnClient - ) + } } } } diff --git a/magenta-lib/src/main/scala/magenta/tasks/UpdateCloudFormationTask.scala b/magenta-lib/src/main/scala/magenta/tasks/UpdateCloudFormationTask.scala index 6573c62ae..b089fc1ed 100644 --- a/magenta-lib/src/main/scala/magenta/tasks/UpdateCloudFormationTask.scala +++ b/magenta-lib/src/main/scala/magenta/tasks/UpdateCloudFormationTask.scala @@ -1,6 +1,7 @@ package magenta.tasks import magenta.deployment_type.CloudFormationDeploymentTypeParameters._ +import magenta.tasks.ASG.TagMatch import magenta.tasks.CloudFormation._ import magenta.tasks.UpdateCloudFormationTask.{ CloudFormationStackLookupStrategy, @@ -167,12 +168,13 @@ object CloudFormationStackMetadata { case class CloudFormationParameters( target: DeployTarget, - stackTags: Option[Map[String, String]], + stackTags: Option[Map[String, String]], // TODO remove this as its not used userParameters: Map[String, String], amiParameterMap: Map[CfnParam, TagCriteria], latestImage: CfnParam => String => String => Map[String, String] => Option[ String - ] + ], + minInServiceParameterMap: Map[CfnParam, List[TagMatch]] ) object CloudFormationParameters { @@ -209,7 +211,8 @@ object CloudFormationParameters { cfnParameters: CloudFormationParameters, accountNumber: String, templateParameters: List[TemplateParameter], - existingParameters: List[ExistingParameter] + existingParameters: List[ExistingParameter], + minInServiceParameters: Map[CfnParam, Int] ): Either[String, List[InputParameter]] = { val resolvedAmiParameters: Map[String, String] = @@ -237,8 +240,11 @@ object CloudFormationParameters { deployParameters = deploymentParameters, existingParameters = existingParameters, templateParameters = templateParameters, - specifiedParameters = - cfnParameters.userParameters ++ resolvedAmiParameters + specifiedParameters = { + cfnParameters.userParameters ++ + resolvedAmiParameters ++ + minInServiceParameters.map({ case (k, v) => k -> v.toString }) + } ) combined.map(convertParameters) } diff --git a/magenta-lib/src/test/scala/magenta/deployment_type/CloudFormationTest.scala b/magenta-lib/src/test/scala/magenta/deployment_type/CloudFormationTest.scala index 79f264404..fdb273d60 100644 --- a/magenta-lib/src/test/scala/magenta/deployment_type/CloudFormationTest.scala +++ b/magenta-lib/src/test/scala/magenta/deployment_type/CloudFormationTest.scala @@ -568,7 +568,8 @@ class CloudFormationTest Map.empty, Map.empty, (_: CfnParam) => - (_: String) => (_: String) => (_: Map[String, String]) => None + (_: String) => (_: String) => (_: Map[String, String]) => None, + Map.empty ) val params = resolve( @@ -580,7 +581,8 @@ class CloudFormationTest TemplateParameter("param3", default = true), TemplateParameter("Stage", default = true) ), - Nil + Nil, + Map.empty ) params.right.value shouldBe List(InputParameter("Stage", "PROD")) @@ -602,7 +604,8 @@ class CloudFormationTest Map.empty, Map.empty, (_: CfnParam) => - (_: String) => (_: String) => (_: Map[String, String]) => None + (_: String) => (_: String) => (_: Map[String, String]) => None, + Map.empty ) val params = resolve( @@ -618,7 +621,8 @@ class CloudFormationTest ExistingParameter("param1", "value1", None), ExistingParameter("param3", "value3", None), ExistingParameter("Stage", "BOB", None) - ) + ), + Map.empty ) params.right.value should contain theSameElementsAs (List( @@ -644,7 +648,8 @@ class CloudFormationTest Map.empty, Map.empty, (_: CfnParam) => - (_: String) => (_: String) => (_: Map[String, String]) => None + (_: String) => (_: String) => (_: Map[String, String]) => None, + Map.empty ) val params = resolve( @@ -659,7 +664,8 @@ class CloudFormationTest List( ExistingParameter("param3", "value3", None), ExistingParameter("Stage", "PROD", None) - ) + ), + Map.empty ) params.right.value should contain theSameElementsAs (List( @@ -686,7 +692,8 @@ class CloudFormationTest userParameters, Map.empty, (_: CfnParam) => - (_: String) => (_: String) => (_: Map[String, String]) => None + (_: String) => (_: String) => (_: Map[String, String]) => None, + Map.empty ) val params = resolve( @@ -701,7 +708,8 @@ class CloudFormationTest List( ExistingParameter("param3", "value3", None), ExistingParameter("Stage", "PROD", None) - ) + ), + Map.empty ) params.right.value should contain theSameElementsAs (List( @@ -727,7 +735,8 @@ class CloudFormationTest Map.empty, Map.empty, (_: CfnParam) => - (_: String) => (_: String) => (_: Map[String, String]) => None + (_: String) => (_: String) => (_: Map[String, String]) => None, + Map.empty ) val params = resolve( @@ -742,7 +751,8 @@ class CloudFormationTest List( ExistingParameter("param3", "value3", None), ExistingParameter("Stage", "PROD", None) - ) + ), + Map.empty ) params.left.value should startWith("Missing parameters for param1:") diff --git a/magenta-lib/src/test/scala/magenta/tasks/ASGTest.scala b/magenta-lib/src/test/scala/magenta/tasks/ASGTest.scala index 50225a271..91ff5df61 100644 --- a/magenta-lib/src/test/scala/magenta/tasks/ASGTest.scala +++ b/magenta-lib/src/test/scala/magenta/tasks/ASGTest.scala @@ -398,6 +398,150 @@ class ASGTest extends AnyFlatSpec with Matchers with MockitoSugar { ) shouldBe Right(()) } + it should "calculate MinInstancesInService as desired when there is capacity to double" in { + val asgClientMock = mock[AutoScalingClient] + val asgDescribeIterableMock = mock[DescribeAutoScalingGroupsIterable] + + when(asgDescribeIterableMock.autoScalingGroups()) thenReturn toSdkIterable( + List( + AutoScalingGroupWithTags( + min = 3, + max = 6, + desired = 3, + "Stack" -> "playground", + "Stage" -> "PROD", + "App" -> "api", + "aws:cloudformation:stack-name" -> "playground-PROD-api" + ) + ).asJava + ) + when( + asgClientMock.describeAutoScalingGroupsPaginator() + ) thenReturn asgDescribeIterableMock + + val minInstancesInService = + ASG.getMinInstancesInService( + List( + TagMatch("Stack", "playground"), + TagMatch("Stage", "PROD"), + TagMatch("App", "api"), + TagMatch("aws:cloudformation:stack-name", "playground-PROD-api") + ), + asgClientMock, + reporter + ) + + minInstancesInService shouldBe 3 + } + + it should "calculate MinInstancesInService as desired when partially scaled out" in { + val asgClientMock = mock[AutoScalingClient] + val asgDescribeIterableMock = mock[DescribeAutoScalingGroupsIterable] + + when(asgDescribeIterableMock.autoScalingGroups()) thenReturn toSdkIterable( + List( + AutoScalingGroupWithTags( + min = 3, + max = 9, + desired = 5, + "Stack" -> "playground", + "Stage" -> "PROD", + "App" -> "api", + "aws:cloudformation:stack-name" -> "playground-PROD-api" + ) + ).asJava + ) + when( + asgClientMock.describeAutoScalingGroupsPaginator() + ) thenReturn asgDescribeIterableMock + + val minInstancesInService = + ASG.getMinInstancesInService( + List( + TagMatch("Stack", "playground"), + TagMatch("Stage", "PROD"), + TagMatch("App", "api"), + TagMatch("aws:cloudformation:stack-name", "playground-PROD-api") + ), + asgClientMock, + reporter + ) + + minInstancesInService shouldBe 5 + } + + it should "calculate MinInstancesInService as 75% of max when desired is high" in { + val asgClientMock = mock[AutoScalingClient] + val asgDescribeIterableMock = mock[DescribeAutoScalingGroupsIterable] + + when(asgDescribeIterableMock.autoScalingGroups()) thenReturn toSdkIterable( + List( + AutoScalingGroupWithTags( + min = 10, + max = 100, + desired = 80, + "Stack" -> "playground", + "Stage" -> "PROD", + "App" -> "api", + "aws:cloudformation:stack-name" -> "playground-PROD-api" + ) + ).asJava + ) + when( + asgClientMock.describeAutoScalingGroupsPaginator() + ) thenReturn asgDescribeIterableMock + + val minInstancesInService = + ASG.getMinInstancesInService( + List( + TagMatch("Stack", "playground"), + TagMatch("Stage", "PROD"), + TagMatch("App", "api"), + TagMatch("aws:cloudformation:stack-name", "playground-PROD-api") + ), + asgClientMock, + reporter + ) + + minInstancesInService shouldBe 75 + } + + it should "calculate MinInstancesInService as 75% of max when fully scaled out" in { + val asgClientMock = mock[AutoScalingClient] + val asgDescribeIterableMock = mock[DescribeAutoScalingGroupsIterable] + + when(asgDescribeIterableMock.autoScalingGroups()) thenReturn toSdkIterable( + List( + AutoScalingGroupWithTags( + min = 10, + max = 100, + desired = 100, + "Stack" -> "playground", + "Stage" -> "PROD", + "App" -> "api", + "aws:cloudformation:stack-name" -> "playground-PROD-api" + ) + ).asJava + ) + when( + asgClientMock.describeAutoScalingGroupsPaginator() + ) thenReturn asgDescribeIterableMock + + val minInstancesInService = + ASG.getMinInstancesInService( + List( + TagMatch("Stack", "playground"), + TagMatch("Stage", "PROD"), + TagMatch("App", "api"), + TagMatch("aws:cloudformation:stack-name", "playground-PROD-api") + ), + asgClientMock, + reporter + ) + + minInstancesInService shouldBe 75 + } + object AutoScalingGroupWithTags { def apply(tags: (String, String)*): AutoScalingGroup = { val awsTags = tags map { case (key, value) => @@ -415,5 +559,22 @@ class ASGTest extends AnyFlatSpec with Matchers with MockitoSugar { .loadBalancerNames(elbName) .build() } + def apply( + min: Int, + max: Int, + desired: Int, + tags: (String, String)* + ): AutoScalingGroup = { + val awsTags = tags map { case (key, value) => + TagDescription.builder().key(key).value(value).build() + } + AutoScalingGroup + .builder() + .tags(awsTags.asJava) + .minSize(min) + .maxSize(max) + .desiredCapacity(desired) + .build() + } } }