Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions infra/terraform/modules/rds/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Terraform `rds` Module

Provisions an Aurora PostgreSQL cluster with its own subnet group and security
group, ready for the EziAgric backend to connect to.

## Overview

The module creates a single Aurora cluster plus a configurable number of
instances, places them in the private subnets of the [`vpc`](../vpc/README.md)
module, and locks network access down with a dedicated security group that only
admits the configured CIDR blocks on the database port.

The database is the system of record for off-chain trade metadata. See
[`docs/data-model-relationships.md`](../../../../docs/data-model-relationships.md)
for the schema context and
[`docs/pii-encryption.md`](../../../../docs/pii-encryption.md) for how PII is
protected at rest.

## Resources

| Resource | Type | Purpose |
|---|---|---|
| `aws_db_subnet_group.this` | `aws_db_subnet_group` | Places the cluster across the supplied private subnets |
| `aws_security_group.this` | `aws_security_group` | Ingress on `port` from `allowed_cidr_blocks`; unrestricted egress. Uses `create_before_destroy` so replacements do not drop traffic |
| `aws_rds_cluster.this` | `aws_rds_cluster` | Aurora cluster: engine, credentials, backup retention and window, subnet group, security group, deletion protection |
| `aws_rds_cluster_instance.this` | `aws_rds_cluster_instance` | `replica_count` instances in the cluster, identified `<project>-rds-instance-<n>` |

Every resource is tagged `Name` and `Environment`.

## Inputs

| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| `project_name` | `string` | — | yes | Project name. Used as the resource name prefix and the `Name` tag. |
| `environment` | `string` | — | yes | Environment name (e.g. `dev`, `staging`). Applied as the `Environment` tag. |
| `vpc_id` | `string` | — | yes | VPC ID in which the security group is created. Use `module.vpc.vpc_id`. |
| `subnet_ids` | `list(string)` | — | yes | Subnet IDs for the DB subnet group. Use `module.vpc.private_subnet_ids`. |
| `allowed_cidr_blocks` | `list(string)` | `[]` | no | CIDR blocks allowed to reach the database on `port`. **Empty means no inbound access at all.** |
| `engine` | `string` | `"aurora-postgresql"` | no | Database engine. |
| `engine_version` | `string` | `"15.4"` | no | Engine version. |
| `instance_class` | `string` | `"db.t3.medium"` | no | Instance class for every cluster instance. |
| `database_name` | `string` | — | yes | Name of the initial database created in the cluster. |
| `master_username` | `string` | — | yes | Master username. Marked sensitive in the calling environment. |
| `master_password` | `string` | — | yes | Master password. **Sensitive** — never commit it to a `*.tfvars` file. |
| `port` | `number` | `5432` | no | Database port; also the port opened in the security group. |
| `replica_count` | `number` | `1` | no | Number of instances in the cluster. `1` = writer only, `2` = writer + one reader. |
| `backup_retention_period` | `number` | `7` | no | Automated backup retention in days. |
| `preferred_backup_window` | `string` | `"03:00-04:00"` | no | Preferred daily backup window (UTC). |
| `skip_final_snapshot` | `bool` | `false` | no | Skip the final snapshot when the cluster is destroyed. |
| `deletion_protection` | `bool` | `true` | no | Block deletion of the cluster while enabled. |

## Outputs

| Name | Type | Description |
|---|---|---|
| `cluster_endpoint` | `string` | Writer endpoint — use this for application connections. |
| `cluster_reader_endpoint` | `string` | Reader endpoint — use this for read-only queries and reporting. |
| `cluster_id` | `string` | ID of the Aurora cluster. |
| `instance_ids` | `list(string)` | IDs of the cluster instances. |

## Usage

```hcl
module "rds" {
source = "../../modules/rds"

project_name = "amana"
environment = "dev"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
allowed_cidr_blocks = [module.vpc.vpc_cidr]
engine = "aurora-postgresql"
engine_version = "15.4"
instance_class = "db.t3.medium"
database_name = "amana_dev"
master_username = var.db_master_username
master_password = var.db_master_password
port = 5432
replica_count = 1
backup_retention_period = 7
preferred_backup_window = "03:00-04:00"
skip_final_snapshot = true
deletion_protection = false
}
```

A live example lives in
[`infra/terraform/environments/dev/main.tf`](../../environments/dev/main.tf);
staging uses the same block with larger instances, a 30-day backup window, and
deletion protection enabled.

Point the backend at the cluster with the writer endpoint:

```
postgresql://<master_username>:<master_password>@<cluster_endpoint>:5432/<database_name>
```

## Operational notes

- **`allowed_cidr_blocks` defaults to `[]`.** With an empty list the security
group has no inbound rule at all and nothing — not even the EKS node group —
can reach the database. Set it explicitly (the environments use
`[module.vpc.vpc_cidr]`).
- **The master password lands in Terraform state.** The password is stored on
the cluster resource, so the state file must stay encrypted. The environment
backends use an S3 bucket with `encrypt = true` and a DynamoDB lock table.
Prefer injecting the value via `TF_VAR_db_master_password` from a secret
store over a committed `*.tfvars` file.
- **`deletion_protection` defaults to `true`.** Set it to `false` (and let the
change apply) before destroying the cluster, otherwise the destroy fails.
- **`skip_final_snapshot` should stay `false` outside dev.** The dev
environment sets it to `true` so that tearing down the stack does not leave
paid-for snapshots behind.
- **Aurora readers scale out, they do not replace the writer.** `replica_count`
counts cluster instances; the cluster always keeps one writer. Use
`cluster_reader_endpoint` for analytics so reporting does not contend with
trade traffic.
- **Backups are managed by the cluster**, not by the database. Restore
procedures are in
[`docs/runbooks/database-migration.md`](../../../../docs/runbooks/database-migration.md).
- **The security group allows all egress** (`0.0.0.0/0`). Egress is restricted
at the network-policy layer in the Kubernetes manifests, not here.

## Related

- [`../vpc/README.md`](../vpc/README.md) — provides `vpc_id` and
`private_subnet_ids`
- [`infra/terraform/environments/dev/main.tf`](../../environments/dev/main.tf) —
dev wiring
- [`docs/secrets-policy.md`](../../../../docs/secrets-policy.md) — where the
database credentials are expected to come from
- [`docs/data-model-relationships.md`](../../../../docs/data-model-relationships.md) —
what lives in this database
- [`docs/runbooks/database-migration.md`](../../../../docs/runbooks/database-migration.md) —
migration and restore procedures
140 changes: 140 additions & 0 deletions infra/terraform/modules/redis/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Terraform `redis` Module

Provisions an ElastiCache for Redis replication group — a Multi-AZ, encrypted,
password-protected cache cluster — together with its own subnet group and
security group.

## Overview

Redis backs the backend's queues, distributed locks, cache, and rate limiting.
This module creates the managed cluster that those consumers talk to, placed in
the private subnets of the [`vpc`](../vpc/README.md) module and reachable only
from the CIDR blocks in `allowed_cidr_blocks`.

The expected behaviour of every consumer during a Redis outage is specified in
[`docs/redis-resilience.md`](../../../../docs/redis-resilience.md), and the
maintenance-window procedure lives in
[`docs/runbooks/redis-maintenance.md`](../../../../docs/runbooks/redis-maintenance.md).

## Resources

| Resource | Type | Purpose |
|---|---|---|
| `aws_security_group.this` | `aws_security_group` | Ingress on `port` from `allowed_cidr_blocks`; unrestricted egress. Uses `create_before_destroy` so replacements do not drop traffic |
| `aws_elasticache_subnet_group.this` | `aws_elasticache_subnet_group` | Places the replication group across the supplied private subnets |
| `aws_elasticache_replication_group.this` | `aws_elasticache_replication_group` | The Redis replication group: node type, cluster count, engine, parameter group, encryption, auth token, and snapshot settings |

Every resource is tagged `Name` and `Environment`.

## Inputs

| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| `project_name` | `string` | — | yes | Project name. Used as the replication group ID and the `Name` tag. |
| `environment` | `string` | — | yes | Environment name (e.g. `dev`, `staging`). Applied as the `Environment` tag. |
| `vpc_id` | `string` | — | yes | VPC ID in which the security group is created. Use `module.vpc.vpc_id`. |
| `subnet_ids` | `list(string)` | — | yes | Subnet IDs for the cache subnet group. Use `module.vpc.private_subnet_ids`. |
| `allowed_cidr_blocks` | `list(string)` | `[]` | no | CIDR blocks allowed to reach Redis on `port`. **Empty means no inbound access at all.** |
| `engine` | `string` | `"redis"` | no | Cache engine. |
| `engine_version` | `string` | `"7.0"` | no | Engine version. |
| `node_type` | `string` | `"cache.t3.medium"` | no | Cache node type for every node in the group. |
| `port` | `number` | `6379` | no | Redis port; also the port opened in the security group. |
| `replica_count` | `number` | `2` | no | Number of cache clusters (`number_cache_clusters`). **Includes the primary** — the default of `2` is one primary plus one replica. |
| `parameter_group_name` | `string` | `"default.redis7"` | no | Parameter group applied to the group. Must already exist. |
| `automatic_failover_enabled` | `bool` | `true` | no | ElastiCache promotes a replica automatically if the primary fails. |
| `multi_az_enabled` | `bool` | `true` | no | Spread the nodes across availability zones. |
| `at_rest_encryption_enabled` | `bool` | `true` | no | Encrypt the data on disk and the snapshots. |
| `transit_encryption_enabled` | `bool` | `true` | no | Encrypt client ↔ Redis traffic (TLS). |
| `auth_token` | `string` | — | yes | Password (16–128 printable characters) clients must present. **Sensitive** — never commit it. |
| `snapshot_retention_limit` | `number` | `5` | no | Number of days snapshots are retained. `0` disables snapshots. |
| `snapshot_window` | `string` | `"02:00-03:00"` | no | Daily snapshot window (UTC). |

## Outputs

| Name | Type | Description |
|---|---|---|
| `primary_endpoint` | `string` | Primary (writer) endpoint — use this for application connections. |
| `reader_endpoint` | `string` | Reader endpoint for read-only clients. |
| `replication_group_id` | `string` | ID of the replication group. |
| `port` | `number` | Port the group is listening on. |

## Usage

```hcl
module "redis" {
source = "../../modules/redis"

project_name = "amana"
environment = "dev"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
allowed_cidr_blocks = [module.vpc.vpc_cidr]
engine = "redis"
engine_version = "7.0"
node_type = "cache.t3.medium"
port = 6379
replica_count = 2
parameter_group_name = "default.redis7"
automatic_failover_enabled = true
multi_az_enabled = true
at_rest_encryption_enabled = true
transit_encryption_enabled = true
auth_token = var.redis_auth_token
snapshot_retention_limit = 5
snapshot_window = "02:00-03:00"
}
```

A live example lives in
[`infra/terraform/environments/dev/main.tf`](../../environments/dev/main.tf);
staging uses the same block with larger nodes, three cache clusters, and a
14-day snapshot window.

Point the backend at the group with the primary endpoint, carrying the auth
token as the URL password and using `rediss://` because transit encryption is
on:

```
rediss://:<auth_token>@<primary_endpoint>:6379
```

The backend reads this from `REDIS_URL`
(see [`backend/src/config/env.ts`](../../../../backend/src/config/env.ts)).

## Operational notes

- **`replica_count` includes the primary.** It is passed straight to
`number_cache_clusters`, so the default `2` means one primary plus one
replica — not two replicas.
- **Failover and Multi-AZ need at least two cache clusters.** AWS rejects
`automatic_failover_enabled = true` or `multi_az_enabled = true` on a group
with a single node. Keep `replica_count >= 2` when either flag is on.
- **`allowed_cidr_blocks` defaults to `[]`.** With an empty list the security
group has no inbound rule and no client can connect. Set it explicitly (the
environments use `[module.vpc.vpc_cidr]`).
- **`transit_encryption_enabled = true` requires a TLS client.** The backend
connects with `ioredis`, so `REDIS_URL` must use the `rediss://` scheme — a
plain `redis://` URL will fail the handshake against an encrypted group.
- **`auth_token` is required and sensitive.** The token is stored in Terraform
state, so the state file must stay encrypted. The AWS provider also rejects
tokens shorter than 16 characters or containing `/`, `"`, or `@`.
- **The auth token cannot be rotated in place** on a replication group —
changing it replaces the group, which is a disruptive operation. Plan
maintenance accordingly; the runbook covers the procedure.
- **Snapshots are enabled by default** (`snapshot_retention_limit = 5`). Setting
it to `0` disables snapshots entirely and removes the last restore point.
- **The security group allows all egress** (`0.0.0.0/0`). Egress is restricted
at the network-policy layer in the Kubernetes manifests, not here.

## Related

- [`../vpc/README.md`](../vpc/README.md) — provides `vpc_id` and
`private_subnet_ids`
- [`infra/terraform/environments/dev/main.tf`](../../environments/dev/main.tf) —
dev wiring
- [`docs/redis-resilience.md`](../../../../docs/redis-resilience.md) — expected
behaviour per consumer when Redis is unavailable
- [`docs/runbooks/redis-maintenance.md`](../../../../docs/runbooks/redis-maintenance.md) —
maintenance-window procedure that guarantees no payout risk
- [`docs/idempotency-redis-roundtrips.md`](../../../../docs/idempotency-redis-roundtrips.md) —
how the idempotency middleware uses this cluster
113 changes: 113 additions & 0 deletions infra/terraform/modules/secrets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Terraform `secrets` Module

Creates the AWS Secrets Manager container that holds `ADMIN_SECRET_KEY` — the
Stellar Soroban signing key used by backend admin operations — and attaches the
IAM policy that lets the EKS node role read it.

## Overview

The module is built around a hard rule: **the secret value is never written to
Terraform state, Git, Kubernetes manifests, or logs.** Terraform creates the
secret container with a placeholder version; the real value is injected out of
band with the AWS CLI immediately after the first apply.

The full provisioning, rotation, and troubleshooting procedure is documented in
[`docs/admin-secret-management.md`](../../../../docs/admin-secret-management.md),
and the wider inventory in
[`docs/secrets-policy.md`](../../../../docs/secrets-policy.md).

## Resources

| Resource | Type | Purpose |
|---|---|---|
| `aws_secretsmanager_secret.admin_secret_key` | `aws_secretsmanager_secret` | Secret container named `<project>-<environment>-admin-secret-key` |
| `aws_secretsmanager_secret_version.admin_secret_key` | `aws_secretsmanager_secret_version` | Initial placeholder version (`REPLACE_WITH_ADMIN_SECRET_KEY`) with `ignore_changes = [secret_string]`, so Terraform never overwrites the injected value |
| `aws_iam_policy_document.admin_secret_key_read` (data) | `aws_iam_policy_document` | Policy document allowing `secretsmanager:GetSecretValue` and `secretsmanager:DescribeSecret` on this secret only |
| `aws_iam_policy.admin_secret_key_read` | `aws_iam_policy` | Customer-managed IAM policy carrying that document |
| `aws_iam_role_policy_attachment.admin_secret_key_read` | `aws_iam_role_policy_attachment` | Attaches the policy to the EKS node role |

## Inputs

| Name | Type | Default | Required | Description |
|---|---|---|---|---|
| `project_name` | `string` | — | yes | Project name. Used as the name prefix of the secret and the IAM policy. |
| `environment` | `string` | — | yes | Environment name (e.g. `dev`, `staging`). Part of the secret name and the `Environment` tag. |
| `eks_node_role_name` | `string` | — | yes | IAM role of the EKS worker nodes, as a **name or an ARN** — the environments pass `module.eks.node_role_arn`. The read policy is attached to it. |

## Outputs

| Name | Type | Description |
|---|---|---|
| `secret_arn` | `string` | ARN of the secret. Use it in a K8s `ExternalSecret` or the Secrets Store CSI driver. |
| `secret_name` | `string` | Name of the secret, for use with `aws secretsmanager` CLI commands. |
| `secret_read_policy_arn` | `string` | ARN of the IAM policy granting read access. |

None of the outputs expose the secret value.

## Usage

```hcl
module "secrets" {
source = "../../modules/secrets"

project_name = "amana"
environment = "dev"
eks_node_role_name = module.eks.node_role_arn
}
```

A live example lives in
[`infra/terraform/environments/dev/main.tf`](../../environments/dev/main.tf);
staging wires the same three inputs.

After the first apply, inject the real signing key — the placeholder is
rejected by the backend's health check:

```bash
aws secretsmanager put-secret-value \
--secret-id "$(terraform output -raw secret_name)" \
--secret-string "S<your-56-char-base32-secret-key>"
```

## Operational notes

- **Terraform creates the container, not the value.** The first version holds
the literal placeholder `REPLACE_WITH_ADMIN_SECRET_KEY`. The
`ignore_changes = [secret_string]` lifecycle rule means subsequent applies
leave whatever you injected in place — Terraform will not reset it to the
placeholder.
- **Inject the real value immediately after the first apply.** Until you do,
the secret exists but carries a non-functional value, and the backend's
`adminSigningKey` health check fails.
- **The real value never enters Terraform state.** It is written through the
AWS CLI, not through Terraform, so it is absent from the state file, from
plan output, and from CI logs.
- **Read access is scoped to the EKS node role and to this one secret.** The
policy grants only `GetSecretValue` and `DescribeSecret` on the secret ARN.
Pods reach the value through the node role, so the pod's service account
needs no additional IAM binding.
- **Rotation is out of band.** Generate a new Stellar keypair, call
`put-secret-value` again, and restart the backend pods. The steps and the
verification command are in
[`docs/admin-secret-management.md`](../../../../docs/admin-secret-management.md#rotation).
- **Automatic rotation is not implemented in this module.** The module creates
no `aws_secretsmanager_secret_rotation` resource — until one is added, rotation
is the manual procedure above.
[`docs/admin-secret-management.md`](../../../../docs/admin-secret-management.md#automated-rotation-optional)
describes the shape of the rotation Lambda.
- **The secret name is environment-scoped.** It resolves to
`amana-<environment>-admin-secret-key`, matching the names used by the
Kubernetes manifests and the rotation docs.

## Related

- [`../eks`](../eks/) — provides the `node_role_arn` used to attach the read
policy
- [`infra/terraform/environments/dev/main.tf`](../../environments/dev/main.tf) —
dev wiring
- [`docs/admin-secret-management.md`](../../../../docs/admin-secret-management.md) —
provisioning, rotation, and troubleshooting for `ADMIN_SECRET_KEY`
- [`docs/secrets-policy.md`](../../../../docs/secrets-policy.md) — secrets
inventory, owners, and rotation cadence
- [`docs/admin-secret-rotation.md`](../../../../docs/admin-secret-rotation.md) —
rotation automation
Loading
Loading