From 05c99a77dbcd3e135d30c12a2108674d2cd0e722 Mon Sep 17 00:00:00 2001 From: Gigi Sayfan Date: Wed, 21 Oct 2026 02:59:32 -0700 Subject: [PATCH] [infra] make the general node groups min and max sizes code-owned, leaving only desired size out of band --- .../modules/controllers/cluster_autoscaler.tf | 10 ++-- .../aws/eks/modules/controllers/variables.tf | 2 +- .../eks/modules/eks-cluster/node_groups.tf | 55 ++++++++++++++++--- .../aws/eks/modules/eks-cluster/outputs.tf | 8 +++ .../aws/eks/modules/eks-cluster/variables.tf | 10 ++-- .../aws/eks/modules/space/controllers.tf | 7 ++- infra/space/aws/eks/modules/space/locals.tf | 7 ++- .../aws/eks/modules/space/node_groups.tf | 39 +++++++------ infra/space/aws/eks/modules/space/outputs.tf | 16 ++++-- .../space/aws/eks/modules/space/variables.tf | 12 ++-- infra/space/aws/reference/cluster/outputs.tf | 2 +- 11 files changed, 119 insertions(+), 49 deletions(-) diff --git a/infra/space/aws/eks/modules/controllers/cluster_autoscaler.tf b/infra/space/aws/eks/modules/controllers/cluster_autoscaler.tf index 5fffeeb78..40357bc18 100644 --- a/infra/space/aws/eks/modules/controllers/cluster_autoscaler.tf +++ b/infra/space/aws/eks/modules/controllers/cluster_autoscaler.tf @@ -10,10 +10,12 @@ # the same ASG ARNs for the same reason. # # Discovery rather than `--nodes=::` because a discovered group's -# bounds are the ASG's own min and max, read live. Terraform leaves the node -# group's sizes alone after creation, so an operator resizes the pool through -# the EKS API and cluster-autoscaler follows, and no apply ever sends a stale -# desired size back to the group. +# bounds are the ASG's own min and max, read live: the caller's apply sets them, +# and cluster-autoscaler follows without its arguments changing. The caller +# leaves the group's desired size to cluster-autoscaler, so only an apply that +# changes min or max sends a desired size back to the group, the one in its +# plan, or one that creates or replaces the group, which comes up at its +# configured size. # # Several groups are the general pool split one per zone. A single-zone ASG # never runs AZRebalance, which EKS drains without honoring PodDisruptionBudgets diff --git a/infra/space/aws/eks/modules/controllers/variables.tf b/infra/space/aws/eks/modules/controllers/variables.tf index 82790628b..043fe58fa 100644 --- a/infra/space/aws/eks/modules/controllers/variables.tf +++ b/infra/space/aws/eks/modules/controllers/variables.tf @@ -82,7 +82,7 @@ variable "aws_load_balancer_controller_resources" { } variable "cluster_autoscaler" { - description = "cluster-autoscaler for the serving general node groups, or null to install none of it. It scales each between that group's own min and max, read live from its ASG, so a resize through the EKS API moves its bounds too. With more than one group it balances scale-ups across them, so give it only groups that differ by zone alone: the same instance type, launch template, labels and taints." + description = "cluster-autoscaler for the serving general node groups, or null to install none of it. It scales each between that group's own min and max, read live from its ASG, so it follows the bounds an apply sets without a restart. With more than one group it balances scale-ups across them, so give it only groups that differ by zone alone: the same instance type, launch template, labels and taints." type = object({ role_name = string kubernetes_version = string diff --git a/infra/space/aws/eks/modules/eks-cluster/node_groups.tf b/infra/space/aws/eks/modules/eks-cluster/node_groups.tf index dea610a3f..34c0a52a5 100644 --- a/infra/space/aws/eks/modules/eks-cluster/node_groups.tf +++ b/infra/space/aws/eks/modules/eks-cluster/node_groups.tf @@ -194,18 +194,55 @@ resource "aws_eks_node_group" "managed" { update = var.node_group_update_timeout } - # These groups' size moves only outside Terraform: an operator resizing them - # through the EKS API, or a cluster-autoscaler the caller runs on one of them, - # which sets the ASG's desired capacity between the min and max it reads live - # from that ASG. Without this, the next apply for any unrelated change would - # reset each group to its creation-time sizing and scale it down under - # whatever is running on it. The sizes in node_pools still decide what a new - # cluster comes up with. + # min_size and max_size are code-owned: node_pools decides them, and a change + # lands through an apply. desired_size isn't. Something else moves it: an + # operator through the EKS API, or a cluster-autoscaler the caller runs on + # one of these groups. Without this, every unrelated apply would reset each + # group to its configured desired size and could scale it down under + # whatever is running on it. node_pools' desired_size still decides what a + # new group comes up with. + # + # The provider sends all three fields in one UpdateNodegroupConfig call, + # desired_size as it stood in state when the plan was made, and EKS requires + # min_size <= desired_size <= max_size. So raising min_size above a group's + # live desired size, or lowering max_size below it (say after + # cluster-autoscaler has grown the pool), can't apply: the managed_node_groups + # output refuses the plan. And an apply of a saved plan, on a pool + # cluster-autoscaler has resized since the plan, sends the old desired size + # back and resizes the pool to it. Both go away if the live group already + # has the new bounds when the plan is made: it then has no bound change and + # sends no size. Make that EKS change immediately before planning the + # matching code change, since any other apply in between reverts it (see + # below). To raise bounds, raise max, then desired if it's below the new + # min, then min. To lower them, lower min, then shrink the pool to the new + # max, then lower max. Shrink by draining a chosen node and terminating it + # with `aws autoscaling terminate-instance-in-auto-scaling-group + # --should-decrement-desired-capacity`, one at a time, each from the zone + # with the most nodes: a smaller desired size lets the ASG pick which + # instances go, and a multi-zone ASG left uneven runs AZRebalance, neither + # of which honors PodDisruptionBudgets. + # + # Otherwise, on a cluster running the autoscaler, plan immediately before + # applying a min/max change, check the plan's desired_size against the live + # group, and keep the change out of an apply that also rolls the group: the + # provider rolls first, which can take hours, and only then sends the + # desired size it planned. + # + # An out-of-band min/max change through the EKS API is drift: every later + # apply on the cluster, whatever it's for, plans to revert it, and the plan + # fails while the live desired size sits outside the configured bounds. + # Record an emergency resize in node_pools rather than leaving it live-only. lifecycle { ignore_changes = [ - scaling_config[0].min_size, scaling_config[0].desired_size, - scaling_config[0].max_size, ] } } + +locals { + managed_node_groups_outside_bounds = [ + for name, group in aws_eks_node_group.managed : name + if group.scaling_config[0].desired_size < group.scaling_config[0].min_size || + group.scaling_config[0].desired_size > group.scaling_config[0].max_size + ] +} diff --git a/infra/space/aws/eks/modules/eks-cluster/outputs.tf b/infra/space/aws/eks/modules/eks-cluster/outputs.tf index 8ab301819..d6e17f6ba 100644 --- a/infra/space/aws/eks/modules/eks-cluster/outputs.tf +++ b/infra/space/aws/eks/modules/eks-cluster/outputs.tf @@ -100,6 +100,14 @@ output "managed_node_groups" { scaling_config = group.scaling_config } } + + # desired_size is ignored, so the plan carries the size EKS reported when it + # refreshed, and a min/max change sends that size with the new bounds. EKS + # refuses one outside them partway through the apply; this refuses the plan. + precondition { + condition = length(local.managed_node_groups_outside_bounds) == 0 + error_message = "The live desired size of ${join(", ", local.managed_node_groups_outside_bounds)} is outside its configured min_size and max_size, and EKS refuses those bounds. Move the live group to the new bounds through the EKS API, in the order the comment on aws_eks_node_group.managed in the eks-cluster module gives, and re-plan; or change the configured sizes, which for a group being created or replaced are its creation sizes." + } } output "managed_node_group_arns" { diff --git a/infra/space/aws/eks/modules/eks-cluster/variables.tf b/infra/space/aws/eks/modules/eks-cluster/variables.tf index a6a80c592..6f838d0da 100644 --- a/infra/space/aws/eks/modules/eks-cluster/variables.tf +++ b/infra/space/aws/eks/modules/eks-cluster/variables.tf @@ -134,11 +134,11 @@ variable "node_pools" { by default; `AL2023_ARM_64_STANDARD` for Graviton instance types); set, the group runs that AMI as `CUSTOM`, `ami_type` is ignored, and `node_pool_user_data` must carry the pool's bootstrap script. `additional_policy_arns` keys must be known at plan time; their - values may not be. For a pool with no `ami_id`, `min_size`, - `desired_size`, and `max_size` apply at creation only: afterwards the - group is resized through the EKS API (or its ASG's desired capacity by a - cluster-autoscaler the caller runs on it) and Terraform ignores all three. A custom-AMI pool - keeps its size under Terraform. `update_strategy` is the group's + values may not be. For a pool with no `ami_id`, `min_size` and + `max_size` are applied whenever they change, and `desired_size` at creation + only: afterwards it moves through the EKS API or a cluster-autoscaler the + caller runs on the group, and Terraform ignores it. A custom-AMI pool + keeps all three under Terraform. `update_strategy` is the group's rolling-update strategy: null leaves EKS's default, which launches replacements before draining; `MINIMAL` drains first, so a roll never runs more nodes than the group's size. diff --git a/infra/space/aws/eks/modules/space/controllers.tf b/infra/space/aws/eks/modules/space/controllers.tf index e8d6535ff..fb74bc7d7 100644 --- a/infra/space/aws/eks/modules/space/controllers.tf +++ b/infra/space/aws/eks/modules/space/controllers.tf @@ -18,7 +18,7 @@ locals { ) # The groups general_serving_node_group names, each with the recorded floor - # its live min has to meet: one group, or every per-AZ group for + # its min has to meet: one group, or every per-AZ group for # general-per-az. Its validations guarantee the names exist in # general_node_groups: general can only be retired while another group # serves, and general-2 or the per-AZ groups can only serve once they're @@ -29,6 +29,11 @@ locals { var.general_blue_green_node_group == null && var.general_per_az_node_groups == null ? 1 : var.general_min_size ) } + general_node_groups_outside_bounds = [ + for group in concat(aws_eks_node_group.general_blue_green, values(aws_eks_node_group.general_per_az)) : group.node_group_name + if group.scaling_config[0].desired_size < group.scaling_config[0].min_size || + group.scaling_config[0].desired_size > group.scaling_config[0].max_size + ] serving_general_node_groups = { for name in keys(local.serving_general_node_group_floors) : name => local.general_node_groups[name] } diff --git a/infra/space/aws/eks/modules/space/locals.tf b/infra/space/aws/eks/modules/space/locals.tf index df6c67e92..3fdfc1b26 100644 --- a/infra/space/aws/eks/modules/space/locals.tf +++ b/infra/space/aws/eks/modules/space/locals.tf @@ -227,7 +227,12 @@ locals { var.general_labels, ) max_size = var.general_max_size - min_size = var.general_min_size + # The recorded floor applies only while general serves. Parked, its + # minimum is zero, so an operator can scale it to zero through EKS + # without the next apply raising it again. desired_size is ignored, but + # the apply that drops the minimum sends the desired size the plan read, + # so plan it immediately before applying while the pool serves. + min_size = var.general_serving_node_group == "general" ? var.general_min_size : 0 ami_id = null service_account = { name = var.general_service_account_name diff --git a/infra/space/aws/eks/modules/space/node_groups.tf b/infra/space/aws/eks/modules/space/node_groups.tf index d010909cf..16171a050 100644 --- a/infra/space/aws/eks/modules/space/node_groups.tf +++ b/infra/space/aws/eks/modules/space/node_groups.tf @@ -252,7 +252,8 @@ resource "aws_eks_node_group" "general_blue_green" { scaling_config { desired_size = var.general_blue_green_node_group.recorded_active_size max_size = var.general_blue_green_node_group.max_size - min_size = var.general_blue_green_node_group.recorded_active_size + # As on general: the recorded floor only while this pool serves. + min_size = var.general_serving_node_group == "general-2" ? var.general_blue_green_node_group.recorded_active_size : 0 } update_config { @@ -279,16 +280,16 @@ resource "aws_eks_node_group" "general_blue_green" { delete = "2h" } - # After creation, operators scale this pool between zero and its active - # target through EKS, and while it serves, cluster-autoscaler moves its - # desired size inside those bounds. Unrelated Terraform applies must not - # wake a dormant pool or scale down one that is carrying the active general - # workload. + # Same ownership split as module.eks's managed groups, and the same + # constraint on raising min_size above the live desired size: min and max + # are code-owned, desired moves through EKS or, while this pool serves, + # cluster-autoscaler. So switching general_serving_node_group to a pool + # parked at zero, this one, general or a per-AZ group, needs it grown + # through EKS first, and the switch, like any bound change, should be + # planned immediately before it's applied (see the per-AZ steps below). lifecycle { ignore_changes = [ - scaling_config[0].min_size, scaling_config[0].desired_size, - scaling_config[0].max_size, ] } } @@ -304,11 +305,18 @@ resource "aws_eks_node_group" "general_blue_green" { # # Moving a cluster onto them takes three applies, each with nothing else in it: # 1. Declare general_per_az_node_groups. The groups come up at their floors -# beside the serving group, which keeps serving. A serving general's live -# min_size now has to hold general_min_size, as it does beside general-2. +# beside the serving group, which keeps serving. Their minimums stay zero +# until they serve. # 2. Set general_serving_node_group = "general-per-az". The discovery tags move, # so cluster-autoscaler stops scaling the old group and balances the new -# ones. Then cordon the old group's nodes and drain them through the +# ones, and the per-AZ minimums rise to each zone's min_size while the old +# group's falls to zero. The plan refuses a minimum above a group's live +# desired size, so grow any per-AZ group scaled below its floor through EKS +# and plan again. Each bound change sends the desired size from the plan, +# and cluster-autoscaler is still scaling the old group until this apply +# moves its tag, so plan immediately before applying: a saved plan applied +# after the autoscaler has grown the old group shrinks it back. +# Then cordon the old group's nodes and drain them through the # eviction API, which honors PodDisruptionBudgets and each pod's grace # period; the evicted pods land on the per-AZ groups, which # cluster-autoscaler grows as they go Pending (without it, grow them through @@ -356,7 +364,8 @@ resource "aws_eks_node_group" "general_per_az" { scaling_config { desired_size = each.value.min_size max_size = each.value.max_size - min_size = each.value.min_size + # As on general-2: each zone's floor only while the per-AZ groups serve. + min_size = var.general_serving_node_group == "general-per-az" ? each.value.min_size : 0 } update_config { @@ -383,14 +392,10 @@ resource "aws_eks_node_group" "general_per_az" { delete = "2h" } - # The same contract as general-2: after creation each group is sized through - # EKS, and while the groups serve, cluster-autoscaler moves their desired - # sizes, so no apply sends a stale size back. + # The same ownership split as general-2. lifecycle { ignore_changes = [ - scaling_config[0].min_size, scaling_config[0].desired_size, - scaling_config[0].max_size, ] } } diff --git a/infra/space/aws/eks/modules/space/outputs.tf b/infra/space/aws/eks/modules/space/outputs.tf index e7cc6a9a8..bac8ddc7f 100644 --- a/infra/space/aws/eks/modules/space/outputs.tf +++ b/infra/space/aws/eks/modules/space/outputs.tf @@ -102,9 +102,9 @@ output "general_serving_node_group" { precondition { # Existing single-pool environments retain their positive-capacity gate. # Blue/green and per-AZ configurations additionally enforce the - # operator-recorded floor on every serving group. The sizes are the live - # ones: Terraform ignores them after creation, so the plan carries what EKS - # reports. + # operator-recorded floor on every serving group. Terraform sets a serving + # group's minimum to that floor, so those hold by construction; the + # single-pool gate still refuses a general_min_size of zero. condition = alltrue([ for name, group in local.serving_general_node_groups : group.scaling_config[0].min_size >= local.serving_general_node_group_floors[name] @@ -112,6 +112,14 @@ output "general_serving_node_group" { error_message = "The recorded general_serving_node_group must be live and, for blue/green and per-AZ configurations, meet its recorded capacity floor." } + # As module.eks checks for its managed groups: a serving switch raises the + # incoming group's minimum to its floor, and EKS refuses one above the live + # desired size partway through the apply, so this refuses the plan instead. + precondition { + condition = length(local.general_node_groups_outside_bounds) == 0 + error_message = "The live desired size of ${join(", ", local.general_node_groups_outside_bounds)} is outside the min_size and max_size this plan sets, and EKS refuses those bounds. Move the live group to the new bounds through the EKS API, in the order the comment on aws_eks_node_group.managed in the eks-cluster module gives, and re-plan; or change the configured sizes, which for a group being created or replaced are its creation sizes." + } + precondition { condition = alltrue([ for name, asg in data.aws_autoscaling_group.retiring_general : @@ -153,7 +161,7 @@ output "general_serving_node_group" { } output "general_serving_min_size" { - description = "Largest live floor of the declared general groups. Any of them may carry the active workload, so a root-owned DaemonSet that must roll on the general pool gates on this while the inactive pools stay at zero; referencing it also orders the consumer after a floor update." + description = "Largest configured minimum among the declared general groups: the serving group's floor, or the largest zone floor while the per-AZ groups serve, since the parked groups' minimums are zero. A root-owned DaemonSet that must roll on the general pool gates on this; referencing it also orders the consumer after a floor update." value = max([for group in values(local.general_node_groups) : group.scaling_config[0].min_size]...) } diff --git a/infra/space/aws/eks/modules/space/variables.tf b/infra/space/aws/eks/modules/space/variables.tf index 6f02b0f5f..709dd8068 100644 --- a/infra/space/aws/eks/modules/space/variables.tf +++ b/infra/space/aws/eks/modules/space/variables.tf @@ -285,7 +285,7 @@ variable "external_dns_records_enabled" { } variable "cluster_autoscaler_enabled" { - description = "Whether to run cluster-autoscaler on the serving general node group (general_serving_node_group), bounded by each group's own min and max size as EKS reports them, so resizing a group through the EKS API moves its bounds. When the per-AZ groups serve, it scales all of them with --balance-similar-node-groups, so a scale-up goes to the zones with the fewest nodes. A scale-down picks nodes by utilization and can leave the zones uneven until the next scale-up. The serving pool's recorded floor (general_min_size, general_blue_green_node_group.recorded_active_size, or each zone's min_size) still has to hold for that min, so lower the record before lowering the min. It never touches any other pool." + description = "Whether to run cluster-autoscaler on the serving general node group (general_serving_node_group), bounded by each group's own min and max size as Terraform applies them. When the per-AZ groups serve, it scales all of them with --balance-similar-node-groups, so a scale-up goes to the zones with the fewest nodes. A scale-down picks nodes by utilization and can leave the zones uneven until the next scale-up. The serving pool's min is its recorded floor (general_min_size, general_blue_green_node_group.recorded_active_size, or each zone's min_size), and the parked pools' is zero. It never touches any other pool." type = bool default = false nullable = false @@ -773,7 +773,7 @@ variable "general_instance_types" { } variable "general_min_size" { - description = "Minimum size of the general node group" + description = "Minimum size of the general node group while general_serving_node_group names it, and, beside general-2 or the per-AZ groups, its recorded floor; zero while another group serves." type = number default = 1 } @@ -785,7 +785,7 @@ variable "general_max_size" { } variable "general_desired_size" { - description = "Desired size of the general node group" + description = "Desired size of the general node group at creation; afterwards it moves through the EKS API or cluster-autoscaler, and Terraform ignores it." type = number default = 2 } @@ -819,7 +819,7 @@ variable "general_taints" { } variable "general_blue_green_node_group" { - description = "Optional permanent general-2 MNG used for additive blue/green swaps. recorded_active_size creates the pool initially and records its operator-managed active target for capacity checks. After creation the pool is sized only through the EKS API, by operators or, while it serves, cluster-autoscaler." + description = "Optional permanent general-2 MNG used for additive blue/green swaps. recorded_active_size is the pool's size at creation, its min_size while general_serving_node_group names it (zero otherwise), and the floor capacity checks use. min_size and max_size are applied by Terraform; desired_size is left to operators or, while the pool serves, cluster-autoscaler." type = object({ instance_types = list(string) recorded_active_size = number @@ -840,7 +840,7 @@ variable "general_blue_green_node_group" { } variable "general_per_az_node_groups" { - description = "Optional general MNGs, one per availability zone and each in that zone's private subnet only, named general-. They run on general's launch template and node role, with its labels and taints, so general workloads move onto them without a Helm change. A single-zone group has no other zone to rebalance into, so EC2 Auto Scaling's AZRebalance never terminates one of its nodes. zones maps each zone to its group's min_size, which creates the group at that size and records its floor for capacity checks, and its max_size. Size each zone explicitly. Over three zones, a min_size of ceil(min/3) each keeps at least a pool-wide floor; a max_size of floor(max/3) each stays within a pool-wide ceiling, and ceil(max/3) can exceed it by up to two nodes. After creation the groups are sized only through the EKS API, by operators or, while they serve, cluster-autoscaler. Changing instance_types destroys every group and its nodes before recreating it, since the fixed names rule out creating the new group first. The plan refuses it until, in earlier applies, general or general-2 serves (declared again if need be) and each per-AZ group is drained and scaled to zero through EKS. general_capacity_type replaces these groups the same way, and general and general-2 with them, so it has no safe path while any general group serves. Declaring this makes every plan read the cluster's node groups from AWS, to refuse removing a general group that still runs nodes or that cluster-autoscaler still discovers. Setting it back to null turns those reads off, so that removal isn't checked unless general is retired: park the groups by hand first. Don't put this module behind a module-level depends_on, which defers the reads and fails the plan." + description = "Optional general MNGs, one per availability zone and each in that zone's private subnet only, named general-. They run on general's launch template and node role, with its labels and taints, so general workloads move onto them without a Helm change. A single-zone group has no other zone to rebalance into, so EC2 Auto Scaling's AZRebalance never terminates one of its nodes. zones maps each zone to its group's min_size and max_size. min_size is the group's size at creation, its minimum while general_serving_node_group is general-per-az (zero otherwise), and the floor capacity checks use. Size each zone explicitly. Over three zones, a min_size of ceil(min/3) each keeps at least a pool-wide floor; a max_size of floor(max/3) each stays within a pool-wide ceiling, and ceil(max/3) can exceed it by up to two nodes. Terraform applies each group's minimum and max_size; its desired size is left to operators or, while the groups serve, cluster-autoscaler. Changing instance_types destroys every group and its nodes before recreating it, since the fixed names rule out creating the new group first. The plan refuses it until, in earlier applies, general or general-2 serves (declared again if need be) and each per-AZ group is drained and scaled to zero through EKS. general_capacity_type replaces these groups the same way, and general and general-2 with them, so it has no safe path while any general group serves. Declaring this makes every plan read the cluster's node groups from AWS, to refuse removing a general group that still runs nodes or that cluster-autoscaler still discovers. Setting it back to null turns those reads off, so that removal isn't checked unless general is retired: park the groups by hand first. Don't put this module behind a module-level depends_on, which defers the reads and fails the plan." type = object({ instance_types = list(string) zones = map(object({ @@ -1019,7 +1019,7 @@ variable "ci_helm_deploy_external_id" { } variable "cred_authority_node_count" { - description = "Node count (min = desired) for the dedicated cred-authority pool that hosts credmgr-authority. Defaults to 1 for clusters without live authority traffic; set 2 on clusters with live authority traffic so the two authority replicas spread across nodes." + description = "Node count (min = desired, max one more) for the dedicated cred-authority pool that hosts credmgr-authority. Terraform applies min and max whenever they change but desired only at creation, and the plan refuses bounds that don't contain the live desired size. So on an existing pool, change it one step at a time. To grow, raise the pool's desired size by one through the EKS API, then raise this. To shrink, lower this by one, then drain one of the pool's nodes and terminate it with `aws autoscaling terminate-instance-in-auto-scaling-group --should-decrement-desired-capacity`. Defaults to 1 for clusters without live authority traffic; set 2 on clusters with live authority traffic so the two authority replicas spread across nodes." type = number default = 1 diff --git a/infra/space/aws/reference/cluster/outputs.tf b/infra/space/aws/reference/cluster/outputs.tf index 7b018f3a7..9f046b84a 100644 --- a/infra/space/aws/reference/cluster/outputs.tf +++ b/infra/space/aws/reference/cluster/outputs.tf @@ -19,7 +19,7 @@ output "cluster_name" { } output "node_pool_sizes" { - description = "Nodes each pool was sized for at the last apply, keyed by the `space.pplx.net/node-pool` label value. The scheduled job takes the pool names from here and each pool's current size from EKS, since `general` can be resized through the EKS API without a Terraform change." + description = "Nodes each pool was sized for at the last apply, keyed by the `space.pplx.net/node-pool` label value. The scheduled job takes the pool names from here and each pool's current size from EKS, since `general`'s desired size can move through the EKS API, within its configured bounds, without a Terraform change." value = { for pool, group in module.eks.node_groups : pool => group.scaling_config[0].desired_size }