@veltrixsecops/app-sdk 3.2.0 → 3.5.1
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.
- package/README.md +68 -1
- package/dist/byol/index.cjs +3802 -0
- package/dist/byol/index.cjs.map +1 -0
- package/dist/byol/index.d.cts +578 -0
- package/dist/byol/index.d.ts +578 -0
- package/dist/byol/index.js +2587 -0
- package/dist/byol/index.js.map +1 -0
- package/dist/chunk-3WK4EVQ3.js +842 -0
- package/dist/chunk-3WK4EVQ3.js.map +1 -0
- package/dist/{chunk-EOOEHZGC.js → chunk-HIL6RIDG.js} +107 -3
- package/dist/chunk-HIL6RIDG.js.map +1 -0
- package/dist/{chunk-VWFTOFTI.js → chunk-Y6XBNZGR.js} +8 -2
- package/dist/chunk-Y6XBNZGR.js.map +1 -0
- package/dist/client/index.cjs +112 -2
- package/dist/client/index.cjs.map +1 -1
- package/dist/client/index.d.cts +118 -6
- package/dist/client/index.d.ts +118 -6
- package/dist/client/index.js +13 -1
- package/dist/connections/index.cjs +1787 -0
- package/dist/connections/index.cjs.map +1 -0
- package/dist/connections/index.d.cts +78 -0
- package/dist/connections/index.d.ts +78 -0
- package/dist/connections/index.js +754 -0
- package/dist/connections/index.js.map +1 -0
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.d.cts +2 -2
- package/dist/hooks/index.d.ts +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/index.cjs +7 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/opentofu/index.cjs +138 -0
- package/dist/opentofu/index.cjs.map +1 -0
- package/dist/opentofu/index.d.cts +131 -0
- package/dist/opentofu/index.d.ts +131 -0
- package/dist/opentofu/index.js +109 -0
- package/dist/opentofu/index.js.map +1 -0
- package/dist/pipeline/index.cjs +7 -0
- package/dist/pipeline/index.cjs.map +1 -1
- package/dist/pipeline/index.d.cts +9 -2
- package/dist/pipeline/index.d.ts +9 -2
- package/dist/pipeline/index.js +3 -1
- package/dist/pipeline-beeT8dje.d.cts +408 -0
- package/dist/pipeline-beeT8dje.d.ts +408 -0
- package/dist/ui/index.cjs +136 -0
- package/dist/ui/index.cjs.map +1 -1
- package/dist/ui/index.d.cts +109 -1
- package/dist/ui/index.d.ts +109 -1
- package/dist/ui/index.js +35 -677
- package/dist/ui/index.js.map +1 -1
- package/dist/{use-app-context-OQlcmq7t.d.cts → use-app-context-Byv_fam0.d.cts} +206 -4
- package/dist/{use-app-context-OQlcmq7t.d.ts → use-app-context-Byv_fam0.d.ts} +206 -4
- package/opentofu/README.md +114 -0
- package/opentofu/modules/aws/main.tf +1008 -0
- package/opentofu/modules/aws/outputs.tf +90 -0
- package/opentofu/modules/aws/variables.tf +437 -0
- package/opentofu/modules/aws/versions.tf +23 -0
- package/opentofu/modules/azure/main.tf +920 -0
- package/opentofu/modules/azure/outputs.tf +72 -0
- package/opentofu/modules/azure/variables.tf +498 -0
- package/opentofu/modules/azure/versions.tf +27 -0
- package/opentofu/modules/gcp/main.tf +645 -0
- package/opentofu/modules/gcp/outputs.tf +73 -0
- package/opentofu/modules/gcp/variables.tf +428 -0
- package/opentofu/modules/gcp/versions.tf +27 -0
- package/opentofu/modules/hetzner/main.tf +432 -0
- package/opentofu/modules/hetzner/outputs.tf +72 -0
- package/opentofu/modules/hetzner/variables.tf +423 -0
- package/opentofu/modules/hetzner/versions.tf +26 -0
- package/package.json +33 -1
- package/dist/chunk-EOOEHZGC.js.map +0 -1
- package/dist/chunk-VWFTOFTI.js.map +0 -1
- package/dist/pipeline-A-gSsPRR.d.cts +0 -189
- 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
|
+
}
|