carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1variable "name" {
2 description = "Host name — used for the security group name and the Name tag on the instance and EIP (e.g. \"carlos-flagship-ie\")."
3 type = string
4}
5
6variable "sg_description" {
7 description = "Security group description. Changing it forces SG replacement — keep it stable."
8 type = string
9}
10
11variable "iam_name" {
12 description = "Name for the IAM role and instance profile (e.g. \"carlos-flagship-host\"). The inline bucket policy is named \"<iam_name>-buckets\"."
13 type = string
14}
15
16variable "instance_type" {
17 description = "EC2 instance type (arm64 Graviton, e.g. \"t4g.small\")."
18 type = string
19}
20
21variable "root_volume_size" {
22 description = "Root gp3 volume size in GiB."
23 type = number
24}
25
26variable "user_data" {
27 description = "First-boot init script content (e.g. file(\"$${path.module}/host-init.sh\")). Changes never replace the instance — hosts are re-initialised via bootstrap, not recreation."
28 type = string
29}
30
31variable "deployment_bucket_arn" {
32 description = "ARN of the deployment bucket this host may reach."
33 type = string
34}
35
36variable "data_bucket_arn" {
37 description = <<-EOT
38 ARN of THIS region's data bucket — the only data bucket the host role may
39 reach.
40
41 Granting it is half the job. The box must also be TOLD the bucket, as
42 CARLOS_HIBERNATE_BUCKET (plus CARLOS_HIBERNATE_REGION) in
43 /etc/carlos/host.env, or its hibernation activator never arms and the box
44 is an edge that cannot host: it will accept an instance, mint the route
45 and stage the binary, and then nothing on it ever starts the process.
46
47 This module does not write host.env — user_data is yours — so it is a
48 step that has to be remembered, and the bring-up README's minimum env
49 does not list it.
50
51 The box publishes what it was told, as hibernation_configured in its
52 control/edges/<box>.json, and the console reads that: a region whose
53 boxes report false is refused at instance create rather than accepted and
54 stranded. The field reports CONFIGURATION only — a box naming a bucket
55 its IAM role cannot reach still reports true and still fails every wake.
56 EOT
57 type = string
58}
59
60variable "ssm_parameter_arn" {
61 description = "SSM parameter ARN pattern the host may read (e.g. \"arn:aws:ssm:eu-west-1:<account>:parameter/carlos/*\")."
62 type = string
63}
64
65variable "vpc_id" {
66 description = <<-EOT
67 VPC to place this host in. Null (the default) keeps the module's original
68 behaviour: the security group is built in the region's DEFAULT VPC and the
69 instance lands in that VPC's default subnet.
70
71 That default is not safe to rely on. A default VPC is created per region
72 per account, and deleting them is ordinary CIS hygiene — an account that
73 has done so has none, and the failure arrives MID-APPLY as
74 `VPCIdNotSpecified: No default VPC for this user`, after the buckets, the
75 IAM role and the instance profile have already been created (discussion
76 #7: two of six regions, standing up a fleet in a day). The module now
77 fails at PLAN time with a message naming this variable instead.
78
79 Set it together with subnet_id; naming one without the other is refused,
80 because a security group in your VPC and an instance in the default one
81 is an apply that fails at the EC2 layer with a much worse message.
82 EOT
83 type = string
84 default = null
85}
86
87variable "subnet_id" {
88 description = "Subnet to launch the host in. Required when vpc_id is set, and must belong to it. Null (the default) lets EC2 pick the default subnet of the default VPC, which is the module's original behaviour."
89 type = string
90 default = null
91}
92
93variable "scope_ssm_parameter_reads" {
94 description = <<-EOT
95 Deny this role every SSM parameter read outside ssm_parameter_arn.
96
97 The module attaches the AWS-managed AmazonSSMManagedInstanceCore policy,
98 because Session Manager is the only admin path to these boxes. That policy
99 grants `ssm:GetParameter*` on `Resource: "*"` — so without this deny,
100 every host can read EVERY SSM parameter in the account, whatever
101 ssm_parameter_arn says. On one deployment that included the root
102 release-signing key, readable by an internet-facing edge box (discussion
103 #7, verified with simulate-principal-policy).
104
105 On (the default) adds an explicit Deny on the four parameter-read actions
106 for everything the Allow above does not name. It does not touch any other
107 action, so Session Manager keeps working exactly as before — the deny is
108 scoped to parameter reads and nothing else.
109
110 Set it to false only if this deployment's hosts genuinely read parameters
111 outside ssm_parameter_arn's pattern. The better fix in that case is to
112 widen the pattern, because a role that may read every parameter in the
113 account is not the role this module's variables describe.
114 EOT
115 type = bool
116 default = true
117}
118
119variable "extra_ingress" {
120 description = "Additional ingress rules beyond the standard 80/443 (e.g. tcp/25 for a host running an inbound MX). The SG uses in-line rules, so extra rules MUST come through here — a standalone aws_vpc_security_group_ingress_rule in the root would fight the in-line set and be stripped on every apply."
121 type = list(object({
122 description = string
123 from_port = number
124 to_port = number
125 protocol = string
126 cidr_blocks = list(string)
127 }))
128 default = []
129}
130
131variable "ami_name_pattern" {
132 description = "AMI name filter for the host image."
133 type = string
134 default = "debian-13-arm64-*"
135}
136
137variable "ami_owner" {
138 description = "AMI owner account id."
139 type = string
140 default = "136693071363" # Debian
141}
142
143variable "route53_zone_ids" {
144 description = "Hosted-zone IDs the host may write ACME DNS-01 challenge records into (wildcard certs, CARLOS_DNS_PROVIDER=route53). Empty = no Route53 access."
145 type = list(string)
146 default = []
147 validation {
148 condition = alltrue([for z in var.route53_zone_ids : can(regex("^Z[A-Z0-9]+$", z))])
149 error_message = "route53_zone_ids entries must be bare hosted-zone ids (Z...), not /hostedzone/-prefixed paths or wildcards."
150 }
151}
152
153variable "steering_zone_ids" {
154 description = "Hosted-zone IDs the DNS-steering converger (internal/steering, a housekeeper duty) may write latency A records into, and whose health checks it may manage. Empty = no steering grant, which is every deployment that leaves CARLOS_STEERING_ZONE_ID unset. Normally ONE id: the zone holding the apps domain and any steered custom domains."
155 type = list(string)
156 default = []
157 validation {
158 condition = alltrue([for z in var.steering_zone_ids : can(regex("^Z[A-Z0-9]+$", z))])
159 error_message = "steering_zone_ids entries must be bare hosted-zone ids (Z...), not /hostedzone/-prefixed paths or wildcards."
160 }
161}
162
163variable "steering_record_names" {
164 description = "Optional NAME bound on the steering grant: DNS name patterns (IAM StringLike, matched against route53:ChangeResourceRecordSetsNormalizedRecordNames — lowercase, no trailing dot) this role may write. Normally exactly one entry, \"*.<apps-domain>\", which covers the steering hostname edge.<apps-domain> and every steered platform host under it; steered CUSTOM domains resolve through a CNAME in the CUSTOMER's own zone, which this role has no grant on at all. Empty (the default) means NO name bound: the role may then UPSERT or DELETE any A record in the named zones, the APEX A included — often the marketing site — with only the converger's own managed-set rules standing in the way. Set it."
165 type = list(string)
166 default = []
167 validation {
168 condition = alltrue([for n in var.steering_record_names : can(regex("^[*a-z0-9][a-z0-9.*_-]*\\.[a-z]{2,}$", n))])
169 error_message = "steering_record_names entries must be lowercase DNS name patterns with a dot (e.g. \"*.oncarlos.com\") — a bare \"*\" is not a bound and is refused."
170 }
171}
172
173variable "enable_auto_recovery" {
174 description = "Arm an EC2 auto-recovery alarm on the host (HA ladder rung 0)."
175 type = bool
176 default = true
177}
178
179variable "enable_cost_explorer" {
180 description = "Grant ce:GetCostAndUsage so a console on this box can pull the monthly bill for the economics dashboard"
181 type = bool
182 default = false
183}
184
185variable "alerts_email" {
186 description = "Email that receives this host's memory alarm (memory-pressure spec §2.4). A CloudWatch alarm can only notify an SNS topic in its own region, so the module creates a regional topic + subscription per host rather than taking a topic ARN. Empty: no topic, no alarm; the PutMetricData grant is unconditional so the agent's memwatch duty can publish either way."
187 type = string
188 default = ""
189}
190
191variable "sibling_staging_arns" {
192 description = "Sibling regions' data-bucket ARNs this host may STAGE residence-moves into (instance-move spec, issue #331). The grant is moves/*-scoped push-only: Put/Delete on the staging objects and a prefix-conditioned ListBucket — never instances/*, never GetObject, so residency-at-rest holds while a move's delta rounds run. Empty (the default): no cross-bucket statement at all."
193 type = list(string)
194 default = []
195}
196
197variable "store_bucket_prefix" {
198 description = "Bucket-name prefix this deployment mints object stores under (CARLOS_STORE_BUCKET_PREFIX, e.g. \"carlos-\"). Set it and the host role gains the object-storage provisioning grant: bucket lifecycle plus the IAM user lifecycle, both scoped to this prefix and to the store user path, and the module creates the permissions boundary those users are made under. Empty (the default): no store grant and no boundary, which is every deployment that has not switched object storage on."
199 type = string
200 default = ""
201}
202
203variable "store_user_path" {
204 description = "IAM path every store principal lives under (objstore.UserPath). It is what scopes the role's user-lifecycle grant to store users and nothing else, so it must match the platform constant exactly — change it here without changing it there and the console can create a user it then cannot give a key to."
205 type = string
206 default = "/carlos-store/"
207}
208
209variable "store_scanning" {
210 description = "Let this host's role READ and TAG objects under store_bucket_prefix, which is what the console's virus scanner needs (s3:GetObject, s3:GetObjectTagging, s3:PutObjectTagging). Off by default: without it the console provisions stores and never reads tenant data. Set it only on the box that runs the console, and only on a deployment that runs a scanner (infra/modules/scanner-host). Requires store_bucket_prefix."
211 type = bool
212 default = false
213}
214
215variable "relay" {
216 description = "Open the platform STUN/TURN relay's ports (udp+tcp 3478, udp 49152-65535). Off by default; a box also needs /etc/carlos/relay-masters before the unit starts."
217 type = bool
218 default = false
219}
220
221variable "console_mail_from" {
222 description = "The console's own From address (CARLOS_CONSOLE_MAIL_FROM, or noreply@console-mail.bab.<platform suffix>). Empty: the host role gets no ses:SendEmail and the console sends no email of its own."
223 type = string
224 default = ""
225 validation {
226 condition = var.console_mail_from == "" || can(regex("^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$", var.console_mail_from))
227 error_message = "console_mail_from must be one lowercase email address, the exact From the console sends (CARLOS_CONSOLE_MAIL_FROM, or noreply@console-mail.bab.<platform suffix>)."
228 }
229}
230
231variable "console_mail_region" {
232 description = "SES region the console sends from (CARLOS_MAIL_REGION). Required when console_mail_from is set."
233 type = string
234 default = ""
235}
236