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
20 changes: 20 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ jobs:
TF_VAR_hmac_key: ${{ secrets[format('HMAC_KEY_{0}', matrix.environment)] }}
TF_VAR_sendgrid_api_key: ${{ secrets[format('SENDGRID_API_KEY_{0}', matrix.environment)] }}
TF_VAR_api_signing_key: ${{ secrets[format('API_SIGNING_KEY_{0}', matrix.environment)] }}
TF_VAR_api_keys: ${{ secrets[format('API_KEYS_{0}', matrix.environment)] }}
TF_VAR_unsubscribe_signing_secret: ${{ secrets[format('UNSUBSCRIBE_SIGNING_SECRET_{0}', matrix.environment)] }}
TF_VAR_sendgrid_webhook_secret: ${{ secrets[format('SENDGRID_WEBHOOK_SECRET_{0}', matrix.environment)] }}
TF_VAR_email_idempotency_secret: ${{ secrets[format('EMAIL_IDEMPOTENCY_SECRET_{0}', matrix.environment)] }}
TF_VAR_base_url: ${{ secrets[format('BASE_URL_{0}', matrix.environment)] }}
TF_VAR_cors_allowed_origins: ${{ secrets[format('CORS_ALLOWED_ORIGINS_{0}', matrix.environment)] }}
TF_VAR_from_email: ${{ secrets[format('FROM_EMAIL_{0}', matrix.environment)] }}
TF_VAR_blockchain_rpc_url: ${{ secrets[format('BLOCKCHAIN_RPC_URL_{0}', matrix.environment)] }}
TF_VAR_predictiq_contract_id: ${{ secrets[format('PREDICTIQ_CONTRACT_ID_{0}', matrix.environment)] }}
TF_VAR_app_env: ${{ secrets[format('APP_ENV_{0}', matrix.environment)] }}
TF_VAR_acm_certificate_arn: ${{ secrets[format('ACM_CERTIFICATE_ARN_{0}', matrix.environment)] }}
TF_VAR_api_image_uri: ${{ secrets[format('API_IMAGE_URI_{0}', matrix.environment)] }}
TF_VAR_redis_auth_token: ${{ secrets[format('REDIS_AUTH_TOKEN_{0}', matrix.environment)] }}
Expand Down Expand Up @@ -168,6 +178,16 @@ jobs:
TF_VAR_hmac_key: ${{ secrets[format('HMAC_KEY_{0}', matrix.environment)] }}
TF_VAR_sendgrid_api_key: ${{ secrets[format('SENDGRID_API_KEY_{0}', matrix.environment)] }}
TF_VAR_api_signing_key: ${{ secrets[format('API_SIGNING_KEY_{0}', matrix.environment)] }}
TF_VAR_api_keys: ${{ secrets[format('API_KEYS_{0}', matrix.environment)] }}
TF_VAR_unsubscribe_signing_secret: ${{ secrets[format('UNSUBSCRIBE_SIGNING_SECRET_{0}', matrix.environment)] }}
TF_VAR_sendgrid_webhook_secret: ${{ secrets[format('SENDGRID_WEBHOOK_SECRET_{0}', matrix.environment)] }}
TF_VAR_email_idempotency_secret: ${{ secrets[format('EMAIL_IDEMPOTENCY_SECRET_{0}', matrix.environment)] }}
TF_VAR_base_url: ${{ secrets[format('BASE_URL_{0}', matrix.environment)] }}
TF_VAR_cors_allowed_origins: ${{ secrets[format('CORS_ALLOWED_ORIGINS_{0}', matrix.environment)] }}
TF_VAR_from_email: ${{ secrets[format('FROM_EMAIL_{0}', matrix.environment)] }}
TF_VAR_blockchain_rpc_url: ${{ secrets[format('BLOCKCHAIN_RPC_URL_{0}', matrix.environment)] }}
TF_VAR_predictiq_contract_id: ${{ secrets[format('PREDICTIQ_CONTRACT_ID_{0}', matrix.environment)] }}
TF_VAR_app_env: ${{ secrets[format('APP_ENV_{0}', matrix.environment)] }}
TF_VAR_acm_certificate_arn: ${{ secrets[format('ACM_CERTIFICATE_ARN_{0}', matrix.environment)] }}
TF_VAR_api_image_uri: ${{ secrets[format('API_IMAGE_URI_{0}', matrix.environment)] }}
TF_VAR_redis_auth_token: ${{ secrets[format('REDIS_AUTH_TOKEN_{0}', matrix.environment)] }}
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1445,9 +1445,49 @@ jobs:
- name: Run Jest tests with coverage
run: npm test -- --coverage

frontend-lint-typecheck:
name: Frontend Lint & Type Check
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- name: ESLint
run: npm run lint
- name: TypeScript
run: npx tsc --noEmit
- name: Prettier
run: npm run format:check

infra-config-guards:
name: Infra Config Guards
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: DB_HOST must not include the port
run: |
if grep -n 'db_host.*module\.rds\.endpoint' infrastructure/terraform/main.tf; then
echo "db_host must use module.rds.address (endpoint includes :port)"; exit 1
fi
- name: ECS task sets APP_ENV and does not use API_SIGNING_KEY
run: |
grep -q 'name = "APP_ENV"' infrastructure/terraform/modules/ecs/main.tf
! grep -q 'name = "API_SIGNING_KEY"' infrastructure/terraform/modules/ecs/main.tf

all-tests-passed:
name: All Tests Passed
needs:
- frontend-lint-typecheck
- infra-config-guards
- prometheus-rules-test
- unit-tests
- integration-tests
Expand Down
4 changes: 2 additions & 2 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,9 @@ The ECS task definition (`infrastructure/terraform/modules/ecs/main.tf`) injects
| `HMAC_KEY` | `secrets` | `aws_secretsmanager_secret.hmac_key` |
| `SENDGRID_API_KEY` | `secrets` | `aws_secretsmanager_secret.sendgrid_api_key` |
| `SENDGRID_KEY_ROTATED_AT` | `secrets` | `aws_secretsmanager_secret.sendgrid_key_rotated_at` |
| `API_SIGNING_KEY` | `secrets` | `aws_secretsmanager_secret.api_signing_key` |
| `REQUEST_SIGNING_SECRET` | `secrets` | `aws_secretsmanager_secret.api_signing_key` |

Known divergences to reconcile with `services/api/.env.example`: the task definition supplies `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USER`/`DB_PASSWORD` separately rather than a single `DATABASE_URL`, and does not yet inject `API_KEYS`, `BASE_URL`, `FROM_EMAIL`, `UNSUBSCRIBE_SIGNING_SECRET`, `PREDICTIQ_CONTRACT_ID`, `BLOCKCHAIN_RPC_URL` or `STELLAR_NETWORK_PASSPHRASE`.
Known divergence: the task definition supplies `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USER`/`DB_PASSWORD` separately rather than a single `DATABASE_URL` (`DB_HOST` is the RDS `address`, without port). `API_KEYS`, `UNSUBSCRIBE_SIGNING_SECRET`, `SENDGRID_WEBHOOK_SECRET` and `EMAIL_IDEMPOTENCY_SECRET` are injected as secrets; `APP_ENV`, `RUST_ENV`, `BASE_URL`, `CORS_ALLOWED_ORIGINS`, `FROM_EMAIL`, `BLOCKCHAIN_RPC_URL` and `PREDICTIQ_CONTRACT_ID` as environment variables.

### Updating a secret

Expand Down
8 changes: 7 additions & 1 deletion docs/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ outputs, or version control.
| `redis-url` | `predictiq/<env>/redis-url` | `REDIS_URL` | Connection string | Manual |
| `hmac-key` | `predictiq/<env>/hmac-key` | `HMAC_KEY` | 32+ byte random hex | **Every 90 days** |
| `sendgrid-api-key` | `predictiq/<env>/sendgrid-api-key` | `SENDGRID_API_KEY` | SendGrid API key (`SG.*`) | **Every 180 days** |
| `api-signing-key` | `predictiq/<env>/api-signing-key` | `API_SIGNING_KEY` | 32+ byte random hex | **Every 90 days** |
| `api-signing-key` | `predictiq/<env>/api-signing-key` | `REQUEST_SIGNING_SECRET` | 32+ byte random hex | **Every 90 days** |
| `api-keys` | `predictiq/<env>/api-keys` | `API_KEYS` | Comma-separated keys | Manual |
| `unsubscribe-signing-secret` | `predictiq/<env>/unsubscribe-signing-secret` | `UNSUBSCRIBE_SIGNING_SECRET` | Random hex | Manual |
| `sendgrid-webhook-secret` | `predictiq/<env>/sendgrid-webhook-secret` | `SENDGRID_WEBHOOK_SECRET` | SendGrid webhook secret | Manual |
| `email-idempotency-secret` | `predictiq/<env>/email-idempotency-secret` | `EMAIL_IDEMPOTENCY_SECRET` | Random hex | Manual |

Non-secret settings (`APP_ENV`, `RUST_ENV`, `BASE_URL`, `CORS_ALLOWED_ORIGINS`, `FROM_EMAIL`, `BLOCKCHAIN_RPC_URL`, `PREDICTIQ_CONTRACT_ID`) are plain ECS environment variables set from Terraform variables. `APP_ENV` must be `development`, `staging` or `production`; the API refuses to start otherwise.

`<env>` is one of `dev`, `staging`, or `prod`.

Expand Down
12 changes: 11 additions & 1 deletion infrastructure/terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ module "ecs" {
api_desired_count = var.api_desired_count
api_cpu = var.api_cpu
api_memory = var.api_memory
db_host = module.rds.endpoint
db_host = module.rds.address
db_port = 5432
db_name = var.db_name
db_user = var.db_username
Expand All @@ -130,6 +130,16 @@ module "ecs" {
hmac_key = var.hmac_key
sendgrid_api_key = var.sendgrid_api_key
api_signing_key = var.api_signing_key
app_env = var.app_env
base_url = var.base_url
cors_allowed_origins = var.cors_allowed_origins
from_email = var.from_email
blockchain_rpc_url = var.blockchain_rpc_url
predictiq_contract_id = var.predictiq_contract_id
api_keys = var.api_keys
unsubscribe_signing_secret = var.unsubscribe_signing_secret
sendgrid_webhook_secret = var.sendgrid_webhook_secret
email_idempotency_secret = var.email_idempotency_secret
ecs_tasks_sg_id = aws_security_group.ecs_tasks.id
otlp_endpoint = var.otlp_endpoint
}
Expand Down
103 changes: 97 additions & 6 deletions infrastructure/terraform/modules/ecs/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,61 @@ variable "api_signing_key" {
sensitive = true
}

variable "app_env" {
type = string
description = "Value for APP_ENV/RUST_ENV; one of development, staging, production"
validation {
condition = contains(["development", "staging", "production"], var.app_env)
error_message = "app_env must be one of: development, staging, production."
}
}

variable "base_url" {
type = string
description = "Public base URL injected as BASE_URL"
}

variable "cors_allowed_origins" {
type = string
description = "Comma-separated origins injected as CORS_ALLOWED_ORIGINS"
}

variable "from_email" {
type = string
description = "Sender address injected as FROM_EMAIL"
}

variable "blockchain_rpc_url" {
type = string
description = "Soroban RPC URL injected as BLOCKCHAIN_RPC_URL"
}

variable "predictiq_contract_id" {
type = string
description = "Contract ID injected as PREDICTIQ_CONTRACT_ID"
}

variable "api_keys" {
type = string
sensitive = true
description = "Comma-separated API keys injected as API_KEYS"
}

variable "unsubscribe_signing_secret" {
type = string
sensitive = true
}

variable "sendgrid_webhook_secret" {
type = string
sensitive = true
}

variable "email_idempotency_secret" {
type = string
sensitive = true
}

variable "otlp_endpoint" {
type = string
default = ""
Expand All @@ -137,6 +192,13 @@ variable "ecs_tasks_sg_id" {
}

locals {
extra_secrets = {
api_keys = { env = "API_KEYS", value = var.api_keys }
unsubscribe_signing_secret = { env = "UNSUBSCRIBE_SIGNING_SECRET", value = var.unsubscribe_signing_secret }
sendgrid_webhook_secret = { env = "SENDGRID_WEBHOOK_SECRET", value = var.sendgrid_webhook_secret }
email_idempotency_secret = { env = "EMAIL_IDEMPOTENCY_SECRET", value = var.email_idempotency_secret }
}

common_tags = {
Project = "predictiq"
Environment = var.environment
Expand Down Expand Up @@ -233,7 +295,14 @@ resource "aws_ecs_task_definition" "api" {
{
name = "ENVIRONMENT"
value = var.environment
}
},
{ name = "APP_ENV", value = var.app_env },
{ name = "RUST_ENV", value = var.app_env },
{ name = "BASE_URL", value = var.base_url },
{ name = "CORS_ALLOWED_ORIGINS", value = var.cors_allowed_origins },
{ name = "FROM_EMAIL", value = var.from_email },
{ name = "BLOCKCHAIN_RPC_URL", value = var.blockchain_rpc_url },
{ name = "PREDICTIQ_CONTRACT_ID", value = var.predictiq_contract_id }
], var.otlp_endpoint == "" ? [] : [
{
name = "OTLP_ENDPOINT"
Expand All @@ -242,7 +311,7 @@ resource "aws_ecs_task_definition" "api" {
])
# All secrets are injected from AWS Secrets Manager — no plaintext
# environment variables for sensitive values.
secrets = [
secrets = concat([
{
name = "DB_HOST"
valueFrom = aws_secretsmanager_secret.db_host.arn
Expand Down Expand Up @@ -288,10 +357,16 @@ resource "aws_ecs_task_definition" "api" {
valueFrom = aws_secretsmanager_secret.sendgrid_key_rotated_at.arn
},
{
name = "API_SIGNING_KEY"
# The API reads this value as REQUEST_SIGNING_SECRET (no reader exists for API_SIGNING_KEY).
name = "REQUEST_SIGNING_SECRET"
valueFrom = aws_secretsmanager_secret.api_signing_key.arn
}
]
], [
for k, v in local.extra_secrets : {
name = v.env
valueFrom = aws_secretsmanager_secret.extra[k].arn
}
])
logConfiguration = {
logDriver = "awslogs"
options = {
Expand Down Expand Up @@ -655,6 +730,22 @@ resource "aws_secretsmanager_secret" "api_signing_key" {
})
}

resource "aws_secretsmanager_secret" "extra" {
for_each = local.extra_secrets
name = "predictiq/${var.environment}/${replace(each.key, "_", "-")}"
description = "Injected into the API task as ${each.value.env}"

tags = merge(local.common_tags, {
Name = "predictiq-${var.environment}-${replace(each.key, "_", "-")}"
})
}

resource "aws_secretsmanager_secret_version" "extra" {
for_each = local.extra_secrets
secret_id = aws_secretsmanager_secret.extra[each.key].id
secret_string = each.value.value
}

resource "aws_secretsmanager_secret_version" "api_signing_key" {
secret_id = aws_secretsmanager_secret.api_signing_key.id
secret_string = var.api_signing_key
Expand Down Expand Up @@ -702,7 +793,7 @@ resource "aws_iam_role_policy" "ecs_task_execution_secrets" {
Action = [
"secretsmanager:GetSecretValue"
]
Resource = [
Resource = concat([
aws_secretsmanager_secret.db_host.arn,
aws_secretsmanager_secret.db_port.arn,
aws_secretsmanager_secret.db_name.arn,
Expand All @@ -715,7 +806,7 @@ resource "aws_iam_role_policy" "ecs_task_execution_secrets" {
aws_secretsmanager_secret.sendgrid_api_key.arn,
aws_secretsmanager_secret.sendgrid_key_rotated_at.arn,
aws_secretsmanager_secret.api_signing_key.arn,
]
], [for s in aws_secretsmanager_secret.extra : s.arn])
},
{
Effect = "Allow"
Expand Down
6 changes: 6 additions & 0 deletions infrastructure/terraform/modules/rds/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ output "sg_id" {
value = aws_security_group.rds.id
}

output "address" {
description = "RDS hostname without the port (aws_db_instance.endpoint is host:port)"
value = aws_db_instance.main.address
sensitive = true
}

output "endpoint" {
value = aws_db_instance.main.endpoint
sensitive = true
Expand Down
59 changes: 59 additions & 0 deletions infrastructure/terraform/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,66 @@ variable "api_signing_key" {
validation {
condition = length(var.api_signing_key) >= 32
error_message = "API signing key must be at least 32 characters."
}
}

variable "app_env" {
description = "Runtime APP_ENV/RUST_ENV for the API (development, staging, production)"
type = string
validation {
condition = contains(["development", "staging", "production"], var.app_env)
error_message = "app_env must be one of: development, staging, production."
}
}

variable "base_url" {
description = "Public base URL of the API (BASE_URL)"
type = string
}

variable "cors_allowed_origins" {
description = "Comma-separated CORS_ALLOWED_ORIGINS"
type = string
}

variable "from_email" {
description = "Sender address (FROM_EMAIL)"
type = string
}

variable "blockchain_rpc_url" {
description = "Soroban RPC URL (BLOCKCHAIN_RPC_URL)"
type = string
}

variable "predictiq_contract_id" {
description = "Deployed contract ID (PREDICTIQ_CONTRACT_ID)"
type = string
}

variable "api_keys" {
description = "Comma-separated API keys (API_KEYS). Stored in Secrets Manager."
type = string
sensitive = true
}

variable "unsubscribe_signing_secret" {
description = "UNSUBSCRIBE_SIGNING_SECRET. Stored in Secrets Manager."
type = string
sensitive = true
}

variable "sendgrid_webhook_secret" {
description = "SENDGRID_WEBHOOK_SECRET. Stored in Secrets Manager."
type = string
sensitive = true
}

variable "email_idempotency_secret" {
description = "EMAIL_IDEMPOTENCY_SECRET. Stored in Secrets Manager."
type = string
sensitive = true
}

variable "alert_email_endpoints" {
type = list(string)
Expand Down
Loading