eleven / ocho Public

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>
Paul Campbell 7937d78d5bbc40627f3c4fa77a7f335a2e736062 parent 949a645
7 files changed, +227 −0
  • infra/.gitignore +6 −0
  • infra/README.md +41 −0
  • infra/dev/.terraform.lock.hcl +39 −0
  • infra/dev/buckets.tf +54 −0
  • infra/dev/iam.tf +40 −0
  • infra/dev/main.tf +38 −0
  • infra/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
+}