diff --git a/.github/aws/github-oidc-trust-policy.json.example b/.github/aws/github-oidc-trust-policy.json.example index d137634..8f1a9c8 100644 --- a/.github/aws/github-oidc-trust-policy.json.example +++ b/.github/aws/github-oidc-trust-policy.json.example @@ -12,7 +12,10 @@ "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" }, "StringLike": { - "token.actions.githubusercontent.com:sub": "repo:GITHUB_ORG/REPO:*" + "token.actions.githubusercontent.com:sub": [ + "repo:OWNER/REPO:ref:refs/heads/main", + "repo:OWNER/REPO:ref:refs/tags/*" + ] } } } diff --git a/.github/aws/github-oidc-trust-policy.json.tftpl b/.github/aws/github-oidc-trust-policy.json.tftpl new file mode 100644 index 0000000..d33b54d --- /dev/null +++ b/.github/aws/github-oidc-trust-policy.json.tftpl @@ -0,0 +1,20 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "${provider_arn}" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + }, + "StringLike": { + "token.actions.githubusercontent.com:sub": ${github_subs_json} + } + } + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a7e005..916e14f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,4 @@ -# Scaffold — replace this workflow with real checks when the team is ready. -# Suggested jobs: uv sync + tests (backend), npm ci + lint + build (frontend), terraform fmt/validate. - -name: CI (scaffold) +name: CI on: pull_request: @@ -12,12 +9,57 @@ permissions: contents: read jobs: - placeholder: + frontend: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: CI not wired yet + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Require Clerk publishable key for frontend build + run: | + if [ -z "${{ vars.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}" ]; then + echo "Set Actions variable NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY (Clerk publishable key, e.g. pk_test_...)." + echo "Repository Settings → Secrets and variables → Actions → Variables" + exit 1 + fi + + - name: Lint and build frontend + working-directory: frontend + env: + NEXT_PUBLIC_API_URL: "" + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ vars.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + run: | + npm ci + npm run lint + npm run build + + backend-lambda-package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build Lambda artifact + run: python3 scripts/prep_backend_lambda.py + + terraform-validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.6.6 + + - name: Terraform fmt and validate + working-directory: terraform run: | - echo "TalentStreamAI CI scaffold: no commands run here." - echo "Add jobs for backend (uv), frontend (npm), and Terraform when pipelines should gate merges." + terraform fmt -check + terraform init -input=false -backend=false + terraform validate diff --git a/.github/workflows/deploy-aws.yml b/.github/workflows/deploy-aws.yml index b41b583..dd8b2c2 100644 --- a/.github/workflows/deploy-aws.yml +++ b/.github/workflows/deploy-aws.yml @@ -1,31 +1,116 @@ -# Scaffold — no AWS calls. Copy patterns from AWS docs when you add OIDC, terraform apply, image push, etc. - -name: Deploy (scaffold) +name: Deploy AWS on: workflow_dispatch: inputs: environment: - description: Placeholder label for future dev/staging/prod deploys + description: Deployment environment type: choice options: - dev - staging - prod default: dev + target: + description: Which deployment flow to run + type: choice + options: + - oidc + - frontend + - backend + - all + default: all permissions: contents: read + id-token: write jobs: - placeholder: + deploy: runs-on: ubuntu-latest + environment: ${{ inputs.environment }} steps: - uses: actions/checkout@v4 - - name: Deploy not wired yet + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.6.6 + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION || 'eu-central-1' }} + + - name: Setup Terraform backend and tfvars run: | - echo "TalentStreamAI deploy scaffold: no AWS credentials or Terraform apply in this workflow." - echo "When ready: add OIDC (see .github/aws/github-oidc-trust-policy.json.example), repository secrets/vars," - echo "then steps for terraform init/apply, ECR push, S3 sync, CloudFront invalidation, etc." - echo "Environment label (for future use): ${{ inputs.environment }}" + cat > terraform/backend.hcl < terraform/terraform.tfvars < eu-central-1 +python3 scripts/setup_github_oidc.py --environment dev +python3 scripts/terraform_provision.py --environment dev ``` -`./scripts/destroy-aws.sh` is a thin wrapper around `terraform destroy` for when resources eventually exist. +`python3 scripts/destroy_aws.py` runs `terraform destroy` for an environment after confirmation. + +## Packaging and deploy scripts + +Independent scripts are provided for prep, provision, and upload so frontend and backend can be deployed separately or together: + +```bash +# Frontend +python3 scripts/prep_frontend.py +python3 scripts/upload_frontend.py +python3 scripts/deploy_frontend.py --environment dev + +# Backend Lambda +python3 scripts/prep_backend_lambda.py +python3 scripts/upload_backend_lambda.py +python3 scripts/deploy_backend.py --environment dev + +# Full orchestration +python3 scripts/deploy_all.py --environment dev +``` + +Each deploy script supports partial flows (for example `--skip-prep`, `--skip-tf`, `--provision-only`, or `--upload-only`). ## Project structure @@ -177,8 +201,8 @@ TalentStreamAI/ │ ├── Dockerfile # Local `next dev` in Docker (used by Compose) │ ├── next.config.ts │ └── package.json -├── terraform/ # AWS scaffold (no resources yet) -│ ├── main.tf # terraform {} + locals + architecture checklist +├── terraform/ # AWS IaC root (OIDC + S3/CloudFront + API GW/Lambda) +│ ├── main.tf # Core AWS resources and IAM policies │ ├── variables.tf │ ├── outputs.tf │ ├── providers.tf @@ -188,10 +212,17 @@ TalentStreamAI/ ├── scripts/ │ ├── bootstrap-local.sh # uv sync + npm install │ ├── run.sh / stop.sh # Docker Compose up / down -│ └── deploy-aws.sh / destroy-aws.sh # Terraform plan / destroy helpers +│ ├── bootstrap_tf_state.py # One-time S3 + DynamoDB remote state bootstrap +│ ├── setup_github_oidc.py # One-time OIDC provider + deploy role bootstrap +│ ├── terraform_provision.py # terraform init/plan/apply wrapper +│ ├── prep_frontend.py / upload_frontend.py / deploy_frontend.py +│ ├── prep_backend_lambda.py / upload_backend_lambda.py / deploy_backend.py +│ ├── deploy_all.py # One command for frontend + backend +│ └── destroy_aws.py # Terraform destroy helper ├── .github/ │ ├── workflows/ # CI + deploy placeholder workflows │ └── aws/ +│ ├── github-oidc-trust-policy.json.tftpl │ └── github-oidc-trust-policy.json.example ├── docker-compose.yml # Local API (:8000) + Next dev server (:3000) ├── .env.example # Shared env template (copy to repo-root .env) @@ -199,13 +230,40 @@ TalentStreamAI/ └── README.md ``` -## GitHub Actions (scaffold) +## GitHub Actions + +`.github/workflows/ci.yml` now runs: -`.github/workflows/ci.yml` runs on pushes and pull requests to `main` but **only checks out the repository** and echoes that real CI is still to be defined (backend uv/tests, frontend npm/lint/build, Terraform fmt/validate, and so on). +- frontend lint + static build (requires Actions variable `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`, same as [.env.example](.env.example)), +- backend Lambda packaging check, +- Terraform fmt/init(validate with local backend disabled). -`.github/workflows/deploy-aws.yml` is **manual (`workflow_dispatch`) only** and does **not** call AWS or Terraform. It prints a short checklist for when you add OIDC, secrets, `terraform apply`, image pushes, and static asset publishing. +`.github/workflows/deploy-aws.yml` is a manual workflow with targets (`oidc`, `frontend`, `backend`, `all`) that: -For OIDC trust policy shaping, see `.github/aws/github-oidc-trust-policy.json.example` and replace `ACCOUNT_ID`, `GITHUB_ORG`, and `REPO` before attaching it to an IAM role. +- assumes AWS with GitHub OIDC, +- generates backend.hcl and terraform.tfvars from repository/environment variables, +- runs the same deploy scripts used locally. + +For OIDC trust policy shaping, Terraform renders `.github/aws/github-oidc-trust-policy.json.tftpl` using `github_org`, `github_repo`, and `github_ref_patterns` in `terraform.tfvars`. See `.github/aws/github-oidc-trust-policy.json.example` for a human-readable sample with placeholders. + +### Required GitHub variables + +At minimum, configure these repository or environment variables before running the deploy workflow or CI frontend build: + +- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` — Clerk **publishable** key (`pk_test_...` / `pk_live_...` from Clerk Dashboard → API Keys). Baked into the static Next.js export at build time; safe to store as an Actions **variable** (not a secret). +- `AWS_ROLE_ARN` +- `AWS_REGION` +- `AWS_ACCOUNT_ID` +- `TF_STATE_BUCKET` +- `TF_STATE_LOCK_TABLE` +- `REPOSITORY_OWNER` +- `REPOSITORY_NAME` +- `FRONTEND_BUCKET_NAME` +- `CLERK_JWT_ISSUER` +- `CLERK_JWT_AUDIENCE` +- `CORS_ORIGINS` + +Those two repository identifiers become Terraform `github_org` and `github_repo`. Custom GitHub variable names must not start with `GITHUB_`; that prefix is reserved for GitHub Actions built-in contexts. ## Where feature work should land @@ -226,12 +284,16 @@ cd backend && uv sync && uv run python -m compileall -q app Add pytest, Ruff, or mypy when the API surface grows; the scaffold stays intentionally small. +## Lambda runtime note + +FastAPI is exposed to Lambda via `backend/app/lambda_handler.py` using Mangum. `scripts/prep_backend_lambda.py` builds a zip artifact at `dist/backend-lambda.zip` including app code and Lambda dependencies. + ## Troubleshooting quick hits - **UI shows “Backend not responding.”** Confirm Uvicorn is listening on `8000`, `CORS_ORIGINS` includes your UI origin, and `NEXT_PUBLIC_API_URL` matches how your browser reaches the API. - **`docker compose` cannot reach Docker.** Start Docker Desktop (macOS/Windows) or the Linux daemon, then rerun `./scripts/run.sh`. - **`http://localhost:3000` does nothing.** Confirm the frontend container is up (`docker compose ps`) and check `docker compose logs frontend`. If **`docker compose build frontend`** fails or the container exits with **137**, raise **Memory** in Docker Desktop (Settings → Resources), then rebuild. After **`package-lock.json`** changes, run `docker compose build frontend` (or `./scripts/run.sh` with `--build`) again. - **Terraform init asks for backend settings.** Create `terraform/backend.hcl` or export `TALENTSTREAM_USE_LOCAL_TF_STATE=1` for disposable local state. -- **GitHub Actions.** The bundled workflows are placeholders only; there is nothing to “fix” for credentials until you replace them with real jobs. +- **Deploy workflow fails before Terraform apply.** Confirm GitHub repository/environment variables include state bucket/table, role ARN, Clerk issuer/audience, and frontend bucket name. diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 916ada1..3145470 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -90,6 +90,8 @@ class Settings(BaseSettings): default=True, validation_alias=AliasChoices("LANGFUSE_TRACING_ENABLED", "langfuse_tracing_enabled"), ) + # Comma-separated ARNs for Lambda / runtime secret loading (optional locally). + app_secrets_arns: str = "" @property def cors_origins_list(self) -> list[str]: @@ -131,5 +133,9 @@ def _normalize_s3_sse(cls, value: str | None) -> str | None: return "aws:kms" return normalized + @property + def app_secrets_arn_list(self) -> list[str]: + return [arn.strip() for arn in self.app_secrets_arns.split(",") if arn.strip()] + settings = Settings() diff --git a/backend/app/lambda_handler.py b/backend/app/lambda_handler.py new file mode 100644 index 0000000..a6a2d6f --- /dev/null +++ b/backend/app/lambda_handler.py @@ -0,0 +1,5 @@ +from mangum import Mangum + +from app.main import app + +handler = Mangum(app) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e949879..5ea4832 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,6 +5,7 @@ description = "TalentStreamAI FastAPI service" requires-python = ">=3.12" dependencies = [ "fastapi==0.115.12", + "mangum==0.19.0", "uvicorn[standard]==0.34.0", "pydantic-settings==2.8.1", "python-multipart==0.0.20", diff --git a/backend/requirements.lambda.txt b/backend/requirements.lambda.txt new file mode 100644 index 0000000..347bfa7 --- /dev/null +++ b/backend/requirements.lambda.txt @@ -0,0 +1,3 @@ +fastapi==0.115.12 +pydantic-settings==2.8.1 +mangum==0.19.0 diff --git a/deploy.md b/deploy.md new file mode 100644 index 0000000..826037e --- /dev/null +++ b/deploy.md @@ -0,0 +1,320 @@ +# Deployment Guide + +This document is a comprehensive step-by-step walkthrough for deploying TalentStreamAI infrastructure and application artifacts on AWS using Terraform, Python deployment scripts, and GitHub Actions OIDC. + +The deployment model in this repository is: + +- Frontend: static Next.js export -> S3 (private) -> CloudFront +- Backend: FastAPI (Mangum) -> Lambda -> API Gateway HTTP API +- Edge routing: CloudFront `/*` -> S3, CloudFront `/api/*` -> API Gateway +- CI/CD auth: GitHub Actions assumes an AWS IAM role through OIDC (no long-lived AWS keys required) + +--- + +## 1) Prerequisites + +Install these tools locally: + +- Python 3.12+ +- Node.js 20+ +- Terraform 1.6+ +- AWS CLI v2 + +Configure AWS credentials for local bootstrap/deployment: + +```bash +aws configure +aws sts get-caller-identity +``` + +You should also have: + +- An AWS account and region selected (`eu-central-1` / Frankfurt by default) +- A GitHub repository for this project +- Clerk issuer/audience values ready for API Gateway JWT authorizer + +--- + +## 2) Understand deployment scripts + +Core Python scripts are in `scripts/`: + +- `bootstrap_tf_state.py`: one-time S3 + DynamoDB bootstrap for Terraform state +- `setup_github_oidc.py`: one-time OIDC provider + IAM deploy role bootstrap +- `terraform_provision.py`: Terraform init/validate/plan/apply +- `prep_frontend.py`: builds frontend static export (`frontend/out`) +- `upload_frontend.py`: uploads `frontend/out` to S3 + CloudFront invalidation +- `prep_backend_lambda.py`: packages backend zip (`dist/backend-lambda.zip`) +- `upload_backend_lambda.py`: updates Lambda code +- `deploy_frontend.py`: prep -> provision -> upload for frontend +- `deploy_backend.py`: prep -> provision -> upload for backend +- `deploy_all.py`: frontend deploy then backend deploy + +Shell files (`*.sh`) exist as compatibility wrappers and delegate to Python. + +--- + +## 3) One-time Terraform remote state bootstrap + +Run once per AWS account/region: + +```bash +python3 scripts/bootstrap_tf_state.py +``` + +Example: + +```bash +python3 scripts/bootstrap_tf_state.py talentstreamai-tf-state terraform-locks eu-central-1 +``` + +Then create `terraform/backend.hcl` (gitignored): + +```hcl +bucket = "talentstreamai-tf-state" +key = "talentstreamai/dev/terraform.tfstate" +region = "eu-central-1" +dynamodb_table = "terraform-locks" +encrypt = true +``` + +--- + +## 4) Configure Terraform variables + +Copy the example file: + +```bash +cp terraform/terraform.tfvars.example terraform/terraform.tfvars +``` + +Edit required values in `terraform/terraform.tfvars`: + +- `github_org` +- `github_repo` +- `frontend_bucket_name` (globally unique S3 bucket name) +- `clerk_jwt_issuer` +- `clerk_jwt_audiences` +- `cors_origins` (set to your CloudFront URL) +- `state_bucket_arn` +- `state_bucket_objects_arn` +- `state_lock_table_arn` + +Optional but recommended: + +- `lambda_environment` map for non-secret runtime config +- `lambda_secret_arns` if using pre-existing Secrets Manager secrets + +--- + +## 5) Bootstrap GitHub OIDC deploy role (one-time) + +This step creates/updates: + +- `aws_iam_openid_connect_provider` (unless disabled via tfvars) +- GitHub deploy IAM role +- Deploy role policy attachment + +Run: + +```bash +python3 scripts/setup_github_oidc.py --environment dev +``` + +Capture output: + +- `github_actions_role_arn` + +Use it as your GitHub `AWS_ROLE_ARN` variable. + +--- + +## 6) Local deployment flows + +## 6.1 Full deploy (frontend + backend) + +```bash +python3 scripts/deploy_all.py --environment dev +``` + +This does: + +1. Frontend prep/build +2. Terraform provision/apply +3. Frontend upload and CloudFront invalidation +4. Backend package +5. Backend Lambda code upload + +Note: Backend deploy is invoked with `--skip-tf` inside `deploy_all.py` to avoid duplicate apply. + +## 6.2 Frontend only + +```bash +python3 scripts/deploy_frontend.py --environment dev +``` + +## 6.3 Backend only + +```bash +python3 scripts/deploy_backend.py --environment dev +``` + +## 6.4 Partial deploy options + +Both frontend and backend deploy scripts support: + +- `--skip-prep` (reuse existing artifact) +- `--skip-tf` (skip Terraform apply) +- `--provision-only` (apply infra only) +- `--upload-only` (artifact upload only; implies skip prep + skip tf) + +Examples: + +```bash +python3 scripts/deploy_frontend.py --environment dev --provision-only +python3 scripts/deploy_backend.py --environment dev --upload-only +``` + +--- + +## 7) GitHub Actions deployment + +Workflow file: `.github/workflows/deploy-aws.yml` +Trigger: manual (`workflow_dispatch`) with: + +- `environment`: `dev|staging|prod` +- `target`: `oidc|frontend|backend|all` + +### 7.1 Required GitHub variables + +Set these repository/environment variables. Names must not start with `GITHUB_`; that prefix is reserved for GitHub Actions built-in variables and contexts. + +- `AWS_ROLE_ARN` +- `AWS_REGION` +- `AWS_ACCOUNT_ID` +- `TF_STATE_BUCKET` +- `TF_STATE_LOCK_TABLE` +- `TF_STATE_KEY_PREFIX` (optional, defaults to `talentstreamai`) +- `TF_PROJECT_NAME` (optional) +- `REPOSITORY_OWNER` (GitHub org or user slug) +- `REPOSITORY_NAME` (repository name only, without the owner prefix) +- `FRONTEND_BUCKET_NAME` +- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` — same value as repo-root `.env` / [.env.example](.env.example); required for static `next build` in CI and deploy workflows (Clerk publishable key, safe as an Actions variable). +- `CLERK_JWT_ISSUER` +- `CLERK_JWT_AUDIENCE` +- `CORS_ORIGINS` +- `TF_CREATE_OIDC_PROVIDER` (optional) +- `TF_EXISTING_OIDC_PROVIDER_ARN` (optional) +- `TF_DEPLOY_ROLE_NAME` (optional) +- `LAMBDA_FUNCTION_NAME` (optional) +- `APP_CONFIG_SECRET_NAME` (optional) + +### 7.2 Run the workflow + +From GitHub UI: + +1. Go to Actions -> `Deploy AWS` +2. Click `Run workflow` +3. Pick environment (`dev`, `staging`, `prod`) +4. Pick target: + - `oidc`: bootstrap OIDC role only + - `frontend`: deploy frontend only + - `backend`: deploy backend only + - `all`: full deploy + +The workflow generates `terraform/backend.hcl` and `terraform/terraform.tfvars` dynamically from those variables, then runs the same Python scripts used locally. + +--- + +## 8) Secrets and environment variable strategy + +## 8.1 Lambda runtime env + +Non-secrets: + +- Use Terraform vars and `lambda_environment` +- Examples: `DEPLOYMENT_ENVIRONMENT`, logging flags, feature toggles + +Secrets: + +- Store secret values in AWS Secrets Manager (not in git, not in tfvars) +- Provide secret ARNs via `lambda_secret_arns` and/or use generated `app_config_secret_arn` +- Lambda role already supports `secretsmanager:GetSecretValue` for configured ARNs + +## 8.2 Frontend env + +Frontend is static export; env values are baked at build time. + +- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` is required for `next build` (see [frontend/src/components/clerk-provider.tsx](frontend/src/components/clerk-provider.tsx)); set it in GitHub Actions variables for CI and deploy jobs that run the frontend build. +- `NEXT_PUBLIC_API_URL` defaults to empty for same-origin `/api/*` +- Set other `NEXT_PUBLIC_*` values in the CI environment if needed at build time + +--- + +## 9) Verify deployment + +After successful apply/deploy: + +```bash +cd terraform +terraform output cloudfront_domain_name +terraform output api_gateway_endpoint +terraform output frontend_bucket_name +terraform output lambda_function_name +``` + +Check: + +- CloudFront URL loads frontend +- `/api/v1/health` works via CloudFront path routing +- Lambda logs appear in CloudWatch log group + +--- + +## 10) Destroy resources + +Interactive destroy: + +```bash +python3 scripts/destroy_aws.py dev +``` + +Non-interactive: + +```bash +python3 scripts/destroy_aws.py dev --yes +``` + +--- + +## 11) Common issues and fixes + +- **`terraform init` fails on backend config** + Ensure `terraform/backend.hcl` exists or set `TALENTSTREAM_USE_LOCAL_TF_STATE=1` for disposable local state. + +- **OIDC assume role fails in GitHub Actions** + Confirm trust policy matches repo/ref patterns and `AWS_ROLE_ARN` is correct. + +- **CloudFront serves frontend but API fails** + Verify CloudFront `/api/*` behavior exists, API Gateway routes are present, and Clerk issuer/audience values are correct. + +- **Lambda code updated but env change not reflected** + Run Terraform apply; env/config changes are not applied by `update-function-code` alone. + +- **Frontend points to wrong API** + Ensure `NEXT_PUBLIC_API_URL` behavior is intentional (empty = same-origin `/api/*`). + +--- + +## 12) Recommended rollout order per environment + +For each new environment (`dev`, `staging`, `prod`): + +1. Bootstrap remote state once (if not already done) +2. Configure env-specific `terraform/backend.hcl` key path +3. Prepare `terraform.tfvars` values for that environment +4. Run `python3 scripts/setup_github_oidc.py --environment ` if needed +5. Run `python3 scripts/deploy_all.py --environment ` +6. Verify outputs and health endpoint + +This keeps the same tooling and sequence for local and CI/CD deployments. diff --git a/scripts/_common.py b/scripts/_common.py new file mode 100644 index 0000000..2c8c32f --- /dev/null +++ b/scripts/_common.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import Iterable + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = ROOT / "scripts" +TERRAFORM_DIR = ROOT / "terraform" + + +def require_command(command: str) -> None: + from shutil import which + + if which(command) is None: + raise SystemExit(f"{command} is required but not available on PATH.") + + +def run( + command: list[str], + *, + cwd: Path | None = None, + env: dict[str, str] | None = None, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + merged_env = os.environ.copy() + if env: + merged_env.update(env) + return subprocess.run( + command, + cwd=str(cwd) if cwd else None, + env=merged_env, + text=True, + check=check, + ) + + +def capture(command: list[str], *, cwd: Path | None = None) -> str: + result = subprocess.run( + command, + cwd=str(cwd) if cwd else None, + text=True, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + return result.stdout.strip() + + +def ensure_environment(environment: str) -> None: + if environment not in {"dev", "staging", "prod"}: + raise SystemExit(f"Invalid environment: {environment}. Expected dev, staging, or prod.") + + +def terraform_init(terraform_dir: Path) -> None: + backend_hcl = terraform_dir / "backend.hcl" + use_local_state = os.getenv("TALENTSTREAM_USE_LOCAL_TF_STATE") == "1" + + if backend_hcl.exists(): + run(["terraform", "init", "-input=false", "-backend-config=backend.hcl"], cwd=terraform_dir) + elif use_local_state: + print("Using local Terraform state (TALENTSTREAM_USE_LOCAL_TF_STATE=1).") + run(["terraform", "init", "-input=false", "-backend=false"], cwd=terraform_dir) + else: + raise SystemExit( + f"Missing {backend_hcl}. Copy backend.hcl.example to backend.hcl, " + "or set TALENTSTREAM_USE_LOCAL_TF_STATE=1." + ) + + +def ensure_tfvars(terraform_dir: Path) -> None: + tfvars = terraform_dir / "terraform.tfvars" + if not tfvars.exists(): + raise SystemExit( + f"Missing {tfvars}. Copy terraform.tfvars.example to terraform.tfvars and update values." + ) + + +def passthrough_args(args: Iterable[str]) -> list[str]: + return list(args) + + +def print_step(message: str) -> None: + print(f"\n==> {message}") + sys.stdout.flush() diff --git a/scripts/bootstrap-tf-state.sh b/scripts/bootstrap-tf-state.sh new file mode 100755 index 0000000..439cff6 --- /dev/null +++ b/scripts/bootstrap-tf-state.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 "${ROOT}/scripts/bootstrap_tf_state.py" "$@" diff --git a/scripts/bootstrap_tf_state.py b/scripts/bootstrap_tf_state.py new file mode 100644 index 0000000..d8ab154 --- /dev/null +++ b/scripts/bootstrap_tf_state.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json + +from _common import require_command, run + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Bootstrap Terraform remote state resources (S3 + DynamoDB)." + ) + parser.add_argument("state_bucket_name") + parser.add_argument("lock_table_name") + parser.add_argument("region", nargs="?", default="eu-central-1") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + require_command("aws") + + bucket = args.state_bucket_name + table = args.lock_table_name + region = args.region + + print(f"Bootstrapping Terraform state resources in region: {region}") + + head_bucket = run( + ["aws", "s3api", "head-bucket", "--bucket", bucket], + check=False, + ) + if head_bucket.returncode == 0: + print(f"State bucket already exists: {bucket}") + else: + if region == "us-east-1": + run(["aws", "s3api", "create-bucket", "--bucket", bucket, "--region", region]) + else: + run( + [ + "aws", + "s3api", + "create-bucket", + "--bucket", + bucket, + "--region", + region, + "--create-bucket-configuration", + f"LocationConstraint={region}", + ] + ) + print(f"Created bucket: {bucket}") + + run( + [ + "aws", + "s3api", + "put-bucket-versioning", + "--bucket", + bucket, + "--versioning-configuration", + "Status=Enabled", + ] + ) + + run( + [ + "aws", + "s3api", + "put-bucket-encryption", + "--bucket", + bucket, + "--server-side-encryption-configuration", + json.dumps( + { + "Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}] + } + ), + ] + ) + + run( + [ + "aws", + "s3api", + "put-public-access-block", + "--bucket", + bucket, + "--public-access-block-configuration", + "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true", + ] + ) + + describe_table = run( + ["aws", "dynamodb", "describe-table", "--table-name", table, "--region", region], + check=False, + ) + if describe_table.returncode == 0: + print(f"Lock table already exists: {table}") + else: + run( + [ + "aws", + "dynamodb", + "create-table", + "--table-name", + table, + "--attribute-definitions", + "AttributeName=LockID,AttributeType=S", + "--key-schema", + "AttributeName=LockID,KeyType=HASH", + "--billing-mode", + "PAY_PER_REQUEST", + "--region", + region, + ] + ) + print(f"Created lock table: {table}") + + print( + "\nBootstrap complete.\n\n" + "Suggested terraform/backend.hcl:\n" + f'bucket = "{bucket}"\n' + 'key = "talentstreamai/dev/terraform.tfstate"\n' + f'region = "{region}"\n' + f'dynamodb_table = "{table}"\n' + "encrypt = true\n" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/deploy-all.sh b/scripts/deploy-all.sh new file mode 100755 index 0000000..292ed8d --- /dev/null +++ b/scripts/deploy-all.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 "${ROOT}/scripts/deploy_all.py" "$@" diff --git a/scripts/deploy-aws.sh b/scripts/deploy-aws.sh index 8be5ffa..2b9338f 100755 --- a/scripts/deploy-aws.sh +++ b/scripts/deploy-aws.sh @@ -2,48 +2,4 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TF_DIR="$ROOT/terraform" -ENVIRONMENT="${TF_ENVIRONMENT:-${1:-dev}}" - -if ! command -v terraform >/dev/null 2>&1; then - echo "Terraform is not installed or not on PATH." - exit 1 -fi - -if ! command -v aws >/dev/null 2>&1; then - echo "AWS CLI is not installed. Install it and configure credentials before using remote state." - exit 1 -fi - -case "$ENVIRONMENT" in - dev | staging | prod) ;; - *) - echo "Invalid environment: $ENVIRONMENT (expected dev, staging, or prod)" - exit 1 - ;; -esac - -cd "$TF_DIR" - -if [ -f backend.hcl ]; then - terraform init -input=false -backend-config=backend.hcl -elif [ "${TALENTSTREAM_USE_LOCAL_TF_STATE:-}" = "1" ]; then - echo "Using local Terraform state (TALENTSTREAM_USE_LOCAL_TF_STATE=1)." - terraform init -input=false -backend=false -else - echo "Missing $TF_DIR/backend.hcl" - echo "Copy backend.hcl.example to backend.hcl and fill in your S3 remote state settings," - echo "or export TALENTSTREAM_USE_LOCAL_TF_STATE=1 for a disposable local state file." - exit 1 -fi - -if [ ! -f terraform.tfvars ]; then - echo "Missing terraform.tfvars in $TF_DIR" - echo "Copy terraform.tfvars.example to terraform.tfvars, adjust values, then rerun." - exit 1 -fi - -terraform plan -var="environment=${ENVIRONMENT}" -out=tfplan -echo -echo "Scaffold: there are no resources in main.tf yet. When you add them, review the plan and run:" -echo " terraform apply tfplan" +exec python3 "${ROOT}/scripts/deploy_aws.py" "$@" diff --git a/scripts/deploy-backend.sh b/scripts/deploy-backend.sh new file mode 100755 index 0000000..79bcf79 --- /dev/null +++ b/scripts/deploy-backend.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 "${ROOT}/scripts/deploy_backend.py" "$@" diff --git a/scripts/deploy-frontend.sh b/scripts/deploy-frontend.sh new file mode 100755 index 0000000..cc58276 --- /dev/null +++ b/scripts/deploy-frontend.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 "${ROOT}/scripts/deploy_frontend.py" "$@" diff --git a/scripts/deploy_all.py b/scripts/deploy_all.py new file mode 100644 index 0000000..3e08f51 --- /dev/null +++ b/scripts/deploy_all.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os + +from _common import SCRIPTS_DIR, ensure_environment, run + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Deploy frontend and backend sequentially.") + parser.add_argument("--environment", default=None, help="dev/staging/prod") + parser.add_argument("--skip-prep", action="store_true") + parser.add_argument("--skip-tf", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + environment = args.environment or os.environ.get("TF_ENVIRONMENT", "dev") + ensure_environment(environment) + + env = {"TF_ENVIRONMENT": environment} + front_cmd = ["python3", str(SCRIPTS_DIR / "deploy_frontend.py"), "--environment", environment] + back_cmd = [ + "python3", + str(SCRIPTS_DIR / "deploy_backend.py"), + "--environment", + environment, + "--skip-tf", + ] + + if args.skip_prep: + front_cmd.append("--skip-prep") + back_cmd.append("--skip-prep") + if args.skip_tf: + front_cmd.append("--skip-tf") + + run(front_cmd, env=env) + run(back_cmd, env=env) + print(f"Completed frontend + backend deploy flow for {environment}.") + + +if __name__ == "__main__": + main() diff --git a/scripts/deploy_aws.py b/scripts/deploy_aws.py new file mode 100644 index 0000000..917a6cd --- /dev/null +++ b/scripts/deploy_aws.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse + +from _common import SCRIPTS_DIR, run + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Compatibility wrapper around terraform_provision.py.") + parser.add_argument("environment", nargs="?", default="dev") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + print("scripts/deploy_aws.py delegates to scripts/terraform_provision.py") + run( + [ + "python3", + str(SCRIPTS_DIR / "terraform_provision.py"), + "--environment", + args.environment, + ], + env={"TF_ENVIRONMENT": args.environment}, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/deploy_backend.py b/scripts/deploy_backend.py new file mode 100644 index 0000000..d492324 --- /dev/null +++ b/scripts/deploy_backend.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os + +from _common import SCRIPTS_DIR, ensure_environment, run + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Deploy backend Lambda (prep -> provision -> upload).") + parser.add_argument("--environment", default=None, help="dev/staging/prod") + parser.add_argument("--skip-prep", action="store_true") + parser.add_argument("--skip-tf", action="store_true") + parser.add_argument("--upload-only", action="store_true") + parser.add_argument("--provision-only", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + environment = args.environment or os.environ.get("TF_ENVIRONMENT", "dev") + ensure_environment(environment) + + upload_only = args.upload_only + skip_prep = args.skip_prep or upload_only + skip_tf = args.skip_tf or upload_only + + env = {"TF_ENVIRONMENT": environment} + if not skip_prep: + run(["python3", str(SCRIPTS_DIR / "prep_backend_lambda.py")], env=env) + + if not skip_tf: + run(["python3", str(SCRIPTS_DIR / "terraform_provision.py"), "--environment", environment], env=env) + + if args.provision_only: + return + + run(["python3", str(SCRIPTS_DIR / "upload_backend_lambda.py")], env=env) + + +if __name__ == "__main__": + main() diff --git a/scripts/deploy_frontend.py b/scripts/deploy_frontend.py new file mode 100644 index 0000000..e49c0ca --- /dev/null +++ b/scripts/deploy_frontend.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from _common import SCRIPTS_DIR, ensure_environment, run + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Deploy frontend (prep -> provision -> upload).") + parser.add_argument("--environment", default=None, help="dev/staging/prod") + parser.add_argument("--skip-prep", action="store_true") + parser.add_argument("--skip-tf", action="store_true") + parser.add_argument("--upload-only", action="store_true") + parser.add_argument("--provision-only", action="store_true") + return parser.parse_args() + + +def call(script: str, environment: str) -> None: + run(["python3", str(SCRIPTS_DIR / script)], env={"TF_ENVIRONMENT": environment}) + + +def main() -> None: + args = parse_args() + environment = args.environment or os.environ.get("TF_ENVIRONMENT", "dev") + ensure_environment(environment) + + upload_only = args.upload_only + skip_prep = args.skip_prep or upload_only + skip_tf = args.skip_tf or upload_only + + if not skip_prep: + call("prep_frontend.py", environment) + + if not skip_tf: + run( + [ + "python3", + str(SCRIPTS_DIR / "terraform_provision.py"), + "--environment", + environment, + ], + env={"TF_ENVIRONMENT": environment}, + ) + + if args.provision_only: + return + + call("upload_frontend.py", environment) + + +if __name__ == "__main__": + main() diff --git a/scripts/destroy-aws.sh b/scripts/destroy-aws.sh index 386d93e..e4291a2 100755 --- a/scripts/destroy-aws.sh +++ b/scripts/destroy-aws.sh @@ -2,34 +2,4 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TF_DIR="$ROOT/terraform" -ENVIRONMENT="${TF_ENVIRONMENT:-${1:-dev}}" - -if ! command -v terraform >/dev/null 2>&1; then - echo "Terraform is not installed or not on PATH." - exit 1 -fi - -case "$ENVIRONMENT" in - dev | staging | prod) ;; - *) - echo "Invalid environment: $ENVIRONMENT (expected dev, staging, or prod)" - exit 1 - ;; -esac - -cd "$TF_DIR" - -if [ ! -d .terraform ]; then - echo "Terraform has not been initialized in $TF_DIR. Run ./scripts/deploy-aws.sh first." - exit 1 -fi - -echo "Scaffold: destroy is a no-op until resources exist. When they do, this will remove environment=${ENVIRONMENT} from state." -read -r -p "Type 'destroy' to continue: " confirm -if [ "$confirm" != "destroy" ]; then - echo "Aborted." - exit 1 -fi - -terraform destroy -auto-approve -var="environment=${ENVIRONMENT}" +exec python3 "${ROOT}/scripts/destroy_aws.py" "$@" diff --git a/scripts/destroy_aws.py b/scripts/destroy_aws.py new file mode 100644 index 0000000..a895e5b --- /dev/null +++ b/scripts/destroy_aws.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse + +from _common import TERRAFORM_DIR, ensure_environment, require_command, run, terraform_init + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Destroy Terraform-managed resources.") + parser.add_argument("environment", nargs="?", default="dev") + parser.add_argument("--yes", action="store_true", help="Skip interactive confirmation") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + require_command("terraform") + ensure_environment(args.environment) + + terraform_init(TERRAFORM_DIR) + + if not args.yes: + confirm = input("Type 'destroy' to continue: ").strip() + if confirm != "destroy": + raise SystemExit("Aborted.") + + run( + [ + "terraform", + "destroy", + "-auto-approve", + f"-var=environment={args.environment}", + ], + cwd=TERRAFORM_DIR, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/prep-backend-lambda.sh b/scripts/prep-backend-lambda.sh new file mode 100755 index 0000000..6208a02 --- /dev/null +++ b/scripts/prep-backend-lambda.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 "${ROOT}/scripts/prep_backend_lambda.py" "$@" diff --git a/scripts/prep-frontend.sh b/scripts/prep-frontend.sh new file mode 100755 index 0000000..853c715 --- /dev/null +++ b/scripts/prep-frontend.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 "${ROOT}/scripts/prep_frontend.py" "$@" diff --git a/scripts/prep_backend_lambda.py b/scripts/prep_backend_lambda.py new file mode 100644 index 0000000..970d57a --- /dev/null +++ b/scripts/prep_backend_lambda.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import shutil +import zipfile + +from _common import ROOT, require_command, run + + +def main() -> None: + require_command("python3") + + backend_dir = ROOT / "backend" + build_dir = ROOT / "dist" / "backend-lambda" + artifact_path = ROOT / "dist" / "backend-lambda.zip" + + print("Preparing Lambda package...") + if build_dir.exists(): + shutil.rmtree(build_dir) + build_dir.mkdir(parents=True, exist_ok=True) + + run(["python3", "-m", "pip", "install", "--upgrade", "pip"]) + run( + [ + "python3", + "-m", + "pip", + "install", + "-r", + str(backend_dir / "requirements.lambda.txt"), + "-t", + str(build_dir), + ] + ) + + shutil.copytree(backend_dir / "app", build_dir / "app", dirs_exist_ok=True) + + artifact_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(artifact_path, "w", zipfile.ZIP_DEFLATED) as archive: + for path in build_dir.rglob("*"): + if path.is_file(): + archive.write(path, path.relative_to(build_dir)) + + print(f"Backend Lambda artifact ready at {artifact_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/prep_frontend.py b/scripts/prep_frontend.py new file mode 100644 index 0000000..d01a506 --- /dev/null +++ b/scripts/prep_frontend.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os + +from _common import ROOT, require_command, run + + +def main() -> None: + require_command("npm") + frontend_dir = ROOT / "frontend" + out_dir = frontend_dir / "out" + + print("Installing frontend dependencies...") + run(["npm", "ci"], cwd=frontend_dir) + + print("Building static frontend export...") + env: dict[str, str] = { + "NEXT_PUBLIC_API_URL": os.environ.get("NEXT_PUBLIC_API_URL", ""), + } + clerk_key = os.environ.get("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY") + if clerk_key: + env["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"] = clerk_key + run(["npm", "run", "build"], cwd=frontend_dir, env=env) + + if not out_dir.exists(): + raise SystemExit("Expected build output directory frontend/out was not created.") + + print("Frontend package ready at frontend/out") + + +if __name__ == "__main__": + main() diff --git a/scripts/setup-github-oidc.sh b/scripts/setup-github-oidc.sh new file mode 100755 index 0000000..b78caa4 --- /dev/null +++ b/scripts/setup-github-oidc.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 "${ROOT}/scripts/setup_github_oidc.py" "$@" diff --git a/scripts/setup_github_oidc.py b/scripts/setup_github_oidc.py new file mode 100644 index 0000000..e4a2e99 --- /dev/null +++ b/scripts/setup_github_oidc.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from pathlib import Path + +from _common import TERRAFORM_DIR, capture, ensure_environment, ensure_tfvars, require_command, run, terraform_init + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="One-time OIDC deploy role bootstrap using targeted Terraform apply." + ) + parser.add_argument("--environment", default=None, help="dev/staging/prod") + return parser.parse_args() + + +def should_create_oidc_provider(tfvars_path: Path) -> bool: + if not tfvars_path.exists(): + return True + content = tfvars_path.read_text(encoding="utf-8") + return "create_oidc_provider = false" not in content + + +def main() -> None: + args = parse_args() + require_command("terraform") + + environment = args.environment or __import__("os").environ.get("TF_ENVIRONMENT", "dev") + ensure_environment(environment) + + terraform_init(TERRAFORM_DIR) + ensure_tfvars(TERRAFORM_DIR) + + targets = [ + "-target=aws_iam_role.github_actions_deploy", + "-target=aws_iam_policy.github_actions_deploy", + "-target=aws_iam_role_policy_attachment.github_actions_deploy", + ] + if should_create_oidc_provider(TERRAFORM_DIR / "terraform.tfvars"): + targets.append("-target=aws_iam_openid_connect_provider.github") + + run( + [ + "terraform", + "apply", + "-auto-approve", + f"-var=environment={environment}", + *targets, + ], + cwd=TERRAFORM_DIR, + ) + + role_arn = capture(["terraform", "output", "-raw", "github_actions_role_arn"], cwd=TERRAFORM_DIR) + print("\nOIDC bootstrap complete.") + print("GitHub Actions role ARN:") + print(role_arn) + + +if __name__ == "__main__": + main() diff --git a/scripts/terraform-provision.sh b/scripts/terraform-provision.sh new file mode 100755 index 0000000..993307e --- /dev/null +++ b/scripts/terraform-provision.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 "${ROOT}/scripts/terraform_provision.py" "$@" diff --git a/scripts/terraform_provision.py b/scripts/terraform_provision.py new file mode 100644 index 0000000..06d0330 --- /dev/null +++ b/scripts/terraform_provision.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse + +from _common import TERRAFORM_DIR, ensure_environment, ensure_tfvars, require_command, run, terraform_init + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Terraform init/plan/apply helper.") + parser.add_argument( + "--environment", + default=None, + help="Deployment environment (dev/staging/prod). Defaults to TF_ENVIRONMENT or dev.", + ) + parser.add_argument("--plan-only", action="store_true", help="Only run terraform plan.") + parser.add_argument( + "--no-auto-approve", + action="store_true", + help="Disable -auto-approve when applying.", + ) + parser.add_argument("extra_args", nargs=argparse.REMAINDER) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + require_command("terraform") + + environment = args.environment or __import__("os").environ.get("TF_ENVIRONMENT", "dev") + ensure_environment(environment) + + terraform_init(TERRAFORM_DIR) + ensure_tfvars(TERRAFORM_DIR) + + run(["terraform", "fmt", "-check"], cwd=TERRAFORM_DIR) + run(["terraform", "validate"], cwd=TERRAFORM_DIR) + + passthrough = args.extra_args + if passthrough and passthrough[0] == "--": + passthrough = passthrough[1:] + + if args.plan_only: + run(["terraform", "plan", f"-var=environment={environment}", *passthrough], cwd=TERRAFORM_DIR) + return + + plan_file = f"tfplan-{environment}" + run( + ["terraform", "plan", f"-var=environment={environment}", f"-out={plan_file}", *passthrough], + cwd=TERRAFORM_DIR, + ) + + apply_cmd = ["terraform", "apply", plan_file] + if not args.no_auto_approve: + apply_cmd.insert(2, "-auto-approve") + run(apply_cmd, cwd=TERRAFORM_DIR) + + +if __name__ == "__main__": + main() diff --git a/scripts/upload-backend-lambda.sh b/scripts/upload-backend-lambda.sh new file mode 100755 index 0000000..e5bfe84 --- /dev/null +++ b/scripts/upload-backend-lambda.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 "${ROOT}/scripts/upload_backend_lambda.py" "$@" diff --git a/scripts/upload-frontend.sh b/scripts/upload-frontend.sh new file mode 100755 index 0000000..d592ebb --- /dev/null +++ b/scripts/upload-frontend.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 "${ROOT}/scripts/upload_frontend.py" "$@" diff --git a/scripts/upload_backend_lambda.py b/scripts/upload_backend_lambda.py new file mode 100644 index 0000000..36bc7ee --- /dev/null +++ b/scripts/upload_backend_lambda.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os + +from _common import ROOT, TERRAFORM_DIR, capture, require_command, run + + +def main() -> None: + require_command("aws") + require_command("terraform") + + environment = os.environ.get("TF_ENVIRONMENT", "dev") + artifact_path = ROOT / "dist" / "backend-lambda.zip" + if not artifact_path.exists(): + raise SystemExit( + f"Missing artifact {artifact_path}. Run scripts/prep_backend_lambda.py first." + ) + + function_name = capture(["terraform", "output", "-raw", "lambda_function_name"], cwd=TERRAFORM_DIR) + print(f"Updating Lambda function code for {function_name}...") + run( + [ + "aws", + "lambda", + "update-function-code", + "--function-name", + function_name, + "--zip-file", + f"fileb://{artifact_path}", + ] + ) + print(f"Lambda upload complete for environment {environment}.") + + +if __name__ == "__main__": + main() diff --git a/scripts/upload_frontend.py b/scripts/upload_frontend.py new file mode 100644 index 0000000..703559e --- /dev/null +++ b/scripts/upload_frontend.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os + +from _common import ROOT, TERRAFORM_DIR, capture, require_command, run + + +def main() -> None: + require_command("aws") + require_command("terraform") + + environment = os.environ.get("TF_ENVIRONMENT", "dev") + frontend_out_dir = ROOT / "frontend" / "out" + if not frontend_out_dir.exists(): + raise SystemExit( + f"Missing frontend build output at {frontend_out_dir}. Run scripts/prep_frontend.py first." + ) + + bucket_name = capture(["terraform", "output", "-raw", "frontend_bucket_name"], cwd=TERRAFORM_DIR) + distribution_id = capture( + ["terraform", "output", "-raw", "cloudfront_distribution_id"], cwd=TERRAFORM_DIR + ) + + print(f"Syncing frontend assets to s3://{bucket_name}...") + run(["aws", "s3", "sync", f"{frontend_out_dir}/", f"s3://{bucket_name}/", "--delete"]) + + print(f"Creating CloudFront invalidation for distribution {distribution_id}...") + run( + [ + "aws", + "cloudfront", + "create-invalidation", + "--distribution-id", + distribution_id, + "--paths", + "/*", + ] + ) + + print(f"Frontend upload complete for environment {environment}.") + + +if __name__ == "__main__": + main() diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl index 0bf1214..d86e8aa 100644 --- a/terraform/.terraform.lock.hcl +++ b/terraform/.terraform.lock.hcl @@ -1,11 +1,33 @@ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. +provider "registry.terraform.io/hashicorp/archive" { + version = "2.7.1" + constraints = "~> 2.5" + hashes = [ + "h1:62VrkalDPMKB9zerCBS4iKTbvxejwnAWn/XXYZZQWD4=", + "h1:A7EnRBVm4h9ryO9LwxYnKr4fy7ExPMwD5a1DsY7m1Y0=", + "zh:19881bb356a4a656a865f48aee70c0b8a03c35951b7799b6113883f67f196e8e", + "zh:2fcfbf6318dd514863268b09bbe19bfc958339c636bcbcc3664b45f2b8bf5cc6", + "zh:3323ab9a504ce0a115c28e64d0739369fe85151291a2ce480d51ccbb0c381ac5", + "zh:362674746fb3da3ab9bd4e70c75a3cdd9801a6cf258991102e2c46669cf68e19", + "zh:7140a46d748fdd12212161445c46bbbf30a3f4586c6ac97dd497f0c2565fe949", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:875e6ce78b10f73b1efc849bfcc7af3a28c83a52f878f503bb22776f71d79521", + "zh:b872c6ed24e38428d817ebfb214da69ea7eefc2c38e5a774db2ccd58e54d3a22", + "zh:cd6a44f731c1633ae5d37662af86e7b01ae4c96eb8b04144255824c3f350392d", + "zh:e0600f5e8da12710b0c52d6df0ba147a5486427c1a2cc78f31eea37a47ee1b07", + "zh:f21b2e2563bbb1e44e73557bcd6cdbc1ceb369d471049c40eb56cb84b6317a60", + "zh:f752829eba1cc04a479cf7ae7271526b402e206d5bcf1fcce9f535de5ff9e4e6", + ] +} + provider "registry.terraform.io/hashicorp/aws" { version = "5.100.0" constraints = "~> 5.75" hashes = [ "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "h1:edXOJWE4ORX8Fm+dpVpICzMZJat4AX0VRCAy/xkcOc0=", "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", @@ -23,3 +45,23 @@ provider "registry.terraform.io/hashicorp/aws" { "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", ] } + +provider "registry.terraform.io/hashicorp/tls" { + version = "4.2.1" + constraints = "~> 4.0" + hashes = [ + "h1:akFNuHwvrtnYMBofieoeXhPJDhYZzJVu/Q/BgZK2fgg=", + "zh:0d1e7d07ac973b97fa228f46596c800de830820506ee145626f079dd6bbf8d8a", + "zh:5c7e3d4348cb4861ab812973ef493814a4b224bdd3e9d534a7c8a7c992382b86", + "zh:7c6d4a86cd7a4e9c1025c6b3a3a6a45dea202af85d870cddbab455fb1bd568ad", + "zh:7d0864755ba093664c4b2c07c045d3f5e3d7c799dda1a3ef33d17ed1ac563191", + "zh:83734f57950ab67c0d6a87babdb3f13c908cbe0a48949333f489698532e1391b", + "zh:951e3c285218ebca0cf20eaa4265020b4ef042fea9c6ade115ad1558cfe459e5", + "zh:b9543955b4297e1d93b85900854891c0e645d936d8285a190030475379c5c635", + "zh:bb1bd9e86c003d08c30c1b00d44118ed5bbbf6b1d2d6f7eaac4fa5c6ebea5933", + "zh:c9477bfe00653629cd77ddac3968475f7ad93ac3ca8bc45b56d1d9efb25e4a6e", + "zh:d4cfda8687f736d0cba664c22ec49dae1188289e214ef57f5afe6a7217854fed", + "zh:dc77ee066cf96532a48f0578c35b1eaf6dc4d8ddd0e3ae8e029a3b10676dd5d3", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/terraform/backend.hcl.example b/terraform/backend.hcl.example index faf9d8a..2532acd 100644 --- a/terraform/backend.hcl.example +++ b/terraform/backend.hcl.example @@ -1,8 +1,11 @@ # Copy to "backend.hcl" (gitignored) for remote Terraform state. # GitHub Actions generates this file during deploys. +# +# First-time setup: provision bucket + lock table with terraform/bootstrap/ +# (see terraform/bootstrap/README.md), then fill bucket / region / dynamodb_table here. bucket = "your-terraform-state-bucket" key = "talentstreamai/dev/terraform.tfstate" -region = "us-east-1" +region = "eu-central-1" dynamodb_table = "terraform-locks" encrypt = true diff --git a/terraform/bootstrap/.terraform.lock.hcl b/terraform/bootstrap/.terraform.lock.hcl new file mode 100644 index 0000000..0bf1214 --- /dev/null +++ b/terraform/bootstrap/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.75" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/terraform/bootstrap/README.md b/terraform/bootstrap/README.md new file mode 100644 index 0000000..f4ccd84 --- /dev/null +++ b/terraform/bootstrap/README.md @@ -0,0 +1,34 @@ +# One-time Terraform remote state bootstrap + +Creates the **S3 bucket** and **DynamoDB lock table** used by the main root (`terraform/`) backend. This module keeps **local** Terraform state (not stored in the bucket it creates). + +## Prerequisites + +- AWS credentials with permission to create S3 buckets and DynamoDB tables in the target account. + +## Steps + +1. Choose a **globally unique** `state_bucket_name` (S3 bucket names are global). + +2. Copy variables and apply: + + ```bash + cd terraform/bootstrap + cp terraform.tfvars.example terraform.tfvars + # edit terraform.tfvars + terraform init + terraform apply + ``` + +3. Wire the main stack: + + - Copy `terraform output -raw backend_hcl_snippet` into `terraform/backend.hcl` (or merge with `backend.hcl.example`), and adjust `key` if needed. + - Set `state_bucket_arn`, `state_bucket_objects_arn`, and `state_lock_table_arn` in `terraform/terraform.tfvars` from `terraform output` in this directory. + +4. From **`terraform/`** (main root): + + ```bash + terraform init -backend-config=backend.hcl + ``` + +`aws_region` here must match `region` in `backend.hcl` and the DynamoDB table ARN region in the main `terraform.tfvars`. diff --git a/terraform/bootstrap/main.tf b/terraform/bootstrap/main.tf new file mode 100644 index 0000000..c376157 --- /dev/null +++ b/terraform/bootstrap/main.tf @@ -0,0 +1,43 @@ +resource "aws_s3_bucket" "terraform_state" { + bucket = var.state_bucket_name + force_destroy = var.force_destroy_state_bucket +} + +resource "aws_s3_bucket_versioning" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_public_access_block" "terraform_state" { + bucket = aws_s3_bucket.terraform_state.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# Terraform S3 backend expects a partition key named LockID (String). +resource "aws_dynamodb_table" "terraform_locks" { + name = var.dynamodb_table_name + billing_mode = "PAY_PER_REQUEST" + hash_key = "LockID" + + attribute { + name = "LockID" + type = "S" + } +} diff --git a/terraform/bootstrap/outputs.tf b/terraform/bootstrap/outputs.tf new file mode 100644 index 0000000..2e1fa2f --- /dev/null +++ b/terraform/bootstrap/outputs.tf @@ -0,0 +1,35 @@ +output "state_bucket_name" { + value = aws_s3_bucket.terraform_state.bucket + description = "backend \"bucket\" in terraform/backend.hcl." +} + +output "dynamodb_table_name" { + value = aws_dynamodb_table.terraform_locks.name + description = "backend \"dynamodb_table\" in terraform/backend.hcl." +} + +output "state_bucket_arn" { + value = aws_s3_bucket.terraform_state.arn + description = "state_bucket_arn in the main terraform/terraform.tfvars (GitHub deploy role)." +} + +output "state_bucket_objects_arn" { + value = "${aws_s3_bucket.terraform_state.arn}/*" + description = "state_bucket_objects_arn in the main terraform/terraform.tfvars." +} + +output "state_lock_table_arn" { + value = aws_dynamodb_table.terraform_locks.arn + description = "state_lock_table_arn in the main terraform/terraform.tfvars." +} + +output "backend_hcl_snippet" { + value = <<-EOT + bucket = "${aws_s3_bucket.terraform_state.bucket}" + key = "talentstreamai/dev/terraform.tfstate" + region = "${var.aws_region}" + dynamodb_table = "${aws_dynamodb_table.terraform_locks.name}" + encrypt = true + EOT + description = "Values for terraform/backend.hcl (adjust key per environment)." +} diff --git a/terraform/bootstrap/providers.tf b/terraform/bootstrap/providers.tf new file mode 100644 index 0000000..b72db0f --- /dev/null +++ b/terraform/bootstrap/providers.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.75" + } + } +} + +provider "aws" { + region = var.aws_region +} diff --git a/terraform/bootstrap/terraform.tfvars.example b/terraform/bootstrap/terraform.tfvars.example new file mode 100644 index 0000000..c81d4c5 --- /dev/null +++ b/terraform/bootstrap/terraform.tfvars.example @@ -0,0 +1,6 @@ +aws_region = "eu-central-1" +state_bucket_name = "your-company-talentstreamai-tfstate-UNIQUE" +dynamodb_table_name = "terraform-locks" + +# Use true only for disposable sandboxes so terraform destroy can empty the bucket. +force_destroy_state_bucket = false diff --git a/terraform/bootstrap/variables.tf b/terraform/bootstrap/variables.tf new file mode 100644 index 0000000..6cb99e0 --- /dev/null +++ b/terraform/bootstrap/variables.tf @@ -0,0 +1,22 @@ +variable "aws_region" { + type = string + description = "Region for the state bucket and lock table (must match the main root backend.hcl region)." + default = "eu-central-1" +} + +variable "state_bucket_name" { + type = string + description = "Globally unique S3 bucket name for Terraform remote state." +} + +variable "dynamodb_table_name" { + type = string + description = "DynamoDB table name for S3 backend state locking (backend dynamodb_table)." + default = "terraform-locks" +} + +variable "force_destroy_state_bucket" { + type = bool + description = "If true, the state bucket can be emptied and destroyed by Terraform. Use false in production." + default = false +} diff --git a/terraform/main.tf b/terraform/main.tf index 5022d9c..b2da794 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -8,23 +8,523 @@ terraform { source = "hashicorp/aws" version = "~> 5.75" } + archive = { + source = "hashicorp/archive" + version = "~> 2.5" + } + tls = { + source = "hashicorp/tls" + version = "~> 4.0" + } } } locals { name = "${var.project_name}-${var.environment}" + + github_sub_patterns = [ + for pattern in var.github_ref_patterns : + "repo:${var.github_org}/${var.github_repo}:${pattern}" + ] + + oidc_provider_arn = var.create_oidc_provider ? aws_iam_openid_connect_provider.github[0].arn : var.existing_oidc_provider_arn + + # Root module is terraform/; repository root is the parent directory (e.g. .github/aws templates). + repository_root = abspath("${path.root}/..") + + lambda_secret_arns = concat( + var.lambda_secret_arns, + aws_secretsmanager_secret.app_config[*].arn, + ) + + lambda_base_environment = { + API_HOST = "0.0.0.0" + API_PORT = "8000" + CORS_ORIGINS = var.cors_origins + DEPLOYMENT_ENVIRONMENT = var.deployment_environment + APP_SECRETS_ARNS = join(",", local.lambda_secret_arns) + } + + api_origin_domain = replace(aws_apigatewayv2_api.http.api_endpoint, "https://", "") + + frontend_bucket_arn = "arn:${data.aws_partition.current.partition}:s3:::${var.frontend_bucket_name}" + lambda_function_arn = "arn:${data.aws_partition.current.partition}:lambda:${var.aws_region}:${data.aws_caller_identity.current.account_id}:function:${var.lambda_function_name}-${var.environment}" +} + +data "aws_partition" "current" {} + +data "aws_caller_identity" "current" {} + +data "tls_certificate" "github" { + url = "https://token.actions.githubusercontent.com" +} + +resource "aws_iam_openid_connect_provider" "github" { + count = var.create_oidc_provider ? 1 : 0 + + url = "https://token.actions.githubusercontent.com" + + client_id_list = ["sts.amazonaws.com"] + + thumbprint_list = [data.tls_certificate.github.certificates[0].sha1_fingerprint] +} + +resource "aws_iam_role" "github_actions_deploy" { + name = var.deploy_role_name + description = "Assumed by GitHub Actions via OIDC for Terraform and deployment automation." + + assume_role_policy = templatefile("${local.repository_root}/.github/aws/github-oidc-trust-policy.json.tftpl", { + provider_arn = local.oidc_provider_arn + github_subs_json = jsonencode(local.github_sub_patterns) + }) +} + +data "aws_iam_policy_document" "github_actions_deploy" { + statement { + sid = "TerraformRemoteStateS3" + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + ] + resources = [var.state_bucket_objects_arn] + } + + statement { + sid = "TerraformRemoteStateBucket" + effect = "Allow" + actions = [ + "s3:ListBucket", + ] + resources = [var.state_bucket_arn] + } + + statement { + sid = "TerraformRemoteStateLock" + effect = "Allow" + actions = [ + "dynamodb:DescribeTable", + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + ] + resources = [var.state_lock_table_arn] + } + + statement { + sid = "FrontendPublish" + effect = "Allow" + actions = [ + "s3:ListBucket", + "s3:GetBucketLocation", + ] + resources = [local.frontend_bucket_arn] + } + + statement { + sid = "FrontendPublishObjects" + effect = "Allow" + actions = [ + "s3:PutObject", + "s3:DeleteObject", + "s3:GetObject", + ] + resources = ["${local.frontend_bucket_arn}/*"] + } + + statement { + sid = "CloudFrontInvalidation" + effect = "Allow" + actions = [ + "cloudfront:CreateInvalidation", + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", + ] + resources = ["arn:${data.aws_partition.current.partition}:cloudfront::${data.aws_caller_identity.current.account_id}:distribution/*"] + } + + statement { + sid = "LambdaCodeDeploy" + effect = "Allow" + actions = [ + "lambda:GetFunction", + "lambda:UpdateFunctionCode", + "lambda:UpdateFunctionConfiguration", + "lambda:PublishVersion", + ] + resources = [local.lambda_function_arn] + } + + statement { + sid = "TerraformApplyAwsCrud" + effect = "Allow" + actions = [ + "apigateway:*", + "cloudfront:*", + "iam:GetRole", + "iam:CreateRole", + "iam:DeleteRole", + "iam:TagRole", + "iam:UntagRole", + "iam:PassRole", + "iam:CreatePolicy", + "iam:DeletePolicy", + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:ListPolicyVersions", + "iam:CreatePolicyVersion", + "iam:DeletePolicyVersion", + "iam:AttachRolePolicy", + "iam:DetachRolePolicy", + "iam:PutRolePolicy", + "iam:DeleteRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListRolePolicies", + "iam:GetRolePolicy", + "iam:CreateOpenIDConnectProvider", + "iam:GetOpenIDConnectProvider", + "iam:DeleteOpenIDConnectProvider", + "iam:TagOpenIDConnectProvider", + "iam:UntagOpenIDConnectProvider", + "lambda:*", + "logs:*", + "s3:*", + "secretsmanager:*", + ] + resources = ["*"] + } +} + +resource "aws_iam_policy" "github_actions_deploy" { + name = "${local.name}-github-actions-deploy" + description = "Permissions for Terraform apply and app artifact publishing." + policy = data.aws_iam_policy_document.github_actions_deploy.json +} + +resource "aws_iam_role_policy_attachment" "github_actions_deploy" { + role = aws_iam_role.github_actions_deploy.name + policy_arn = aws_iam_policy.github_actions_deploy.arn +} + +resource "aws_s3_bucket" "frontend" { + bucket = var.frontend_bucket_name +} + +resource "aws_s3_bucket_public_access_block" "frontend" { + bucket = aws_s3_bucket.frontend.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_versioning" "frontend" { + bucket = aws_s3_bucket.frontend.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_cloudfront_origin_access_control" "frontend" { + name = "${local.name}-frontend-oac" + description = "OAC for static site bucket." + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +resource "aws_cloudfront_cache_policy" "api" { + name = "${local.name}-api-caching-disabled" + default_ttl = 0 + max_ttl = 0 + min_ttl = 0 + + parameters_in_cache_key_and_forwarded_to_origin { + cookies_config { + cookie_behavior = "all" + } + + headers_config { + header_behavior = "whitelist" + headers { + items = [ + "Authorization", + "Origin", + "Access-Control-Request-Method", + "Access-Control-Request-Headers", + ] + } + } + + query_strings_config { + query_string_behavior = "all" + } + + enable_accept_encoding_brotli = true + enable_accept_encoding_gzip = true + } +} + +resource "aws_cloudfront_distribution" "frontend" { + enabled = true + is_ipv6_enabled = true + comment = "TalentStreamAI frontend and API edge routing." + + default_root_object = "index.html" + + origin { + domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name + origin_id = "frontend-s3-origin" + origin_access_control_id = aws_cloudfront_origin_access_control.frontend.id + } + + origin { + domain_name = local.api_origin_domain + origin_id = "backend-apigw-origin" + + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" + origin_ssl_protocols = ["TLSv1.2"] + } + } + + default_cache_behavior { + target_origin_id = "frontend-s3-origin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + compress = true + + forwarded_values { + query_string = false + cookies { + forward = "none" + } + } + } + + ordered_cache_behavior { + path_pattern = "/api/*" + target_origin_id = "backend-apigw-origin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "PATCH", "POST", "DELETE"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + compress = true + cache_policy_id = aws_cloudfront_cache_policy.api.id + } + + custom_error_response { + error_code = 403 + response_code = 200 + response_page_path = "/index.html" + error_caching_min_ttl = 0 + } + + custom_error_response { + error_code = 404 + response_code = 200 + response_page_path = "/index.html" + error_caching_min_ttl = 0 + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + cloudfront_default_certificate = true + minimum_protocol_version = "TLSv1.2_2021" + } } -# --------------------------------------------------------------------------- -# Scaffold only — no AWS resources are declared here on purpose. -# -# Implement the reference architecture in this order (split into modules or -# keep flat until the shape stabilizes): -# -# 1) Networking — VPC, public/private subnets, routing, NAT (or reuse an account landing zone). -# 2) Data — Aurora Serverless v2, RDS-managed master secret in Secrets Manager, Data API enabled. -# 3) App secrets — Secrets Manager entries the ECS/Lambda task will read (OpenRouter, etc.). -# 4) Compute — ECS Fargate (LangGraph + OpenRouter client) or Lambda behind API Gateway; ECR for images. -# 5) Edge — API Gateway HTTP API (Clerk JWT authorizer on protected routes), CloudFront + S3 for the static Next export. -# 6) CI/CD — GitHub Actions OIDC role with least privilege for plan/apply and for publishing artifacts. -# --------------------------------------------------------------------------- +data "aws_iam_policy_document" "frontend_bucket_policy" { + statement { + sid = "AllowCloudFrontReadOnly" + effect = "Allow" + actions = [ + "s3:GetObject", + ] + resources = ["${aws_s3_bucket.frontend.arn}/*"] + + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [aws_cloudfront_distribution.frontend.arn] + } + } +} + +resource "aws_s3_bucket_policy" "frontend" { + bucket = aws_s3_bucket.frontend.id + policy = data.aws_iam_policy_document.frontend_bucket_policy.json +} + +resource "aws_apigatewayv2_api" "http" { + name = "${local.name}-http-api" + protocol_type = "HTTP" +} + +resource "aws_apigatewayv2_authorizer" "clerk" { + api_id = aws_apigatewayv2_api.http.id + name = "clerk-jwt-authorizer" + authorizer_type = "JWT" + identity_sources = ["$request.header.Authorization"] + + jwt_configuration { + audience = var.clerk_jwt_audiences + issuer = var.clerk_jwt_issuer + } +} + +resource "aws_iam_role" "lambda_execution" { + name = "${local.name}-lambda-execution" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "lambda_logs" { + role = aws_iam_role.lambda_execution.name + policy_arn = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +data "aws_iam_policy_document" "lambda_secrets" { + count = length(local.lambda_secret_arns) > 0 ? 1 : 0 + + statement { + effect = "Allow" + actions = [ + "secretsmanager:GetSecretValue", + ] + resources = local.lambda_secret_arns + } +} + +resource "aws_iam_policy" "lambda_secrets" { + count = length(local.lambda_secret_arns) > 0 ? 1 : 0 + + name = "${local.name}-lambda-secrets" + policy = data.aws_iam_policy_document.lambda_secrets[0].json +} + +resource "aws_iam_role_policy_attachment" "lambda_secrets" { + count = length(local.lambda_secret_arns) > 0 ? 1 : 0 + + role = aws_iam_role.lambda_execution.name + policy_arn = aws_iam_policy.lambda_secrets[0].arn +} + +resource "aws_secretsmanager_secret" "app_config" { + count = var.create_app_config_secret ? 1 : 0 + + name = "${var.app_config_secret_name}/${var.environment}" + description = var.app_config_secret_description + recovery_window_in_days = 7 +} + +data "archive_file" "lambda_bootstrap" { + type = "zip" + output_path = "${path.module}/lambda-bootstrap.zip" + + source { + content = <<-PY + def handler(event, context): + return { + "statusCode": 200, + "headers": {"content-type": "application/json"}, + "body": "{\\"message\\": \\"Deploy application package with scripts/upload_backend_lambda.py\\"}" + } + PY + filename = "app/lambda_handler.py" + } +} + +resource "aws_lambda_function" "api" { + function_name = "${var.lambda_function_name}-${var.environment}" + role = aws_iam_role.lambda_execution.arn + runtime = var.lambda_runtime + handler = var.lambda_handler + architectures = var.lambda_architectures + timeout = var.lambda_timeout + memory_size = var.lambda_memory_size + + filename = data.archive_file.lambda_bootstrap.output_path + source_code_hash = data.archive_file.lambda_bootstrap.output_base64sha256 + + environment { + variables = merge(local.lambda_base_environment, var.lambda_environment) + } +} + +resource "aws_cloudwatch_log_group" "lambda_api" { + name = "/aws/lambda/${aws_lambda_function.api.function_name}" + retention_in_days = 30 +} + +resource "aws_apigatewayv2_integration" "lambda_proxy" { + api_id = aws_apigatewayv2_api.http.id + integration_type = "AWS_PROXY" + integration_uri = aws_lambda_function.api.invoke_arn + integration_method = "POST" + payload_format_version = "2.0" +} + +resource "aws_apigatewayv2_route" "api_proxy" { + api_id = aws_apigatewayv2_api.http.id + route_key = "ANY /api/{proxy+}" + target = "integrations/${aws_apigatewayv2_integration.lambda_proxy.id}" + + authorization_type = "JWT" + authorizer_id = aws_apigatewayv2_authorizer.clerk.id +} + +resource "aws_apigatewayv2_route" "api_root" { + api_id = aws_apigatewayv2_api.http.id + route_key = "ANY /api" + target = "integrations/${aws_apigatewayv2_integration.lambda_proxy.id}" + + authorization_type = "JWT" + authorizer_id = aws_apigatewayv2_authorizer.clerk.id +} + +resource "aws_apigatewayv2_route" "health" { + api_id = aws_apigatewayv2_api.http.id + route_key = "GET /api/v1/health" + target = "integrations/${aws_apigatewayv2_integration.lambda_proxy.id}" + + authorization_type = "NONE" +} + +resource "aws_apigatewayv2_stage" "default" { + api_id = aws_apigatewayv2_api.http.id + name = var.api_stage_name + auto_deploy = true +} + +resource "aws_lambda_permission" "allow_api_gateway" { + statement_id = "AllowExecutionFromAPIGateway" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.api.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*" +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf index 1db5a31..4212496 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -8,10 +8,42 @@ output "aws_region" { description = "Region the AWS provider is configured to use." } -output "next_steps" { - value = <<-EOT - Terraform is intentionally empty: add resources under this root module (or introduce child modules) following the checklist in main.tf. - Wire remote state (backend.hcl), then iterate with `terraform plan` before any apply in a shared account. - EOT - description = "Human-oriented reminder for implementers." +output "github_actions_role_arn" { + value = aws_iam_role.github_actions_deploy.arn + description = "Role ARN to store in GitHub repository variables for OIDC deploys." +} + +output "cloudfront_distribution_id" { + value = aws_cloudfront_distribution.frontend.id + description = "CloudFront distribution ID for invalidation steps." +} + +output "cloudfront_distribution_arn" { + value = aws_cloudfront_distribution.frontend.arn + description = "CloudFront distribution ARN." +} + +output "cloudfront_domain_name" { + value = aws_cloudfront_distribution.frontend.domain_name + description = "Default CloudFront domain for the frontend." +} + +output "frontend_bucket_name" { + value = aws_s3_bucket.frontend.bucket + description = "S3 bucket that stores frontend static assets." +} + +output "api_gateway_endpoint" { + value = aws_apigatewayv2_api.http.api_endpoint + description = "API Gateway endpoint URL." +} + +output "lambda_function_name" { + value = aws_lambda_function.api.function_name + description = "Lambda function name used by upload scripts." +} + +output "app_config_secret_arn" { + value = try(aws_secretsmanager_secret.app_config[0].arn, "") + description = "Optional app config secret ARN for runtime secrets." } diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index cd71d48..ae8e686 100644 --- a/terraform/terraform.tfvars.example +++ b/terraform/terraform.tfvars.example @@ -1,3 +1,49 @@ -aws_region = "us-east-1" +aws_region = "eu-central-1" project_name = "talentstreamai" environment = "dev" + +github_org = "your-github-org" +github_repo = "TalentStreamAI" + +frontend_bucket_name = "talentstreamai-dev-frontend-example" + +# Patterns map to token.actions.githubusercontent.com:sub +# repo:/:ref:refs/heads/main +# Trust JSON is rendered from ../.github/aws/github-oidc-trust-policy.json.tftpl +github_ref_patterns = ["ref:refs/heads/main"] + +# If your account already has the provider, set create_oidc_provider=false +# and provide existing_oidc_provider_arn. +create_oidc_provider = true +existing_oidc_provider_arn = "" + +deploy_role_name = "github-actions-talentstreamai-deploy" + +lambda_function_name = "talentstreamai-api" +lambda_handler = "app.lambda_handler.handler" +lambda_runtime = "python3.12" +lambda_timeout = 30 +lambda_memory_size = 512 + +clerk_jwt_issuer = "https://your-clerk-domain.clerk.accounts.dev" +clerk_jwt_audiences = ["your-clerk-audience"] + +cors_origins = "https://example.cloudfront.net" +deployment_environment = "dev" +lambda_environment = { + LOG_LEVEL = "INFO" +} + +# Optional list of pre-existing secret ARNs Lambda can read. +lambda_secret_arns = [] + +create_app_config_secret = true +app_config_secret_name = "talentstreamai/app-config" +app_config_secret_description = "Application runtime secret payload for TalentStreamAI Lambda." + +api_stage_name = "$default" + +# Remote state resources used by deploy role permissions. +state_bucket_arn = "arn:aws:s3:::your-terraform-state-bucket" +state_bucket_objects_arn = "arn:aws:s3:::your-terraform-state-bucket/*" +state_lock_table_arn = "arn:aws:dynamodb:eu-central-1:123456789012:table/terraform-locks" diff --git a/terraform/variables.tf b/terraform/variables.tf index 1924395..9bff7ff 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -1,7 +1,7 @@ variable "aws_region" { type = string description = "Primary AWS region for future resources." - default = "us-east-1" + default = "eu-central-1" } variable "project_name" { @@ -19,3 +19,151 @@ variable "environment" { error_message = "environment must be one of: dev, staging, prod." } } + +variable "github_org" { + type = string + description = "GitHub organization that owns this repository." +} + +variable "github_repo" { + type = string + description = "GitHub repository name without org." +} + +variable "github_ref_patterns" { + type = list(string) + description = "GitHub OIDC sub patterns allowed to assume the deploy role." + default = ["ref:refs/heads/main"] +} + +variable "create_oidc_provider" { + type = bool + description = "Create GitHub OIDC provider in this account. Set false if it already exists." + default = true +} + +variable "existing_oidc_provider_arn" { + type = string + description = "Existing GitHub OIDC provider ARN when create_oidc_provider is false." + default = "" +} + +variable "deploy_role_name" { + type = string + description = "Name for the GitHub Actions deploy IAM role." + default = "github-actions-talentstreamai-deploy" +} + +variable "frontend_bucket_name" { + type = string + description = "S3 bucket name for frontend static assets." +} + +variable "lambda_function_name" { + type = string + description = "Lambda function name backing the API Gateway." + default = "talentstreamai-api" +} + +variable "lambda_handler" { + type = string + description = "Lambda handler path." + default = "app.lambda_handler.handler" +} + +variable "lambda_runtime" { + type = string + description = "Lambda runtime." + default = "python3.12" +} + +variable "lambda_timeout" { + type = number + description = "Lambda timeout in seconds." + default = 30 +} + +variable "lambda_memory_size" { + type = number + description = "Lambda memory size in MB." + default = 512 +} + +variable "lambda_architectures" { + type = list(string) + description = "Lambda CPU architecture list." + default = ["x86_64"] +} + +variable "clerk_jwt_issuer" { + type = string + description = "JWT issuer URL for Clerk authorizer." +} + +variable "clerk_jwt_audiences" { + type = list(string) + description = "Allowed JWT audiences for Clerk authorizer." +} + +variable "cors_origins" { + type = string + description = "Comma-separated origins allowed by the FastAPI CORS middleware." + default = "*" +} + +variable "deployment_environment" { + type = string + description = "Deployment environment passed to backend runtime." + default = "dev" +} + +variable "lambda_environment" { + type = map(string) + description = "Extra non-secret Lambda environment variables." + default = {} +} + +variable "lambda_secret_arns" { + type = list(string) + description = "Secrets Manager secret ARNs Lambda can read." + default = [] +} + +variable "create_app_config_secret" { + type = bool + description = "Create an application config secret resource (value managed out-of-band)." + default = true +} + +variable "app_config_secret_name" { + type = string + description = "Name for the optional app config secret." + default = "talentstreamai/app-config" +} + +variable "app_config_secret_description" { + type = string + description = "Description for the optional app config secret." + default = "Application runtime secret payload for TalentStreamAI Lambda." +} + +variable "api_stage_name" { + type = string + description = "API Gateway stage name." + default = "$default" +} + +variable "state_bucket_arn" { + type = string + description = "Terraform remote state S3 bucket ARN used by GitHub deploy role." +} + +variable "state_bucket_objects_arn" { + type = string + description = "Terraform remote state object ARN prefix (bucket ARN plus /*)." +} + +variable "state_lock_table_arn" { + type = string + description = "Terraform remote state lock DynamoDB table ARN." +}