diff --git a/.github/workflows/terraform-ci.yml b/.github/workflows/terraform-ci.yml new file mode 100644 index 0000000..6ffee90 --- /dev/null +++ b/.github/workflows/terraform-ci.yml @@ -0,0 +1,80 @@ +name: Terraform CI + +on: + pull_request: + paths: + - 'infra/**' + - 'infra-bootstrap/**' + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: terraform-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + terraform: + runs-on: ubuntu-latest + + services: + localstack: + image: localstack/localstack + ports: + - 4566:4566 + env: + SERVICES: s3,dynamodb,iam,lambda,ec2,ssm + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + + env: + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + AWS_DEFAULT_REGION: us-west-2 + TF_VAR_groq_api_key: dummy + TF_VAR_fish_audio_api_key: dummy + TF_VAR_fish_audio_voice_id: dummy + + steps: + - uses: actions/checkout@v5 + + - uses: hashicorp/setup-terraform@v3 + + - name: terraform fmt check + run: terraform fmt -check -recursive + + - name: terraform validate (infra-bootstrap) + working-directory: infra-bootstrap + run: | + terraform init -backend=false + terraform validate + + - name: terraform validate (infra) + working-directory: infra + run: | + terraform init -backend=false + terraform validate + + - name: Wait for LocalStack + run: | + timeout 60 sh -c 'until curl -sf http://localhost:4566/_localstack/health; do sleep 2; done' + + - name: bootstrap state bucket + working-directory: infra-bootstrap + run: | + terraform init + terraform apply -auto-approve + + - name: terraform plan (infra) + working-directory: infra + run: | + terraform init -reconfigure + set +e + terraform plan -detailed-exitcode + exit_code=$? + set -e + if [ $exit_code -eq 1 ]; then + exit 1 + fi + exit 0 diff --git a/.gitignore b/.gitignore index aff9626..75827c5 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,14 @@ next-env.d.ts *.egg-info/ .pytest_cache/ .mypy_cache/ + +# terraform +**/.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +**/lambda/handler.zip + +# planning docs (local guidance only) +docs/stages +PLAN.md diff --git a/backend/agents/graph.py b/backend/agents/graph.py index add211d..17139e6 100644 --- a/backend/agents/graph.py +++ b/backend/agents/graph.py @@ -9,12 +9,11 @@ import json import logging import traceback -from pathlib import Path from typing import Literal, Annotated, Callable, TypeVar -from functools import wraps +from functools import wraps, lru_cache from typing_extensions import TypedDict -from dotenv import load_dotenv +import boto3 from langgraph.graph import StateGraph, START, END from langgraph.graph.message import add_messages from langchain_groq import ChatGroq @@ -24,9 +23,23 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# Load environment variables from .env file -env_path = Path(__file__).parent.parent / ".env" -load_dotenv(env_path) +# SSM (LocalStack in dev; real AWS SSM in a deployed environment) +SSM_ENDPOINT_URL = os.getenv("SSM_ENDPOINT_URL", "http://localhost:4566") + +_ssm_client = boto3.client( + "ssm", + region_name="us-west-2", + endpoint_url=SSM_ENDPOINT_URL, + aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID", "test"), + aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY", "test"), +) + + +@lru_cache(maxsize=None) +def get_ssm_parameter(name: str) -> str: + """Fetch and cache a config value from SSM Parameter Store.""" + response = _ssm_client.get_parameter(Name=name, WithDecryption=True) + return response["Parameter"]["Value"] # ============================================================================= @@ -123,12 +136,12 @@ def safe_json_parse(content: str, agent_name: str) -> dict | None: def get_model(): """Get the Groq model instance.""" - api_key = os.getenv("GROQ_API_KEY") + api_key = get_ssm_parameter("/firstwave/backend/GROQ_API_KEY") if not api_key: - raise ValueError("GROQ_API_KEY not found. Please set it in backend/.env") - + raise ValueError("GROQ_API_KEY not found in SSM at /firstwave/backend/GROQ_API_KEY") + return ChatGroq( - model="llama-3.3-70b-versatile", # Free tier model + model="openai/gpt-oss-120b", # llama-3.3-70b-versatile was decommissioned by Groq temperature=0.1, api_key=api_key, timeout=30, # Add timeout to prevent hanging diff --git a/backend/main.py b/backend/main.py index 1c4c8d1..3d97622 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,10 +11,9 @@ from pydantic import BaseModel, field_validator from agents import dispatcher_graph +from agents.graph import get_ssm_parameter -# Fish Audio TTS configuration -FISH_AUDIO_API_KEY = os.getenv("FISH_AUDIO_API_KEY") -FISH_AUDIO_VOICE_ID = os.getenv("FISH_AUDIO_VOICE_ID", "") # Optional: specific voice +# Fish Audio TTS configuration (keys/voice ID come from SSM, see agents/graph.py) FISH_AUDIO_API_URL = "https://api.fish.audio/v1/tts" # Configure logging @@ -159,18 +158,27 @@ async def text_to_speech(request: TTSRequest): Returns audio bytes (MP3 format) that can be played directly. """ - if not FISH_AUDIO_API_KEY: + try: + fish_audio_api_key = get_ssm_parameter("/firstwave/backend/FISH_AUDIO_API_KEY") + except Exception: + fish_audio_api_key = None + + if not fish_audio_api_key: logger.error("Fish Audio API key not configured") raise APIError( status_code=500, message="TTS service not configured", - details="FISH_AUDIO_API_KEY environment variable is not set" + details="FISH_AUDIO_API_KEY not found in SSM at /firstwave/backend/FISH_AUDIO_API_KEY" ) - + logger.info(f"TTS request (text length: {len(request.text)})") - + # Use provided voice_id or fall back to configured default - voice_id = request.voice_id or FISH_AUDIO_VOICE_ID + try: + default_voice_id = get_ssm_parameter("/firstwave/backend/FISH_AUDIO_VOICE_ID") + except Exception: + default_voice_id = "" + voice_id = request.voice_id or default_voice_id try: async with httpx.AsyncClient(timeout=30.0) as client: @@ -187,7 +195,7 @@ async def text_to_speech(request: TTSRequest): response = await client.post( FISH_AUDIO_API_URL, headers={ - "Authorization": f"Bearer {FISH_AUDIO_API_KEY}", + "Authorization": f"Bearer {fish_audio_api_key}", "Content-Type": "application/json", }, json=payload, diff --git a/backend/requirements.txt b/backend/requirements.txt index 4a6e228..c0a4912 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,3 +6,4 @@ langchain-anthropic==1.7.2 langchain-groq==1.1.3 langchain-core==1.6.3 httpx==0.28.1 +boto3==1.40.30 diff --git a/docker-compose.yml b/docker-compose.yml index d3cda4d..e419a6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: - ./backend/.env environment: ALLOWED_ORIGINS: http://localhost:3000 + SSM_ENDPOINT_URL: http://host.docker.internal:4566 frontend: build: @@ -18,3 +19,26 @@ services: - "3000:3000" depends_on: - backend + + localstack: + image: localstack/localstack:latest + container_name: firstwave-localstack + ports: + - "4566:4566" + environment: + LOCALSTACK_AUTH_TOKEN: ${LOCALSTACK_AUTH_TOKEN:?set LOCALSTACK_AUTH_TOKEN in .env} + SERVICES: s3,dynamodb,iam,lambda,logs,ec2,ssm + DEBUG: "0" + AWS_DEFAULT_REGION: us-west-2 + volumes: + - localstack-data:/var/lib/localstack + - /var/run/docker.sock:/var/run/docker.sock + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + +volumes: + localstack-data: diff --git a/docs/reference/aws-cli.md b/docs/reference/aws-cli.md new file mode 100644 index 0000000..041d92b --- /dev/null +++ b/docs/reference/aws-cli.md @@ -0,0 +1,26 @@ +# AWS CLI reference (against LocalStack) + +LocalStack ignores credential values but the CLI still refuses to sign a request with none at all. Pass dummy creds + a region: + +``` +AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test \ + aws --endpoint-url=http://localhost:4566 --region us-west-2 s3 ls +``` + +Without them: `An error occurred (NoCredentials): Unable to locate credentials.` + +## Named profile (avoids prefixing every command) + +Needs AWS CLI v2.13+ (for `endpoint_url` in a profile): + +``` +aws configure set aws_access_key_id test --profile localstack +aws configure set aws_secret_access_key test --profile localstack +aws configure set region us-west-2 --profile localstack +aws configure set endpoint_url http://localhost:4566 --profile localstack +``` + +Then: +``` +aws --profile localstack s3 ls +``` diff --git a/docs/reference/localstack.md b/docs/reference/localstack.md new file mode 100644 index 0000000..bb3e8af --- /dev/null +++ b/docs/reference/localstack.md @@ -0,0 +1,35 @@ +# LocalStack reference + +## Auth token (required as of 2026) + +LocalStack merged the community and pro images into one as of 2026.3.0. `localstack/localstack:latest` refuses to start without a token — container exits with code 55 and logs "License activation failed! No credentials were found in the environment." Free tier is still free, but needs an account. + +Sign up at https://app.localstack.cloud, copy the auth token, put it in a gitignored root `.env`: + +``` +LOCALSTACK_AUTH_TOKEN=ls-xxxxxxxx +``` + +`docker-compose.yml` reads it via `${LOCALSTACK_AUTH_TOKEN:?...}`, so a missing token fails at `docker compose config` time with a clear message instead of a mystery exit 55. Never commit the token — root `.env` is gitignored. + +Alternative if you want to avoid the account: pin `localstack/localstack:4.4.0`, the last pre-merge community release. Frozen 2025 feature set — the `lstk` CLI notes below won't apply. + +## Health check + +``` +curl http://localhost:4566/_localstack/health +``` +Expect JSON with each service's status, e.g. `"s3": "available"`. + +## CLI tooling + +The old `awslocal`/`tflocal`/`samlocal`/`cdklocal` Python wrappers are deprecated — as of 2026-09-08 LocalStack replaced them with a single Go binary, `lstk`. Equivalent of `awslocal s3 ls` is `lstk awslocal s3 ls`. + +## Known free-tier gaps + +ALB, Route53, ACM — networking/LB edge is weak or paid-tier. Not worth chasing since there's no real deploy target. + +## Endpoints + +- Most services: `http://localhost:4566` +- S3 (virtual-hosted-style): `http://s3.localhost.localstack.cloud:4566` diff --git a/docs/reference/terraform.md b/docs/reference/terraform.md new file mode 100644 index 0000000..7b438b1 --- /dev/null +++ b/docs/reference/terraform.md @@ -0,0 +1,31 @@ +# Terraform reference + +## Core commands + +- `terraform init` — downloads provider plugins, sets up backend. Run once per config dir (and after provider/backend changes). +- `terraform plan` — dry run, shows what would change. Creates nothing. +- `terraform apply` — applies the plan, prompts `yes`. Creates/updates real (or LocalStack) resources. +- `terraform destroy` — tears down everything Terraform manages in the current state. Prompts `yes`. +- `terraform show` — prints current state in human-readable form. + +## Provider block against LocalStack + +```hcl +provider "aws" { + access_key = "test" + secret_key = "test" + region = "us-east-1" + skip_credentials_validation = true + skip_metadata_api_check = true + + endpoints { + s3 = "http://s3.localhost.localstack.cloud:4566" + } +} +``` + +Add more `endpoints { ... }` entries per service as new resources are introduced (`dynamodb`, `iam`, `lambda`, etc.) — see `docs/reference/localstack.md` for endpoint conventions. + +## State + +`terraform.tfstate` is the source of truth Terraform diffs against — not the `.tf` files. `destroy` updates state; it never edits `.tf` files, so `apply` can always rebuild from them. Stage 7 covers moving state to a remote (S3) backend. diff --git a/infra-bootstrap/.terraform.lock.hcl b/infra-bootstrap/.terraform.lock.hcl new file mode 100644 index 0000000..66a073f --- /dev/null +++ b/infra-bootstrap/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.66.0" + constraints = "~> 6.0" + hashes = [ + "h1:OnLj4nhqJnEcUzyyRKUjp1FgWG00Y8maikJEYSf9Zjw=", + "zh:156fe7164a3d26ef6b35734c43e99fb198df90575ed897d1182b8e930b8cd523", + "zh:1af52b22b35be00f8d16e3ebebff9fa699ec4db2ef69e6032ba5c536f80c03d9", + "zh:2545a8478bd551fdc9694f6cc1a1ad24617f6736f8bde0ad6cae90987c65380f", + "zh:4070db1ee369ccb41cb610bfd887386bc0a9b9ecad60aeb4dbce58443d2519dd", + "zh:53da7d3c1840ef875c7d34e967732502a64fe677af0e78824773d4c15a8fe740", + "zh:576a93a28bf611a4de2a2e6ced697a41d5126b8fd31d30782b16797e410a9706", + "zh:58fed5fa9a033355b9d4f3092c817b70d934100e0d8678d6e4c93f3c9493d4e4", + "zh:6a9ca2f24e2ee9156dd785d159a850b35d190e9cf7eca21cb9582970c2db80cd", + "zh:729edd30f99cc16009deba5c013265b0c81eda261a3d0821cbd011d3287fd230", + "zh:7ae460049b75bd4aefee465ef7c53a01ac2df46d4d3e3ac00824afa8b5cb83fb", + "zh:9051fa85c8034ade8a57a5c6f232fd33da28f3800bb5aa40bc8625dbc5e27632", + "zh:906547e4319805e7acf7fbdf2bac28a4b1a7370790a2a430c7adb1b29bb934eb", + "zh:998f27410a66158a35ee5ed142c27e5b21fe8601941da55da2157f8042d6dcca", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9c1804eff1dda0446dc2d215231015bb65a2fc6c3b7ba24584fe45f1ddd3fa9f", + "zh:b03ff5efdee310502aaaeb460144dc059bce72a0d8217e6b989099ef8aef9283", + ] +} diff --git a/infra-bootstrap/main.tf b/infra-bootstrap/main.tf new file mode 100644 index 0000000..6d69c89 --- /dev/null +++ b/infra-bootstrap/main.tf @@ -0,0 +1,15 @@ +resource "aws_s3_bucket" "tf_state" { + bucket = "firstwave-tf-state" +} + +resource "aws_s3_bucket_versioning" "tf_state" { + bucket = aws_s3_bucket.tf_state.id + + versioning_configuration { + status = "Enabled" + } +} + +output "state_bucket_name" { + value = aws_s3_bucket.tf_state.id +} diff --git a/infra-bootstrap/provider.tf b/infra-bootstrap/provider.tf new file mode 100644 index 0000000..ec1c1e6 --- /dev/null +++ b/infra-bootstrap/provider.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + access_key = "test" + secret_key = "test" + region = "us-west-2" + skip_credentials_validation = true + skip_metadata_api_check = true + skip_requesting_account_id = true + + endpoints { + s3 = "http://s3.localhost.localstack.cloud:4566" + s3control = "http://localhost.localstack.cloud:4566" + } +} diff --git a/infra/.terraform.lock.hcl b/infra/.terraform.lock.hcl new file mode 100644 index 0000000..13af87d --- /dev/null +++ b/infra/.terraform.lock.hcl @@ -0,0 +1,47 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/archive" { + version = "2.8.1" + constraints = "~> 2.4" + hashes = [ + "h1:aLNmq6dc3cDcqZc8s/8eKtn0I+UQXyJMGrmo4rRFtNw=", + "zh:03de290604114a89fcd45c2e5bc7787d5a1ebfc5f964fb5989306bea7a4c79ec", + "zh:0a7d69dc9fbbc48960bc2f04588c8fb1bd92c78a8f306566b7fb17fc4a4f2058", + "zh:4df1f3981379c35f1757da957470f7f7724496b57219da3485177cf1647bdf59", + "zh:50f0e72ba53bfe6e11b03b7fc899e1f72536354381d302a743a274ae2a3f45f4", + "zh:5c4e15a04c98e2a8cafb1cd9632b48ad318051e8462d105b1153006277985c35", + "zh:66069e604bcf5c4af0278e15997d9e6bd755c54fb3801d78885838b889729c5f", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:979765db3f42601870ab377104ba70befb029547b278337ec4ada980e3582de6", + "zh:b165254da774f49945a73fbccc3ef1b63d70ea00a98fa9e14716665ba80ecaad", + "zh:c0bb2697b525da9fec4511f569ed2bd2b42f25d5c1f9fa00f8f3645b50cfc2e9", + "zh:c48f6695d12df0d0fa5231f7c1b8d518a4feae91a733fdd35a5804af3a303831", + "zh:d589954c93f075180c9f4e2ce91780d5edcb56dfd0d3cda8ba08e13c10b52249", + "zh:f5792ed06da65d0daf7ca3711f5399ff78c7cb4400afe53fbce9a926fbde4477", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.66.0" + constraints = "~> 6.0" + hashes = [ + "h1:OnLj4nhqJnEcUzyyRKUjp1FgWG00Y8maikJEYSf9Zjw=", + "zh:156fe7164a3d26ef6b35734c43e99fb198df90575ed897d1182b8e930b8cd523", + "zh:1af52b22b35be00f8d16e3ebebff9fa699ec4db2ef69e6032ba5c536f80c03d9", + "zh:2545a8478bd551fdc9694f6cc1a1ad24617f6736f8bde0ad6cae90987c65380f", + "zh:4070db1ee369ccb41cb610bfd887386bc0a9b9ecad60aeb4dbce58443d2519dd", + "zh:53da7d3c1840ef875c7d34e967732502a64fe677af0e78824773d4c15a8fe740", + "zh:576a93a28bf611a4de2a2e6ced697a41d5126b8fd31d30782b16797e410a9706", + "zh:58fed5fa9a033355b9d4f3092c817b70d934100e0d8678d6e4c93f3c9493d4e4", + "zh:6a9ca2f24e2ee9156dd785d159a850b35d190e9cf7eca21cb9582970c2db80cd", + "zh:729edd30f99cc16009deba5c013265b0c81eda261a3d0821cbd011d3287fd230", + "zh:7ae460049b75bd4aefee465ef7c53a01ac2df46d4d3e3ac00824afa8b5cb83fb", + "zh:9051fa85c8034ade8a57a5c6f232fd33da28f3800bb5aa40bc8625dbc5e27632", + "zh:906547e4319805e7acf7fbdf2bac28a4b1a7370790a2a430c7adb1b29bb934eb", + "zh:998f27410a66158a35ee5ed142c27e5b21fe8601941da55da2157f8042d6dcca", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9c1804eff1dda0446dc2d215231015bb65a2fc6c3b7ba24584fe45f1ddd3fa9f", + "zh:b03ff5efdee310502aaaeb460144dc059bce72a0d8217e6b989099ef8aef9283", + ] +} diff --git a/infra/lambda/handler.py b/infra/lambda/handler.py new file mode 100644 index 0000000..6ca8a2e --- /dev/null +++ b/infra/lambda/handler.py @@ -0,0 +1,44 @@ +import os +import urllib.parse +from datetime import datetime, timezone + +import boto3 + +dynamodb = boto3.resource("dynamodb", endpoint_url=os.environ.get("DYNAMODB_ENDPOINT_URL")) +s3 = boto3.client("s3", endpoint_url=os.environ.get("S3_ENDPOINT_URL")) + +TABLE_NAME = os.environ.get("TABLE_NAME", "uploads-metadata") + + +def handler(event, context): + table = dynamodb.Table(TABLE_NAME) + + for record in event.get("Records", []): + s3_info = record["s3"] + bucket = s3_info["bucket"]["name"] + key = urllib.parse.unquote_plus(s3_info["object"]["key"]) + size_bytes = s3_info["object"].get("size") + uploaded_at = record.get("eventTime", datetime.now(timezone.utc).isoformat()) + + item = { + "id": key, + "filename": key, + "size_bytes": size_bytes, + "uploaded_at": uploaded_at, + } + + try: + head = s3.head_object(Bucket=bucket, Key=key) + content_type = head.get("ContentType") + if content_type: + item["content_type"] = content_type + if size_bytes is None: + item["size_bytes"] = head.get("ContentLength") + except Exception: + pass + + item = {k: v for k, v in item.items() if v is not None} + + table.put_item(Item=item) + + return {"statusCode": 200} diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 0000000..c4ac428 --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,39 @@ +module "networking" { + source = "./modules/networking" +} + +module "storage" { + source = "./modules/storage" +} + +module "iam" { + source = "./modules/iam" + + dynamodb_arn = module.storage.table_arn + bucket_arn = module.storage.bucket_arn +} + +module "compute" { + source = "./modules/compute" + + table_name = module.storage.table_name + bucket_name = module.storage.bucket_name + bucket_arn = module.storage.bucket_arn + role_arn = module.iam.role_arn + + groq_api_key = var.groq_api_key + fish_audio_api_key = var.fish_audio_api_key + fish_audio_voice_id = var.fish_audio_voice_id +} + +output "bucket_name" { + value = module.storage.bucket_name +} + +output "table_name" { + value = module.storage.table_name +} + +output "lambda_function_name" { + value = module.compute.lambda_function_name +} diff --git a/infra/modules/compute/main.tf b/infra/modules/compute/main.tf new file mode 100644 index 0000000..7dcbb35 --- /dev/null +++ b/infra/modules/compute/main.tf @@ -0,0 +1,60 @@ +data "archive_file" "lambda_zip" { + type = "zip" + source_file = "${path.root}/lambda/handler.py" + output_path = "${path.root}/lambda/handler.zip" +} + +resource "aws_lambda_function" "uploads_metadata" { + function_name = "uploads-metadata-writer" + role = var.role_arn + filename = data.archive_file.lambda_zip.output_path + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + handler = "handler.handler" + runtime = "python3.12" + timeout = 10 + + environment { + variables = { + TABLE_NAME = var.table_name + DYNAMODB_ENDPOINT_URL = "http://host.docker.internal:4566" + S3_ENDPOINT_URL = "http://host.docker.internal:4566" + } + } +} + +resource "aws_lambda_permission" "allow_s3" { + statement_id = "AllowS3Invoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.uploads_metadata.function_name + principal = "s3.amazonaws.com" + source_arn = var.bucket_arn +} + +resource "aws_s3_bucket_notification" "uploads" { + bucket = var.bucket_name + + lambda_function { + lambda_function_arn = aws_lambda_function.uploads_metadata.arn + events = ["s3:ObjectCreated:*"] + } + + depends_on = [aws_lambda_permission.allow_s3] +} + +resource "aws_ssm_parameter" "groq_api_key" { + name = "/firstwave/backend/GROQ_API_KEY" + type = "SecureString" + value = var.groq_api_key +} + +resource "aws_ssm_parameter" "fish_audio_api_key" { + name = "/firstwave/backend/FISH_AUDIO_API_KEY" + type = "SecureString" + value = var.fish_audio_api_key +} + +resource "aws_ssm_parameter" "fish_audio_voice_id" { + name = "/firstwave/backend/FISH_AUDIO_VOICE_ID" + type = "String" + value = var.fish_audio_voice_id +} diff --git a/infra/modules/compute/outputs.tf b/infra/modules/compute/outputs.tf new file mode 100644 index 0000000..dd08abf --- /dev/null +++ b/infra/modules/compute/outputs.tf @@ -0,0 +1,3 @@ +output "lambda_function_name" { + value = aws_lambda_function.uploads_metadata.function_name +} diff --git a/infra/modules/compute/variables.tf b/infra/modules/compute/variables.tf new file mode 100644 index 0000000..744ad60 --- /dev/null +++ b/infra/modules/compute/variables.tf @@ -0,0 +1,37 @@ +variable "table_name" { + description = "Name of the DynamoDB table the lambda writes to" + type = string +} + +variable "bucket_name" { + description = "Name of the S3 bucket that triggers the lambda" + type = string +} + +variable "bucket_arn" { + description = "ARN of the S3 bucket that triggers the lambda" + type = string +} + +variable "role_arn" { + description = "ARN of the IAM role the lambda assumes" + type = string +} + +variable "groq_api_key" { + description = "Groq API key" + type = string + sensitive = true +} + +variable "fish_audio_api_key" { + description = "Fish Audio API key" + type = string + sensitive = true +} + +variable "fish_audio_voice_id" { + description = "Fish Audio voice ID (not secret)" + type = string + default = "" +} diff --git a/infra/modules/iam/main.tf b/infra/modules/iam/main.tf new file mode 100644 index 0000000..e892afa --- /dev/null +++ b/infra/modules/iam/main.tf @@ -0,0 +1,46 @@ +resource "aws_iam_role" "lambda_exec" { + name = "uploads-metadata-lambda-exec" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + Action = "sts:AssumeRole" + } + ] + }) +} + +resource "aws_iam_role_policy" "lambda_exec" { + name = "uploads-metadata-lambda-exec-policy" + role = aws_iam_role.lambda_exec.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = "dynamodb:PutItem" + Resource = var.dynamodb_arn + }, + { + Effect = "Allow" + Action = "s3:GetObject" + Resource = "${var.bucket_arn}/*" + }, + { + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "arn:aws:logs:*:*:*" + } + ] + }) +} diff --git a/infra/modules/iam/outputs.tf b/infra/modules/iam/outputs.tf new file mode 100644 index 0000000..9ef1db2 --- /dev/null +++ b/infra/modules/iam/outputs.tf @@ -0,0 +1,3 @@ +output "role_arn" { + value = aws_iam_role.lambda_exec.arn +} diff --git a/infra/modules/iam/variables.tf b/infra/modules/iam/variables.tf new file mode 100644 index 0000000..be6cf8f --- /dev/null +++ b/infra/modules/iam/variables.tf @@ -0,0 +1,9 @@ +variable "dynamodb_arn" { + description = "ARN of the DynamoDB table the lambda writes to" + type = string +} + +variable "bucket_arn" { + description = "ARN of the S3 bucket the lambda reads from" + type = string +} diff --git a/infra/modules/networking/main.tf b/infra/modules/networking/main.tf new file mode 100644 index 0000000..d1a3fb4 --- /dev/null +++ b/infra/modules/networking/main.tf @@ -0,0 +1,82 @@ +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + + tags = { + Name = "firstwave-main" + } +} + +resource "aws_subnet" "public" { + vpc_id = aws_vpc.main.id + cidr_block = var.public_subnet_cidr + map_public_ip_on_launch = true + + tags = { + Name = "firstwave-public" + } +} + +resource "aws_subnet" "private" { + vpc_id = aws_vpc.main.id + cidr_block = var.private_subnet_cidr + + tags = { + Name = "firstwave-private" + } +} + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "firstwave-igw" + } +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { + Name = "firstwave-public-rt" + } +} + +resource "aws_route_table_association" "public" { + subnet_id = aws_subnet.public.id + route_table_id = aws_route_table.public.id +} + +# Private subnet intentionally gets no route table association — no outbound +# internet access. NAT gateway deferred until something in the private +# subnet actually needs it. + +resource "aws_security_group" "app" { + name = "firstwave-app" + description = "Backend app traffic (port 8000), scoped to the VPC" + vpc_id = aws_vpc.main.id + + ingress { + description = "Backend API port, VPC-internal only" + from_port = 8000 + to_port = 8000 + protocol = "tcp" + cidr_blocks = [aws_vpc.main.cidr_block] + } + + egress { + description = "Outbound within the VPC" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = [aws_vpc.main.cidr_block] + } + + tags = { + Name = "firstwave-app" + } +} diff --git a/infra/modules/networking/outputs.tf b/infra/modules/networking/outputs.tf new file mode 100644 index 0000000..f4a8d40 --- /dev/null +++ b/infra/modules/networking/outputs.tf @@ -0,0 +1,15 @@ +output "vpc_id" { + value = aws_vpc.main.id +} + +output "public_subnet_id" { + value = aws_subnet.public.id +} + +output "private_subnet_id" { + value = aws_subnet.private.id +} + +output "security_group_id" { + value = aws_security_group.app.id +} diff --git a/infra/modules/networking/variables.tf b/infra/modules/networking/variables.tf new file mode 100644 index 0000000..edc0fa0 --- /dev/null +++ b/infra/modules/networking/variables.tf @@ -0,0 +1,17 @@ +variable "vpc_cidr" { + description = "CIDR block for the VPC" + type = string + default = "10.0.0.0/16" +} + +variable "public_subnet_cidr" { + description = "CIDR block for the public subnet" + type = string + default = "10.0.1.0/24" +} + +variable "private_subnet_cidr" { + description = "CIDR block for the private subnet" + type = string + default = "10.0.2.0/24" +} diff --git a/infra/modules/storage/main.tf b/infra/modules/storage/main.tf new file mode 100644 index 0000000..c1741b5 --- /dev/null +++ b/infra/modules/storage/main.tf @@ -0,0 +1,14 @@ +resource "aws_s3_bucket" "uploads" { + bucket = "firstwave-uploads" +} + +resource "aws_dynamodb_table" "uploads_metadata" { + name = "uploads-metadata" + billing_mode = "PAY_PER_REQUEST" + hash_key = "id" + + attribute { + name = "id" + type = "S" + } +} diff --git a/infra/modules/storage/outputs.tf b/infra/modules/storage/outputs.tf new file mode 100644 index 0000000..4701986 --- /dev/null +++ b/infra/modules/storage/outputs.tf @@ -0,0 +1,15 @@ +output "bucket_name" { + value = aws_s3_bucket.uploads.id +} + +output "bucket_arn" { + value = aws_s3_bucket.uploads.arn +} + +output "table_name" { + value = aws_dynamodb_table.uploads_metadata.name +} + +output "table_arn" { + value = aws_dynamodb_table.uploads_metadata.arn +} diff --git a/infra/moved.tf b/infra/moved.tf new file mode 100644 index 0000000..085518f --- /dev/null +++ b/infra/moved.tf @@ -0,0 +1,84 @@ +moved { + from = aws_vpc.main + to = module.networking.aws_vpc.main +} + +moved { + from = aws_subnet.public + to = module.networking.aws_subnet.public +} + +moved { + from = aws_subnet.private + to = module.networking.aws_subnet.private +} + +moved { + from = aws_internet_gateway.main + to = module.networking.aws_internet_gateway.main +} + +moved { + from = aws_route_table.public + to = module.networking.aws_route_table.public +} + +moved { + from = aws_route_table_association.public + to = module.networking.aws_route_table_association.public +} + +moved { + from = aws_security_group.app + to = module.networking.aws_security_group.app +} + +moved { + from = aws_s3_bucket.uploads + to = module.storage.aws_s3_bucket.uploads +} + +moved { + from = aws_dynamodb_table.uploads_metadata + to = module.storage.aws_dynamodb_table.uploads_metadata +} + +moved { + from = aws_iam_role.lambda_exec + to = module.iam.aws_iam_role.lambda_exec +} + +moved { + from = aws_iam_role_policy.lambda_exec + to = module.iam.aws_iam_role_policy.lambda_exec +} + +moved { + from = aws_lambda_function.uploads_metadata + to = module.compute.aws_lambda_function.uploads_metadata +} + +moved { + from = aws_lambda_permission.allow_s3 + to = module.compute.aws_lambda_permission.allow_s3 +} + +moved { + from = aws_s3_bucket_notification.uploads + to = module.compute.aws_s3_bucket_notification.uploads +} + +moved { + from = aws_ssm_parameter.groq_api_key + to = module.compute.aws_ssm_parameter.groq_api_key +} + +moved { + from = aws_ssm_parameter.fish_audio_api_key + to = module.compute.aws_ssm_parameter.fish_audio_api_key +} + +moved { + from = aws_ssm_parameter.fish_audio_voice_id + to = module.compute.aws_ssm_parameter.fish_audio_voice_id +} diff --git a/infra/provider.tf b/infra/provider.tf new file mode 100644 index 0000000..30cc3dd --- /dev/null +++ b/infra/provider.tf @@ -0,0 +1,49 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + archive = { + source = "hashicorp/archive" + version = "~> 2.4" + } + } + + backend "s3" { + bucket = "firstwave-tf-state" + key = "firstwave/terraform.tfstate" + region = "us-west-2" + use_lockfile = true + + access_key = "test" + secret_key = "test" + skip_credentials_validation = true + skip_metadata_api_check = true + skip_requesting_account_id = true + skip_region_validation = true + + endpoints = { + s3 = "http://s3.localhost.localstack.cloud:4566" + } + } +} + +provider "aws" { + access_key = "test" + secret_key = "test" + region = "us-west-2" + skip_credentials_validation = true + skip_metadata_api_check = true + skip_requesting_account_id = true + + endpoints { + s3 = "http://s3.localhost.localstack.cloud:4566" + s3control = "http://localhost.localstack.cloud:4566" + dynamodb = "http://localhost:4566" + iam = "http://localhost:4566" + lambda = "http://localhost:4566" + ec2 = "http://localhost:4566" + ssm = "http://localhost:4566" + } +} diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example new file mode 100644 index 0000000..a0cdfd1 --- /dev/null +++ b/infra/terraform.tfvars.example @@ -0,0 +1,3 @@ +groq_api_key = "your-groq-api-key-here" +fish_audio_api_key = "your_api_key_here" +fish_audio_voice_id = "optional_voice_id" diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..35a7108 --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,17 @@ +variable "groq_api_key" { + description = "Groq API key" + type = string + sensitive = true +} + +variable "fish_audio_api_key" { + description = "Fish Audio API key" + type = string + sensitive = true +} + +variable "fish_audio_voice_id" { + description = "Fish Audio voice ID (not secret)" + type = string + default = "" +}