|
| 1 | +locals { |
| 2 | + # AKS requires the system pool inline on the cluster, so "main" is handled |
| 3 | + # separately from every other pool. |
| 4 | + main_pool = var.node_pools["main"] |
| 5 | + |
| 6 | + gpu_node_pool = var.enable_gpu_node_pool ? { |
| 7 | + gpu = { |
| 8 | + vm_size = var.gpu_node_vm_size |
| 9 | + min_count = 1 |
| 10 | + max_count = 1 |
| 11 | + os_disk_size_gb = 100 |
| 12 | + os_disk_type = "Managed" |
| 13 | + node_labels = { "onyx.app/gpu" = "true" } |
| 14 | + node_taints = ["nvidia.com/gpu=true:NoSchedule"] |
| 15 | + zones = [] |
| 16 | + mode = "User" |
| 17 | + } |
| 18 | + } : {} |
| 19 | + |
| 20 | + sandbox_node_pool = var.enable_sandbox_node_pool ? { |
| 21 | + sandbox = { |
| 22 | + vm_size = var.sandbox_node_vm_size |
| 23 | + min_count = var.sandbox_node_min_count |
| 24 | + max_count = var.sandbox_node_max_count |
| 25 | + os_disk_size_gb = var.sandbox_node_disk_size_gb |
| 26 | + os_disk_type = "Managed" |
| 27 | + node_labels = { "onyx.app/workload" = "sandbox" } |
| 28 | + node_taints = ["workload=sandbox:NoSchedule"] |
| 29 | + zones = [] |
| 30 | + mode = "User" |
| 31 | + } |
| 32 | + } : {} |
| 33 | + |
| 34 | + additional_node_pools = merge( |
| 35 | + { |
| 36 | + for key, pool in var.node_pools : key => pool |
| 37 | + if key != "main" && (key != "index" || var.index_node_pool_enabled) |
| 38 | + }, |
| 39 | + local.gpu_node_pool, |
| 40 | + local.sandbox_node_pool, |
| 41 | + ) |
| 42 | + |
| 43 | + # Setting the DNS service address from the service range removes the chance |
| 44 | + # of picking one outside it, which AKS rejects. |
| 45 | + dns_service_ip = var.dns_service_ip != null ? var.dns_service_ip : cidrhost(var.service_cidr, 10) |
| 46 | + |
| 47 | + # AKS rotates a pool through a spare name when a property changes. The name |
| 48 | + # must be a valid pool name and unique across the cluster, so truncating the |
| 49 | + # key is not enough: two keys sharing six characters would collide. A digest |
| 50 | + # of the full key makes it unique. |
| 51 | + rotation_names = { |
| 52 | + for key in concat(["main"], keys(local.additional_node_pools)) : |
| 53 | + key => substr("${substr(key, 0, 6)}${substr(sha1(key), 0, 6)}", 0, 12) |
| 54 | + } |
| 55 | + |
| 56 | + workload_identity_enabled = length(var.storage_account_ids) > 0 |
| 57 | + |
| 58 | + workload_service_account_names = distinct(concat( |
| 59 | + [var.workload_service_account_name], |
| 60 | + var.additional_workload_service_account_names, |
| 61 | + )) |
| 62 | + |
| 63 | + workload_service_account_subjects = { |
| 64 | + for name in local.workload_service_account_names : |
| 65 | + name => "system:serviceaccount:${var.workload_service_account_namespace}:${name}" |
| 66 | + } |
| 67 | + |
| 68 | + # Azure caps a federated credential name at 120 characters, and a namespace |
| 69 | + # and a service account name can each be 63. Truncating alone would let two |
| 70 | + # long names collide, so a digest of the full subject goes on the end. |
| 71 | + federated_credential_names = { |
| 72 | + for name, subject in local.workload_service_account_subjects : |
| 73 | + name => "${substr("${var.workload_service_account_namespace}-${name}", 0, 100)}-${substr(sha1(subject), 0, 8)}" |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +resource "azurerm_kubernetes_cluster" "this" { |
| 78 | + name = var.cluster_name |
| 79 | + resource_group_name = var.resource_group_name |
| 80 | + location = var.location |
| 81 | + dns_prefix = var.cluster_name |
| 82 | + kubernetes_version = var.kubernetes_version |
| 83 | + sku_tier = var.sku_tier |
| 84 | + |
| 85 | + private_cluster_enabled = var.private_cluster_enabled |
| 86 | + azure_policy_enabled = var.azure_policy_enabled |
| 87 | + role_based_access_control_enabled = true |
| 88 | + |
| 89 | + # The pair that makes workload identity work. The issuer is the trust anchor |
| 90 | + # the federated credentials below point at. |
| 91 | + oidc_issuer_enabled = true |
| 92 | + workload_identity_enabled = true |
| 93 | + |
| 94 | + # This pool is always the system pool, so it takes no mode argument. |
| 95 | + default_node_pool { |
| 96 | + name = "main" |
| 97 | + vm_size = local.main_pool.vm_size |
| 98 | + |
| 99 | + auto_scaling_enabled = true |
| 100 | + min_count = local.main_pool.min_count |
| 101 | + max_count = local.main_pool.max_count |
| 102 | + |
| 103 | + os_disk_size_gb = local.main_pool.os_disk_size_gb |
| 104 | + os_disk_type = local.main_pool.os_disk_type |
| 105 | + node_labels = local.main_pool.node_labels |
| 106 | + zones = local.main_pool.zones |
| 107 | + vnet_subnet_id = var.subnet_id |
| 108 | + |
| 109 | + # Without a name to rotate through, changing a property of the system pool |
| 110 | + # replaces the whole cluster instead of the pool. |
| 111 | + temporary_name_for_rotation = local.rotation_names["main"] |
| 112 | + |
| 113 | + tags = var.tags |
| 114 | + } |
| 115 | + |
| 116 | + identity { |
| 117 | + type = "SystemAssigned" |
| 118 | + } |
| 119 | + |
| 120 | + network_profile { |
| 121 | + network_plugin = "azure" |
| 122 | + network_plugin_mode = var.network_plugin_mode |
| 123 | + network_policy = var.network_policy |
| 124 | + network_data_plane = var.network_policy == "cilium" ? "cilium" : null |
| 125 | + pod_cidr = var.network_plugin_mode == "overlay" ? var.pod_cidr : null |
| 126 | + service_cidr = var.service_cidr |
| 127 | + dns_service_ip = local.dns_service_ip |
| 128 | + outbound_type = var.outbound_type |
| 129 | + load_balancer_sku = "standard" |
| 130 | + } |
| 131 | + |
| 132 | + storage_profile { |
| 133 | + disk_driver_enabled = true |
| 134 | + file_driver_enabled = true |
| 135 | + blob_driver_enabled = false |
| 136 | + snapshot_controller_enabled = true |
| 137 | + } |
| 138 | + |
| 139 | + dynamic "api_server_access_profile" { |
| 140 | + for_each = length(var.api_server_authorized_ip_ranges) > 0 ? [1] : [] |
| 141 | + content { |
| 142 | + authorized_ip_ranges = var.api_server_authorized_ip_ranges |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + dynamic "azure_active_directory_role_based_access_control" { |
| 147 | + for_each = length(var.entra_rbac_admin_group_object_ids) > 0 ? [1] : [] |
| 148 | + content { |
| 149 | + admin_group_object_ids = var.entra_rbac_admin_group_object_ids |
| 150 | + azure_rbac_enabled = var.entra_rbac_enabled |
| 151 | + } |
| 152 | + } |
| 153 | + |
| 154 | + dynamic "oms_agent" { |
| 155 | + for_each = var.log_analytics_workspace_id != null ? [1] : [] |
| 156 | + content { |
| 157 | + log_analytics_workspace_id = var.log_analytics_workspace_id |
| 158 | + msi_auth_for_monitoring_enabled = true |
| 159 | + } |
| 160 | + } |
| 161 | + |
| 162 | + tags = var.tags |
| 163 | + |
| 164 | + lifecycle { |
| 165 | + # AKS reports the node count the autoscaler settled on. Treating that as |
| 166 | + # drift would fight the autoscaler on every apply. |
| 167 | + ignore_changes = [default_node_pool[0].node_count] |
| 168 | + } |
| 169 | +} |
| 170 | + |
| 171 | +resource "azurerm_kubernetes_cluster_node_pool" "this" { |
| 172 | + for_each = local.additional_node_pools |
| 173 | + |
| 174 | + name = each.key |
| 175 | + kubernetes_cluster_id = azurerm_kubernetes_cluster.this.id |
| 176 | + vm_size = each.value.vm_size |
| 177 | + mode = each.value.mode |
| 178 | + |
| 179 | + auto_scaling_enabled = true |
| 180 | + min_count = each.value.min_count |
| 181 | + max_count = each.value.max_count |
| 182 | + |
| 183 | + os_disk_size_gb = each.value.os_disk_size_gb |
| 184 | + os_disk_type = each.value.os_disk_type |
| 185 | + node_labels = each.value.node_labels |
| 186 | + node_taints = each.value.node_taints |
| 187 | + zones = each.value.zones |
| 188 | + vnet_subnet_id = var.subnet_id |
| 189 | + |
| 190 | + temporary_name_for_rotation = local.rotation_names[each.key] |
| 191 | + |
| 192 | + tags = var.tags |
| 193 | + |
| 194 | + lifecycle { |
| 195 | + ignore_changes = [node_count] |
| 196 | + } |
| 197 | +} |
| 198 | + |
| 199 | +# --- Workload identity ------------------------------------------------------- |
| 200 | + |
| 201 | +resource "azurerm_user_assigned_identity" "workload" { |
| 202 | + count = local.workload_identity_enabled ? 1 : 0 |
| 203 | + |
| 204 | + name = "${var.cluster_name}-workload" |
| 205 | + resource_group_name = var.resource_group_name |
| 206 | + location = var.location |
| 207 | + tags = var.tags |
| 208 | +} |
| 209 | + |
| 210 | +# One credential per service account. The subject is the same |
| 211 | +# system:serviceaccount:<namespace>:<name> string the AWS module puts in an |
| 212 | +# IRSA trust policy; Azure matches it against the cluster's OIDC issuer. |
| 213 | +resource "azurerm_federated_identity_credential" "workload" { |
| 214 | + for_each = local.workload_identity_enabled ? local.workload_service_account_subjects : {} |
| 215 | + |
| 216 | + name = local.federated_credential_names[each.key] |
| 217 | + user_assigned_identity_id = azurerm_user_assigned_identity.workload[0].id |
| 218 | + audience = ["api://AzureADTokenExchange"] |
| 219 | + issuer = azurerm_kubernetes_cluster.this.oidc_issuer_url |
| 220 | + subject = each.value |
| 221 | +} |
| 222 | + |
| 223 | +# Counted rather than keyed by account id. The ids normally come from a storage |
| 224 | +# module in the same apply, so they are not known at plan time, and a for_each |
| 225 | +# over unknown keys fails the plan outright. |
| 226 | +resource "azurerm_role_assignment" "workload_storage" { |
| 227 | + count = local.workload_identity_enabled ? length(var.storage_account_ids) : 0 |
| 228 | + |
| 229 | + scope = var.storage_account_ids[count.index] |
| 230 | + role_definition_name = var.storage_role_definition_name |
| 231 | + principal_id = azurerm_user_assigned_identity.workload[0].principal_id |
| 232 | + principal_type = "ServicePrincipal" |
| 233 | + |
| 234 | + # The identity is created in this same apply, and Entra does not always have |
| 235 | + # the service principal replicated by the time the assignment is made. Without |
| 236 | + # this the apply fails with a transient PrincipalNotFound. |
| 237 | + skip_service_principal_aad_check = true |
| 238 | +} |
| 239 | + |
| 240 | +# Created before the service account below, which cannot exist without it. The |
| 241 | +# documented Helm install then targets this namespace rather than making its own. |
| 242 | +resource "kubernetes_namespace" "workload" { |
| 243 | + count = local.workload_identity_enabled && var.create_workload_service_account && var.create_workload_namespace ? 1 : 0 |
| 244 | + |
| 245 | + metadata { |
| 246 | + name = var.workload_service_account_namespace |
| 247 | + } |
| 248 | + |
| 249 | + depends_on = [azurerm_kubernetes_cluster.this] |
| 250 | +} |
| 251 | + |
| 252 | +resource "kubernetes_service_account" "workload" { |
| 253 | + count = local.workload_identity_enabled && var.create_workload_service_account ? 1 : 0 |
| 254 | + |
| 255 | + depends_on = [azurerm_kubernetes_cluster.this] |
| 256 | + |
| 257 | + metadata { |
| 258 | + name = var.workload_service_account_name |
| 259 | + # Reading the name back off the namespace is what orders this after it. |
| 260 | + namespace = var.create_workload_namespace ? kubernetes_namespace.workload[0].metadata[0].name : var.workload_service_account_namespace |
| 261 | + |
| 262 | + annotations = { |
| 263 | + "azure.workload.identity/client-id" = azurerm_user_assigned_identity.workload[0].client_id |
| 264 | + } |
| 265 | + |
| 266 | + # Without this label the webhook does not project a token into the pod, and |
| 267 | + # the identity is never used. |
| 268 | + labels = { |
| 269 | + "azure.workload.identity/use" = "true" |
| 270 | + } |
| 271 | + } |
| 272 | +} |
| 273 | + |
| 274 | +# --- Cluster add-ons --------------------------------------------------------- |
| 275 | + |
| 276 | +resource "kubernetes_storage_class" "premium" { |
| 277 | + count = var.create_premium_storage_class ? 1 : 0 |
| 278 | + |
| 279 | + metadata { |
| 280 | + name = var.premium_storage_class_name |
| 281 | + annotations = var.premium_storage_class_is_default ? { |
| 282 | + "storageclass.kubernetes.io/is-default-class" = "true" |
| 283 | + } : {} |
| 284 | + } |
| 285 | + |
| 286 | + storage_provisioner = "disk.csi.azure.com" |
| 287 | + reclaim_policy = "Delete" |
| 288 | + # A disk is created in one zone, so binding has to wait until the scheduler |
| 289 | + # has picked the node that will use it. |
| 290 | + volume_binding_mode = "WaitForFirstConsumer" |
| 291 | + allow_volume_expansion = true |
| 292 | + |
| 293 | + parameters = { |
| 294 | + skuName = "Premium_LRS" |
| 295 | + } |
| 296 | + |
| 297 | + depends_on = [azurerm_kubernetes_cluster.this] |
| 298 | +} |
| 299 | + |
| 300 | +resource "azurerm_monitor_diagnostic_setting" "this" { |
| 301 | + count = var.log_analytics_workspace_id != null ? 1 : 0 |
| 302 | + |
| 303 | + name = "${var.cluster_name}-diagnostics" |
| 304 | + target_resource_id = azurerm_kubernetes_cluster.this.id |
| 305 | + log_analytics_workspace_id = var.log_analytics_workspace_id |
| 306 | + |
| 307 | + dynamic "enabled_log" { |
| 308 | + for_each = var.control_plane_log_categories |
| 309 | + content { |
| 310 | + category = enabled_log.value |
| 311 | + } |
| 312 | + } |
| 313 | + |
| 314 | + enabled_metric { |
| 315 | + category = "AllMetrics" |
| 316 | + } |
| 317 | +} |
0 commit comments