@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,645 @@
1
+ # =============================================================================
2
+ # GCP environment module — generic, tool-agnostic. A faithful translation of
3
+ # sdk/opentofu/modules/aws (the reference), honoring the SAME variable + output
4
+ # contract. Driven by an app's rendered InfraSpec; NOTHING here is tool-specific.
5
+ #
6
+ # Network mode (worker-set deployment var, NOT app InfraSpec):
7
+ # shared — Veltrix-hosted: data-source the shared network + create ONE
8
+ # per-stack subnetwork (subnet_cidr) in it.
9
+ # dedicated — BYOC: CREATE a fresh custom-mode network + a subnetwork (vpc_cidr)
10
+ # + Cloud Router + Cloud NAT for private egress.
11
+ # existing — BYOC: data-source a customer network + create a subnetwork in it.
12
+ # DNS mode: managed (in-project public A record → LB global IP + a Google-managed
13
+ # SSL cert) / delegated (worker does the cross-account public DNS; module uses
14
+ # certificate_arn) / private-only.
15
+ #
16
+ # GCP↔AWS mapping notes:
17
+ # * SG-to-SG least privilege → a per-stack NETWORK TAG (local.stack_tag) applied
18
+ # to every instance, plus google_compute_firewall rules keyed by (rule,source).
19
+ # "self" => source_tags = [stack_tag]; "admin" => admin_cidr; "alb" => Google's
20
+ # LB health-check + proxy ranges. GCP has no per-instance security group.
21
+ # * ALB + WAFv2 → EXTERNAL_MANAGED global HTTPS LB (health check + unmanaged
22
+ # instance group + backend service + url map + HTTPS proxy + global forwarding
23
+ # rule + global address) with a Cloud Armor security policy for WAF.
24
+ # * Compute has NO public IP in any mode; egress is via Cloud NAT (dedicated) or
25
+ # the shared network's own NAT (shared/existing), mirroring AWS private subnets.
26
+ # * Cognito MFA (alb_auth) → GCP IAP is a follow-on; the var is NO-OP'd here.
27
+ # * Tags → sanitized LABELS (see local.labels) on every labelable resource.
28
+ #
29
+ # One compute instance per compute plan item (for_each keyed by plan_key) +
30
+ # storage / secrets / TLS / LB / DNS per topology tier. Cost/attribution:
31
+ # sanitized var.tags on every labelable resource.
32
+ # =============================================================================
33
+
34
+ locals {
35
+ # Short, DNS/label-safe prefix. infrastructure_id is a UUID; 8 chars is enough
36
+ # to disambiguate within a customer while staying under GCP name limits. Used
37
+ # for human-facing label VALUES / descriptions (not as a resource name).
38
+ name_prefix = "${var.app_id}-${substr(var.infrastructure_id, 0, 8)}"
39
+
40
+ # The per-stack GCP NETWORK TAG. This is the SG-to-SG analog: it is applied to
41
+ # every instance and referenced by the firewall rules (self / target). It also
42
+ # serves as the base for every GCP resource NAME, so it MUST satisfy RFC1035 /
43
+ # the network-tag charset `[a-z]([-a-z0-9]*[a-z0-9])?`. We lowercase and replace
44
+ # any invalid char with "-"; name_prefix starts with app_id (a letter) and ends
45
+ # with a hex char, so the result starts with a letter and ends alnum.
46
+ stack_tag = substr(lower(replace(local.name_prefix, "/[^a-zA-Z0-9-]/", "-")), 0, 63)
47
+
48
+ # plan_key -> plan object, for compute nodes only. This map's keys ARE the
49
+ # google_compute_instance.node[...] addresses, so status maps 1:1 back to rows.
50
+ # Tool-agnostic: an explicit compute_kinds allow-list wins; otherwise compute =
51
+ # any plan item whose kind is NOT a generic foundation kind. So an app's roles
52
+ # (app nodes, e.g. any clustered workload's tiers) are compute
53
+ # automatically, with no per-tool list in the module.
54
+ compute_nodes = {
55
+ for r in var.plan : r.plan_key => r
56
+ if(length(var.compute_kinds) > 0
57
+ ? contains(var.compute_kinds, r.kind)
58
+ : !contains(var.foundation_kinds, r.kind))
59
+ }
60
+
61
+ # Presence flags for the optional foundation tiers (derived from the plan).
62
+ has_storage = length([for r in var.plan : r if r.kind == "storage"]) > 0
63
+ has_secrets = length([for r in var.plan : r if r.kind == "secrets"]) > 0
64
+ has_license_file = length([for r in var.plan : r if r.kind == "license-file"]) > 0
65
+ has_tls = length([for r in var.plan : r if r.kind == "tls"]) > 0
66
+ has_lb = length([for r in var.plan : r if r.kind == "load-balancer"]) > 0
67
+ has_dns = length([for r in var.plan : r if r.kind == "dns"]) > 0 && var.public_dns_managed_zone != ""
68
+ has_hec = length([for r in var.plan : r if r.kind == "hec"]) > 0
69
+
70
+ resolved_image = var.image != "" ? var.image : data.google_compute_image.default.self_link
71
+
72
+ # --- Tags -> GCP labels sanitization ----------------------------------
73
+ # GCP labels: keys+values match [a-z0-9_-], keys start with a letter, <=63 chars.
74
+ # The Veltrix tag keys (Veltrix:Customer, CostCenter, ...) are INVALID as-is, so
75
+ # every taggable resource gets local.labels instead of var.tags: lowercase, any
76
+ # char outside [a-zA-Z0-9_-] replaced with "_", truncated to 63. The canonical
77
+ # Veltrix tag keys all start with a letter, so the sanitized keys are valid.
78
+ labels = {
79
+ for k, v in var.tags :
80
+ substr(lower(replace(k, "/[^a-zA-Z0-9_-]/", "_")), 0, 63) =>
81
+ substr(lower(replace(v, "/[^a-zA-Z0-9_-]/", "_")), 0, 63)
82
+ }
83
+
84
+ # --- Network mode: hosted-shared vs BYOC dedicated/existing -----------
85
+ is_dedicated = var.network_mode == "dedicated"
86
+ lookup_network = var.network_mode == "shared" || var.network_mode == "existing"
87
+
88
+ # Resolved network self-link, uniform across all three modes:
89
+ # dedicated -> the created custom-mode network
90
+ # shared/existing -> the looked-up network
91
+ network_self_link = local.is_dedicated ? google_compute_network.env[0].self_link : data.google_compute_network.shared[0].self_link
92
+
93
+ # Single per-stack subnetwork (GCP LBs are global/regional and don't need the
94
+ # multi-AZ public/private split AWS uses). One subnet in all modes.
95
+ compute_subnet_ids = [google_compute_subnetwork.env.self_link]
96
+ # Compute nodes are spread round-robin across the available compute subnets
97
+ # (a single subnet here, so all land on index 0). Kept for AWS parity.
98
+ compute_subnet_for = {
99
+ for idx, k in sort(keys(local.compute_nodes)) : k =>
100
+ local.compute_subnet_ids[idx % length(local.compute_subnet_ids)]
101
+ }
102
+
103
+ # --- DNS mode: managed (in-project) / delegated (worker x-account) / none
104
+ dns_managed = var.dns_mode == "managed"
105
+ has_public_dns = var.dns_mode != "private-only"
106
+ # The listener cert is either issued here (managed) or supplied (delegated).
107
+ listener_cert = local.dns_managed ? (
108
+ (local.has_tls && var.dns_domain != "") ? google_compute_managed_ssl_certificate.env[0].id : ""
109
+ ) : var.certificate_arn
110
+
111
+ # --- Derived LB / listener gates --------------------------------------
112
+ # has_lb = the plan carries a load-balancer item; has_lb_spec additionally
113
+ # requires the app to have supplied a load_balancer spec. The backend service +
114
+ # health check build only when a spec is present. has_listener adds the TLS cert
115
+ # + domain (plan-time-known, like the other has_* flags), gating the HTTPS proxy
116
+ # + forwarding rule identically.
117
+ has_lb_spec = local.has_lb && var.load_balancer != null
118
+ # HTTPS front door needs a cert: issued here (managed + a tls plan item) or
119
+ # supplied by the worker (delegated + certificate_arn).
120
+ has_listener = local.has_lb_spec && var.dns_domain != "" && (
121
+ local.dns_managed ? local.has_tls : var.certificate_arn != ""
122
+ )
123
+ # Cognito MFA (alb_auth) -> GCP IAP is a documented follow-on; NO-OP here. The
124
+ # flag is surfaced for parity but drives no resources (see header + variables).
125
+ alb_auth_enabled = var.alb_auth.enabled
126
+
127
+ # Health-check protocol from the LB spec (falls back to target protocol). Guarded
128
+ # so it never dereferences a null load_balancer.
129
+ hc_protocol = local.has_lb_spec ? (
130
+ var.load_balancer.health_check_protocol != "" ? var.load_balancer.health_check_protocol : var.load_balancer.target_protocol
131
+ ) : ""
132
+
133
+ # Compute nodes that sit behind the LB — the kinds the app named as LB targets
134
+ # (e.g. the app's web/standalone nodes). try() keeps it null-safe when no spec.
135
+ lb_target_kinds = try(var.load_balancer.target_kinds, [])
136
+ search_targets = {
137
+ for k, r in local.compute_nodes : k => r
138
+ if contains(local.lb_target_kinds, r.kind)
139
+ }
140
+
141
+ # --- Per-node function DNS labels -------------------------------------
142
+ # DNS-label-safe prefix per compute kind, from the app's dns_prefixes (falls
143
+ # back to the kind string). The bring-up layer resolves intra-cluster peers by
144
+ # these function FQDNs (e.g. idx1.<domain>), so the mapping MUST be
145
+ # deterministic and stable across applies. (Verbatim from the AWS module.)
146
+ node_prefix = {
147
+ for k, r in local.compute_nodes : k => lookup(var.dns_prefixes, r.kind, r.kind)
148
+ }
149
+
150
+ # Deterministic per-kind ordinal: the label index is the key's position within
151
+ # its kind's lexically-sorted plan_key list (1-based via index()+1). Distinct
152
+ # keys therefore get unique, collision-free ordinals independent of the plan's
153
+ # input order (e.g. idx1, idx2 / sh1, sh2).
154
+ node_keys_by_kind = {
155
+ for kind in distinct([for k, r in local.compute_nodes : r.kind]) : kind => sort([
156
+ for k, r in local.compute_nodes : k if r.kind == kind
157
+ ])
158
+ }
159
+ node_short_labels = {
160
+ for k, r in local.compute_nodes : k => format(
161
+ "%s%d",
162
+ local.node_prefix[k],
163
+ index(local.node_keys_by_kind[r.kind], k) + 1,
164
+ )
165
+ }
166
+
167
+ # plan_key -> function FQDN in the private zone. Empty when no domain is set.
168
+ node_fqdns = var.dns_domain != "" ? {
169
+ for k, label in local.node_short_labels : k => "${label}.${var.dns_domain}"
170
+ } : {}
171
+
172
+ # --- Private DNS zone resolution --------------------------------------
173
+ # Either create a private managed zone for var.dns_domain (create_private_zone)
174
+ # or point at a caller-supplied one (private_zone_id). want_private_dns is the
175
+ # plan-time-known intent used to gate the per-node record for_each (the zone name
176
+ # itself may stay computed until apply, which is fine for a record attribute but
177
+ # NOT for a for_each key set).
178
+ create_private_zone = var.create_private_zone && var.dns_domain != "" && var.private_zone_id == ""
179
+ want_private_dns = var.dns_domain != "" && (var.private_zone_id != "" || local.create_private_zone)
180
+ private_zone_name = var.private_zone_id != "" ? var.private_zone_id : (
181
+ local.create_private_zone ? google_dns_managed_zone.private[0].name : ""
182
+ )
183
+
184
+ # --- Flattened firewall ingress rules (from the app's security_rules) --
185
+ # Each (rule, source) pair becomes one google_compute_firewall, keyed
186
+ # "<port>-<protocol>-<source>". "alb"-sourced rules are dropped when there is no
187
+ # LB. This replaces any hardcoded, tool-specific port list — the app declares its
188
+ # ports in InfraSpec.securityRules. (Verbatim shape from the AWS module.)
189
+ fw_ingress = merge([
190
+ for r in var.security_rules : {
191
+ for s in r.sources : "${r.port}-${r.protocol}-${s}" => {
192
+ port = r.port
193
+ protocol = r.protocol
194
+ source = s
195
+ description = r.description != "" ? r.description : "port ${r.port} (${r.protocol}) from ${s}"
196
+ }
197
+ if !(s == "alb" && !local.has_lb)
198
+ }
199
+ ]...)
200
+
201
+ # Google Cloud Load Balancing health-check + proxy source ranges (used by the
202
+ # "alb"-sourced firewall rules so the LB can reach the backends + health-check).
203
+ gclb_ranges = ["130.211.0.0/22", "35.191.0.0/16"]
204
+ }
205
+
206
+ # Scaffold fallback image only. Production must pass a tool-preinstalled image.
207
+ data "google_compute_image" "default" {
208
+ family = "debian-12"
209
+ project = "debian-cloud"
210
+ }
211
+
212
+ # --- Network lookup (network_mode = shared | existing) --------------------
213
+ # The network is data-sourced (never created) — the shared Veltrix network for
214
+ # hosted, or a customer-designated network for BYOC "existing". Absent when
215
+ # dedicated. GCP networks are keyed by NAME (no tag lookup), so network_ref is the
216
+ # network name for both network_lookup_by values.
217
+
218
+ data "google_compute_network" "shared" {
219
+ count = local.lookup_network ? 1 : 0
220
+ name = var.network_ref
221
+ project = var.project
222
+ }
223
+
224
+ # --- Dedicated network fabric (network_mode = dedicated / BYOC) ------------
225
+ # A fresh, isolated custom-mode network created in the DEPLOY project (the
226
+ # customer's, for BYOC), plus a Cloud Router + Cloud NAT so the private (no public
227
+ # IP) instances get egress. Nothing here runs in shared/existing mode.
228
+
229
+ resource "google_compute_network" "env" {
230
+ count = local.is_dedicated ? 1 : 0
231
+ name = "${local.stack_tag}-net"
232
+ project = var.project
233
+ auto_create_subnetworks = false
234
+ }
235
+
236
+ resource "google_compute_router" "env" {
237
+ count = local.is_dedicated ? 1 : 0
238
+ name = "${local.stack_tag}-router"
239
+ project = var.project
240
+ region = var.region
241
+ network = google_compute_network.env[0].self_link
242
+ }
243
+
244
+ resource "google_compute_router_nat" "env" {
245
+ count = local.is_dedicated ? 1 : 0
246
+ name = "${local.stack_tag}-nat"
247
+ project = var.project
248
+ region = var.region
249
+ router = google_compute_router.env[0].name
250
+ nat_ip_allocate_option = "AUTO_ONLY"
251
+ source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
252
+ }
253
+
254
+ # --- Subnetwork (all modes) -----------------------------------------------
255
+ # The stack's single subnetwork: in the created network (dedicated, range =
256
+ # vpc_cidr) or the looked-up network (shared/existing, range = subnet_cidr).
257
+ # private_ip_google_access lets no-public-IP instances reach Google APIs
258
+ # (Secret Manager / Storage) without traversing the NAT. Subnetworks are NOT
259
+ # labelable in GCP.
260
+
261
+ resource "google_compute_subnetwork" "env" {
262
+ name = "${local.stack_tag}-subnet"
263
+ project = var.project
264
+ region = var.region
265
+ network = local.network_self_link
266
+ ip_cidr_range = local.is_dedicated ? var.vpc_cidr : var.subnet_cidr
267
+ private_ip_google_access = true
268
+ }
269
+
270
+ # --- Firewall: SG-to-SG least privilege via a per-stack network tag --------
271
+ # GCP has no per-instance security group. Instead every instance carries the
272
+ # stack network tag (local.stack_tag), and each (security_rules entry, source)
273
+ # becomes one firewall rule targeting that tag. "self" -> source_tags (this
274
+ # stack's own instances); "admin" -> admin_cidr; "alb" -> Google's LB
275
+ # health-check + proxy ranges. Exactly one origin is set per rule; the other is
276
+ # null (omitted). "alb" rules are pre-filtered out of fw_ingress when there is no
277
+ # LB. Firewalls are NOT labelable in GCP.
278
+
279
+ resource "google_compute_firewall" "node" {
280
+ for_each = local.fw_ingress
281
+
282
+ name = substr("${local.stack_tag}-${each.key}", 0, 63)
283
+ project = var.project
284
+ network = local.network_self_link
285
+ direction = "INGRESS"
286
+ description = each.value.description
287
+
288
+ allow {
289
+ protocol = each.value.protocol
290
+ ports = [tostring(each.value.port)]
291
+ }
292
+
293
+ target_tags = [local.stack_tag]
294
+ source_tags = each.value.source == "self" ? [local.stack_tag] : null
295
+ source_ranges = (
296
+ each.value.source == "admin" ? [var.admin_cidr] :
297
+ each.value.source == "alb" ? local.gclb_ranges :
298
+ null
299
+ )
300
+ }
301
+
302
+ # --- Compute: one google_compute_instance per compute plan item ------------
303
+ # for_each keyed by plan_key => google_compute_instance.node["data/indexer-1"].
304
+ # No public IP (egress via Cloud NAT). The stack network tag ties the instance to
305
+ # the firewall rules above. `hostname` must be a FQDN on GCP, so we use the node
306
+ # function FQDN when a domain is set (else omit -> the default internal name).
307
+
308
+ resource "google_compute_instance" "node" {
309
+ for_each = local.compute_nodes
310
+
311
+ name = substr(lower("${local.stack_tag}-${local.node_short_labels[each.key]}"), 0, 63)
312
+ project = var.project
313
+ # Multi-AZ placement: pin to the node's zone when set, else the default zone.
314
+ zone = coalesce(each.value.zone, var.zone)
315
+ machine_type = coalesce(
316
+ lookup(var.machine_types_by_kind, each.value.kind, null),
317
+ lookup(var.machine_types, each.value.tier, null),
318
+ var.default_machine_type,
319
+ )
320
+ hostname = lookup(local.node_fqdns, each.key, null)
321
+
322
+ boot_disk {
323
+ initialize_params {
324
+ image = local.resolved_image
325
+ size = var.boot_disk_gb
326
+ type = "pd-ssd"
327
+ }
328
+ }
329
+
330
+ network_interface {
331
+ subnetwork = local.compute_subnet_for[each.key]
332
+ # No access_config block => no external IP. Egress is via Cloud NAT.
333
+ }
334
+
335
+ tags = [local.stack_tag]
336
+ metadata = var.ssh_public_key != "" ? { "ssh-keys" = var.ssh_public_key } : {}
337
+
338
+ labels = local.labels
339
+ }
340
+
341
+ # --- Storage: object-storage bucket (e.g. SmartStore, warm/cold tiers) -----
342
+ # Generic GCS bucket for the app's bulk/object storage. Private: uniform
343
+ # bucket-level access + enforced public-access prevention (no ACLs, no public).
344
+
345
+ resource "random_id" "bucket_suffix" {
346
+ count = local.has_storage ? 1 : 0
347
+ byte_length = 4
348
+ }
349
+
350
+ resource "google_storage_bucket" "objstore" {
351
+ count = local.has_storage ? 1 : 0
352
+ name = "${local.stack_tag}-objstore-${random_id.bucket_suffix[0].hex}"
353
+ project = var.project
354
+ location = var.region
355
+ force_destroy = false
356
+ uniform_bucket_level_access = true
357
+ public_access_prevention = "enforced"
358
+
359
+ labels = local.labels
360
+ }
361
+
362
+ # --- Secrets: per-tenant secret bundle (admin seed / pass4SymmKey / etc.) ----
363
+
364
+ resource "google_secret_manager_secret" "env" {
365
+ count = local.has_secrets ? 1 : 0
366
+ secret_id = "${local.stack_tag}-env-secrets"
367
+ project = var.project
368
+
369
+ replication {
370
+ auto {}
371
+ }
372
+
373
+ labels = local.labels
374
+ }
375
+
376
+ # --- BYOL license file (stored as a secret; validated post-apply) ---------
377
+
378
+ resource "google_secret_manager_secret" "license" {
379
+ count = local.has_license_file ? 1 : 0
380
+ secret_id = "${local.stack_tag}-byol-license"
381
+ project = var.project
382
+
383
+ replication {
384
+ auto {}
385
+ }
386
+
387
+ labels = local.labels
388
+ }
389
+
390
+ # --- TLS certificate (Google-managed, for the public domain) --------------
391
+ # Issued in-project only for dns_mode = managed. In delegated mode the worker
392
+ # provisions the cert and passes certificate_arn (a cert self-link). Managed SSL
393
+ # certs are NOT labelable in GCP.
394
+
395
+ resource "google_compute_managed_ssl_certificate" "env" {
396
+ count = local.dns_managed && local.has_tls && var.dns_domain != "" ? 1 : 0
397
+ name = "${local.stack_tag}-cert"
398
+ project = var.project
399
+
400
+ managed {
401
+ domains = [var.dns_domain]
402
+ }
403
+
404
+ # NOTE: unlike the AWS ACM cert (create_before_destroy), a google_compute_
405
+ # managed_ssl_certificate has a unique NAME, so create_before_destroy would fail
406
+ # on replacement (two certs can't share a name). Cert domain changes are rare;
407
+ # we take the default replace behaviour. Zero-downtime cert rotation would use a
408
+ # name suffix (random_id), a GCP-specific follow-on.
409
+ }
410
+
411
+ # --- Load balancer: EXTERNAL_MANAGED global HTTPS LB ----------------------
412
+ # The front door for the app's web tier + HEC ingress. Global external
413
+ # Application LB: a reserved global IP + health check + unmanaged instance group
414
+ # (target-kind instances) + backend service + url map + HTTPS proxy + global
415
+ # forwarding rule. Google runs the proxy fleet, so (unlike the AWS ALB) there is
416
+ # no public edge subnet/SG — client access is governed by Cloud Armor.
417
+
418
+ # Reserved global IPv4 for the LB frontend. Gated on has_lb (mirrors aws_lb).
419
+ resource "google_compute_global_address" "lb" {
420
+ count = local.has_lb ? 1 : 0
421
+ name = "${local.stack_tag}-lb-ip"
422
+ project = var.project
423
+ ip_version = "IPV4"
424
+ }
425
+
426
+ # Health check from the app's load_balancer spec. Port = the LB target port (the
427
+ # GCP equivalent of the ALB's "traffic-port"), so the "alb"-sourced firewall rule
428
+ # on that port also admits health checks. HTTP vs HTTPS selected by hc_protocol.
429
+ resource "google_compute_health_check" "lb" {
430
+ count = local.has_lb_spec ? 1 : 0
431
+ name = "${local.stack_tag}-hc"
432
+ project = var.project
433
+
434
+ dynamic "http_health_check" {
435
+ for_each = local.hc_protocol == "HTTP" ? [1] : []
436
+ content {
437
+ port = var.load_balancer.target_port
438
+ request_path = var.load_balancer.health_check_path
439
+ }
440
+ }
441
+
442
+ dynamic "https_health_check" {
443
+ for_each = local.hc_protocol == "HTTPS" ? [1] : []
444
+ content {
445
+ port = var.load_balancer.target_port
446
+ request_path = var.load_balancer.health_check_path
447
+ }
448
+ }
449
+ }
450
+
451
+ # Unmanaged instance group holding the web-serving nodes (the kinds the app named
452
+ # in the LB spec's target_kinds), added by self-link. named_port maps the target
453
+ # port to the "http" port name the backend service references.
454
+ resource "google_compute_instance_group" "lb" {
455
+ count = local.has_lb_spec ? 1 : 0
456
+ name = "${local.stack_tag}-ig"
457
+ project = var.project
458
+ zone = var.zone
459
+ instances = [for k, r in local.search_targets : google_compute_instance.node[k].self_link]
460
+
461
+ named_port {
462
+ name = "http"
463
+ port = var.load_balancer.target_port
464
+ }
465
+ }
466
+
467
+ # Backend service: protocol from target_protocol, the health check, and the
468
+ # instance group backend. Cloud Armor security policy attached when waf_enabled.
469
+ resource "google_compute_backend_service" "lb" {
470
+ count = local.has_lb_spec ? 1 : 0
471
+ name = "${local.stack_tag}-backend"
472
+ project = var.project
473
+ load_balancing_scheme = "EXTERNAL_MANAGED"
474
+ protocol = var.load_balancer.target_protocol
475
+ port_name = "http"
476
+ health_checks = [google_compute_health_check.lb[0].id]
477
+ security_policy = var.waf_enabled ? google_compute_security_policy.waf[0].id : null
478
+
479
+ backend {
480
+ group = google_compute_instance_group.lb[0].self_link
481
+ balancing_mode = "UTILIZATION"
482
+ capacity_scaler = 1.0
483
+ }
484
+ }
485
+
486
+ resource "google_compute_url_map" "lb" {
487
+ count = local.has_lb_spec ? 1 : 0
488
+ name = "${local.stack_tag}-urlmap"
489
+ project = var.project
490
+ default_service = google_compute_backend_service.lb[0].id
491
+ }
492
+
493
+ # HTTPS proxy + global forwarding rule — the public listener. Gated on
494
+ # has_listener (LB spec + domain + a cert: managed cert here, or certificate_arn).
495
+ resource "google_compute_target_https_proxy" "lb" {
496
+ count = local.has_listener ? 1 : 0
497
+ name = "${local.stack_tag}-https-proxy"
498
+ project = var.project
499
+ url_map = google_compute_url_map.lb[0].id
500
+ ssl_certificates = [local.listener_cert]
501
+ }
502
+
503
+ resource "google_compute_global_forwarding_rule" "lb" {
504
+ count = local.has_listener ? 1 : 0
505
+ name = "${local.stack_tag}-fr"
506
+ project = var.project
507
+ load_balancing_scheme = "EXTERNAL_MANAGED"
508
+ target = google_compute_target_https_proxy.lb[0].id
509
+ ip_address = google_compute_global_address.lb[0].address
510
+ port_range = tostring(var.load_balancer.listener_port)
511
+
512
+ labels = local.labels
513
+ }
514
+
515
+ # NOTE: an HTTP(80)->HTTPS redirect (the AWS module's http listener) is omitted —
516
+ # on GCP it needs a separate redirect url-map + HTTP target proxy + forwarding
517
+ # rule. It can be added as a follow-on; the primary HTTPS front door is complete.
518
+
519
+ # --- Cloud Armor (WAF analog) ---------------------------------------------
520
+ # Attached to the backend service when waf_enabled. Default allow, with OWASP
521
+ # preconfigured rules (SQLi/XSS) blocking and a per-IP rate limit (~2000 req /
522
+ # 5 min), mirroring the AWS WAFv2 managed rule groups + rate-based rule. Gated on
523
+ # has_lb (a Cloud Armor policy fronts the LB backend). Not labelable.
524
+
525
+ resource "google_compute_security_policy" "waf" {
526
+ count = local.has_lb && var.waf_enabled ? 1 : 0
527
+ name = "${local.stack_tag}-waf"
528
+ project = var.project
529
+
530
+ # OWASP CRS: SQL injection.
531
+ rule {
532
+ action = "deny(403)"
533
+ priority = 1000
534
+ description = "OWASP CRS: SQL injection"
535
+
536
+ match {
537
+ expr {
538
+ expression = "evaluatePreconfiguredExpr('sqli-v33-stable')"
539
+ }
540
+ }
541
+ }
542
+
543
+ # OWASP CRS: cross-site scripting.
544
+ rule {
545
+ action = "deny(403)"
546
+ priority = 1001
547
+ description = "OWASP CRS: cross-site scripting"
548
+
549
+ match {
550
+ expr {
551
+ expression = "evaluatePreconfiguredExpr('xss-v33-stable')"
552
+ }
553
+ }
554
+ }
555
+
556
+ # Volumetric protection: throttle a source IP over ~2000 requests / 5 min.
557
+ rule {
558
+ action = "throttle"
559
+ priority = 2000
560
+ description = "Per-IP rate limit"
561
+
562
+ match {
563
+ versioned_expr = "SRC_IPS_V1"
564
+ config {
565
+ src_ip_ranges = ["*"]
566
+ }
567
+ }
568
+
569
+ rate_limit_options {
570
+ conform_action = "allow"
571
+ exceed_action = "deny(429)"
572
+ enforce_on_key = "IP"
573
+
574
+ rate_limit_threshold {
575
+ count = 2000
576
+ interval_sec = 300
577
+ }
578
+ }
579
+ }
580
+
581
+ # Required default rule (lowest priority): allow everything else.
582
+ rule {
583
+ action = "allow"
584
+ priority = 2147483647
585
+ description = "Default allow"
586
+
587
+ match {
588
+ versioned_expr = "SRC_IPS_V1"
589
+ config {
590
+ src_ip_ranges = ["*"]
591
+ }
592
+ }
593
+ }
594
+ }
595
+
596
+ # --- Public DNS record ----------------------------------------------------
597
+ # Created in-project only for dns_mode = managed. delegated => the worker writes
598
+ # it cross-account into Veltrix's zone; private-only => no public record. An A
599
+ # record to the LB's reserved global IP (GCP LBs have no DNS name to alias).
600
+
601
+ resource "google_dns_record_set" "env" {
602
+ count = local.dns_managed && local.has_dns && local.has_lb ? 1 : 0
603
+ name = "${var.dns_domain}."
604
+ project = var.project
605
+ managed_zone = var.public_dns_managed_zone
606
+ type = "A"
607
+ ttl = 60
608
+ rrdatas = [google_compute_global_address.lb[0].address]
609
+ }
610
+
611
+ # --- Private DNS: intra-cluster function FQDNs ----------------------------
612
+ # A private managed zone (visibility = private, bound to the stack network) gives
613
+ # every node a stable function FQDN (idx1.<domain>, sh1.<domain>, ...). The
614
+ # bring-up layer uses node_fqdns (see outputs) to build its inventory. Either
615
+ # create the zone here (create_private_zone) or reuse a caller-supplied one
616
+ # (private_zone_id).
617
+
618
+ resource "google_dns_managed_zone" "private" {
619
+ count = local.create_private_zone ? 1 : 0
620
+ name = "${local.stack_tag}-private"
621
+ project = var.project
622
+ dns_name = "${var.dns_domain}."
623
+ visibility = "private"
624
+
625
+ private_visibility_config {
626
+ networks {
627
+ network_url = local.network_self_link
628
+ }
629
+ }
630
+
631
+ labels = local.labels
632
+ }
633
+
634
+ # One A record per compute node -> its private IP, keyed by plan_key so the set
635
+ # tracks the compute for_each. Gated on want_private_dns (plan-time-known) so the
636
+ # key set never depends on the not-yet-known created-zone name.
637
+ resource "google_dns_record_set" "node" {
638
+ for_each = local.want_private_dns ? local.node_fqdns : {}
639
+ name = "${each.value}."
640
+ project = var.project
641
+ managed_zone = local.private_zone_name
642
+ type = "A"
643
+ ttl = 60
644
+ rrdatas = [google_compute_instance.node[each.key].network_interface[0].network_ip]
645
+ }