@veltrixsecops/app-sdk 3.1.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +1 -1
  2. package/dist/byol/index.cjs +3802 -0
  3. package/dist/byol/index.cjs.map +1 -0
  4. package/dist/byol/index.d.cts +578 -0
  5. package/dist/byol/index.d.ts +578 -0
  6. package/dist/byol/index.js +2587 -0
  7. package/dist/byol/index.js.map +1 -0
  8. package/dist/chunk-3WK4EVQ3.js +842 -0
  9. package/dist/chunk-3WK4EVQ3.js.map +1 -0
  10. package/dist/{chunk-6KUTYJW5.js → chunk-HIL6RIDG.js} +107 -3
  11. package/dist/chunk-HIL6RIDG.js.map +1 -0
  12. package/dist/{chunk-VWFTOFTI.js → chunk-Y6XBNZGR.js} +8 -2
  13. package/dist/chunk-Y6XBNZGR.js.map +1 -0
  14. package/dist/client/index.cjs +112 -2
  15. package/dist/client/index.cjs.map +1 -1
  16. package/dist/client/index.d.cts +127 -6
  17. package/dist/client/index.d.ts +127 -6
  18. package/dist/client/index.js +13 -1
  19. package/dist/connections/index.cjs +1787 -0
  20. package/dist/connections/index.cjs.map +1 -0
  21. package/dist/connections/index.d.cts +78 -0
  22. package/dist/connections/index.d.ts +78 -0
  23. package/dist/connections/index.js +754 -0
  24. package/dist/connections/index.js.map +1 -0
  25. package/dist/hooks/index.cjs +7 -0
  26. package/dist/hooks/index.cjs.map +1 -1
  27. package/dist/hooks/index.d.cts +24 -3
  28. package/dist/hooks/index.d.ts +24 -3
  29. package/dist/hooks/index.js +7 -1
  30. package/dist/hooks/index.js.map +1 -1
  31. package/dist/index.cjs +7 -0
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +3 -3
  34. package/dist/index.d.ts +3 -3
  35. package/dist/index.js +3 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/opentofu/index.cjs +138 -0
  38. package/dist/opentofu/index.cjs.map +1 -0
  39. package/dist/opentofu/index.d.cts +131 -0
  40. package/dist/opentofu/index.d.ts +131 -0
  41. package/dist/opentofu/index.js +109 -0
  42. package/dist/opentofu/index.js.map +1 -0
  43. package/dist/pipeline/index.cjs +7 -0
  44. package/dist/pipeline/index.cjs.map +1 -1
  45. package/dist/pipeline/index.d.cts +9 -2
  46. package/dist/pipeline/index.d.ts +9 -2
  47. package/dist/pipeline/index.js +3 -1
  48. package/dist/pipeline-beeT8dje.d.cts +408 -0
  49. package/dist/pipeline-beeT8dje.d.ts +408 -0
  50. package/dist/ui/index.cjs +248 -0
  51. package/dist/ui/index.cjs.map +1 -1
  52. package/dist/ui/index.d.cts +238 -1
  53. package/dist/ui/index.d.ts +238 -1
  54. package/dist/ui/index.js +39 -573
  55. package/dist/ui/index.js.map +1 -1
  56. package/dist/{use-app-context-SEU1tyZd.d.cts → use-app-context-Byv_fam0.d.cts} +241 -4
  57. package/dist/{use-app-context-SEU1tyZd.d.ts → use-app-context-Byv_fam0.d.ts} +241 -4
  58. package/opentofu/README.md +114 -0
  59. package/opentofu/modules/aws/main.tf +1008 -0
  60. package/opentofu/modules/aws/outputs.tf +90 -0
  61. package/opentofu/modules/aws/variables.tf +437 -0
  62. package/opentofu/modules/aws/versions.tf +23 -0
  63. package/opentofu/modules/azure/main.tf +920 -0
  64. package/opentofu/modules/azure/outputs.tf +72 -0
  65. package/opentofu/modules/azure/variables.tf +498 -0
  66. package/opentofu/modules/azure/versions.tf +27 -0
  67. package/opentofu/modules/gcp/main.tf +645 -0
  68. package/opentofu/modules/gcp/outputs.tf +73 -0
  69. package/opentofu/modules/gcp/variables.tf +428 -0
  70. package/opentofu/modules/gcp/versions.tf +27 -0
  71. package/opentofu/modules/hetzner/main.tf +432 -0
  72. package/opentofu/modules/hetzner/outputs.tf +72 -0
  73. package/opentofu/modules/hetzner/variables.tf +423 -0
  74. package/opentofu/modules/hetzner/versions.tf +26 -0
  75. package/package.json +33 -1
  76. package/dist/chunk-6KUTYJW5.js.map +0 -1
  77. package/dist/chunk-VWFTOFTI.js.map +0 -1
  78. package/dist/pipeline-A-gSsPRR.d.cts +0 -189
  79. package/dist/pipeline-A-gSsPRR.d.ts +0 -189
@@ -0,0 +1,920 @@
1
+ # =============================================================================
2
+ # Azure environment module — generic, tool-agnostic. A faithful translation of
3
+ # sdk/opentofu/modules/aws. Driven by an app's rendered InfraSpec; NOTHING here
4
+ # is tool-specific.
5
+ #
6
+ # Network mode (worker-set deployment var, NOT app InfraSpec):
7
+ # shared — Veltrix-hosted: data-source the shared VNet + create the
8
+ # allocated per-stack subnet.
9
+ # dedicated — BYOC: CREATE a fresh VNet + App Gateway/private subnets + a NAT
10
+ # gateway (private egress) in the customer's subscription.
11
+ # existing — BYOC: data-source a customer VNet + create subnets in it.
12
+ # DNS mode: managed (in-account public record; App Gateway consumes a Key Vault
13
+ # cert) / delegated (worker does the cross-account public DNS; module uses
14
+ # certificate_arn) / private-only.
15
+ #
16
+ # One compute VM per compute plan item (for_each keyed by plan_key) + storage /
17
+ # secrets / TLS / LB / DNS per topology tier. Isolation: per-stack subnet +
18
+ # NSG/ASG SG-to-SG. Cost/attribution: var.tags on every taggable resource.
19
+ #
20
+ # Azure <-> AWS primitive map: VNet=VPC, Subnet=Subnet, NSG+ASG=SG self-ref,
21
+ # App Gateway(+WAF policy)=ALB(+WAFv2), Private DNS zone=Route53 private zone,
22
+ # Storage Account=S3, Key Vault=Secrets Manager. See per-resource comments.
23
+ # =============================================================================
24
+
25
+ locals {
26
+ # Short, DNS/label-safe prefix. infrastructure_id is a UUID; 8 chars is enough
27
+ # to disambiguate within a customer while staying under Azure name limits.
28
+ name_prefix = "${var.app_id}-${substr(var.infrastructure_id, 0, 8)}"
29
+
30
+ # plan_key -> plan object, for compute nodes only. This map's keys ARE the
31
+ # azurerm_linux_virtual_machine.node[...] addresses, so status maps 1:1 back to
32
+ # resource rows. Tool-agnostic: an explicit compute_kinds allow-list wins;
33
+ # otherwise compute = any plan item whose kind is NOT a generic foundation kind.
34
+ compute_nodes = {
35
+ for r in var.plan : r.plan_key => r
36
+ if(length(var.compute_kinds) > 0
37
+ ? contains(var.compute_kinds, r.kind)
38
+ : !contains(var.foundation_kinds, r.kind))
39
+ }
40
+
41
+ # Presence flags for the optional foundation tiers (derived from the plan).
42
+ has_storage = length([for r in var.plan : r if r.kind == "storage"]) > 0
43
+ has_secrets = length([for r in var.plan : r if r.kind == "secrets"]) > 0
44
+ has_license_file = length([for r in var.plan : r if r.kind == "license-file"]) > 0
45
+ has_tls = length([for r in var.plan : r if r.kind == "tls"]) > 0
46
+ has_lb = length([for r in var.plan : r if r.kind == "load-balancer"]) > 0
47
+ has_dns = length([for r in var.plan : r if r.kind == "dns"]) > 0 && var.public_dns_zone_name != ""
48
+ has_hec = length([for r in var.plan : r if r.kind == "hec"]) > 0
49
+
50
+ # A single Key Vault backs both the secrets bundle and the BYOL license file.
51
+ has_key_vault = local.has_secrets || local.has_license_file
52
+
53
+ # A per-resource Name/plan_key is merged INTO the canonical tag set so every
54
+ # object still carries var.tags (incl. Veltrix:ManagedBy) verbatim.
55
+ base_tags = var.tags
56
+
57
+ # --- Network mode: hosted-shared vs BYOC dedicated/existing -----------
58
+ is_dedicated = var.network_mode == "dedicated"
59
+ lookup_network = var.network_mode == "shared" || var.network_mode == "existing"
60
+
61
+ # SSH key vs generated-password auth for the VMs.
62
+ use_ssh_key = var.admin_ssh_public_key != ""
63
+
64
+ # Resolved network + subnet sets, uniform across all three modes:
65
+ # dedicated -> the created VNet + its private (compute) / appgw subnets
66
+ # shared/existing -> the looked-up VNet + the single allocated subnet
67
+ # Azure subnets span the region (no per-AZ split like AWS), so each set is a
68
+ # single subnet — but we keep them as lists to mirror the AWS locals exactly.
69
+ network_id = local.is_dedicated ? azurerm_virtual_network.env[0].id : data.azurerm_virtual_network.shared[0].id
70
+
71
+ compute_subnet_ids = local.is_dedicated ? [azurerm_subnet.private[0].id] : [azurerm_subnet.env[0].id]
72
+ lb_subnet_id = local.is_dedicated ? azurerm_subnet.appgw[0].id : one(azurerm_subnet.appgw_shared[*].id)
73
+
74
+ # Compute nodes are spread round-robin across the available compute subnets
75
+ # (a single subnet on Azure, so all land on it — the loop mirrors AWS 1:1).
76
+ compute_subnet_for = {
77
+ for idx, k in sort(keys(local.compute_nodes)) : k =>
78
+ local.compute_subnet_ids[idx % length(local.compute_subnet_ids)]
79
+ }
80
+
81
+ # Plan-time-known CIDR of the App Gateway subnet, used as the "alb" source in
82
+ # NSG rules (the AWS module references the ALB SG id; on Azure we reference the
83
+ # gateway subnet prefix). dedicated -> carved from vpc_cidr; else the caller's
84
+ # appgw_subnet_cidr. Never a computed id, so it is safe in a for_each/rule set.
85
+ appgw_subnet_prefix = local.is_dedicated ? cidrsubnet(var.vpc_cidr, 4, 0) : var.appgw_subnet_cidr
86
+
87
+ # --- DNS mode: managed (in-account) / delegated (worker x-account) / none
88
+ dns_managed = var.dns_mode == "managed"
89
+ has_public_dns = var.dns_mode != "private-only"
90
+
91
+ # The App Gateway listener consumes a Key Vault certificate. Azure has no
92
+ # in-module public CA issuance (unlike AWS ACM), so BOTH managed and delegated
93
+ # reference var.certificate_arn (a KV cert secret id) here.
94
+ listener_cert_secret_id = var.certificate_arn
95
+
96
+ # --- Derived front-door / listener gates ------------------------------
97
+ # has_lb = the plan carries a load-balancer item.
98
+ # has_lb_spec = has_lb AND the app supplied a load_balancer spec. Unlike AWS
99
+ # (which can create a bare aws_lb on has_lb alone), an Azure Application
100
+ # Gateway REQUIRES a full backend/probe/listener/rule config, so the gateway
101
+ # is gated on has_lb_spec.
102
+ has_lb_spec = local.has_lb && var.load_balancer != null
103
+ # HTTPS listener needs BOTH a KV cert id AND a user-assigned identity that can
104
+ # read it. When either is missing the App Gateway serves HTTP only (so a
105
+ # partial plan still applies) — this is the documented Azure divergence from
106
+ # AWS's has_listener (AWS issues its own cert in managed mode).
107
+ has_listener = local.has_lb_spec && var.dns_domain != "" && var.certificate_arn != "" && var.appgw_identity_id != ""
108
+ # alb_auth (Cognito MFA) has NO Application Gateway equivalent — accepted but
109
+ # no-op'd (see the App Gateway block). Kept for tfvars uniformity.
110
+ alb_auth_enabled = var.alb_auth.enabled
111
+
112
+ # Azure protocol enum is title-case ("Http"/"Https"); the spec is upper-case.
113
+ # Guarded with a null check because locals are always evaluated (the App
114
+ # Gateway that consumes them only exists when has_lb_spec).
115
+ appgw_backend_protocol = var.load_balancer != null ? title(lower(var.load_balancer.target_protocol)) : "Http"
116
+ appgw_probe_protocol = (var.load_balancer != null && try(var.load_balancer.health_check_protocol, "") != "") ? (
117
+ title(lower(var.load_balancer.health_check_protocol))
118
+ ) : local.appgw_backend_protocol
119
+
120
+ # Compute nodes that sit behind the front door — the kinds the app named as LB
121
+ # targets. try() keeps it null-safe when there is no spec.
122
+ lb_target_kinds = try(var.load_balancer.target_kinds, [])
123
+ search_targets = {
124
+ for k, r in local.compute_nodes : k => r
125
+ if contains(local.lb_target_kinds, r.kind)
126
+ }
127
+
128
+ # --- Per-node function DNS labels (reused verbatim from the AWS module) ----
129
+ node_prefix = {
130
+ for k, r in local.compute_nodes : k => lookup(var.dns_prefixes, r.kind, r.kind)
131
+ }
132
+ node_keys_by_kind = {
133
+ for kind in distinct([for k, r in local.compute_nodes : r.kind]) : kind => sort([
134
+ for k, r in local.compute_nodes : k if r.kind == kind
135
+ ])
136
+ }
137
+ node_short_labels = {
138
+ for k, r in local.compute_nodes : k => format(
139
+ "%s%d",
140
+ local.node_prefix[k],
141
+ index(local.node_keys_by_kind[r.kind], k) + 1,
142
+ )
143
+ }
144
+ # plan_key -> function FQDN in the private zone. Empty when no domain is set.
145
+ node_fqdns = var.dns_domain != "" ? {
146
+ for k, label in local.node_short_labels : k => "${label}.${var.dns_domain}"
147
+ } : {}
148
+
149
+ # --- Private DNS zone resolution --------------------------------------
150
+ # Either create a private zone for var.dns_domain (create_private_zone) or point
151
+ # at a caller-supplied one (private_zone_id != "" => reuse). want_private_dns is
152
+ # the plan-time-known intent used to gate the per-node record for_each. On Azure
153
+ # a private DNS record is addressed by zone NAME (= dns_domain) + resource group,
154
+ # not a zone id — so private_zone_id acts only as the "reuse existing" flag.
155
+ create_private_zone = var.create_private_zone && var.dns_domain != "" && var.private_zone_id == ""
156
+ want_private_dns = var.dns_domain != "" && (var.private_zone_id != "" || local.create_private_zone)
157
+ private_zone_name = var.dns_domain
158
+ private_zone_rg = local.create_private_zone ? azurerm_resource_group.env.name : var.private_zone_resource_group
159
+
160
+ # Public managed record name relative to public_dns_zone_name ("@" for apex).
161
+ public_record_name = var.dns_domain == var.public_dns_zone_name ? "@" : trimsuffix(var.dns_domain, ".${var.public_dns_zone_name}")
162
+
163
+ # --- Flattened NSG ingress rules (from the app's security_rules) --------
164
+ # Each (rule, source) pair becomes one azurerm_network_security_rule, keyed
165
+ # "<port>-<protocol>-<source>". "alb"-sourced rules are dropped when there is no
166
+ # load balancer (no gateway subnet to reference). This replaces any hardcoded,
167
+ # tool-specific port list — the app declares its ports in InfraSpec.securityRules.
168
+ sg_ingress = merge([
169
+ for r in var.security_rules : {
170
+ for s in r.sources : "${r.port}-${r.protocol}-${s}" => {
171
+ port = r.port
172
+ protocol = r.protocol
173
+ source = s
174
+ description = r.description != "" ? r.description : "port ${r.port} (${r.protocol}) from ${s}"
175
+ }
176
+ if !(s == "alb" && !local.has_lb)
177
+ }
178
+ ]...)
179
+
180
+ # NSG rules need an ascending, unique priority. Derive it deterministically from
181
+ # the lexically-sorted key set (plan-time-known) so every apply is stable.
182
+ sg_ingress_keys = sort(keys(local.sg_ingress))
183
+ sg_rule_priority = { for i, k in local.sg_ingress_keys : k => 100 + i }
184
+
185
+ # App Gateway sub-resource names (referenced across multiple blocks).
186
+ appgw_frontend_ip_name = "frontend-ip"
187
+ appgw_http_port_name = "port-http"
188
+ appgw_https_port_name = "port-https"
189
+ appgw_backend_pool_name = "node-pool"
190
+ appgw_backend_http_name = "node-http-settings"
191
+ appgw_probe_name = "node-probe"
192
+ appgw_http_listener_name = "http-listener"
193
+ appgw_https_listener_name = "https-listener"
194
+ appgw_ssl_cert_name = "listener-cert"
195
+ appgw_gateway_ip_name = "gateway-ip-config"
196
+ appgw_redirect_name = "http-to-https"
197
+ }
198
+
199
+ # Current subscription/tenant/principal — for Key Vault tenant + access policy.
200
+ data "azurerm_client_config" "current" {}
201
+
202
+ # --- Per-stack resource group ---------------------------------------------
203
+ # Azure requires a resource group for every resource (AWS has no equivalent). We
204
+ # create ONE per stack so a stack's resources are grouped + destroyed together.
205
+ # In shared/existing mode the compute subnet still lands in the VNet's OWN RG
206
+ # (subnets belong to their VNet), so only that subnet lives outside this RG.
207
+ resource "azurerm_resource_group" "env" {
208
+ name = "${local.name_prefix}-rg"
209
+ location = var.region
210
+
211
+ tags = merge(local.base_tags, {
212
+ Name = "${local.name_prefix}-rg"
213
+ "Veltrix:PlanKey" = "foundation/network"
214
+ "Veltrix:Tier" = "foundation"
215
+ })
216
+ }
217
+
218
+ # --- Network lookup (network_mode = shared | existing) --------------------
219
+ # The VNet is data-sourced (never created) — the shared Veltrix VNet for hosted,
220
+ # or a customer-designated VNet for BYOC "existing". Absent when dedicated. Azure
221
+ # resolves a VNet by name + resource group (there is no by-id data source).
222
+ data "azurerm_virtual_network" "shared" {
223
+ count = local.lookup_network ? 1 : 0
224
+ name = var.network_ref
225
+ resource_group_name = var.network_resource_group
226
+ }
227
+
228
+ # The stack's single allocated compute subnet, in the looked-up VNet
229
+ # (shared|existing). Created into the VNet's RG. Azure subnets are not taggable.
230
+ resource "azurerm_subnet" "env" {
231
+ count = local.lookup_network ? 1 : 0
232
+ name = "${local.name_prefix}-subnet"
233
+ resource_group_name = data.azurerm_virtual_network.shared[0].resource_group_name
234
+ virtual_network_name = data.azurerm_virtual_network.shared[0].name
235
+ address_prefixes = [var.subnet_cidr]
236
+ }
237
+
238
+ # Dedicated App Gateway subnet in shared/existing mode. An Application Gateway
239
+ # MUST have its own empty subnet, so we carve one from appgw_subnet_cidr whenever
240
+ # there is a load balancer (the Azure analogue of AWS extra_lb_subnet_ids).
241
+ resource "azurerm_subnet" "appgw_shared" {
242
+ count = local.lookup_network && local.has_lb ? 1 : 0
243
+ name = "${local.name_prefix}-appgw-subnet"
244
+ resource_group_name = data.azurerm_virtual_network.shared[0].resource_group_name
245
+ virtual_network_name = data.azurerm_virtual_network.shared[0].name
246
+ address_prefixes = [var.appgw_subnet_cidr]
247
+ }
248
+
249
+ # --- Dedicated network fabric (network_mode = dedicated / BYOC) ------------
250
+ # A fresh, isolated VNet created in the DEPLOY subscription (the customer's, for
251
+ # BYOC): one App Gateway subnet + one private compute subnet + a NAT gateway for
252
+ # private egress. Nothing here runs in shared/existing mode.
253
+ resource "azurerm_virtual_network" "env" {
254
+ count = local.is_dedicated ? 1 : 0
255
+ name = "${local.name_prefix}-vnet"
256
+ location = var.region
257
+ resource_group_name = azurerm_resource_group.env.name
258
+ address_space = [var.vpc_cidr]
259
+
260
+ tags = merge(local.base_tags, {
261
+ Name = "${local.name_prefix}-vnet"
262
+ "Veltrix:PlanKey" = "foundation/network"
263
+ "Veltrix:Tier" = "foundation"
264
+ })
265
+ }
266
+
267
+ # App Gateway subnet (dedicated) — cidrsubnet(/16, 4, 0) => first /20.
268
+ resource "azurerm_subnet" "appgw" {
269
+ count = local.is_dedicated ? 1 : 0
270
+ name = "${local.name_prefix}-appgw-subnet"
271
+ resource_group_name = azurerm_resource_group.env.name
272
+ virtual_network_name = azurerm_virtual_network.env[0].name
273
+ address_prefixes = [cidrsubnet(var.vpc_cidr, 4, 0)]
274
+ }
275
+
276
+ # Private compute subnet (dedicated) — cidrsubnet(/16, 4, 1) => second /20.
277
+ resource "azurerm_subnet" "private" {
278
+ count = local.is_dedicated ? 1 : 0
279
+ name = "${local.name_prefix}-private-subnet"
280
+ resource_group_name = azurerm_resource_group.env.name
281
+ virtual_network_name = azurerm_virtual_network.env[0].name
282
+ address_prefixes = [cidrsubnet(var.vpc_cidr, 4, 1)]
283
+ }
284
+
285
+ # NAT gateway for private-subnet egress (peers, object storage, license/updates).
286
+ resource "azurerm_public_ip" "nat" {
287
+ count = local.is_dedicated ? 1 : 0
288
+ name = "${local.name_prefix}-nat-pip"
289
+ location = var.region
290
+ resource_group_name = azurerm_resource_group.env.name
291
+ allocation_method = "Static"
292
+ sku = "Standard"
293
+
294
+ tags = merge(local.base_tags, {
295
+ Name = "${local.name_prefix}-nat-pip"
296
+ "Veltrix:PlanKey" = "foundation/network"
297
+ "Veltrix:Tier" = "foundation"
298
+ })
299
+ }
300
+
301
+ resource "azurerm_nat_gateway" "env" {
302
+ count = local.is_dedicated ? 1 : 0
303
+ name = "${local.name_prefix}-nat"
304
+ location = var.region
305
+ resource_group_name = azurerm_resource_group.env.name
306
+ sku_name = "Standard"
307
+
308
+ tags = merge(local.base_tags, {
309
+ Name = "${local.name_prefix}-nat"
310
+ "Veltrix:PlanKey" = "foundation/network"
311
+ "Veltrix:Tier" = "foundation"
312
+ })
313
+ }
314
+
315
+ resource "azurerm_nat_gateway_public_ip_association" "env" {
316
+ count = local.is_dedicated ? 1 : 0
317
+ nat_gateway_id = azurerm_nat_gateway.env[0].id
318
+ public_ip_address_id = azurerm_public_ip.nat[0].id
319
+ }
320
+
321
+ # Route private-subnet egress through the NAT (the AWS private route table + NAT).
322
+ resource "azurerm_subnet_nat_gateway_association" "private" {
323
+ count = local.is_dedicated ? 1 : 0
324
+ subnet_id = azurerm_subnet.private[0].id
325
+ nat_gateway_id = azurerm_nat_gateway.env[0].id
326
+ }
327
+
328
+ # --- Security: SG-to-SG least privilege (NSG + Application Security Group) -----
329
+ # The AWS module self-references the node SG; on Azure "self" is expressed with an
330
+ # Application Security Group (ASG). Every compute NIC joins the node ASG, and
331
+ # NSG rules for "self" use it as source + destination — scoping intra-cluster
332
+ # traffic to THIS stack's instances, not a whole CIDR. "alb" => the App Gateway
333
+ # subnet prefix; "admin" => var.admin_cidr.
334
+
335
+ resource "azurerm_application_security_group" "node" {
336
+ name = "${local.name_prefix}-node-asg"
337
+ location = var.region
338
+ resource_group_name = azurerm_resource_group.env.name
339
+
340
+ tags = merge(local.base_tags, {
341
+ Name = "${local.name_prefix}-node-asg"
342
+ "Veltrix:PlanKey" = "foundation/network"
343
+ "Veltrix:Tier" = "foundation"
344
+ })
345
+ }
346
+
347
+ # Node NSG. Rules live in a single for_each'd standalone rule resource (below);
348
+ # do NOT add inline security_rule blocks here — mixing inline + standalone rules
349
+ # on one NSG makes them clobber each other (same caveat as the AWS module).
350
+ resource "azurerm_network_security_group" "node" {
351
+ name = "${local.name_prefix}-nsg"
352
+ location = var.region
353
+ resource_group_name = azurerm_resource_group.env.name
354
+
355
+ tags = merge(local.base_tags, {
356
+ Name = "${local.name_prefix}-nsg"
357
+ "Veltrix:PlanKey" = "foundation/network"
358
+ "Veltrix:Tier" = "foundation"
359
+ })
360
+ }
361
+
362
+ # One inbound rule per (security_rules entry, source), from local.sg_ingress.
363
+ # Exactly one source form is set per rule (the others are null/omitted):
364
+ # "self" -> source ASG; "alb" -> the App Gateway subnet prefix; "admin" ->
365
+ # var.admin_cidr. Destination is always the node ASG. NSG rules are not taggable.
366
+ resource "azurerm_network_security_rule" "node" {
367
+ for_each = local.sg_ingress
368
+
369
+ name = "allow-${each.key}"
370
+ priority = local.sg_rule_priority[each.key]
371
+ direction = "Inbound"
372
+ access = "Allow"
373
+ protocol = title(each.value.protocol)
374
+ source_port_range = "*"
375
+ destination_port_range = tostring(each.value.port)
376
+ description = each.value.description
377
+ resource_group_name = azurerm_resource_group.env.name
378
+ network_security_group_name = azurerm_network_security_group.node.name
379
+
380
+ source_application_security_group_ids = each.value.source == "self" ? [azurerm_application_security_group.node.id] : null
381
+ source_address_prefix = each.value.source == "admin" ? var.admin_cidr : (
382
+ each.value.source == "alb" ? local.appgw_subnet_prefix : null
383
+ )
384
+
385
+ destination_application_security_group_ids = [azurerm_application_security_group.node.id]
386
+ }
387
+
388
+ # Egress: Azure NSGs carry default rules that already permit outbound to the VNet
389
+ # and the Internet (AllowVnetOutBound / AllowInternetOutBound), so no explicit
390
+ # all-egress rule is needed (the AWS module adds one because AWS SGs deny egress
391
+ # by default). Nodes can therefore reach peers, object storage, and updates.
392
+
393
+ # --- Compute: one Linux VM (+ NIC) per compute plan item ------------------
394
+ # for_each keyed by plan_key => azurerm_linux_virtual_machine.node["data/indexer-1"].
395
+ # Hostname = the node's function short-label (idx1, sh1, ...).
396
+
397
+ resource "azurerm_network_interface" "node" {
398
+ for_each = local.compute_nodes
399
+ name = "${local.name_prefix}-${local.node_short_labels[each.key]}-nic"
400
+ location = var.region
401
+ resource_group_name = azurerm_resource_group.env.name
402
+
403
+ ip_configuration {
404
+ name = "internal"
405
+ subnet_id = local.compute_subnet_for[each.key]
406
+ private_ip_address_allocation = "Dynamic"
407
+ }
408
+
409
+ tags = merge(local.base_tags, {
410
+ Name = "${local.name_prefix}-${local.node_short_labels[each.key]}-nic"
411
+ "Veltrix:PlanKey" = each.key
412
+ "Veltrix:Tier" = each.value.tier
413
+ "Veltrix:Kind" = each.value.kind
414
+ "Veltrix:Role" = each.value.role
415
+ })
416
+ }
417
+
418
+ # Attach the node NSG to each compute NIC (closest analogue of AWS's per-instance
419
+ # vpc_security_group_ids). Associations are not taggable.
420
+ resource "azurerm_network_interface_security_group_association" "node" {
421
+ for_each = local.compute_nodes
422
+ network_interface_id = azurerm_network_interface.node[each.key].id
423
+ network_security_group_id = azurerm_network_security_group.node.id
424
+ }
425
+
426
+ # Join each compute NIC to the node ASG so "self" NSG rules scope to this stack.
427
+ resource "azurerm_network_interface_application_security_group_association" "node" {
428
+ for_each = local.compute_nodes
429
+ network_interface_id = azurerm_network_interface.node[each.key].id
430
+ application_security_group_id = azurerm_application_security_group.node.id
431
+ }
432
+
433
+ # When no SSH key is supplied, generate a complexity-compliant password so a
434
+ # minimal plan still applies (Azure requires SSH key OR password on a Linux VM).
435
+ resource "random_password" "vm" {
436
+ count = !local.use_ssh_key && length(local.compute_nodes) > 0 ? 1 : 0
437
+ length = 24
438
+ min_lower = 2
439
+ min_upper = 2
440
+ min_numeric = 2
441
+ min_special = 2
442
+ override_special = "!@#$%*()-_"
443
+ }
444
+
445
+ resource "azurerm_linux_virtual_machine" "node" {
446
+ for_each = local.compute_nodes
447
+ name = "${local.name_prefix}-${local.node_short_labels[each.key]}"
448
+ computer_name = local.node_short_labels[each.key]
449
+ location = var.region
450
+ resource_group_name = azurerm_resource_group.env.name
451
+ # Multi-AZ placement: pin to the node's availability zone when set (null = non-zonal).
452
+ zone = each.value.zone
453
+ size = coalesce(
454
+ lookup(var.vm_sizes_by_kind, each.value.kind, null),
455
+ lookup(var.vm_sizes, each.value.tier, null),
456
+ var.vm_size,
457
+ )
458
+ admin_username = var.admin_username
459
+ network_interface_ids = [azurerm_network_interface.node[each.key].id]
460
+
461
+ disable_password_authentication = local.use_ssh_key
462
+ admin_password = local.use_ssh_key ? null : one(random_password.vm[*].result)
463
+
464
+ dynamic "admin_ssh_key" {
465
+ for_each = local.use_ssh_key ? [1] : []
466
+ content {
467
+ username = var.admin_username
468
+ public_key = var.admin_ssh_public_key
469
+ }
470
+ }
471
+
472
+ os_disk {
473
+ caching = "ReadWrite"
474
+ storage_account_type = "StandardSSD_LRS"
475
+ disk_size_gb = var.os_disk_gb
476
+ }
477
+
478
+ # Custom image id when supplied; else the Ubuntu 22.04 LTS marketplace fallback
479
+ # (scaffold only — production must pass a tool-preinstalled image_ref).
480
+ source_image_id = var.image_ref != "" ? var.image_ref : null
481
+
482
+ dynamic "source_image_reference" {
483
+ for_each = var.image_ref == "" ? [1] : []
484
+ content {
485
+ publisher = "Canonical"
486
+ offer = "0001-com-ubuntu-server-jammy"
487
+ sku = "22_04-lts-gen2"
488
+ version = "latest"
489
+ }
490
+ }
491
+
492
+ tags = merge(local.base_tags, {
493
+ Name = "${local.name_prefix}-${local.node_short_labels[each.key]}"
494
+ "Veltrix:PlanKey" = each.key
495
+ "Veltrix:Tier" = each.value.tier
496
+ "Veltrix:Kind" = each.value.kind
497
+ "Veltrix:Role" = each.value.role
498
+ })
499
+ }
500
+
501
+ # --- Storage: object-storage account/container (SmartStore, warm/cold, etc.) --
502
+ # Generic private blob storage for the app's bulk/object storage. The tool's
503
+ # meaning is app-defined (InfraSpec.storage); the module just provisions it.
504
+ # Storage account names are globally unique, 3-24 chars, lowercase alphanumeric.
505
+ resource "random_string" "storage_suffix" {
506
+ count = local.has_storage ? 1 : 0
507
+ length = 6
508
+ upper = false
509
+ special = false
510
+ }
511
+
512
+ resource "azurerm_storage_account" "objstore" {
513
+ count = local.has_storage ? 1 : 0
514
+ name = "${substr("st${replace(lower(local.name_prefix), "/[^a-z0-9]/", "")}", 0, 18)}${random_string.storage_suffix[0].result}"
515
+ resource_group_name = azurerm_resource_group.env.name
516
+ location = var.region
517
+ account_tier = "Standard"
518
+ account_replication_type = "LRS"
519
+ min_tls_version = "TLS1_2"
520
+ allow_nested_items_to_be_public = false
521
+
522
+ tags = merge(local.base_tags, {
523
+ Name = "${local.name_prefix}-objstore"
524
+ "Veltrix:PlanKey" = "foundation/storage"
525
+ "Veltrix:Tier" = "foundation"
526
+ })
527
+ }
528
+
529
+ # Private container inside the account. Storage containers are not taggable.
530
+ resource "azurerm_storage_container" "objstore" {
531
+ count = local.has_storage ? 1 : 0
532
+ name = "objstore"
533
+ storage_account_name = azurerm_storage_account.objstore[0].name
534
+ container_access_type = "private"
535
+ }
536
+
537
+ # --- Secrets + BYOL license: a per-tenant Key Vault ------------------------
538
+ # One Key Vault backs the secrets bundle (admin seed / pass4SymmKey / etc.) AND
539
+ # the BYOL license file (each a secret). Created when EITHER tier is in the plan.
540
+ # An access policy grants the apply principal secret data-plane permissions.
541
+ resource "random_string" "kv_suffix" {
542
+ count = local.has_key_vault ? 1 : 0
543
+ length = 6
544
+ upper = false
545
+ special = false
546
+ }
547
+
548
+ resource "azurerm_key_vault" "secrets" {
549
+ count = local.has_key_vault ? 1 : 0
550
+ name = "${substr("kv${replace(lower(local.name_prefix), "/[^a-z0-9]/", "")}", 0, 18)}${random_string.kv_suffix[0].result}"
551
+ location = var.region
552
+ resource_group_name = azurerm_resource_group.env.name
553
+ tenant_id = data.azurerm_client_config.current.tenant_id
554
+ sku_name = "standard"
555
+
556
+ access_policy {
557
+ tenant_id = data.azurerm_client_config.current.tenant_id
558
+ object_id = data.azurerm_client_config.current.object_id
559
+ secret_permissions = ["Get", "List", "Set", "Delete", "Purge", "Recover"]
560
+ }
561
+
562
+ tags = merge(local.base_tags, {
563
+ Name = "${local.name_prefix}-kv"
564
+ "Veltrix:PlanKey" = "foundation/secrets"
565
+ "Veltrix:Tier" = "foundation"
566
+ })
567
+ }
568
+
569
+ # Seed secret for the secrets tier. Azure requires a value (AWS leaves the secret
570
+ # container empty), so we generate a real random seed; bring-up overwrites it,
571
+ # and ignore_changes keeps that rotation from showing as drift.
572
+ resource "random_password" "secret_seed" {
573
+ count = local.has_secrets ? 1 : 0
574
+ length = 32
575
+ min_lower = 2
576
+ min_upper = 2
577
+ min_numeric = 2
578
+ min_special = 2
579
+ override_special = "!@#$%*()-_"
580
+ }
581
+
582
+ resource "azurerm_key_vault_secret" "env" {
583
+ count = local.has_secrets ? 1 : 0
584
+ name = "env-secrets"
585
+ value = random_password.secret_seed[0].result
586
+ key_vault_id = azurerm_key_vault.secrets[0].id
587
+
588
+ tags = merge(local.base_tags, {
589
+ Name = "${local.name_prefix}-secrets"
590
+ "Veltrix:PlanKey" = "foundation/secrets"
591
+ "Veltrix:Tier" = "foundation"
592
+ })
593
+
594
+ lifecycle {
595
+ ignore_changes = [value]
596
+ }
597
+ }
598
+
599
+ # BYOL license file (stored as a secret; the real content is uploaded post-apply).
600
+ resource "azurerm_key_vault_secret" "license" {
601
+ count = local.has_license_file ? 1 : 0
602
+ name = "byol-license"
603
+ value = "REPLACE_WITH_LICENSE"
604
+ key_vault_id = azurerm_key_vault.secrets[0].id
605
+
606
+ tags = merge(local.base_tags, {
607
+ Name = "${local.name_prefix}-license"
608
+ "Veltrix:PlanKey" = "foundation/license-file"
609
+ "Veltrix:Tier" = "foundation"
610
+ })
611
+
612
+ lifecycle {
613
+ ignore_changes = [value]
614
+ }
615
+ }
616
+
617
+ # --- TLS certificate note --------------------------------------------------
618
+ # Azure has no in-module public CA issuance equivalent to AWS ACM. The App
619
+ # Gateway HTTPS listener consumes a Key Vault certificate referenced by
620
+ # var.certificate_arn (see the App Gateway ssl_certificate block). In dns_mode =
621
+ # managed the caller/worker provisions that KV cert (and the public A record is
622
+ # still created below); in delegated the worker also supplies certificate_arn.
623
+ # There is therefore NO azurerm resource here mirroring aws_acm_certificate.
624
+
625
+ # --- Front door: Application Gateway (+ WAF policy) ------------------------
626
+ # The Azure analogue of the ALB (+ WAFv2). Public IP + gateway-subnet IP config +
627
+ # backend pool (target-kind NICs, attached below) + backend HTTP settings + probe
628
+ # (from the app's load_balancer spec) + an HTTP listener, plus an HTTPS listener +
629
+ # HTTP->HTTPS redirect when a KV cert + identity are supplied. WAF_v2 SKU with an
630
+ # OWASP policy when waf_enabled, else Standard_v2. Gated on has_lb_spec.
631
+
632
+ resource "azurerm_public_ip" "appgw" {
633
+ count = local.has_lb_spec ? 1 : 0
634
+ name = "${local.name_prefix}-appgw-pip"
635
+ location = var.region
636
+ resource_group_name = azurerm_resource_group.env.name
637
+ allocation_method = "Static"
638
+ sku = "Standard"
639
+
640
+ tags = merge(local.base_tags, {
641
+ Name = "${local.name_prefix}-appgw-pip"
642
+ "Veltrix:PlanKey" = "foundation/load-balancer"
643
+ "Veltrix:Tier" = "foundation"
644
+ })
645
+ }
646
+
647
+ # OWASP-managed WAF policy, associated with the gateway when waf_enabled. Mirrors
648
+ # the AWS WAFv2 web ACL (managed rule groups). Default action is Prevention/block.
649
+ resource "azurerm_web_application_firewall_policy" "env" {
650
+ count = local.has_lb_spec && var.waf_enabled ? 1 : 0
651
+ name = "${local.name_prefix}-waf"
652
+ resource_group_name = azurerm_resource_group.env.name
653
+ location = var.region
654
+
655
+ policy_settings {
656
+ enabled = true
657
+ mode = "Prevention"
658
+ }
659
+
660
+ managed_rules {
661
+ managed_rule_set {
662
+ type = "OWASP"
663
+ version = "3.2"
664
+ }
665
+ }
666
+
667
+ tags = merge(local.base_tags, {
668
+ Name = "${local.name_prefix}-waf"
669
+ "Veltrix:PlanKey" = "foundation/load-balancer"
670
+ "Veltrix:Tier" = "foundation"
671
+ })
672
+ }
673
+
674
+ resource "azurerm_application_gateway" "env" {
675
+ count = local.has_lb_spec ? 1 : 0
676
+ name = "${local.name_prefix}-appgw"
677
+ location = var.region
678
+ resource_group_name = azurerm_resource_group.env.name
679
+
680
+ sku {
681
+ name = var.waf_enabled ? "WAF_v2" : "Standard_v2"
682
+ tier = var.waf_enabled ? "WAF_v2" : "Standard_v2"
683
+ capacity = 2
684
+ }
685
+
686
+ # OWASP WAF policy when enabled; null (and Standard_v2) otherwise.
687
+ firewall_policy_id = one(azurerm_web_application_firewall_policy.env[*].id)
688
+
689
+ gateway_ip_configuration {
690
+ name = local.appgw_gateway_ip_name
691
+ subnet_id = local.lb_subnet_id
692
+ }
693
+
694
+ frontend_ip_configuration {
695
+ name = local.appgw_frontend_ip_name
696
+ public_ip_address_id = azurerm_public_ip.appgw[0].id
697
+ }
698
+
699
+ frontend_port {
700
+ name = local.appgw_http_port_name
701
+ port = 80
702
+ }
703
+
704
+ dynamic "frontend_port" {
705
+ for_each = local.has_listener ? [1] : []
706
+ content {
707
+ name = local.appgw_https_port_name
708
+ port = var.load_balancer.listener_port
709
+ }
710
+ }
711
+
712
+ # Empty backend pool; target-kind NICs are attached via the association below
713
+ # (the Azure analogue of aws_lb_target_group_attachment, keyed by plan_key).
714
+ backend_address_pool {
715
+ name = local.appgw_backend_pool_name
716
+ }
717
+
718
+ # port/protocol/health check all come from the app's load_balancer spec.
719
+ backend_http_settings {
720
+ name = local.appgw_backend_http_name
721
+ cookie_based_affinity = "Disabled"
722
+ port = var.load_balancer.target_port
723
+ protocol = local.appgw_backend_protocol
724
+ request_timeout = 30
725
+ probe_name = local.appgw_probe_name
726
+ pick_host_name_from_backend_address = false
727
+ }
728
+
729
+ probe {
730
+ name = local.appgw_probe_name
731
+ protocol = local.appgw_probe_protocol
732
+ path = var.load_balancer.health_check_path
733
+ host = "127.0.0.1"
734
+ interval = 30
735
+ timeout = 5
736
+ unhealthy_threshold = 3
737
+ pick_host_name_from_backend_http_settings = false
738
+
739
+ match {
740
+ status_code = [var.load_balancer.health_check_matcher]
741
+ }
742
+ }
743
+
744
+ # Always-present HTTP listener on :80.
745
+ http_listener {
746
+ name = local.appgw_http_listener_name
747
+ frontend_ip_configuration_name = local.appgw_frontend_ip_name
748
+ frontend_port_name = local.appgw_http_port_name
749
+ protocol = "Http"
750
+ }
751
+
752
+ # HTTPS listener + KV cert + user-assigned identity, only when has_listener.
753
+ dynamic "http_listener" {
754
+ for_each = local.has_listener ? [1] : []
755
+ content {
756
+ name = local.appgw_https_listener_name
757
+ frontend_ip_configuration_name = local.appgw_frontend_ip_name
758
+ frontend_port_name = local.appgw_https_port_name
759
+ protocol = "Https"
760
+ ssl_certificate_name = local.appgw_ssl_cert_name
761
+ }
762
+ }
763
+
764
+ dynamic "ssl_certificate" {
765
+ for_each = local.has_listener ? [1] : []
766
+ content {
767
+ name = local.appgw_ssl_cert_name
768
+ key_vault_secret_id = local.listener_cert_secret_id
769
+ }
770
+ }
771
+
772
+ # The identity that reads the Key Vault cert. has_listener already requires
773
+ # appgw_identity_id to be non-empty.
774
+ dynamic "identity" {
775
+ for_each = local.has_listener ? [1] : []
776
+ content {
777
+ type = "UserAssigned"
778
+ identity_ids = [var.appgw_identity_id]
779
+ }
780
+ }
781
+
782
+ # HTTP -> HTTPS permanent redirect (the AWS HTTP(80) -> 301 listener), used by
783
+ # the http-redirect routing rule below.
784
+ dynamic "redirect_configuration" {
785
+ for_each = local.has_listener ? [1] : []
786
+ content {
787
+ name = local.appgw_redirect_name
788
+ redirect_type = "Permanent"
789
+ target_listener_name = local.appgw_https_listener_name
790
+ include_path = true
791
+ include_query_string = true
792
+ }
793
+ }
794
+
795
+ # No TLS cert -> the HTTP listener forwards straight to the backend.
796
+ dynamic "request_routing_rule" {
797
+ for_each = local.has_listener ? [] : [1]
798
+ content {
799
+ name = "http-forward"
800
+ rule_type = "Basic"
801
+ http_listener_name = local.appgw_http_listener_name
802
+ backend_address_pool_name = local.appgw_backend_pool_name
803
+ backend_http_settings_name = local.appgw_backend_http_name
804
+ priority = 100
805
+ }
806
+ }
807
+
808
+ # TLS cert present -> HTTPS forwards to the backend; HTTP redirects to HTTPS.
809
+ dynamic "request_routing_rule" {
810
+ for_each = local.has_listener ? [1] : []
811
+ content {
812
+ name = "https-forward"
813
+ rule_type = "Basic"
814
+ http_listener_name = local.appgw_https_listener_name
815
+ backend_address_pool_name = local.appgw_backend_pool_name
816
+ backend_http_settings_name = local.appgw_backend_http_name
817
+ priority = 100
818
+ }
819
+ }
820
+
821
+ dynamic "request_routing_rule" {
822
+ for_each = local.has_listener ? [1] : []
823
+ content {
824
+ name = "http-redirect"
825
+ rule_type = "Basic"
826
+ http_listener_name = local.appgw_http_listener_name
827
+ redirect_configuration_name = local.appgw_redirect_name
828
+ priority = 200
829
+ }
830
+ }
831
+
832
+ # NOTE (documented gap): AWS enforces optional Cognito/OIDC MFA at the ALB
833
+ # listener (authenticate-cognito). Azure Application Gateway has NO equivalent
834
+ # built-in auth action — the Azure path is Azure AD Application Proxy / Front
835
+ # Door + Entra ID, a follow-on. var.alb_auth (alb_auth_enabled) is therefore
836
+ # ACCEPTED but NOT wired here. Do NOT fake it.
837
+
838
+ tags = merge(local.base_tags, {
839
+ Name = "${local.name_prefix}-appgw"
840
+ "Veltrix:PlanKey" = "foundation/load-balancer"
841
+ "Veltrix:Tier" = "foundation"
842
+ })
843
+ }
844
+
845
+ # One backend-pool membership per web-serving node (the kinds named in the LB
846
+ # spec's target_kinds). Keyed by plan_key so the set tracks the compute for_each —
847
+ # the Azure analogue of aws_lb_target_group_attachment. Associations are untagged.
848
+ resource "azurerm_network_interface_application_gateway_backend_address_pool_association" "node" {
849
+ for_each = local.has_lb_spec ? local.search_targets : {}
850
+ network_interface_id = azurerm_network_interface.node[each.key].id
851
+ ip_configuration_name = "internal"
852
+ backend_address_pool_id = one([for p in azurerm_application_gateway.env[0].backend_address_pool : p.id if p.name == local.appgw_backend_pool_name])
853
+ }
854
+
855
+ # --- Public DNS record ----------------------------------------------------
856
+ # Created in-account only for dns_mode = managed. delegated => the worker writes
857
+ # it cross-account into Veltrix's zone; private-only => no public record. An alias
858
+ # A record (target_resource_id) points at the App Gateway public IP — the Azure
859
+ # analogue of the AWS alias to the ALB.
860
+ resource "azurerm_dns_a_record" "env" {
861
+ count = local.dns_managed && local.has_dns && local.has_lb_spec ? 1 : 0
862
+ name = local.public_record_name
863
+ zone_name = var.public_dns_zone_name
864
+ resource_group_name = var.public_dns_rg
865
+ ttl = 60
866
+ target_resource_id = azurerm_public_ip.appgw[0].id
867
+
868
+ tags = merge(local.base_tags, {
869
+ Name = "${local.name_prefix}-public-dns"
870
+ "Veltrix:PlanKey" = "foundation/dns"
871
+ "Veltrix:Tier" = "foundation"
872
+ })
873
+ }
874
+
875
+ # --- Private DNS: intra-cluster function FQDNs ----------------------------
876
+ # A Private DNS zone (linked to the deploy VNet) gives every node a stable
877
+ # function FQDN (idx1.<domain>, sh1.<domain>, ...). The bring-up layer uses
878
+ # node_fqdns (see outputs) to build its inventory. The PUBLIC record above is
879
+ # unaffected. Either create the zone here (create_private_zone) or reuse a
880
+ # caller-supplied one (private_zone_id != "").
881
+ resource "azurerm_private_dns_zone" "env" {
882
+ count = local.create_private_zone ? 1 : 0
883
+ name = var.dns_domain
884
+ resource_group_name = azurerm_resource_group.env.name
885
+
886
+ tags = merge(local.base_tags, {
887
+ Name = "${local.name_prefix}-private-zone"
888
+ "Veltrix:PlanKey" = "foundation/dns"
889
+ "Veltrix:Tier" = "foundation"
890
+ })
891
+ }
892
+
893
+ resource "azurerm_private_dns_zone_virtual_network_link" "env" {
894
+ count = local.create_private_zone ? 1 : 0
895
+ name = "${local.name_prefix}-dns-link"
896
+ resource_group_name = azurerm_resource_group.env.name
897
+ private_dns_zone_name = azurerm_private_dns_zone.env[0].name
898
+ virtual_network_id = local.network_id
899
+ registration_enabled = false
900
+
901
+ tags = merge(local.base_tags, {
902
+ Name = "${local.name_prefix}-dns-link"
903
+ "Veltrix:PlanKey" = "foundation/dns"
904
+ "Veltrix:Tier" = "foundation"
905
+ })
906
+ }
907
+
908
+ # One A record per compute node -> its private IP, keyed by plan_key so the set
909
+ # tracks the compute for_each. Gated on want_private_dns (plan-time-known). The
910
+ # record NAME is the relative label (idx1), the zone NAME is dns_domain. zone_name
911
+ # resolves via the created zone when present (establishing the dependency), else
912
+ # the reused zone name.
913
+ resource "azurerm_private_dns_a_record" "node" {
914
+ for_each = local.want_private_dns ? local.node_short_labels : {}
915
+ name = each.value
916
+ zone_name = coalesce(one(azurerm_private_dns_zone.env[*].name), local.private_zone_name)
917
+ resource_group_name = local.private_zone_rg
918
+ ttl = 60
919
+ records = [azurerm_network_interface.node[each.key].private_ip_address]
920
+ }