From 7e6ce7e8487fa6da231799a93ffd1d9689d660c9 Mon Sep 17 00:00:00 2001 From: Narux Date: Tue, 20 Oct 2026 05:57:05 +0000 Subject: [PATCH] fix(infra,ci): frontend lint/typecheck job, ECS env/secrets, RDS host (#1769 #1770 #1771 #1772) --- .github/workflows/deploy.yml | 20 ++++ .github/workflows/test.yml | 40 +++++++ docs/deployment.md | 4 +- docs/secrets.md | 8 +- infrastructure/terraform/main.tf | 12 ++- infrastructure/terraform/modules/ecs/main.tf | 103 +++++++++++++++++-- infrastructure/terraform/modules/rds/main.tf | 6 ++ infrastructure/terraform/variables.tf | 58 +++++++++++ services/api/src/config.rs | 30 +++++- 9 files changed, 270 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6c214624..5c1dc1c6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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)] }} @@ -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)] }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ef566800..d23af0c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/docs/deployment.md b/docs/deployment.md index 79e06904..315c5827 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -222,9 +222,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 diff --git a/docs/secrets.md b/docs/secrets.md index 0026e5ce..f07fc989 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -15,7 +15,13 @@ outputs, or version control. | `redis-url` | `predictiq//redis-url` | `REDIS_URL` | Connection string | Manual | | `hmac-key` | `predictiq//hmac-key` | `HMAC_KEY` | 32+ byte random hex | **Every 90 days** | | `sendgrid-api-key` | `predictiq//sendgrid-api-key` | `SENDGRID_API_KEY` | SendGrid API key (`SG.*`) | **Every 180 days** | -| `api-signing-key` | `predictiq//api-signing-key` | `API_SIGNING_KEY` | 32+ byte random hex | **Every 90 days** | +| `api-signing-key` | `predictiq//api-signing-key` | `REQUEST_SIGNING_SECRET` | 32+ byte random hex | **Every 90 days** | +| `api-keys` | `predictiq//api-keys` | `API_KEYS` | Comma-separated keys | Manual | +| `unsubscribe-signing-secret` | `predictiq//unsubscribe-signing-secret` | `UNSUBSCRIBE_SIGNING_SECRET` | Random hex | Manual | +| `sendgrid-webhook-secret` | `predictiq//sendgrid-webhook-secret` | `SENDGRID_WEBHOOK_SECRET` | SendGrid webhook secret | Manual | +| `email-idempotency-secret` | `predictiq//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. `` is one of `dev`, `staging`, or `prod`. diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf index 8a92a0e8..18b7bfa7 100644 --- a/infrastructure/terraform/main.tf +++ b/infrastructure/terraform/main.tf @@ -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 @@ -128,6 +128,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 } diff --git a/infrastructure/terraform/modules/ecs/main.tf b/infrastructure/terraform/modules/ecs/main.tf index ed46a335..8faa4ac7 100644 --- a/infrastructure/terraform/modules/ecs/main.tf +++ b/infrastructure/terraform/modules/ecs/main.tf @@ -102,6 +102,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 = "" @@ -114,6 +169,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 @@ -173,7 +235,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" @@ -182,7 +251,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 @@ -220,10 +289,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 = { @@ -535,6 +610,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 @@ -582,7 +673,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, @@ -593,7 +684,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]) } ] }) diff --git a/infrastructure/terraform/modules/rds/main.tf b/infrastructure/terraform/modules/rds/main.tf index 5073f09b..44b3af36 100644 --- a/infrastructure/terraform/modules/rds/main.tf +++ b/infrastructure/terraform/modules/rds/main.tf @@ -127,6 +127,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 diff --git a/infrastructure/terraform/variables.tf b/infrastructure/terraform/variables.tf index 9fbb8876..52e9347e 100644 --- a/infrastructure/terraform/variables.tf +++ b/infrastructure/terraform/variables.tf @@ -246,3 +246,61 @@ variable "api_signing_key" { 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 +} diff --git a/services/api/src/config.rs b/services/api/src/config.rs index fa9f142b..0f75790b 100644 --- a/services/api/src/config.rs +++ b/services/api/src/config.rs @@ -99,6 +99,20 @@ fn hmac_key_entropy(data: &[u8]) -> f64 { .sum() } +/// Known `APP_ENV` values. Startup fails for anything else so a typo (or a +/// mismatched variable name in the deployment) cannot silently disable +/// production safety checks. +pub fn validate_app_env(value: &str) -> Result<(), String> { + const KNOWN: [&str; 3] = ["development", "staging", "production"]; + if KNOWN.iter().any(|k| k.eq_ignore_ascii_case(value)) { + Ok(()) + } else { + Err(format!( + "APP_ENV={value:?} is not a known environment (expected one of {KNOWN:?})" + )) + } +} + // ── CORS configuration ──────────────────────────────────────────────────────── /// CORS policy loaded from environment variables. @@ -643,7 +657,13 @@ impl Config { is_production: env::var("APP_ENV") .map(|v| v.eq_ignore_ascii_case("production")) .unwrap_or(false), - app_env: env::var("APP_ENV").unwrap_or_else(|_| "development".to_string()), + app_env: { + let value = env::var("APP_ENV").unwrap_or_else(|_| "development".to_string()); + if let Err(msg) = validate_app_env(&value) { + panic!("{msg}"); + } + value + }, require_https: env::var("REQUIRE_HTTPS") .map(|v| v.eq_ignore_ascii_case("true") || v == "1") .unwrap_or(false), @@ -1122,6 +1142,14 @@ impl ContractKeySchema { #[cfg(test)] mod tests { + #[test] + fn validate_app_env_rejects_unknown_values() { + assert!(super::validate_app_env("production").is_ok()); + assert!(super::validate_app_env("Staging").is_ok()); + assert!(super::validate_app_env("prod").is_err()); + assert!(super::validate_app_env("").is_err()); + } + use super::*; #[test]