mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 06:33:12 +00:00
Merge pull request #122 from Sea-Haven-Industries/feature/hcp-terraform-migration
Some checks failed
Build Lambda Layer / build (push) Has been cancelled
Some checks failed
Build Lambda Layer / build (push) Has been cancelled
feat(infra): migrate meal-order-manager to HCP Terraform (PLAT-70)
This commit is contained in:
commit
27d6bbeaca
31 changed files with 2367 additions and 58 deletions
68
.github/workflows/build-layer.yml
vendored
Normal file
68
.github/workflows/build-layer.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
name: Build Lambda Layer
|
||||||
|
|
||||||
|
# Build verification only. Terraform owns Lambda packaging: terraform/artifacts.tf
|
||||||
|
# runs terraform/build_packages.sh during plan and carries the resulting zips into
|
||||||
|
# the plan as content_base64, so there is no artifact for this workflow to upload
|
||||||
|
# and no job here holds AWS credentials.
|
||||||
|
#
|
||||||
|
# What it does check is that the layer still builds for the Lambda target
|
||||||
|
# (python3.12 / arm64) and stays small enough to travel inside a plan. boto3 and
|
||||||
|
# friends are stripped by build_packages.sh because the runtime provides them; if
|
||||||
|
# that strip ever stops working, the size guard below fails the PR rather than
|
||||||
|
# letting a multi-hundred-megabyte plan payload reach HCP Terraform.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "src/shared/**"
|
||||||
|
- "functions/**"
|
||||||
|
- "terraform/build_packages.sh"
|
||||||
|
- "terraform/build_packages_external.sh"
|
||||||
|
- ".github/workflows/build-layer.yml"
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "src/shared/**"
|
||||||
|
- "functions/**"
|
||||||
|
- "terraform/build_packages.sh"
|
||||||
|
- "terraform/build_packages_external.sh"
|
||||||
|
- ".github/workflows/build-layer.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-layer-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
|
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Build packages
|
||||||
|
run: bash terraform/build_packages.sh
|
||||||
|
|
||||||
|
- name: Check layer size
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd terraform/build/layer
|
||||||
|
zip -qrX ../packages/layer-check.zip python
|
||||||
|
BYTES=$(wc -c < ../packages/layer-check.zip)
|
||||||
|
LIMIT=$((40 * 1024 * 1024))
|
||||||
|
echo "Layer zip: $BYTES bytes (limit $LIMIT)"
|
||||||
|
if [ "$BYTES" -gt "$LIMIT" ]; then
|
||||||
|
echo "Layer exceeds the plan-payload budget. Check that build_packages.sh still strips the runtime-provided packages." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -d python/boto3 ]; then
|
||||||
|
echo "boto3 is present in the layer; the runtime already provides it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
32
.github/workflows/ci-terraform.yaml
vendored
Normal file
32
.github/workflows/ci-terraform.yaml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
name: Terraform CI
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "terraform/**"
|
||||||
|
- ".github/workflows/ci-terraform.yaml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
terraform:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: terraform
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
|
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||||
|
with:
|
||||||
|
terraform_version: "1.9.8"
|
||||||
|
|
||||||
|
- name: Terraform fmt
|
||||||
|
run: terraform fmt -check -recursive
|
||||||
|
|
||||||
|
- name: Terraform init
|
||||||
|
run: terraform init -backend=false
|
||||||
|
|
||||||
|
- name: Terraform validate
|
||||||
|
run: terraform validate
|
||||||
22
.github/workflows/deploy.yml
vendored
22
.github/workflows/deploy.yml
vendored
|
|
@ -1,22 +0,0 @@
|
||||||
name: Deploy
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/cd-sam.yaml@81cf168170f356d1423d7736f7ce93fd6611ad53 # v1.0.4
|
|
||||||
with:
|
|
||||||
stack-name: meal-order-manager
|
|
||||||
cfn-role-arn: arn:aws:iam::328440206208:role/github-cfn-execution-role
|
|
||||||
secrets:
|
|
||||||
deploy-role-arn: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
|
|
||||||
parameter-overrides: ${{ secrets.SAM_PARAMETER_OVERRIDES }}
|
|
||||||
86
.github/workflows/weekly-menu.yml
vendored
86
.github/workflows/weekly-menu.yml
vendored
|
|
@ -28,8 +28,9 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Timezone guard
|
- name: Timezone guard
|
||||||
if: github.event_name == 'schedule'
|
if: github.event_name == 'schedule'
|
||||||
|
env:
|
||||||
|
CRON: ${{ github.event.schedule }}
|
||||||
run: |
|
run: |
|
||||||
CRON="${{ github.event.schedule }}"
|
|
||||||
OFFSET=$(TZ='America/New_York' date +%z)
|
OFFSET=$(TZ='America/New_York' date +%z)
|
||||||
echo "Cron: $CRON | Eastern offset: $OFFSET"
|
echo "Cron: $CRON | Eastern offset: $OFFSET"
|
||||||
if { [ "$OFFSET" = "-0400" ] && [ "$CRON" = "30 12 * * 1" ]; } || \
|
if { [ "$OFFSET" = "-0400" ] && [ "$CRON" = "30 12 * * 1" ]; } || \
|
||||||
|
|
@ -63,37 +64,39 @@ jobs:
|
||||||
if: env.SKIP_RUN != 'true'
|
if: env.SKIP_RUN != 'true'
|
||||||
run: python3 src/scraper/scrape_menu.py
|
run: python3 src/scraper/scrape_menu.py
|
||||||
|
|
||||||
- name: Get stack outputs
|
# Deploy targets come from Parameter Store, written by Terraform
|
||||||
|
# (terraform/ssm.tf). They replace the CloudFormation stack outputs this
|
||||||
|
# job used to read; there is no CloudFormation stack any more.
|
||||||
|
- name: Get deploy parameters
|
||||||
if: env.SKIP_RUN != 'true'
|
if: env.SKIP_RUN != 'true'
|
||||||
id: stack
|
id: stack
|
||||||
run: |
|
run: |
|
||||||
API_URL=$(aws cloudformation describe-stacks \
|
set -euo pipefail
|
||||||
--stack-name meal-order-manager \
|
get_param() {
|
||||||
--query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \
|
aws ssm get-parameter --name "$1" --query 'Parameter.Value' --output text
|
||||||
--output text)
|
}
|
||||||
FORM_BUCKET=$(aws cloudformation describe-stacks \
|
API_URL=$(get_param /meal-order-manager/deploy/api-url)
|
||||||
--stack-name meal-order-manager \
|
FORM_BUCKET=$(get_param /meal-order-manager/deploy/form-bucket)
|
||||||
--query 'Stacks[0].Outputs[?OutputKey==`FormBucketName`].OutputValue' \
|
DIST_ID=$(get_param /meal-order-manager/deploy/distribution-id)
|
||||||
--output text)
|
FORM_URL=$(get_param /meal-order-manager/deploy/form-url)
|
||||||
DIST_ID=$(aws cloudformation describe-stacks \
|
for v in "$API_URL" "$FORM_BUCKET" "$DIST_ID" "$FORM_URL"; do
|
||||||
--stack-name meal-order-manager \
|
if [ -z "$v" ] || [ "$v" = "None" ]; then
|
||||||
--query 'Stacks[0].Outputs[?OutputKey==`DistributionId`].OutputValue' \
|
echo "A /meal-order-manager/deploy/* parameter is missing; has Terraform been applied?" >&2
|
||||||
--output text)
|
exit 1
|
||||||
FORM_URL=$(aws cloudformation describe-stacks \
|
fi
|
||||||
--stack-name meal-order-manager \
|
done
|
||||||
--query 'Stacks[0].Outputs[?OutputKey==`FormUrl`].OutputValue' \
|
echo "api_url=$API_URL" >> "$GITHUB_OUTPUT"
|
||||||
--output text)
|
echo "form_bucket=$FORM_BUCKET" >> "$GITHUB_OUTPUT"
|
||||||
echo "api_url=$API_URL" >> $GITHUB_OUTPUT
|
echo "dist_id=$DIST_ID" >> "$GITHUB_OUTPUT"
|
||||||
echo "form_bucket=$FORM_BUCKET" >> $GITHUB_OUTPUT
|
echo "form_url=$FORM_URL" >> "$GITHUB_OUTPUT"
|
||||||
echo "dist_id=$DIST_ID" >> $GITHUB_OUTPUT
|
|
||||||
echo "form_url=$FORM_URL" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Get discount settings
|
- name: Get discount settings
|
||||||
if: env.SKIP_RUN != 'true'
|
if: env.SKIP_RUN != 'true'
|
||||||
id: discount
|
id: discount
|
||||||
|
env:
|
||||||
|
API_URL: ${{ steps.stack.outputs.api_url }}
|
||||||
run: |
|
run: |
|
||||||
SETTINGS=$(python3 scripts/upload_menu.py settings \
|
SETTINGS=$(python3 scripts/upload_menu.py settings --api-url "$API_URL")
|
||||||
--api-url "${{ steps.stack.outputs.api_url }}")
|
|
||||||
BULK=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["bulk_discount_percent"])' "$SETTINGS")
|
BULK=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["bulk_discount_percent"])' "$SETTINGS")
|
||||||
SUBSIDY=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["company_subsidy_percent"])' "$SETTINGS")
|
SUBSIDY=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["company_subsidy_percent"])' "$SETTINGS")
|
||||||
echo "bulk_discount=$BULK" >> "$GITHUB_OUTPUT"
|
echo "bulk_discount=$BULK" >> "$GITHUB_OUTPUT"
|
||||||
|
|
@ -111,42 +114,53 @@ jobs:
|
||||||
echo "Google client ID is required for cloud form generation" >&2
|
echo "Google client ID is required for cloud form generation" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT
|
echo "client_id=$GOOGLE_CLIENT_ID" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Generate order form
|
- name: Generate order form
|
||||||
if: env.SKIP_RUN != 'true'
|
if: env.SKIP_RUN != 'true'
|
||||||
|
env:
|
||||||
|
API_URL: ${{ steps.stack.outputs.api_url }}
|
||||||
|
BULK_DISCOUNT: ${{ steps.discount.outputs.bulk_discount }}
|
||||||
|
COMPANY_SUBSIDY: ${{ steps.discount.outputs.company_subsidy }}
|
||||||
|
GOOGLE_CLIENT_ID: ${{ steps.google.outputs.client_id }}
|
||||||
run: |
|
run: |
|
||||||
python3 src/server/generate_form.py \
|
python3 src/server/generate_form.py \
|
||||||
--api-url "${{ steps.stack.outputs.api_url }}" \
|
--api-url "$API_URL" \
|
||||||
--bulk-discount "${{ steps.discount.outputs.bulk_discount }}" \
|
--bulk-discount "$BULK_DISCOUNT" \
|
||||||
--company-subsidy "${{ steps.discount.outputs.company_subsidy }}" \
|
--company-subsidy "$COMPANY_SUBSIDY" \
|
||||||
--google-client-id "${{ steps.google.outputs.client_id }}"
|
--google-client-id "$GOOGLE_CLIENT_ID"
|
||||||
|
|
||||||
- name: Publish menu through API
|
- name: Publish menu through API
|
||||||
if: env.SKIP_RUN != 'true'
|
if: env.SKIP_RUN != 'true'
|
||||||
run: |
|
env:
|
||||||
python3 scripts/upload_menu.py publish \
|
API_URL: ${{ steps.stack.outputs.api_url }}
|
||||||
--api-url "${{ steps.stack.outputs.api_url }}"
|
run: python3 scripts/upload_menu.py publish --api-url "$API_URL"
|
||||||
|
|
||||||
- name: Upload form to S3
|
- name: Upload form to S3
|
||||||
if: env.SKIP_RUN != 'true'
|
if: env.SKIP_RUN != 'true'
|
||||||
|
env:
|
||||||
|
FORM_BUCKET: ${{ steps.stack.outputs.form_bucket }}
|
||||||
run: |
|
run: |
|
||||||
WEEK=$(date +%Y-W%U)
|
WEEK=$(date +%Y-W%U)
|
||||||
aws s3 cp "output/order-form-$WEEK.html" \
|
aws s3 cp "output/order-form-$WEEK.html" \
|
||||||
"s3://${{ steps.stack.outputs.form_bucket }}/index.html" \
|
"s3://${FORM_BUCKET}/index.html" \
|
||||||
--content-type "text/html" \
|
--content-type "text/html" \
|
||||||
--cache-control "no-cache"
|
--cache-control "no-cache"
|
||||||
aws s3 cp "output/order-form-$WEEK.html" \
|
aws s3 cp "output/order-form-$WEEK.html" \
|
||||||
"s3://${{ steps.stack.outputs.form_bucket }}/archive/$WEEK.html" \
|
"s3://${FORM_BUCKET}/archive/$WEEK.html" \
|
||||||
--content-type "text/html"
|
--content-type "text/html"
|
||||||
|
|
||||||
- name: Invalidate CloudFront cache
|
- name: Invalidate CloudFront cache
|
||||||
if: env.SKIP_RUN != 'true'
|
if: env.SKIP_RUN != 'true'
|
||||||
|
env:
|
||||||
|
DIST_ID: ${{ steps.stack.outputs.dist_id }}
|
||||||
run: |
|
run: |
|
||||||
aws cloudfront create-invalidation \
|
aws cloudfront create-invalidation \
|
||||||
--distribution-id "${{ steps.stack.outputs.dist_id }}" \
|
--distribution-id "$DIST_ID" \
|
||||||
--paths "/index.html"
|
--paths "/index.html"
|
||||||
|
|
||||||
- name: Notify Slack
|
- name: Notify Slack
|
||||||
if: env.SKIP_RUN != 'true'
|
if: env.SKIP_RUN != 'true'
|
||||||
run: python3 scripts/notify_slack.py "${{ steps.stack.outputs.form_url }}"
|
env:
|
||||||
|
FORM_URL: ${{ steps.stack.outputs.form_url }}
|
||||||
|
run: python3 scripts/notify_slack.py "$FORM_URL"
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -8,3 +8,6 @@ output/
|
||||||
.aws-sam/
|
.aws-sam/
|
||||||
samconfig.toml
|
samconfig.toml
|
||||||
node_modules/
|
node_modules/
|
||||||
|
terraform/build/
|
||||||
|
.terraform/
|
||||||
|
*.tfvars
|
||||||
|
|
|
||||||
39
infra/layer-artifacts-role/README.md
Normal file
39
infra/layer-artifacts-role/README.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# github-meal-order-manager-layer-artifacts
|
||||||
|
|
||||||
|
**Not provisioned, and not currently needed.** Kept as the reference definition in case
|
||||||
|
S3-mediated layer artifacts are reintroduced.
|
||||||
|
|
||||||
|
Terraform now owns Lambda packaging. `terraform/artifacts.tf` runs
|
||||||
|
`terraform/build_packages.sh` during plan and carries the layer and function zips into the
|
||||||
|
plan as `content_base64`, uploading them to `meal-order-manager-artifacts-011934824531` at
|
||||||
|
apply time under the HCP Terraform workspace's own credentials. No GitHub Actions job
|
||||||
|
writes to that bucket, so `.github/workflows/build-layer.yml` holds no AWS credentials and
|
||||||
|
runs as build verification only.
|
||||||
|
|
||||||
|
Account and bucket references in `trust-policy.json` and `permissions-policy.json` are
|
||||||
|
updated to prod (`011934824531`) so the definition stays usable as-is.
|
||||||
|
|
||||||
|
## Scope, if it is ever created
|
||||||
|
|
||||||
|
An OIDC role for the `upload` job of `.github/workflows/build-layer.yml`, writing and
|
||||||
|
reading objects under the `layers/` prefix of one bucket and nothing else. The
|
||||||
|
`DenyEverythingElse` statement uses `NotAction` so any future Allow — added here or
|
||||||
|
inherited — cannot widen the role beyond those three S3 actions. `s3:ListBucket` is
|
||||||
|
required because `head-object` on a missing key returns 403 instead of 404 without it,
|
||||||
|
which would make the "already present" check indistinguishable from a permissions failure;
|
||||||
|
it is prefix-conditioned to `layers/*`.
|
||||||
|
|
||||||
|
## Trust
|
||||||
|
|
||||||
|
Pinned three ways: `sub` to `refs/heads/main`, `job_workflow_ref` to the build-layer
|
||||||
|
workflow file at main, and `aud` to `sts.amazonaws.com`. The `job_workflow_ref` pin is what
|
||||||
|
stops any other workflow in the repo — including a future one added by a PR — from
|
||||||
|
assuming it.
|
||||||
|
|
||||||
|
Deliberately **not** trusted for `pull_request`. A PR-triggered run executes the PR's own
|
||||||
|
copy of the workflow, so a credentialed PR job could overwrite an artifact that a later
|
||||||
|
apply publishes, without the PR ever merging.
|
||||||
|
|
||||||
|
Both mandatory gates (cross-family review and `/sh-security-review`) must pass on these
|
||||||
|
exact JSONs before the role lands in the `github-oidc-deploy-roles` stack. Repo secret
|
||||||
|
would be `AWS_LAYER_ARTIFACTS_ROLE_ARN`.
|
||||||
26
infra/layer-artifacts-role/permissions-policy.json
Normal file
26
infra/layer-artifacts-role/permissions-policy.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "LayerArtifactWrite",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["s3:PutObject", "s3:GetObject"],
|
||||||
|
"Resource": "arn:aws:s3:::meal-order-manager-artifacts-011934824531/layers/*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Sid": "HeadObjectRequiresListBucket",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["s3:ListBucket"],
|
||||||
|
"Resource": "arn:aws:s3:::meal-order-manager-artifacts-011934824531",
|
||||||
|
"Condition": {
|
||||||
|
"StringLike": {"s3:prefix": "layers/*"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Sid": "DenyEverythingElse",
|
||||||
|
"Effect": "Deny",
|
||||||
|
"NotAction": ["s3:PutObject", "s3:GetObject", "s3:ListBucket"],
|
||||||
|
"Resource": "*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
infra/layer-artifacts-role/trust-policy.json
Normal file
19
infra/layer-artifacts-role/trust-policy.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {
|
||||||
|
"Federated": "arn:aws:iam::011934824531:oidc-provider/token.actions.githubusercontent.com"
|
||||||
|
},
|
||||||
|
"Action": "sts:AssumeRoleWithWebIdentity",
|
||||||
|
"Condition": {
|
||||||
|
"StringEquals": {
|
||||||
|
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||||
|
"token.actions.githubusercontent.com:sub": "repo:Sea-Haven-Industries/meal-order-manager:ref:refs/heads/main",
|
||||||
|
"token.actions.githubusercontent.com:job_workflow_ref": "Sea-Haven-Industries/meal-order-manager/.github/workflows/build-layer.yml@refs/heads/main"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
74
terraform/.terraform.lock.hcl
generated
Normal file
74
terraform/.terraform.lock.hcl
generated
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# This file is maintained automatically by "terraform init".
|
||||||
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
|
provider "registry.terraform.io/hashicorp/archive" {
|
||||||
|
version = "2.8.0"
|
||||||
|
constraints = "~> 2.0"
|
||||||
|
hashes = [
|
||||||
|
"h1:WB6H5ksIZiyq1lQlD/PWeh+tn4FLsbSjVnRW3+4xe2Y=",
|
||||||
|
"h1:cMBtvdHEgvTmglbioVehZCaOIPocumu9+vlGw5Dsaro=",
|
||||||
|
"h1:jdmKm+xl6ZcQrijxapnZ94RVuz/G4vk7hsIa1N0VT5Q=",
|
||||||
|
"h1:oRWx6ZDIdlNBF90L8TzV9X7pQ+P403hoCDiBfmUfhC0=",
|
||||||
|
"zh:0d14713fdc259fb377d0b899ad3c650a34194bd52194c863303ef22a65a580e2",
|
||||||
|
"zh:369b56040c7a8085d04e7e8ffac1e2b321a3170e502f788819bc34b868ec016f",
|
||||||
|
"zh:4d1a3b983ed6af5a52bfe12794674ae55cbadfa6021b37106ade68b433ad216a",
|
||||||
|
"zh:5c547549e26e083573c78a966ca68ce6d7df6bb8f3948f66a575f07da46b74ea",
|
||||||
|
"zh:6de093e62a975eb19a5e3017ce38e6e3cb639c17b79648d2000e0a8348f0e997",
|
||||||
|
"zh:7267936c2cdbc448efeb594d73e6b56a53d6a7ae14fe88cdd2a4133adc3302f0",
|
||||||
|
"zh:7482f023050ed426b4b45116e1761643bc33b1fd4ce4a6fab207ae2571f35940",
|
||||||
|
"zh:76bbd93b234e5a2927d98b511d86565700f549b570871a194c35f944b96cefb7",
|
||||||
|
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||||
|
"zh:c6afc4bc1f002bac9c173007dd4da05fde788cd14c2916089f958c33fedb0dfa",
|
||||||
|
"zh:d3ba40bd806a3a08e9237dece679193c99afb2085de6b45d7f5d1f673cfcd368",
|
||||||
|
"zh:e1ad7ded53ecd6f0e5b473a3b44eae2b2e885653a56050ab583d387332be02e4",
|
||||||
|
"zh:e93e78575ce82be6084cc153c24ba8f385dc8d6880888ee66e918460c870953d",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "registry.terraform.io/hashicorp/aws" {
|
||||||
|
version = "6.58.0"
|
||||||
|
constraints = "~> 6.57"
|
||||||
|
hashes = [
|
||||||
|
"h1:1im6ypdeXLXq3sHElserTE62qmIqNiHLAyC3R5EnFFw=",
|
||||||
|
"h1:2kpake4zZKRX5437QVIRU3qFYH6Bjw/QE1fgVCPOrUg=",
|
||||||
|
"h1:OWl47Bo8Vzlf5srTUCmA6v4kvQGfah/P1joRtIYUUMc=",
|
||||||
|
"h1:UFot9S97tuAPvjKvoxm08sDG/gKYdDK+lMwsZKtLieY=",
|
||||||
|
"zh:1221253beee5629fb503d79cebc9bc661279cbc4be5d01db9ab4c1b702108250",
|
||||||
|
"zh:132bd0925bdc4b72446ac750b7ccb1e19b9ba8fbb6df57b2c1423314d2195d4f",
|
||||||
|
"zh:18cda250b9e82b753808715893c8927f132273c00ffae7a697d65ac1cb577e48",
|
||||||
|
"zh:204c944f1fb7f440a335bb2083c9691a9d1f677aea9701025dd5816aee41f0ba",
|
||||||
|
"zh:2dc41df289f2b10a01e650cdd73699955f0ab0645d09cfb114a8cd0f4cc4ede7",
|
||||||
|
"zh:345633dfa9a234659d52aadd126e6dce658518c3ab5cbf6d871221287ed5ec56",
|
||||||
|
"zh:4dadcced73e742903158bc9838936d911f3fa4c2c37b5591c1a28f8f2a1902a6",
|
||||||
|
"zh:5bc60cc2b8c093da98b211d9f6c21c9ecec0f21b944d9ecbe3961fba33086e80",
|
||||||
|
"zh:6cc8f084938b0033a9c0c910989919dad6b1683e76e0afa1a5c604e39f398a75",
|
||||||
|
"zh:7db214647f79de9a033b5dfd6cbfaa42d53c4d056b32cb3acc7ad99306dd548a",
|
||||||
|
"zh:9078589ec881cee7ed9403af262c98ff257fb3e1baae72ff6429a398b1c730af",
|
||||||
|
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||||
|
"zh:bd5bce6aec4d4922b1127b8575688bd4bc4279670ee28d198ede404709826c7c",
|
||||||
|
"zh:cd900ecf56d21023873898b06e40234f3f4d350f2343b7d9b980d6c5cb604fae",
|
||||||
|
"zh:dbe93b276a84421026b956c3c5b4eb8897da6cbb54b93cb89f5d6ebbd30805ca",
|
||||||
|
"zh:f6b6c7bb2dbf04ee085e5c22f7a65b3ccaebf368ed95584dc0dfee8a22771056",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "registry.terraform.io/hashicorp/external" {
|
||||||
|
version = "2.4.0"
|
||||||
|
constraints = "~> 2.0"
|
||||||
|
hashes = [
|
||||||
|
"h1:AmY6ZeIvqoTT5ZjzD+P49PeQH6Va1QLMkX+7MUQfYoA=",
|
||||||
|
"zh:0772afb42b658468ac5e15df33bf2080456f8f0b8ab163bfe9c50d2b2ea02135",
|
||||||
|
"zh:0ac31a9aaa43dfcff5944b791596cdc94e153348e4bb4642282d034dff548134",
|
||||||
|
"zh:32d8492b1bdcc956ca3c6d00c6392d0a83942ff11d4820c7ee63ca6796e06950",
|
||||||
|
"zh:3c0482e894429f528ce6655a76ab0d8a9f7c0dacc6c828865e1515d4a7dbb852",
|
||||||
|
"zh:61e68100b4db2f930b31491f23c602126382fd5e51252be1b551f0e17f8ddbee",
|
||||||
|
"zh:6d60f615a0ad85eb962c9eb94f25e3eba7a72684ce276ba5dfb23f36b295a8f8",
|
||||||
|
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||||
|
"zh:9ced2745eb5f1346203027d2dd7bf856ad1d279a25730ff7dbc6eec187aaca0c",
|
||||||
|
"zh:a8378558a177d43f55aa0d79d4fae91a704695122a1b109668c1daa8fb76f09d",
|
||||||
|
"zh:aadd98086133d3ebea67437d56512fdcc6dfb3bd34dfc23f276c0db9272e27b4",
|
||||||
|
"zh:beff701b653841e70441978137768f54e7dc6c27e7bf12a4589087f01f5bbcee",
|
||||||
|
"zh:c91c2223b29fdbc0044d20e1936ccc051d010727a13f2ff1e75e51f09bff33a3",
|
||||||
|
"zh:d491f9c2d32a39dc4031628469ae7c8aec0074312a7c1f0286b173cdcf854a54",
|
||||||
|
]
|
||||||
|
}
|
||||||
20
terraform/acm.tf
Normal file
20
terraform/acm.tf
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# ACM certificate for the order form's custom domain.
|
||||||
|
#
|
||||||
|
# The certificate is an out-of-band bootstrap dependency and is deliberately NOT
|
||||||
|
# created here. It was requested in the prod account ahead of this configuration
|
||||||
|
# (arn:aws:acm:us-east-1:011934824531:certificate/4edac16c-0e19-4307-a34a-f6da257ccda3)
|
||||||
|
# and validated by DNS. Declaring an aws_acm_certificate resource as well would
|
||||||
|
# request a second certificate for the same domain on the first apply, so this
|
||||||
|
# configuration only reads the issued one.
|
||||||
|
#
|
||||||
|
# Bootstrap order, if the domain is ever rebuilt from nothing:
|
||||||
|
# 1. aws acm request-certificate --domain-name orders.seahaven.com \
|
||||||
|
# --validation-method DNS --region us-east-1
|
||||||
|
# 2. Publish the CNAME validation record and wait for status ISSUED.
|
||||||
|
# 3. Run terraform apply. Until step 2 completes, this data source finds no
|
||||||
|
# ISSUED certificate and the plan fails closed.
|
||||||
|
data "aws_acm_certificate" "orders" {
|
||||||
|
domain = var.domain_name
|
||||||
|
statuses = ["ISSUED"]
|
||||||
|
most_recent = true
|
||||||
|
}
|
||||||
222
terraform/alarms.tf
Normal file
222
terraform/alarms.tf
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
# CloudWatch alarms. All notify the shared site-alerts topic. No OK actions (no
|
||||||
|
# recovery spam), and treat_missing_data = notBreaching so cron functions do not
|
||||||
|
# sit in ALARM between invocations.
|
||||||
|
#
|
||||||
|
# Duration alarms use the p99 extended statistic at ~80% of each function's
|
||||||
|
# timeout. API-fronted functions evaluate 3 datapoints; cron and async-invoked
|
||||||
|
# functions evaluate one, because they fire too rarely to fill a longer window.
|
||||||
|
#
|
||||||
|
# DynamoDB note: the table does not publish ThrottledRequests or SystemErrors at
|
||||||
|
# the TableName-only dimension, so no alarm on those would ever evaluate.
|
||||||
|
# ReadThrottleEvents and WriteThrottleEvents do carry TableName and are used
|
||||||
|
# here for throttle coverage.
|
||||||
|
|
||||||
|
locals {
|
||||||
|
alarm_functions = {
|
||||||
|
"submit-order" = {
|
||||||
|
function_name = aws_lambda_function.submit_order.function_name
|
||||||
|
duration_threshold = 8000
|
||||||
|
duration_timeout = "10s"
|
||||||
|
duration_datapoints = 3
|
||||||
|
}
|
||||||
|
"admin-authorizer" = {
|
||||||
|
function_name = aws_lambda_function.admin_authorizer.function_name
|
||||||
|
duration_threshold = 8000
|
||||||
|
duration_timeout = "10s"
|
||||||
|
duration_datapoints = 3
|
||||||
|
}
|
||||||
|
"close-form" = {
|
||||||
|
function_name = aws_lambda_function.close_form.function_name
|
||||||
|
duration_threshold = 24000
|
||||||
|
duration_timeout = "30s"
|
||||||
|
duration_datapoints = 1
|
||||||
|
}
|
||||||
|
"aggregate-orders" = {
|
||||||
|
function_name = aws_lambda_function.aggregate_orders.function_name
|
||||||
|
duration_threshold = 48000
|
||||||
|
duration_timeout = "60s"
|
||||||
|
duration_datapoints = 1
|
||||||
|
}
|
||||||
|
"slack-notifier" = {
|
||||||
|
function_name = aws_lambda_function.slack_notifier.function_name
|
||||||
|
duration_threshold = 24000
|
||||||
|
duration_timeout = "30s"
|
||||||
|
duration_datapoints = 1
|
||||||
|
}
|
||||||
|
"sync-roster" = {
|
||||||
|
function_name = aws_lambda_function.sync_roster.function_name
|
||||||
|
duration_threshold = 48000
|
||||||
|
duration_timeout = "60s"
|
||||||
|
duration_datapoints = 1
|
||||||
|
}
|
||||||
|
"email-report" = {
|
||||||
|
function_name = aws_lambda_function.email_report.function_name
|
||||||
|
duration_threshold = 24000
|
||||||
|
duration_timeout = "30s"
|
||||||
|
duration_datapoints = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
|
||||||
|
for_each = local.alarm_functions
|
||||||
|
|
||||||
|
alarm_name = "${local.project}-${each.key}-errors"
|
||||||
|
alarm_description = "${each.key} Lambda reported one or more errors in 5 minutes."
|
||||||
|
namespace = "AWS/Lambda"
|
||||||
|
metric_name = "Errors"
|
||||||
|
statistic = "Sum"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = 1
|
||||||
|
threshold = 0
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
FunctionName = each.value.function_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "lambda_throttles" {
|
||||||
|
for_each = local.alarm_functions
|
||||||
|
|
||||||
|
alarm_name = "${local.project}-${each.key}-throttles"
|
||||||
|
alarm_description = "${each.key} Lambda was throttled in the last 5 minutes."
|
||||||
|
namespace = "AWS/Lambda"
|
||||||
|
metric_name = "Throttles"
|
||||||
|
statistic = "Sum"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = 1
|
||||||
|
threshold = 0
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
FunctionName = each.value.function_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "lambda_duration" {
|
||||||
|
for_each = local.alarm_functions
|
||||||
|
|
||||||
|
alarm_name = "${local.project}-${each.key}-duration"
|
||||||
|
alarm_description = "${each.key} p99 duration exceeded ${each.value.duration_threshold}ms (80% of its ${each.value.duration_timeout} timeout)."
|
||||||
|
namespace = "AWS/Lambda"
|
||||||
|
metric_name = "Duration"
|
||||||
|
extended_statistic = "p99"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = each.value.duration_datapoints
|
||||||
|
datapoints_to_alarm = each.value.duration_datapoints
|
||||||
|
threshold = each.value.duration_threshold
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
FunctionName = each.value.function_name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DynamoDB
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "orders_read_throttle" {
|
||||||
|
alarm_name = "${local.project}-orders-read-throttle"
|
||||||
|
alarm_description = "orders table read requests were throttled in the last 5 minutes."
|
||||||
|
namespace = "AWS/DynamoDB"
|
||||||
|
metric_name = "ReadThrottleEvents"
|
||||||
|
statistic = "Sum"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = 1
|
||||||
|
threshold = 0
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
TableName = aws_dynamodb_table.orders.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "orders_write_throttle" {
|
||||||
|
alarm_name = "${local.project}-orders-write-throttle"
|
||||||
|
alarm_description = "orders table write requests were throttled in the last 5 minutes."
|
||||||
|
namespace = "AWS/DynamoDB"
|
||||||
|
metric_name = "WriteThrottleEvents"
|
||||||
|
statistic = "Sum"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = 1
|
||||||
|
threshold = 0
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
TableName = aws_dynamodb_table.orders.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "api_5xx" {
|
||||||
|
alarm_name = "${local.project}-order-api-5xx"
|
||||||
|
alarm_description = "OrderApi returned one or more 5xx responses in 5 minutes."
|
||||||
|
namespace = "AWS/ApiGateway"
|
||||||
|
metric_name = "5xx"
|
||||||
|
statistic = "Sum"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = 1
|
||||||
|
threshold = 0
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
ApiId = aws_apigatewayv2_api.order_api.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Threshold raised and 2-of-3 datapoints, to absorb the routine 401s the
|
||||||
|
# token-based admin authorizer produces without paging.
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "api_4xx" {
|
||||||
|
alarm_name = "${local.project}-order-api-4xx"
|
||||||
|
alarm_description = "OrderApi 4xx responses exceeded 20 in 5 minutes (beyond routine auth noise)."
|
||||||
|
namespace = "AWS/ApiGateway"
|
||||||
|
metric_name = "4xx"
|
||||||
|
statistic = "Sum"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = 3
|
||||||
|
datapoints_to_alarm = 2
|
||||||
|
threshold = 20
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
ApiId = aws_apigatewayv2_api.order_api.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_metric_alarm" "api_latency" {
|
||||||
|
alarm_name = "${local.project}-order-api-latency"
|
||||||
|
alarm_description = "OrderApi p99 latency exceeded 3000ms."
|
||||||
|
namespace = "AWS/ApiGateway"
|
||||||
|
metric_name = "Latency"
|
||||||
|
extended_statistic = "p99"
|
||||||
|
period = 300
|
||||||
|
evaluation_periods = 3
|
||||||
|
datapoints_to_alarm = 3
|
||||||
|
threshold = 3000
|
||||||
|
comparison_operator = "GreaterThanThreshold"
|
||||||
|
treat_missing_data = "notBreaching"
|
||||||
|
alarm_actions = [data.aws_sns_topic.site_alerts.arn]
|
||||||
|
|
||||||
|
dimensions = {
|
||||||
|
ApiId = aws_apigatewayv2_api.order_api.id
|
||||||
|
}
|
||||||
|
}
|
||||||
105
terraform/apigateway.tf
Normal file
105
terraform/apigateway.tf
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
# HTTP API fronting the order form.
|
||||||
|
#
|
||||||
|
# Three authorization modes coexist, matching template.yaml:
|
||||||
|
# NONE — the public form routes (submit-order, form-status, roster)
|
||||||
|
# AWS_IAM — the weekly-menu publication routes, called with SigV4 by
|
||||||
|
# scripts/upload_menu.py from GitHub Actions
|
||||||
|
# CUSTOM — every /api/admin route, behind the Google ID token authorizer
|
||||||
|
#
|
||||||
|
# All nine routes integrate with submit-order, which dispatches internally on
|
||||||
|
# the route key.
|
||||||
|
|
||||||
|
resource "aws_apigatewayv2_api" "order_api" {
|
||||||
|
name = local.project
|
||||||
|
protocol_type = "HTTP"
|
||||||
|
description = "meal-order-manager order form and admin API"
|
||||||
|
|
||||||
|
# Only the production origin. Local development uses the Flask dev server in
|
||||||
|
# app.py, which proxies API calls and does not enforce CORS.
|
||||||
|
cors_configuration {
|
||||||
|
allow_origins = [local.form_url]
|
||||||
|
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||||
|
allow_headers = ["Content-Type", "Authorization"]
|
||||||
|
max_age = 3600
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Result caching is off. With caching, an expired Google token or an admin
|
||||||
|
# removed from the allow-list would stay authorized for the cache TTL, and the
|
||||||
|
# tokeninfo call dominates latency anyway.
|
||||||
|
resource "aws_apigatewayv2_authorizer" "admin_google" {
|
||||||
|
api_id = aws_apigatewayv2_api.order_api.id
|
||||||
|
name = "AdminGoogleAuthorizer"
|
||||||
|
authorizer_type = "REQUEST"
|
||||||
|
authorizer_uri = aws_lambda_function.admin_authorizer.invoke_arn
|
||||||
|
authorizer_credentials_arn = aws_iam_role.admin_authorizer_invoke.arn
|
||||||
|
authorizer_payload_format_version = "2.0"
|
||||||
|
authorizer_result_ttl_in_seconds = 0
|
||||||
|
enable_simple_responses = true
|
||||||
|
identity_sources = ["$request.header.Authorization"]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_apigatewayv2_integration" "submit_order" {
|
||||||
|
api_id = aws_apigatewayv2_api.order_api.id
|
||||||
|
integration_type = "AWS_PROXY"
|
||||||
|
integration_method = "POST"
|
||||||
|
integration_uri = aws_lambda_function.submit_order.invoke_arn
|
||||||
|
payload_format_version = "2.0"
|
||||||
|
timeout_milliseconds = 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_apigatewayv2_route" "this" {
|
||||||
|
for_each = local.api_routes
|
||||||
|
|
||||||
|
api_id = aws_apigatewayv2_api.order_api.id
|
||||||
|
route_key = each.value.route_key
|
||||||
|
target = "integrations/${aws_apigatewayv2_integration.submit_order.id}"
|
||||||
|
|
||||||
|
authorization_type = each.value.authorizer
|
||||||
|
authorizer_id = each.value.authorizer == "CUSTOM" ? aws_apigatewayv2_authorizer.admin_google.id : null
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_apigatewayv2_stage" "default" {
|
||||||
|
api_id = aws_apigatewayv2_api.order_api.id
|
||||||
|
name = "$default"
|
||||||
|
auto_deploy = true
|
||||||
|
|
||||||
|
access_log_settings {
|
||||||
|
destination_arn = aws_cloudwatch_log_group.api_access.arn
|
||||||
|
format = "{\"requestId\":\"$context.requestId\",\"ip\":\"$context.identity.sourceIp\",\"requestTime\":\"$context.requestTime\",\"method\":\"$context.httpMethod\",\"routeKey\":\"$context.routeKey\",\"status\":\"$context.status\",\"protocol\":\"$context.protocol\",\"responseLength\":\"$context.responseLength\",\"integrationError\":\"$context.integrationErrorMessage\"}"
|
||||||
|
}
|
||||||
|
|
||||||
|
default_route_settings {
|
||||||
|
throttling_burst_limit = 50
|
||||||
|
throttling_rate_limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
# Order submission is human-paced; menu publication runs once a week. Both are
|
||||||
|
# throttled well below the account default so a loop in either client cannot
|
||||||
|
# exhaust the API's burst budget for the public form.
|
||||||
|
route_settings {
|
||||||
|
route_key = "POST /api/submit-order"
|
||||||
|
throttling_burst_limit = 10
|
||||||
|
throttling_rate_limit = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
route_settings {
|
||||||
|
route_key = "POST /api/publish/menu"
|
||||||
|
throttling_burst_limit = 2
|
||||||
|
throttling_rate_limit = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [aws_apigatewayv2_route.this]
|
||||||
|
}
|
||||||
|
|
||||||
|
# One grant per route rather than a single wildcard, so adding a route to the
|
||||||
|
# API does not silently make the function invocable through it.
|
||||||
|
resource "aws_lambda_permission" "api_route" {
|
||||||
|
for_each = local.api_routes
|
||||||
|
|
||||||
|
statement_id = "AllowApiGatewayInvoke-${each.key}"
|
||||||
|
action = "lambda:InvokeFunction"
|
||||||
|
function_name = aws_lambda_function.submit_order.function_name
|
||||||
|
principal = "apigateway.amazonaws.com"
|
||||||
|
source_arn = "${aws_apigatewayv2_api.order_api.execution_arn}/*/${each.value.permission_source}"
|
||||||
|
}
|
||||||
130
terraform/artifacts.tf
Normal file
130
terraform/artifacts.tf
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# Lambda packaging.
|
||||||
|
#
|
||||||
|
# HCP plan and apply run on separate workers, so a zip written during plan is
|
||||||
|
# not on disk at apply time. The bytes are therefore carried inside the plan as
|
||||||
|
# content_base64 on aws_s3_object and uploaded at apply, and the functions and
|
||||||
|
# layer read from S3 rather than from a local file.
|
||||||
|
#
|
||||||
|
# The build itself runs during plan through an external data source:
|
||||||
|
# local-exec provisioners only run on apply, and archive_file needs build/ to
|
||||||
|
# already exist when the plan is computed.
|
||||||
|
#
|
||||||
|
# build_packages.sh deletes boto3, botocore, s3transfer, jmespath and urllib3
|
||||||
|
# from the layer after pip install. The Python 3.12 runtime ships boto3, and
|
||||||
|
# leaving it in the layer would push the base64-encoded plan payload into the
|
||||||
|
# tens of megabytes.
|
||||||
|
|
||||||
|
data "external" "package_build" {
|
||||||
|
program = ["bash", "${path.module}/build_packages_external.sh"]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket" "artifacts" {
|
||||||
|
bucket = local.artifacts_bucket_name
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Purpose = "Lambda deployment packages for meal-order-manager"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Superseded package versions are only useful for a manual rollback, and the
|
||||||
|
# function/layer resources always point at the current object.
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Packages
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
data "archive_file" "shared_layer" {
|
||||||
|
type = "zip"
|
||||||
|
source_dir = "${path.module}/build/layer"
|
||||||
|
output_path = "${path.module}/build/packages/shared-layer.zip"
|
||||||
|
|
||||||
|
depends_on = [data.external.package_build]
|
||||||
|
}
|
||||||
|
|
||||||
|
data "archive_file" "function" {
|
||||||
|
for_each = local.function_packages
|
||||||
|
|
||||||
|
type = "zip"
|
||||||
|
source_dir = "${path.module}/build/functions/${each.key}"
|
||||||
|
output_path = "${path.module}/build/packages/${each.key}.zip"
|
||||||
|
|
||||||
|
depends_on = [data.external.package_build]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_object" "shared_layer" {
|
||||||
|
bucket = aws_s3_bucket.artifacts.id
|
||||||
|
key = "layers/meal-order-manager-shared.zip"
|
||||||
|
content_base64 = filebase64(data.archive_file.shared_layer.output_path)
|
||||||
|
source_hash = data.archive_file.shared_layer.output_base64sha256
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_object" "function" {
|
||||||
|
for_each = local.function_packages
|
||||||
|
|
||||||
|
bucket = aws_s3_bucket.artifacts.id
|
||||||
|
key = "functions/${each.key}.zip"
|
||||||
|
content_base64 = filebase64(data.archive_file.function[each.key].output_path)
|
||||||
|
source_hash = data.archive_file.function[each.key].output_base64sha256
|
||||||
|
}
|
||||||
108
terraform/build_packages.sh
Executable file
108
terraform/build_packages.sh
Executable file
|
|
@ -0,0 +1,108 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Package the shared layer and every function zip for HCP plan/apply.
|
||||||
|
# Runs on the Terraform worker during plan (see artifacts.tf).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
BUILD="${ROOT}/build"
|
||||||
|
REPO="$(cd "${ROOT}/.." && pwd)"
|
||||||
|
FUNCS="${REPO}/functions"
|
||||||
|
SHARED="${REPO}/src/shared"
|
||||||
|
|
||||||
|
FUNCTIONS=(
|
||||||
|
admin_authorizer
|
||||||
|
aggregate_orders
|
||||||
|
close_form
|
||||||
|
email_report
|
||||||
|
slack_notifier
|
||||||
|
submit_order
|
||||||
|
sync_roster
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy a regular file, refusing symlinks and any path that resolves outside the
|
||||||
|
# expected tree.
|
||||||
|
copy_file() {
|
||||||
|
local src_path="$1"
|
||||||
|
local dest="$2"
|
||||||
|
local base="$3"
|
||||||
|
|
||||||
|
if [[ -L "${src_path}" ]]; then
|
||||||
|
echo "error: refusing symlink source: ${src_path}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f "${src_path}" ]]; then
|
||||||
|
echo "error: missing regular file: ${src_path}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local resolved
|
||||||
|
resolved="$(cd "$(dirname "${src_path}")" && pwd)/$(basename "${src_path}")"
|
||||||
|
case "${resolved}" in
|
||||||
|
"${base}"/*) ;;
|
||||||
|
*)
|
||||||
|
echo "error: path escapes ${base}: ${resolved}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${dest}")"
|
||||||
|
# -P: never follow symlinks if the destination path is replaced mid-run.
|
||||||
|
cp -P "${src_path}" "${dest}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shipped by the python3.12 Lambda runtime. Keeping them in the layer adds tens
|
||||||
|
# of megabytes to the base64-encoded plan payload for no runtime benefit.
|
||||||
|
RUNTIME_PROVIDED=(
|
||||||
|
boto3
|
||||||
|
botocore
|
||||||
|
jmespath
|
||||||
|
s3transfer
|
||||||
|
urllib3
|
||||||
|
)
|
||||||
|
|
||||||
|
rm -rf "${BUILD}"
|
||||||
|
mkdir -p "${BUILD}/layer/python" "${BUILD}/packages"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared layer: pip dependencies + the `shared` package.
|
||||||
|
#
|
||||||
|
# Wheels must match the Lambda target (python3.12 / arm64), not the worker.
|
||||||
|
# --only-binary=:all: makes a source-only package fail loudly here rather than
|
||||||
|
# silently shipping a wheel built for the wrong platform.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
python3 -m pip install \
|
||||||
|
--quiet \
|
||||||
|
--disable-pip-version-check \
|
||||||
|
-r "${SHARED}/requirements.txt" \
|
||||||
|
-t "${BUILD}/layer/python" \
|
||||||
|
--platform manylinux2014_aarch64 \
|
||||||
|
--implementation cp \
|
||||||
|
--python-version 3.12 \
|
||||||
|
--only-binary=:all: \
|
||||||
|
--upgrade
|
||||||
|
|
||||||
|
# boto3 is pinned in src/shared/requirements.txt so local development and the
|
||||||
|
# test suite resolve a known version, but it must not ship in the layer: the
|
||||||
|
# runtime already provides it. Drop each package and its metadata after the
|
||||||
|
# install rather than removing the pin.
|
||||||
|
for pkg in "${RUNTIME_PROVIDED[@]}"; do
|
||||||
|
rm -rf "${BUILD}/layer/python/${pkg}"
|
||||||
|
find "${BUILD}/layer/python" -maxdepth 1 \
|
||||||
|
\( -name "${pkg}-*.dist-info" -o -name "${pkg}-*.egg-info" \) \
|
||||||
|
-prune -exec rm -rf {} +
|
||||||
|
done
|
||||||
|
|
||||||
|
cp -RP "${SHARED}/shared" "${BUILD}/layer/python/shared"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Function packages: handler only. boto3 and fpdf2 come from the shared layer,
|
||||||
|
# so functions/*/requirements.txt is not part of the deployment artifact.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
for fn in "${FUNCTIONS[@]}"; do
|
||||||
|
mkdir -p "${BUILD}/functions/${fn}"
|
||||||
|
copy_file "${FUNCS}/${fn}/handler.py" "${BUILD}/functions/${fn}/handler.py" "${FUNCS}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Byte-compiled caches would make the zip hash unstable across workers.
|
||||||
|
find "${BUILD}" -name '__pycache__' -type d -prune -exec rm -rf {} +
|
||||||
|
find "${BUILD}" -name '*.pyc' -type f -delete
|
||||||
24
terraform/build_packages_external.sh
Executable file
24
terraform/build_packages_external.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Terraform external data source entrypoint. Stdout must be JSON only.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
"${ROOT}/build_packages.sh" >&2
|
||||||
|
|
||||||
|
# sha256sum on Linux workers, shasum on macOS.
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
SHA=(sha256sum)
|
||||||
|
else
|
||||||
|
SHA=(shasum -a 256)
|
||||||
|
fi
|
||||||
|
|
||||||
|
hash="$(
|
||||||
|
{
|
||||||
|
# -P: do not follow symlinks; only hash regular files under build/.
|
||||||
|
find -P "${ROOT}/build" -type f -print0 2>/dev/null \
|
||||||
|
| sort -z \
|
||||||
|
| xargs -0 "${SHA[@]}"
|
||||||
|
} | "${SHA[@]}" | awk '{print $1}'
|
||||||
|
)"
|
||||||
|
|
||||||
|
printf '{"status":"ok","hash":"%s"}\n' "${hash}"
|
||||||
64
terraform/cloudfront.tf
Normal file
64
terraform/cloudfront.tf
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
resource "aws_cloudfront_origin_access_control" "form" {
|
||||||
|
name = "${local.project}-oac"
|
||||||
|
description = "OAC for the meal-order-manager form origin bucket"
|
||||||
|
origin_access_control_origin_type = "s3"
|
||||||
|
signing_behavior = "always"
|
||||||
|
signing_protocol = "sigv4"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudfront_distribution" "form" {
|
||||||
|
enabled = true
|
||||||
|
is_ipv6_enabled = true
|
||||||
|
http_version = "http2and3"
|
||||||
|
comment = "meal-order-manager form hosting"
|
||||||
|
default_root_object = "index.html"
|
||||||
|
price_class = "PriceClass_100"
|
||||||
|
aliases = [var.domain_name]
|
||||||
|
|
||||||
|
# Shared org CloudFront WAF (audit M-17), resolved from Parameter Store.
|
||||||
|
web_acl_id = data.aws_ssm_parameter.app_web_acl_arn.value
|
||||||
|
|
||||||
|
origin {
|
||||||
|
origin_id = "S3FormOrigin"
|
||||||
|
domain_name = aws_s3_bucket.form.bucket_regional_domain_name
|
||||||
|
origin_access_control_id = aws_cloudfront_origin_access_control.form.id
|
||||||
|
}
|
||||||
|
|
||||||
|
default_cache_behavior {
|
||||||
|
target_origin_id = "S3FormOrigin"
|
||||||
|
viewer_protocol_policy = "redirect-to-https"
|
||||||
|
allowed_methods = ["GET", "HEAD"]
|
||||||
|
cached_methods = ["GET", "HEAD"]
|
||||||
|
compress = true
|
||||||
|
|
||||||
|
# AWS managed policy: CachingDisabled. The form HTML is republished weekly
|
||||||
|
# and read through a signed API, so a stale edge copy is worse than an
|
||||||
|
# origin fetch.
|
||||||
|
cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A request for an object the private origin does not hold returns 403, not
|
||||||
|
# 404. Rewriting it to the form keeps deep links working, matching the SAM
|
||||||
|
# template.
|
||||||
|
custom_error_response {
|
||||||
|
error_code = 403
|
||||||
|
response_code = 200
|
||||||
|
response_page_path = "/index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
restrictions {
|
||||||
|
geo_restriction {
|
||||||
|
restriction_type = "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer_certificate {
|
||||||
|
acm_certificate_arn = data.aws_acm_certificate.orders.arn
|
||||||
|
ssl_support_method = "sni-only"
|
||||||
|
minimum_protocol_version = "TLSv1.2_2021"
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
42
terraform/data.tf
Normal file
42
terraform/data.tf
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
data "aws_caller_identity" "current" {}
|
||||||
|
|
||||||
|
# Resource names in locals.tf embed the account ID. If the workspace is ever
|
||||||
|
# pointed at another account, fail the plan here rather than creating a parallel
|
||||||
|
# set of oddly-named resources somewhere else.
|
||||||
|
check "correct_account" {
|
||||||
|
assert {
|
||||||
|
condition = data.aws_caller_identity.current.account_id == local.account_id
|
||||||
|
error_message = "This configuration targets account ${local.account_id}, but the credentials resolve to ${data.aws_caller_identity.current.account_id}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Alarm sink owned by seahaven-org-baseline, not by this configuration.
|
||||||
|
data "aws_sns_topic" "site_alerts" {
|
||||||
|
name = "site-alerts"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shared CloudFront WAF WebACL (audit M-17), published to Parameter Store by the
|
||||||
|
# org baseline. aws_cloudfront_distribution.web_acl_id takes the WAFv2 ARN
|
||||||
|
# despite the attribute name.
|
||||||
|
data "aws_ssm_parameter" "app_web_acl_arn" {
|
||||||
|
name = "/seahaven/waf/app-web-acl-arn"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Google OAuth client ID used by submit-order and the admin authorizer. The
|
||||||
|
# parameter is created and rotated out-of-band because it varies per
|
||||||
|
# environment; this lookup only asserts that it exists before an apply wires
|
||||||
|
# functions that read it at runtime. Its NAME, not its value, is what reaches
|
||||||
|
# the functions.
|
||||||
|
data "aws_ssm_parameter" "google_client_id" {
|
||||||
|
name = local.google_client_id_param
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fail closed if the OOB Google client ID parameter is missing or empty. The
|
||||||
|
# functions receive the parameter NAME via env; this check forces the data
|
||||||
|
# source to be evaluated so apply cannot succeed without the parameter.
|
||||||
|
check "google_client_id_present" {
|
||||||
|
assert {
|
||||||
|
condition = length(data.aws_ssm_parameter.google_client_id.value) > 0
|
||||||
|
error_message = "SSM parameter ${local.google_client_id_param} is missing or empty; create it out of band before apply."
|
||||||
|
}
|
||||||
|
}
|
||||||
35
terraform/dynamodb.tf
Normal file
35
terraform/dynamodb.tf
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Single-table store for orders, roster entries and weekly settings.
|
||||||
|
#
|
||||||
|
# Deletion protection and point-in-time recovery are both on: this table holds
|
||||||
|
# the only copy of submitted orders, and a rebuild would lose payroll history.
|
||||||
|
resource "aws_dynamodb_table" "orders" {
|
||||||
|
name = local.table_name
|
||||||
|
billing_mode = "PAY_PER_REQUEST"
|
||||||
|
hash_key = "PK"
|
||||||
|
range_key = "SK"
|
||||||
|
|
||||||
|
deletion_protection_enabled = true
|
||||||
|
|
||||||
|
attribute {
|
||||||
|
name = "PK"
|
||||||
|
type = "S"
|
||||||
|
}
|
||||||
|
|
||||||
|
attribute {
|
||||||
|
name = "SK"
|
||||||
|
type = "S"
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl {
|
||||||
|
attribute_name = "ttl"
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
point_in_time_recovery {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
99
terraform/events.tf
Normal file
99
terraform/events.tf
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
# EventBridge schedules.
|
||||||
|
#
|
||||||
|
# Every schedule is an EST/EDT pair firing the same function one hour apart in
|
||||||
|
# UTC: EventBridge cron has no timezone. Both fire year-round and the handlers
|
||||||
|
# are idempotent, so the run that lands in the wrong offset is a harmless no-op.
|
||||||
|
# Do not "deduplicate" a pair.
|
||||||
|
#
|
||||||
|
# Rule names are stable and hand-chosen (the retired SAM stack used generated
|
||||||
|
# physical IDs). They are load-bearing: each aws_lambda_permission grants
|
||||||
|
# events.amazonaws.com on the matching rule ARN.
|
||||||
|
|
||||||
|
locals {
|
||||||
|
schedules = {
|
||||||
|
"close-form-est" = {
|
||||||
|
description = "Close form Thursday 11:59pm EST (04:59 UTC Friday)"
|
||||||
|
schedule = "cron(59 4 ? * FRI *)"
|
||||||
|
function_arn = aws_lambda_function.close_form.arn
|
||||||
|
function_name = aws_lambda_function.close_form.function_name
|
||||||
|
input = null
|
||||||
|
}
|
||||||
|
"close-form-edt" = {
|
||||||
|
description = "Close form Thursday 11:59pm EDT (03:59 UTC Friday)"
|
||||||
|
schedule = "cron(59 3 ? * FRI *)"
|
||||||
|
function_arn = aws_lambda_function.close_form.arn
|
||||||
|
function_name = aws_lambda_function.close_form.function_name
|
||||||
|
input = null
|
||||||
|
}
|
||||||
|
"reminder-est" = {
|
||||||
|
description = "DM reminders Thursday 10am EST (15:00 UTC)"
|
||||||
|
schedule = "cron(0 15 ? * THU *)"
|
||||||
|
function_arn = aws_lambda_function.slack_notifier.arn
|
||||||
|
function_name = aws_lambda_function.slack_notifier.function_name
|
||||||
|
input = "{\"event\": \"reminder\"}"
|
||||||
|
}
|
||||||
|
"reminder-edt" = {
|
||||||
|
description = "DM reminders Thursday 10am EDT (14:00 UTC)"
|
||||||
|
schedule = "cron(0 14 ? * THU *)"
|
||||||
|
function_arn = aws_lambda_function.slack_notifier.arn
|
||||||
|
function_name = aws_lambda_function.slack_notifier.function_name
|
||||||
|
input = "{\"event\": \"reminder\"}"
|
||||||
|
}
|
||||||
|
"sync-roster-est" = {
|
||||||
|
description = "Sync roster Monday 6:55am EST (11:55 UTC), before menu publish"
|
||||||
|
schedule = "cron(55 11 ? * MON *)"
|
||||||
|
function_arn = aws_lambda_function.sync_roster.arn
|
||||||
|
function_name = aws_lambda_function.sync_roster.function_name
|
||||||
|
input = null
|
||||||
|
}
|
||||||
|
"sync-roster-edt" = {
|
||||||
|
description = "Sync roster Monday 6:55am EDT (10:55 UTC), before menu publish"
|
||||||
|
schedule = "cron(55 10 ? * MON *)"
|
||||||
|
function_arn = aws_lambda_function.sync_roster.arn
|
||||||
|
function_name = aws_lambda_function.sync_roster.function_name
|
||||||
|
input = null
|
||||||
|
}
|
||||||
|
"payroll-email-est" = {
|
||||||
|
description = "Email payroll deductions Monday 7am EST (12:00 UTC)"
|
||||||
|
schedule = "cron(0 12 ? * MON *)"
|
||||||
|
function_arn = aws_lambda_function.email_report.arn
|
||||||
|
function_name = aws_lambda_function.email_report.function_name
|
||||||
|
input = null
|
||||||
|
}
|
||||||
|
"payroll-email-edt" = {
|
||||||
|
description = "Email payroll deductions Monday 7am EDT (11:00 UTC)"
|
||||||
|
schedule = "cron(0 11 ? * MON *)"
|
||||||
|
function_arn = aws_lambda_function.email_report.arn
|
||||||
|
function_name = aws_lambda_function.email_report.function_name
|
||||||
|
input = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_event_rule" "schedule" {
|
||||||
|
for_each = local.schedules
|
||||||
|
|
||||||
|
name = "${local.project}-${each.key}"
|
||||||
|
description = each.value.description
|
||||||
|
schedule_expression = each.value.schedule
|
||||||
|
state = "ENABLED"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_event_target" "schedule" {
|
||||||
|
for_each = local.schedules
|
||||||
|
|
||||||
|
rule = aws_cloudwatch_event_rule.schedule[each.key].name
|
||||||
|
target_id = "${local.project}-${each.key}"
|
||||||
|
arn = each.value.function_arn
|
||||||
|
input = each.value.input
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lambda_permission" "schedule" {
|
||||||
|
for_each = local.schedules
|
||||||
|
|
||||||
|
statement_id = "AllowEventBridgeInvoke-${each.key}"
|
||||||
|
action = "lambda:InvokeFunction"
|
||||||
|
function_name = each.value.function_name
|
||||||
|
principal = "events.amazonaws.com"
|
||||||
|
source_arn = aws_cloudwatch_event_rule.schedule[each.key].arn
|
||||||
|
}
|
||||||
422
terraform/iam.tf
Normal file
422
terraform/iam.tf
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
# Execution roles for the seven Lambda functions plus the API Gateway role that
|
||||||
|
# invokes the admin authorizer.
|
||||||
|
#
|
||||||
|
# Every role is created under the /tf-managed/ path and carries the account's
|
||||||
|
# seahaven-lambda-execution-boundary permissions boundary. The path is what
|
||||||
|
# distinguishes Terraform-owned roles from the /cfn-managed/ roles the retired
|
||||||
|
# SAM stack created.
|
||||||
|
#
|
||||||
|
# The inline policies below are hand-expanded from the SAM policy templates in
|
||||||
|
# template.yaml (DynamoDBCrudPolicy, DynamoDBReadPolicy, S3CrudPolicy,
|
||||||
|
# S3ReadPolicy). Two deliberate narrowings from the SAM expansions:
|
||||||
|
# - s3:PutObjectAcl is omitted. The reports bucket enforces
|
||||||
|
# BucketOwnerEnforced ownership, so ACL writes fail regardless.
|
||||||
|
# - s3:GetLifecycleConfiguration / s3:PutLifecycleConfiguration are omitted.
|
||||||
|
# Bucket lifecycle is owned by this configuration, not by function code.
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "lambda_assume" {
|
||||||
|
statement {
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sts:AssumeRole"]
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Service"
|
||||||
|
identifiers = ["lambda.amazonaws.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reusable policy documents
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "dynamodb_crud" {
|
||||||
|
statement {
|
||||||
|
sid = "OrdersTableCrud"
|
||||||
|
effect = "Allow"
|
||||||
|
|
||||||
|
actions = [
|
||||||
|
"dynamodb:BatchGetItem",
|
||||||
|
"dynamodb:BatchWriteItem",
|
||||||
|
"dynamodb:ConditionCheckItem",
|
||||||
|
"dynamodb:DeleteItem",
|
||||||
|
"dynamodb:DescribeTable",
|
||||||
|
"dynamodb:GetItem",
|
||||||
|
"dynamodb:PutItem",
|
||||||
|
"dynamodb:Query",
|
||||||
|
"dynamodb:Scan",
|
||||||
|
"dynamodb:UpdateItem",
|
||||||
|
]
|
||||||
|
|
||||||
|
resources = [
|
||||||
|
aws_dynamodb_table.orders.arn,
|
||||||
|
"${aws_dynamodb_table.orders.arn}/index/*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "dynamodb_read" {
|
||||||
|
statement {
|
||||||
|
sid = "OrdersTableRead"
|
||||||
|
effect = "Allow"
|
||||||
|
|
||||||
|
actions = [
|
||||||
|
"dynamodb:BatchGetItem",
|
||||||
|
"dynamodb:ConditionCheckItem",
|
||||||
|
"dynamodb:DescribeTable",
|
||||||
|
"dynamodb:GetItem",
|
||||||
|
"dynamodb:Query",
|
||||||
|
"dynamodb:Scan",
|
||||||
|
]
|
||||||
|
|
||||||
|
resources = [
|
||||||
|
aws_dynamodb_table.orders.arn,
|
||||||
|
"${aws_dynamodb_table.orders.arn}/index/*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "ssm_read" {
|
||||||
|
statement {
|
||||||
|
sid = "ReadProjectParameters"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["ssm:GetParameter"]
|
||||||
|
resources = [local.ssm_parameter_arn_wildcard]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The SAM template granted secretsmanager:GetSecretValue on
|
||||||
|
# `secret:meal-order-manager/*`. Scoped here to the one secret the code actually
|
||||||
|
# reads, supplied as an ARN so the grant cannot drift onto a future secret that
|
||||||
|
# happens to share the prefix.
|
||||||
|
data "aws_iam_policy_document" "slack_bot_secret_read" {
|
||||||
|
statement {
|
||||||
|
sid = "ReadSlackBotToken"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["secretsmanager:GetSecretValue"]
|
||||||
|
resources = [var.slack_bot_secret_arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# submit-order
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_iam_role" "submit_order" {
|
||||||
|
name = "${local.project}-submit-order"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
|
||||||
|
permissions_boundary = local.boundary_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "submit_order_basic" {
|
||||||
|
role = aws_iam_role.submit_order.name
|
||||||
|
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "submit_order" {
|
||||||
|
source_policy_documents = [
|
||||||
|
data.aws_iam_policy_document.dynamodb_crud.json,
|
||||||
|
data.aws_iam_policy_document.ssm_read.json,
|
||||||
|
]
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "InvokeSlackNotifier"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["lambda:InvokeFunction"]
|
||||||
|
resources = [aws_lambda_function.slack_notifier.arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read-only access to the weekly summary PDFs only — not the payroll or order
|
||||||
|
# CSVs — for the admin summary-pdf presigned-URL endpoint.
|
||||||
|
statement {
|
||||||
|
sid = "ReadWeeklySummaryPdfs"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["s3:GetObject"]
|
||||||
|
resources = ["${aws_s3_bucket.reports.arn}/reports/*/weekly-summary-*.pdf"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "submit_order" {
|
||||||
|
name = "submit-order"
|
||||||
|
role = aws_iam_role.submit_order.id
|
||||||
|
policy = data.aws_iam_policy_document.submit_order.json
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# admin-authorizer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_iam_role" "admin_authorizer" {
|
||||||
|
name = "${local.project}-admin-authorizer"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
|
||||||
|
permissions_boundary = local.boundary_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "admin_authorizer_basic" {
|
||||||
|
role = aws_iam_role.admin_authorizer.name
|
||||||
|
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "admin_authorizer" {
|
||||||
|
source_policy_documents = [
|
||||||
|
data.aws_iam_policy_document.dynamodb_read.json,
|
||||||
|
data.aws_iam_policy_document.ssm_read.json,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "admin_authorizer" {
|
||||||
|
name = "admin-authorizer"
|
||||||
|
role = aws_iam_role.admin_authorizer.id
|
||||||
|
policy = data.aws_iam_policy_document.admin_authorizer.json
|
||||||
|
}
|
||||||
|
|
||||||
|
# Role API Gateway assumes to invoke the authorizer Lambda. The authorizer has
|
||||||
|
# no resource policy of its own; this identity-based grant is the only path.
|
||||||
|
# SourceAccount + execute-api ArnLike close the confused-deputy window without
|
||||||
|
# pinning the authorizer id (that would cycle: authorizer needs this role).
|
||||||
|
data "aws_iam_policy_document" "apigateway_assume" {
|
||||||
|
statement {
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sts:AssumeRole"]
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Service"
|
||||||
|
identifiers = ["apigateway.amazonaws.com"]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "aws:SourceAccount"
|
||||||
|
values = [local.account_id]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "ArnLike"
|
||||||
|
variable = "aws:SourceArn"
|
||||||
|
values = ["arn:aws:execute-api:${var.aws_region}:${local.account_id}:${aws_apigatewayv2_api.order_api.id}/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "admin_authorizer_invoke" {
|
||||||
|
name = "${local.project}-admin-authorizer-invoke"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.apigateway_assume.json
|
||||||
|
permissions_boundary = local.boundary_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "admin_authorizer_invoke" {
|
||||||
|
statement {
|
||||||
|
sid = "InvokeAdminAuthorizer"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["lambda:InvokeFunction"]
|
||||||
|
resources = [aws_lambda_function.admin_authorizer.arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "admin_authorizer_invoke" {
|
||||||
|
name = "invoke-admin-authorizer"
|
||||||
|
role = aws_iam_role.admin_authorizer_invoke.id
|
||||||
|
policy = data.aws_iam_policy_document.admin_authorizer_invoke.json
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# close-form
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_iam_role" "close_form" {
|
||||||
|
name = "${local.project}-close-form"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
|
||||||
|
permissions_boundary = local.boundary_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "close_form_basic" {
|
||||||
|
role = aws_iam_role.close_form.name
|
||||||
|
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "close_form" {
|
||||||
|
source_policy_documents = [data.aws_iam_policy_document.dynamodb_crud.json]
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "InvokeAggregateOrders"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["lambda:InvokeFunction"]
|
||||||
|
resources = [aws_lambda_function.aggregate_orders.arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "close_form" {
|
||||||
|
name = "close-form"
|
||||||
|
role = aws_iam_role.close_form.id
|
||||||
|
policy = data.aws_iam_policy_document.close_form.json
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# aggregate-orders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_iam_role" "aggregate_orders" {
|
||||||
|
name = "${local.project}-aggregate-orders"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
|
||||||
|
permissions_boundary = local.boundary_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "aggregate_orders_basic" {
|
||||||
|
role = aws_iam_role.aggregate_orders.name
|
||||||
|
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "aggregate_orders" {
|
||||||
|
source_policy_documents = [data.aws_iam_policy_document.dynamodb_crud.json]
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "ReportsBucketCrud"
|
||||||
|
effect = "Allow"
|
||||||
|
|
||||||
|
actions = [
|
||||||
|
"s3:DeleteObject",
|
||||||
|
"s3:GetObject",
|
||||||
|
"s3:GetObjectVersion",
|
||||||
|
"s3:PutObject",
|
||||||
|
]
|
||||||
|
|
||||||
|
resources = ["${aws_s3_bucket.reports.arn}/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "ReportsBucketList"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
|
||||||
|
resources = [aws_s3_bucket.reports.arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "InvokeSlackNotifier"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["lambda:InvokeFunction"]
|
||||||
|
resources = [aws_lambda_function.slack_notifier.arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "aggregate_orders" {
|
||||||
|
name = "aggregate-orders"
|
||||||
|
role = aws_iam_role.aggregate_orders.id
|
||||||
|
policy = data.aws_iam_policy_document.aggregate_orders.json
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# slack-notifier
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_iam_role" "slack_notifier" {
|
||||||
|
name = "${local.project}-slack-notifier"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
|
||||||
|
permissions_boundary = local.boundary_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "slack_notifier_basic" {
|
||||||
|
role = aws_iam_role.slack_notifier.name
|
||||||
|
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "slack_notifier" {
|
||||||
|
source_policy_documents = [
|
||||||
|
data.aws_iam_policy_document.dynamodb_read.json,
|
||||||
|
data.aws_iam_policy_document.ssm_read.json,
|
||||||
|
data.aws_iam_policy_document.slack_bot_secret_read.json,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "slack_notifier" {
|
||||||
|
name = "slack-notifier"
|
||||||
|
role = aws_iam_role.slack_notifier.id
|
||||||
|
policy = data.aws_iam_policy_document.slack_notifier.json
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# sync-roster
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_iam_role" "sync_roster" {
|
||||||
|
name = "${local.project}-sync-roster"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
|
||||||
|
permissions_boundary = local.boundary_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "sync_roster_basic" {
|
||||||
|
role = aws_iam_role.sync_roster.name
|
||||||
|
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "sync_roster" {
|
||||||
|
source_policy_documents = [
|
||||||
|
data.aws_iam_policy_document.dynamodb_crud.json,
|
||||||
|
data.aws_iam_policy_document.ssm_read.json,
|
||||||
|
data.aws_iam_policy_document.slack_bot_secret_read.json,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "sync_roster" {
|
||||||
|
name = "sync-roster"
|
||||||
|
role = aws_iam_role.sync_roster.id
|
||||||
|
policy = data.aws_iam_policy_document.sync_roster.json
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# email-report
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_iam_role" "email_report" {
|
||||||
|
name = "${local.project}-email-report"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
|
||||||
|
permissions_boundary = local.boundary_arn
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy_attachment" "email_report_basic" {
|
||||||
|
role = aws_iam_role.email_report.name
|
||||||
|
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "email_report" {
|
||||||
|
source_policy_documents = [data.aws_iam_policy_document.dynamodb_read.json]
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "ReportsBucketRead"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["s3:GetObject", "s3:GetObjectVersion"]
|
||||||
|
resources = ["${aws_s3_bucket.reports.arn}/*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "ReportsBucketList"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
|
||||||
|
resources = [aws_s3_bucket.reports.arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scope SendRawEmail to the verified sender domain/identity rather than "*".
|
||||||
|
# SES still enforces verification; IAM pins the From identity ARNs.
|
||||||
|
statement {
|
||||||
|
sid = "SendPayrollReport"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["ses:SendRawEmail"]
|
||||||
|
resources = [
|
||||||
|
"arn:aws:ses:${var.aws_region}:${local.account_id}:identity/${var.sender_email}",
|
||||||
|
"arn:aws:ses:${var.aws_region}:${local.account_id}:identity/seahavenind.com",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "email_report" {
|
||||||
|
name = "email-report"
|
||||||
|
role = aws_iam_role.email_report.id
|
||||||
|
policy = data.aws_iam_policy_document.email_report.json
|
||||||
|
}
|
||||||
239
terraform/lambda.tf
Normal file
239
terraform/lambda.tf
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
# The shared layer and the seven functions.
|
||||||
|
#
|
||||||
|
# Packages come from the artifacts bucket (see artifacts.tf). Function packages
|
||||||
|
# contain the handler only: boto3 comes from the runtime, and fpdf2 plus the
|
||||||
|
# `shared` package come from the layer.
|
||||||
|
|
||||||
|
resource "aws_lambda_layer_version" "shared" {
|
||||||
|
layer_name = "${local.project}-shared"
|
||||||
|
description = "fpdf2 and the shared package for meal-order-manager"
|
||||||
|
|
||||||
|
s3_bucket = aws_s3_bucket.artifacts.id
|
||||||
|
s3_key = aws_s3_object.shared_layer.key
|
||||||
|
source_code_hash = data.archive_file.shared_layer.output_base64sha256
|
||||||
|
|
||||||
|
compatible_runtimes = ["python3.12"]
|
||||||
|
compatible_architectures = ["arm64"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# submit-order — HTTP API handler for the order form and the admin surface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_lambda_function" "submit_order" {
|
||||||
|
function_name = "${local.project}-submit-order"
|
||||||
|
role = aws_iam_role.submit_order.arn
|
||||||
|
handler = "handler.lambda_handler"
|
||||||
|
runtime = "python3.12"
|
||||||
|
architectures = ["arm64"]
|
||||||
|
memory_size = 128
|
||||||
|
timeout = 10
|
||||||
|
|
||||||
|
s3_bucket = aws_s3_bucket.artifacts.id
|
||||||
|
s3_key = aws_s3_object.function["submit_order"].key
|
||||||
|
source_code_hash = data.archive_file.function["submit_order"].output_base64sha256
|
||||||
|
|
||||||
|
layers = [aws_lambda_layer_version.shared.arn]
|
||||||
|
|
||||||
|
environment {
|
||||||
|
variables = merge(local.common_env, {
|
||||||
|
SLACK_NOTIFIER_ARN = aws_lambda_function.slack_notifier.arn
|
||||||
|
GOOGLE_CLIENT_ID_PARAM = local.google_client_id_param
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
aws_cloudwatch_log_group.function,
|
||||||
|
aws_iam_role_policy.submit_order,
|
||||||
|
aws_iam_role_policy_attachment.submit_order_basic,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# admin-authorizer — validates the Google ID token for every /api/admin route
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_lambda_function" "admin_authorizer" {
|
||||||
|
function_name = "${local.project}-admin-authorizer"
|
||||||
|
role = aws_iam_role.admin_authorizer.arn
|
||||||
|
handler = "handler.lambda_handler"
|
||||||
|
runtime = "python3.12"
|
||||||
|
architectures = ["arm64"]
|
||||||
|
memory_size = 128
|
||||||
|
timeout = 10
|
||||||
|
|
||||||
|
s3_bucket = aws_s3_bucket.artifacts.id
|
||||||
|
s3_key = aws_s3_object.function["admin_authorizer"].key
|
||||||
|
source_code_hash = data.archive_file.function["admin_authorizer"].output_base64sha256
|
||||||
|
|
||||||
|
layers = [aws_lambda_layer_version.shared.arn]
|
||||||
|
|
||||||
|
environment {
|
||||||
|
variables = merge(local.common_env, {
|
||||||
|
GOOGLE_CLIENT_ID_PARAM = local.google_client_id_param
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
aws_cloudwatch_log_group.function,
|
||||||
|
aws_iam_role_policy.admin_authorizer,
|
||||||
|
aws_iam_role_policy_attachment.admin_authorizer_basic,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# close-form — closes the weekly order window, then fans out to aggregation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_lambda_function" "close_form" {
|
||||||
|
function_name = "${local.project}-close-form"
|
||||||
|
role = aws_iam_role.close_form.arn
|
||||||
|
handler = "handler.lambda_handler"
|
||||||
|
runtime = "python3.12"
|
||||||
|
architectures = ["arm64"]
|
||||||
|
memory_size = 128
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
|
s3_bucket = aws_s3_bucket.artifacts.id
|
||||||
|
s3_key = aws_s3_object.function["close_form"].key
|
||||||
|
source_code_hash = data.archive_file.function["close_form"].output_base64sha256
|
||||||
|
|
||||||
|
layers = [aws_lambda_layer_version.shared.arn]
|
||||||
|
|
||||||
|
environment {
|
||||||
|
variables = merge(local.common_env, {
|
||||||
|
AGGREGATE_FUNCTION_ARN = aws_lambda_function.aggregate_orders.arn
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
aws_cloudwatch_log_group.function,
|
||||||
|
aws_iam_role_policy.close_form,
|
||||||
|
aws_iam_role_policy_attachment.close_form_basic,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# aggregate-orders — rolls the week's orders into CSV and PDF artifacts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_lambda_function" "aggregate_orders" {
|
||||||
|
function_name = "${local.project}-aggregate-orders"
|
||||||
|
role = aws_iam_role.aggregate_orders.arn
|
||||||
|
handler = "handler.lambda_handler"
|
||||||
|
runtime = "python3.12"
|
||||||
|
architectures = ["arm64"]
|
||||||
|
memory_size = 256
|
||||||
|
timeout = 60
|
||||||
|
|
||||||
|
s3_bucket = aws_s3_bucket.artifacts.id
|
||||||
|
s3_key = aws_s3_object.function["aggregate_orders"].key
|
||||||
|
source_code_hash = data.archive_file.function["aggregate_orders"].output_base64sha256
|
||||||
|
|
||||||
|
layers = [aws_lambda_layer_version.shared.arn]
|
||||||
|
|
||||||
|
environment {
|
||||||
|
variables = merge(local.common_env, {
|
||||||
|
SLACK_NOTIFIER_ARN = aws_lambda_function.slack_notifier.arn
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
aws_cloudwatch_log_group.function,
|
||||||
|
aws_iam_role_policy.aggregate_orders,
|
||||||
|
aws_iam_role_policy_attachment.aggregate_orders_basic,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# slack-notifier — reminders and summaries into Slack
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_lambda_function" "slack_notifier" {
|
||||||
|
function_name = "${local.project}-slack-notifier"
|
||||||
|
role = aws_iam_role.slack_notifier.arn
|
||||||
|
handler = "handler.lambda_handler"
|
||||||
|
runtime = "python3.12"
|
||||||
|
architectures = ["arm64"]
|
||||||
|
memory_size = 128
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
|
s3_bucket = aws_s3_bucket.artifacts.id
|
||||||
|
s3_key = aws_s3_object.function["slack_notifier"].key
|
||||||
|
source_code_hash = data.archive_file.function["slack_notifier"].output_base64sha256
|
||||||
|
|
||||||
|
layers = [aws_lambda_layer_version.shared.arn]
|
||||||
|
|
||||||
|
environment {
|
||||||
|
variables = local.common_env
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
aws_cloudwatch_log_group.function,
|
||||||
|
aws_iam_role_policy.slack_notifier,
|
||||||
|
aws_iam_role_policy_attachment.slack_notifier_basic,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# sync-roster — pulls the employee roster from Slack ahead of the menu publish
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_lambda_function" "sync_roster" {
|
||||||
|
function_name = "${local.project}-sync-roster"
|
||||||
|
role = aws_iam_role.sync_roster.arn
|
||||||
|
handler = "handler.lambda_handler"
|
||||||
|
runtime = "python3.12"
|
||||||
|
architectures = ["arm64"]
|
||||||
|
memory_size = 128
|
||||||
|
timeout = 60
|
||||||
|
|
||||||
|
s3_bucket = aws_s3_bucket.artifacts.id
|
||||||
|
s3_key = aws_s3_object.function["sync_roster"].key
|
||||||
|
source_code_hash = data.archive_file.function["sync_roster"].output_base64sha256
|
||||||
|
|
||||||
|
layers = [aws_lambda_layer_version.shared.arn]
|
||||||
|
|
||||||
|
environment {
|
||||||
|
variables = local.common_env
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
aws_cloudwatch_log_group.function,
|
||||||
|
aws_iam_role_policy.sync_roster,
|
||||||
|
aws_iam_role_policy_attachment.sync_roster_basic,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# email-report — emails the weekly payroll deduction report
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_lambda_function" "email_report" {
|
||||||
|
function_name = "${local.project}-email-report"
|
||||||
|
role = aws_iam_role.email_report.arn
|
||||||
|
handler = "handler.lambda_handler"
|
||||||
|
runtime = "python3.12"
|
||||||
|
architectures = ["arm64"]
|
||||||
|
memory_size = 128
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
|
s3_bucket = aws_s3_bucket.artifacts.id
|
||||||
|
s3_key = aws_s3_object.function["email_report"].key
|
||||||
|
source_code_hash = data.archive_file.function["email_report"].output_base64sha256
|
||||||
|
|
||||||
|
layers = [aws_lambda_layer_version.shared.arn]
|
||||||
|
|
||||||
|
environment {
|
||||||
|
variables = merge(local.common_env, {
|
||||||
|
PAYROLL_EMAIL = var.payroll_email
|
||||||
|
SENDER_EMAIL = var.sender_email
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
aws_cloudwatch_log_group.function,
|
||||||
|
aws_iam_role_policy.email_report,
|
||||||
|
aws_iam_role_policy_attachment.email_report_basic,
|
||||||
|
]
|
||||||
|
}
|
||||||
100
terraform/locals.tf
Normal file
100
terraform/locals.tf
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
locals {
|
||||||
|
project = "meal-order-manager"
|
||||||
|
account_id = "011934824531"
|
||||||
|
|
||||||
|
# Every execution role in this configuration is created under /tf-managed/ and
|
||||||
|
# carries the account's Lambda execution boundary.
|
||||||
|
boundary_arn = "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary"
|
||||||
|
|
||||||
|
form_bucket_name = "${local.project}-form-${local.account_id}"
|
||||||
|
reports_bucket_name = "${local.project}-reports-${local.account_id}"
|
||||||
|
artifacts_bucket_name = "${local.project}-artifacts-${local.account_id}"
|
||||||
|
|
||||||
|
table_name = "${local.project}-orders"
|
||||||
|
form_url = "https://${var.domain_name}"
|
||||||
|
|
||||||
|
ssm_prefix = "/${local.project}"
|
||||||
|
slack_channel_param = "${local.ssm_prefix}/slack-channel-id"
|
||||||
|
|
||||||
|
# google-client-id is created and rotated out-of-band. Terraform reads it
|
||||||
|
# (data.tf) but never owns it.
|
||||||
|
google_client_id_param = "${local.ssm_prefix}/google-client-id"
|
||||||
|
|
||||||
|
# shared/slack.py resolves the token by NAME, while the IAM grant is scoped to
|
||||||
|
# the ARN in var.slack_bot_secret_arn. Both must refer to the same secret.
|
||||||
|
slack_bot_secret_name = "${local.project}/slack-bot-token"
|
||||||
|
|
||||||
|
ssm_parameter_arn_wildcard = "arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*"
|
||||||
|
|
||||||
|
# Directory names under functions/, which build_packages.sh mirrors into
|
||||||
|
# terraform/build/functions/.
|
||||||
|
function_packages = toset([
|
||||||
|
"admin_authorizer",
|
||||||
|
"aggregate_orders",
|
||||||
|
"close_form",
|
||||||
|
"email_report",
|
||||||
|
"slack_notifier",
|
||||||
|
"submit_order",
|
||||||
|
"sync_roster",
|
||||||
|
])
|
||||||
|
|
||||||
|
# SAM Globals.Function.Environment.Variables — every function receives these.
|
||||||
|
common_env = {
|
||||||
|
TABLE_NAME = local.table_name
|
||||||
|
REPORTS_BUCKET = local.reports_bucket_name
|
||||||
|
SLACK_CHANNEL_PARAM = local.slack_channel_param
|
||||||
|
FORM_URL = local.form_url
|
||||||
|
SLACK_BOT_SM_NAME = local.slack_bot_secret_name
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP API routes, all integrated with submit-order. `authorizer` selects the
|
||||||
|
# authorization mode; `permission_source` is the method/path suffix of the
|
||||||
|
# per-route lambda:InvokeFunction grant (path parameters become `*`).
|
||||||
|
api_routes = {
|
||||||
|
submit_order = {
|
||||||
|
route_key = "POST /api/submit-order"
|
||||||
|
authorizer = "NONE"
|
||||||
|
permission_source = "POST/api/submit-order"
|
||||||
|
}
|
||||||
|
form_status = {
|
||||||
|
route_key = "GET /api/form-status/{week}"
|
||||||
|
authorizer = "NONE"
|
||||||
|
permission_source = "GET/api/form-status/*"
|
||||||
|
}
|
||||||
|
roster = {
|
||||||
|
route_key = "GET /api/roster"
|
||||||
|
authorizer = "NONE"
|
||||||
|
permission_source = "GET/api/roster"
|
||||||
|
}
|
||||||
|
publish_settings = {
|
||||||
|
route_key = "GET /api/publish/settings"
|
||||||
|
authorizer = "AWS_IAM"
|
||||||
|
permission_source = "GET/api/publish/settings"
|
||||||
|
}
|
||||||
|
publish_menu = {
|
||||||
|
route_key = "POST /api/publish/menu"
|
||||||
|
authorizer = "AWS_IAM"
|
||||||
|
permission_source = "POST/api/publish/menu"
|
||||||
|
}
|
||||||
|
admin_orders_get = {
|
||||||
|
route_key = "GET /api/admin/orders"
|
||||||
|
authorizer = "CUSTOM"
|
||||||
|
permission_source = "GET/api/admin/orders"
|
||||||
|
}
|
||||||
|
admin_orders_put = {
|
||||||
|
route_key = "PUT /api/admin/orders"
|
||||||
|
authorizer = "CUSTOM"
|
||||||
|
permission_source = "PUT/api/admin/orders"
|
||||||
|
}
|
||||||
|
admin_orders_delete = {
|
||||||
|
route_key = "DELETE /api/admin/orders"
|
||||||
|
authorizer = "CUSTOM"
|
||||||
|
permission_source = "DELETE/api/admin/orders"
|
||||||
|
}
|
||||||
|
admin_summary_pdf = {
|
||||||
|
route_key = "GET /api/admin/summary-pdf"
|
||||||
|
authorizer = "CUSTOM"
|
||||||
|
permission_source = "GET/api/admin/summary-pdf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
terraform/logs.tf
Normal file
17
terraform/logs.tf
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Log groups are created explicitly rather than left to Lambda's implicit
|
||||||
|
# on-first-invoke creation, so retention is enforced from the start. Each name
|
||||||
|
# matches the runtime default (/aws/lambda/<function-name>), and every function
|
||||||
|
# depends on its group.
|
||||||
|
|
||||||
|
resource "aws_cloudwatch_log_group" "function" {
|
||||||
|
for_each = local.function_packages
|
||||||
|
|
||||||
|
name = "/aws/lambda/${local.project}-${replace(each.key, "_", "-")}"
|
||||||
|
retention_in_days = 60
|
||||||
|
}
|
||||||
|
|
||||||
|
# Longer than the Lambda groups on purpose, for request-level forensics.
|
||||||
|
resource "aws_cloudwatch_log_group" "api_access" {
|
||||||
|
name = "/aws/apigateway/${local.project}"
|
||||||
|
retention_in_days = 90
|
||||||
|
}
|
||||||
57
terraform/outputs.tf
Normal file
57
terraform/outputs.tf
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
output "api_url" {
|
||||||
|
description = "HTTP API endpoint URL."
|
||||||
|
value = aws_apigatewayv2_api.order_api.api_endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
output "form_url" {
|
||||||
|
description = "Public order form URL."
|
||||||
|
value = local.form_url
|
||||||
|
}
|
||||||
|
|
||||||
|
output "distribution_id" {
|
||||||
|
description = "CloudFront distribution ID, for cache invalidation."
|
||||||
|
value = aws_cloudfront_distribution.form.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "distribution_domain_name" {
|
||||||
|
description = "CloudFront distribution domain name, the DNS target for the custom domain."
|
||||||
|
value = aws_cloudfront_distribution.form.domain_name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "form_bucket_name" {
|
||||||
|
description = "S3 bucket holding the order form HTML."
|
||||||
|
value = aws_s3_bucket.form.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "reports_bucket_name" {
|
||||||
|
description = "S3 bucket holding payroll CSVs and weekly summary PDFs."
|
||||||
|
value = aws_s3_bucket.reports.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "artifacts_bucket_name" {
|
||||||
|
description = "S3 bucket holding Lambda deployment packages."
|
||||||
|
value = aws_s3_bucket.artifacts.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "orders_table_name" {
|
||||||
|
description = "DynamoDB orders table."
|
||||||
|
value = aws_dynamodb_table.orders.name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "shared_layer_arn" {
|
||||||
|
description = "Version ARN of the shared Lambda layer."
|
||||||
|
value = aws_lambda_layer_version.shared.arn
|
||||||
|
}
|
||||||
|
|
||||||
|
output "function_arns" {
|
||||||
|
description = "ARNs of every Lambda function in this configuration."
|
||||||
|
value = {
|
||||||
|
admin_authorizer = aws_lambda_function.admin_authorizer.arn
|
||||||
|
aggregate_orders = aws_lambda_function.aggregate_orders.arn
|
||||||
|
close_form = aws_lambda_function.close_form.arn
|
||||||
|
email_report = aws_lambda_function.email_report.arn
|
||||||
|
slack_notifier = aws_lambda_function.slack_notifier.arn
|
||||||
|
submit_order = aws_lambda_function.submit_order.arn
|
||||||
|
sync_roster = aws_lambda_function.sync_roster.arn
|
||||||
|
}
|
||||||
|
}
|
||||||
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 = "meal-order-manager"
|
||||||
|
ManagedBy = "terraform"
|
||||||
|
Workspace = "meal-order-manager-prod"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
210
terraform/s3.tf
Normal file
210
terraform/s3.tf
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
# Form-hosting and reports buckets. The Lambda artifact bucket lives in
|
||||||
|
# artifacts.tf.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Form bucket — private origin for the CloudFront distribution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_s3_bucket" "form" {
|
||||||
|
bucket = local.form_bucket_name
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Purpose = "meal-order-form-hosting"
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_public_access_block" "form" {
|
||||||
|
bucket = aws_s3_bucket.form.id
|
||||||
|
|
||||||
|
block_public_acls = true
|
||||||
|
block_public_policy = true
|
||||||
|
ignore_public_acls = true
|
||||||
|
restrict_public_buckets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_ownership_controls" "form" {
|
||||||
|
bucket = aws_s3_bucket.form.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
object_ownership = "BucketOwnerEnforced"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_server_side_encryption_configuration" "form" {
|
||||||
|
bucket = aws_s3_bucket.form.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
apply_server_side_encryption_by_default {
|
||||||
|
sse_algorithm = "AES256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_lifecycle_configuration" "form" {
|
||||||
|
bucket = aws_s3_bucket.form.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
id = "delete-old-archives"
|
||||||
|
status = "Enabled"
|
||||||
|
|
||||||
|
filter {
|
||||||
|
prefix = "archive/"
|
||||||
|
}
|
||||||
|
|
||||||
|
expiration {
|
||||||
|
days = 90
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "form" {
|
||||||
|
statement {
|
||||||
|
sid = "DenyInsecureTransport"
|
||||||
|
effect = "Deny"
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "*"
|
||||||
|
identifiers = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
actions = ["s3:*"]
|
||||||
|
|
||||||
|
resources = [
|
||||||
|
aws_s3_bucket.form.arn,
|
||||||
|
"${aws_s3_bucket.form.arn}/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "Bool"
|
||||||
|
variable = "aws:SecureTransport"
|
||||||
|
values = ["false"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "AllowCloudFrontOAC"
|
||||||
|
effect = "Allow"
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Service"
|
||||||
|
identifiers = ["cloudfront.amazonaws.com"]
|
||||||
|
}
|
||||||
|
|
||||||
|
actions = ["s3:GetObject"]
|
||||||
|
resources = ["${aws_s3_bucket.form.arn}/*"]
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "AWS:SourceArn"
|
||||||
|
values = [aws_cloudfront_distribution.form.arn]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_policy" "form" {
|
||||||
|
bucket = aws_s3_bucket.form.id
|
||||||
|
policy = data.aws_iam_policy_document.form.json
|
||||||
|
|
||||||
|
depends_on = [aws_s3_bucket_public_access_block.form]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reports bucket — payroll CSVs and weekly summary PDFs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
resource "aws_s3_bucket" "reports" {
|
||||||
|
bucket = local.reports_bucket_name
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Purpose = "meal-order-reports"
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_public_access_block" "reports" {
|
||||||
|
bucket = aws_s3_bucket.reports.id
|
||||||
|
|
||||||
|
block_public_acls = true
|
||||||
|
block_public_policy = true
|
||||||
|
ignore_public_acls = true
|
||||||
|
restrict_public_buckets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_ownership_controls" "reports" {
|
||||||
|
bucket = aws_s3_bucket.reports.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
object_ownership = "BucketOwnerEnforced"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_server_side_encryption_configuration" "reports" {
|
||||||
|
bucket = aws_s3_bucket.reports.id
|
||||||
|
|
||||||
|
rule {
|
||||||
|
apply_server_side_encryption_by_default {
|
||||||
|
sse_algorithm = "AES256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_lifecycle_configuration" "reports" {
|
||||||
|
bucket = aws_s3_bucket.reports.id
|
||||||
|
|
||||||
|
# Whole-bucket rule, matching the SAM template's unprefixed rule.
|
||||||
|
rule {
|
||||||
|
id = "archive-old-reports"
|
||||||
|
status = "Enabled"
|
||||||
|
|
||||||
|
filter {
|
||||||
|
prefix = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
transition {
|
||||||
|
days = 90
|
||||||
|
storage_class = "GLACIER_IR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The SAM template attached no policy to this bucket. The TLS-only deny is a
|
||||||
|
# deliberate addition; it grants nothing and blocks plaintext access to payroll
|
||||||
|
# data.
|
||||||
|
data "aws_iam_policy_document" "reports" {
|
||||||
|
statement {
|
||||||
|
sid = "DenyInsecureTransport"
|
||||||
|
effect = "Deny"
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "*"
|
||||||
|
identifiers = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
actions = ["s3:*"]
|
||||||
|
|
||||||
|
resources = [
|
||||||
|
aws_s3_bucket.reports.arn,
|
||||||
|
"${aws_s3_bucket.reports.arn}/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "Bool"
|
||||||
|
variable = "aws:SecureTransport"
|
||||||
|
values = ["false"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_s3_bucket_policy" "reports" {
|
||||||
|
bucket = aws_s3_bucket.reports.id
|
||||||
|
policy = data.aws_iam_policy_document.reports.json
|
||||||
|
|
||||||
|
depends_on = [aws_s3_bucket_public_access_block.reports]
|
||||||
|
}
|
||||||
18
terraform/secrets.tf
Normal file
18
terraform/secrets.tf
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Secrets Manager — intentionally empty of resources.
|
||||||
|
#
|
||||||
|
# meal-order-manager/slack-bot-token is created and rotated out-of-band. Only
|
||||||
|
# its ARN enters this configuration, through var.slack_bot_secret_arn, and it is
|
||||||
|
# used for one thing: scoping secretsmanager:GetSecretValue on the slack-notifier
|
||||||
|
# and sync-roster execution roles (see iam.tf).
|
||||||
|
#
|
||||||
|
# Secret VALUES never enter Terraform state. An aws_secretsmanager_secret_version
|
||||||
|
# resource would write the plaintext into state and is never used in this repo.
|
||||||
|
# Rotate with:
|
||||||
|
# aws secretsmanager put-secret-value \
|
||||||
|
# --secret-id meal-order-manager/slack-bot-token --secret-string <value>
|
||||||
|
#
|
||||||
|
# There is no `data "aws_secretsmanager_secret_version"` lookup either: reading a
|
||||||
|
# version through a data source also lands the plaintext in state.
|
||||||
|
#
|
||||||
|
# If a future resource needs another secret, add a variable carrying its ARN —
|
||||||
|
# never a managed resource, and never a version.
|
||||||
48
terraform/ssm.tf
Normal file
48
terraform/ssm.tf
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Parameter Store entries.
|
||||||
|
#
|
||||||
|
# /meal-order-manager/google-client-id is deliberately NOT declared here. It is
|
||||||
|
# created and rotated out-of-band because it varies per environment; data.tf
|
||||||
|
# reads it. Do not turn that lookup into a resource.
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "slack_channel_id" {
|
||||||
|
name = local.slack_channel_param
|
||||||
|
type = "String"
|
||||||
|
value = var.slack_channel_id
|
||||||
|
description = "Slack channel ID for meal order notifications"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deploy-time lookups
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# These replace the CloudFormation stack outputs that
|
||||||
|
# .github/workflows/weekly-menu.yml used to read, so the job can resolve its
|
||||||
|
# deploy targets without a CloudFormation stack.
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_api_url" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/api-url"
|
||||||
|
type = "String"
|
||||||
|
value = aws_apigatewayv2_api.order_api.api_endpoint
|
||||||
|
description = "API Gateway endpoint URL; read by the weekly-menu deploy job"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_form_bucket" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/form-bucket"
|
||||||
|
type = "String"
|
||||||
|
value = aws_s3_bucket.form.id
|
||||||
|
description = "S3 bucket holding the order form; sync target for the weekly-menu deploy job"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_distribution_id" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/distribution-id"
|
||||||
|
type = "String"
|
||||||
|
value = aws_cloudfront_distribution.form.id
|
||||||
|
description = "CloudFront distribution ID; cache-invalidation target for the weekly-menu deploy job"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "deploy_form_url" {
|
||||||
|
name = "${local.ssm_prefix}/deploy/form-url"
|
||||||
|
type = "String"
|
||||||
|
value = local.form_url
|
||||||
|
description = "Public order form URL; reported by the weekly-menu deploy job"
|
||||||
|
}
|
||||||
21
terraform/terraform.tfvars.example
Normal file
21
terraform/terraform.tfvars.example
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Values for the meal-order-manager-prod HCP Terraform workspace.
|
||||||
|
# Set these as workspace variables; this file is a reference, not an input.
|
||||||
|
|
||||||
|
# Region every resource is created in.
|
||||||
|
aws_region = "us-east-1"
|
||||||
|
|
||||||
|
# Custom domain for the order form. An ISSUED ACM certificate for this domain
|
||||||
|
# must already exist in us-east-1 (see acm.tf).
|
||||||
|
domain_name = "orders.seahaven.com"
|
||||||
|
|
||||||
|
# Payroll deduction report recipient and SES-verified sender.
|
||||||
|
payroll_email = "payroll@seahavenind.com"
|
||||||
|
sender_email = "adam@seahavenind.com"
|
||||||
|
|
||||||
|
# ARN of the out-of-band Secrets Manager secret holding the Slack bot token.
|
||||||
|
# Only the ARN is used; the value never enters Terraform state.
|
||||||
|
slack_bot_secret_arn = "arn:aws:secretsmanager:us-east-1:011934824531:secret:meal-order-manager/slack-bot-token-XXXXXX"
|
||||||
|
|
||||||
|
# Slack channel that receives meal order notifications. Written to
|
||||||
|
# /meal-order-manager/slack-channel-id.
|
||||||
|
slack_channel_id = "C00000000000"
|
||||||
38
terraform/variables.tf
Normal file
38
terraform/variables.tf
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
variable "aws_region" {
|
||||||
|
description = "Region every resource in this configuration is created in."
|
||||||
|
type = string
|
||||||
|
default = "us-east-1"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "domain_name" {
|
||||||
|
description = "Custom domain served by the CloudFront distribution. An ISSUED ACM certificate for this domain must already exist in us-east-1 (see acm.tf)."
|
||||||
|
type = string
|
||||||
|
default = "orders.seahaven.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "payroll_email" {
|
||||||
|
description = "Recipient of the weekly payroll deduction report."
|
||||||
|
type = string
|
||||||
|
default = "payroll@seahavenind.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "sender_email" {
|
||||||
|
description = "SES-verified From address for the payroll deduction report."
|
||||||
|
type = string
|
||||||
|
default = "adam@seahavenind.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "slack_bot_secret_arn" {
|
||||||
|
description = "ARN of the Secrets Manager secret holding the Slack bot token. The secret and its value are managed out-of-band; only the ARN enters this configuration."
|
||||||
|
type = string
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = can(regex("^arn:aws:secretsmanager:", var.slack_bot_secret_arn))
|
||||||
|
error_message = "slack_bot_secret_arn must be a Secrets Manager ARN."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "slack_channel_id" {
|
||||||
|
description = "Slack channel ID for meal order notifications. Written to /meal-order-manager/slack-channel-id."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
26
terraform/versions.tf
Normal file
26
terraform/versions.tf
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
terraform {
|
||||||
|
required_version = ">= 1.7.0"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
aws = {
|
||||||
|
source = "hashicorp/aws"
|
||||||
|
version = "~> 6.57"
|
||||||
|
}
|
||||||
|
archive = {
|
||||||
|
source = "hashicorp/archive"
|
||||||
|
version = "~> 2.0"
|
||||||
|
}
|
||||||
|
external = {
|
||||||
|
source = "hashicorp/external"
|
||||||
|
version = "~> 2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cloud {
|
||||||
|
organization = "seahaven"
|
||||||
|
|
||||||
|
workspaces {
|
||||||
|
name = "meal-order-manager-prod"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue