diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml new file mode 100644 index 0000000..6393492 --- /dev/null +++ b/.github/workflows/deploy-api.yaml @@ -0,0 +1,271 @@ +name: Deploy API + +# Fargate image CD. GitHub Actions builds the API image, pushes to ECR, and +# registers a new task definition. Terraform owns the cluster, service, ALB, +# and ignores container_definitions / task_definition. +# +# push to main -> GitHub Environment dev, at github.sha +# workflow_dispatch -> GitHub Environment dev. The workflow file must be main. +# inputs.ref is only the image source. Deploy scripts stay +# on github.sha, which is the trusted workflow commit. +# +# Cluster, service, ECR, and task env come from SSM after assuming the +# Environment's DEPLOY_ROLE_ARN. Terraform owns /seahaven-ap/deploy/task-environment; +# this workflow applies that JSON and writes GIT_SHA. Nothing here creates an HCP run. +# Prod is AP-12. + +on: + push: + branches: [main] + paths: + - "packages/api/**" + - "packages/shared/**" + - "package.json" + - "package-lock.json" + - "Dockerfile" + - ".dockerignore" + - "scripts/patch-ecs-task-def.py" + - ".github/workflows/deploy-api.yaml" + workflow_dispatch: + inputs: + environment: + description: "Target Environment" + required: true + type: choice + options: [dev] + ref: + description: "Git ref to build and deploy (branch or SHA). Empty means the workflow ref." + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + target: + name: Resolve target + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + environment: ${{ steps.resolve.outputs.environment }} + ref: ${{ steps.resolve.outputs.ref }} + steps: + - id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + GITHUB_REF_NAME_IN: ${{ github.ref }} + GITHUB_SHA_IN: ${{ github.sha }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} + INPUT_REF: ${{ inputs.ref }} + run: | + set -euo pipefail + case "${EVENT_NAME}" in + push) + if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then + echo "push deploys only run from main" >&2 + exit 1 + fi + environment=dev + ref="${GITHUB_SHA_IN}" + ;; + workflow_dispatch) + if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then + echo "workflow_dispatch deploys only run from main" >&2 + exit 1 + fi + environment="${INPUT_ENVIRONMENT:-dev}" + if [ "${environment}" != "dev" ]; then + echo "only GitHub Environment dev is allowed" >&2 + exit 1 + fi + ref="${INPUT_REF:-${GITHUB_SHA_IN}}" + ;; + *) + echo "unsupported event ${EVENT_NAME}" >&2 + exit 1 + ;; + esac + { + echo "environment=${environment}" + echo "ref=${ref}" + } >> "${GITHUB_OUTPUT}" + echo "Deploying ${ref} to ${environment}" + + deploy: + name: Deploy API to ${{ needs.target.outputs.environment }} + needs: target + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: ${{ needs.target.outputs.environment }} + concurrency: + group: deploy-api-${{ needs.target.outputs.environment }} + cancel-in-progress: false + permissions: + contents: read + id-token: write + env: + AWS_REGION: us-east-1 + DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }} + steps: + - name: Checkout trusted workflow + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + path: ci + + - name: Checkout image source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.target.outputs.ref }} + persist-credentials: false + path: src + + - name: Resolve commit + id: commit + working-directory: src + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + echo "sha=${sha}" >> "${GITHUB_OUTPUT}" + echo "Building ${sha}" + + - name: Require deploy role + run: | + set -euo pipefail + if [ -z "${DEPLOY_ROLE_ARN}" ]; then + echo "DEPLOY_ROLE_ARN is empty. Create GitHub Environment dev and set it after the seahaven-ap-dev apply, then re-run." >&2 + exit 1 + fi + + - name: Configure AWS credentials using OIDC + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + role-to-assume: ${{ env.DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + audience: sts.amazonaws.com + + - name: Get deploy parameters + id: deploy + run: | + set -euo pipefail + get_param() { + local name="$1" err value + err="$(mktemp)" + if ! value="$(aws ssm get-parameter --name "${name}" --query Parameter.Value --output text 2>"${err}")"; then + if grep -q ParameterNotFound "${err}"; then + echo "SSM parameter ${name} does not exist yet. Apply the seahaven-ap-dev workspace, then re-run this workflow." >&2 + else + cat "${err}" >&2 + fi + rm -f "${err}" + exit 1 + fi + rm -f "${err}" + printf '%s\n' "${value}" + } + CLUSTER=$(get_param /seahaven-ap/deploy/cluster) + SERVICE=$(get_param /seahaven-ap/deploy/service) + FAMILY=$(get_param /seahaven-ap/deploy/task-family) + ECR=$(get_param /seahaven-ap/deploy/ecr-repository) + CONTAINER=$(get_param /seahaven-ap/deploy/container-name) + DIST_ID=$(get_param /seahaven-ap/deploy/distribution-id) + DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text) + { + echo "cluster=${CLUSTER}" + echo "service=${SERVICE}" + echo "family=${FAMILY}" + echo "ecr=${ECR}" + echo "container=${CONTAINER}" + echo "site_url=https://${DOMAIN}" + } >> "${GITHUB_OUTPUT}" + + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7 + + - name: Build image + env: + ECR: ${{ steps.deploy.outputs.ecr }} + GIT_SHA: ${{ steps.commit.outputs.sha }} + ENVIRONMENT: ${{ needs.target.outputs.environment }} + working-directory: src + run: | + set -euo pipefail + docker build \ + --platform linux/amd64 \ + --build-arg "GIT_SHA=${GIT_SHA}" \ + -t "${ECR}:${GIT_SHA}" \ + -t "${ECR}:${ENVIRONMENT}" \ + . + + - name: Push image + env: + ECR: ${{ steps.deploy.outputs.ecr }} + GIT_SHA: ${{ steps.commit.outputs.sha }} + ENVIRONMENT: ${{ needs.target.outputs.environment }} + run: | + set -euo pipefail + docker push "${ECR}:${GIT_SHA}" + docker push "${ECR}:${ENVIRONMENT}" + + - name: Register task definition, migrate, and update service + env: + CLUSTER: ${{ steps.deploy.outputs.cluster }} + SERVICE: ${{ steps.deploy.outputs.service }} + FAMILY: ${{ steps.deploy.outputs.family }} + CONTAINER: ${{ steps.deploy.outputs.container }} + IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }} + GIT_SHA: ${{ steps.commit.outputs.sha }} + run: | + set -euo pipefail + TASK_ENV_JSON="$(aws ssm get-parameter \ + --name /seahaven-ap/deploy/task-environment \ + --with-decryption \ + --query Parameter.Value \ + --output text)" + export TASK_ENV_JSON + aws ecs describe-task-definition \ + --task-definition "${FAMILY}" \ + --query taskDefinition \ + --output json \ + | python3 "${GITHUB_WORKSPACE}/ci/scripts/patch-ecs-task-def.py" > /tmp/task-def.json + REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)" + NET="$(aws ecs describe-services --cluster "${CLUSTER}" --services "${SERVICE}" \ + --query 'services[0].networkConfiguration.awsvpcConfiguration' --output json)" + export NET + SUBNETS="$(python3 -c 'import json,os; print(",".join(json.loads(os.environ["NET"])["subnets"]))')" + SGS="$(python3 -c 'import json,os; print(",".join(json.loads(os.environ["NET"])["securityGroups"]))')" + RUN_JSON="$(aws ecs run-task \ + --cluster "${CLUSTER}" \ + --task-definition "${FAMILY}:${REV}" \ + --launch-type FARGATE \ + --network-configuration "awsvpcConfiguration={subnets=[${SUBNETS}],securityGroups=[${SGS}],assignPublicIp=ENABLED}" \ + --overrides "{\"containerOverrides\":[{\"name\":\"${CONTAINER}\",\"command\":[\"node\",\"packages/api/dist/db/migrate.js\"],\"environment\":[{\"name\":\"DEV_AUTH_BYPASS\",\"value\":\"false\"}]}]}" \ + --output json)" + TASK_ARN="$(printf '%s' "${RUN_JSON}" | python3 -c 'import json,sys; data=json.load(sys.stdin); tasks=data.get("tasks") or []; print(tasks[0].get("taskArn") or "" if tasks else "")')" + if [ -z "${TASK_ARN}" ] || [ "${TASK_ARN}" = "None" ]; then + echo "ecs run-task did not start a migrate task" >&2 + printf '%s' "${RUN_JSON}" | python3 -c 'import json,sys; data=json.load(sys.stdin); print(json.dumps(data.get("failures") or [], indent=2))' >&2 + exit 1 + fi + aws ecs wait tasks-stopped --cluster "${CLUSTER}" --tasks "${TASK_ARN}" + EXIT="$(aws ecs describe-tasks --cluster "${CLUSTER}" --tasks "${TASK_ARN}" \ + --query 'tasks[0].containers[0].exitCode' --output text)" + if [ "${EXIT}" != "0" ]; then + echo "migrate task ${TASK_ARN} exited ${EXIT}" >&2 + exit 1 + fi + aws ecs update-service \ + --cluster "${CLUSTER}" \ + --service "${SERVICE}" \ + --task-definition "${FAMILY}:${REV}" \ + --force-new-deployment \ + >/dev/null + aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}" + + - name: Verify API health + env: + SITE_URL: ${{ steps.deploy.outputs.site_url }} + EXPECTED_SHA: ${{ steps.commit.outputs.sha }} + run: bash "${GITHUB_WORKSPACE}/ci/scripts/verify-api-health.sh" diff --git a/.github/workflows/deploy-web.yaml b/.github/workflows/deploy-web.yaml new file mode 100644 index 0000000..a496646 --- /dev/null +++ b/.github/workflows/deploy-web.yaml @@ -0,0 +1,199 @@ +name: Deploy Web + +# SPA CD. GitHub Actions syncs the committed placeholder to the S3 origin +# bucket root, then invalidates CloudFront. Terraform owns the bucket and the +# distribution and never touches content. Do not run a SPA production build. +# +# push to main -> GitHub Environment dev, at github.sha +# workflow_dispatch -> GitHub Environment dev. The workflow file must be main. +# inputs.ref selects the placeholder tree to publish. +# Job steps are the workflow file, not scripts from that ref. +# +# Nothing here creates an HCP run. Prod is AP-12. + +on: + push: + branches: [main] + paths-ignore: + - "terraform/**" + - "packages/api/**" + - "packages/shared/**" + - "docs/**" + - "**/*.md" + - "Dockerfile" + - ".dockerignore" + - "scripts/verify-api-health.sh" + - "scripts/test-verify-api-health.sh" + - "scripts/test-terraform-dev-only.py" + - "scripts/patch-ecs-task-def.py" + - ".github/workflows/deploy-api.yaml" + - ".github/workflows/ci.yaml" + workflow_dispatch: + inputs: + environment: + description: "Target Environment" + required: true + type: choice + options: [dev] + ref: + description: "Git ref to deploy (branch or SHA). Empty means the workflow ref." + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + target: + name: Resolve target + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + environment: ${{ steps.resolve.outputs.environment }} + ref: ${{ steps.resolve.outputs.ref }} + steps: + - id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + GITHUB_REF_NAME_IN: ${{ github.ref }} + GITHUB_SHA_IN: ${{ github.sha }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} + INPUT_REF: ${{ inputs.ref }} + run: | + set -euo pipefail + case "${EVENT_NAME}" in + push) + if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then + echo "push deploys only run from main" >&2 + exit 1 + fi + environment=dev + ref="${GITHUB_SHA_IN}" + ;; + workflow_dispatch) + if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then + echo "workflow_dispatch deploys only run from main" >&2 + exit 1 + fi + environment="${INPUT_ENVIRONMENT:-dev}" + if [ "${environment}" != "dev" ]; then + echo "only GitHub Environment dev is allowed" >&2 + exit 1 + fi + ref="${INPUT_REF:-${GITHUB_SHA_IN}}" + ;; + *) + echo "unsupported event ${EVENT_NAME}" >&2 + exit 1 + ;; + esac + { + echo "environment=${environment}" + echo "ref=${ref}" + } >> "${GITHUB_OUTPUT}" + echo "Deploying ${ref} to ${environment}" + + deploy: + name: Deploy SPA to ${{ needs.target.outputs.environment }} + needs: target + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: ${{ needs.target.outputs.environment }} + concurrency: + group: deploy-web-${{ needs.target.outputs.environment }} + cancel-in-progress: false + permissions: + contents: read + id-token: write + env: + AWS_REGION: us-east-1 + DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.target.outputs.ref }} + persist-credentials: false + + - name: Resolve commit + id: commit + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + echo "sha=${sha}" >> "${GITHUB_OUTPUT}" + echo "Deploying ${sha}" + test -f placeholder/index.html + + - name: Require deploy role + run: | + set -euo pipefail + if [ -z "${DEPLOY_ROLE_ARN}" ]; then + echo "DEPLOY_ROLE_ARN is empty. Create GitHub Environment dev and set it after the seahaven-ap-dev apply, then re-run." >&2 + exit 1 + fi + + - name: Configure AWS credentials using OIDC + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + role-to-assume: ${{ env.DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + audience: sts.amazonaws.com + + - name: Get deploy parameters + id: deploy + run: | + set -euo pipefail + get_param() { + local name="$1" err value + err="$(mktemp)" + if ! value="$(aws ssm get-parameter --name "${name}" --query Parameter.Value --output text 2>"${err}")"; then + if grep -q ParameterNotFound "${err}"; then + echo "SSM parameter ${name} does not exist yet. Apply the seahaven-ap-dev workspace, then re-run this workflow." >&2 + else + cat "${err}" >&2 + fi + rm -f "${err}" + exit 1 + fi + rm -f "${err}" + printf '%s\n' "${value}" + } + BUCKET=$(get_param /seahaven-ap/deploy/bucket) + DIST_ID=$(get_param /seahaven-ap/deploy/distribution-id) + DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text) + { + echo "bucket=${BUCKET}" + echo "distribution_id=${DIST_ID}" + echo "site_url=https://${DOMAIN}" + } >> "${GITHUB_OUTPUT}" + + - name: Sync placeholder/ to the bucket root + env: + SITE_BUCKET: ${{ steps.deploy.outputs.bucket }} + run: | + set -euo pipefail + aws s3 sync placeholder/ "s3://${SITE_BUCKET}/" \ + --exclude "index.html" \ + --cache-control "public,max-age=31536000,immutable" + aws s3 cp placeholder/index.html "s3://${SITE_BUCKET}/index.html" \ + --cache-control "no-cache,no-store,must-revalidate" \ + --content-type "text/html" + aws s3 sync placeholder/ "s3://${SITE_BUCKET}/" \ + --delete \ + --exclude "index.html" \ + --cache-control "public,max-age=31536000,immutable" + aws s3api head-object --bucket "${SITE_BUCKET}" --key index.html + + - name: Invalidate CloudFront + env: + DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }} + run: | + set -euo pipefail + invalidation_id="$(aws cloudfront create-invalidation \ + --distribution-id "${DISTRIBUTION_ID}" \ + --paths "/*" \ + --query Invalidation.Id --output text)" + echo "Invalidation ${invalidation_id} created; waiting" + aws cloudfront wait invalidation-completed \ + --distribution-id "${DISTRIBUTION_ID}" \ + --id "${invalidation_id}" diff --git a/README.md b/README.md index 480e36c..a7b2f3a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sea Haven AP -Internal accounts payable automation for Sea Haven Industries. Local API is `http://127.0.0.1:8787`. CloudFront on seahaven-dev is the first hosted origin. +Internal accounts payable automation for Sea Haven Industries. Local API is `http://127.0.0.1:8787`. Hosted origin on seahaven-dev is the CloudFront default domain after HCP apply; GitHub Environment `dev` deploys the placeholder and API image. ## Workspace layout @@ -54,9 +54,24 @@ npm run lint:api npm run docs:preview # builds HTML via redocly build-docs and opens it ``` +## Hosted seahaven-dev + +HCP Terraform workspace `seahaven-ap-dev` (project `seahaven-dev`) uses working directory `terraform` and a `terraform/**` VCS trigger on `main`. GitHub Environment `dev` holds `DEPLOY_ROLE_ARN` for `githubdeploy-seahaven-ap`. + +The first apply creates secret `seahaven-ap/google-oidc` with `client_id` and `client_secret` set to `replace-me`. Replace both values in Secrets Manager, then re-run the HCP apply. A `terraform/**` change on `main` starts that apply. The Google IdP is not registered while the placeholder is still current. + +The merge that adds these workflows can start Deploy Web and Deploy API before that apply has written `/seahaven-ap/deploy/*` and before GitHub Environment `dev` has `DEPLOY_ROLE_ARN`. Those runs fail on purpose until both exist. Re-run them after the apply. + +- `.github/workflows/deploy-web.yaml` syncs `placeholder/` to the web bucket. It does not run `vite build`. +- `.github/workflows/deploy-api.yaml` builds the API image with `GIT_SHA`, registers the task definition from `/seahaven-ap/deploy/task-environment`, migrates, and checks `GET /api/health`. + +Terraform `environment` is `dev` only. Prod hostname and GitHub Environment `prod` are AP-12. + ## Verify ```bash npm run verify npm run test:e2e +python3 scripts/test-terraform-dev-only.py +bash scripts/test-verify-api-health.sh ``` diff --git a/placeholder/index.html b/placeholder/index.html new file mode 100644 index 0000000..6a86066 --- /dev/null +++ b/placeholder/index.html @@ -0,0 +1,17 @@ + + + + + + Sea Haven AP + + +
+

Sea Haven AP

+

+ Accounts payable origin for seahaven-dev. The live SPA is not published on this distribution + yet. +

+
+ + diff --git a/scripts/patch-ecs-task-def.py b/scripts/patch-ecs-task-def.py new file mode 100755 index 0000000..a8f8391 --- /dev/null +++ b/scripts/patch-ecs-task-def.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Apply Terraform-owned task env onto an ECS task definition JSON. + +Reads describe-task-definition JSON on stdin. Writes register-task-definition +input on stdout. IMAGE, GIT_SHA, CONTAINER, and TASK_ENV_JSON must be set. +TASK_ENV_JSON is the SecureString at /seahaven-ap/deploy/task-environment. +GIT_SHA is owned by GitHub and always overwrites the map. +""" + +from __future__ import annotations + +import json +import os +import sys + + +def patch_task_definition(td: dict, *, image: str, sha: str, container_name: str, env_map: dict) -> dict: + if not isinstance(env_map, dict) or not env_map: + raise SystemExit("TASK_ENV_JSON must be a non-empty JSON object") + owned = {str(key): str(value) for key, value in env_map.items()} + owned.pop("GIT_SHA", None) + owned["GIT_SHA"] = sha + + matched = False + for container in td.get("containerDefinitions") or []: + if container.get("name") != container_name: + continue + matched = True + container["image"] = image + container["environment"] = [{"name": key, "value": value} for key, value in owned.items()] + container["stopTimeout"] = 60 + container.pop("command", None) + if not matched: + raise SystemExit(f"container {container_name!r} not found in task definition") + return td + + +def main() -> None: + image = os.environ["IMAGE"] + sha = os.environ["GIT_SHA"] + name = os.environ["CONTAINER"] + env_map = json.loads(os.environ["TASK_ENV_JSON"]) + td = json.load(sys.stdin) + for key in ( + "taskDefinitionArn", + "revision", + "status", + "requiresAttributes", + "compatibilities", + "registeredAt", + "registeredBy", + "deregisteredAt", + ): + td.pop(key, None) + json.dump(patch_task_definition(td, image=image, sha=sha, container_name=name, env_map=env_map), sys.stdout) + + +if __name__ == "__main__": + main() diff --git a/scripts/test-terraform-dev-only.py b/scripts/test-terraform-dev-only.py new file mode 100755 index 0000000..92d8957 --- /dev/null +++ b/scripts/test-terraform-dev-only.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Guard seahaven-dev-only Terraform and deploy workflows (AP-9/10/11).""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_no_hcp_iam_and_no_prod(): + tf_dir = ROOT / "terraform" + assert not (tf_dir / "hcp_iam.tf").exists() + assert not (tf_dir / "acm.tf").exists() + joined = "\n".join(p.read_text() for p in sorted(tf_dir.glob("*.tf"))) + for needle in ( + "environment:prod", + "seahaven-ap-prod", + "seahaven-prod", + "ap.seahaven.com", + "011934824531", + "afterhours", + "hcptf-bootstrap", + 'contains(["dev", "prod"]', + ): + assert needle not in joined, needle + variables = (tf_dir / "variables.tf").read_text() + assert 'var.environment == "dev"' in variables + locals_tf = (tf_dir / "locals.tf").read_text() + assert "vpc_cidr" in locals_tf and "10.63.0.0/16" in locals_tf + assert "hcp_workspace" in locals_tf and "seahaven-ap-dev" in locals_tf + ecs = (tf_dir / "ecs.tf").read_text() + assert "ignore_changes = [container_definitions]" in ecs + assert "ignore_changes = [task_definition, desired_count]" in ecs + assert 'path = "/api/health"' in ecs + assert "public.ecr.aws/docker/library/node:24-alpine" in ecs + assert 'tagStatus = "untagged"' in ecs + assert 'tagStatus = "any"' not in ecs + cloudfront = (tf_dir / "cloudfront.tf").read_text() + assert "cloudfront_default_certificate = true" in cloudfront + assert "aliases" not in cloudfront + alarms = (tf_dir / "alarms.tf").read_text() + assert alarms.count("alarm_actions = [local.site_alerts_arn]") == 2 + assert "insufficient_data_actions" not in alarms + assert "ok_actions" not in alarms + locals_tf = (tf_dir / "locals.tf").read_text() + assert ( + 'site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts"' + in locals_tf + ) + cognito = (tf_dir / "cognito.tf").read_text() + assert 'supported_identity_providers = ["COGNITO", "Google"]' in cognito + assert '"ALLOW_USER_SRP_AUTH"' in cognito + assert "aws_secretsmanager_secret_version.google_oidc" in cognito + assert 'local.google_oidc_client_id != "replace-me"' in cognito + assert 'local.google_oidc_client_secret != "replace-me"' in cognito + readme = (ROOT / "README.md").read_text() + assert "Replace both values in Secrets Manager, then re-run the HCP apply." in readme + assert "Those runs fail on purpose until both exist." in readme + secrets = (tf_dir / "secrets.tf").read_text() + assert 'resource "aws_secretsmanager_secret_version" "google_oidc"' in secrets + assert "ignore_changes = [secret_string]" in secrets + github = (tf_dir / "iam_github_deploy.tf").read_text() + assert "environment:dev" in github + assert "environment:prod" not in github + assert "refs/tags/" not in github + assert "deploy-web.yaml@refs/heads/" in github + assert "deploy-api.yaml@refs/heads/" in github + + +def test_deploy_workflows_are_dev_only(): + for name in ("deploy-web.yaml", "deploy-api.yaml"): + text = (ROOT / ".github" / "workflows" / name).read_text() + assert "release:" not in text + assert "options: [dev]" in text + assert "options: [dev, prod]" not in text + assert "environment:prod" not in text + assert "cancel-in-progress: false" in text + assert "environment: ${{ needs.target.outputs.environment }}" in text + assert "DEPLOY_ROLE_ARN is empty" in text + assert "does not exist yet. Apply the seahaven-ap-dev workspace" in text + web = (ROOT / ".github" / "workflows" / "deploy-web.yaml").read_text() + assert "vite build" not in web + assert "placeholder/" in web + assert "npm run build" not in web + api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text() + assert "/seahaven-ap/deploy/" in api + assert "GIT_SHA" in api + assert "verify-api-health.sh" in api + assert "packages/api/dist/db/migrate.js" in api + assert "DEV_AUTH_BYPASS" in api + assert '\\"value\\":\\"false\\"' in api + + +if __name__ == "__main__": + test_no_hcp_iam_and_no_prod() + test_deploy_workflows_are_dev_only() + print("PASS: seahaven-dev terraform and deploy workflow guards") diff --git a/scripts/test-verify-api-health.sh b/scripts/test-verify-api-health.sh new file mode 100755 index 0000000..b841ade --- /dev/null +++ b/scripts/test-verify-api-health.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Stubbed curl tests for scripts/verify-api-health.sh. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VERIFY="${ROOT}/scripts/verify-api-health.sh" +SHA="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +failures=0 + +assert_exit() { + local name="$1" expected="$2" got="$3" log="$4" + if [[ "${got}" != "${expected}" ]]; then + echo "FAIL: ${name}: expected exit ${expected}, got ${got}" >&2 + sed -n '1,80p' "${log}" >&2 + failures=$((failures + 1)) + else + echo "PASS: ${name}" + fi +} + +run_with_curl() { + local name="$1" expected="$2" curl_body="$3" must="${4:-}" forbid="${5:-}" + local dir + dir="$(mktemp -d)" + cat > "${dir}/curl" << CURL +#!/usr/bin/env bash +set -euo pipefail +output="" +write_out="" +args=("\$@") +i=0 +while [[ \$i -lt \${#args[@]} ]]; do + arg="\${args[\$i]}" + case "\${arg}" in + -o) i=\$((i + 1)); output="\${args[\$i]}" ;; + -w) i=\$((i + 1)); write_out="\${args[\$i]}" ;; + esac + i=\$((i + 1)) +done +${curl_body} +CURL + chmod +x "${dir}/curl" + export PATH="${dir}:${PATH}" + export SITE_URL="https://d111111abcdef8.cloudfront.net" + export EXPECTED_SHA="${SHA}" + export BUDGET=2 + export INTERVAL=0 + local log="${dir}/log.txt" + set +e + bash "${VERIFY}" > "${log}" 2>&1 + local code=$? + set -e + assert_exit "${name}" "${expected}" "${code}" "${log}" + if [[ -n "${must}" ]] && ! grep -F "${must}" "${log}" >/dev/null; then + echo "FAIL: ${name}: log missing ${must}" >&2 + sed -n '1,80p' "${log}" >&2 + failures=$((failures + 1)) + fi + if [[ -n "${forbid}" ]] && grep -F "${forbid}" "${log}" >/dev/null; then + echo "FAIL: ${name}: log contains ${forbid}" >&2 + sed -n '1,80p' "${log}" >&2 + failures=$((failures + 1)) + fi + rm -rf "${dir}" +} + +run_with_curl "matching-sha" 0 ' +[[ -n "${output}" ]] && printf "{\"stage\":\"dev\",\"sha\":\"'"${SHA}"'\"}\n" > "${output}" +[[ -n "${write_out}" ]] && printf "200" +exit 0 +' + +run_with_curl "wrong-sha" 1 ' +[[ -n "${output}" ]] && printf "{\"stage\":\"dev\",\"sha\":\"unknown\"}\n" > "${output}" +[[ -n "${write_out}" ]] && printf "200" +exit 0 +' + +run_with_curl "health-503" 1 ' +[[ -n "${output}" ]] && printf "{\"message\":\"nope\"}\n" > "${output}" +[[ -n "${write_out}" ]] && printf "503" +exit 0 +' + +run_with_curl "curl-failure" 1 ' +[[ -n "${write_out}" ]] && printf "000" +exit 1 +' 'http=000 sha=' 'http=000000' + +if [[ "${failures}" -ne 0 ]]; then + echo "FAIL: ${failures} verify-api-health cases failed" >&2 + exit 1 +fi +echo "PASS: API health verify checks" diff --git a/scripts/verify-api-health.sh b/scripts/verify-api-health.sh new file mode 100755 index 0000000..de09b4f --- /dev/null +++ b/scripts/verify-api-health.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Verify the API origin through CloudFront /api/health. +set -euo pipefail + +SITE_URL="${SITE_URL:-}" +EXPECTED_SHA="${EXPECTED_SHA:-}" +BUDGET="${BUDGET:-20}" +INTERVAL="${INTERVAL:-5}" + +if [[ -z "${SITE_URL}" || -z "${EXPECTED_SHA}" ]]; then + echo "Usage: SITE_URL EXPECTED_SHA must be set." >&2 + exit 2 +fi + +SITE_URL="${SITE_URL%/}" +last_code="unreachable" +last_sha="unreachable" + +attempt=0 +while [[ "${attempt}" -lt "${BUDGET}" ]]; do + attempt=$((attempt + 1)) + tmp="$(mktemp)" + last_code="$(curl -sS --max-time 30 -o "${tmp}" -w '%{http_code}' "${SITE_URL}/api/health" || true)" + last_sha="$(python3 -c 'import json,sys +try: + print(json.load(open(sys.argv[1], encoding="utf-8")).get("sha") or "") +except Exception: + print("") +' "${tmp}")" + rm -f "${tmp}" + echo "poll ${attempt}/${BUDGET}: http=${last_code} sha=${last_sha}" + if [[ "${last_code}" == "200" && "${last_sha}" == "${EXPECTED_SHA}" ]]; then + echo "PASS: GET /api/health is 200 with sha ${EXPECTED_SHA}" + exit 0 + fi + sleep "${INTERVAL}" +done + +echo "FAIL: GET /api/health did not converge to sha ${EXPECTED_SHA} (last http=${last_code} sha=${last_sha})." >&2 +exit 1 diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..de3ad37 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,11 @@ +.terraform/ +*.tfstate +*.tfstate.* +crash.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +*.tfvars +*.tfvars.json +build/ diff --git a/terraform/alarms.tf b/terraform/alarms.tf new file mode 100644 index 0000000..1322798 --- /dev/null +++ b/terraform/alarms.tf @@ -0,0 +1,36 @@ +resource "aws_cloudwatch_metric_alarm" "alb_5xx" { + alarm_name = "${local.project}-alb-5xx" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "HTTPCode_Target_5XX_Count" + namespace = "AWS/ApplicationELB" + period = 60 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_description = "ALB target 5xx for seahaven-ap." + alarm_actions = [local.site_alerts_arn] + + dimensions = { + LoadBalancer = aws_lb.api.arn_suffix + } +} + +resource "aws_cloudwatch_metric_alarm" "ecs_cpu" { + alarm_name = "${local.project}-ecs-cpu" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/ECS" + period = 300 + statistic = "Average" + threshold = 80 + treat_missing_data = "notBreaching" + alarm_description = "seahaven-ap ECS CPU above 80 percent." + alarm_actions = [local.site_alerts_arn] + + dimensions = { + ClusterName = aws_ecs_cluster.api.name + ServiceName = aws_ecs_service.api.name + } +} diff --git a/terraform/artifacts.tf b/terraform/artifacts.tf new file mode 100644 index 0000000..dccd6d6 --- /dev/null +++ b/terraform/artifacts.tf @@ -0,0 +1,101 @@ +resource "aws_s3_bucket" "artifacts" { + bucket = local.artifacts_bucket_name + + tags = { + Purpose = "Cognito pre-signup packages for seahaven-ap" + } +} + +resource "aws_s3_bucket_public_access_block" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + id = "expire-noncurrent-packages" + status = "Enabled" + + filter {} + + noncurrent_version_expiration { + noncurrent_days = 180 + } + } + + rule { + id = "abort-incomplete-multipart" + status = "Enabled" + + filter {} + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } + + depends_on = [aws_s3_bucket_versioning.artifacts] +} + +data "aws_iam_policy_document" "artifacts" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + resources = [ + aws_s3_bucket.artifacts.arn, + "${aws_s3_bucket.artifacts.arn}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +resource "aws_s3_bucket_policy" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + policy = data.aws_iam_policy_document.artifacts.json + + depends_on = [aws_s3_bucket_public_access_block.artifacts] +} diff --git a/terraform/aurora.tf b/terraform/aurora.tf new file mode 100644 index 0000000..589f363 --- /dev/null +++ b/terraform/aurora.tf @@ -0,0 +1,64 @@ +resource "aws_security_group" "aurora" { + name = "${local.project}-aurora" + description = "Aurora for seahaven-ap" + vpc_id = local.vpc_id + + ingress { + description = "Postgres from Fargate" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.api.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_db_subnet_group" "api" { + name = local.project + subnet_ids = local.private_subnet_ids + + tags = { + Name = "${local.project}-db" + } +} + +resource "aws_rds_cluster" "api" { + cluster_identifier = local.project + engine = "aurora-postgresql" + engine_mode = "provisioned" + engine_version = "16.6" + database_name = "seahaven_ap" + master_username = "seahaven" + master_password = random_password.db.result + db_subnet_group_name = aws_db_subnet_group.api.name + vpc_security_group_ids = [aws_security_group.aurora.id] + storage_encrypted = true + backup_retention_period = 1 + skip_final_snapshot = true + apply_immediately = true + copy_tags_to_snapshot = true + enable_http_endpoint = false + + serverlessv2_scaling_configuration { + min_capacity = 0.5 + max_capacity = 1 + } + + tags = { + Name = local.project + } +} + +resource "aws_rds_cluster_instance" "api" { + identifier = "${local.project}-1" + cluster_identifier = aws_rds_cluster.api.id + instance_class = "db.serverless" + engine = aws_rds_cluster.api.engine + engine_version = aws_rds_cluster.api.engine_version +} diff --git a/terraform/cloudfront.tf b/terraform/cloudfront.tf new file mode 100644 index 0000000..a7b3b81 --- /dev/null +++ b/terraform/cloudfront.tf @@ -0,0 +1,113 @@ +resource "random_password" "origin_verify" { + length = 32 + special = false +} + +resource "aws_cloudfront_origin_access_control" "web" { + name = "${local.project}-${var.environment}-oac" + description = "OAC for ${local.web_bucket_name}" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +resource "aws_cloudfront_function" "spa_rewrite" { + name = "${local.project}-${var.environment}-spa-rewrite" + runtime = "cloudfront-js-1.0" + comment = "SPA routing: rewrite extensionless paths to /index.html" + publish = true + code = local.spa_rewrite_code + + lifecycle { + ignore_changes = [publish] + } +} + +resource "aws_cloudfront_function" "spa_security_headers" { + name = "${local.project}-${var.environment}-spa-security-headers" + runtime = "cloudfront-js-1.0" + comment = "SPA CSP and Permissions-Policy" + publish = true + code = local.spa_security_headers_code + + lifecycle { + ignore_changes = [publish] + } +} + +resource "aws_cloudfront_distribution" "web" { + enabled = true + is_ipv6_enabled = true + http_version = "http2and3" + comment = "${local.project} ${var.environment} SPA" + default_root_object = "index.html" + price_class = "PriceClass_100" + web_acl_id = data.aws_ssm_parameter.app_web_acl_arn.value + + origin { + origin_id = local.s3_origin_id + domain_name = aws_s3_bucket.web.bucket_regional_domain_name + origin_access_control_id = aws_cloudfront_origin_access_control.web.id + } + + origin { + origin_id = local.api_origin_id + domain_name = aws_lb.api.dns_name + custom_header { + name = "X-Origin-Verify" + value = random_password.origin_verify.result + } + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "http-only" + origin_ssl_protocols = ["TLSv1.2"] + } + } + + ordered_cache_behavior { + path_pattern = "/api/*" + target_origin_id = local.api_origin_id + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = local.cache_policy_caching_disabled + origin_request_policy_id = local.origin_request_all_viewer_except_host + response_headers_policy_id = local.response_headers_security_headers + } + + default_cache_behavior { + target_origin_id = local.s3_origin_id + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = local.cache_policy_caching_optimized + response_headers_policy_id = local.response_headers_security_headers + + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.spa_rewrite.arn + } + + function_association { + event_type = "viewer-response" + function_arn = aws_cloudfront_function.spa_security_headers.arn + } + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + cloudfront_default_certificate = true + } + + lifecycle { + prevent_destroy = true + } +} diff --git a/terraform/cognito.tf b/terraform/cognito.tf new file mode 100644 index 0000000..3c6c0c4 --- /dev/null +++ b/terraform/cognito.tf @@ -0,0 +1,199 @@ +locals { + cognito_prefix_domain = "${local.project}-${var.environment}" + google_oidc = jsondecode(data.aws_secretsmanager_secret_version.google_oidc.secret_string) + google_oidc_client_id = local.google_oidc.client_id + google_oidc_client_secret = sensitive(local.google_oidc.client_secret) + + app_origin = "https://${aws_cloudfront_distribution.web.domain_name}" + + portal_callback_urls = [ + "${local.app_origin}/api/auth/callback", + "http://127.0.0.1:8787/api/auth/callback", + ] + portal_logout_urls = [ + local.app_origin, + "http://127.0.0.1:3000/", + ] + + cognito_pool_id = aws_cognito_user_pool.portal.id + cognito_issuer = "https://cognito-idp.${var.aws_region}.amazonaws.com/${aws_cognito_user_pool.portal.id}" + cognito_client_id = aws_cognito_user_pool_client.portal.id + cognito_hosted_domain = "${aws_cognito_user_pool_domain.prefix.domain}.auth.${var.aws_region}.amazoncognito.com" +} + +data "aws_secretsmanager_secret_version" "google_oidc" { + secret_id = aws_secretsmanager_secret.google_oidc.id + + depends_on = [aws_secretsmanager_secret_version.google_oidc] +} + +data "archive_file" "cognito_presignup" { + type = "zip" + source_file = "${path.module}/lambda/cognito-presignup/index.mjs" + output_path = "${path.module}/build/packages/cognito-presignup.zip" +} + +resource "aws_s3_object" "cognito_presignup" { + bucket = aws_s3_bucket.artifacts.id + key = "functions/cognito-presignup.zip" + content_base64 = filebase64(data.archive_file.cognito_presignup.output_path) + source_hash = data.archive_file.cognito_presignup.output_base64sha256 +} + +resource "aws_cloudwatch_log_group" "cognito_presignup" { + name = "/aws/lambda/${local.project}-cognito-presignup" + retention_in_days = 14 +} + +data "aws_iam_policy_document" "lambda_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "cognito_presignup" { + name = "${local.project}-cognito-presignup" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = data.aws_iam_policy.ecs_task_boundary.arn +} + +resource "aws_iam_role_policy_attachment" "cognito_presignup_basic" { + role = aws_iam_role.cognito_presignup.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_lambda_function" "cognito_presignup" { + function_name = "${local.project}-cognito-presignup" + role = aws_iam_role.cognito_presignup.arn + handler = "index.handler" + runtime = "nodejs24.x" + architectures = ["arm64"] + memory_size = 128 + timeout = 5 + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.cognito_presignup.key + source_code_hash = data.archive_file.cognito_presignup.output_base64sha256 + + depends_on = [ + aws_cloudwatch_log_group.cognito_presignup, + aws_iam_role_policy_attachment.cognito_presignup_basic, + ] +} + +resource "aws_cognito_user_pool" "portal" { + name = local.project + + username_attributes = ["email"] + auto_verified_attributes = ["email"] + mfa_configuration = "OFF" + + admin_create_user_config { + allow_admin_create_user_only = true + } + + password_policy { + minimum_length = 32 + require_lowercase = true + require_numbers = true + require_symbols = true + require_uppercase = true + temporary_password_validity_days = 1 + } + + account_recovery_setting { + recovery_mechanism { + name = "verified_email" + priority = 1 + } + } + + lambda_config { + pre_sign_up = aws_lambda_function.cognito_presignup.arn + } + + tags = { + Project = local.project + } +} + +resource "aws_lambda_permission" "cognito_presignup" { + statement_id = "AllowCognitoInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.cognito_presignup.function_name + principal = "cognito-idp.amazonaws.com" + source_arn = aws_cognito_user_pool.portal.arn + source_account = local.account_id +} + +resource "aws_cognito_identity_provider" "google" { + user_pool_id = aws_cognito_user_pool.portal.id + provider_name = "Google" + provider_type = "Google" + + lifecycle { + precondition { + condition = local.google_oidc_client_id != "replace-me" && local.google_oidc_client_secret != "replace-me" + error_message = "seahaven-ap/google-oidc still has the placeholder. Replace client_id and client_secret in Secrets Manager, then re-run the HCP apply." + } + } + + provider_details = { + client_id = local.google_oidc_client_id + client_secret = local.google_oidc_client_secret + authorize_scopes = "openid email profile" + attributes_url = "https://people.googleapis.com/v1/people/me?personFields=" + attributes_url_add_attributes = "true" + authorize_url = "https://accounts.google.com/o/oauth2/v2/auth" + oidc_issuer = "https://accounts.google.com" + token_url = "https://www.googleapis.com/oauth2/v4/token" + token_request_method = "POST" + } + + attribute_mapping = { + email = "email" + name = "name" + username = "sub" + } +} + +resource "aws_cognito_user_pool_client" "portal" { + name = local.project + user_pool_id = aws_cognito_user_pool.portal.id + + generate_secret = false + allowed_oauth_flows_user_pool_client = true + allowed_oauth_flows = ["code"] + allowed_oauth_scopes = ["openid", "email", "profile"] + supported_identity_providers = ["COGNITO", "Google"] + explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH"] + enable_token_revocation = true + prevent_user_existence_errors = "ENABLED" + + callback_urls = local.portal_callback_urls + logout_urls = local.portal_logout_urls + + access_token_validity = 1 + id_token_validity = 1 + refresh_token_validity = 8 + + token_validity_units { + access_token = "hours" + id_token = "hours" + refresh_token = "hours" + } + + depends_on = [aws_cognito_identity_provider.google] +} + +resource "aws_cognito_user_pool_domain" "prefix" { + domain = local.cognito_prefix_domain + user_pool_id = aws_cognito_user_pool.portal.id +} diff --git a/terraform/data.tf b/terraform/data.tf new file mode 100644 index 0000000..3176f1d --- /dev/null +++ b/terraform/data.tf @@ -0,0 +1,40 @@ +data "aws_caller_identity" "current" {} + +check "correct_account" { + assert { + condition = data.aws_caller_identity.current.account_id == local.account_id + error_message = "This configuration targets account ${local.account_id} (${var.environment}), but the credentials resolve to ${data.aws_caller_identity.current.account_id}." + } +} + +data "aws_ssm_parameter" "app_web_acl_arn" { + name = "/seahaven/waf/app-web-acl-arn" +} + +data "aws_iam_policy" "ecs_task_boundary" { + name = "seahaven-ap-ecs-task-boundary" +} + +data "aws_iam_policy" "github_deploy_boundary" { + name = "seahaven-ap-githubdeploy-boundary" +} + +check "existing_vpc_pair" { + assert { + condition = ( + (var.existing_vpc_id == "") == (length(var.existing_public_subnet_ids) == 0) && + (var.existing_vpc_id == "") == (length(var.existing_private_subnet_ids) == 0) + ) + error_message = "existing_vpc_id, existing_public_subnet_ids, and existing_private_subnet_ids must all be set or all be empty." + } +} + +check "existing_subnets_in_vpc" { + assert { + condition = alltrue(concat( + [for subnet in data.aws_subnet.existing_public : subnet.vpc_id == var.existing_vpc_id], + [for subnet in data.aws_subnet.existing_private : subnet.vpc_id == var.existing_vpc_id], + )) + error_message = "Every existing subnet ID must belong to existing_vpc_id." + } +} diff --git a/terraform/documents.tf b/terraform/documents.tf new file mode 100644 index 0000000..4bd43cd --- /dev/null +++ b/terraform/documents.tf @@ -0,0 +1,73 @@ +resource "aws_s3_bucket" "documents" { + bucket = local.documents_bucket_name + + tags = { + Purpose = "seahaven-ap-invoice-documents" + } +} + +resource "aws_s3_bucket_public_access_block" "documents" { + bucket = aws_s3_bucket.documents.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "documents" { + bucket = aws_s3_bucket.documents.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "documents" { + bucket = aws_s3_bucket.documents.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "documents" { + bucket = aws_s3_bucket.documents.id + + versioning_configuration { + status = "Enabled" + } +} + +data "aws_iam_policy_document" "documents" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + resources = [ + aws_s3_bucket.documents.arn, + "${aws_s3_bucket.documents.arn}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +resource "aws_s3_bucket_policy" "documents" { + bucket = aws_s3_bucket.documents.id + policy = data.aws_iam_policy_document.documents.json + + depends_on = [aws_s3_bucket_public_access_block.documents] +} diff --git a/terraform/ecs.tf b/terraform/ecs.tf new file mode 100644 index 0000000..437fd21 --- /dev/null +++ b/terraform/ecs.tf @@ -0,0 +1,229 @@ +resource "aws_ecr_repository" "api" { + name = local.project + image_tag_mutability = "MUTABLE" + force_delete = true + + image_scanning_configuration { + scan_on_push = true + } + + encryption_configuration { + encryption_type = "AES256" + } +} + +resource "aws_ecr_lifecycle_policy" "api" { + repository = aws_ecr_repository.api.name + + policy = jsonencode({ + rules = [ + { + rulePriority = 1 + description = "Expire untagged images. SHA tags stay so registered task revisions can roll back." + selection = { + tagStatus = "untagged" + countType = "sinceImagePushed" + countUnit = "days" + countNumber = 14 + } + action = { + type = "expire" + } + } + ] + }) +} + +resource "aws_security_group" "alb" { + name = "${local.project}-alb" + description = "ALB for seahaven-ap (CloudFront origin only)" + vpc_id = local.vpc_id + + ingress { + description = "HTTP from CloudFront origin-facing prefix list" + from_port = 80 + to_port = 80 + protocol = "tcp" + prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront_origin.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_security_group" "api" { + name = "${local.project}-api" + description = "Fargate tasks for seahaven-ap" + vpc_id = local.vpc_id + + ingress { + description = "From ALB" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + security_groups = [aws_security_group.alb.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_lb" "api" { + name = local.project + load_balancer_type = "application" + idle_timeout = 120 + security_groups = [aws_security_group.alb.id] + subnets = local.public_subnet_ids + drop_invalid_header_fields = true +} + +resource "aws_lb_target_group" "api" { + name = "${local.project}-api" + port = 8080 + protocol = "HTTP" + vpc_id = local.vpc_id + target_type = "ip" + + health_check { + enabled = true + path = "/api/health" + matcher = "200" + interval = 30 + timeout = 5 + healthy_threshold = 2 + unhealthy_threshold = 3 + } +} + +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.api.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.api.arn + } +} + +resource "aws_ecs_cluster" "api" { + name = local.project + + setting { + name = "containerInsights" + value = "disabled" + } +} + +locals { + api_container_name = "api" + bootstrap_command = [ + "node", + "-e", + "require('http').createServer((q,s)=>{const p=(q.url||'').split('?')[0];const ok=q.method==='GET'&&p==='/api/health';const b=Buffer.from(ok?JSON.stringify({stage:'bootstrap',sha:'bootstrap'}):'');s.writeHead(ok?200:503,ok?{'content-type':'application/json','content-length':b.length}:{});s.end(b)}).listen(8080)", + ] + + database_url = "postgresql://seahaven:${urlencode(random_password.db.result)}@${aws_rds_cluster.api.endpoint}:5432/seahaven_ap" + + api_environment_map = { + NODE_ENV = "production" + STAGE = var.environment + API_PORT = "8080" + DATABASE_DRIVER = "postgres" + DATABASE_URL = local.database_url + AWS_REGION = var.aws_region + COGNITO_ISSUER = local.cognito_issuer + COGNITO_AUDIENCE = local.cognito_client_id + COGNITO_DOMAIN = local.cognito_hosted_domain + APP_ORIGIN = local.app_origin + ORIGIN_VERIFY_SECRET = random_password.origin_verify.result + DOCUMENTS_BUCKET = aws_s3_bucket.documents.id + } + + api_environment = concat( + [for name, value in local.api_environment_map : { name = name, value = value }], + [{ name = "GIT_SHA", value = "bootstrap" }], + ) +} + +resource "aws_ecs_task_definition" "api" { + family = local.project + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = "512" + memory = "1024" + execution_role_arn = aws_iam_role.ecs_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + runtime_platform { + operating_system_family = "LINUX" + cpu_architecture = "X86_64" + } + + container_definitions = jsonencode([ + { + name = local.api_container_name + image = "public.ecr.aws/docker/library/node:24-alpine" + essential = true + command = local.bootstrap_command + portMappings = [ + { + containerPort = 8080 + protocol = "tcp" + } + ] + environment = local.api_environment + stopTimeout = 60 + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.api.name + "awslogs-region" = var.aws_region + "awslogs-stream-prefix" = "ecs" + } + } + } + ]) + + lifecycle { + ignore_changes = [container_definitions] + } +} + +resource "aws_ecs_service" "api" { + name = local.project + cluster = aws_ecs_cluster.api.id + task_definition = aws_ecs_task_definition.api.arn + desired_count = 1 + launch_type = "FARGATE" + + network_configuration { + subnets = local.public_subnet_ids + security_groups = [aws_security_group.api.id] + assign_public_ip = true + } + + load_balancer { + target_group_arn = aws_lb_target_group.api.arn + container_name = local.api_container_name + container_port = 8080 + } + + health_check_grace_period_seconds = 60 + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 200 + + lifecycle { + ignore_changes = [task_definition, desired_count] + } + + depends_on = [aws_lb_listener.http] +} diff --git a/terraform/iam_ecs.tf b/terraform/iam_ecs.tf new file mode 100644 index 0000000..f1ff969 --- /dev/null +++ b/terraform/iam_ecs.tf @@ -0,0 +1,107 @@ +data "aws_iam_policy_document" "ecs_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "ecs_task" { + statement { + sid = "ReadProjectParameters" + effect = "Allow" + actions = ["ssm:GetParameter", "ssm:GetParameters"] + resources = ["arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*"] + } + + statement { + sid = "ReadProjectSecrets" + effect = "Allow" + actions = ["secretsmanager:GetSecretValue"] + resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:seahaven-ap/*"] + } + + statement { + sid = "EcrAuth" + effect = "Allow" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + + statement { + sid = "EcrPull" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + ] + resources = [aws_ecr_repository.api.arn] + } + + statement { + sid = "TaskLogs" + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:CreateLogGroup", + ] + resources = [ + aws_cloudwatch_log_group.api.arn, + "${aws_cloudwatch_log_group.api.arn}:*", + ] + } + + statement { + sid = "DocumentsBucket" + effect = "Allow" + actions = [ + "s3:ListBucket", + "s3:GetBucketLocation", + ] + resources = [aws_s3_bucket.documents.arn] + } + + statement { + sid = "DocumentsObjects" + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts", + ] + resources = ["${aws_s3_bucket.documents.arn}/*"] + } +} + +resource "aws_iam_role" "ecs_execution" { + name = "${local.project}-ecs-exec" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.ecs_assume.json + permissions_boundary = data.aws_iam_policy.ecs_task_boundary.arn +} + +resource "aws_iam_role_policy_attachment" "ecs_execution" { + role = aws_iam_role.ecs_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role" "ecs_task" { + name = "${local.project}-ecs-task" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.ecs_assume.json + permissions_boundary = data.aws_iam_policy.ecs_task_boundary.arn +} + +resource "aws_iam_role_policy" "ecs_task" { + name = "api-runtime" + role = aws_iam_role.ecs_task.id + policy = data.aws_iam_policy_document.ecs_task.json +} diff --git a/terraform/iam_github_deploy.tf b/terraform/iam_github_deploy.tf new file mode 100644 index 0000000..2c7295d --- /dev/null +++ b/terraform/iam_github_deploy.tf @@ -0,0 +1,195 @@ +data "aws_iam_policy_document" "github_deploy_assume" { + statement { + sid = "GithubDeployOidc" + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [local.github_oidc_provider_arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = ["repo:Sea-Haven-Industries@183236204/seahaven-ap@1330218238:environment:dev"] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:job_workflow_ref" + values = [ + "${var.github_repo}/.github/workflows/deploy-web.yaml@refs/heads/${var.github_deploy_branch}", + "${var.github_repo}/.github/workflows/deploy-api.yaml@refs/heads/${var.github_deploy_branch}", + ] + } + } +} + +resource "aws_iam_role" "github_deploy" { + name = local.deploy_role + path = "/tf-managed/" + description = "GitHub Actions SPA and API deploy role for ${var.github_repo} Environment dev" + assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json + permissions_boundary = data.aws_iam_policy.github_deploy_boundary.arn + max_session_duration = 3600 +} + +data "aws_iam_policy_document" "github_deploy" { + statement { + sid = "ListWebBucket" + effect = "Allow" + actions = [ + "s3:GetBucketLocation", + "s3:ListBucket", + ] + resources = [aws_s3_bucket.web.arn] + } + + statement { + sid = "SyncWebBucket" + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + ] + resources = ["${aws_s3_bucket.web.arn}/*"] + } + + statement { + sid = "InvalidateDistribution" + effect = "Allow" + actions = [ + "cloudfront:CreateInvalidation", + "cloudfront:GetInvalidation", + "cloudfront:GetDistribution", + ] + resources = [aws_cloudfront_distribution.web.arn] + } + + statement { + sid = "EcrAuth" + effect = "Allow" + actions = [ + "ecr:GetAuthorizationToken", + ] + resources = ["*"] + } + + statement { + sid = "EcrPush" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:CompleteLayerUpload", + "ecr:GetDownloadUrlForLayer", + "ecr:InitiateLayerUpload", + "ecr:PutImage", + "ecr:UploadLayerPart", + "ecr:DescribeRepositories", + "ecr:DescribeImages", + ] + resources = [aws_ecr_repository.api.arn] + } + + statement { + sid = "EcsRegisterTaskDefinition" + effect = "Allow" + actions = [ + "ecs:DescribeTaskDefinition", + "ecs:RegisterTaskDefinition", + ] + resources = ["*"] + + condition { + test = "StringEquals" + variable = "aws:RequestedRegion" + values = [var.aws_region] + } + } + + statement { + sid = "EcsUpdateService" + effect = "Allow" + actions = [ + "ecs:DescribeServices", + "ecs:DescribeTasks", + "ecs:ListTasks", + "ecs:RunTask", + "ecs:StopTask", + "ecs:TagResource", + "ecs:UpdateService", + ] + resources = [ + aws_ecs_cluster.api.arn, + aws_ecs_service.api.id, + "arn:aws:ecs:${var.aws_region}:${local.account_id}:task/${local.project}/*", + "arn:aws:ecs:${var.aws_region}:${local.account_id}:task-definition/${local.project}", + "arn:aws:ecs:${var.aws_region}:${local.account_id}:task-definition/${local.project}:*", + ] + } + + statement { + sid = "PassTaskRoles" + effect = "Allow" + actions = ["iam:PassRole"] + resources = [ + aws_iam_role.ecs_task.arn, + aws_iam_role.ecs_execution.arn, + ] + + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["ecs-tasks.amazonaws.com"] + } + } + + statement { + sid = "DeployParams" + effect = "Allow" + actions = [ + "ssm:GetParameter", + ] + resources = [ + aws_ssm_parameter.deploy_bucket.arn, + aws_ssm_parameter.deploy_distribution_id.arn, + aws_ssm_parameter.deploy_cluster.arn, + aws_ssm_parameter.deploy_service.arn, + aws_ssm_parameter.deploy_task_family.arn, + aws_ssm_parameter.deploy_ecr_repository.arn, + aws_ssm_parameter.deploy_container_name.arn, + aws_ssm_parameter.deploy_task_environment.arn, + ] + } + + statement { + sid = "DecryptTaskEnvironment" + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [ + "arn:aws:kms:${var.aws_region}:${local.account_id}:alias/aws/ssm", + "arn:aws:kms:${var.aws_region}:${local.account_id}:key/*", + ] + + condition { + test = "StringEquals" + variable = "kms:ViaService" + values = ["ssm.${var.aws_region}.amazonaws.com"] + } + } +} + +resource "aws_iam_role_policy" "github_deploy" { + name = "seahaven-ap-spa-api-deploy" + role = aws_iam_role.github_deploy.id + policy = data.aws_iam_policy_document.github_deploy.json +} diff --git a/terraform/lambda/cognito-presignup/index.mjs b/terraform/lambda/cognito-presignup/index.mjs new file mode 100644 index 0000000..cbaf7c1 --- /dev/null +++ b/terraform/lambda/cognito-presignup/index.mjs @@ -0,0 +1,17 @@ +const ALLOWED_DOMAINS = new Set(["seahavenind.com", "seahaven.com"]); + +export async function handler(event) { + const email = String(event?.request?.userAttributes?.email ?? "") + .trim() + .toLowerCase(); + const at = email.lastIndexOf("@"); + const domain = at >= 0 ? email.slice(at + 1) : ""; + + if (!ALLOWED_DOMAINS.has(domain)) { + throw new Error("Email domain is not allowed"); + } + + event.response.autoConfirmUser = true; + event.response.autoVerifyEmail = true; + return event; +} diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 0000000..07710e1 --- /dev/null +++ b/terraform/locals.tf @@ -0,0 +1,77 @@ +locals { + project = "seahaven-ap" + account_id = "710827005802" + hcp_project = "seahaven-dev" + hcp_workspace = "seahaven-ap-dev" + apply_role = "hcptf-seahaven-ap" + plan_role = "hcptf-seahaven-ap-plan" + deploy_role = "githubdeploy-seahaven-ap" + + web_bucket_name = "seahaven-ap-web-${local.account_id}" + artifacts_bucket_name = "seahaven-ap-artifacts-${local.account_id}" + documents_bucket_name = "seahaven-ap-documents-${local.account_id}" + ssm_prefix = "/seahaven-ap" + + manage_vpc = var.existing_vpc_id == "" + vpc_cidr = "10.63.0.0/16" + public_subnet_cidrs = ["10.63.0.0/24", "10.63.1.0/24"] + private_subnet_cidrs = ["10.63.10.0/24", "10.63.11.0/24"] + + github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com" + + # Org-baseline topic in this account. Alarm-only; no OK or insufficient-data action. + site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts" + + cache_policy_caching_optimized = "658327ea-f89d-4fab-a63d-7e88639e58f6" + cache_policy_caching_disabled = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" + origin_request_all_viewer_except_host = "b689b0a8-53d0-40ab-baf2-68738e2966ac" + response_headers_security_headers = "67f7725c-6f97-4210-82d7-5512b31e9d03" + + s3_origin_id = "S3WebOrigin" + api_origin_id = "ApiOrigin" + + spa_csp = join(" ", [ + "default-src 'self';", + "script-src 'self';", + "style-src 'self' 'unsafe-inline';", + "img-src 'self' data:;", + "font-src 'self';", + "connect-src 'self';", + "object-src 'none';", + "base-uri 'self';", + "form-action 'self';", + "frame-ancestors 'none';", + "upgrade-insecure-requests;", + ]) + + spa_permissions_policy = join(", ", [ + "accelerometer=()", + "camera=()", + "geolocation=()", + "gyroscope=()", + "magnetometer=()", + "microphone=()", + "payment=()", + "usb=()", + ]) + + spa_rewrite_code = join("\n", [ + "function handler(event) {", + " var request = event.request;", + " var uri = request.uri;", + " if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {", + " request.uri = '/index.html';", + " }", + " return request;", + "}", + ]) + + spa_security_headers_code = join("\n", [ + "function handler(event) {", + " var headers = event.response.headers;", + " headers['content-security-policy'] = { value: ${jsonencode(local.spa_csp)} };", + " headers['permissions-policy'] = { value: ${jsonencode(local.spa_permissions_policy)} };", + " return event.response;", + "}", + ]) +} diff --git a/terraform/logs.tf b/terraform/logs.tf new file mode 100644 index 0000000..dd7e917 --- /dev/null +++ b/terraform/logs.tf @@ -0,0 +1,4 @@ +resource "aws_cloudwatch_log_group" "api" { + name = "/ecs/${local.project}" + retention_in_days = 14 +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..bd0e486 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,69 @@ +output "web_bucket_name" { + description = "S3 origin bucket name. deploy-web.yaml syncs placeholder/ to the bucket root." + value = aws_s3_bucket.web.bucket +} + +output "cloudfront_distribution_id" { + description = "CloudFront distribution ID. deploy-web.yaml invalidates /* after each sync." + value = aws_cloudfront_distribution.web.id +} + +output "cloudfront_domain_name" { + description = "CloudFront distribution domain (*.cloudfront.net)." + value = aws_cloudfront_distribution.web.domain_name +} + +output "github_deploy_role_arn" { + description = "OIDC role ARN for deploy-web.yaml and deploy-api.yaml (Environment variable DEPLOY_ROLE_ARN)." + value = aws_iam_role.github_deploy.arn +} + +output "ecs_cluster_name" { + description = "ECS cluster name." + value = aws_ecs_cluster.api.name +} + +output "ecs_service_name" { + description = "ECS service name." + value = aws_ecs_service.api.name +} + +output "alb_dns_name" { + description = "API ALB DNS name. CloudFront /api/* origin." + value = aws_lb.api.dns_name +} + +output "cognito_user_pool_id" { + description = "seahaven-ap Cognito user pool ID." + value = aws_cognito_user_pool.portal.id +} + +output "cognito_user_pool_client_id" { + description = "Public app client ID (authorization code + PKCE)." + value = aws_cognito_user_pool_client.portal.id +} + +output "cognito_prefix_domain" { + description = "Cognito hosted UI prefix domain." + value = "${aws_cognito_user_pool_domain.prefix.domain}.auth.${var.aws_region}.amazoncognito.com" +} + +output "vpc_id" { + description = "VPC the ALB, Fargate tasks, and Aurora run in." + value = local.vpc_id +} + +output "public_subnet_ids" { + description = "Public subnet IDs for the ALB and Fargate tasks." + value = local.public_subnet_ids +} + +output "aurora_cluster_endpoint" { + description = "Aurora writer endpoint." + value = aws_rds_cluster.api.endpoint +} + +output "documents_bucket_name" { + description = "Invoice documents bucket." + value = aws_s3_bucket.documents.bucket +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..4f9d903 --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,11 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = local.project + Environment = var.environment + ManagedBy = "terraform" + } + } +} diff --git a/terraform/s3.tf b/terraform/s3.tf new file mode 100644 index 0000000..6a57cfe --- /dev/null +++ b/terraform/s3.tf @@ -0,0 +1,96 @@ +resource "aws_s3_bucket" "web" { + bucket = local.web_bucket_name + + tags = { + Purpose = "seahaven-ap-spa" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "web" { + bucket = aws_s3_bucket.web.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "web" { + bucket = aws_s3_bucket.web.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "web" { + bucket = aws_s3_bucket.web.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "web" { + bucket = aws_s3_bucket.web.id + + versioning_configuration { + status = "Enabled" + } +} + +data "aws_iam_policy_document" "web" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + resources = [ + aws_s3_bucket.web.arn, + "${aws_s3_bucket.web.arn}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } + + statement { + sid = "AllowCloudFrontOacRead" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + + actions = ["s3:GetObject"] + resources = ["${aws_s3_bucket.web.arn}/*"] + + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [aws_cloudfront_distribution.web.arn] + } + } +} + +resource "aws_s3_bucket_policy" "web" { + bucket = aws_s3_bucket.web.id + policy = data.aws_iam_policy_document.web.json + + depends_on = [aws_s3_bucket_public_access_block.web] +} diff --git a/terraform/secrets.tf b/terraform/secrets.tf new file mode 100644 index 0000000..3ee9452 --- /dev/null +++ b/terraform/secrets.tf @@ -0,0 +1,36 @@ +resource "aws_secretsmanager_secret" "google_oidc" { + name = "seahaven-ap/google-oidc" + description = "Google OIDC client credentials for seahaven-ap Cognito. Value is written outside Terraform." +} + +# Placeholder so the first apply has an AWSCURRENT version to read. Replace the +# value in Secrets Manager; Terraform will not write this placeholder back. +resource "aws_secretsmanager_secret_version" "google_oidc" { + secret_id = aws_secretsmanager_secret.google_oidc.id + secret_string = jsonencode({ + client_id = "replace-me" + client_secret = "replace-me" + }) + + lifecycle { + ignore_changes = [secret_string] + } +} + +resource "aws_secretsmanager_secret" "database" { + name = "seahaven-ap/database" + description = "Aurora master credentials for seahaven-ap" +} + +resource "aws_secretsmanager_secret_version" "database" { + secret_id = aws_secretsmanager_secret.database.id + secret_string = jsonencode({ + username = "seahaven" + password = random_password.db.result + }) +} + +resource "random_password" "db" { + length = 32 + special = false +} diff --git a/terraform/ssm.tf b/terraform/ssm.tf new file mode 100644 index 0000000..725df5c --- /dev/null +++ b/terraform/ssm.tf @@ -0,0 +1,55 @@ +resource "aws_ssm_parameter" "deploy_bucket" { + name = "${local.ssm_prefix}/deploy/bucket" + type = "String" + value = aws_s3_bucket.web.id + description = "SPA origin bucket; deploy-web syncs placeholder/ to the bucket root" +} + +resource "aws_ssm_parameter" "deploy_distribution_id" { + name = "${local.ssm_prefix}/deploy/distribution-id" + type = "String" + value = aws_cloudfront_distribution.web.id + description = "CloudFront distribution ID; deploy-web invalidates /* after sync" +} + +resource "aws_ssm_parameter" "deploy_cluster" { + name = "${local.ssm_prefix}/deploy/cluster" + type = "String" + value = aws_ecs_cluster.api.name + description = "ECS cluster name for deploy-api.yaml" +} + +resource "aws_ssm_parameter" "deploy_service" { + name = "${local.ssm_prefix}/deploy/service" + type = "String" + value = aws_ecs_service.api.name + description = "ECS service name for deploy-api.yaml" +} + +resource "aws_ssm_parameter" "deploy_task_family" { + name = "${local.ssm_prefix}/deploy/task-family" + type = "String" + value = aws_ecs_task_definition.api.family + description = "ECS task definition family for deploy-api.yaml" +} + +resource "aws_ssm_parameter" "deploy_ecr_repository" { + name = "${local.ssm_prefix}/deploy/ecr-repository" + type = "String" + value = aws_ecr_repository.api.repository_url + description = "ECR repository URL for deploy-api.yaml" +} + +resource "aws_ssm_parameter" "deploy_container_name" { + name = "${local.ssm_prefix}/deploy/container-name" + type = "String" + value = local.api_container_name + description = "Container name in the ECS task definition" +} + +resource "aws_ssm_parameter" "deploy_task_environment" { + name = "${local.ssm_prefix}/deploy/task-environment" + type = "SecureString" + value = jsonencode(local.api_environment_map) + description = "Terraform-owned API task env JSON. deploy-api.yaml applies it on each image deploy, then sets GIT_SHA." +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..b6f3138 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,55 @@ +variable "aws_region" { + description = "Region every resource in this configuration is created in." + type = string + default = "us-east-1" +} + +variable "environment" { + description = "HCP workspace stage. seahaven-dev only; prod is AP-12." + type = string + + validation { + condition = var.environment == "dev" + error_message = "environment must be \"dev\". Prod is AP-12." + } +} + +variable "github_repo" { + description = "GitHub owner/name for the SPA and API deploy OIDC trust." + type = string + default = "Sea-Haven-Industries/seahaven-ap" +} + +variable "github_deploy_branch" { + description = "Git branch pinned in job_workflow_ref for the SPA and API deploy role." + type = string + default = "main" +} + +variable "existing_vpc_id" { + description = "When set, place the ALB, Fargate tasks, and Aurora in this VPC instead of creating one." + type = string + default = "" +} + +variable "existing_public_subnet_ids" { + description = "Public subnet IDs in existing_vpc_id. Required with existing_vpc_id." + type = list(string) + default = [] + + validation { + condition = var.existing_vpc_id == "" || length(var.existing_public_subnet_ids) >= 2 + error_message = "existing_public_subnet_ids must list at least two subnets when existing_vpc_id is set." + } +} + +variable "existing_private_subnet_ids" { + description = "Private subnet IDs in existing_vpc_id for Aurora. Required with existing_vpc_id." + type = list(string) + default = [] + + validation { + condition = var.existing_vpc_id == "" || length(var.existing_private_subnet_ids) >= 2 + error_message = "existing_private_subnet_ids must list at least two subnets when existing_vpc_id is set." + } +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..8754570 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.65" + } + archive = { + source = "hashicorp/archive" + version = "~> 2.8" + } + random = { + source = "hashicorp/random" + version = "~> 3.9" + } + } + + cloud { + organization = "seahaven" + + workspaces { + name = "seahaven-ap-dev" + } + } +} diff --git a/terraform/vpc.tf b/terraform/vpc.tf new file mode 100644 index 0000000..a7bb48e --- /dev/null +++ b/terraform/vpc.tf @@ -0,0 +1,101 @@ +data "aws_availability_zones" "available" { + count = local.manage_vpc ? 1 : 0 + state = "available" +} + +data "aws_vpc" "existing" { + count = var.existing_vpc_id == "" ? 0 : 1 + id = var.existing_vpc_id +} + +data "aws_subnet" "existing_public" { + for_each = toset(var.existing_public_subnet_ids) + id = each.value +} + +data "aws_subnet" "existing_private" { + for_each = toset(var.existing_private_subnet_ids) + id = each.value +} + +data "aws_ec2_managed_prefix_list" "cloudfront_origin" { + name = "com.amazonaws.global.cloudfront.origin-facing" +} + +resource "aws_vpc" "this" { + count = local.manage_vpc ? 1 : 0 + + cidr_block = local.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "${local.project}-vpc" + } +} + +resource "aws_internet_gateway" "this" { + count = local.manage_vpc ? 1 : 0 + + vpc_id = aws_vpc.this[0].id + + tags = { + Name = "${local.project}-igw" + } +} + +resource "aws_subnet" "public" { + count = local.manage_vpc ? length(local.public_subnet_cidrs) : 0 + + vpc_id = aws_vpc.this[0].id + cidr_block = local.public_subnet_cidrs[count.index] + availability_zone = data.aws_availability_zones.available[0].names[count.index] + map_public_ip_on_launch = true + + tags = { + Name = "${local.project}-public-${count.index}" + } +} + +resource "aws_subnet" "private" { + count = local.manage_vpc ? length(local.private_subnet_cidrs) : 0 + + vpc_id = aws_vpc.this[0].id + cidr_block = local.private_subnet_cidrs[count.index] + availability_zone = data.aws_availability_zones.available[0].names[count.index] + + tags = { + Name = "${local.project}-private-${count.index}" + } +} + +resource "aws_route_table" "public" { + count = local.manage_vpc ? 1 : 0 + + vpc_id = aws_vpc.this[0].id + + tags = { + Name = "${local.project}-public" + } +} + +resource "aws_route" "public_default" { + count = local.manage_vpc ? 1 : 0 + + route_table_id = aws_route_table.public[0].id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this[0].id +} + +resource "aws_route_table_association" "public" { + count = local.manage_vpc ? length(local.public_subnet_cidrs) : 0 + + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public[0].id +} + +locals { + vpc_id = local.manage_vpc ? aws_vpc.this[0].id : try(data.aws_vpc.existing[0].id, var.existing_vpc_id) + public_subnet_ids = local.manage_vpc ? aws_subnet.public[*].id : var.existing_public_subnet_ids + private_subnet_ids = local.manage_vpc ? aws_subnet.private[*].id : var.existing_private_subnet_ids +}