This commit is contained in:
Adam Moussa 2026-09-28 16:18:25 +00:00 • committed by GitHub
commit 2f367638ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2507 additions and 1 deletions

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

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

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

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

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,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
```

17
placeholder/index.html Normal file
View file

@ -0,0 +1,17 @@
<!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,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")

View file

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

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

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/

36
terraform/alarms.tf Normal file
View file

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

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

199
terraform/cognito.tf Normal file
View file

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

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

229
terraform/ecs.tf Normal file
View file

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

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

77
terraform/locals.tf Normal file
View file

@ -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;",
"}",
])
}

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

36
terraform/secrets.tf Normal file
View file

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

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
}