mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 04:13:12 +00:00
feat(infra): migrate meal-order-manager to HCP Terraform
Freeze SAM CD and add greenfield Terraform for seahaven-prod so HCP is the sole stack deploy path.
This commit is contained in:
parent
b595744882
commit
40ea4ed898
31 changed files with 2313 additions and 43 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 }}
|
||||
43
.github/workflows/weekly-menu.yml
vendored
43
.github/workflows/weekly-menu.yml
vendored
|
|
@ -63,30 +63,31 @@ jobs:
|
|||
if: env.SKIP_RUN != 'true'
|
||||
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'
|
||||
id: stack
|
||||
run: |
|
||||
API_URL=$(aws cloudformation describe-stacks \
|
||||
--stack-name meal-order-manager \
|
||||
--query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \
|
||||
--output text)
|
||||
FORM_BUCKET=$(aws cloudformation describe-stacks \
|
||||
--stack-name meal-order-manager \
|
||||
--query 'Stacks[0].Outputs[?OutputKey==`FormBucketName`].OutputValue' \
|
||||
--output text)
|
||||
DIST_ID=$(aws cloudformation describe-stacks \
|
||||
--stack-name meal-order-manager \
|
||||
--query 'Stacks[0].Outputs[?OutputKey==`DistributionId`].OutputValue' \
|
||||
--output text)
|
||||
FORM_URL=$(aws cloudformation describe-stacks \
|
||||
--stack-name meal-order-manager \
|
||||
--query 'Stacks[0].Outputs[?OutputKey==`FormUrl`].OutputValue' \
|
||||
--output text)
|
||||
echo "api_url=$API_URL" >> $GITHUB_OUTPUT
|
||||
echo "form_bucket=$FORM_BUCKET" >> $GITHUB_OUTPUT
|
||||
echo "dist_id=$DIST_ID" >> $GITHUB_OUTPUT
|
||||
echo "form_url=$FORM_URL" >> $GITHUB_OUTPUT
|
||||
set -euo pipefail
|
||||
get_param() {
|
||||
aws ssm get-parameter --name "$1" --query 'Parameter.Value' --output text
|
||||
}
|
||||
API_URL=$(get_param /meal-order-manager/deploy/api-url)
|
||||
FORM_BUCKET=$(get_param /meal-order-manager/deploy/form-bucket)
|
||||
DIST_ID=$(get_param /meal-order-manager/deploy/distribution-id)
|
||||
FORM_URL=$(get_param /meal-order-manager/deploy/form-url)
|
||||
for v in "$API_URL" "$FORM_BUCKET" "$DIST_ID" "$FORM_URL"; do
|
||||
if [ -z "$v" ] || [ "$v" = "None" ]; then
|
||||
echo "A /meal-order-manager/deploy/* parameter is missing; has Terraform been applied?" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "api_url=$API_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "form_bucket=$FORM_BUCKET" >> "$GITHUB_OUTPUT"
|
||||
echo "dist_id=$DIST_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "form_url=$FORM_URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Get discount settings
|
||||
if: env.SKIP_RUN != 'true'
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -8,3 +8,6 @@ output/
|
|||
.aws-sam/
|
||||
samconfig.toml
|
||||
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
|
||||
}
|
||||
}
|
||||
32
terraform/data.tf
Normal file
32
terraform/data.tf
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
406
terraform/iam.tf
Normal file
406
terraform/iam.tf
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
# 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.
|
||||
data "aws_iam_policy_document" "apigateway_assume" {
|
||||
statement {
|
||||
effect = "Allow"
|
||||
actions = ["sts:AssumeRole"]
|
||||
|
||||
principals {
|
||||
type = "Service"
|
||||
identifiers = ["apigateway.amazonaws.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
# SES does not support resource-level permissions for SendRawEmail; the
|
||||
# sender identity is enforced by SES verification, not IAM. Matches
|
||||
# template.yaml.
|
||||
statement {
|
||||
sid = "SendPayrollReport"
|
||||
effect = "Allow"
|
||||
actions = ["ses:SendRawEmail"]
|
||||
resources = ["*"]
|
||||
}
|
||||
}
|
||||
|
||||
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