# cube-mcp Terraform environments

cube-mcp runs two environments — [`dev`](dev/README.md) and [`prod`](prod/README.md) —
off the **single, flat root module** in the parent directory
([`infra/terraform/`](..)). This `envs/` directory is **documentation, not deployable
root configs**: it records what each environment is, its per-environment resource
registry, and how the stack diverges from the org
[Terraform conventions](https://github.com/EdgescaleAI/edgescaleai-terraform/blob/main/CONVENTIONS.md).

> **Read this first.** cube-mcp does *not* follow the conventional
> `envs/<env>/ + modules/` layout. The `.tf` files one level up
> ([`main.tf`](../main.tf), [`ecs.tf`](../ecs.tf), …) are the *whole* stack;
> an environment is selected at `init`/`apply` time by pointing the S3 backend
> at that env's state key and feeding it that env's `*.tfvars`. The
> `envs/dev/` and `envs/prod/` directories here hold only a `README.md` each.

## How an environment is actually selected

There is one set of `.tf` files. `terraform init` picks the state key and
`terraform apply` picks the variable values:

```bash
# dev
terraform init  -backend-config="key=cube-cloud-dev/terraform.tfstate"
terraform apply -var-file=environments/dev.tfvars

# prod
terraform init  -backend-config="key=cube-cloud/terraform.tfstate"
terraform apply -var-file=environments/prod.tfvars
```

In practice operators never run this by hand — GitHub Actions does it, but the
two pipelines are asymmetric. `deploy-dev.yml` (push to `dev-main`) passes the
dev state key **and** tfvars explicitly
(`terraform init -backend-config="key=…" && terraform apply -var-file=environments/dev.tfvars`).
`deploy.yml` (push to `main`) runs a **bare** `terraform init` — no
`-backend-config`, no `TF_VAR_*` — so prod's *state key* is selected only because
the baked-in default in [`main.tf`](../main.tf) is prod. It **does** pass
`-var-file=environments/prod.tfvars` on both plan and apply
([`deploy.yml:59`](../../../.github/workflows/deploy.yml) and `:116`), wired in so
the telemetry flags would take effect. So `prod.tfvars` edits — `console_enabled`,
the telemetry flags — **do** reach the CI apply; only the backend key is implicit.
(This paragraph previously claimed the opposite; it predated that wiring.) See the repo
[README → Deploy pipelines](../../../README.md#operations--ownership).

## What is per-environment vs. shared

Both environments live in **one AWS account (`992382448282`, `us-west-2`)**.
There is no account-level isolation between them — separation is by resource
name only, and only for the resources that carry the name prefix.

| Scope | Naming | Resources |
|---|---|---|
| **Per-environment** | `${local.name_prefix}` = `cube-mcp-<env>` | ECS cluster/service, ALB + listener, DynamoDB (`-api-keys`, `-login-sessions`), Cognito user pool, ECS task IAM role, CloudWatch log group |
| **Shared across both envs** | fixed `cube-mcp/…` or account-level (no env in the name) | ECR repos (`cube-mcp/cube-cloud`, `cube-mcp/tbot`), profile secrets (`cube-mcp/profiles/*`), Groundcover API key (`cube-mcp/groundcover/api-key`), tbot config secret (`cube-mcp/tbot-config` — the `tbot_config_secret` **variable** overrides to `cube-mcp-dev/tbot-config` in dev, but the secret *resource* the stack creates is the fixed unprefixed name), SES sender identity (`edgescaleai-cube.com` — referenced, not Terraform-managed here: no `aws_ses_*` resource exists in this configuration; it is consumed only via `source_arn`/`from_email_address` in [`cognito.tf`](../cognito.tf)) |

The shared ECR repositories are a documented hazard: a June 2026 prod outage
traced to dev/test image churn evicting the prod `latest` image from the
shared repo, which is why [`ecr.tf`](../ecr.tf) now reaps only *untagged*
images. See [prod/README.md](prod/README.md#shared-resource-hazards).

## Divergences from the org conventions — summary

Full detail (with the "why" and the convention each one breaks) is in each
env's README. The headline gaps:

| # | Convention | cube-mcp today | Where |
|---|---|---|---|
| 1 | Embedded TF lives under top-level `terraform/` | Nested under `infra/terraform/` | repo layout |
| 2 | Thin per-env root configs in `envs/` calling shared `modules/` | One flat root module; envs selected via `-backend-config` + `-var-file` | whole stack |
| 3 | **One environment per AWS account** (`esai-prod` / `esai-dev`); account isolation is the safety boundary | Both envs in the **same** account `992382448282`; separation by name prefix only | [main.tf](../main.tf) |
| 4 | State in the org backend buckets (`edgescaleai-terraform-state` / `esai-dev-terraform-state`) | Repo-private bucket `cube-mcp-terraform-state`, one bucket for both envs | [main.tf](../main.tf) |
| 5 | State `key` prefixed with the repo name (`cube-mcp/prod.tfstate`) | Legacy `cube-cloud/…` prefix (`cube-cloud/terraform.tfstate`, `cube-cloud-dev/…`) | [main.tf](../main.tf) |
| 6 | Backend + `profile` baked into a committed `versions.tf` per env | Backend in `main.tf`, `key` supplied at init via `-backend-config`; no baked `profile` | [main.tf](../main.tf) |
| 7 | `encrypt = true` + `use_lockfile = true`; `required_version >= 1.10.0` | Backend has neither; `required_version = ">= 1.5"` (no S3-native locking) | [main.tf](../main.tf) |
| 8 | Split `providers.tf` / `versions.tf` | Provider and `terraform{}` both in `main.tf` | [main.tf](../main.tf) |
| 9 | Commit `.terraform.lock.hcl` so every operator/CI run pins the same provider versions | Excluded by [`.gitignore`](../../../.gitignore) — the same gap `terraform-bootstrap` and `hdt-pipeline` carry (`edgescale-inventory` commits it) | [.gitignore](../../../.gitignore) |

**Already aligned:** environment naming is `dev`/`prod` (the SCRUM-967 target —
ahead of the reference repos still on `main`/`dev-testing`); resources are split
one-file-per-concern; the AWS provider is pinned `~> 5.0`; and per-env
divergence flows through `*.tfvars` inputs rather than forked code.
