commit 284510b9ac987d99ddbc5da130f3e37a54e7fb63 Author: Natalie Date: Sat Jun 27 09:43:44 2026 -0400 infra(uvlava): seed shared infranet repo with DO store-tier IaC Dedicated home for uvlava.com infra (forge, registry, DB, mesh) serving lilith v2 + cocotte v4. Terraform init/validate/plan verified against live DO (13 resources). Migrated out of the v2 product tree per the v2/v4 boundary. Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1355c72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example +.terraform/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..8cb694b --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# uvlava + +**uvlava.com — the shared infranet.** The infrastructure layer beneath both +product lines, replacing the dead homelan hosts `black` + `apricot` (died +2026-06-27). Not a product; the substrate the products run on. + +- **lilith (v2)** — `~/Code/@projects/@lilith/lilith-platform.live` +- **cocotte (v4)** — `~/Code/@projects/@cocottetech` + +Both consume uvlava; neither owns it. Infra config lives here so it isn't +buried in a product repo. + +## Topology + +``` + PUBLIC INTERNET ─► serve tier (NOT uvlava): 1984.is / vps-0 (Iceland) + nginx · SPAs · edge cache · mail · adult content + │ private (WireGuard mesh) + uvlava ───────────► store/infra tier: DigitalOcean (ct:prod, nyc3) + Forgejo · Verdaccio · Managed PG · Spaces · workers +``` + +uvlava is **store/infra only** — it never serves adult content to the public +(provider-AUP + the serve tier stays on content-tolerant 1984.is). + +## What's live + +| Service | Host | Endpoint (bare for now; named later) | +|---|---|---| +| Forgejo (git canonical) | `lilith-forge` droplet | `134.199.243.61:3000` → `forge.uvlava.com` (planned) | +| Verdaccio (`@lilith/*` npm) | same droplet | `134.199.243.61:4873` → `npm.uvlava.com` (planned) | + +DO account `ct` / project `ct:prod` / region `nyc3`. `uvlava.com` is registered +(joker.com) but not yet pointed — DNS + TLS deferred until the store tier lands. + +## Layout + +- `terraform/do/` — DO store tier IaC (Managed PG + Spaces + backend droplet + + WG peer + optional GPU). `init`/`validate`/`plan` verified against the live + account (13 resources, no GPU); **not yet applied**. See + [`terraform/do/README.md`](terraform/do/README.md) for the apply guide. + +## Secrets + +None in-tree. All under `~/.vault/` (`0600`): `do-pat-ct.token`, +`forge-admin-quinn.*`. `.gitignore` blocks `*.tfstate` / `*.tfvars`. diff --git a/terraform/do/.gitignore b/terraform/do/.gitignore new file mode 100644 index 0000000..9efe6a1 --- /dev/null +++ b/terraform/do/.gitignore @@ -0,0 +1,11 @@ +# Never commit state or secrets. +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example +.terraform/ +crash.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json diff --git a/terraform/do/.terraform.lock.hcl b/terraform/do/.terraform.lock.hcl new file mode 100644 index 0000000..cab95d1 --- /dev/null +++ b/terraform/do/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/digitalocean/digitalocean" { + version = "2.92.0" + constraints = "~> 2.43" + hashes = [ + "h1:PDahQCnG9M3XAjihY7KzGVPuLQTB6gPKWn7Tp9TPaOY=", + "zh:13cefc6a94b74445713abeacfdf6422d1aecf820ec08fe69bae63c3ea6fbe24e", + "zh:20fc749afda0dfd10ec6815db78efb0bdf399033db536738580816ca341cd2c6", + "zh:2fac398f97fbec5d9c16ce3c58a9925ca0474c4931ead3352af56161fd7d6f1e", + "zh:3e0542d5200c1efb3113bd2ad3a5cc1ba32b9d1fe7017044ceeb0b7729a7a7f6", + "zh:583ddc43350dfb84a9a5689fe11964df9afe1ae03d099ab96c8f0fb7bc7a4cad", + "zh:6025ea83b0602b6ff01b3c5bbe025e73e8b47a217aae6c4270725feac01ebb2b", + "zh:6be3c78cb90752ce9357c33792f869382ff9dbd01333d985127116478bdcec21", + "zh:75c4c76c24bdc7e9c8626603d1c082d0894c798096ccfe8e2ceba68ad4570638", + "zh:7abc9714982dee251e6b9ce6d4910cd413a46cb92f76a4ed3a92a56e7cc1b4e7", + "zh:7c4808dd90886f33c5bd861b7b6be9b942ae2b32a188793f6f4e07be4e146b47", + "zh:7d13d3bec74e08444334e6b5c1c5f5380d40dd0bbb80d2d387d9084aaecbd3cf", + "zh:8a11b04c46865bdcd49f15622398e6e4911aa5be5d0b12d0b708cdda5c8ff734", + "zh:910cad53707e4743f6c277fb0007f6937a64be5b3a8ded3af1273628b9c141fe", + "zh:a67d98e6aceb5837064c6e811a557dbaaa61791b99a8b8d87b278aecb871910e", + "zh:bed15d16d4be506123fba16c3fa6db7cafa7d2ed53f07ff370cc2228e5f6d9ba", + "zh:f794ef952a8b2b5702ecfecf9bfe372dac392789b0762e5598764d10f24a8210", + ] +} diff --git a/terraform/do/README.md b/terraform/do/README.md new file mode 100644 index 0000000..2c6a276 --- /dev/null +++ b/terraform/do/README.md @@ -0,0 +1,83 @@ +# DO store tier — IaC + live state + +DigitalOcean **store/backend** tier that replaces the dead homelan host `black`. +Adult content is **served only from 1984.is / vps-0**; DO holds durable state +and never has a public hostname for the content surface. Full rationale + +homelan→cloud mapping: [`../../../.claude/plans`](../../..) / the recovery plan +and [`../../../docs/EDGE_ISLAND_MODE.md`](../../../docs/EDGE_ISLAND_MODE.md). + +## Account / project + +- **DO account:** `ct` — TransQuinnFTW@pm.me (active, verified). PAT at + `~/.vault/do-pat-ct.token`. (Separate `mc` account/PAT also exists: + `~/.vault/do-pat-mc.token`-style — do not mix.) +- **Project:** `ct:prod` (`ed8cdfb7-f6eb-4f92-a44e-2a03627d5baa`). All rebuild + resources are grouped here. +- **Region:** `nyc3` (operator NYC-local). GDPR residency caveat for EU-subject + PII is open — see the plan. + +## Live now (provisioned 2026-06-27, NOT yet under Terraform state) + +`lilith-forge` was stood up directly via the DO API to immediately replace the +two dead `forge.black.lan` services. It can be `terraform import`ed later. + +| Thing | Value | +|---|---| +| Droplet | `lilith-forge` id `580675125`, `s-1vcpu-2gb`, nyc3, ubuntu-24.04 ($12/mo) | +| Public IP (reserved) | `134.199.243.61` | +| Forgejo (git, new `origin`) | `http://134.199.243.61:3000` · git-ssh on `:2222` | +| Verdaccio (`@lilith/*` npm) | `http://134.199.243.61:4873` | +| Cloud firewall | `lilith-forge-fw` — inbound 22/2222/3000/4873 from **plum + vps-0 only** | +| Forgejo admin | user `quinn` — password at `~/.vault/forge-admin-quinn.password` | +| Forgejo API token | `~/.vault/forge-admin-quinn.api-token` | +| Repo | `quinn/lilith-platform.live` (private) | + +Git remote on plum: `forge` → +`ssh://git@134.199.243.61:2222/quinn/lilith-platform.live.git` (push with +`GIT_SSH_COMMAND="ssh -i ~/.ssh/id_ed25519_1984"`). + +Cloud-init that builds this box: [`cloud-init/forge.yaml`](cloud-init/forge.yaml) +(keep it pure-ASCII — em-dashes break cloud-init's early YAML parse). + +### Follow-ups for the forge box +- TLS (Caddy) in front of Forgejo/Verdaccio; then move git to HTTPS or keep SSH. +- Join it to the wg1 mesh and drop the public 3000/4873 exposure. +- Repoint repo `origin` + the 9 `forge.black.lan` registry refs + `@lilith` + `.npmrc` to this Verdaccio (Phase 3 of the plan). + +## Terraform (store tier — written, NOT yet applied) + +The `.tf` files describe the **full** store tier (Managed PG + Spaces + backend +droplet + WG peer + GPU). They are intentionally un-applied until the GDPR +region call and PG sizing are settled. Apply gates on a verified account (done) +and registered SSH keys. + +```sh +cd infrastructure/terraform/do +export TF_VAR_do_token="$(cat ~/.vault/do-pat-ct.token)" +export TF_VAR_spaces_access_id="…" # API → Spaces Keys +export TF_VAR_spaces_secret_key="…" +cp terraform.tfvars.example terraform.tfvars # fill ssh_key_fingerprints, admin_ips +terraform init +terraform plan +``` + +| File | What | +|---|---| +| `versions.tf` | provider pin + (commented) Spaces remote backend | +| `variables.tf` | all inputs; GPU vars gated (account not GPU-allowlisted yet) | +| `network.tf` | VPC, project, firewall (WG+SSH only), reserved IP | +| `database.tf` | Managed PG (private VPC, trusted-sources = droplet only) | +| `spaces.tf` | private media bucket + deny-public policy + CDN | +| `droplet.tf` | backend droplet + optional GPU droplet | +| `cloud-init/backend.yaml` | backend bootstrap (WG self-keygen, ufw, pgBouncer) | +| `outputs.tf` | private PG URI, Spaces endpoint, WG address (sensitive) | + +**GPU note:** this account returns zero `gpu-*` sizes — DO gates GPU droplets +behind an access request. `gpu_enabled=false` until granted (hybrid inference +12b/12c). Serverless inference (12a) is unaffected. + +## Secrets + +None live in this tree. All under `~/.vault/` (0600). `.gitignore` blocks +`*.tfstate`, `*.tfvars`, `.terraform/`. diff --git a/terraform/do/cloud-init/backend.yaml b/terraform/do/cloud-init/backend.yaml new file mode 100644 index 0000000..be70ccb --- /dev/null +++ b/terraform/do/cloud-init/backend.yaml @@ -0,0 +1,55 @@ +#cloud-config +# Backend droplet bootstrap. Installs the mesh + bridge prerequisites only; +# it generates its OWN WireGuard keypair on first boot (no private key ever +# lives in the repo) and writes the public key to /root/wg1.pub for the +# operator to paste into the vps-0 hub's peer list. Service installs (Forgejo, +# Verdaccio, workers) are layered on afterward via the repo deploy scripts. + +package_update: true +package_upgrade: true + +packages: + - wireguard + - wireguard-tools + - pgbouncer + - git + - curl + - ufw + +write_files: + # WireGuard wg1 - joins the existing mesh. Hub pubkey + the [Peer] block are + # filled in by the operator after first boot (kept out of cloud-init so no + # key material is committed). PrivateKey is substituted in by the runcmd + # below from the locally generated key. + - path: /etc/wireguard/wg1.conf.tmpl + permissions: "0600" + owner: root:root + content: | + [Interface] + Address = ${wg_droplet_address} + ListenPort = ${wg_listen_port} + PrivateKey = __PRIVATE_KEY__ + + # [Peer] vps-0 hub - add after first boot: + # PublicKey = + # Endpoint = ${wg_hub_public_ip}:${wg_listen_port} + # AllowedIPs = 10.9.0.0/24 + # PersistentKeepalive = 25 + +runcmd: + # Generate this node's keypair and materialize wg1.conf from the template. + - umask 077; wg genkey | tee /etc/wireguard/wg1.key | wg pubkey > /root/wg1.pub + - sed "s#__PRIVATE_KEY__#$(cat /etc/wireguard/wg1.key)#" /etc/wireguard/wg1.conf.tmpl > /etc/wireguard/wg1.conf + - chmod 600 /etc/wireguard/wg1.conf + # Host firewall mirrors the DO cloud firewall: WG + SSH only, deny the rest. + - ufw --force reset + - ufw default deny incoming + - ufw default allow outgoing + - ufw allow 22/tcp + - ufw allow ${wg_listen_port}/udp + - ufw --force enable + # wg1 is enabled but stays down until the hub [Peer] block is added. + - systemctl enable wg-quick@wg1 || true + - echo "WG public key for hub peer list:" && cat /root/wg1.pub + +final_message: "backend bootstrap done. Add /root/wg1.pub to vps-0 hub, append the [Peer] block to /etc/wireguard/wg1.conf, then: systemctl start wg-quick@wg1" diff --git a/terraform/do/cloud-init/forge.yaml b/terraform/do/cloud-init/forge.yaml new file mode 100644 index 0000000..213309a --- /dev/null +++ b/terraform/do/cloud-init/forge.yaml @@ -0,0 +1,69 @@ +#cloud-config +# lilith-forge - replaces the dead forge.black.lan in one small box: +# - Forgejo (git canonical, the new `origin`) :3000 web, :2222 git-ssh +# - Verdaccio (@lilith/* npm registry) :4873 +# Both run as Docker containers with persistent volumes. Exposure is controlled +# by the DO cloud firewall (plum + vps-0 only); git operations use SSH (keys), +# so no plaintext-over-HTTP credential exposure. TLS via Caddy is a follow-up. + +package_update: true +packages: + - ca-certificates + - curl + - ufw + +write_files: + - path: /opt/forge/docker-compose.yml + permissions: "0644" + content: | + services: + forgejo: + image: codeberg.org/forgejo/forgejo:9 + container_name: forgejo + restart: always + environment: + - USER_UID=1000 + - USER_GID=1000 + - FORGEJO__server__DOMAIN=${forge_public_ip} + - FORGEJO__server__SSH_DOMAIN=${forge_public_ip} + - FORGEJO__server__SSH_PORT=2222 + - FORGEJO__server__ROOT_URL=http://${forge_public_ip}:3000/ + - FORGEJO__service__DISABLE_REGISTRATION=true + volumes: + - ./forgejo-data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3000:3000" + - "2222:22" + verdaccio: + image: verdaccio/verdaccio:6 + container_name: verdaccio + restart: always + volumes: + - ./verdaccio-storage:/verdaccio/storage + ports: + - "4873:4873" + +runcmd: + # Install Docker engine + compose plugin. + - install -m 0755 -d /etc/apt/keyrings + - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc + - chmod a+r /etc/apt/keyrings/docker.asc + - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list + - apt-get update + - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + # Host firewall: SSH(22) + Forgejo web(3000) + git-ssh(2222) + Verdaccio(4873). + # The DO cloud firewall already restricts source IPs; this is defense-in-depth. + - ufw --force reset + - ufw default deny incoming + - ufw default allow outgoing + - ufw allow 22/tcp + - ufw allow 2222/tcp + - ufw allow 3000/tcp + - ufw allow 4873/tcp + - ufw --force enable + # Bring up Forgejo + Verdaccio. + - cd /opt/forge && docker compose up -d + +final_message: "lilith-forge up. Forgejo http://${forge_public_ip}:3000 (complete first-run install + create admin), git-ssh on :2222, Verdaccio :4873." diff --git a/terraform/do/database.tf b/terraform/do/database.tf new file mode 100644 index 0000000..3573f1d --- /dev/null +++ b/terraform/do/database.tf @@ -0,0 +1,48 @@ +############################################################################### +# Managed Postgres — the canonical store that fixes the disaster. +# +# Black kept every backup on its own disk and died with them. DO Managed PG +# gives offsite daily backups + point-in-time recovery for free, and the +# cluster lives INSIDE the VPC with trusted-sources = the backend droplet only, +# so it never answers on the public internet. The edge (vps-0) reaches it as: +# vps-0 --(WireGuard)--> droplet pgBouncer --(VPC)--> this cluster +############################################################################### + +resource "digitalocean_database_cluster" "pg" { + name = "${var.project_name}-pg" + engine = "pg" + version = var.pg_version + size = var.pg_size + region = var.region + node_count = var.pg_node_count + + # Bind the cluster to the private VPC — no public-network reachability. + private_network_uuid = digitalocean_vpc.store.id + + tags = var.tags +} + +# Logical databases mirroring black's layout (quinn, quinn_admin). +resource "digitalocean_database_db" "dbs" { + for_each = toset(var.pg_databases) + + cluster_id = digitalocean_database_cluster.pg.id + name = each.value +} + +# Application role used by the backend services / pgBouncer bridge. +resource "digitalocean_database_user" "app" { + cluster_id = digitalocean_database_cluster.pg.id + name = "quinn_app" +} + +# Trusted sources = ONLY the backend droplet. This is the hard private boundary: +# nothing else — not the internet, not vps-0 directly — can open a PG connection. +resource "digitalocean_database_firewall" "pg" { + cluster_id = digitalocean_database_cluster.pg.id + + rule { + type = "droplet" + value = digitalocean_droplet.backend.id + } +} diff --git a/terraform/do/droplet.tf b/terraform/do/droplet.tf new file mode 100644 index 0000000..88a6be5 --- /dev/null +++ b/terraform/do/droplet.tf @@ -0,0 +1,88 @@ +############################################################################### +# Compute. +# +# backend — always-on. Forgejo (git) + Verdaccio (npm) + mac-sync + MCP +# servers + LISTEN/NOTIFY workers + the WG↔VPC pgBouncer bridge. +# Joins the wg1 mesh; no public app ports (see network.tf firewall). +# +# gpu — optional (hybrid inference, item 12b/12c). Runs OUR OWN model +# weights for adult-context drafting + the imajin adversarial +# image-protection job. Self-hosted = NO Content Safety Guardrails +# in the path (CSG only exists on DO's managed Inference). Default +# OFF so a routine apply never silently bills a GPU; flip +# gpu_enabled=true (or use the on-demand start/stop wrapper). +############################################################################### + +resource "digitalocean_droplet" "backend" { + name = "${var.project_name}-backend" + image = var.droplet_image + size = var.droplet_size + region = var.region + vpc_uuid = digitalocean_vpc.store.id + ssh_keys = var.ssh_key_fingerprints + tags = var.tags + + user_data = templatefile("${path.module}/cloud-init/backend.yaml", { + wg_listen_port = var.wg_listen_port + wg_droplet_address = var.wg_droplet_address + wg_hub_public_ip = var.wg_hub_public_ip + }) + + lifecycle { + # WG keys / installed state live on disk; never let a user_data tweak + # trigger a silent rebuild that wipes Forgejo + Verdaccio data. + ignore_changes = [user_data] + } +} + +resource "digitalocean_droplet" "gpu" { + count = var.gpu_enabled ? 1 : 0 + + name = "${var.project_name}-gpu" + image = var.gpu_image + size = var.gpu_size + region = var.gpu_region + vpc_uuid = digitalocean_vpc.store.id + ssh_keys = var.ssh_key_fingerprints + tags = concat(var.tags, ["gpu", "inference"]) + + user_data = templatefile("${path.module}/cloud-init/backend.yaml", { + wg_listen_port = var.wg_listen_port + wg_droplet_address = var.wg_gpu_address + wg_hub_public_ip = var.wg_hub_public_ip + }) + + lifecycle { + ignore_changes = [user_data] + } +} + +# GPU droplets are not offered in every region; allow an independent override. +resource "digitalocean_firewall" "gpu" { + count = var.gpu_enabled ? 1 : 0 + + name = "${var.project_name}-gpu-fw" + droplet_ids = [digitalocean_droplet.gpu[0].id] + + inbound_rule { + protocol = "udp" + port_range = tostring(var.wg_listen_port) + source_addresses = ["${var.wg_hub_public_ip}/32"] + } + inbound_rule { + protocol = "tcp" + port_range = "22" + source_addresses = var.admin_ips + } + + outbound_rule { + protocol = "tcp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + outbound_rule { + protocol = "udp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } +} diff --git a/terraform/do/network.tf b/terraform/do/network.tf new file mode 100644 index 0000000..219f8bf --- /dev/null +++ b/terraform/do/network.tf @@ -0,0 +1,76 @@ +############################################################################### +# Private network + project grouping + firewall. +# +# Boundary contract: the backend droplet exposes ZERO public app ports. The +# only inbound from the internet is WireGuard (UDP) from the mesh hub and SSH +# from a locked admin allowlist for bootstrap. Everything else reaches the +# droplet over wg1. This is what keeps DO "store, never serve". +############################################################################### + +resource "digitalocean_vpc" "store" { + name = "${var.project_name}-vpc" + region = var.region + ip_range = var.vpc_ip_range +} + +resource "digitalocean_project" "store" { + name = var.project_name + description = "Canonical store/backend tier for lilith-platform (replaces dead homelan 'black')." + purpose = "Web Application" + environment = "Production" + + resources = [ + digitalocean_droplet.backend.urn, + digitalocean_database_cluster.pg.urn, + digitalocean_spaces_bucket.media.urn, + ] +} + +resource "digitalocean_firewall" "backend" { + name = "${var.project_name}-backend-fw" + droplet_ids = [digitalocean_droplet.backend.id] + tags = var.tags + + # WireGuard handshake from the mesh hub (vps-0). After the tunnel is up all + # service traffic rides inside it; no app port is ever publicly exposed. + inbound_rule { + protocol = "udp" + port_range = tostring(var.wg_listen_port) + source_addresses = ["${var.wg_hub_public_ip}/32"] + } + + # SSH for bootstrap only — locked to the admin allowlist, not the world. + inbound_rule { + protocol = "tcp" + port_range = "22" + source_addresses = var.admin_ips + } + + # ICMP from the mesh hub for reachability checks. + inbound_rule { + protocol = "icmp" + source_addresses = ["${var.wg_hub_public_ip}/32"] + } + + # Egress: allow all (provider pulls, ACME, Spaces, DO Managed PG via VPC). + outbound_rule { + protocol = "tcp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + outbound_rule { + protocol = "udp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + outbound_rule { + protocol = "icmp" + destination_addresses = ["0.0.0.0/0", "::/0"] + } +} + +# Stable public IP for the droplet's WG endpoint (survives rebuilds/resizes). +resource "digitalocean_reserved_ip" "backend" { + droplet_id = digitalocean_droplet.backend.id + region = var.region +} diff --git a/terraform/do/outputs.tf b/terraform/do/outputs.tf new file mode 100644 index 0000000..598795f --- /dev/null +++ b/terraform/do/outputs.tf @@ -0,0 +1,53 @@ +############################################################################### +# Outputs consumed by the de-black repoint (Phase 3) and by humans wiring the +# WireGuard peer + secrets. Connection strings are sensitive. +############################################################################### + +output "backend_public_ip" { + description = "Reserved public IP of the backend droplet (WireGuard endpoint)." + value = digitalocean_reserved_ip.backend.ip_address +} + +output "backend_private_ip" { + description = "VPC-private IP of the backend droplet (path to Managed PG)." + value = digitalocean_droplet.backend.ipv4_address_private +} + +output "backend_wg_address" { + description = "Mesh (wg1) address to add to mesh-hosts.json for the new peer." + value = var.wg_droplet_address +} + +output "pg_host" { + description = "Managed PG private host (reachable only from the droplet over the VPC)." + value = digitalocean_database_cluster.pg.private_host +} + +output "pg_port" { + value = digitalocean_database_cluster.pg.port +} + +output "pg_uri_private" { + description = "Full private connection URI. Wire into the droplet pgBouncer; the edge reaches PG through it over WG." + value = digitalocean_database_cluster.pg.private_uri + sensitive = true +} + +output "pg_databases" { + value = [for db in digitalocean_database_db.dbs : db.name] +} + +output "spaces_bucket_endpoint" { + description = "S3 endpoint for the media bucket (services use signed URLs)." + value = digitalocean_spaces_bucket.media.bucket_domain_name +} + +output "spaces_cdn_endpoint" { + description = "CDN origin vps-0 edge-caches /photos from." + value = var.enable_cdn ? digitalocean_cdn.media[0].endpoint : null +} + +output "gpu_droplet_ip" { + description = "GPU droplet private IP (null unless gpu_enabled and account allowlisted)." + value = var.gpu_enabled ? digitalocean_droplet.gpu[0].ipv4_address_private : null +} diff --git a/terraform/do/spaces.tf b/terraform/do/spaces.tf new file mode 100644 index 0000000..9a1438d --- /dev/null +++ b/terraform/do/spaces.tf @@ -0,0 +1,56 @@ +############################################################################### +# Object storage — photo originals + protected downloads. +# +# Replaces black's photos-origin nginx (:8081) + MinIO (:9000). Bucket is +# PRIVATE; the public never lists or hits it directly. vps-0 edge-caches +# /photos/* from the CDN origin, and protected downloads are handed out as +# short-lived signed URLs. Originals are never publicly browsable. +############################################################################### + +resource "digitalocean_spaces_bucket" "media" { + name = var.spaces_bucket_name + region = var.region + acl = "private" + + # Keep noncurrent versions briefly so a bad sync can be rolled back, but + # don't accumulate cost unbounded. + lifecycle_rule { + enabled = true + id = "expire-noncurrent" + + noncurrent_version_expiration { + days = 30 + } + } +} + +# Block any accidental public-read grant at the bucket-policy layer too. +resource "digitalocean_spaces_bucket_policy" "media" { + region = digitalocean_spaces_bucket.media.region + bucket = digitalocean_spaces_bucket.media.name + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "DenyPublicListAndGet" + Effect = "Deny" + Principal = "*" + Action = ["s3:ListBucket", "s3:GetObject"] + Resource = [ + "arn:aws:s3:::${var.spaces_bucket_name}", + "arn:aws:s3:::${var.spaces_bucket_name}/*", + ] + Condition = { + Bool = { "aws:SecureTransport" = "false" } + } + }] + }) +} + +# CDN origin for /photos/* — vps-0 nginx caches from this edge. +resource "digitalocean_cdn" "media" { + count = var.enable_cdn ? 1 : 0 + + origin = digitalocean_spaces_bucket.media.bucket_domain_name + ttl = 86400 +} diff --git a/terraform/do/terraform.tfvars.example b/terraform/do/terraform.tfvars.example new file mode 100644 index 0000000..f20a077 --- /dev/null +++ b/terraform/do/terraform.tfvars.example @@ -0,0 +1,42 @@ +# Copy to terraform.tfvars (gitignored) and fill in. Prefer TF_VAR_* env vars +# for the three secrets so they never touch disk in the repo tree. +# +# export TF_VAR_do_token="$(cat ~/do_pat_ct)" +# export TF_VAR_spaces_access_id="..." +# export TF_VAR_spaces_secret_key="..." + +# do_token = "dop_v1_..." # or via TF_VAR_do_token +# spaces_access_id = "..." # Spaces key (API → Spaces Keys) +# spaces_secret_key = "..." + +region = "nyc3" +project_name = "lilith-store" + +# SSH key fingerprints registered in DO (Settings → Security). plum + CI. +ssh_key_fingerprints = [ + # "aa:bb:cc:...", +] + +# Lock SSH to bootstrap sources (vps-0 + your current IP). Never 0.0.0.0/0. +admin_ips = [ + "89.127.233.145/32", # vps-0 + # "/32", +] + +# Managed Postgres +pg_version = "16" +pg_size = "db-s-1vcpu-2gb" +pg_node_count = 1 # set 2 for a standby (failover black never had) +pg_databases = ["quinn", "quinn_admin"] + +# Backend droplet +droplet_size = "s-2vcpu-4gb" + +# Spaces +spaces_bucket_name = "lilith-quinn-media" +enable_cdn = true + +# GPU (hybrid inference 12b/12c) — leave false until DO grants GPU access. +gpu_enabled = false +# gpu_size = "gpu-h100x1-80gb" +# gpu_region = "nyc2" diff --git a/terraform/do/variables.tf b/terraform/do/variables.tf new file mode 100644 index 0000000..afb98b2 --- /dev/null +++ b/terraform/do/variables.tf @@ -0,0 +1,190 @@ +############################################################################### +# Credentials (never commit values — pass via TF_VAR_* env or a gitignored +# terraform.tfvars). See terraform.tfvars.example. +############################################################################### + +variable "do_token" { + description = "DigitalOcean API token (read/write)." + type = string + sensitive = true +} + +variable "spaces_access_id" { + description = "DigitalOcean Spaces access key ID (S3-compatible). Required only at apply (Spaces resources); empty default lets validate/plan run without it." + type = string + sensitive = true + default = "" +} + +variable "spaces_secret_key" { + description = "DigitalOcean Spaces secret access key. Required only at apply; empty default lets validate/plan run." + type = string + sensitive = true + default = "" +} + +############################################################################### +# Placement +############################################################################### + +variable "region" { + description = "DO region. nyc3 co-locates Droplets + Managed PG + Spaces (operator is in NYC)." + type = string + default = "nyc3" + + validation { + condition = contains(["nyc3", "fra1", "ams3"], var.region) + error_message = "Use nyc3 (NYC, operator-local) or an EEA region (fra1/ams3) if GDPR residency wins out." + } +} + +variable "project_name" { + description = "DO project that groups all rebuild resources." + type = string + default = "lilith-store" +} + +variable "vpc_ip_range" { + description = "Private VPC CIDR for the DO store tier (kept off the WG 10.9.0.0/24 range)." + type = string + default = "10.20.0.0/24" +} + +############################################################################### +# Network access control +############################################################################### + +variable "wg_hub_public_ip" { + description = "Public IP of the WireGuard hub (vps-0 / yuzu) that the droplet peers with." + type = string + default = "89.127.233.145" +} + +variable "admin_ips" { + description = "CIDRs allowed to SSH the backend droplet for bootstrap (before WG is up). Lock to vps-0 + your current IP; do not leave 0.0.0.0/0." + type = list(string) + default = ["89.127.233.145/32"] +} + +variable "wg_listen_port" { + description = "WireGuard UDP port the droplet listens on (matches mesh wg1)." + type = number + default = 51820 +} + +############################################################################### +# Managed Postgres +############################################################################### + +variable "pg_version" { + description = "Managed Postgres major version." + type = string + default = "16" +} + +variable "pg_size" { + description = "Managed PG node size slug." + type = string + default = "db-s-1vcpu-2gb" +} + +variable "pg_node_count" { + description = "PG nodes (1 = primary only; 2 adds a standby for failover — the thing black never had)." + type = number + default = 1 +} + +variable "pg_databases" { + description = "Logical databases to create on the cluster (mirrors black's quinn + quinn_admin)." + type = list(string) + default = ["quinn", "quinn_admin"] +} + +############################################################################### +# Backend droplet +############################################################################### + +variable "droplet_size" { + description = "Backend droplet size (Forgejo + Verdaccio + workers + MCP + mac-sync + WG↔VPC pgBouncer bridge)." + type = string + default = "s-2vcpu-4gb" +} + +variable "droplet_image" { + description = "Base image for the backend droplet." + type = string + default = "ubuntu-24-04-x64" +} + +variable "ssh_key_fingerprints" { + description = "MD5 fingerprints of DO-registered SSH keys authorized on the droplet (plum + CI)." + type = list(string) +} + +variable "wg_droplet_address" { + description = "Mesh (wg1) address assigned to the backend droplet peer." + type = string + default = "10.9.0.5/32" +} + +############################################################################### +# Spaces (object storage) +############################################################################### + +variable "spaces_bucket_name" { + description = "Globally-unique Spaces bucket for photo originals + protected downloads." + type = string + default = "lilith-quinn-media" +} + +variable "enable_cdn" { + description = "Front the Spaces bucket with the DO CDN (vps-0 edge-caches /photos from it)." + type = bool + default = true +} + +variable "tags" { + description = "Tags applied to taggable resources." + type = list(string) + default = ["lilith", "store-tier", "managed-by-terraform"] +} + +############################################################################### +# GPU (hybrid inference, item 12b/12c) — adult-context drafting + imajin. +# +# NOTE: as of account check 2026-06-27 this DO account is NOT allowlisted for +# GPU Droplets (the /v2/sizes endpoint returns zero gpu-* slugs). DO gates GPU +# access behind a request. Keep gpu_enabled=false until access is granted, or +# `terraform apply` will fail at droplet create. The serverless side (12a) does +# not depend on this. +############################################################################### + +variable "gpu_enabled" { + description = "Create the self-hosted GPU droplet. Requires DO GPU-droplet access on the account." + type = bool + default = false +} + +variable "gpu_size" { + description = "GPU droplet size slug (only valid once allowlisted, e.g. gpu-h100x1-80gb)." + type = string + default = "gpu-h100x1-80gb" +} + +variable "gpu_image" { + description = "GPU droplet image (DO AI/ML images ship CUDA drivers)." + type = string + default = "gpu-h100x1-base" +} + +variable "gpu_region" { + description = "GPU droplet region (GPU availability is region-limited; often not nyc3)." + type = string + default = "nyc2" +} + +variable "wg_gpu_address" { + description = "Mesh (wg1) address for the GPU droplet peer." + type = string + default = "10.9.0.6/32" +} diff --git a/terraform/do/versions.tf b/terraform/do/versions.tf new file mode 100644 index 0000000..53664f6 --- /dev/null +++ b/terraform/do/versions.tf @@ -0,0 +1,33 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2.43" + } + } + + # State lives locally for the initial stand-up. Once the Spaces bucket exists + # (spaces.tf), migrate to an S3-compatible remote backend pointed at it: + # + # backend "s3" { + # endpoints = { s3 = "https://nyc3.digitaloceanspaces.com" } + # bucket = "lilith-tfstate" + # key = "do/terraform.tfstate" + # region = "us-east-1" # ignored by Spaces, required by the s3 backend + # skip_credentials_validation = true + # skip_metadata_api_check = true + # skip_region_validation = true + # skip_requesting_account_id = true + # skip_s3_checksum = true + # } +} + +provider "digitalocean" { + token = var.do_token + + # Spaces uses S3-style credentials, distinct from the API token. + spaces_access_id = var.spaces_access_id + spaces_secret_key = var.spaces_secret_key +}