infra: OpenTofu skeleton for the AWS dev environment (#550 proof build) (#609)
The static-skeleton-only IaC layer from the planet-scale plan: dev buckets for instance state (Litestream targets) and the home vault store — versioned, keep-last-5-noncurrent lifecycle, Block Public Access — plus a scoped litestream-canary IAM user confined to the canary/ prefix. State in S3 with native lockfile locking; the state bucket itself is the one imperative resource. Boundary rule documented: the IaC never learns an instance hostname.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+227
−0
infra/.gitignore+6 −0infra/README.md+41 −0infra/dev/.terraform.lock.hcl+39 −0infra/dev/buckets.tf+54 −0infra/dev/iam.tf+40 −0infra/dev/main.tf+38 −0infra/dev/outputs.tf+9 −0
diff --git a/infra/.gitignore b/infra/.gitignore| new file mode 100644 |
| index 0000000..3843d48 |
| --- /dev/null |
| +++ b/infra/.gitignore |
| @@ -0,0 +1,6 @@ |
| +# 🤖 Local OpenTofu working files — the lock file IS committed; provider |
| +# binaries, saved plans (may embed sensitive values), and any local state are not. |
| +**/.terraform/ |
| +*.tfplan |
| +*.tfstate |
| +*.tfstate.* |
diff --git a/infra/README.md b/infra/README.md| new file mode 100644 |
| index 0000000..e4336f7 |
| --- /dev/null |
| +++ b/infra/README.md |
| @@ -0,0 +1,41 @@ |
| +# 🤖 infra/ — the Eleven Cloud IaC skeleton |
| + |
| +OpenTofu for the AWS side of the planet-scale plan (issue #550). This layer |
| +manages the **static skeleton only** — buckets, IAM, and (later) VPC/NLB/ASG. |
| +All intelligence lives in the `lchat` binary; the boundary test from the plan |
| +holds for every change here: **if the IaC needs to know an instance hostname, |
| +the design is wrong.** |
| + |
| +## Environments |
| + |
| +- `dev/` — the proof-of-concept environment in the dev account |
| + (`427880590669`, eu-west-1, AWS profile `eleven-dev`). Test data only: |
| + nothing in this account is production, and the production fleet stays on |
| + the router box until #550's build triggers fire. |
| + |
| +## Running |
| + |
| +```sh |
| +cd infra/dev |
| +AWS_PROFILE=eleven-dev tofu init |
| +AWS_PROFILE=eleven-dev tofu plan |
| +AWS_PROFILE=eleven-dev tofu apply |
| +``` |
| + |
| +State lives in S3 (`eleven-tofu-state-427880590669`, versioned, private) with |
| +native lockfile locking — no DynamoDB lock table needed. That bucket is the |
| +one imperatively-created resource (chicken and egg); everything else is |
| +declared here. |
| + |
| +## What exists (dev) |
| + |
| +- `eleven-dev-instances-…` — the instance-state bucket: Litestream replication |
| + targets, one prefix per instance. Versioning on, lifecycle keeps the last 5 |
| + noncurrent versions, Block Public Access. Instance DBs are opaque ciphertext |
| + plus routing metadata the server already sees, so replicating them here is |
| + threat-model-free (the E2EE rule did the hard part). |
| +- `eleven-dev-home-…` — the home vault bucket for the `s3Store` backend work |
| + (#550 Part 1): same posture. |
| +- A scoped `litestream-canary` IAM user/policy confined to the canary's prefix |
| + — the shape per-instance credentials will take; the admin `dev-agent` user |
| + is console-made and not managed here. |
diff --git a/infra/dev/.terraform.lock.hcl b/infra/dev/.terraform.lock.hcl| new file mode 100644 |
| index 0000000..9d87350 |
| --- /dev/null |
| +++ b/infra/dev/.terraform.lock.hcl |
| @@ -0,0 +1,39 @@ |
| +# This file is maintained automatically by "tofu init". |
| +# Manual edits may be lost in future updates. |
| + |
| +provider "registry.opentofu.org/hashicorp/aws" { |
| + version = "6.54.0" |
| + constraints = "~> 6.0" |
| + hashes = [ |
| + "h1:+fuoXxQEOoT6LtTlxiz+2JQz29pOev6T3f+EnrKJQdU=", |
| + "h1:/YoIrgxHWluPcxV7VkPDQXGUTWGLHyWrFZ0p4YQtD/k=", |
| + "h1:2iBfjJWoE/mLz4Nlk3k3B01jmmrZ3nt6jUjcOBfdsIg=", |
| + "h1:CGF4EHrHHvRlA4zDygUK3bEvleYHVzVO8B/77MpXN2U=", |
| + "h1:HA9VzSihsNsOH9/43H3WRQ2B6ra+pWrJpntHrePtLro=", |
| + "h1:KbJ2hmW/5X2UUbtkx5+NkSbqlypbQz9heJO9kJZuHVs=", |
| + "h1:P1B4qs1A7J5fsd4nWIvNxSd95/hMOluCNZIgOUR7jdY=", |
| + "h1:TgNW1ixmV3WyGXOnsX0ojjqjqk7kvLPiPOrwMHSPj9g=", |
| + "h1:U0h5Zv8SDKQqWRG2PzOutGwynnNeotoc/Qi790KhlX8=", |
| + "h1:dR1gLh4Jb58GSGTaTFJ5cRBmWVDILybvTqgsJ1FMPxQ=", |
| + "h1:hqzkTh+nLtxUcXVbPspshZZ6jKD0Kv1ULWpc+LovkMQ=", |
| + "h1:nXG/JZ187e+FJsHcgdQEpqEA0nGZCysU6+i0Rv2OLK0=", |
| + "h1:nhC7R1QTAbieBYvXYnME2qVlzUTQenswXzRhMGthKw4=", |
| + "h1:pJuq/1c7MVniTzmZz9+/iroIxv1C9c6NfZD4qf1uvvM=", |
| + "h1:q5ygK0xlXRNjazU/a66Q72ijPmpFWOAtquYUIYEAFXo=", |
| + "zh:03efcbe4eb2b94e8e1dc10e3fcc35c3dcc2c5ed03e8a4f14b027aa26d2b391d8", |
| + "zh:081e2ccd4719f894ef2577c65949e4944a11ceb45ec55413db5482232c1d6dc3", |
| + "zh:276572fc0da8b7e99680626d0399f9c1845b71ea66df1e30f4c4cabeee3220da", |
| + "zh:67a0703538c6ea969a8436d3740866e31acadff3b3ff48d24941295ac6b1dbf4", |
| + "zh:81e9e9c2e33366a8b0085e79542911a289e5e00df347f6a22960d5f54aa1d6b7", |
| + "zh:8df21a1e549f49f18127bc997df4da596cf69b6f6d75b5f1a5ffdf351a01037d", |
| + "zh:8e9d5c5c892634b7ab8a10a9a47e581662f976d17b49a2292d51b17e2260c5c0", |
| + "zh:9b58601ba5f64c8594b0f05da934bf58d6564eb800bff86ce746733ebaa3ab36", |
| + "zh:a5ffd086c56b5812ccec7632cd4053bcb4b0552790c99a9cf4b12dea24390ba8", |
| + "zh:af87d40420bdd1c935c7ccd729b31f97f819f8576f9897b57cf6356a4fbda880", |
| + "zh:d09bd4c2e6d0a8cfc2699a25a535d07eb4b31d8a71f912b6943ab978e3c8b37c", |
| + "zh:d832d91ab28d0fea6dc7ac3fd313f75cc6ab7d90d63cdcf7ba649f6e70898b15", |
| + "zh:e9d81182bb0bf5b8c13a0eb6029597ec2fc0d56914eea564004f6ae84529ed52", |
| + "zh:eb8fe7a194c4f9b3d86366371773631052667e22136424ce725517775ef5b6fe", |
| + "zh:f0b9c91fe2708200c198405ca758c100ab06ca0b4e39db5cabbaa739b6231a16", |
| + ] |
| +} |
diff --git a/infra/dev/buckets.tf b/infra/dev/buckets.tf| new file mode 100644 |
| index 0000000..240051e |
| --- /dev/null |
| +++ b/infra/dev/buckets.tf |
| @@ -0,0 +1,54 @@ |
| +# 🤖 The two state buckets. Same posture for both: versioned, keep the last |
| +# 5 noncurrent versions (1-day linger is a lifecycle floor, not a cap), and |
| +# Block Public Access — the plan's "design as if public, configure as private" |
| +# line. Contents are client-side ciphertext either way. |
| + |
| +locals { |
| + buckets = { |
| + # Litestream replication targets, one prefix per instance host. |
| + instances = "eleven-dev-instances-${local.account_id}" |
| + # home's vault store (the s3Store backend, #550 Part 1). |
| + home = "eleven-dev-home-${local.account_id}" |
| + } |
| +} |
| + |
| +resource "aws_s3_bucket" "state" { |
| + for_each = local.buckets |
| + bucket = each.value |
| +} |
| + |
| +resource "aws_s3_bucket_versioning" "state" { |
| + for_each = local.buckets |
| + bucket = aws_s3_bucket.state[each.key].id |
| + |
| + versioning_configuration { |
| + status = "Enabled" |
| + } |
| +} |
| + |
| +resource "aws_s3_bucket_lifecycle_configuration" "state" { |
| + for_each = local.buckets |
| + bucket = aws_s3_bucket.state[each.key].id |
| + |
| + rule { |
| + id = "keep-last-5-noncurrent" |
| + status = "Enabled" |
| + |
| + filter {} |
| + |
| + noncurrent_version_expiration { |
| + noncurrent_days = 1 |
| + newer_noncurrent_versions = 5 |
| + } |
| + } |
| +} |
| + |
| +resource "aws_s3_bucket_public_access_block" "state" { |
| + for_each = local.buckets |
| + bucket = aws_s3_bucket.state[each.key].id |
| + |
| + block_public_acls = true |
| + block_public_policy = true |
| + ignore_public_acls = true |
| + restrict_public_buckets = true |
| +} |
diff --git a/infra/dev/iam.tf b/infra/dev/iam.tf| new file mode 100644 |
| index 0000000..3d049b6 |
| --- /dev/null |
| +++ b/infra/dev/iam.tf |
| @@ -0,0 +1,40 @@ |
| +# 🤖 Scoped credentials for the Litestream canary — the shape per-instance |
| +# (later per-box) credentials take: confined to one prefix of the instances |
| +# bucket, so a leaked key exposes one instance's ciphertext, not the fleet's. |
| +# No access key is declared here (it would land in state); mint one with |
| +# aws iam create-access-key --user-name litestream-canary |
| +# and store it in 1Password. |
| + |
| +resource "aws_iam_user" "litestream_canary" { |
| + name = "litestream-canary" |
| +} |
| + |
| +data "aws_iam_policy_document" "litestream_canary" { |
| + statement { |
| + sid = "ListCanaryPrefix" |
| + actions = ["s3:ListBucket"] |
| + resources = [aws_s3_bucket.state["instances"].arn] |
| + |
| + condition { |
| + test = "StringLike" |
| + variable = "s3:prefix" |
| + values = ["canary/*", "canary"] |
| + } |
| + } |
| + |
| + statement { |
| + sid = "ReadWriteCanaryObjects" |
| + actions = [ |
| + "s3:GetObject", |
| + "s3:PutObject", |
| + "s3:DeleteObject", |
| + ] |
| + resources = ["${aws_s3_bucket.state["instances"].arn}/canary/*"] |
| + } |
| +} |
| + |
| +resource "aws_iam_user_policy" "litestream_canary" { |
| + name = "litestream-canary-prefix" |
| + user = aws_iam_user.litestream_canary.name |
| + policy = data.aws_iam_policy_document.litestream_canary.json |
| +} |
diff --git a/infra/dev/main.tf b/infra/dev/main.tf| new file mode 100644 |
| index 0000000..66532f8 |
| --- /dev/null |
| +++ b/infra/dev/main.tf |
| @@ -0,0 +1,38 @@ |
| +# 🤖 Dev environment for the #550 proof build. Static skeleton only — |
| +# see infra/README.md for the boundary rules. |
| + |
| +terraform { |
| + required_version = ">= 1.10" |
| + |
| + required_providers { |
| + aws = { |
| + source = "hashicorp/aws" |
| + version = "~> 6.0" |
| + } |
| + } |
| + |
| + backend "s3" { |
| + bucket = "eleven-tofu-state-427880590669" |
| + key = "dev/terraform.tfstate" |
| + region = "eu-west-1" |
| + use_lockfile = true |
| + } |
| +} |
| + |
| +provider "aws" { |
| + region = "eu-west-1" |
| + |
| + default_tags { |
| + tags = { |
| + project = "eleven" |
| + env = "dev" |
| + managed = "opentofu" |
| + } |
| + } |
| +} |
| + |
| +data "aws_caller_identity" "current" {} |
| + |
| +locals { |
| + account_id = data.aws_caller_identity.current.account_id |
| +} |
diff --git a/infra/dev/outputs.tf b/infra/dev/outputs.tf| new file mode 100644 |
| index 0000000..cd55695 |
| --- /dev/null |
| +++ b/infra/dev/outputs.tf |
| @@ -0,0 +1,9 @@ |
| +# 🤖 |
| + |
| +output "instances_bucket" { |
| + value = aws_s3_bucket.state["instances"].bucket |
| +} |
| + |
| +output "home_bucket" { |
| + value = aws_s3_bucket.state["home"].bucket |
| +} |