Platform marketing site live; infra refactored into reusable modules
site/platform: first-draft marketing page, shipped as app platform-site via canary/site, serving at platform.carlosframework.com. infra/modules: deployment-buckets + region-host extracted with moved blocks; both stacks plan zero-change. svc-pdf stays tito-local by design. Kass replication to the flagship bucket verified (restore round-trip green) — old Kass account safe to stand down.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
22 files changed,
+964
−280
infra/flagship/dns.tf+2 −2infra/flagship/main.tf+39 −38infra/flagship/outputs.tf+4 −4infra/flagship/plan.outbinaryinfra/flagship/region-ie.tf+37 −96infra/modules/README.md+140 −0infra/modules/deployment-buckets/main.tf+61 −0infra/modules/deployment-buckets/outputs.tf+19 −0infra/modules/deployment-buckets/variables.tf+9 −0infra/modules/deployment-buckets/versions.tf+9 −0infra/modules/region-host/main.tf+121 −0infra/modules/region-host/outputs.tf+29 −0infra/modules/region-host/variables.tf+56 −0infra/modules/region-host/versions.tf+9 −0infra/scripts/promote-platform-site.sh+10 −0infra/scripts/promote-titogo-canary.sh+9 −0infra/tito/dns.tf+1 −1infra/tito/main.tf+40 −38infra/tito/outputs.tf+3 −3infra/tito/region-ie.tf+40 −96infra/tito/svc-pdf.tf+2 −2site/platform/index.html+324 −0
diff --git a/infra/flagship/dns.tf b/infra/flagship/dns.tf| index 2baf40db9..9a4f427b9 100644 |
| --- a/infra/flagship/dns.tf |
| +++ b/infra/flagship/dns.tf |
| @@ -18,7 +18,7 @@ resource "dnsimple_zone_record" "oncarlos_wildcard" { |
| zone_name = "oncarlos.com" |
| name = "*" |
| type = "A" |
| - value = aws_eip.host_ie.public_ip |
| + value = module.host_ie.eip_public_ip |
| ttl = 300 |
| } |
| @@ -26,6 +26,6 @@ resource "dnsimple_zone_record" "oncarlos_apex" { |
| zone_name = "oncarlos.com" |
| name = "" |
| type = "A" |
| - value = aws_eip.host_ie.public_ip |
| + value = module.host_ie.eip_public_ip |
| ttl = 300 |
| } |
diff --git a/infra/flagship/main.tf b/infra/flagship/main.tf| index 47c21424e..5081f33ae 100644 |
| --- a/infra/flagship/main.tf |
| +++ b/infra/flagship/main.tf |
| @@ -3,6 +3,10 @@ |
| # DNS; the platform owns apps, releases, instances, and dynamic DNS. |
| # Nothing account-shaped (tenant hostnames, routes) may ever enter this |
| # state — `tofu apply` must always be safe. |
| +# |
| +# The reusable shapes live in ../modules (self-hosters consume them by |
| +# git source URL; see infra/modules/README.md). This root is the thin |
| +# flagship-specific instantiation: names, sizes, DNS, mail. |
| terraform { |
| required_version = ">= 1.10" |
| @@ -34,56 +38,53 @@ locals { |
| account = "271376211898" |
| } |
| -# The deployment bucket: apps, releases, channels, instance records, host |
| -# config. Routing metadata only — nothing customer-shaped rests here. |
| -resource "aws_s3_bucket" "deployment" { |
| - bucket = "carlos-flagship-${local.account}" |
| - lifecycle { prevent_destroy = true } |
| +# The deployment bucket + the IE regional data bucket (the residency |
| +# promise). Versioning, SSE, public-access block, prevent_destroy — all |
| +# inside the module. |
| +module "buckets" { |
| + source = "../modules/deployment-buckets" |
| + deployment_bucket_name = "carlos-flagship-${local.account}" |
| + data_bucket_name = "carlos-data-ie-${local.account}" |
| } |
| -# The IE regional data bucket: parked databases, WAL streams, leases, |
| -# blobs. This bucket is the residency promise. |
| -resource "aws_s3_bucket" "data_ie" { |
| - bucket = "carlos-data-ie-${local.account}" |
| - lifecycle { prevent_destroy = true } |
| +# State surgery from the pre-module layout (refactor 2026-08-01) — pure |
| +# bookkeeping, no real resource is touched. |
| +moved { |
| + from = aws_s3_bucket.deployment |
| + to = module.buckets.aws_s3_bucket.deployment |
| } |
| -resource "aws_s3_bucket_versioning" "deployment" { |
| - bucket = aws_s3_bucket.deployment.id |
| - versioning_configuration { status = "Enabled" } |
| +moved { |
| + from = aws_s3_bucket.data_ie |
| + to = module.buckets.aws_s3_bucket.data |
| } |
| -resource "aws_s3_bucket_versioning" "data_ie" { |
| - bucket = aws_s3_bucket.data_ie.id |
| - versioning_configuration { status = "Enabled" } |
| +moved { |
| + from = aws_s3_bucket_versioning.deployment |
| + to = module.buckets.aws_s3_bucket_versioning.deployment |
| } |
| -resource "aws_s3_bucket_public_access_block" "deployment" { |
| - bucket = aws_s3_bucket.deployment.id |
| - block_public_acls = true |
| - ignore_public_acls = true |
| - block_public_policy = true |
| - restrict_public_buckets = true |
| +moved { |
| + from = aws_s3_bucket_versioning.data_ie |
| + to = module.buckets.aws_s3_bucket_versioning.data |
| } |
| -resource "aws_s3_bucket_public_access_block" "data_ie" { |
| - bucket = aws_s3_bucket.data_ie.id |
| - block_public_acls = true |
| - ignore_public_acls = true |
| - block_public_policy = true |
| - restrict_public_buckets = true |
| +moved { |
| + from = aws_s3_bucket_public_access_block.deployment |
| + to = module.buckets.aws_s3_bucket_public_access_block.deployment |
| } |
| -resource "aws_s3_bucket_server_side_encryption_configuration" "deployment" { |
| - bucket = aws_s3_bucket.deployment.id |
| - rule { |
| - apply_server_side_encryption_by_default { sse_algorithm = "AES256" } |
| - } |
| +moved { |
| + from = aws_s3_bucket_public_access_block.data_ie |
| + to = module.buckets.aws_s3_bucket_public_access_block.data |
| } |
| -resource "aws_s3_bucket_server_side_encryption_configuration" "data_ie" { |
| - bucket = aws_s3_bucket.data_ie.id |
| - rule { |
| - apply_server_side_encryption_by_default { sse_algorithm = "AES256" } |
| - } |
| +moved { |
| + from = aws_s3_bucket_server_side_encryption_configuration.deployment |
| + to = module.buckets.aws_s3_bucket_server_side_encryption_configuration.deployment |
| +} |
| + |
| +moved { |
| + from = aws_s3_bucket_server_side_encryption_configuration.data_ie |
| + to = module.buckets.aws_s3_bucket_server_side_encryption_configuration.data |
| } |
diff --git a/infra/flagship/outputs.tf b/infra/flagship/outputs.tf| index 8aca3bdb6..c58058ade 100644 |
| --- a/infra/flagship/outputs.tf |
| +++ b/infra/flagship/outputs.tf |
| @@ -1,15 +1,15 @@ |
| output "ie_eip" { |
| - value = aws_eip.host_ie.public_ip |
| + value = module.host_ie.eip_public_ip |
| } |
| output "deployment_bucket" { |
| - value = aws_s3_bucket.deployment.bucket |
| + value = module.buckets.deployment_bucket |
| } |
| output "data_bucket_ie" { |
| - value = aws_s3_bucket.data_ie.bucket |
| + value = module.buckets.data_bucket |
| } |
| output "host_role_arn" { |
| - value = aws_iam_role.host.arn |
| + value = module.host_ie.role_arn |
| } |
diff --git a/infra/flagship/plan.out b/infra/flagship/plan.out| index c9e4c57b7..8d821cc29 100644 |
| Binary files a/infra/flagship/plan.out and b/infra/flagship/plan.out differ |
diff --git a/infra/flagship/region-ie.tf b/infra/flagship/region-ie.tf| index 3b8a43d1c..07f6dbd7b 100644 |
| --- a/infra/flagship/region-ie.tf |
| +++ b/infra/flagship/region-ie.tf |
| @@ -1,115 +1,56 @@ |
| # The IE region of the flagship: one Graviton box running `carlos agent`, |
| # behind an Elastic IP. The box is disposable by construction — all |
| # durable state is in the buckets — so there is no backup story for hosts |
| -# at all, only for buckets. |
| +# at all, only for buckets. Residency is enforced in the module: the host |
| +# role reaches the deployment bucket plus ITS OWN region's data bucket |
| +# and nothing else. |
| -data "aws_ami" "debian_arm64" { |
| - most_recent = true |
| - owners = ["136693071363"] # Debian |
| - filter { |
| - name = "name" |
| - values = ["debian-13-arm64-*"] |
| - } |
| - filter { |
| - name = "architecture" |
| - values = ["arm64"] |
| - } |
| +module "host_ie" { |
| + source = "../modules/region-host" |
| + name = "carlos-flagship-ie" |
| + sg_description = "carlos flagship IE host" |
| + iam_name = "carlos-flagship-host" |
| + instance_type = "t4g.small" |
| + root_volume_size = 20 |
| + user_data = file("${path.module}/host-init.sh") |
| + deployment_bucket_arn = module.buckets.deployment_bucket_arn |
| + data_bucket_arn = module.buckets.data_bucket_arn |
| + ssm_parameter_arn = "arn:aws:ssm:eu-west-1:${local.account}:parameter/carlos/*" |
| } |
| -resource "aws_security_group" "host_ie" { |
| - name = "carlos-flagship-ie" |
| - description = "carlos flagship IE host" |
| - ingress { |
| - from_port = 80 |
| - to_port = 80 |
| - protocol = "tcp" |
| - cidr_blocks = ["0.0.0.0/0"] |
| - } |
| - ingress { |
| - from_port = 443 |
| - to_port = 443 |
| - protocol = "tcp" |
| - cidr_blocks = ["0.0.0.0/0"] |
| - } |
| - # No SSH ingress: admin access is SSM Session Manager only, and the |
| - # carlos binary arrives from the deployment bucket, not over scp. |
| - egress { |
| - from_port = 0 |
| - to_port = 0 |
| - protocol = "-1" |
| - cidr_blocks = ["0.0.0.0/0"] |
| - } |
| +# State surgery from the pre-module layout (refactor 2026-08-01) — pure |
| +# bookkeeping, no real resource is touched. |
| +moved { |
| + from = aws_security_group.host_ie |
| + to = module.host_ie.aws_security_group.host |
| } |
| -resource "aws_iam_role" "host" { |
| - name = "carlos-flagship-host" |
| - assume_role_policy = jsonencode({ |
| - Version = "2012-10-17" |
| - Statement = [{ |
| - Effect = "Allow" |
| - Principal = { Service = "ec2.amazonaws.com" } |
| - Action = "sts:AssumeRole" |
| - }] |
| - }) |
| +moved { |
| + from = aws_iam_role.host |
| + to = module.host_ie.aws_iam_role.host |
| } |
| -# Residency enforced by construction: the host role reaches the deployment |
| -# bucket plus ITS OWN region's data bucket and nothing else. |
| -resource "aws_iam_role_policy" "host_buckets" { |
| - name = "carlos-flagship-host-buckets" |
| - role = aws_iam_role.host.id |
| - policy = jsonencode({ |
| - Version = "2012-10-17" |
| - Statement = [ |
| - { |
| - Effect = "Allow" |
| - Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] |
| - Resource = [ |
| - "${aws_s3_bucket.deployment.arn}/*", |
| - "${aws_s3_bucket.data_ie.arn}/*", |
| - ] |
| - }, |
| - { |
| - Effect = "Allow" |
| - Action = ["s3:ListBucket", "s3:GetBucketLocation"] |
| - Resource = [aws_s3_bucket.deployment.arn, aws_s3_bucket.data_ie.arn] |
| - }, |
| - { |
| - Effect = "Allow" |
| - Action = ["ssm:GetParameter"] |
| - Resource = "arn:aws:ssm:eu-west-1:${local.account}:parameter/carlos/*" |
| - }, |
| - ] |
| - }) |
| +moved { |
| + from = aws_iam_role_policy.host_buckets |
| + to = module.host_ie.aws_iam_role_policy.host_buckets |
| } |
| -resource "aws_iam_role_policy_attachment" "host_ssm" { |
| - role = aws_iam_role.host.name |
| - policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" |
| +moved { |
| + from = aws_iam_role_policy_attachment.host_ssm |
| + to = module.host_ie.aws_iam_role_policy_attachment.host_ssm |
| } |
| -resource "aws_iam_instance_profile" "host" { |
| - name = "carlos-flagship-host" |
| - role = aws_iam_role.host.name |
| +moved { |
| + from = aws_iam_instance_profile.host |
| + to = module.host_ie.aws_iam_instance_profile.host |
| } |
| -resource "aws_instance" "host_ie" { |
| - ami = data.aws_ami.debian_arm64.id |
| - instance_type = "t4g.small" |
| - vpc_security_group_ids = [aws_security_group.host_ie.id] |
| - iam_instance_profile = aws_iam_instance_profile.host.name |
| - user_data = file("${path.module}/host-init.sh") |
| - user_data_replace_on_change = false |
| - root_block_device { |
| - volume_size = 20 |
| - volume_type = "gp3" |
| - encrypted = true |
| - } |
| - tags = { Name = "carlos-flagship-ie" } |
| +moved { |
| + from = aws_instance.host_ie |
| + to = module.host_ie.aws_instance.host |
| } |
| -resource "aws_eip" "host_ie" { |
| - domain = "vpc" |
| - instance = aws_instance.host_ie.id |
| - tags = { Name = "carlos-flagship-ie" } |
| +moved { |
| + from = aws_eip.host_ie |
| + to = module.host_ie.aws_eip.host |
| } |
diff --git a/infra/modules/README.md b/infra/modules/README.md| new file mode 100644 |
| index 000000000..245ee36a9 |
| --- /dev/null |
| +++ b/infra/modules/README.md |
| @@ -0,0 +1,140 @@ |
| +# 🤖 CARLOS infra modules |
| + |
| +> 🤖 Written by an LLM (Claude), per factor X. |
| + |
| +Reusable OpenTofu modules for a CARLOS deployment. Our own stacks |
| +(`infra/flagship/`, `infra/tito/`) consume them by relative path |
| +(`source = "../modules/region-host"`); a self-hoster — or Tito, from its |
| +own repo — consumes the exact same modules by git source URL: |
| + |
| +```hcl |
| +source = "git::https://github.com/carlosframework/platform.git//infra/modules/region-host?ref=main" |
| +``` |
| + |
| +The division of labour is unchanged by modularisation: **tofu owns boxes, |
| +buckets, IAM, and static DNS; the platform owns apps, releases, |
| +instances, and dynamic DNS.** Nothing account-shaped (tenant hostnames, |
| +routes) may ever enter tofu state — `tofu apply` must always be safe. |
| +Providers, backends, and DNS records stay in your root module: they are |
| +deployment-specific, the modules are not. |
| + |
| +## deployment-buckets |
| + |
| +The durable pair: the **deployment bucket** (apps, releases, channels, |
| +instance records, host config — routing metadata only, nothing |
| +customer-shaped) and one **regional data bucket** (parked databases, WAL |
| +streams, leases, blobs — the residency promise). Both get versioning, |
| +AES256 SSE, a full public-access block, and `prevent_destroy`. Boxes are |
| +disposable; these buckets are not. |
| + |
| +| Variable | Description | |
| +|---|---| |
| +| `deployment_bucket_name` | Name of the deployment bucket. | |
| +| `data_bucket_name` | Name of the regional data bucket. | |
| + |
| +Outputs: `deployment_bucket`, `deployment_bucket_arn`, `data_bucket`, |
| +`data_bucket_arn`. |
| + |
| +Adding a second region later means a second `deployment-buckets`-shaped |
| +data bucket; for now each deployment is one region, so the module is a |
| +pair. |
| + |
| +## region-host |
| + |
| +One region of a deployment: a Debian arm64 (Graviton) box running |
| +`carlos agent`, behind an Elastic IP. Provides the AMI lookup, a security |
| +group admitting 80/443 only (**no SSH** — admin access is SSM Session |
| +Manager, and the carlos binary arrives from the deployment bucket, not |
| +over scp), an IAM role scoped to exactly the deployment bucket + this |
| +region's data bucket + the deployment's SSM parameter prefix, the |
| +instance profile with `AmazonSSMManagedInstanceCore` attached, the |
| +instance (encrypted gp3 root), and the EIP. |
| + |
| +Residency is enforced by construction: a host in another region gets a |
| +different module instance whose role does not include this region's data |
| +bucket, so a wrong-region wake fails loudly at the IAM layer. |
| + |
| +| Variable | Description | |
| +|---|---| |
| +| `name` | Security group name + `Name` tag (e.g. `carlos-flagship-ie`). | |
| +| `sg_description` | SG description (changing it forces SG replacement — keep it stable). | |
| +| `iam_name` | IAM role + instance profile name; inline policy is `<iam_name>-buckets`. | |
| +| `instance_type` | arm64 instance type, e.g. `t4g.small`. | |
| +| `root_volume_size` | Root gp3 volume size, GiB. | |
| +| `user_data` | First-boot init script *content* (pass `file("${path.module}/host-init.sh")`). Changes never replace the instance. | |
| +| `deployment_bucket_arn` | ARN of the deployment bucket. | |
| +| `data_bucket_arn` | ARN of THIS region's data bucket. | |
| +| `ssm_parameter_arn` | SSM parameter ARN pattern the host may read, e.g. `arn:aws:ssm:<region>:<account>:parameter/carlos/*`. | |
| +| `ami_name_pattern` | AMI name filter (default `debian-13-arm64-*`). | |
| +| `ami_owner` | AMI owner account (default `136693071363`, Debian). | |
| + |
| +Outputs: `eip_public_ip` (point your DNS here), `instance_id`, |
| +`security_group_id` (for sibling services that admit fleet traffic |
| +only), `role_arn`, `role_name`, `ami_id`. |
| + |
| +Not a module: Tito's `svc/pdf` box stays in `infra/tito/svc-pdf.tf` — it |
| +is deliberately deployment-local (a stateless Gotenberg whose SG admits |
| +only the fleet, with *no* bucket or parameter grants; statelessness is |
| +imposed, not promised). Generalise it only when a second deployment |
| +wants one. |
| + |
| +## Self-hosting from your own repo |
| + |
| +A minimal root module — backend + providers + the two module blocks + a |
| +DNS record. State backend, AWS profile, names, and DNS are yours; the |
| +shapes are these modules. |
| + |
| +```hcl |
| +terraform { |
| + required_version = ">= 1.10" |
| + backend "s3" { |
| + bucket = "my-tfstate-<account-id>" |
| + key = "carlos/terraform.tfstate" |
| + region = "eu-west-1" |
| + use_lockfile = true |
| + } |
| + required_providers { |
| + aws = { |
| + source = "hashicorp/aws" |
| + version = "~> 6.0" |
| + } |
| + } |
| +} |
| + |
| +provider "aws" { |
| + region = "eu-west-1" |
| +} |
| + |
| +locals { account = "123456789012" } |
| + |
| +module "buckets" { |
| + source = "git::https://github.com/carlosframework/platform.git//infra/modules/deployment-buckets?ref=main" |
| + deployment_bucket_name = "carlos-myco-platform-${local.account}" |
| + data_bucket_name = "carlos-myco-ie-${local.account}" |
| +} |
| + |
| +module "host_ie" { |
| + source = "git::https://github.com/carlosframework/platform.git//infra/modules/region-host?ref=main" |
| + name = "carlos-myco-ie" |
| + sg_description = "carlos myco IE host" |
| + iam_name = "carlos-myco-host-ie" |
| + instance_type = "t4g.small" |
| + root_volume_size = 20 |
| + user_data = file("${path.module}/host-init.sh") |
| + deployment_bucket_arn = module.buckets.deployment_bucket_arn |
| + data_bucket_arn = module.buckets.data_bucket_arn |
| + ssm_parameter_arn = "arn:aws:ssm:eu-west-1:${local.account}:parameter/carlos/*" |
| +} |
| + |
| +resource "aws_route53_record" "ie" { |
| + zone_id = data.aws_route53_zone.mine.zone_id |
| + name = "ie.carlos.example.com" |
| + type = "A" |
| + ttl = 300 |
| + records = [module.host_ie.eip_public_ip] |
| +} |
| +``` |
| + |
| +Copy a `host-init.sh` from `infra/flagship/` or `infra/tito/` as your |
| +starting point, then `tofu init && tofu plan`. Pin `?ref=` to a tag or |
| +commit rather than `main` once you're live. |
diff --git a/infra/modules/deployment-buckets/main.tf b/infra/modules/deployment-buckets/main.tf| new file mode 100644 |
| index 000000000..b6fcbb7b9 |
| --- /dev/null |
| +++ b/infra/modules/deployment-buckets/main.tf |
| @@ -0,0 +1,61 @@ |
| +# deployment-buckets: the durable heart of a CARLOS deployment — the |
| +# deployment bucket (apps, releases, channels, instance records, host |
| +# config; routing metadata only, nothing customer-shaped) paired with one |
| +# regional data bucket (parked databases, WAL streams, leases, blobs — |
| +# the residency promise). Boxes are disposable; these buckets are not, |
| +# hence prevent_destroy, versioning, SSE, and a full public-access block |
| +# on both. |
| + |
| +# The deployment bucket: apps, releases, channels, instance records, host |
| +# config. Routing metadata only — nothing customer-shaped rests here. |
| +resource "aws_s3_bucket" "deployment" { |
| + bucket = var.deployment_bucket_name |
| + lifecycle { prevent_destroy = true } |
| +} |
| + |
| +# The regional data bucket: parked databases, WAL streams, leases, blobs. |
| +# This bucket is the residency promise. |
| +resource "aws_s3_bucket" "data" { |
| + bucket = var.data_bucket_name |
| + lifecycle { prevent_destroy = true } |
| +} |
| + |
| +resource "aws_s3_bucket_versioning" "deployment" { |
| + bucket = aws_s3_bucket.deployment.id |
| + versioning_configuration { status = "Enabled" } |
| +} |
| + |
| +resource "aws_s3_bucket_versioning" "data" { |
| + bucket = aws_s3_bucket.data.id |
| + versioning_configuration { status = "Enabled" } |
| +} |
| + |
| +resource "aws_s3_bucket_public_access_block" "deployment" { |
| + bucket = aws_s3_bucket.deployment.id |
| + block_public_acls = true |
| + ignore_public_acls = true |
| + block_public_policy = true |
| + restrict_public_buckets = true |
| +} |
| + |
| +resource "aws_s3_bucket_public_access_block" "data" { |
| + bucket = aws_s3_bucket.data.id |
| + block_public_acls = true |
| + ignore_public_acls = true |
| + block_public_policy = true |
| + restrict_public_buckets = true |
| +} |
| + |
| +resource "aws_s3_bucket_server_side_encryption_configuration" "deployment" { |
| + bucket = aws_s3_bucket.deployment.id |
| + rule { |
| + apply_server_side_encryption_by_default { sse_algorithm = "AES256" } |
| + } |
| +} |
| + |
| +resource "aws_s3_bucket_server_side_encryption_configuration" "data" { |
| + bucket = aws_s3_bucket.data.id |
| + rule { |
| + apply_server_side_encryption_by_default { sse_algorithm = "AES256" } |
| + } |
| +} |
diff --git a/infra/modules/deployment-buckets/outputs.tf b/infra/modules/deployment-buckets/outputs.tf| new file mode 100644 |
| index 000000000..4542387eb |
| --- /dev/null |
| +++ b/infra/modules/deployment-buckets/outputs.tf |
| @@ -0,0 +1,19 @@ |
| +output "deployment_bucket" { |
| + description = "Name of the deployment bucket." |
| + value = aws_s3_bucket.deployment.bucket |
| +} |
| + |
| +output "deployment_bucket_arn" { |
| + description = "ARN of the deployment bucket." |
| + value = aws_s3_bucket.deployment.arn |
| +} |
| + |
| +output "data_bucket" { |
| + description = "Name of the regional data bucket." |
| + value = aws_s3_bucket.data.bucket |
| +} |
| + |
| +output "data_bucket_arn" { |
| + description = "ARN of the regional data bucket." |
| + value = aws_s3_bucket.data.arn |
| +} |
diff --git a/infra/modules/deployment-buckets/variables.tf b/infra/modules/deployment-buckets/variables.tf| new file mode 100644 |
| index 000000000..eba25373b |
| --- /dev/null |
| +++ b/infra/modules/deployment-buckets/variables.tf |
| @@ -0,0 +1,9 @@ |
| +variable "deployment_bucket_name" { |
| + description = "Name of the deployment bucket (apps, releases, channels, instance records, host config)." |
| + type = string |
| +} |
| + |
| +variable "data_bucket_name" { |
| + description = "Name of the regional data bucket (parked databases, WAL streams, leases, blobs)." |
| + type = string |
| +} |
diff --git a/infra/modules/deployment-buckets/versions.tf b/infra/modules/deployment-buckets/versions.tf| new file mode 100644 |
| index 000000000..c028066c2 |
| --- /dev/null |
| +++ b/infra/modules/deployment-buckets/versions.tf |
| @@ -0,0 +1,9 @@ |
| +terraform { |
| + required_version = ">= 1.10" |
| + required_providers { |
| + aws = { |
| + source = "hashicorp/aws" |
| + version = ">= 6.0" |
| + } |
| + } |
| +} |
diff --git a/infra/modules/region-host/main.tf b/infra/modules/region-host/main.tf| new file mode 100644 |
| index 000000000..a56f32447 |
| --- /dev/null |
| +++ b/infra/modules/region-host/main.tf |
| @@ -0,0 +1,121 @@ |
| +# region-host: one region of a CARLOS deployment — a single Graviton box |
| +# running `carlos agent`, behind an Elastic IP. The box is disposable by |
| +# construction: all durable state is in the buckets, so there is no backup |
| +# story for hosts at all, only for buckets. |
| +# |
| +# Residency is enforced by construction: the host role reaches the |
| +# deployment bucket plus ITS OWN region's data bucket and nothing else. A |
| +# host in another region gets a different module instance whose role does |
| +# not include this region's bucket — a wrong-region wake fails loudly at |
| +# the IAM layer. |
| + |
| +data "aws_ami" "debian_arm64" { |
| + most_recent = true |
| + owners = [var.ami_owner] |
| + filter { |
| + name = "name" |
| + values = [var.ami_name_pattern] |
| + } |
| + filter { |
| + name = "architecture" |
| + values = ["arm64"] |
| + } |
| +} |
| + |
| +resource "aws_security_group" "host" { |
| + name = var.name |
| + description = var.sg_description |
| + ingress { |
| + from_port = 80 |
| + to_port = 80 |
| + protocol = "tcp" |
| + cidr_blocks = ["0.0.0.0/0"] |
| + } |
| + ingress { |
| + from_port = 443 |
| + to_port = 443 |
| + protocol = "tcp" |
| + cidr_blocks = ["0.0.0.0/0"] |
| + } |
| + # No SSH ingress: admin access is SSM Session Manager only, and the |
| + # carlos binary arrives from the deployment bucket, not over scp. |
| + egress { |
| + from_port = 0 |
| + to_port = 0 |
| + protocol = "-1" |
| + cidr_blocks = ["0.0.0.0/0"] |
| + } |
| +} |
| + |
| +resource "aws_iam_role" "host" { |
| + name = var.iam_name |
| + assume_role_policy = jsonencode({ |
| + Version = "2012-10-17" |
| + Statement = [{ |
| + Effect = "Allow" |
| + Principal = { Service = "ec2.amazonaws.com" } |
| + Action = "sts:AssumeRole" |
| + }] |
| + }) |
| +} |
| + |
| +# Scoped to exactly the deployment bucket, this region's data bucket, and |
| +# the deployment's SSM parameter prefix. |
| +resource "aws_iam_role_policy" "host_buckets" { |
| + name = "${var.iam_name}-buckets" |
| + role = aws_iam_role.host.id |
| + policy = jsonencode({ |
| + Version = "2012-10-17" |
| + Statement = [ |
| + { |
| + Effect = "Allow" |
| + Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] |
| + Resource = [ |
| + "${var.deployment_bucket_arn}/*", |
| + "${var.data_bucket_arn}/*", |
| + ] |
| + }, |
| + { |
| + Effect = "Allow" |
| + Action = ["s3:ListBucket", "s3:GetBucketLocation"] |
| + Resource = [var.deployment_bucket_arn, var.data_bucket_arn] |
| + }, |
| + { |
| + Effect = "Allow" |
| + Action = ["ssm:GetParameter"] |
| + Resource = var.ssm_parameter_arn |
| + }, |
| + ] |
| + }) |
| +} |
| + |
| +resource "aws_iam_role_policy_attachment" "host_ssm" { |
| + role = aws_iam_role.host.name |
| + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" |
| +} |
| + |
| +resource "aws_iam_instance_profile" "host" { |
| + name = var.iam_name |
| + role = aws_iam_role.host.name |
| +} |
| + |
| +resource "aws_instance" "host" { |
| + ami = data.aws_ami.debian_arm64.id |
| + instance_type = var.instance_type |
| + vpc_security_group_ids = [aws_security_group.host.id] |
| + iam_instance_profile = aws_iam_instance_profile.host.name |
| + user_data = var.user_data |
| + user_data_replace_on_change = false |
| + root_block_device { |
| + volume_size = var.root_volume_size |
| + volume_type = "gp3" |
| + encrypted = true |
| + } |
| + tags = { Name = var.name } |
| +} |
| + |
| +resource "aws_eip" "host" { |
| + domain = "vpc" |
| + instance = aws_instance.host.id |
| + tags = { Name = var.name } |
| +} |
diff --git a/infra/modules/region-host/outputs.tf b/infra/modules/region-host/outputs.tf| new file mode 100644 |
| index 000000000..0468a3a3e |
| --- /dev/null |
| +++ b/infra/modules/region-host/outputs.tf |
| @@ -0,0 +1,29 @@ |
| +output "eip_public_ip" { |
| + description = "Public IP of the host's Elastic IP — the address DNS records point at." |
| + value = aws_eip.host.public_ip |
| +} |
| + |
| +output "instance_id" { |
| + description = "EC2 instance id of the host." |
| + value = aws_instance.host.id |
| +} |
| + |
| +output "security_group_id" { |
| + description = "Security group id of the host — useful for sibling services that admit traffic from the fleet only." |
| + value = aws_security_group.host.id |
| +} |
| + |
| +output "role_arn" { |
| + description = "ARN of the host IAM role." |
| + value = aws_iam_role.host.arn |
| +} |
| + |
| +output "role_name" { |
| + description = "Name of the host IAM role." |
| + value = aws_iam_role.host.name |
| +} |
| + |
| +output "ami_id" { |
| + description = "Resolved AMI id (most recent matching image) — reusable by sibling boxes in the same root." |
| + value = data.aws_ami.debian_arm64.id |
| +} |
diff --git a/infra/modules/region-host/variables.tf b/infra/modules/region-host/variables.tf| new file mode 100644 |
| index 000000000..830fccfa8 |
| --- /dev/null |
| +++ b/infra/modules/region-host/variables.tf |
| @@ -0,0 +1,56 @@ |
| +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 \"<iam_name>-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 = "ARN of THIS region's data bucket — the only data bucket the host role may reach." |
| + type = string |
| +} |
| + |
| +variable "ssm_parameter_arn" { |
| + description = "SSM parameter ARN pattern the host may read (e.g. \"arn:aws:ssm:eu-west-1:<account>:parameter/carlos/*\")." |
| + type = string |
| +} |
| + |
| +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 |
| +} |
diff --git a/infra/modules/region-host/versions.tf b/infra/modules/region-host/versions.tf| new file mode 100644 |
| index 000000000..c028066c2 |
| --- /dev/null |
| +++ b/infra/modules/region-host/versions.tf |
| @@ -0,0 +1,9 @@ |
| +terraform { |
| + required_version = ">= 1.10" |
| + required_providers { |
| + aws = { |
| + source = "hashicorp/aws" |
| + version = ">= 6.0" |
| + } |
| + } |
| +} |
diff --git a/infra/scripts/promote-platform-site.sh b/infra/scripts/promote-platform-site.sh| new file mode 100755 |
| index 000000000..b7ff38366 |
| --- /dev/null |
| +++ b/infra/scripts/promote-platform-site.sh |
| @@ -0,0 +1,10 @@ |
| +#!/bin/bash |
| +# 🤖 Promote the platform marketing site draft to canary/site (zero bake |
| +# hold — it's a draft for Paul's review, served at the real hostname |
| +# because review happens on something deployed, never localhost). |
| +set -euo pipefail |
| +carlos=${CARLOS_BIN:-./carlos} |
| +AWS_PROFILE=keymail AWS_REGION=eu-west-1 \ |
| +CARLOS_DEPLOYMENT_BUCKET=carlos-flagship-271376211898 \ |
| +CARLOS_RELEASE_KEY=$(aws --profile keymail ssm get-parameter --name /carlos/release-key --with-decryption --query Parameter.Value --output text) \ |
| + "$carlos" promote -app platform-site site1 canary/site |
diff --git a/infra/scripts/promote-titogo-canary.sh b/infra/scripts/promote-titogo-canary.sh| new file mode 100755 |
| index 000000000..a7534ba40 |
| --- /dev/null |
| +++ b/infra/scripts/promote-titogo-canary.sh |
| @@ -0,0 +1,9 @@ |
| +#!/bin/bash |
| +# 🤖 Promote titogo ff61d3b-r2 (target-keyed re-ship) to its adoption |
| +# canary channel on the Tito deployment. Key from SSM, never printed. |
| +set -euo pipefail |
| +carlos=${CARLOS_BIN:-./carlos} |
| +AWS_PROFILE=titogo AWS_REGION=eu-west-1 \ |
| +CARLOS_DEPLOYMENT_BUCKET=carlos-tito-platform-665106592142 \ |
| +CARLOS_RELEASE_KEY=$(aws --profile titogo ssm get-parameter --name /carlos/release-key --with-decryption --query Parameter.Value --output text) \ |
| + "$carlos" promote -app titogo ff61d3b-r2 canary/platform-adoption |
diff --git a/infra/tito/dns.tf b/infra/tito/dns.tf| index c842e92db..998aeeced 100644 |
| --- a/infra/tito/dns.tf |
| +++ b/infra/tito/dns.tf |
| @@ -14,5 +14,5 @@ resource "aws_route53_record" "ie_alias" { |
| name = "ie.go.tito.io" |
| type = "A" |
| ttl = 300 |
| - records = [aws_eip.host_ie.public_ip] |
| + records = [module.host_ie.eip_public_ip] |
| } |
diff --git a/infra/tito/main.tf b/infra/tito/main.tf| index db38e9d74..ab1d62a3a 100644 |
| --- a/infra/tito/main.tf |
| +++ b/infra/tito/main.tf |
| @@ -2,6 +2,11 @@ |
| # own; platform spec §12, Tito infra spec). Tofu owns boxes, buckets, IAM, |
| # and static DNS; the platform owns apps, releases, instances, and dynamic |
| # DNS. Nothing account-shaped may ever enter this state. |
| +# |
| +# The reusable shapes live in ../modules; from Tito's own repo the same |
| +# modules are consumable by git source URL (see infra/modules/README.md). |
| +# This root is the thin Tito-specific instantiation: names, sizes, DNS, |
| +# svc/pdf. |
| terraform { |
| required_version = ">= 1.10" |
| @@ -29,56 +34,53 @@ locals { |
| account = "665106592142" |
| } |
| -# The deployment bucket: apps, releases, channels, instance records, host |
| -# config. Routing metadata only — nothing customer-shaped rests here. |
| -resource "aws_s3_bucket" "deployment" { |
| - bucket = "carlos-tito-platform-${local.account}" |
| - lifecycle { prevent_destroy = true } |
| +# The deployment bucket + the IE regional data bucket (the residency |
| +# promise, Tito infra spec §4). Versioning, SSE, public-access block, |
| +# prevent_destroy — all inside the module. |
| +module "buckets" { |
| + source = "../modules/deployment-buckets" |
| + deployment_bucket_name = "carlos-tito-platform-${local.account}" |
| + data_bucket_name = "carlos-tito-ie-${local.account}" |
| } |
| -# The IE regional data bucket: parked databases, WAL streams, leases, |
| -# blobs. This bucket is the residency promise (Tito infra spec §4). |
| -resource "aws_s3_bucket" "data_ie" { |
| - bucket = "carlos-tito-ie-${local.account}" |
| - lifecycle { prevent_destroy = true } |
| +# State surgery from the pre-module layout (refactor 2026-08-01) — pure |
| +# bookkeeping, no real resource is touched. |
| +moved { |
| + from = aws_s3_bucket.deployment |
| + to = module.buckets.aws_s3_bucket.deployment |
| } |
| -resource "aws_s3_bucket_versioning" "deployment" { |
| - bucket = aws_s3_bucket.deployment.id |
| - versioning_configuration { status = "Enabled" } |
| +moved { |
| + from = aws_s3_bucket.data_ie |
| + to = module.buckets.aws_s3_bucket.data |
| } |
| -resource "aws_s3_bucket_versioning" "data_ie" { |
| - bucket = aws_s3_bucket.data_ie.id |
| - versioning_configuration { status = "Enabled" } |
| +moved { |
| + from = aws_s3_bucket_versioning.deployment |
| + to = module.buckets.aws_s3_bucket_versioning.deployment |
| } |
| -resource "aws_s3_bucket_public_access_block" "deployment" { |
| - bucket = aws_s3_bucket.deployment.id |
| - block_public_acls = true |
| - ignore_public_acls = true |
| - block_public_policy = true |
| - restrict_public_buckets = true |
| +moved { |
| + from = aws_s3_bucket_versioning.data_ie |
| + to = module.buckets.aws_s3_bucket_versioning.data |
| } |
| -resource "aws_s3_bucket_public_access_block" "data_ie" { |
| - bucket = aws_s3_bucket.data_ie.id |
| - block_public_acls = true |
| - ignore_public_acls = true |
| - block_public_policy = true |
| - restrict_public_buckets = true |
| +moved { |
| + from = aws_s3_bucket_public_access_block.deployment |
| + to = module.buckets.aws_s3_bucket_public_access_block.deployment |
| } |
| -resource "aws_s3_bucket_server_side_encryption_configuration" "deployment" { |
| - bucket = aws_s3_bucket.deployment.id |
| - rule { |
| - apply_server_side_encryption_by_default { sse_algorithm = "AES256" } |
| - } |
| +moved { |
| + from = aws_s3_bucket_public_access_block.data_ie |
| + to = module.buckets.aws_s3_bucket_public_access_block.data |
| } |
| -resource "aws_s3_bucket_server_side_encryption_configuration" "data_ie" { |
| - bucket = aws_s3_bucket.data_ie.id |
| - rule { |
| - apply_server_side_encryption_by_default { sse_algorithm = "AES256" } |
| - } |
| +moved { |
| + from = aws_s3_bucket_server_side_encryption_configuration.deployment |
| + to = module.buckets.aws_s3_bucket_server_side_encryption_configuration.deployment |
| +} |
| + |
| +moved { |
| + from = aws_s3_bucket_server_side_encryption_configuration.data_ie |
| + to = module.buckets.aws_s3_bucket_server_side_encryption_configuration.data |
| } |
diff --git a/infra/tito/outputs.tf b/infra/tito/outputs.tf| index ab83c78fb..f73fc30fd 100644 |
| --- a/infra/tito/outputs.tf |
| +++ b/infra/tito/outputs.tf |
| @@ -1,13 +1,13 @@ |
| output "ie_eip" { |
| - value = aws_eip.host_ie.public_ip |
| + value = module.host_ie.eip_public_ip |
| } |
| output "deployment_bucket" { |
| - value = aws_s3_bucket.deployment.bucket |
| + value = module.buckets.deployment_bucket |
| } |
| output "data_bucket_ie" { |
| - value = aws_s3_bucket.data_ie.bucket |
| + value = module.buckets.data_bucket |
| } |
| output "svc_pdf_private_ip" { |
diff --git a/infra/tito/region-ie.tf b/infra/tito/region-ie.tf| index cee70c7cd..33b48aaab 100644 |
| --- a/infra/tito/region-ie.tf |
| +++ b/infra/tito/region-ie.tf |
| @@ -2,115 +2,59 @@ |
| # spec §2.2). One Graviton box running `carlos agent`. t4g.medium — the |
| # Hetzner fleet's whole workload fits with headroom (its redline was 3.8GB |
| # with ~20 instances at ~190MB). |
| +# |
| +# Residency is enforced in the module: this host's role reaches the |
| +# deployment bucket plus the IE data bucket and nothing else. A future |
| +# Sydney host's module instance will not include the IE bucket — the |
| +# wrong-region wake the spec requires to fail loudly fails at the IAM |
| +# layer. |
| -data "aws_ami" "debian_arm64" { |
| - most_recent = true |
| - owners = ["136693071363"] # Debian |
| - filter { |
| - name = "name" |
| - values = ["debian-13-arm64-*"] |
| - } |
| - filter { |
| - name = "architecture" |
| - values = ["arm64"] |
| - } |
| +module "host_ie" { |
| + source = "../modules/region-host" |
| + name = "carlos-tito-ie" |
| + sg_description = "carlos tito IE host" |
| + iam_name = "carlos-tito-host-ie" |
| + instance_type = "t4g.medium" |
| + root_volume_size = 40 |
| + user_data = file("${path.module}/host-init.sh") |
| + deployment_bucket_arn = module.buckets.deployment_bucket_arn |
| + data_bucket_arn = module.buckets.data_bucket_arn |
| + ssm_parameter_arn = "arn:aws:ssm:eu-west-1:${local.account}:parameter/carlos/*" |
| } |
| -resource "aws_security_group" "host_ie" { |
| - name = "carlos-tito-ie" |
| - description = "carlos tito IE host" |
| - ingress { |
| - from_port = 80 |
| - to_port = 80 |
| - protocol = "tcp" |
| - cidr_blocks = ["0.0.0.0/0"] |
| - } |
| - ingress { |
| - from_port = 443 |
| - to_port = 443 |
| - protocol = "tcp" |
| - cidr_blocks = ["0.0.0.0/0"] |
| - } |
| - # No SSH ingress: admin access is SSM Session Manager only. |
| - egress { |
| - from_port = 0 |
| - to_port = 0 |
| - protocol = "-1" |
| - cidr_blocks = ["0.0.0.0/0"] |
| - } |
| +# State surgery from the pre-module layout (refactor 2026-08-01) — pure |
| +# bookkeeping, no real resource is touched. |
| +moved { |
| + from = aws_security_group.host_ie |
| + to = module.host_ie.aws_security_group.host |
| } |
| -resource "aws_iam_role" "host_ie" { |
| - name = "carlos-tito-host-ie" |
| - assume_role_policy = jsonencode({ |
| - Version = "2012-10-17" |
| - Statement = [{ |
| - Effect = "Allow" |
| - Principal = { Service = "ec2.amazonaws.com" } |
| - Action = "sts:AssumeRole" |
| - }] |
| - }) |
| +moved { |
| + from = aws_iam_role.host_ie |
| + to = module.host_ie.aws_iam_role.host |
| } |
| -# Residency enforced by construction: this role reaches the deployment |
| -# bucket plus the IE data bucket and nothing else. A future Sydney host's |
| -# role will not include the IE bucket — the wrong-region wake the spec |
| -# requires to fail loudly fails at the IAM layer. |
| -resource "aws_iam_role_policy" "host_ie_buckets" { |
| - name = "carlos-tito-host-ie-buckets" |
| - role = aws_iam_role.host_ie.id |
| - policy = jsonencode({ |
| - Version = "2012-10-17" |
| - Statement = [ |
| - { |
| - Effect = "Allow" |
| - Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] |
| - Resource = [ |
| - "${aws_s3_bucket.deployment.arn}/*", |
| - "${aws_s3_bucket.data_ie.arn}/*", |
| - ] |
| - }, |
| - { |
| - Effect = "Allow" |
| - Action = ["s3:ListBucket", "s3:GetBucketLocation"] |
| - Resource = [aws_s3_bucket.deployment.arn, aws_s3_bucket.data_ie.arn] |
| - }, |
| - { |
| - Effect = "Allow" |
| - Action = ["ssm:GetParameter"] |
| - Resource = "arn:aws:ssm:eu-west-1:${local.account}:parameter/carlos/*" |
| - }, |
| - ] |
| - }) |
| +moved { |
| + from = aws_iam_role_policy.host_ie_buckets |
| + to = module.host_ie.aws_iam_role_policy.host_buckets |
| } |
| -resource "aws_iam_role_policy_attachment" "host_ie_ssm" { |
| - role = aws_iam_role.host_ie.name |
| - policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" |
| +moved { |
| + from = aws_iam_role_policy_attachment.host_ie_ssm |
| + to = module.host_ie.aws_iam_role_policy_attachment.host_ssm |
| } |
| -resource "aws_iam_instance_profile" "host_ie" { |
| - name = "carlos-tito-host-ie" |
| - role = aws_iam_role.host_ie.name |
| +moved { |
| + from = aws_iam_instance_profile.host_ie |
| + to = module.host_ie.aws_iam_instance_profile.host |
| } |
| -resource "aws_instance" "host_ie" { |
| - ami = data.aws_ami.debian_arm64.id |
| - instance_type = "t4g.medium" |
| - vpc_security_group_ids = [aws_security_group.host_ie.id] |
| - iam_instance_profile = aws_iam_instance_profile.host_ie.name |
| - user_data = file("${path.module}/host-init.sh") |
| - user_data_replace_on_change = false |
| - root_block_device { |
| - volume_size = 40 |
| - volume_type = "gp3" |
| - encrypted = true |
| - } |
| - tags = { Name = "carlos-tito-ie" } |
| +moved { |
| + from = aws_instance.host_ie |
| + to = module.host_ie.aws_instance.host |
| } |
| -resource "aws_eip" "host_ie" { |
| - domain = "vpc" |
| - instance = aws_instance.host_ie.id |
| - tags = { Name = "carlos-tito-ie" } |
| +moved { |
| + from = aws_eip.host_ie |
| + to = module.host_ie.aws_eip.host |
| } |
diff --git a/infra/tito/svc-pdf.tf b/infra/tito/svc-pdf.tf| index a93f44cd1..1eb92c6b7 100644 |
| --- a/infra/tito/svc-pdf.tf |
| +++ b/infra/tito/svc-pdf.tf |
| @@ -10,7 +10,7 @@ resource "aws_security_group" "svc_pdf" { |
| from_port = 443 |
| to_port = 443 |
| protocol = "tcp" |
| - security_groups = [aws_security_group.host_ie.id] |
| + security_groups = [module.host_ie.security_group_id] |
| } |
| egress { |
| from_port = 0 |
| @@ -45,7 +45,7 @@ resource "aws_iam_instance_profile" "svc_pdf" { |
| } |
| resource "aws_instance" "svc_pdf" { |
| - ami = data.aws_ami.debian_arm64.id |
| + ami = module.host_ie.ami_id |
| instance_type = "t4g.small" |
| vpc_security_group_ids = [aws_security_group.svc_pdf.id] |
| iam_instance_profile = aws_iam_instance_profile.svc_pdf.name |
diff --git a/site/platform/index.html b/site/platform/index.html| new file mode 100644 |
| index 000000000..d1e72d2eb |
| --- /dev/null |
| +++ b/site/platform/index.html |
| @@ -0,0 +1,324 @@ |
| +<!doctype html> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8"> |
| +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| +<title>The CARLOS Platform</title> |
| +<meta name="description" content="🤖 The place where CARLOS apps run: ship an immutable release, get a canary URL in seconds, promote the same artifact to stable. Apps sleep to a bucket when idle and wake on demand. The whole platform is one binary, a bucket, and DNS — open, and trivially self-hostable."> |
| +<meta property="og:title" content="The CARLOS Platform"> |
| +<meta property="og:description" content="A black-box deployment platform for CARLOS apps. One binary, a bucket, and DNS — Heroku's ease, keeping CARLOS's promises."> |
| +<meta property="og:type" content="website"> |
| +<meta property="og:url" content="https://platform.carlosframework.com/"> |
| +<style> |
| + :root { |
| + --bg: #ffffff; |
| + --ink: #1d2029; |
| + --muted: #64707f; |
| + --accent: #0f5e85; |
| + --accent-soft: rgba(15, 94, 133, 0.09); |
| + --rule: #e7e9f0; |
| + --max: 42rem; |
| + --wide: 62rem; |
| + } |
| + @media (prefers-color-scheme: dark) { |
| + :root { |
| + --bg: #14151c; |
| + --ink: #eef0f6; |
| + --muted: #93a4ba; |
| + --accent: #5db8dd; |
| + --accent-soft: rgba(93, 184, 221, 0.12); |
| + --rule: #2a2e38; |
| + } |
| + } |
| + :root[data-theme="dark"] { |
| + --bg: #14151c; |
| + --ink: #eef0f6; |
| + --muted: #93a4ba; |
| + --accent: #5db8dd; |
| + --accent-soft: rgba(93, 184, 221, 0.12); |
| + --rule: #2a2e38; |
| + } |
| + :root[data-theme="light"] { |
| + --bg: #ffffff; |
| + --ink: #1d2029; |
| + --muted: #64707f; |
| + --accent: #0f5e85; |
| + --accent-soft: rgba(15, 94, 133, 0.09); |
| + --rule: #e7e9f0; |
| + } |
| + * { margin: 0; padding: 0; box-sizing: border-box; } |
| + body { |
| + background: var(--bg); |
| + color: var(--ink); |
| + font: 400 1.0625rem/1.65 Charter, Georgia, "Times New Roman", serif; |
| + -webkit-font-smoothing: antialiased; |
| + padding: 0 1.25rem; |
| + } |
| + a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; } |
| + strong { font-weight: 700; } |
| + code { |
| + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; |
| + font-size: 0.88em; background: var(--accent-soft); |
| + padding: 0.1em 0.35em; border-radius: 0.25rem; |
| + } |
| + |
| + /* hero */ |
| + header { |
| + max-width: var(--wide); margin: 0 auto; |
| + padding: 5rem 0 3rem; text-align: center; |
| + } |
| + h1 { |
| + font-size: 2.9rem; line-height: 1.1; letter-spacing: 0.01em; |
| + margin: 0 0 0.85rem; font-weight: 700; |
| + } |
| + .tagline { font-size: 1.2rem; color: var(--muted); font-style: italic; } |
| + .lede { |
| + font-size: 1.2rem; max-width: 38rem; |
| + margin: 1.5rem auto 0; |
| + } |
| + .cta { |
| + margin: 2rem 0 0; display: flex; gap: 0.75rem; |
| + justify-content: center; flex-wrap: wrap; |
| + } |
| + .btn { |
| + font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; |
| + font-size: 0.95rem; font-weight: 600; text-decoration: none; |
| + padding: 0.65rem 1.25rem; border-radius: 0.5rem; |
| + } |
| + .btn.primary { background: var(--accent); color: var(--bg); } |
| + .btn.ghost { color: var(--accent); border: 1px solid var(--rule); } |
| + .btn.ghost:hover, .btn.primary:hover { filter: brightness(1.1); } |
| + .robot-note { |
| + margin: 2.25rem auto 0; padding: 0.6rem 0.9rem; max-width: 36rem; |
| + background: var(--accent-soft); border-radius: 0.5rem; |
| + font-size: 0.9rem; color: var(--muted); |
| + font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; |
| + } |
| + .robot-note a { color: var(--accent); } |
| + |
| + /* stat strip */ |
| + .stats { |
| + max-width: var(--wide); margin: 0 auto; |
| + border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); |
| + display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); |
| + text-align: center; |
| + } |
| + .stats div { padding: 1.4rem 0.5rem; } |
| + .stats .n { display: block; font-size: 2rem; font-weight: 700; color: var(--accent); line-height: 1.1; } |
| + .stats .l { |
| + font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; |
| + font-size: 0.85rem; color: var(--muted); |
| + } |
| + |
| + /* sections */ |
| + main { max-width: var(--wide); margin: 0 auto; padding: 0 0 3rem; } |
| + section { padding: 3rem 0; } |
| + section + section { border-top: 1px solid var(--rule); } |
| + section > * { max-width: var(--max); margin-left: auto; margin-right: auto; } |
| + section > .cards { max-width: var(--wide); } |
| + h2 { |
| + font-size: 1.6rem; line-height: 1.25; font-weight: 700; |
| + letter-spacing: -0.01em; margin-bottom: 0.4rem; text-align: center; |
| + } |
| + h2 a { color: inherit; text-decoration: none; } |
| + h2 a:hover { color: var(--accent); } |
| + .epigraph { |
| + font-size: 1.15rem; font-style: italic; color: var(--muted); |
| + margin-bottom: 1.4rem; text-align: center; |
| + } |
| + p { margin: 0 0 1.1rem; } |
| + section > p:last-child { margin-bottom: 0; } |
| + |
| + /* cards */ |
| + .cards { |
| + display: grid; gap: 1rem; margin-top: 1.75rem; |
| + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); |
| + } |
| + .card { |
| + border: 1px solid var(--rule); border-radius: 0.75rem; |
| + padding: 1.25rem 1.35rem; |
| + } |
| + .card.feature { border-color: var(--accent); background: var(--accent-soft); } |
| + .card h3 { font-size: 1.1rem; font-weight: 700; margin: 0 0 0.35rem; } |
| + .card .kicker { |
| + display: block; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; |
| + font-size: 0.78rem; font-weight: 600; letter-spacing: 0.06em; |
| + text-transform: uppercase; color: var(--accent); margin-bottom: 0.3rem; |
| + } |
| + .card p { color: var(--muted); font-size: 0.97rem; margin: 0; } |
| + |
| + .keyline { |
| + margin: 1.5rem auto; padding: 1rem 1.1rem; |
| + border-left: 3px solid var(--accent); |
| + background: var(--accent-soft); |
| + border-radius: 0 0.4rem 0.4rem 0; |
| + } |
| + .keyline p:last-child { margin-bottom: 0; } |
| + |
| + ul.plain { list-style: none; margin: 1.1rem auto; } |
| + ul.plain li { |
| + padding: 0.45rem 0 0.45rem 1.25rem; position: relative; |
| + } |
| + ul.plain li::before { |
| + content: "—"; position: absolute; left: 0; color: var(--accent); |
| + } |
| + |
| + footer { |
| + max-width: var(--wide); margin: 0 auto; |
| + border-top: 1px solid var(--rule); |
| + padding: 2rem 0 4rem; text-align: center; |
| + color: var(--muted); font-size: 0.95rem; |
| + } |
| + footer p { margin-bottom: 0.6rem; max-width: var(--max); margin-left: auto; margin-right: auto; } |
| +</style> |
| +</head> |
| +<body> |
| + |
| +<header> |
| + <h1>The CARLOS Platform</h1> |
| + <p class="tagline">Where CARLOS apps run.</p> |
| + <p class="lede">A black-box deployment platform for <a href="https://carlosframework.com">CARLOS</a> apps: ship a release, and the platform places it on instances, routes to it, sleeps it when idle, and wakes it on demand. Heroku's ease, keeping CARLOS's promises — and the platform itself is one binary, a bucket, and DNS.</p> |
| + <div class="cta"> |
| + <a class="btn primary" href="#ship">What shipping looks like</a> |
| + <a class="btn ghost" href="#status">How young is it?</a> |
| + </div> |
| + <p class="robot-note">🤖 This page was written by an LLM, on the ideas, instruction, and editing of humans. AI-written text here is always marked, visibly, like this — that's <a href="https://11factor.org/#x">factor X</a>.</p> |
| +</header> |
| + |
| +<div class="stats"> |
| + <div><span class="n">1</span><span class="l">binary, playing every role</span></div> |
| + <div><span class="n">0</span><span class="l">control-plane servers</span></div> |
| + <div><span class="n">0</span><span class="l">rebuilds from canary to stable</span></div> |
| + <div><span class="n">72h</span><span class="l">bake before stable adopts</span></div> |
| +</div> |
| + |
| +<main> |
| + |
| +<section id="what"> |
| + <p>The framework defines the shape of an app; the platform is the substrate it runs on — the way Heroku stood beside the twelve factors. An author ships <strong>releases</strong>: immutable, content-addressed artifacts. The platform places them on <strong>instances</strong>, routes to them through a mesh, hibernates them when idle, and wakes them on the request that needs them. Apps from many authors run side by side on the same fleet, sharing the same blind infrastructure.</p> |
| + |
| + <div class="keyline"> |
| + <p>The whole self-hosting story is one sentence: <strong>a deployment is a bucket, N hosts running one binary, and DNS.</strong> Keeping it one sentence is a design constraint on everything else.</p> |
| + </div> |
| + |
| + <p>There is no control-plane server to stand up, and none to be locked out of. All durable platform state — apps, releases, channels, instance records, leases — lives in an object-store bucket; hosts converge by watching it, and a burned-down box is replaced by pointing a fresh one at the bucket. The platform makes about itself the promise its instances already make: <strong>the box is disposable, the bucket is not.</strong> It is entirely open and trivially self-hostable, and two of its first three deployments <em>are</em> self-hosted — so anything the flagship needs that a self-hoster can't have is a design error, caught immediately.</p> |
| +</section> |
| + |
| +<section id="ship"> |
| + <h2><a href="#ship">Ship, canary, promote</a></h2> |
| + <p class="epigraph">The dev pipeline is the prod pipeline, exercised on every push.</p> |
| + |
| + <p><code>carlos ship</code> uploads a release under its content hash. A <strong>channel</strong> is a per-app pointer to a release — <code>stable</code>, <code>beta</code>, and ephemeral <code>canary/*</code> channels — and advancing one is a single pointer write. Instances subscribe to a channel and roll onto new releases at their next natural boundary: wake from hibernation, or a drain-and-restart sweep. Rollback is the same operation backwards.</p> |
| + |
| + <div class="cards"> |
| + <div class="card"> |
| + <h3>Every branch gets a canary URL</h3> |
| + <p><code>carlos canary</code> from a worktree builds, ships, creates a channel, spawns one instance bound to sandbox glue, and returns its URL — in seconds, not minutes. A dev environment is not a separate system; it is a channel with one subscriber.</p> |
| + </div> |
| + <div class="card"> |
| + <h3>Promotion never rebuilds</h3> |
| + <p><code>carlos promote</code> points a wider channel at the same hash: canary → beta → stable. Every environment running release <code>abc123</code> runs byte-identical code. What you tested is what ships, because it is the same bytes.</p> |
| + </div> |
| + <div class="card"> |
| + <h3>Rollback is a pointer move</h3> |
| + <p>A bad release hits gradually — instances adopt on wake — and pointing the channel back at the previous hash stops the bleed. Additive-only migrations mean old data stays readable under new code and new data survives the trip back.</p> |
| + </div> |
| + <div class="card"> |
| + <h3>Static sites ride the same channels</h3> |
| + <p>A static release is a content-addressed file tree the edge serves directly — no process, nothing to hibernate. Same canary URLs per branch, same promotion, same rollback. This page ships that way.</p> |
| + </div> |
| + </div> |
| +</section> |
| + |
| +<section id="run"> |
| + <h2><a href="#run">Sleeping is the default state</a></h2> |
| + <p class="epigraph">Most software is used a few hours a week. The platform bills like it.</p> |
| + |
| + <p>An idle instance hibernates, and its database <strong>parks</strong> to object storage: a parked app is a few objects in a bucket — no process, no memory, no host doing anything on its behalf. The next request wakes it, on whichever eligible host answers first. Availability is continuous even when execution isn't.</p> |
| + |
| + <ul class="plain"> |
| + <li><strong>Per-instance databases, continuously replicated.</strong> Every instance owns its own database, and it streams to the bucket from the first byte. Losing a host costs a restart, not a history: its leases expire and the instances wake elsewhere from their last parked state.</li> |
| + <li><strong>Scheduling is deliberately dumb.</strong> No bin-packing optimizer — hibernation makes placement mistakes cheap. A badly-placed instance parks and wakes somewhere better next time.</li> |
| + <li><strong>Scale is a declaration, not a rewrite.</strong> Parked → embedded SQLite → replicated → an attached server database (one per instance, inside the app's trust domain, never multi-tenant) → a dedicated pool. An app moves along that curve by changing its manifest, not its architecture.</li> |
| + </ul> |
| +</section> |
| + |
| +<section id="place"> |
| + <h2><a href="#place">Placement you can point at</a></h2> |
| + <p class="epigraph">Residency is enforced where the instance wakes, not promised in a policy PDF.</p> |
| + |
| + <div class="cards"> |
| + <div class="card"> |
| + <span class="kicker">Residency</span> |
| + <h3>Choose where data lives</h3> |
| + <p>An app — or a single instance, because one customer may demand EU while another doesn't care — declares <code>residency: eu</code>. A host outside the region will not claim the lease, so there is no code path where the instance runs elsewhere "just this once during a failover": no eligible host up means the wake fails loudly. Parked data, streams, and blobs rest in region-scoped buckets. Residency covers rest as well as execution.</p> |
| + </div> |
| + <div class="card"> |
| + <span class="kicker">Dedicated pools</span> |
| + <h3>Your own hardware, same platform</h3> |
| + <p>Apps that hold real personal or payment data — not everything is end-to-end encrypted — can reserve hosts: their instances, parked data, and wake path only ever touch that pool, optionally with its own bucket, while still riding the shared edge, channels, CLI, and services.</p> |
| + </div> |
| + </div> |
| +</section> |
| + |
| +<section id="services"> |
| + <h2><a href="#services">Shared services: blind or seeing, never both</a></h2> |
| + <p class="epigraph">Nothing that sees may store.</p> |
| + |
| + <p>Every shared service carries a declared class, visible in the manifest of every app that consumes it. <strong>Blind services</strong> may hold data, but only data they cannot read: the object store holds parked databases and blobs but no plaintext of E2EE apps; the vault holds sealed blobs whose keys live elsewhere; the push relay holds device tokens and timing but never notification content; the OAuth relay passes tokens through sealed to the requesting instance, never retaining them. What makes them blind is falsifiable the CARLOS way — publish their state and see what leaks.</p> |
| + |
| + <p><strong>Seeing services</strong> process plaintext — a URL unfurler, a PDF renderer, outbound mail — and are therefore forbidden to hold it. Not by promise: the platform grants a seeing service no database binding, no bucket write access, no persistent volume. Statelessness is imposed by construction. One honest caveat the mail service carries with it: the underlying email provider retains delivery metadata — bounces, complaints, suppressions — scoped per tenant.</p> |
| +</section> |
| + |
| +<section id="supply"> |
| + <h2><a href="#supply">Supply-chain honesty</a></h2> |
| + <p class="epigraph">The scary step in any pipeline is the pointer that says "this is stable now."</p> |
| + |
| + <ul class="plain"> |
| + <li><strong>Channel pointers are signed.</strong> A host agent rolls instances onto a release only when the pointer advancing the channel carries a valid signature — a bucket write alone is not a deployment.</li> |
| + <li><strong>Releases bake before stable adopts them.</strong> A release must sit on its channel for 72 hours before stable instances roll onto it — time for a poisoned artifact to be noticed while its blast radius is still the people who opted in.</li> |
| + <li><strong>Rollback never bakes.</strong> Pointing back at a hash that already served traffic takes effect immediately. The waiting period protects against new code, and going backwards is never new code.</li> |
| + </ul> |
| + |
| + <p>Every release records its build attestation — commit and builder — under its hash, and every platform change ships through the platform itself: worktree, canary channel, a miniature canary deployment, a headless-browser gate that fails on any console error. The release machinery is tested by being the only way anything ships, including itself.</p> |
| +</section> |
| + |
| +<section id="where"> |
| + <h2><a href="#where">Three deployments, one platform</a></h2> |
| + <p class="epigraph">The Open promise is proven from day one, because the first two production users are self-hosters.</p> |
| + |
| + <div class="cards"> |
| + <div class="card"> |
| + <h3>Tito</h3> |
| + <p>Ticketing for conferences and events, running its own self-hosted deployment on its own account. Self-hosted use-case number one, and the platform's first proving ground.</p> |
| + </div> |
| + <div class="card"> |
| + <h3>Eleven</h3> |
| + <p>The encrypted messenger, on its own deployment — and the code quarry: the platform's edge, host agent, channels, and mergeable stores were extracted from Eleven's proven machinery.</p> |
| + </div> |
| + <div class="card feature"> |
| + <h3>The flagship cloud</h3> |
| + <p>The shared multi-tenant deployment, operated by the CARLOS authors, hosting Keymail, Woodstar, Slopbox, and Kass — the first four customers. Same open platform, differing from the self-hosted two in variables, never in shape.</p> |
| + </div> |
| + </div> |
| + |
| + <p style="margin-top:1.75rem">Apps live at <code><sqid>.oncarlos.com</code> — an address that never encodes region or host, so relocation and placement stay invisible to it. Custom domains ride on top as aliases.</p> |
| +</section> |
| + |
| +<section id="status"> |
| + <h2><a href="#status">Where it actually stands</a></h2> |
| + <p class="epigraph">Fewer claims, all of them checkable — including this one.</p> |
| + |
| + <p>The platform is young. It was extracted from Eleven and titogo — systems that kept rebuilding the same deployment shape until it was obviously one thing — and its first production tenant, Kass, migrated onto it on 2026-08-01. Everything above describes the design being built and proven, not a mature product with years of scar tissue. If that honesty reads oddly on a homepage — good. It is the same trade the framework makes: the platform holds itself to the promises it hosts, and a promise you can't check yet is stated as exactly that.</p> |
| +</section> |
| + |
| +</main> |
| + |
| +<footer> |
| + <p>🤖 Written by an LLM (Claude), on the ideas, instruction, and editing of humans. AI-written text here is always marked and always disclosed — see <a href="https://11factor.org/#x">factor X</a>.</p> |
| + <p>The platform is the substrate for apps built on <a href="https://carlosframework.com">the CARLOS framework</a>, keeping the promises set out in <a href="https://11factor.org">The Eleven Factors</a> — the way Heroku stood beside the twelve.</p> |
| + <p><a href="https://github.com/carlosframework/platform">Source for the platform — and this site — on GitHub</a>. Open, of course.</p> |
| +</footer> |
| + |
| +</body> |
| +</html> |