@veltrixsecops/app-sdk 3.2.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 (77) 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-EOOEHZGC.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 +118 -6
  17. package/dist/client/index.d.ts +118 -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.map +1 -1
  26. package/dist/hooks/index.d.cts +2 -2
  27. package/dist/hooks/index.d.ts +2 -2
  28. package/dist/hooks/index.js +1 -1
  29. package/dist/index.cjs +7 -0
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.d.cts +3 -3
  32. package/dist/index.d.ts +3 -3
  33. package/dist/index.js +3 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/opentofu/index.cjs +138 -0
  36. package/dist/opentofu/index.cjs.map +1 -0
  37. package/dist/opentofu/index.d.cts +131 -0
  38. package/dist/opentofu/index.d.ts +131 -0
  39. package/dist/opentofu/index.js +109 -0
  40. package/dist/opentofu/index.js.map +1 -0
  41. package/dist/pipeline/index.cjs +7 -0
  42. package/dist/pipeline/index.cjs.map +1 -1
  43. package/dist/pipeline/index.d.cts +9 -2
  44. package/dist/pipeline/index.d.ts +9 -2
  45. package/dist/pipeline/index.js +3 -1
  46. package/dist/pipeline-beeT8dje.d.cts +408 -0
  47. package/dist/pipeline-beeT8dje.d.ts +408 -0
  48. package/dist/ui/index.cjs +136 -0
  49. package/dist/ui/index.cjs.map +1 -1
  50. package/dist/ui/index.d.cts +109 -1
  51. package/dist/ui/index.d.ts +109 -1
  52. package/dist/ui/index.js +35 -677
  53. package/dist/ui/index.js.map +1 -1
  54. package/dist/{use-app-context-OQlcmq7t.d.cts → use-app-context-Byv_fam0.d.cts} +206 -4
  55. package/dist/{use-app-context-OQlcmq7t.d.ts → use-app-context-Byv_fam0.d.ts} +206 -4
  56. package/opentofu/README.md +114 -0
  57. package/opentofu/modules/aws/main.tf +1008 -0
  58. package/opentofu/modules/aws/outputs.tf +90 -0
  59. package/opentofu/modules/aws/variables.tf +437 -0
  60. package/opentofu/modules/aws/versions.tf +23 -0
  61. package/opentofu/modules/azure/main.tf +920 -0
  62. package/opentofu/modules/azure/outputs.tf +72 -0
  63. package/opentofu/modules/azure/variables.tf +498 -0
  64. package/opentofu/modules/azure/versions.tf +27 -0
  65. package/opentofu/modules/gcp/main.tf +645 -0
  66. package/opentofu/modules/gcp/outputs.tf +73 -0
  67. package/opentofu/modules/gcp/variables.tf +428 -0
  68. package/opentofu/modules/gcp/versions.tf +27 -0
  69. package/opentofu/modules/hetzner/main.tf +432 -0
  70. package/opentofu/modules/hetzner/outputs.tf +72 -0
  71. package/opentofu/modules/hetzner/variables.tf +423 -0
  72. package/opentofu/modules/hetzner/versions.tf +26 -0
  73. package/package.json +33 -1
  74. package/dist/chunk-EOOEHZGC.js.map +0 -1
  75. package/dist/chunk-VWFTOFTI.js.map +0 -1
  76. package/dist/pipeline-A-gSsPRR.d.cts +0 -189
  77. package/dist/pipeline-A-gSsPRR.d.ts +0 -189
@@ -0,0 +1,1008 @@
1
+ # =============================================================================
2
+ # AWS environment module — generic, tool-agnostic (REFERENCE for the other
3
+ # clouds). Driven by an app's rendered InfraSpec; NOTHING here is tool-specific.
4
+ #
5
+ # Network mode (worker-set deployment var, NOT app InfraSpec):
6
+ # shared — Veltrix-hosted: data-source the shared VPC + allocated subnet.
7
+ # dedicated — BYOC: CREATE a fresh VPC + multi-AZ public/private subnets + IGW
8
+ # + NAT + route tables in the customer's account.
9
+ # existing — BYOC: data-source a customer VPC + create a subnet in it.
10
+ # DNS mode: managed (in-account record + ACM cert) / delegated (worker does the
11
+ # cross-account public DNS; module uses certificate_arn) / private-only.
12
+ #
13
+ # One compute resource per compute plan item (for_each keyed by plan_key) +
14
+ # storage / secrets / TLS / LB / DNS per topology tier. Isolation: per-stack
15
+ # subnet + SG-to-SG. Cost/attribution: var.tags on every taggable resource.
16
+ # =============================================================================
17
+
18
+ locals {
19
+ # Short, DNS/label-safe prefix. infrastructure_id is a UUID; 8 chars is enough
20
+ # to disambiguate within a customer while staying under AWS name limits.
21
+ name_prefix = "${var.app_id}-${substr(var.infrastructure_id, 0, 8)}"
22
+
23
+ # plan_key -> plan object, for compute nodes only. This map's keys ARE the
24
+ # aws_instance.node[...] addresses, so status maps 1:1 back to resource rows.
25
+ # Tool-agnostic: an explicit compute_kinds allow-list wins; otherwise compute =
26
+ # any plan item whose kind is NOT a generic foundation kind. So an app's roles
27
+ # (app nodes, e.g. any clustered workload's tiers) are compute
28
+ # automatically, with no per-tool list in the module.
29
+ compute_nodes = {
30
+ for r in var.plan : r.plan_key => r
31
+ if(length(var.compute_kinds) > 0
32
+ ? contains(var.compute_kinds, r.kind)
33
+ : !contains(var.foundation_kinds, r.kind))
34
+ }
35
+
36
+ # Presence flags for the optional foundation tiers (derived from the plan).
37
+ has_storage = length([for r in var.plan : r if r.kind == "storage"]) > 0
38
+ has_secrets = length([for r in var.plan : r if r.kind == "secrets"]) > 0
39
+ has_license_file = length([for r in var.plan : r if r.kind == "license-file"]) > 0
40
+ has_tls = length([for r in var.plan : r if r.kind == "tls"]) > 0
41
+ has_lb = length([for r in var.plan : r if r.kind == "load-balancer"]) > 0
42
+ has_dns = length([for r in var.plan : r if r.kind == "dns"]) > 0 && var.route53_zone_id != ""
43
+ has_hec = length([for r in var.plan : r if r.kind == "hec"]) > 0
44
+
45
+ # Instance-profile inline policy references only the secret ARNs this stack created
46
+ # ([*] splat yields [] when the count-gated resource is absent). node_needs_inline
47
+ # gates the inline policy so we never attach an empty document.
48
+ node_secret_arns = concat(aws_secretsmanager_secret.env[*].arn, aws_secretsmanager_secret.license[*].arn)
49
+ node_needs_inline = local.has_storage || length(local.node_secret_arns) > 0 || var.artifacts_bucket != ""
50
+
51
+ resolved_ami = var.ami_id != "" ? var.ami_id : data.aws_ami.al2023.id
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
+ # Two AZs for a multi-AZ dedicated fabric (and the ALB's 2-subnet minimum).
62
+ azs = slice(data.aws_availability_zones.available.names, 0, 2)
63
+
64
+ # Availability zones the plan pins nodes to (multi-AZ placement — indexer /
65
+ # search-head only). Unioned with the default AZs so a dedicated fabric creates
66
+ # a compute subnet for every zone a node needs.
67
+ placement_azs = distinct(compact([for r in var.plan : r.zone]))
68
+ all_azs = distinct(concat(local.azs, local.placement_azs))
69
+
70
+ # Resolved network + subnet sets, uniform across all three modes:
71
+ # dedicated -> the created VPC + its private (compute) / public (ALB) subnets
72
+ # shared/existing -> the looked-up VPC + the single allocated subnet
73
+ network_id = local.is_dedicated ? aws_vpc.env[0].id : data.aws_vpc.shared[0].id
74
+ compute_subnet_ids = local.is_dedicated ? [for s in aws_subnet.private : s.id] : [aws_subnet.env[0].id]
75
+ lb_subnet_ids = local.is_dedicated ? [for s in aws_subnet.public : s.id] : concat(
76
+ [aws_subnet.env[0].id], var.extra_lb_subnet_ids,
77
+ )
78
+ # AZ name -> compute subnet id. Dedicated mode has a private subnet per AZ;
79
+ # shared/existing has only the single allocated subnet, so this stays empty and
80
+ # every node falls back to that subnet (multi-AZ needs a dedicated fabric).
81
+ compute_subnet_by_az = local.is_dedicated ? {
82
+ for az in local.all_azs : az => aws_subnet.private[az].id
83
+ } : {}
84
+ # Pin each node to the subnet in its `zone` when one exists; otherwise spread
85
+ # round-robin across the available compute subnets (backward-compatible default
86
+ # for single-site plans, which carry no zone).
87
+ compute_subnet_for = {
88
+ for idx, k in sort(keys(local.compute_nodes)) : k =>
89
+ lookup(
90
+ local.compute_subnet_by_az,
91
+ coalesce(local.compute_nodes[k].zone, "__no_zone__"),
92
+ local.compute_subnet_ids[idx % length(local.compute_subnet_ids)],
93
+ )
94
+ }
95
+
96
+ # --- DNS mode: managed (in-account) / delegated (worker x-account) / none
97
+ dns_managed = var.dns_mode == "managed"
98
+ has_public_dns = var.dns_mode != "private-only"
99
+ # The listener cert is either issued here (managed) or supplied (delegated).
100
+ listener_cert_arn = local.dns_managed ? (
101
+ (local.has_tls && var.dns_domain != "") ? aws_acm_certificate.env[0].arn : ""
102
+ ) : var.certificate_arn
103
+
104
+ # --- Derived ALB / listener gates -------------------------------------
105
+ # has_lb = the plan carries a load-balancer item; has_lb_spec additionally
106
+ # requires the app to have supplied a load_balancer spec (target port / health
107
+ # check / target kinds). The target group + listeners build only when a spec is
108
+ # present. has_listener adds the TLS cert + domain (plan-time-known, like the
109
+ # other has_* flags, so every listener/attachment gates identically).
110
+ has_lb_spec = local.has_lb && var.load_balancer != null
111
+ # HTTPS listener needs a cert: issued here (managed + a tls plan item) or
112
+ # supplied by the worker (delegated + certificate_arn).
113
+ has_listener = local.has_lb_spec && var.dns_domain != "" && (
114
+ local.dns_managed ? local.has_tls : var.certificate_arn != ""
115
+ )
116
+ alb_auth_enabled = var.alb_auth.enabled
117
+
118
+ # Compute nodes that sit behind the ALB — the kinds the app named as LB targets
119
+ # (e.g. the app's web/standalone nodes). try() keeps it null-safe when no spec.
120
+ lb_target_kinds = try(var.load_balancer.target_kinds, [])
121
+ search_targets = {
122
+ for k, r in local.compute_nodes : k => r
123
+ if contains(local.lb_target_kinds, r.kind)
124
+ }
125
+
126
+ # --- Per-node function DNS labels -------------------------------------
127
+ # DNS-label-safe prefix per compute kind, from the app's dns_prefixes (falls
128
+ # back to the kind string). The bring-up layer resolves intra-cluster peers by
129
+ # these function FQDNs (e.g. idx1.<domain>), so the mapping MUST be
130
+ # deterministic and stable across applies.
131
+ node_prefix = {
132
+ for k, r in local.compute_nodes : k => lookup(var.dns_prefixes, r.kind, r.kind)
133
+ }
134
+
135
+ # Per-kind sorted plan_key list — the fallback ordinal source for role-based keys
136
+ # (management nodes) that carry no numeric suffix. Numbered tiers (indexer / search-head
137
+ # / heavy-forwarder) instead take their ordinal from the plan_key suffix; see
138
+ # node_ordinal below.
139
+ node_keys_by_kind = {
140
+ for kind in distinct([for k, r in local.compute_nodes : r.kind]) : kind => sort([
141
+ for k, r in local.compute_nodes : k if r.kind == kind
142
+ ])
143
+ }
144
+ # A node's ordinal is the NUMERIC SUFFIX of its plan_key (data/indexer-3 -> 3), so its
145
+ # label is stable and identity-preserving: indexer-3 is ALWAYS idx3, even after
146
+ # indexer-2 is removed, and scaling down drops the highest ordinal without renumbering
147
+ # the survivors. Role-based keys with no numeric suffix (management nodes) fall back to
148
+ # a stable lexical position.
149
+ node_ordinal = {
150
+ for k, r in local.compute_nodes : k => (
151
+ length(regexall("[0-9]+$", k)) > 0
152
+ ? tonumber(regexall("[0-9]+$", k)[0])
153
+ : index(local.node_keys_by_kind[r.kind], k) + 1
154
+ )
155
+ }
156
+ # Customer shortname prefix for hostnames — derived from the Veltrix:Customer tag
157
+ # (the shortname when set, else the customer UUID, which we skip). Sanitized to a
158
+ # DNS/label-safe token so every node's hostname/serverName/FQDN carries the customer,
159
+ # e.g. acme-cm1 / acme-idx3. Empty (no shortname) stays backward-compatible (cm1/idx3).
160
+ tag_customer = lower(lookup(var.tags, "Veltrix:Customer", ""))
161
+ customer_prefix = (
162
+ local.tag_customer == "" || length(regexall("^[0-9a-f]{8}-[0-9a-f]{4}-", local.tag_customer)) > 0
163
+ ? ""
164
+ : "${trim(replace(local.tag_customer, "/[^a-z0-9]+/", "-"), "-")}-"
165
+ )
166
+ node_short_labels = {
167
+ for k, r in local.compute_nodes : k => format("%s%s%d", local.customer_prefix, local.node_prefix[k], local.node_ordinal[k])
168
+ }
169
+
170
+ # Domain for the INTERNAL zone + node FQDNs. Distinct from var.dns_domain (which
171
+ # also drives the PUBLIC ALB record + ACM cert): a dedicated fabric with no public
172
+ # domain still wants internal FQDNs (cm1.<internal>, idx1.<internal>) but must NOT
173
+ # trigger public DNS/ACM. Falls back to dns_domain so a public-domain deploy is
174
+ # unchanged.
175
+ private_domain = var.private_dns_domain != "" ? var.private_dns_domain : var.dns_domain
176
+
177
+ # plan_key -> function FQDN in the private zone. Empty when no domain is set.
178
+ node_fqdns = local.private_domain != "" ? {
179
+ for k, label in local.node_short_labels : k => "${label}.${local.private_domain}"
180
+ } : {}
181
+
182
+ # --- Private DNS zone resolution --------------------------------------
183
+ # Either create a private hosted zone for var.dns_domain (create_private_zone)
184
+ # or point at a caller-supplied one (private_zone_id). want_private_dns is the
185
+ # plan-time-known intent used to gate the per-node record for_each (the zone id
186
+ # itself may stay computed until apply, which is fine for a record attribute
187
+ # but NOT for a for_each key set).
188
+ create_private_zone = var.create_private_zone && local.private_domain != "" && var.private_zone_id == ""
189
+ want_private_dns = local.private_domain != "" && (var.private_zone_id != "" || local.create_private_zone)
190
+ private_zone_id = var.private_zone_id != "" ? var.private_zone_id : (
191
+ local.create_private_zone ? aws_route53_zone.private[0].id : ""
192
+ )
193
+
194
+ # --- Flattened SG ingress rules (from the app's security_rules) --------
195
+ # Each (rule, source) pair becomes one aws_vpc_security_group_ingress_rule,
196
+ # keyed "<port>-<protocol>-<source>". "alb"-sourced rules are dropped when there
197
+ # is no ALB (no alb SG to reference). This replaces any hardcoded, tool-specific
198
+ # port list — the app declares its ports in InfraSpec.securityRules.
199
+ sg_ingress = merge([
200
+ for r in var.security_rules : {
201
+ for s in r.sources : "${r.port}-${r.protocol}-${s}" => {
202
+ port = r.port
203
+ protocol = r.protocol
204
+ source = s
205
+ description = r.description != "" ? r.description : "port ${r.port} (${r.protocol}) from ${s}"
206
+ }
207
+ if !(s == "alb" && !local.has_lb)
208
+ }
209
+ ]...)
210
+
211
+ # Cross-region cluster traffic: peers in a PEERED VPC (a multi-region indexer/
212
+ # search-head satellite) are in a different security group, so the "self" rules
213
+ # don't reach them. For every intra-cluster ("self") rule, allow the same port
214
+ # from each peer VPC CIDR. Empty peer_cidrs (the single-region default) yields
215
+ # no extra rules — fully backward compatible.
216
+ sg_peer_ingress = merge([
217
+ for r in var.security_rules : {
218
+ for cidr in(contains(r.sources, "self") ? var.peer_cidrs : []) :
219
+ "${r.port}-${r.protocol}-peer-${cidr}" => {
220
+ port = r.port
221
+ protocol = r.protocol
222
+ cidr = cidr
223
+ description = "port ${r.port} (${r.protocol}) from peer VPC ${cidr}"
224
+ }
225
+ }
226
+ ]...)
227
+ }
228
+
229
+ data "aws_availability_zones" "available" {
230
+ state = "available"
231
+ }
232
+
233
+ # Scaffold fallback AMI only. Production must pass a tool-preinstalled AMI.
234
+ data "aws_ami" "al2023" {
235
+ most_recent = true
236
+ owners = ["amazon"]
237
+
238
+ filter {
239
+ name = "name"
240
+ values = ["al2023-ami-*-x86_64"]
241
+ }
242
+ }
243
+
244
+ # --- Network lookup (network_mode = shared | existing) --------------------
245
+ # The VPC is data-sourced (never created) — the shared Veltrix VPC for hosted,
246
+ # or a customer-designated VPC for BYOC "existing". Absent when dedicated.
247
+
248
+ data "aws_vpc" "shared" {
249
+ count = local.lookup_network ? 1 : 0
250
+ id = var.network_lookup_by == "id" ? var.network_ref : null
251
+
252
+ dynamic "filter" {
253
+ for_each = var.network_lookup_by == "tag" ? [1] : []
254
+ content {
255
+ name = "tag:Name"
256
+ values = [var.network_ref]
257
+ }
258
+ }
259
+ }
260
+
261
+ # The stack's single allocated subnet, in the looked-up VPC (shared|existing).
262
+ resource "aws_subnet" "env" {
263
+ count = local.lookup_network ? 1 : 0
264
+ vpc_id = data.aws_vpc.shared[0].id
265
+ cidr_block = var.subnet_cidr
266
+ availability_zone = local.azs[0]
267
+
268
+ tags = merge(local.base_tags, {
269
+ Name = "${local.name_prefix}-subnet"
270
+ "Veltrix:PlanKey" = "foundation/network"
271
+ "Veltrix:Tier" = "foundation"
272
+ })
273
+ }
274
+
275
+ # --- Dedicated network fabric (network_mode = dedicated / BYOC) ------------
276
+ # A fresh, isolated VPC created in the DEPLOY account (the customer's, for BYOC):
277
+ # 2 public + 2 private subnets across 2 AZs, an internet gateway, a NAT gateway,
278
+ # and route tables. Compute nodes live in the private subnets; the ALB in the
279
+ # public subnets. Nothing here runs in shared/existing mode.
280
+
281
+ resource "aws_vpc" "env" {
282
+ count = local.is_dedicated ? 1 : 0
283
+ cidr_block = var.vpc_cidr
284
+ enable_dns_support = true
285
+ enable_dns_hostnames = true
286
+
287
+ tags = merge(local.base_tags, {
288
+ Name = "${local.name_prefix}-vpc"
289
+ "Veltrix:PlanKey" = "foundation/network"
290
+ "Veltrix:Tier" = "foundation"
291
+ })
292
+ }
293
+
294
+ resource "aws_internet_gateway" "env" {
295
+ count = local.is_dedicated ? 1 : 0
296
+ vpc_id = aws_vpc.env[0].id
297
+
298
+ tags = merge(local.base_tags, {
299
+ Name = "${local.name_prefix}-igw"
300
+ "Veltrix:PlanKey" = "foundation/network"
301
+ "Veltrix:Tier" = "foundation"
302
+ })
303
+ }
304
+
305
+ # Public subnets (one per AZ) — carry the ALB + NAT. cidrsubnet(/16, 4, i) => /20.
306
+ resource "aws_subnet" "public" {
307
+ for_each = local.is_dedicated ? toset(local.azs) : toset([])
308
+ vpc_id = aws_vpc.env[0].id
309
+ availability_zone = each.key
310
+ cidr_block = cidrsubnet(var.vpc_cidr, 4, index(local.azs, each.key))
311
+ map_public_ip_on_launch = true
312
+
313
+ tags = merge(local.base_tags, {
314
+ Name = "${local.name_prefix}-public-${each.key}"
315
+ "Veltrix:PlanKey" = "foundation/network"
316
+ "Veltrix:Tier" = "foundation"
317
+ })
318
+ }
319
+
320
+ # Private subnets (one per AZ the plan uses) — carry the compute nodes. Offset by
321
+ # 8 => distinct /20 blocks that never overlap the public ones.
322
+ resource "aws_subnet" "private" {
323
+ for_each = local.is_dedicated ? toset(local.all_azs) : toset([])
324
+ vpc_id = aws_vpc.env[0].id
325
+ availability_zone = each.key
326
+ cidr_block = cidrsubnet(var.vpc_cidr, 4, index(local.all_azs, each.key) + 8)
327
+
328
+ tags = merge(local.base_tags, {
329
+ Name = "${local.name_prefix}-private-${each.key}"
330
+ "Veltrix:PlanKey" = "foundation/network"
331
+ "Veltrix:Tier" = "foundation"
332
+ })
333
+ }
334
+
335
+ resource "aws_eip" "nat" {
336
+ count = local.is_dedicated ? 1 : 0
337
+ domain = "vpc"
338
+
339
+ tags = merge(local.base_tags, {
340
+ Name = "${local.name_prefix}-nat-eip"
341
+ "Veltrix:PlanKey" = "foundation/network"
342
+ "Veltrix:Tier" = "foundation"
343
+ })
344
+ }
345
+
346
+ resource "aws_nat_gateway" "env" {
347
+ count = local.is_dedicated ? 1 : 0
348
+ allocation_id = aws_eip.nat[0].id
349
+ subnet_id = aws_subnet.public[local.azs[0]].id
350
+ depends_on = [aws_internet_gateway.env]
351
+
352
+ tags = merge(local.base_tags, {
353
+ Name = "${local.name_prefix}-nat"
354
+ "Veltrix:PlanKey" = "foundation/network"
355
+ "Veltrix:Tier" = "foundation"
356
+ })
357
+ }
358
+
359
+ resource "aws_route_table" "public" {
360
+ count = local.is_dedicated ? 1 : 0
361
+ vpc_id = aws_vpc.env[0].id
362
+
363
+ route {
364
+ cidr_block = "0.0.0.0/0"
365
+ gateway_id = aws_internet_gateway.env[0].id
366
+ }
367
+
368
+ tags = merge(local.base_tags, {
369
+ Name = "${local.name_prefix}-public-rt"
370
+ "Veltrix:PlanKey" = "foundation/network"
371
+ "Veltrix:Tier" = "foundation"
372
+ })
373
+ }
374
+
375
+ resource "aws_route_table" "private" {
376
+ count = local.is_dedicated ? 1 : 0
377
+ vpc_id = aws_vpc.env[0].id
378
+
379
+ tags = merge(local.base_tags, {
380
+ Name = "${local.name_prefix}-private-rt"
381
+ "Veltrix:PlanKey" = "foundation/network"
382
+ "Veltrix:Tier" = "foundation"
383
+ })
384
+ }
385
+
386
+ # Default egress via NAT — a SEPARATE aws_route (not an inline `route` block) so
387
+ # the root can add cross-region VPC-peering routes to this same table without the
388
+ # inline-vs-resource conflict the AWS provider forbids. Same route, no behavior change.
389
+ resource "aws_route" "private_nat" {
390
+ count = local.is_dedicated ? 1 : 0
391
+ route_table_id = aws_route_table.private[0].id
392
+ destination_cidr_block = "0.0.0.0/0"
393
+ nat_gateway_id = aws_nat_gateway.env[0].id
394
+ }
395
+
396
+ resource "aws_route_table_association" "public" {
397
+ for_each = local.is_dedicated ? aws_subnet.public : {}
398
+ subnet_id = each.value.id
399
+ route_table_id = aws_route_table.public[0].id
400
+ }
401
+
402
+ resource "aws_route_table_association" "private" {
403
+ for_each = local.is_dedicated ? aws_subnet.private : {}
404
+ subnet_id = each.value.id
405
+ route_table_id = aws_route_table.private[0].id
406
+ }
407
+
408
+ # --- Security groups: SG-to-SG least privilege ----------------------------
409
+ # Two SGs replace the old flat-CIDR SG:
410
+ # * alb — public edge, only 443/80 from the web ingress CIDR.
411
+ # * node — app compute hosts; peer ports self-reference the node SG so intra-cluster
412
+ # traffic is scoped to *this stack's* instances (not a whole CIDR), and
413
+ # the app's web UI is reachable only via the ALB (or admin CIDR with no ALB).
414
+
415
+ # Public ALB edge SG. Only created when the plan carries a load-balancer.
416
+ resource "aws_security_group" "alb" {
417
+ count = local.has_lb ? 1 : 0
418
+ name = "${local.name_prefix}-alb-sg"
419
+ description = "Public ALB ingress (443/80) for ${local.name_prefix}"
420
+ vpc_id = local.network_id
421
+
422
+ ingress {
423
+ description = "HTTPS from web clients"
424
+ from_port = 443
425
+ to_port = 443
426
+ protocol = "tcp"
427
+ cidr_blocks = [var.web_ingress_cidr]
428
+ }
429
+
430
+ ingress {
431
+ description = "HTTP (redirected to HTTPS at the listener)"
432
+ from_port = 80
433
+ to_port = 80
434
+ protocol = "tcp"
435
+ cidr_blocks = [var.web_ingress_cidr]
436
+ }
437
+
438
+ egress {
439
+ description = "All egress (forward to app nodes)"
440
+ from_port = 0
441
+ to_port = 0
442
+ protocol = "-1"
443
+ cidr_blocks = ["0.0.0.0/0"]
444
+ }
445
+
446
+ tags = merge(local.base_tags, {
447
+ Name = "${local.name_prefix}-alb-sg"
448
+ "Veltrix:PlanKey" = "foundation/load-balancer"
449
+ "Veltrix:Tier" = "foundation"
450
+ })
451
+ }
452
+
453
+ # Node SG. Ingress lives in a single for_each'd standalone rule resource (below)
454
+ # so intra-cluster ports can self-reference this SG's own id without an inline
455
+ # dependency cycle. Do NOT add inline rules here — mixing inline blocks with
456
+ # standalone rule resources on one SG makes them clobber each other. The resource
457
+ # name stays `splunk` so security_group_id / the instances' vpc_security_group_ids
458
+ # keep a stable address across apps.
459
+ resource "aws_security_group" "splunk" {
460
+ name = "${local.name_prefix}-sg"
461
+ description = "Compute node ports for ${local.name_prefix} (SG-to-SG least privilege)"
462
+ vpc_id = local.network_id
463
+
464
+ tags = merge(local.base_tags, {
465
+ Name = "${local.name_prefix}-sg"
466
+ "Veltrix:PlanKey" = "foundation/network"
467
+ "Veltrix:Tier" = "foundation"
468
+ })
469
+ }
470
+
471
+ # One ingress rule per (security_rules entry, source), from local.sg_ingress.
472
+ # The app declares its ports/sources in InfraSpec.securityRules — the module
473
+ # stays tool-agnostic. Exactly one origin attribute is set per rule; the others
474
+ # are null (omitted): "self"/"alb" -> referenced_security_group_id, "admin" ->
475
+ # cidr_ipv4. "alb" rules are pre-filtered out of sg_ingress when there is no ALB.
476
+ resource "aws_vpc_security_group_ingress_rule" "node" {
477
+ for_each = local.sg_ingress
478
+
479
+ security_group_id = aws_security_group.splunk.id
480
+ from_port = each.value.port
481
+ to_port = each.value.port
482
+ ip_protocol = each.value.protocol
483
+ description = each.value.description
484
+
485
+ referenced_security_group_id = (
486
+ each.value.source == "self" ? aws_security_group.splunk.id :
487
+ each.value.source == "alb" ? aws_security_group.alb[0].id :
488
+ null
489
+ )
490
+ cidr_ipv4 = each.value.source == "admin" ? var.admin_cidr : null
491
+
492
+ tags = merge(local.base_tags, { Name = "${local.name_prefix}-${each.key}" })
493
+ }
494
+
495
+ # Cross-region cluster ingress: same intra-cluster ports, from each peered VPC's
496
+ # CIDR (multi-region satellites). Empty when peer_cidrs is empty (single-region).
497
+ resource "aws_vpc_security_group_ingress_rule" "peer" {
498
+ for_each = local.sg_peer_ingress
499
+
500
+ security_group_id = aws_security_group.splunk.id
501
+ from_port = each.value.port
502
+ to_port = each.value.port
503
+ ip_protocol = each.value.protocol
504
+ description = each.value.description
505
+ cidr_ipv4 = each.value.cidr
506
+
507
+ tags = merge(local.base_tags, { Name = "${local.name_prefix}-${each.key}" })
508
+ }
509
+
510
+ # Egress: all (nodes reach peers, object storage, license/registration, updates).
511
+ resource "aws_vpc_security_group_egress_rule" "node_all" {
512
+ security_group_id = aws_security_group.splunk.id
513
+ cidr_ipv4 = "0.0.0.0/0"
514
+ ip_protocol = "-1"
515
+ description = "All egress"
516
+
517
+ tags = merge(local.base_tags, { Name = "${local.name_prefix}-egress" })
518
+ }
519
+
520
+ # --- Compute: one aws_instance per compute plan item ----------------------
521
+ # for_each keyed by plan_key => aws_instance.node["data/indexer-1"] etc.
522
+
523
+ resource "aws_instance" "node" {
524
+ for_each = local.compute_nodes
525
+
526
+ ami = local.resolved_ami
527
+ instance_type = coalesce(
528
+ lookup(var.instance_types_by_kind, each.value.kind, null),
529
+ lookup(var.instance_types, each.value.tier, null),
530
+ var.default_instance_type,
531
+ )
532
+ subnet_id = local.compute_subnet_for[each.key]
533
+ vpc_security_group_ids = [aws_security_group.splunk.id]
534
+ key_name = var.key_name != "" ? var.key_name : null
535
+ # Instance profile gives the bring-up layer SSM reachability (no SSH) plus scoped
536
+ # reads of the secret bundle, the SmartStore bucket, and the app's install-artifacts bucket.
537
+ iam_instance_profile = aws_iam_instance_profile.node.name
538
+
539
+ root_block_device {
540
+ volume_size = var.root_volume_gb
541
+ volume_type = "gp3"
542
+ encrypted = true
543
+ }
544
+
545
+ tags = merge(local.base_tags, {
546
+ # Meaningful, unique per-node name (e.g. <prefix>-cm1 / -idx1 / -sh1) instead of
547
+ # colliding on kind. node_short_labels also feeds the private-DNS FQDNs.
548
+ Name = "${local.name_prefix}-${local.node_short_labels[each.key]}"
549
+ "Veltrix:PlanKey" = each.key
550
+ "Veltrix:Tier" = each.value.tier
551
+ "Veltrix:Kind" = each.value.kind
552
+ "Veltrix:Role" = each.value.role
553
+ # Consolidated control-plane roles + placement zone (topology authoring).
554
+ "Veltrix:Roles" = join(",", each.value.roles)
555
+ "Veltrix:Zone" = each.value.zone != null ? each.value.zone : ""
556
+ })
557
+ }
558
+
559
+ # --- Instance IAM: SSM reachability + Secrets + S3 (SmartStore + artifacts) ---
560
+ # Every node gets an instance profile so the bring-up layer can reach it over SSM
561
+ # Run Command (no SSH, no inbound), read its admin-seed / pass4SymmKey bundle,
562
+ # read/write the SmartStore bucket, and pull the app's install artifacts from the artifacts bucket.
563
+
564
+ data "aws_iam_policy_document" "node_assume" {
565
+ statement {
566
+ actions = ["sts:AssumeRole"]
567
+ principals {
568
+ type = "Service"
569
+ identifiers = ["ec2.amazonaws.com"]
570
+ }
571
+ }
572
+ }
573
+
574
+ resource "aws_iam_role" "node" {
575
+ name = "${local.name_prefix}-node"
576
+ assume_role_policy = data.aws_iam_policy_document.node_assume.json
577
+ tags = local.base_tags
578
+ }
579
+
580
+ # SSM Run Command / Session Manager connectivity + inventory.
581
+ resource "aws_iam_role_policy_attachment" "node_ssm" {
582
+ role = aws_iam_role.node.name
583
+ policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
584
+ }
585
+
586
+ # Scoped inline policy: only the ARNs this stack actually created.
587
+ data "aws_iam_policy_document" "node_inline" {
588
+ dynamic "statement" {
589
+ for_each = length(local.node_secret_arns) > 0 ? [1] : []
590
+ content {
591
+ sid = "ReadSecrets"
592
+ actions = ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"]
593
+ resources = local.node_secret_arns
594
+ }
595
+ }
596
+ dynamic "statement" {
597
+ for_each = local.has_storage ? [1] : []
598
+ content {
599
+ sid = "SmartStoreBucket"
600
+ actions = ["s3:ListBucket", "s3:GetBucketLocation"]
601
+ resources = [aws_s3_bucket.objstore[0].arn]
602
+ }
603
+ }
604
+ dynamic "statement" {
605
+ for_each = local.has_storage ? [1] : []
606
+ content {
607
+ sid = "SmartStoreObjects"
608
+ actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
609
+ resources = ["${aws_s3_bucket.objstore[0].arn}/*"]
610
+ }
611
+ }
612
+ dynamic "statement" {
613
+ for_each = var.artifacts_bucket != "" ? [1] : []
614
+ content {
615
+ sid = "SplunkArtifacts"
616
+ actions = ["s3:GetObject", "s3:ListBucket"]
617
+ resources = ["arn:aws:s3:::${var.artifacts_bucket}", "arn:aws:s3:::${var.artifacts_bucket}/*"]
618
+ }
619
+ }
620
+ }
621
+
622
+ resource "aws_iam_role_policy" "node" {
623
+ count = local.node_needs_inline ? 1 : 0
624
+ name = "${local.name_prefix}-node"
625
+ role = aws_iam_role.node.id
626
+ policy = data.aws_iam_policy_document.node_inline.json
627
+ }
628
+
629
+ resource "aws_iam_instance_profile" "node" {
630
+ name = "${local.name_prefix}-node"
631
+ role = aws_iam_role.node.name
632
+ }
633
+
634
+ # --- Storage: object-storage bucket (e.g. SmartStore, warm/cold tiers) -----
635
+ # Generic S3 bucket for the app's bulk/object storage. The tool's meaning is
636
+ # app-defined (InfraSpec.storage); the module just provisions a private bucket.
637
+
638
+ resource "random_id" "bucket_suffix" {
639
+ count = local.has_storage ? 1 : 0
640
+ byte_length = 4
641
+ }
642
+
643
+ resource "aws_s3_bucket" "objstore" {
644
+ count = local.has_storage ? 1 : 0
645
+ bucket = "${local.name_prefix}-objstore-${random_id.bucket_suffix[0].hex}"
646
+ force_destroy = false
647
+
648
+ tags = merge(local.base_tags, {
649
+ Name = "${local.name_prefix}-objstore"
650
+ "Veltrix:PlanKey" = "foundation/storage"
651
+ "Veltrix:Tier" = "foundation"
652
+ })
653
+ }
654
+
655
+ resource "aws_s3_bucket_public_access_block" "objstore" {
656
+ count = local.has_storage ? 1 : 0
657
+ bucket = aws_s3_bucket.objstore[0].id
658
+ block_public_acls = true
659
+ block_public_policy = true
660
+ ignore_public_acls = true
661
+ restrict_public_buckets = true
662
+ }
663
+
664
+ # --- Secrets: per-tenant secret bundle (admin seed / pass4SymmKey / etc.) ----
665
+
666
+ resource "aws_secretsmanager_secret" "env" {
667
+ count = local.has_secrets ? 1 : 0
668
+ name = "${local.name_prefix}/env-secrets"
669
+
670
+ tags = merge(local.base_tags, {
671
+ Name = "${local.name_prefix}-secrets"
672
+ "Veltrix:PlanKey" = "foundation/secrets"
673
+ "Veltrix:Tier" = "foundation"
674
+ })
675
+ }
676
+
677
+ # --- BYOL license file (stored as a secret; validated post-apply) ---------
678
+
679
+ resource "aws_secretsmanager_secret" "license" {
680
+ count = local.has_license_file ? 1 : 0
681
+ name = "${local.name_prefix}/byol-license"
682
+
683
+ tags = merge(local.base_tags, {
684
+ Name = "${local.name_prefix}-license"
685
+ "Veltrix:PlanKey" = "foundation/license-file"
686
+ "Veltrix:Tier" = "foundation"
687
+ })
688
+ }
689
+
690
+ # --- TLS certificate (web + inter-node) -----------------------------------
691
+
692
+ resource "aws_acm_certificate" "env" {
693
+ # Issued in-account only for dns_mode = managed. In delegated mode the worker
694
+ # provisions + cross-account-validates the cert and passes certificate_arn.
695
+ count = local.dns_managed && local.has_tls && var.dns_domain != "" ? 1 : 0
696
+ domain_name = var.dns_domain
697
+ validation_method = "DNS"
698
+
699
+ tags = merge(local.base_tags, {
700
+ Name = "${local.name_prefix}-cert"
701
+ "Veltrix:PlanKey" = "foundation/tls"
702
+ "Veltrix:Tier" = "foundation"
703
+ })
704
+
705
+ lifecycle {
706
+ create_before_destroy = true
707
+ }
708
+ }
709
+
710
+ # --- Load balancer: the app's web tier + HEC ingress ----------------------
711
+ # An ALB needs >= 2 subnets in >= 2 AZs. In dedicated mode local.lb_subnet_ids
712
+ # is the created multi-AZ public subnets; in shared/existing it is the per-stack
713
+ # subnet plus var.extra_lb_subnet_ids (which MUST cover a second AZ).
714
+
715
+ resource "aws_lb" "env" {
716
+ count = local.has_lb ? 1 : 0
717
+ name = substr("${local.name_prefix}-alb", 0, 32)
718
+ internal = false
719
+ load_balancer_type = "application"
720
+ security_groups = [aws_security_group.alb[0].id]
721
+ subnets = local.lb_subnet_ids
722
+
723
+ tags = merge(local.base_tags, {
724
+ Name = "${local.name_prefix}-alb"
725
+ "Veltrix:PlanKey" = "foundation/load-balancer"
726
+ "Veltrix:Tier" = "foundation"
727
+ })
728
+ }
729
+
730
+ # --- ALB target group: the app's web tier ---------------------------------
731
+ # Port / protocol / health check all come from the app's load_balancer spec
732
+ # (InfraSpec.loadBalancer). TLS terminates at the ALB listener, so the target
733
+ # protocol is typically HTTP. Gated on has_lb_spec (plan LB item + a spec).
734
+
735
+ resource "aws_lb_target_group" "search" {
736
+ count = local.has_lb_spec ? 1 : 0
737
+ name = substr("${local.name_prefix}-tg", 0, 32)
738
+ port = var.load_balancer.target_port
739
+ protocol = var.load_balancer.target_protocol
740
+ vpc_id = local.network_id
741
+ target_type = "instance"
742
+
743
+ health_check {
744
+ path = var.load_balancer.health_check_path
745
+ protocol = var.load_balancer.health_check_protocol != "" ? var.load_balancer.health_check_protocol : var.load_balancer.target_protocol
746
+ port = "traffic-port"
747
+ matcher = var.load_balancer.health_check_matcher
748
+ healthy_threshold = 3
749
+ unhealthy_threshold = 3
750
+ interval = 30
751
+ timeout = 5
752
+ }
753
+
754
+ tags = merge(local.base_tags, {
755
+ Name = "${local.name_prefix}-tg"
756
+ "Veltrix:PlanKey" = "foundation/load-balancer"
757
+ "Veltrix:Tier" = "foundation"
758
+ })
759
+ }
760
+
761
+ # One attachment per web-serving node (the kinds the app named in the LB spec's
762
+ # target_kinds). Keyed by plan_key so the set tracks the compute for_each.
763
+ resource "aws_lb_target_group_attachment" "search" {
764
+ for_each = local.has_lb_spec ? local.search_targets : {}
765
+ target_group_arn = aws_lb_target_group.search[0].arn
766
+ target_id = aws_instance.node[each.key].id
767
+ port = var.load_balancer.target_port
768
+ }
769
+
770
+ # --- ALB listeners: HTTPS(443) terminate + optional Cognito MFA, HTTP(80)→301 -
771
+ # The whole listener chain is gated on has_listener (ALB + TLS cert + domain).
772
+
773
+ resource "aws_lb_listener" "https" {
774
+ count = local.has_listener ? 1 : 0
775
+ load_balancer_arn = aws_lb.env[0].arn
776
+ port = var.load_balancer.listener_port
777
+ protocol = "HTTPS"
778
+ ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
779
+ certificate_arn = local.listener_cert_arn
780
+
781
+ # Optional OIDC/Cognito MFA in front of the app's web UI. When enabled the default
782
+ # action becomes an ordered pair — authenticate-cognito (order 1) THEN forward
783
+ # (order 2). The dynamic block emits the auth action only when configured, so
784
+ # the plain forward-only path stays valid when auth is disabled.
785
+ dynamic "default_action" {
786
+ for_each = local.alb_auth_enabled ? [1] : []
787
+ content {
788
+ type = "authenticate-cognito"
789
+ order = 1
790
+
791
+ authenticate_cognito {
792
+ user_pool_arn = var.alb_auth.user_pool_arn
793
+ user_pool_client_id = var.alb_auth.user_pool_client_id
794
+ user_pool_domain = var.alb_auth.user_pool_domain
795
+ }
796
+ }
797
+ }
798
+
799
+ default_action {
800
+ type = "forward"
801
+ order = local.alb_auth_enabled ? 2 : 1
802
+ target_group_arn = aws_lb_target_group.search[0].arn
803
+ }
804
+
805
+ tags = merge(local.base_tags, {
806
+ Name = "${local.name_prefix}-https"
807
+ "Veltrix:PlanKey" = "foundation/load-balancer"
808
+ "Veltrix:Tier" = "foundation"
809
+ })
810
+ }
811
+
812
+ resource "aws_lb_listener" "http" {
813
+ count = local.has_listener ? 1 : 0
814
+ load_balancer_arn = aws_lb.env[0].arn
815
+ port = 80
816
+ protocol = "HTTP"
817
+
818
+ default_action {
819
+ type = "redirect"
820
+
821
+ redirect {
822
+ port = tostring(var.load_balancer.listener_port)
823
+ protocol = "HTTPS"
824
+ status_code = "HTTP_301"
825
+ }
826
+ }
827
+
828
+ tags = merge(local.base_tags, {
829
+ Name = "${local.name_prefix}-http-redirect"
830
+ "Veltrix:PlanKey" = "foundation/load-balancer"
831
+ "Veltrix:Tier" = "foundation"
832
+ })
833
+ }
834
+
835
+ # --- WAFv2 web ACL (regional, ALB-scoped) ---------------------------------
836
+ # Default allow, with AWS-managed rule groups + an IP rate limit. Managed groups
837
+ # use override_action { none {} } so their own block/count verdicts stand (we do
838
+ # NOT override them to count). Gated on has_lb (WAFv2 REGIONAL fronts the ALB).
839
+
840
+ resource "aws_wafv2_web_acl" "env" {
841
+ count = local.has_lb && var.waf_enabled ? 1 : 0
842
+ name = "${local.name_prefix}-waf"
843
+ scope = "REGIONAL"
844
+
845
+ default_action {
846
+ allow {}
847
+ }
848
+
849
+ rule {
850
+ name = "AWSManagedRulesCommonRuleSet"
851
+ priority = 1
852
+
853
+ override_action {
854
+ none {}
855
+ }
856
+
857
+ statement {
858
+ managed_rule_group_statement {
859
+ name = "AWSManagedRulesCommonRuleSet"
860
+ vendor_name = "AWS"
861
+ }
862
+ }
863
+
864
+ visibility_config {
865
+ cloudwatch_metrics_enabled = true
866
+ metric_name = "${local.name_prefix}-common"
867
+ sampled_requests_enabled = true
868
+ }
869
+ }
870
+
871
+ rule {
872
+ name = "AWSManagedRulesKnownBadInputsRuleSet"
873
+ priority = 2
874
+
875
+ override_action {
876
+ none {}
877
+ }
878
+
879
+ statement {
880
+ managed_rule_group_statement {
881
+ name = "AWSManagedRulesKnownBadInputsRuleSet"
882
+ vendor_name = "AWS"
883
+ }
884
+ }
885
+
886
+ visibility_config {
887
+ cloudwatch_metrics_enabled = true
888
+ metric_name = "${local.name_prefix}-bad-inputs"
889
+ sampled_requests_enabled = true
890
+ }
891
+ }
892
+
893
+ rule {
894
+ name = "AWSManagedRulesAmazonIpReputationList"
895
+ priority = 3
896
+
897
+ override_action {
898
+ none {}
899
+ }
900
+
901
+ statement {
902
+ managed_rule_group_statement {
903
+ name = "AWSManagedRulesAmazonIpReputationList"
904
+ vendor_name = "AWS"
905
+ }
906
+ }
907
+
908
+ visibility_config {
909
+ cloudwatch_metrics_enabled = true
910
+ metric_name = "${local.name_prefix}-ip-rep"
911
+ sampled_requests_enabled = true
912
+ }
913
+ }
914
+
915
+ # Volumetric protection: block a source IP over ~2000 requests / 5 min.
916
+ rule {
917
+ name = "RateLimitPerIP"
918
+ priority = 4
919
+
920
+ action {
921
+ block {}
922
+ }
923
+
924
+ statement {
925
+ rate_based_statement {
926
+ limit = 2000
927
+ aggregate_key_type = "IP"
928
+ }
929
+ }
930
+
931
+ visibility_config {
932
+ cloudwatch_metrics_enabled = true
933
+ metric_name = "${local.name_prefix}-rate-limit"
934
+ sampled_requests_enabled = true
935
+ }
936
+ }
937
+
938
+ visibility_config {
939
+ cloudwatch_metrics_enabled = true
940
+ metric_name = "${local.name_prefix}-waf"
941
+ sampled_requests_enabled = true
942
+ }
943
+
944
+ tags = merge(local.base_tags, {
945
+ Name = "${local.name_prefix}-waf"
946
+ "Veltrix:PlanKey" = "foundation/load-balancer"
947
+ "Veltrix:Tier" = "foundation"
948
+ })
949
+ }
950
+
951
+ resource "aws_wafv2_web_acl_association" "env" {
952
+ count = local.has_lb && var.waf_enabled ? 1 : 0
953
+ resource_arn = aws_lb.env[0].arn
954
+ web_acl_arn = aws_wafv2_web_acl.env[0].arn
955
+ }
956
+
957
+ # --- Public DNS record ----------------------------------------------------
958
+ # Created in-account only for dns_mode = managed. delegated => the worker writes
959
+ # it cross-account into Veltrix's zone; private-only => no public record.
960
+
961
+ resource "aws_route53_record" "env" {
962
+ count = local.dns_managed && local.has_dns && local.has_lb ? 1 : 0
963
+ zone_id = var.route53_zone_id
964
+ name = var.dns_domain
965
+ type = "A"
966
+
967
+ alias {
968
+ name = aws_lb.env[0].dns_name
969
+ zone_id = aws_lb.env[0].zone_id
970
+ evaluate_target_health = true
971
+ }
972
+ # aws_route53_record does not support tags.
973
+ }
974
+
975
+ # --- Private DNS: intra-cluster function FQDNs ----------------------------
976
+ # A private hosted zone (associated with the shared VPC) gives every node a
977
+ # stable function FQDN (idx1.<domain>, sh1.<domain>, cm1.<domain>, ...). The app's
978
+ # clustering config references peers by these names, and the bring-up layer uses
979
+ # node_fqdns (see outputs) to build its inventory. The PUBLIC ALB record above is
980
+ # unaffected. Either create the zone here (create_private_zone) or reuse a
981
+ # caller-supplied one (private_zone_id).
982
+
983
+ resource "aws_route53_zone" "private" {
984
+ count = local.create_private_zone ? 1 : 0
985
+ name = local.private_domain
986
+
987
+ vpc {
988
+ vpc_id = local.network_id
989
+ }
990
+
991
+ tags = merge(local.base_tags, {
992
+ Name = "${local.name_prefix}-private-zone"
993
+ "Veltrix:PlanKey" = "foundation/dns"
994
+ "Veltrix:Tier" = "foundation"
995
+ })
996
+ }
997
+
998
+ # One A record per compute node → its private IP, keyed by plan_key so the set
999
+ # tracks the compute for_each. Gated on want_private_dns (plan-time-known) so the
1000
+ # key set never depends on the not-yet-known created-zone id.
1001
+ resource "aws_route53_record" "node" {
1002
+ for_each = local.want_private_dns ? local.node_fqdns : {}
1003
+ zone_id = local.private_zone_id
1004
+ name = each.value
1005
+ type = "A"
1006
+ ttl = 60
1007
+ records = [aws_instance.node[each.key].private_ip]
1008
+ }