variable "name" { description = "Host name — used for the security group name and the Name tag on the instance and EIP (e.g. \"carlos-flagship-ie\")." type = string } variable "sg_description" { description = "Security group description. Changing it forces SG replacement — keep it stable." type = string } variable "iam_name" { description = "Name for the IAM role and instance profile (e.g. \"carlos-flagship-host\"). The inline bucket policy is named \"-buckets\"." type = string } variable "instance_type" { description = "EC2 instance type (arm64 Graviton, e.g. \"t4g.small\")." type = string } variable "root_volume_size" { description = "Root gp3 volume size in GiB." type = number } variable "user_data" { 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." type = string } variable "deployment_bucket_arn" { description = "ARN of the deployment bucket this host may reach." type = string } variable "data_bucket_arn" { description = <<-EOT ARN of THIS region's data bucket — the only data bucket the host role may reach. Granting it is half the job. The box must also be TOLD the bucket, as CARLOS_HIBERNATE_BUCKET (plus CARLOS_HIBERNATE_REGION) in /etc/carlos/host.env, or its hibernation activator never arms and the box is an edge that cannot host: it will accept an instance, mint the route and stage the binary, and then nothing on it ever starts the process. This module does not write host.env — user_data is yours — so it is a step that has to be remembered, and the bring-up README's minimum env does not list it. The box publishes what it was told, as hibernation_configured in its control/edges/.json, and the console reads that: a region whose boxes report false is refused at instance create rather than accepted and stranded. The field reports CONFIGURATION only — a box naming a bucket its IAM role cannot reach still reports true and still fails every wake. EOT type = string } variable "ssm_parameter_arn" { description = "SSM parameter ARN pattern the host may read (e.g. \"arn:aws:ssm:eu-west-1::parameter/carlos/*\")." type = string } variable "vpc_id" { description = <<-EOT VPC to place this host in. Null (the default) keeps the module's original behaviour: the security group is built in the region's DEFAULT VPC and the instance lands in that VPC's default subnet. That default is not safe to rely on. A default VPC is created per region per account, and deleting them is ordinary CIS hygiene — an account that has done so has none, and the failure arrives MID-APPLY as `VPCIdNotSpecified: No default VPC for this user`, after the buckets, the IAM role and the instance profile have already been created (discussion #7: two of six regions, standing up a fleet in a day). The module now fails at PLAN time with a message naming this variable instead. Set it together with subnet_id; naming one without the other is refused, because a security group in your VPC and an instance in the default one is an apply that fails at the EC2 layer with a much worse message. EOT type = string default = null } variable "subnet_id" { 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." type = string default = null } variable "scope_ssm_parameter_reads" { description = <<-EOT Deny this role every SSM parameter read outside ssm_parameter_arn. The module attaches the AWS-managed AmazonSSMManagedInstanceCore policy, because Session Manager is the only admin path to these boxes. That policy grants `ssm:GetParameter*` on `Resource: "*"` — so without this deny, every host can read EVERY SSM parameter in the account, whatever ssm_parameter_arn says. On one deployment that included the root release-signing key, readable by an internet-facing edge box (discussion #7, verified with simulate-principal-policy). On (the default) adds an explicit Deny on the four parameter-read actions for everything the Allow above does not name. It does not touch any other action, so Session Manager keeps working exactly as before — the deny is scoped to parameter reads and nothing else. Set it to false only if this deployment's hosts genuinely read parameters outside ssm_parameter_arn's pattern. The better fix in that case is to widen the pattern, because a role that may read every parameter in the account is not the role this module's variables describe. EOT type = bool default = true } variable "extra_ingress" { 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." type = list(object({ description = string from_port = number to_port = number protocol = string cidr_blocks = list(string) })) default = [] } variable "ami_name_pattern" { description = "AMI name filter for the host image." type = string default = "debian-13-arm64-*" } variable "ami_owner" { description = "AMI owner account id." type = string default = "136693071363" # Debian } variable "route53_zone_ids" { description = "Hosted-zone IDs the host may write ACME DNS-01 challenge records into (wildcard certs, CARLOS_DNS_PROVIDER=route53). Empty = no Route53 access." type = list(string) default = [] validation { condition = alltrue([for z in var.route53_zone_ids : can(regex("^Z[A-Z0-9]+$", z))]) error_message = "route53_zone_ids entries must be bare hosted-zone ids (Z...), not /hostedzone/-prefixed paths or wildcards." } } variable "steering_zone_ids" { 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." type = list(string) default = [] validation { condition = alltrue([for z in var.steering_zone_ids : can(regex("^Z[A-Z0-9]+$", z))]) error_message = "steering_zone_ids entries must be bare hosted-zone ids (Z...), not /hostedzone/-prefixed paths or wildcards." } } variable "steering_record_names" { 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, \"*.\", which covers the steering hostname edge. 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." type = list(string) default = [] validation { condition = alltrue([for n in var.steering_record_names : can(regex("^[*a-z0-9][a-z0-9.*_-]*\\.[a-z]{2,}$", n))]) 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." } } variable "enable_auto_recovery" { description = "Arm an EC2 auto-recovery alarm on the host (HA ladder rung 0)." type = bool default = true } variable "enable_cost_explorer" { description = "Grant ce:GetCostAndUsage so a console on this box can pull the monthly bill for the economics dashboard" type = bool default = false } variable "alerts_email" { 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." type = string default = "" } variable "sibling_staging_arns" { 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." type = list(string) default = [] } variable "store_bucket_prefix" { 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." type = string default = "" } variable "store_user_path" { 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." type = string default = "/carlos-store/" } variable "store_scanning" { 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." type = bool default = false } variable "relay" { 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." type = bool default = false } variable "console_mail_from" { description = "The console's own From address (CARLOS_CONSOLE_MAIL_FROM, or noreply@console-mail.bab.). Empty: the host role gets no ses:SendEmail and the console sends no email of its own." type = string default = "" validation { condition = var.console_mail_from == "" || can(regex("^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$", var.console_mail_from)) 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.)." } } variable "console_mail_region" { description = "SES region the console sends from (CARLOS_MAIL_REGION). Required when console_mail_from is set." type = string default = "" }