feat(cd): seahaven-dev SPA and API terraform plus deploy (AP-11)

This commit is contained in:
Adam Moussa 2026-09-22 13:07:43 -04:00
parent 963d48f140
commit 352830ef3e
No known key found for this signature in database
30 changed files with 2357 additions and 1 deletions

228
.github/workflows/deploy-api.yaml vendored Normal file
View file

@ -0,0 +1,228 @@
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 at a chosen ref
#
# 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)
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:
- 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 "Building ${sha}"
- 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() {
aws ssm get-parameter --name "$1" --query Parameter.Value --output text
}
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 }}
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 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"]))')"
TASK_ARN="$(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\"]}]}" \
--query 'tasks[0].taskArn' --output text)"
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 scripts/verify-api-health.sh

170
.github/workflows/deploy-web.yaml vendored Normal file
View file

@ -0,0 +1,170 @@
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 at a chosen 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)
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: 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
BUCKET=$(aws ssm get-parameter --name /seahaven-ap/deploy/bucket --query Parameter.Value --output text)
DIST_ID=$(aws ssm get-parameter --name /seahaven-ap/deploy/distribution-id --query Parameter.Value --output text)
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}"

View file

@ -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,20 @@ 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`.
- `.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
```

14
placeholder/index.html Normal file
View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sea Haven AP</title>
</head>
<body>
<main>
<h1>Sea Haven AP</h1>
<p>Accounts payable origin for seahaven-dev. The live SPA is not published on this distribution yet.</p>
</main>
</body>
</html>

59
scripts/patch-ecs-task-def.py Executable file
View file

@ -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()

View file

@ -0,0 +1,69 @@
#!/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
cloudfront = (tf_dir / "cloudfront.tf").read_text()
assert "cloudfront_default_certificate = true" in cloudfront
assert "aliases" not in cloudfront
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
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
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")

View file

@ -0,0 +1,79 @@
#!/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"
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}"
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
'
if [[ "${failures}" -ne 0 ]]; then
echo "FAIL: ${failures} verify-api-health cases failed" >&2
exit 1
fi
echo "PASS: API health verify checks"

40
scripts/verify-api-health.sh Executable file
View file

@ -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" || printf '000')"
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

11
terraform/.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
.terraform/
*.tfstate
*.tfstate.*
crash.log
override.tf
override.tf.json
*_override.tf
*_override.tf.json
*.tfvars
*.tfvars.json
build/

34
terraform/alarms.tf Normal file
View file

@ -0,0 +1,34 @@
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."
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."
dimensions = {
ClusterName = aws_ecs_cluster.api.name
ServiceName = aws_ecs_service.api.name
}
}

101
terraform/artifacts.tf Normal file
View file

@ -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]
}

64
terraform/aurora.tf Normal file
View file

@ -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
}

113
terraform/cloudfront.tf Normal file
View file

@ -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
}
}

190
terraform/cognito.tf Normal file
View file

@ -0,0 +1,190 @@
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
}
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"
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 = ["Google"]
explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_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
}

40
terraform/data.tf Normal file
View file

@ -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."
}
}

73
terraform/documents.tf Normal file
View file

@ -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]
}

228
terraform/ecs.tf Normal file
View file

@ -0,0 +1,228 @@
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 = "Keep the last 20 images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 20
}
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]
}

107
terraform/iam_ecs.tf Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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;
}

74
terraform/locals.tf Normal file
View file

@ -0,0 +1,74 @@
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"
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;",
"}",
])
}

4
terraform/logs.tf Normal file
View file

@ -0,0 +1,4 @@
resource "aws_cloudwatch_log_group" "api" {
name = "/ecs/${local.project}"
retention_in_days = 14
}

69
terraform/outputs.tf Normal file
View file

@ -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
}

11
terraform/providers.tf Normal file
View file

@ -0,0 +1,11 @@
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = local.project
Environment = var.environment
ManagedBy = "terraform"
}
}
}

96
terraform/s3.tf Normal file
View file

@ -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]
}

22
terraform/secrets.tf Normal file
View file

@ -0,0 +1,22 @@
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."
}
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
}

55
terraform/ssm.tf Normal file
View file

@ -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."
}

55
terraform/variables.tf Normal file
View file

@ -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."
}
}

26
terraform/versions.tf Normal file
View file

@ -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"
}
}
}

101
terraform/vpc.tf Normal file
View file

@ -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
}