@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,432 @@
1
+ # =============================================================================
2
+ # Hetzner Cloud environment module — generic, tool-agnostic. A faithful
3
+ # translation of the AWS reference module (sdk/opentofu/modules/aws), honoring
4
+ # the SAME variable + output contract. Driven by an app's rendered InfraSpec;
5
+ # NOTHING here is tool-specific.
6
+ #
7
+ # Network mode (worker-set deployment var, NOT app InfraSpec):
8
+ # shared — Veltrix-hosted: data-source the shared hcloud_network + create
9
+ # the per-stack cloud subnet.
10
+ # dedicated — BYOC: CREATE a fresh hcloud_network + one cloud subnet.
11
+ # existing — BYOC: data-source a customer network + create a subnet in it.
12
+ #
13
+ # Servers attach to the private network (hcloud_server_network) for private IPs;
14
+ # they keep their default PUBLIC interface for egress (Hetzner private networks
15
+ # have no built-in NAT — a NAT gateway server would be a separate, out-of-band
16
+ # resource). The firewall (hcloud_firewall) is the SG analog and attaches to
17
+ # this stack's servers via a `veltrix-stack` label selector.
18
+ #
19
+ # =============================================================================
20
+ # DOCUMENTED GAPS — Hetzner is the simplest cloud and lacks several primitives
21
+ # the AWS module uses. These are handled HONESTLY (no-op + comment), never faked:
22
+ #
23
+ # * WAF — Hetzner has no WAF. `waf_enabled` is accepted for contract
24
+ # parity but is a NO-OP; a real WAF would require an external
25
+ # reverse-proxy/CDN in front of the LB. See the LB section.
26
+ # * DNS records— The hcloud provider has NO managed-DNS resource (Hetzner DNS
27
+ # is the separate `timohirt/hetznerdns` provider). This module
28
+ # creates NO public or private DNS records in ANY dns_mode. It
29
+ # computes per-node FQDNs and EMITS them (output node_fqdns) so
30
+ # the bring-up layer can populate /etc/hosts or a hetznerdns
31
+ # stack out-of-band. dns_mode / create_private_zone /
32
+ # private_zone_id are kept for contract uniformity (no-ops).
33
+ # * Object store— Hetzner Object Storage is S3-compatible but NOT in the hcloud
34
+ # provider. `storage` plan items create a block volume
35
+ # (hcloud_volume) instead; true object storage is external.
36
+ # * Secrets — Hetzner has no Secrets Manager / Key Vault. `secrets` and
37
+ # `license-file` plan items create NO resource here; those
38
+ # values live in the platform vault (injected out-of-band).
39
+ # * MFA (alb_auth)— Hetzner LBs have no OIDC/Cognito equivalent. `alb_auth` is
40
+ # accepted for contract parity but is a NO-OP.
41
+ # =============================================================================
42
+
43
+ locals {
44
+ # Short, DNS/label-safe prefix. infrastructure_id is a UUID; 8 chars is enough
45
+ # to disambiguate within a customer while staying under name limits.
46
+ name_prefix = "${var.app_id}-${substr(var.infrastructure_id, 0, 8)}"
47
+
48
+ # Hostname-safe form of name_prefix (lowercase, [a-z0-9-]) for server names.
49
+ host_prefix = replace(lower(local.name_prefix), "/[^a-z0-9-]/", "-")
50
+
51
+ # Label-value-safe form of name_prefix, used as the per-stack selector value.
52
+ stack_label = substr(replace(local.name_prefix, "/[^a-zA-Z0-9._-]/", "_"), 0, 63)
53
+
54
+ # --- Location / network zone resolution -------------------------------
55
+ # var.region may be a location (nbg1) OR a network zone (eu-central). Servers
56
+ # and volumes need a location; the subnet and LB need a network zone. Resolve
57
+ # both, deriving the missing side, with explicit override vars taking priority.
58
+ network_zones = ["eu-central", "us-east", "us-west", "ap-southeast"]
59
+ location_zone = {
60
+ nbg1 = "eu-central"
61
+ fsn1 = "eu-central"
62
+ hel1 = "eu-central"
63
+ ash = "us-east"
64
+ hil = "us-west"
65
+ sin = "ap-southeast"
66
+ }
67
+ zone_default_location = {
68
+ "eu-central" = "nbg1"
69
+ "us-east" = "ash"
70
+ "us-west" = "hil"
71
+ "ap-southeast" = "sin"
72
+ }
73
+ region_is_zone = contains(local.network_zones, var.region)
74
+ network_zone = var.network_zone != "" ? var.network_zone : (
75
+ local.region_is_zone ? var.region : lookup(local.location_zone, var.region, "eu-central")
76
+ )
77
+ location = var.location != "" ? var.location : (
78
+ local.region_is_zone ? lookup(local.zone_default_location, var.region, "nbg1") : var.region
79
+ )
80
+
81
+ # plan_key -> plan object, for compute nodes only. This map's keys ARE the
82
+ # hcloud_server.node[...] addresses, so status maps 1:1 back to resource rows.
83
+ # Tool-agnostic: an explicit compute_kinds allow-list wins; otherwise compute =
84
+ # any plan item whose kind is NOT a generic foundation kind.
85
+ compute_nodes = {
86
+ for r in var.plan : r.plan_key => r
87
+ if(length(var.compute_kinds) > 0
88
+ ? contains(var.compute_kinds, r.kind)
89
+ : !contains(var.foundation_kinds, r.kind))
90
+ }
91
+
92
+ # Presence flags for the optional foundation tiers (derived from the plan).
93
+ # NOTE: `secrets` and `license-file` intentionally have NO flag/resource —
94
+ # Hetzner has no secret store (see the gap notes above).
95
+ has_storage = length([for r in var.plan : r if r.kind == "storage"]) > 0
96
+ has_tls = length([for r in var.plan : r if r.kind == "tls"]) > 0
97
+ has_lb = length([for r in var.plan : r if r.kind == "load-balancer"]) > 0
98
+ has_hec = length([for r in var.plan : r if r.kind == "hec"]) > 0
99
+
100
+ resolved_image = var.image != "" ? var.image : "ubuntu-22.04"
101
+
102
+ # --- Network mode: hosted-shared vs BYOC dedicated/existing -----------
103
+ is_dedicated = var.network_mode == "dedicated"
104
+ lookup_network = var.network_mode == "shared" || var.network_mode == "existing"
105
+
106
+ # Resolved network id + the single per-stack subnet ip_range, uniform across
107
+ # all three modes. Hetzner subnets are ip_ranges (not multi-AZ objects), so one
108
+ # subnet per stack is sufficient. `.id` on an hcloud resource is a string in
109
+ # TF; hcloud_network_subnet.network_id accepts it and coerces to int.
110
+ network_id = local.is_dedicated ? hcloud_network.env[0].id : data.hcloud_network.shared[0].id
111
+ compute_subnet_cidr = var.subnet_cidr != "" ? var.subnet_cidr : var.vpc_cidr
112
+ # Intra-stack peer range: firewall "self"/"alb" sources resolve to the subnet
113
+ # CIDR (there is no SG-to-SG on Hetzner). The LB's private interface lives in
114
+ # this range too, so "alb"-sourced rules use the same CIDR.
115
+ self_cidr = local.compute_subnet_cidr
116
+
117
+ # --- DNS mode: no records are created on Hetzner (see gap notes). ------
118
+ # The LB serves HTTPS only when a certificate id is supplied; otherwise HTTP.
119
+ lb_use_https = local.has_lb && var.load_balancer != null && var.certificate_arn != ""
120
+
121
+ # --- Derived LB gates (mirror the AWS has_lb / has_lb_spec logic) ------
122
+ has_lb_spec = local.has_lb && var.load_balancer != null
123
+
124
+ # Compute nodes that sit behind the LB — the kinds the app named as LB targets.
125
+ # try() keeps it null-safe when no LB spec is present.
126
+ lb_target_kinds = try(var.load_balancer.target_kinds, [])
127
+ search_targets = {
128
+ for k, r in local.compute_nodes : k => r
129
+ if contains(local.lb_target_kinds, r.kind)
130
+ }
131
+
132
+ # --- Per-node function DNS labels (VERBATIM from the AWS module) -------
133
+ # DNS-label-safe prefix per compute kind, from the app's dns_prefixes (falls
134
+ # back to the kind string). The bring-up layer resolves intra-cluster peers by
135
+ # these function FQDNs, so the mapping MUST be deterministic and stable.
136
+ node_prefix = {
137
+ for k, r in local.compute_nodes : k => lookup(var.dns_prefixes, r.kind, r.kind)
138
+ }
139
+
140
+ # Deterministic per-kind ordinal: the label index is the key's position within
141
+ # its kind's lexically-sorted plan_key list (1-based via index()+1). Distinct
142
+ # keys therefore get unique, collision-free ordinals independent of the plan's
143
+ # input order (e.g. idx1, idx2 / sh1, sh2).
144
+ node_keys_by_kind = {
145
+ for kind in distinct([for k, r in local.compute_nodes : r.kind]) : kind => sort([
146
+ for k, r in local.compute_nodes : k if r.kind == kind
147
+ ])
148
+ }
149
+ node_short_labels = {
150
+ for k, r in local.compute_nodes : k => format(
151
+ "%s%d",
152
+ local.node_prefix[k],
153
+ index(local.node_keys_by_kind[r.kind], k) + 1,
154
+ )
155
+ }
156
+
157
+ # plan_key -> function FQDN. Empty when no domain is set. Emitted as an output
158
+ # (node_fqdns); NO DNS record is created for it on Hetzner.
159
+ node_fqdns = var.dns_domain != "" ? {
160
+ for k, label in local.node_short_labels : k => "${label}.${var.dns_domain}"
161
+ } : {}
162
+
163
+ # --- Label sanitization: var.tags -> hcloud labels --------------------
164
+ # hcloud labels use a restricted charset (key/value: start+end alphanumeric,
165
+ # inner [a-zA-Z0-9._-], <=63; NO colons). AWS tag keys like "Veltrix:Customer"
166
+ # are invalid, so: replace every invalid char with "_", truncate to 63, then
167
+ # trim any leading/trailing separator so the value still starts/ends alnum.
168
+ base_labels = {
169
+ for k, v in var.tags :
170
+ replace(substr(replace(k, "/[^a-zA-Z0-9._-]/", "_"), 0, 63), "/^[._-]+|[._-]+$/", "")
171
+ =>
172
+ replace(substr(replace(v, "/[^a-zA-Z0-9._-]/", "_"), 0, 63), "/^[._-]+|[._-]+$/", "")
173
+ }
174
+
175
+ # Per-compute-node sanitized meta (plan_key/tier/kind/role carry "/" etc.).
176
+ compute_meta = {
177
+ for k, r in local.compute_nodes : k => {
178
+ plan_key = replace(substr(replace(k, "/[^a-zA-Z0-9._-]/", "_"), 0, 63), "/^[._-]+|[._-]+$/", "")
179
+ tier = replace(substr(replace(r.tier, "/[^a-zA-Z0-9._-]/", "_"), 0, 63), "/^[._-]+|[._-]+$/", "")
180
+ kind = replace(substr(replace(r.kind, "/[^a-zA-Z0-9._-]/", "_"), 0, 63), "/^[._-]+|[._-]+$/", "")
181
+ role = r.role != "" ? replace(substr(replace(r.role, "/[^a-zA-Z0-9._-]/", "_"), 0, 63), "/^[._-]+|[._-]+$/", "") : "none"
182
+ }
183
+ }
184
+
185
+ # Fixed foundation label sets (values written pre-sanitized: "/" -> "_").
186
+ labels_network = merge(local.base_labels, { "Name" = "${local.stack_label}-network", "Veltrix_PlanKey" = "foundation_network", "Veltrix_Tier" = "foundation", "veltrix-stack" = local.stack_label })
187
+ labels_storage = merge(local.base_labels, { "Name" = "${local.stack_label}-volume", "Veltrix_PlanKey" = "foundation_storage", "Veltrix_Tier" = "foundation", "veltrix-stack" = local.stack_label })
188
+ labels_lb = merge(local.base_labels, { "Name" = "${local.stack_label}-lb", "Veltrix_PlanKey" = "foundation_load-balancer", "Veltrix_Tier" = "foundation", "veltrix-stack" = local.stack_label })
189
+ labels_firewall = merge(local.base_labels, { "Name" = "${local.stack_label}-fw", "Veltrix_PlanKey" = "foundation_network", "Veltrix_Tier" = "foundation", "veltrix-stack" = local.stack_label })
190
+
191
+ # Per-compute-node label set (base + sanitized meta + the stack selector label).
192
+ compute_labels = {
193
+ for k, r in local.compute_nodes : k => merge(local.base_labels, {
194
+ "Name" = "${local.host_prefix}-${local.node_short_labels[k]}"
195
+ "Veltrix_PlanKey" = local.compute_meta[k].plan_key
196
+ "Veltrix_Tier" = local.compute_meta[k].tier
197
+ "Veltrix_Kind" = local.compute_meta[k].kind
198
+ "Veltrix_Role" = local.compute_meta[k].role
199
+ "veltrix-stack" = local.stack_label
200
+ })
201
+ }
202
+
203
+ # --- Flattened firewall ingress rules (from the app's security_rules) --
204
+ # There is NO SG-to-SG on Hetzner: each source resolves to a CIDR.
205
+ # self -> the stack subnet CIDR (peer nodes)
206
+ # alb -> the stack subnet CIDR (the LB's private interface range); the pair
207
+ # is dropped when there is no LB, exactly like the AWS module.
208
+ # admin -> var.admin_cidr
209
+ # The map key is "<port>-<protocol>-<cidr>", which DEDUPES the common case where
210
+ # self and alb resolve to the same CIDR (one firewall rule instead of two).
211
+ source_cidr = {
212
+ self = local.self_cidr
213
+ alb = local.self_cidr
214
+ admin = var.admin_cidr
215
+ }
216
+ fw_rules = {
217
+ for e in flatten([
218
+ for r in var.security_rules : [
219
+ for s in r.sources : {
220
+ port = r.port
221
+ protocol = r.protocol
222
+ cidr = local.source_cidr[s]
223
+ description = r.description != "" ? r.description : "port ${r.port} (${r.protocol}) from ${s}"
224
+ }
225
+ if !(s == "alb" && !local.has_lb)
226
+ ]
227
+ ]) : "${e.port}-${e.protocol}-${e.cidr}" => e
228
+ }
229
+ }
230
+
231
+ # --- Network lookup (network_mode = shared | existing) --------------------
232
+ # The network is data-sourced (never created) — the shared Veltrix network for
233
+ # hosted, or a customer-designated network for BYOC "existing". Absent when
234
+ # dedicated. Resolved by name (default), id, or a label selector (network_lookup_by).
235
+ data "hcloud_network" "shared" {
236
+ count = local.lookup_network ? 1 : 0
237
+ id = var.network_lookup_by == "id" ? var.network_ref : null
238
+ name = var.network_lookup_by == "name" ? var.network_ref : null
239
+ with_selector = var.network_lookup_by == "tag" ? var.network_ref : null
240
+ }
241
+
242
+ # --- Dedicated network fabric (network_mode = dedicated / BYOC) ------------
243
+ # A fresh, isolated private network created in the DEPLOY account (the
244
+ # customer's, for BYOC). One cloud subnet is carved from it (below). Nothing
245
+ # here runs in shared/existing mode.
246
+ resource "hcloud_network" "env" {
247
+ count = local.is_dedicated ? 1 : 0
248
+ name = "${local.host_prefix}-net"
249
+ ip_range = var.vpc_cidr
250
+ labels = local.labels_network
251
+ }
252
+
253
+ # The stack's single cloud subnet, in the resolved network (all three modes).
254
+ # hcloud subnets have no standalone id — the resource id is "<network_id>-<range>".
255
+ resource "hcloud_network_subnet" "env" {
256
+ network_id = local.network_id
257
+ type = "cloud"
258
+ network_zone = local.network_zone
259
+ ip_range = local.compute_subnet_cidr
260
+ }
261
+
262
+ # --- Firewall: the SG analog (CIDR-scoped, label-attached) -----------------
263
+ # One `rule` per (security_rules entry, applicable source), direction=in. Hetzner
264
+ # has no SG-to-SG, so sources are CIDRs (see local.fw_rules). The firewall
265
+ # attaches to THIS stack's servers via the `veltrix-stack` label selector — the
266
+ # per-stack isolation boundary. A firewall with rules is default-DENY inbound;
267
+ # outbound is unrestricted by default (mirrors the AWS "all egress" rule), so no
268
+ # explicit egress rule is needed. The resource is always created so
269
+ # security_group_id is stable (matching the AWS always-present node SG).
270
+ resource "hcloud_firewall" "node" {
271
+ name = "${local.host_prefix}-fw"
272
+ labels = local.labels_firewall
273
+
274
+ dynamic "rule" {
275
+ for_each = local.fw_rules
276
+ content {
277
+ direction = "in"
278
+ protocol = rule.value.protocol
279
+ port = tostring(rule.value.port)
280
+ source_ips = [rule.value.cidr]
281
+ description = substr(rule.value.description, 0, 255)
282
+ }
283
+ }
284
+
285
+ apply_to {
286
+ label_selector = "veltrix-stack==${local.stack_label}"
287
+ }
288
+ }
289
+
290
+ # --- Compute: one hcloud_server per compute plan item ----------------------
291
+ # for_each keyed by plan_key => hcloud_server.node["data/indexer-1"] etc. The
292
+ # server keeps its default public interface (egress); its private IP comes from
293
+ # the hcloud_server_network attachment below. name = function short-label,
294
+ # prefixed with the stack for cross-stack uniqueness (a valid RFC1123 hostname).
295
+ resource "hcloud_server" "node" {
296
+ for_each = local.compute_nodes
297
+
298
+ name = "${local.host_prefix}-${local.node_short_labels[each.key]}"
299
+ image = local.resolved_image
300
+ server_type = coalesce(
301
+ lookup(var.server_types_by_kind, each.value.kind, null),
302
+ lookup(var.server_types, each.value.tier, null),
303
+ var.default_server_type,
304
+ )
305
+ location = local.location
306
+ ssh_keys = var.ssh_keys
307
+ labels = local.compute_labels[each.key]
308
+
309
+ public_net {
310
+ ipv4_enabled = true
311
+ ipv6_enabled = true
312
+ }
313
+ }
314
+
315
+ # Attach each server to the per-stack subnet, giving it a private IP.
316
+ resource "hcloud_server_network" "node" {
317
+ for_each = local.compute_nodes
318
+ server_id = hcloud_server.node[each.key].id
319
+ subnet_id = hcloud_network_subnet.env.id
320
+ }
321
+
322
+ # --- Storage: block volume (object storage is external — see gap notes) -----
323
+ # Hetzner Object Storage is S3-compatible but not in the hcloud provider, so a
324
+ # `storage` plan item provisions a block volume instead. The app's meaning of
325
+ # the storage is app-defined (InfraSpec.storage); the module just provisions it.
326
+ resource "hcloud_volume" "objstore" {
327
+ count = local.has_storage ? 1 : 0
328
+ name = "${local.host_prefix}-vol"
329
+ size = var.storage_volume_gb
330
+ location = local.location
331
+ format = "ext4"
332
+ labels = local.labels_storage
333
+ delete_protection = false
334
+ }
335
+
336
+ # --- TLS ------------------------------------------------------------------
337
+ # NO certificate resource is created here. A MANAGED hcloud certificate needs
338
+ # Hetzner DNS (out of the hcloud provider); an UPLOADED certificate is
339
+ # provisioned out-of-band. The LB references var.certificate_arn (an hcloud
340
+ # certificate id) on its HTTPS service when provided (see below).
341
+
342
+ # --- Load balancer: the app's web tier + HEC ingress ----------------------
343
+ # hcloud_load_balancer + a network attachment (private interface) + one service
344
+ # + per-server targets. NO WAF (Hetzner has none — waf_enabled is a no-op) and
345
+ # NO LB-level MFA (alb_auth is a no-op). Both vars are accepted for contract
346
+ # parity only; realizing either would need an external reverse-proxy/CDN tier.
347
+ resource "hcloud_load_balancer" "env" {
348
+ count = local.has_lb ? 1 : 0
349
+ name = "${local.host_prefix}-lb"
350
+ load_balancer_type = "lb11"
351
+ network_zone = local.network_zone
352
+ labels = local.labels_lb
353
+ }
354
+
355
+ # Attach the LB to the private network so it can reach nodes on their private IPs.
356
+ resource "hcloud_load_balancer_network" "env" {
357
+ count = local.has_lb ? 1 : 0
358
+ load_balancer_id = hcloud_load_balancer.env[0].id
359
+ network_id = local.network_id
360
+ depends_on = [hcloud_network_subnet.env]
361
+ }
362
+
363
+ # The web service: HTTPS (with the supplied cert + automatic HTTP->HTTPS
364
+ # redirect) when certificate_arn is set, else plain HTTP on the listener port.
365
+ # Port / health-check all come from the app's load_balancer spec. Gated on
366
+ # has_lb_spec (plan LB item + a spec), so var.load_balancer is non-null here.
367
+ resource "hcloud_load_balancer_service" "web" {
368
+ count = local.has_lb_spec ? 1 : 0
369
+ load_balancer_id = hcloud_load_balancer.env[0].id
370
+ protocol = local.lb_use_https ? "https" : "http"
371
+ listen_port = var.load_balancer.listener_port
372
+ destination_port = var.load_balancer.target_port
373
+
374
+ dynamic "http" {
375
+ for_each = local.lb_use_https ? [1] : []
376
+ content {
377
+ certificates = [tonumber(var.certificate_arn)]
378
+ redirect_http = true
379
+ }
380
+ }
381
+
382
+ health_check {
383
+ protocol = lower(var.load_balancer.health_check_protocol != "" ? var.load_balancer.health_check_protocol : var.load_balancer.target_protocol)
384
+ port = var.load_balancer.target_port
385
+ interval = 15
386
+ timeout = 10
387
+ retries = 3
388
+
389
+ http {
390
+ # Hetzner uses status-code patterns (e.g. "2??"), not the AWS matcher range
391
+ # "200-399"; "2??"/"3??" is the faithful equivalent of the default matcher.
392
+ path = var.load_balancer.health_check_path
393
+ status_codes = ["2??", "3??"]
394
+ }
395
+ }
396
+ }
397
+
398
+ # One target per web-serving node (the kinds the app named in the LB spec's
399
+ # target_kinds). Keyed by plan_key so the set tracks the compute for_each.
400
+ # use_private_ip requires the LB + servers to share the private network, hence
401
+ # the depends_on on both network attachments.
402
+ resource "hcloud_load_balancer_target" "web" {
403
+ for_each = local.has_lb_spec ? local.search_targets : {}
404
+ type = "server"
405
+ load_balancer_id = hcloud_load_balancer.env[0].id
406
+ server_id = hcloud_server.node[each.key].id
407
+ use_private_ip = true
408
+
409
+ depends_on = [
410
+ hcloud_load_balancer_network.env,
411
+ hcloud_server_network.node,
412
+ ]
413
+ }
414
+
415
+ # --- WAF: NO-OP (Hetzner has no WAF) --------------------------------------
416
+ # `var.waf_enabled` is accepted for cross-cloud contract parity but realizes
417
+ # NOTHING here — Hetzner Cloud has no WAF primitive. Web-application filtering
418
+ # would require an external reverse proxy / CDN (e.g. a self-managed nginx+
419
+ # modsecurity server or a third-party CDN WAF) in front of the LB, provisioned
420
+ # as a separate out-of-band tier. Documented, not faked.
421
+
422
+ # --- MFA (alb_auth): NO-OP (no LB-level OIDC on Hetzner) ------------------
423
+ # `var.alb_auth` is accepted for contract parity but realizes NOTHING — Hetzner
424
+ # LBs have no Cognito/OIDC equivalent. Enforce MFA at the app (its own SAML/OIDC)
425
+ # or via an external identity-aware proxy. Documented, not faked.
426
+
427
+ # --- DNS records: NONE created (see gap notes at top) ---------------------
428
+ # The hcloud provider has no managed-DNS resource. Per-node FQDNs are computed
429
+ # (local.node_fqdns) and emitted as an output for the out-of-band bring-up layer
430
+ # (a `timohirt/hetznerdns` stack, or /etc/hosts built from node_fqdns +
431
+ # instance_private_ips). dns_mode / create_private_zone / private_zone_id are
432
+ # kept as accepted no-op variables so the worker passes a uniform var set.
@@ -0,0 +1,72 @@
1
+ # =============================================================================
2
+ # Hetzner Cloud environment module — outputs.
3
+ #
4
+ # `resource_refs` is THE contract with the status-back path: a map of
5
+ # plan_key -> external cloud ref. The CI apply reads this (via `tofu output
6
+ # -json`) and/or the parsed apply stream and emits `resource.status
7
+ # {planKey, status, externalRef}` per key, which the app's onEvent hook maps
8
+ # onto the matching BYOL-resource row. Output NAMES are identical to the
9
+ # AWS module; hcloud ids are numeric-in-state so they are tostring()'d to keep
10
+ # the ref map a uniform map(string).
11
+ # =============================================================================
12
+
13
+ locals {
14
+ # Foundation-tier refs, included only when the tier is in the plan.
15
+ # * secrets / license-file: NO ref — Hetzner has no secret store (the values
16
+ # live in the platform vault; reported via deployment.step, not a resource).
17
+ # * dns: NO ref — Hetzner creates no DNS records (see gap notes in main.tf).
18
+ # * tls: the supplied hcloud certificate id (not created here) when present.
19
+ # * hec: maps to the LB's public IP when there is an LB (else a post-config
20
+ # step, exactly like the AWS module).
21
+ infra_refs = merge(
22
+ { "foundation/network" = tostring(local.network_id) },
23
+ local.has_storage ? { "foundation/storage" = tostring(hcloud_volume.objstore[0].id) } : {},
24
+ (local.has_tls && var.certificate_arn != "") ? { "foundation/tls" = var.certificate_arn } : {},
25
+ local.has_lb ? { "foundation/load-balancer" = tostring(hcloud_load_balancer.env[0].id) } : {},
26
+ local.has_hec && local.has_lb ? { "ingest/hec" = hcloud_load_balancer.env[0].ipv4 } : {},
27
+ )
28
+ }
29
+
30
+ output "resource_refs" {
31
+ description = "Map of plan_key -> external cloud reference (server id / volume id / cert id / LB id). Drives per-resource status-back."
32
+ value = merge(
33
+ { for k, s in hcloud_server.node : k => tostring(s.id) },
34
+ local.infra_refs,
35
+ )
36
+ }
37
+
38
+ output "subnet_id" {
39
+ description = "Per-stack cloud subnet identifier. hcloud subnets have no standalone id, so this is the composite resource id (\"<network_id>-<ip_range>\")."
40
+ value = hcloud_network_subnet.env.id
41
+ }
42
+
43
+ output "security_group_id" {
44
+ description = "Id of the compute-node firewall (the hcloud SG analog)."
45
+ value = tostring(hcloud_firewall.node.id)
46
+ }
47
+
48
+ output "network_id" {
49
+ description = "Id of the hcloud_network this stack runs in — the created network for dedicated, the looked-up network for shared/existing."
50
+ value = tostring(local.network_id)
51
+ }
52
+
53
+ output "instance_ids" {
54
+ description = "plan_key -> hcloud_server id for compute nodes."
55
+ value = { for k, s in hcloud_server.node : k => tostring(s.id) }
56
+ }
57
+
58
+ output "instance_private_ips" {
59
+ description = "plan_key -> private IP (from the per-stack subnet attachment) for compute nodes."
60
+ value = { for k, n in hcloud_server_network.node : k => n.ip }
61
+ }
62
+
63
+ output "node_fqdns" {
64
+ description = <<-EOT
65
+ plan_key -> function FQDN (e.g. idx1.<dns_domain>, sh1.<dns_domain>) for each
66
+ compute node. Derived identically to the AWS module. On Hetzner NO DNS record
67
+ is created for these (the hcloud provider has no managed DNS); the bring-up
68
+ layer consumes this map to build /etc/hosts or a hetznerdns stack out-of-band.
69
+ Populated whenever dns_domain is set; empty otherwise.
70
+ EOT
71
+ value = local.node_fqdns
72
+ }