From 08be60e661e78c1d76e3505e01aa9b53ab228ed4 Mon Sep 17 00:00:00 2001 From: jknapp Date: Mon, 18 May 2026 09:40:12 -0700 Subject: [PATCH] feat(terraform): AWS Fargate deployment module Adds a terraform/ directory with an opinionated module that deploys shared-memory to ECS Fargate behind an ALB. The module assumes the operator already provides the VPC, RDS Postgres, ACM cert, ECR images, and OIDC clients, and creates everything else: ECS cluster + services, ALB, Service Connect namespace for app-embedder discovery, EFS-backed model cache for the embedder, Secrets Manager entries, IAM roles, CloudWatch log groups, and a one-shot migrator task definition. Includes examples/basic/ with a worked invocation and a README covering prerequisites, quick start, the post-apply migrator run, image updates, DNS setup, and a security note. Main README gains a short Mode C pointer to the terraform/ guide. Validated with `terraform fmt -check -recursive` and `terraform validate` against AWS provider 5.x. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 19 ++ terraform/README.md | 298 +++++++++++++++++++++++ terraform/alb.tf | 85 +++++++ terraform/ecs.tf | 363 ++++++++++++++++++++++++++++ terraform/efs.tf | 57 +++++ terraform/examples/basic/README.md | 63 +++++ terraform/examples/basic/main.tf | 100 ++++++++ terraform/examples/basic/outputs.tf | 54 +++++ terraform/iam.tf | 69 ++++++ terraform/logs.tf | 30 +++ terraform/main.tf | 36 +++ terraform/networking.tf | 133 ++++++++++ terraform/outputs.tf | 89 +++++++ terraform/secrets.tf | 71 ++++++ terraform/variables.tf | 197 +++++++++++++++ terraform/versions.tf | 10 + 16 files changed, 1674 insertions(+) create mode 100644 terraform/README.md create mode 100644 terraform/alb.tf create mode 100644 terraform/ecs.tf create mode 100644 terraform/efs.tf create mode 100644 terraform/examples/basic/README.md create mode 100644 terraform/examples/basic/main.tf create mode 100644 terraform/examples/basic/outputs.tf create mode 100644 terraform/iam.tf create mode 100644 terraform/logs.tf create mode 100644 terraform/main.tf create mode 100644 terraform/networking.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/secrets.tf create mode 100644 terraform/variables.tf create mode 100644 terraform/versions.tf diff --git a/README.md b/README.md index 76f60aa..20a62ba 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,25 @@ docker compose --profile tls up -d Caddy reads `APP_HOSTNAME` and `ACME_EMAIL` from `.env` and proxies to the app on the internal Docker network. +### Mode C — AWS Fargate (Terraform) + +For deployments where docker-compose on a VM isn't a fit (multi-AZ HA, +managed RDS, no host to babysit), the [`terraform/`](terraform/) directory +ships a module that wires the same three components into ECS Fargate +behind an ALB: + +```bash +cd terraform/examples/basic +$EDITOR main.tf terraform.tfvars # plug in your VPC, RDS, ACM, ECR, OIDC +terraform init && terraform apply +``` + +You bring the VPC, RDS Postgres, ACM cert, ECR images, and OIDC clients; +the module brings ECS, ALB, EFS (for the embedder model cache), Secrets +Manager, IAM, CloudWatch, and Service Connect for app↔embedder discovery. +Full walkthrough in [`terraform/README.md`](terraform/README.md), including +the post-apply migrator invocation and DNS setup. + --- ## Quick start diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..d229885 --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,298 @@ +# shared-memory — Terraform module (AWS Fargate) + +Deploys [shared-memory](../README.md) to AWS Fargate ECS behind an ALB. +Brings up the `app` (Next.js web + MCP endpoint), the `embedder` sidecar +(Xenova bge-small on CPU, EFS-backed model cache), and a one-shot +`migrator` task definition. Targets an externally-managed RDS Postgres +instance and an existing OIDC identity provider — neither is the module's +job. + +--- + +## What you provide before running + +The module deliberately stops short of creating shared infrastructure +that's usually account-wide and not specific to this app. You bring: + +### 1. A VPC with public + private subnets + +At least two of each across two AZs. Public subnets host the internet-facing +ALB; private subnets host the Fargate tasks and EFS mount targets. The +private subnets need outbound internet access (NAT gateway or VPC endpoints +for ECR / Secrets Manager / CloudWatch / Hugging Face) so tasks can pull +images, decrypt secrets, and on first cold start download the embedding +model. + +### 2. An RDS Postgres instance + +Postgres **≥ 15.5** with `pgvector`, `pg_trgm`, and `pgcrypto`. RDS makes +all three available on modern versions; you may need to add them to +`rds.allowed_extensions` in the parameter group, but the migrator runs +`CREATE EXTENSION IF NOT EXISTS …` itself. + +Connectivity gotcha: the RDS security group is owned by you. After +`terraform apply` you must add an inbound rule on it allowing 5432 from +the module's task security groups. Use the outputs: + +``` +app_security_group_id # app needs RDS for runtime queries +migrator_security_group_id # migrator needs RDS for DDL on apply +``` + +The embedder does **not** talk to Postgres. + +### 3. An ACM certificate + +In the **same region** as the ALB (ACM certs are regional). Cover the +public hostname you'll use for `domain_name`. DNS validation is the +easiest route; AWS docs walk through it. + +### 4. ECR repositories with pushed images + +The module references `var.app_image` and `var.embedder_image` by URI — +it doesn't build, doesn't push, doesn't create the repos. Two repos +typically: + +``` +shared-memory-web # built from apps/web/Dockerfile +shared-memory-embedder # built from apps/embedder/Dockerfile +``` + +Build from the repo root and tag with whatever version scheme you prefer +(git SHA, semver, etc.). The app and embedder images use unrelated runtime +stacks (Node alpine vs Node slim) — keep them as separate repos. + +### 5. OIDC clients + +Two clients in your IdP (Authentik, EntraID, Keycloak, …) — one +confidential for the Web UI, one public/PKCE for the MCP endpoint. See the +[main README](../README.md#oidc-provider-setup) for the Authentik walkthrough. + +The redirect URI you register on the Web UI client is +`https://${domain_name}/api/auth/callback/oidc`, so plan the domain name +*before* configuring the IdP. + +--- + +## Quick start + +```bash +cd terraform/examples/basic + +# 1. Edit main.tf — replace vpc-…, subnet-…, ARN placeholders, image URIs. +$EDITOR main.tf + +# 2. Create terraform.tfvars with the sensitive values (0600 perms!). +umask 077 +cat > terraform.tfvars < +``` + +### Read its logs + +```bash +LOG_GROUP=$(terraform output -raw migrator_log_group_name) + +aws logs tail "$LOG_GROUP" --follow +``` + +A healthy run prints `Migrations complete.` and (if you have prior data) +`Embedding backfill complete: N memories embedded.`. + +You should re-run the migrator after **every** deploy that ships a new +SQL migration file. It's idempotent — already-applied migrations are +skipped via the `_migrations` ledger table. + +### Verify the app is up + +```bash +ALB=$(terraform output -raw alb_dns_name) +curl -fsS "https://$ALB/api/health" # — once DNS / cert is wired up +``` + +(If DNS isn't wired yet, you can `curl --resolve memory.example.com:443:` +to test against the cert without touching DNS.) + +--- + +## Updating images + +Push a new tag to ECR, then re-apply with the new tag: + +```bash +terraform apply -var 'app_image=…/shared-memory-web:v0.5.1' +``` + +ECS performs a rolling deploy: `deployment_minimum_healthy_percent = 50` +and `deployment_maximum_percent = 200` mean it stands up new tasks before +draining old ones. If the new tasks fail their ALB health check the old +ones stay. + +If the new image ships a SQL migration, **run the migrator again first** +(or right after; the SQL is backwards-compatible in this codebase), then +roll the app. + +The embedder side is rarer to update — the image hardly changes. When it +does, EFS keeps the existing model cache so the new revision is warm +immediately. + +--- + +## DNS setup + +The ALB has a generated DNS name (`…elb.amazonaws.com`); you point your +real hostname at it with an A-alias record. + +If your DNS lives in Route53: + +```hcl +resource "aws_route53_record" "app" { + zone_id = "Z0123456789ABCDEFG" # your hosted zone + name = "memory.example.com" + type = "A" + + alias { + name = module.shared_memory.alb_dns_name + zone_id = module.shared_memory.alb_zone_id + evaluate_target_health = true + } +} +``` + +If your DNS is elsewhere (Cloudflare, NS1, …), a CNAME from +`memory.example.com` → `` works equivalently, modulo apex +limitations. + +Once DNS propagates, the OIDC callback URL you registered earlier +(`https://memory.example.com/api/auth/callback/oidc`) will start working +and you can sign in. + +--- + +## Security note + +Several inputs (`database_url`, `nextauth_secret`, `cli_token_secret`, +`oidc_client_secret_web`) are sensitive. The module marks them as such so +they're scrubbed from CLI output, but they still: + +- Pass through `terraform plan` and `terraform apply` +- Land in `terraform.tfstate` +- Round-trip through Secrets Manager versions + +Hardening checklist: + +- Put values in `terraform.tfvars` (not committed) with `chmod 600`. +- Use a remote state backend with encryption (S3 + KMS) and tight IAM + on the bucket. Local state in a shared repo is the failure mode. +- Consider an external secret manager (1Password, Doppler, Vault) and + feeding values via `-var-file` from a `terraform-data` shim. The + module accepts plain strings — keep the indirection outside. +- Rotate `nextauth_secret` and `cli_token_secret` periodically. Both can + change with no DB migration; in-flight sessions and unexpired CLI + tokens will be invalidated. + +The module's Secrets Manager entries are scoped under +`${name_prefix}/` and the task execution role has +`secretsmanager:GetSecretValue` on those ARNs only — no wildcard. + +--- + +## What the module creates + +| Resource | Purpose | +|---|---| +| `aws_ecs_cluster` | Fargate cluster, Service Connect default namespace | +| `aws_ecs_service.app` | Web/MCP service behind ALB | +| `aws_ecs_service.embedder` | Internal sidecar service | +| `aws_ecs_task_definition.{app,embedder,migrator}` | Task defs | +| `aws_lb` + listener + target group | Public ALB, HTTPS + redirect | +| `aws_efs_file_system` + access point + mount targets | Embedder model cache | +| `aws_secretsmanager_secret.*` (4) | DATABASE_URL, NEXTAUTH_SECRET, CLI_TOKEN_SECRET, OIDC_CLIENT_SECRET_WEB | +| `aws_cloudwatch_log_group.*` (4) | app, embedder, migrator, service-connect | +| `aws_security_group.{alb,app,embedder,migrator,efs}` | Tier security groups | +| `aws_iam_role.{execution,app_task,embedder_task,migrator_task}` | Execution + per-service task roles | +| `aws_service_discovery_http_namespace` | Service Connect namespace `${name_prefix}.internal` | + +## What the module does NOT create + +- VPC, subnets, NAT, route tables — you own these +- RDS instance, parameter group, subnet group — you own +- ACM certificate or its DNS validation records — you own +- ECR repositories or the image build pipeline — you own +- OIDC clients — you own +- Route53 records — you own (see [DNS setup](#dns-setup)) +- WAF, Shield, CloudFront — out of scope + +## Module inputs + +See [`variables.tf`](variables.tf) for the full list with descriptions +and defaults. + +## Module outputs + +See [`outputs.tf`](outputs.tf). The ones you'll use: + +- `alb_dns_name`, `alb_zone_id` — for the Route53 alias +- `ecs_cluster_name`, `migrator_task_definition_family`, + `private_subnet_ids_for_run_task`, `migrator_security_group_id` — + to assemble the `aws ecs run-task` call +- `app_security_group_id` / `migrator_security_group_id` — to whitelist + on your RDS SG +- `app_log_group_name`, `embedder_log_group_name`, `migrator_log_group_name` — + for `aws logs tail` + +## Worked example + +See [`examples/basic/`](examples/basic/). diff --git a/terraform/alb.tf b/terraform/alb.tf new file mode 100644 index 0000000..50e7076 --- /dev/null +++ b/terraform/alb.tf @@ -0,0 +1,85 @@ +# ----------------------------------------------------------------------------- +# Application Load Balancer. +# +# * Internet-facing, in the public subnets +# * HTTP listener on :80 returns a 301 to https://${domain}${path} +# * HTTPS listener on :443 terminates TLS with the user's ACM cert and +# forwards to the app target group on 3000 +# +# Target type is `ip` because Fargate tasks register their ENI IPs directly, +# not via an EC2 instance. +# ----------------------------------------------------------------------------- + +resource "aws_lb" "this" { + name = "${var.name_prefix}-alb" + load_balancer_type = "application" + internal = false + subnets = var.public_subnet_ids + security_groups = [aws_security_group.alb.id] + + # Keep HTTP/2 on (default) so MCP streaming works smoothly. drop_invalid + # headers protects against header smuggling against the upstream. + drop_invalid_header_fields = true + + tags = merge(local.tags, { Name = "${var.name_prefix}-alb" }) +} + +resource "aws_lb_target_group" "app" { + name = "${var.name_prefix}-app" + port = local.app_port + protocol = "HTTP" + target_type = "ip" + vpc_id = var.vpc_id + deregistration_delay = 30 + + health_check { + enabled = true + path = "/api/health" + port = "traffic-port" + protocol = "HTTP" + matcher = "200" + interval = 15 + timeout = 5 + healthy_threshold = 2 + unhealthy_threshold = 3 + } + + tags = local.tags +} + +# Port 80 → 301 redirect to HTTPS. +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.this.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "redirect" + + redirect { + protocol = "HTTPS" + port = "443" + status_code = "HTTP_301" + } + } + + tags = local.tags +} + +# Port 443 → app target group. TLS terminates at the ALB; the app speaks +# plain HTTP behind it. PUBLIC_URL teaches Auth.js and the MCP route that +# the public origin is HTTPS regardless. +resource "aws_lb_listener" "https" { + load_balancer_arn = aws_lb.this.arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" + certificate_arn = var.acm_certificate_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.app.arn + } + + tags = local.tags +} diff --git a/terraform/ecs.tf b/terraform/ecs.tf new file mode 100644 index 0000000..5555c2a --- /dev/null +++ b/terraform/ecs.tf @@ -0,0 +1,363 @@ +# ----------------------------------------------------------------------------- +# ECS cluster + services + task definitions. +# +# Service Connect (introduced in 2022) handles app→embedder discovery: both +# services join the same namespace, the embedder advertises itself as +# `embedder` on port 8080, and the app talks to `http://embedder:8080` like +# it does in docker-compose. No Route53 records, no Cloud Map manual +# wiring, no sidecar plumbing in the app image. +# +# The migrator runs as a task definition with no service — operators invoke +# it via `aws ecs run-task` after a fresh deploy (see README). +# ----------------------------------------------------------------------------- + +# ---- Cluster + Service Connect namespace ---- + +resource "aws_service_discovery_http_namespace" "this" { + name = local.service_connect_namespace + description = "Service Connect namespace for ${var.name_prefix}" + tags = local.tags +} + +resource "aws_ecs_cluster" "this" { + name = var.name_prefix + + service_connect_defaults { + namespace = aws_service_discovery_http_namespace.this.arn + } + + setting { + name = "containerInsights" + value = "enabled" + } + + tags = local.tags +} + +resource "aws_ecs_cluster_capacity_providers" "this" { + cluster_name = aws_ecs_cluster.this.name + capacity_providers = ["FARGATE", "FARGATE_SPOT"] + + default_capacity_provider_strategy { + capacity_provider = "FARGATE" + weight = 1 + base = 1 + } +} + +# ---- Shared env block (non-secret) for app + migrator ---- + +locals { + app_environment = [ + { name = "NODE_ENV", value = "production" }, + { name = "LOG_LEVEL", value = var.log_level }, + { name = "PUBLIC_URL", value = local.public_url }, + { name = "AUTH_URL", value = local.public_url }, + { name = "AUTH_TRUST_HOST", value = "true" }, + { name = "OIDC_ISSUER", value = var.oidc_issuer }, + { name = "OIDC_CLIENT_ID_WEB", value = var.oidc_client_id_web }, + { name = "OIDC_CLIENT_ID_MCP", value = var.oidc_client_id_mcp }, + { name = "OIDC_AUDIENCE", value = var.oidc_audience }, + { name = "EMBEDDER_URL", value = "http://embedder:${local.embedder_port}" }, + { name = "EMBEDDING_MODEL", value = var.embedding_model }, + { name = "EMBEDDING_DIM", value = tostring(var.embedding_dim) }, + ] + + # `secrets` block format that ECS expects: name = env-var name, valueFrom + # = secret ARN. ECS resolves these to env vars at task start. + app_secrets = [ + { name = "DATABASE_URL", valueFrom = aws_secretsmanager_secret.database_url.arn }, + { name = "NEXTAUTH_SECRET", valueFrom = aws_secretsmanager_secret.nextauth_secret.arn }, + { name = "CLI_TOKEN_SECRET", valueFrom = aws_secretsmanager_secret.cli_token_secret.arn }, + { name = "OIDC_CLIENT_SECRET_WEB", valueFrom = aws_secretsmanager_secret.oidc_client_secret_web.arn }, + ] + + embedder_environment = [ + { name = "LOG_LEVEL", value = var.log_level }, + { name = "EMBEDDING_MODEL", value = var.embedding_model }, + { name = "EMBEDDING_DIM", value = tostring(var.embedding_dim) }, + { name = "MODEL_CACHE_DIR", value = "/data/models" }, + ] + + # Migrator needs only the DB + embedder URL. EMBEDDER_URL is what triggers + # the post-migration backfill loop in scripts/migrate.ts. + migrator_environment = [ + { name = "NODE_ENV", value = "production" }, + { name = "LOG_LEVEL", value = var.log_level }, + { name = "EMBEDDER_URL", value = "http://embedder:${local.embedder_port}" }, + ] + + migrator_secrets = [ + { name = "DATABASE_URL", valueFrom = aws_secretsmanager_secret.database_url.arn }, + ] +} + +# ---- App task definition ---- + +resource "aws_ecs_task_definition" "app" { + family = "${var.name_prefix}-app" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.app_cpu + memory = var.app_memory + execution_role_arn = aws_iam_role.execution.arn + task_role_arn = aws_iam_role.app_task.arn + + container_definitions = jsonencode([ + { + name = "app" + image = var.app_image + essential = true + + portMappings = [ + { + name = "app" + containerPort = local.app_port + hostPort = local.app_port + protocol = "tcp" + appProtocol = "http" + }, + ] + + environment = local.app_environment + secrets = local.app_secrets + + # Mirrors the Dockerfile healthcheck — keeps individual tasks honest + # even before ALB health checks notice a problem. + healthCheck = { + command = ["CMD-SHELL", "wget -q -O /dev/null http://localhost:${local.app_port}/api/health || exit 1"] + interval = 15 + timeout = 5 + retries = 5 + startPeriod = 30 + } + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.app.name + awslogs-region = data.aws_region.current.name + awslogs-stream-prefix = "app" + } + } + }, + ]) + + tags = local.tags +} + +# ---- Embedder task definition ---- + +resource "aws_ecs_task_definition" "embedder" { + family = "${var.name_prefix}-embedder" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.embedder_cpu + memory = var.embedder_memory + execution_role_arn = aws_iam_role.execution.arn + task_role_arn = aws_iam_role.embedder_task.arn + + # EFS-backed volume for the model cache. + volume { + name = "models" + + efs_volume_configuration { + file_system_id = aws_efs_file_system.embedder_models.id + transit_encryption = "ENABLED" + + authorization_config { + access_point_id = aws_efs_access_point.embedder_models.id + iam = "DISABLED" + } + } + } + + container_definitions = jsonencode([ + { + name = "embedder" + image = var.embedder_image + essential = true + + portMappings = [ + { + name = "embedder" + containerPort = local.embedder_port + hostPort = local.embedder_port + protocol = "tcp" + appProtocol = "http" + }, + ] + + environment = local.embedder_environment + + mountPoints = [ + { + sourceVolume = "models" + containerPath = "/data/models" + readOnly = false + }, + ] + + # 180s start period mirrors the Dockerfile — first boot has to load + # (and on a cold EFS, download) the model. + healthCheck = { + command = ["CMD-SHELL", "wget -q -O - http://127.0.0.1:${local.embedder_port}/health | grep -q '\"ready\":true' || exit 1"] + interval = 15 + timeout = 5 + retries = 8 + startPeriod = 180 + } + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.embedder.name + awslogs-region = data.aws_region.current.name + awslogs-stream-prefix = "embedder" + } + } + }, + ]) + + tags = local.tags +} + +# ---- Migrator task definition (no service — one-shot via `aws ecs run-task`) ---- + +resource "aws_ecs_task_definition" "migrator" { + family = "${var.name_prefix}-migrator" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.migrator_cpu + memory = var.migrator_memory + execution_role_arn = aws_iam_role.execution.arn + task_role_arn = aws_iam_role.migrator_task.arn + + container_definitions = jsonencode([ + { + name = "migrator" + image = var.app_image # same web image — runs migrate.mjs instead of server.js + essential = true + + # Override the image's CMD to run the bundled migrator. Mirrors the + # docker-compose migrator service. + command = ["node", "apps/web/migrate.mjs"] + + environment = local.migrator_environment + secrets = local.migrator_secrets + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.migrator.name + awslogs-region = data.aws_region.current.name + awslogs-stream-prefix = "migrator" + } + } + }, + ]) + + tags = local.tags +} + +# ---- Services ---- + +# Embedder is created first because the app's Service Connect client config +# references the namespace, not the embedder service ARN — but starting the +# embedder first lets the app pass its DNS health probes immediately on first +# deploy. +resource "aws_ecs_service" "embedder" { + name = "${var.name_prefix}-embedder" + cluster = aws_ecs_cluster.this.id + task_definition = aws_ecs_task_definition.embedder.arn + desired_count = var.embedder_desired_count + launch_type = "FARGATE" + enable_execute_command = true + + network_configuration { + subnets = var.private_subnet_ids + security_groups = [aws_security_group.embedder.id] + assign_public_ip = false + } + + service_connect_configuration { + enabled = true + namespace = aws_service_discovery_http_namespace.this.arn + + # The app reaches this via `embedder:8080`. portName matches the + # portMappings entry in the task def; discoveryName is the DNS label. + service { + port_name = "embedder" + discovery_name = "embedder" + + client_alias { + port = local.embedder_port + dns_name = "embedder" + } + } + + log_configuration { + log_driver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.service_connect.name + awslogs-region = data.aws_region.current.name + awslogs-stream-prefix = "embedder-sc" + } + } + } + + # Cold-start tolerance: the model load can take ~180s, so don't let ECS + # mark the task unhealthy from its perspective during that window. + health_check_grace_period_seconds = 240 + + deployment_minimum_healthy_percent = 50 + deployment_maximum_percent = 200 + + tags = local.tags +} + +resource "aws_ecs_service" "app" { + name = "${var.name_prefix}-app" + cluster = aws_ecs_cluster.this.id + task_definition = aws_ecs_task_definition.app.arn + desired_count = var.app_desired_count + launch_type = "FARGATE" + enable_execute_command = true + + network_configuration { + subnets = var.private_subnet_ids + security_groups = [aws_security_group.app.id] + assign_public_ip = false + } + + load_balancer { + target_group_arn = aws_lb_target_group.app.arn + container_name = "app" + container_port = local.app_port + } + + service_connect_configuration { + enabled = true + namespace = aws_service_discovery_http_namespace.this.arn + + log_configuration { + log_driver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.service_connect.name + awslogs-region = data.aws_region.current.name + awslogs-stream-prefix = "app-sc" + } + } + } + + health_check_grace_period_seconds = 60 + + deployment_minimum_healthy_percent = 50 + deployment_maximum_percent = 200 + + # The HTTPS listener must exist before the service tries to attach to the + # target group — otherwise the first apply races. + depends_on = [aws_lb_listener.https] + + tags = local.tags +} diff --git a/terraform/efs.tf b/terraform/efs.tf new file mode 100644 index 0000000..5681916 --- /dev/null +++ b/terraform/efs.tf @@ -0,0 +1,57 @@ +# ----------------------------------------------------------------------------- +# EFS for the embedder model cache. +# +# Without persistent storage, every cold-start embedder task re-downloads +# the ~30 MB bge-small model from Hugging Face — slow and rate-limit-risky. +# EFS lets us share a warm cache across replicas and across restarts. +# +# The access point pins ownership to UID/GID 1001, matching the +# `node-embedder` user baked into apps/embedder/Dockerfile, so files written +# through the access point are owned correctly. +# ----------------------------------------------------------------------------- + +resource "aws_efs_file_system" "embedder_models" { + creation_token = "${var.name_prefix}-embedder-models" + encrypted = true + + # General Purpose performance mode + bursting throughput is plenty for a + # ~30 MB read-mostly cache. Don't pay for provisioned throughput. + performance_mode = "generalPurpose" + throughput_mode = "bursting" + + tags = merge(local.tags, { Name = "${var.name_prefix}-embedder-models" }) +} + +# One mount target per private subnet so any AZ the embedder lands in can +# reach the file system. +resource "aws_efs_mount_target" "embedder_models" { + for_each = toset(var.private_subnet_ids) + + file_system_id = aws_efs_file_system.embedder_models.id + subnet_id = each.value + security_groups = [aws_security_group.efs.id] +} + +# Access point gives the embedder task a chrooted view of the file system, +# with files always owned by uid/gid 1001 regardless of which task wrote +# them. Matches the `node-embedder` user in the Dockerfile. +resource "aws_efs_access_point" "embedder_models" { + file_system_id = aws_efs_file_system.embedder_models.id + + posix_user { + uid = 1001 + gid = 1001 + } + + root_directory { + path = "/models" + + creation_info { + owner_uid = 1001 + owner_gid = 1001 + permissions = "0755" + } + } + + tags = merge(local.tags, { Name = "${var.name_prefix}-embedder-models" }) +} diff --git a/terraform/examples/basic/README.md b/terraform/examples/basic/README.md new file mode 100644 index 0000000..e82ad03 --- /dev/null +++ b/terraform/examples/basic/README.md @@ -0,0 +1,63 @@ +# Basic example — shared-memory on AWS Fargate + +Minimal invocation of `../../`. Fill in your real IDs and run. + +## Prereqs + +Before you `terraform apply`, you need (see the [module README](../../README.md) +for the long version): + +- A VPC with two public + two private subnets +- An RDS Postgres ≥ 15.5 instance with `pgvector`, `pg_trgm`, `pgcrypto` + available (or creatable by the migrator on first run) +- An ACM certificate in the same region as the ALB, covering `domain_name` +- ECR repos populated with images for `apps/web` and `apps/embedder` +- OIDC clients registered (web confidential + MCP public/PKCE) + +## Configure + +1. Open `main.tf` and replace the placeholder `vpc-…` / `subnet-…` / + `arn:aws:acm:…` / image URIs with your real values. + +2. Create `terraform.tfvars` with the sensitive inputs and chmod it: + + ```bash + umask 077 + cat > terraform.tfvars <= 2 + error_message = "Provide at least two public subnets for ALB HA." + } +} + +variable "private_subnet_ids" { + description = "Private subnets (at least 2 AZs) where ECS tasks and EFS mount targets live." + type = list(string) + + validation { + condition = length(var.private_subnet_ids) >= 2 + error_message = "Provide at least two private subnets for task HA." + } +} + +# -------- TLS / DNS -------- + +variable "acm_certificate_arn" { + description = "ACM certificate ARN attached to the ALB's HTTPS listener. Must be in the same region as the ALB." + type = string +} + +variable "domain_name" { + description = "Public hostname (e.g. memory.example.com). Used to build PUBLIC_URL/AUTH_URL passed to the app." + type = string +} + +# -------- Container images -------- + +variable "app_image" { + description = "Fully qualified image URI for the web app (e.g. 12345.dkr.ecr.us-east-1.amazonaws.com/shared-memory-web:v0.5.0). Same image is reused for the migrator." + type = string +} + +variable "embedder_image" { + description = "Fully qualified image URI for the embedder sidecar." + type = string +} + +# -------- Database -------- + +variable "database_url" { + description = "Postgres connection URL, e.g. postgres://user:pw@host:5432/db. Stored in Secrets Manager. Host must be reachable from the private subnets." + type = string + sensitive = true +} + +# -------- OIDC -------- + +variable "oidc_issuer" { + description = "OIDC issuer URL (matches `iss` claim). Both web and MCP clients must live at this issuer." + type = string +} + +variable "oidc_client_id_web" { + description = "Confidential client ID for the Web UI." + type = string +} + +variable "oidc_client_secret_web" { + description = "Confidential client secret for the Web UI. Stored in Secrets Manager." + type = string + sensitive = true +} + +variable "oidc_client_id_mcp" { + description = "Public (PKCE) client ID used by Claude Code against /api/mcp." + type = string +} + +variable "oidc_audience" { + description = "Expected `aud` claim on MCP access tokens. Typically 'shared-memory'." + type = string +} + +# -------- App-level secrets -------- + +variable "nextauth_secret" { + description = "Session cookie signing key for Auth.js. Generate with `openssl rand -base64 32`." + type = string + sensitive = true +} + +variable "cli_token_secret" { + description = "HMAC key used by /connect to mint long-lived CLI tokens." + type = string + sensitive = true +} + +# -------- Embedder model knobs (rarely overridden) -------- + +variable "embedding_model" { + description = "Xenova/transformers model identifier the embedder downloads on cold start." + type = string + default = "Xenova/bge-small-en-v1.5" +} + +variable "embedding_dim" { + description = "Output dimension of the chosen embedding model. Must match the pgvector column width." + type = number + default = 384 +} + +# -------- Fargate sizing -------- + +variable "app_cpu" { + description = "Fargate CPU units for the app task. 512 = 0.5 vCPU, 1024 = 1 vCPU." + type = number + default = 512 +} + +variable "app_memory" { + description = "Fargate memory (MiB) for the app task." + type = number + default = 1024 +} + +variable "app_desired_count" { + description = "Number of app task replicas." + type = number + default = 1 +} + +variable "embedder_cpu" { + description = "Fargate CPU units for the embedder. The model needs ~1 vCPU for tolerable latency." + type = number + default = 1024 +} + +variable "embedder_memory" { + description = "Fargate memory (MiB) for the embedder. 2 GiB is comfortable for bge-small." + type = number + default = 2048 +} + +variable "embedder_desired_count" { + description = "Number of embedder task replicas." + type = number + default = 1 +} + +variable "migrator_cpu" { + description = "Fargate CPU units for the one-shot migrator task." + type = number + default = 512 +} + +variable "migrator_memory" { + description = "Fargate memory (MiB) for the one-shot migrator task." + type = number + default = 1024 +} + +# -------- Observability / misc -------- + +variable "log_level" { + description = "LOG_LEVEL env var passed to app and embedder." + type = string + default = "info" +} + +variable "log_retention_days" { + description = "CloudWatch retention applied to every log group the module creates." + type = number + default = 14 +} + +variable "tags" { + description = "Tags merged onto every resource the module creates." + type = map(string) + default = {} +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..59c42e8 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = "~> 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +}