Skip to content

Commit 17e7bfe

Browse files
feat(terraform): add the Azure aks module (#14103)
1 parent a88146d commit 17e7bfe

5 files changed

Lines changed: 1321 additions & 0 deletions

File tree

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
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

Comments
 (0)