mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-01 23:13:21 +00:00
Merge 70762c66b5 into 4912016541
This commit is contained in:
commit
2f367638ed
30 changed files with 2507 additions and 1 deletions
271
.github/workflows/deploy-api.yaml
vendored
Normal file
271
.github/workflows/deploy-api.yaml
vendored
Normal 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
199
.github/workflows/deploy-web.yaml
vendored
Normal 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}"
|
||||
17
README.md
17
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Sea Haven AP
|
||||
|
||||
Internal accounts payable automation for Sea Haven Industries. Local API is `http://127.0.0.1:8787`. CloudFront on seahaven-dev is the first hosted origin.
|
||||
Internal accounts payable automation for Sea Haven Industries. Local API is `http://127.0.0.1:8787`. Hosted origin on seahaven-dev is the CloudFront default domain after HCP apply; GitHub Environment `dev` deploys the placeholder and API image.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
|
|
@ -54,9 +54,24 @@ npm run lint:api
|
|||
npm run docs:preview # builds HTML via redocly build-docs and opens it
|
||||
```
|
||||
|
||||
## Hosted seahaven-dev
|
||||
|
||||
HCP Terraform workspace `seahaven-ap-dev` (project `seahaven-dev`) uses working directory `terraform` and a `terraform/**` VCS trigger on `main`. GitHub Environment `dev` holds `DEPLOY_ROLE_ARN` for `githubdeploy-seahaven-ap`.
|
||||
|
||||
The first apply creates secret `seahaven-ap/google-oidc` with `client_id` and `client_secret` set to `replace-me`. Replace both values in Secrets Manager, then re-run the HCP apply. A `terraform/**` change on `main` starts that apply. The Google IdP is not registered while the placeholder is still current.
|
||||
|
||||
The merge that adds these workflows can start Deploy Web and Deploy API before that apply has written `/seahaven-ap/deploy/*` and before GitHub Environment `dev` has `DEPLOY_ROLE_ARN`. Those runs fail on purpose until both exist. Re-run them after the apply.
|
||||
|
||||
- `.github/workflows/deploy-web.yaml` syncs `placeholder/` to the web bucket. It does not run `vite build`.
|
||||
- `.github/workflows/deploy-api.yaml` builds the API image with `GIT_SHA`, registers the task definition from `/seahaven-ap/deploy/task-environment`, migrates, and checks `GET /api/health`.
|
||||
|
||||
Terraform `environment` is `dev` only. Prod hostname and GitHub Environment `prod` are AP-12.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
npm run test:e2e
|
||||
python3 scripts/test-terraform-dev-only.py
|
||||
bash scripts/test-verify-api-health.sh
|
||||
```
|
||||
|
|
|
|||
17
placeholder/index.html
Normal file
17
placeholder/index.html
Normal 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
59
scripts/patch-ecs-task-def.py
Executable 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()
|
||||
96
scripts/test-terraform-dev-only.py
Executable file
96
scripts/test-terraform-dev-only.py
Executable 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")
|
||||
94
scripts/test-verify-api-health.sh
Executable file
94
scripts/test-verify-api-health.sh
Executable 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
40
scripts/verify-api-health.sh
Executable 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
11
terraform/.gitignore
vendored
Normal 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
36
terraform/alarms.tf
Normal 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
101
terraform/artifacts.tf
Normal 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
64
terraform/aurora.tf
Normal 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
113
terraform/cloudfront.tf
Normal 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
199
terraform/cognito.tf
Normal 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
40
terraform/data.tf
Normal 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
73
terraform/documents.tf
Normal 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
229
terraform/ecs.tf
Normal 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
107
terraform/iam_ecs.tf
Normal 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
|
||||
}
|
||||
195
terraform/iam_github_deploy.tf
Normal file
195
terraform/iam_github_deploy.tf
Normal 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
|
||||
}
|
||||
17
terraform/lambda/cognito-presignup/index.mjs
Normal file
17
terraform/lambda/cognito-presignup/index.mjs
Normal 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
77
terraform/locals.tf
Normal 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
4
terraform/logs.tf
Normal 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
69
terraform/outputs.tf
Normal 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
11
terraform/providers.tf
Normal 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
96
terraform/s3.tf
Normal 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
36
terraform/secrets.tf
Normal 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
55
terraform/ssm.tf
Normal 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
55
terraform/variables.tf
Normal 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
26
terraform/versions.tf
Normal 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
101
terraform/vpc.tf
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue