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
80 changes: 80 additions & 0 deletions .github/workflows/terraform-ci.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 23 additions & 10 deletions backend/agents/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]


# =============================================================================
Expand Down Expand Up @@ -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
Expand Down
26 changes: 17 additions & 9 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 24 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ services:
- ./backend/.env
environment:
ALLOWED_ORIGINS: http://localhost:3000
SSM_ENDPOINT_URL: http://host.docker.internal:4566

frontend:
build:
Expand All @@ -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:
26 changes: 26 additions & 0 deletions docs/reference/aws-cli.md
Original file line number Diff line number Diff line change
@@ -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
```
35 changes: 35 additions & 0 deletions docs/reference/localstack.md
Original file line number Diff line number Diff line change
@@ -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`
31 changes: 31 additions & 0 deletions docs/reference/terraform.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions infra-bootstrap/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions infra-bootstrap/main.tf
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading