feat(api): serve meals on ECS Fargate instead of Lambda (PLAT-215) (#199)
Some checks are pending
Deploy API / Resolve target (push) Waiting to run
Deploy API / Deploy API to (push) Blocked by required conditions

* feat(api): serve meals on ECS Fargate instead of Lambda

Keep the Flask app always-on with in-process jobs so CloudFront no longer fronts a cold-start API Gateway.

* fix(jobs): run delayed close and reminder deliveries

Wall-clock skip windows dropped the only weekly SQS attempt when Scheduler already fired in Eastern time. Dev schedules stay disabled.

* fix(api): return JSON objects and stop logging job payloads

Flask now jsonify-s handler dicts so API responses are not HTML, and the worker logs only event and status.

* fix(ci): restore the reusable workflow so the required check is named ci / ci

Inlining the job reported `ci` instead of the org ruleset's `ci / ci`.

* fix(secrets): drop unused os import so ruff check passes

* style: apply ruff format so ci-python-app lint passes

* fix(infra): give meals its own VPC because prod has none

* chore(security): re-key ALB SG checkov suppression after vpc.tf
This commit is contained in:
Adam Moussa 2026-09-21 19:34:24 +00:00 • committed by GitHub
parent 12df37dab8
commit f48a82c476
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 2264 additions and 3463 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
.git
.github
.venv
**/__pycache__
**/*.pyc
.pytest_cache
tests
terraform
output
functions
docs
*.md
infra

View file

@ -1,68 +0,0 @@
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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12.14"
- 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

View file

@ -9,9 +9,11 @@ permissions:
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@9781774f04b824b1182ff41638687f1c01c04361 # v1.0.11
with:
run-tests: false
python-version: "3.12.14"
requirements: "requirements-api.txt"
collect-only: false
template-js:
runs-on: ubuntu-latest
@ -48,6 +50,7 @@ jobs:
shell: bash
run: |
pip install pytest
pip install -r requirements-api.txt
while IFS= read -r -d '' req; do
pip install -r "$req"
done < <(find . -name requirements.txt -not -path './.aws-sam/*' -print0)

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

@ -0,0 +1,237 @@
name: Deploy API
# Fargate image CD (PLAT-215). GitHub Actions builds the Flask image, pushes
# to ECR, and registers a new task definition. Terraform owns the cluster,
# service, ALB, and ignores container_definitions / task_definition.
#
# push to main -> dev, at github.sha
# release: published -> prod, at the release tag
# workflow_dispatch -> chosen environment at a chosen ref
#
# Releases are cut by a human with `gh release create vX.Y.Z --target main`.
# Nothing here creates an HCP run.
on:
push:
branches: [main]
paths-ignore:
- "terraform/**"
- "docs/**"
- "*.md"
- ".github/workflows/weekly-menu.yml"
- ".github/workflows/ci.yml"
- ".github/workflows/ci-terraform.yaml"
release:
types: [published]
workflow_dispatch:
inputs:
environment:
description: "Target Environment"
required: true
type: choice
options: [dev, prod]
ref:
description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref."
required: false
type: string
default: ""
permissions:
contents: read
jobs:
target:
name: Resolve target
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
environment: ${{ steps.resolve.outputs.environment }}
ref: ${{ steps.resolve.outputs.ref }}
steps:
- id: resolve
env:
EVENT_NAME: ${{ github.event_name }}
GITHUB_REF_NAME_IN: ${{ github.ref }}
GITHUB_SHA_IN: ${{ github.sha }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
INPUT_ENVIRONMENT: ${{ inputs.environment }}
INPUT_REF: ${{ inputs.ref }}
run: |
set -euo pipefail
case "${EVENT_NAME}" in
push)
if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
echo "push deploys only run from main" >&2
exit 1
fi
environment=dev
ref="${GITHUB_SHA_IN}"
;;
release)
environment=prod
ref="${RELEASE_TAG}"
status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)"
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2
exit 1
fi
;;
workflow_dispatch)
environment="${INPUT_ENVIRONMENT}"
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
;;
*)
echo "unsupported event ${EVENT_NAME}" >&2
exit 1
;;
esac
{
echo "environment=${environment}"
echo "ref=${ref}"
} >> "${GITHUB_OUTPUT}"
echo "Deploying ${ref} to ${environment}"
deploy:
name: Deploy API to ${{ needs.target.outputs.environment }}
needs: target
runs-on: ubuntu-latest
timeout-minutes: 30
environment: ${{ needs.target.outputs.environment }}
concurrency:
group: deploy-api-${{ needs.target.outputs.environment }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
AWS_REGION: us-east-1
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.target.outputs.ref }}
persist-credentials: false
- name: Resolve commit
id: commit
run: |
set -euo pipefail
sha="$(git rev-parse HEAD)"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
echo "Building ${sha}"
- name: Configure AWS credentials using OIDC
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
with:
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
aws-region: us-east-1
audience: sts.amazonaws.com
- name: Get deploy parameters
id: deploy
run: |
set -euo pipefail
get_param() {
aws ssm get-parameter --name "$1" --query Parameter.Value --output text
}
CLUSTER=$(get_param /meal-order-manager/deploy/cluster)
SERVICE=$(get_param /meal-order-manager/deploy/service)
FAMILY=$(get_param /meal-order-manager/deploy/task-family)
ECR=$(get_param /meal-order-manager/deploy/ecr-repository)
CONTAINER=$(get_param /meal-order-manager/deploy/container-name)
API_URL=$(get_param /meal-order-manager/deploy/api-url)
{
echo "cluster=${CLUSTER}"
echo "service=${SERVICE}"
echo "family=${FAMILY}"
echo "ecr=${ECR}"
echo "container=${CONTAINER}"
echo "api_url=${API_URL}"
} >> "${GITHUB_OUTPUT}"
- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
- name: Build and push image
env:
ECR: ${{ steps.deploy.outputs.ecr }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
ENVIRONMENT: ${{ needs.target.outputs.environment }}
run: |
set -euo pipefail
docker build \
--build-arg "GIT_SHA=${GIT_SHA}" \
-t "${ECR}:${GIT_SHA}" \
-t "${ECR}:${ENVIRONMENT}" \
.
docker push "${ECR}:${GIT_SHA}"
docker push "${ECR}:${ENVIRONMENT}"
- name: Register task definition and update service
env:
CLUSTER: ${{ steps.deploy.outputs.cluster }}
SERVICE: ${{ steps.deploy.outputs.service }}
FAMILY: ${{ steps.deploy.outputs.family }}
CONTAINER: ${{ steps.deploy.outputs.container }}
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
GIT_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
aws ecs describe-task-definition \
--task-definition "${FAMILY}" \
--query taskDefinition \
--output json \
| python3 -c '
import json, os, sys
td = json.load(sys.stdin)
for key in (
"taskDefinitionArn",
"revision",
"status",
"requiresAttributes",
"compatibilities",
"registeredAt",
"registeredBy",
"deregisteredAt",
):
td.pop(key, None)
image = os.environ["IMAGE"]
sha = os.environ["GIT_SHA"]
name = os.environ["CONTAINER"]
for container in td["containerDefinitions"]:
if container["name"] != name:
continue
container["image"] = image
env = {item["name"]: item["value"] for item in container.get("environment", [])}
env["GIT_SHA"] = sha
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
container.pop("command", None)
json.dump(td, sys.stdout)
' > /tmp/task-def.json
REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)"
aws ecs update-service \
--cluster "${CLUSTER}" \
--service "${SERVICE}" \
--task-definition "${FAMILY}:${REV}" \
--force-new-deployment \
>/dev/null
aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}"
- name: Verify health SHA
env:
API_URL: ${{ steps.deploy.outputs.api_url }}
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
run: |
set -euo pipefail
for _ in 1 2 3 4 5 6; do
BODY="$(curl -fsS "${API_URL}/api/health" || true)"
echo "${BODY}"
if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then
exit 0
fi
sleep 10
done
echo "health SHA did not match ${EXPECTED_SHA}" >&2
exit 1

View file

@ -96,7 +96,13 @@ jobs:
env:
API_URL: ${{ steps.stack.outputs.api_url }}
run: |
SETTINGS=$(python3 scripts/upload_menu.py settings --api-url "$API_URL")
set -euo pipefail
MEALS_PUBLISH_KEY=$(aws ssm get-parameter \
--name /meal-order-manager/publish-key \
--with-decryption \
--query 'Parameter.Value' \
--output text)
SETTINGS=$(MEALS_PUBLISH_KEY="$MEALS_PUBLISH_KEY" python3 scripts/upload_menu.py settings --api-url "$API_URL")
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")
echo "bulk_discount=$BULK" >> "$GITHUB_OUTPUT"
@ -119,13 +125,13 @@ jobs:
- name: Generate order form
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: |
# Relative /api paths so the form stays same-origin on CloudFront
# after the ALB origin swap. Do not bake the ALB DNS into HTML.
python3 src/server/generate_form.py \
--api-url "$API_URL" \
--bulk-discount "$BULK_DISCOUNT" \
--company-subsidy "$COMPANY_SUBSIDY" \
--google-client-id "$GOOGLE_CLIENT_ID"
@ -134,7 +140,14 @@ jobs:
if: env.SKIP_RUN != 'true'
env:
API_URL: ${{ steps.stack.outputs.api_url }}
run: python3 scripts/upload_menu.py publish --api-url "$API_URL"
run: |
set -euo pipefail
MEALS_PUBLISH_KEY=$(aws ssm get-parameter \
--name /meal-order-manager/publish-key \
--with-decryption \
--query 'Parameter.Value' \
--output text)
MEALS_PUBLISH_KEY="$MEALS_PUBLISH_KEY" python3 scripts/upload_menu.py publish --api-url "$API_URL"
- name: Upload form to S3
if: env.SKIP_RUN != 'true'

View file

@ -3,6 +3,14 @@
{
"id": "gitleaks-generic-api-key-45",
"justification": "False positive. tests/test_submit_order.py defines a synthetic Google OAuth audience used only for mocked token verification. OAuth client IDs are public identifiers, this fixture is not a credential, and the tests make no real Google or AWS calls."
},
{
"id": "checkov-CKV_AWS_260-39",
"justification": "The meals API ALB is the CloudFront HTTP origin for orders.seahaven.com. TLS and WAF terminate at CloudFront. Restricting the security group to the CloudFront managed prefix list would block GitHub-hosted weekly-menu HMAC publish, which must call the origin with X-Meals-Publish-Key. Application gates are HMAC on /api/publish, Cognito or Google Bearer on admin, and public submit only. Accepted as the HTTP-origin design for PLAT-215; TLS on the ALB is a follow-up."
},
{
"id": "checkov-CKV_AWS_111-40",
"justification": "githubdeploy-meal-order-manager needs ecr:GetAuthorizationToken and ecs:RegisterTaskDefinition, both of which AWS documents as Resource *. Trust is pinned to environment:dev or environment:prod plus job_workflow_ref on deploy-api.yaml at refs/heads/main or refs/tags/v*. iam:PassRole is limited to the ECS task and execution roles. ECR push and SSM parameter reads are already resource-scoped."
}
]
}

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /app
COPY src/shared/requirements.txt /tmp/shared-requirements.txt
COPY requirements-api.txt /tmp/requirements-api.txt
RUN pip install --no-cache-dir -r /tmp/shared-requirements.txt -r /tmp/requirements-api.txt
COPY src /app/src
ARG GIT_SHA=dev
ENV PYTHONPATH=/app/src:/app/src/shared \
GIT_SHA=${GIT_SHA} \
PYTHONUNBUFFERED=1
WORKDIR /app/src
EXPOSE 8080
CMD ["python", "-m", "server.entrypoint"]

126
README.md
View file

@ -1,7 +1,7 @@
# meal-order-manager
![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white)
![AWS SAM](https://img.shields.io/badge/AWS-SAM-FF9900?logo=amazonaws&logoColor=white)
![AWS](https://img.shields.io/badge/AWS-ECS-FF9900?logo=amazonaws&logoColor=white)
![Slack](https://img.shields.io/badge/Slack-integration-4A154B?logo=slack&logoColor=white)
![CI](https://github.com/Sea-Haven-Industries/meal-order-manager/actions/workflows/ci.yml/badge.svg)
@ -10,17 +10,17 @@ Automates weekly meal ordering from [Redefine Meals](https://www.redefinemeals.c
## Architecture
```
Monday 7:30am ET Employees (Mon–Thu) Thursday 6pm ET
Monday 7:30am ET Employees (Mon–Thu) Thursday 11:59pm ET
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ GitHub Actions │ │ orders.seahaven │ │ EventBridge │
│ - Scrape menu │────S3 upload───▶│ ind.com │ │ - Close form │
│ - Generate form │ + signed API │ (CloudFront+S3) │──POST───┐ │ - Aggregate │
│ - Slack notify │ └──────────────────┘ │ │ - Slack summary │
└─────────────────┘ ▼ └──────────────────┘
┌──────────┐
Thu 10am: Slack DM │ API GW + │
reminders to employees │ Lambda │
who haven't ordered │ submit │
│ - Scrape menu │────S3 upload───▶│ .com │ │ Scheduler (ET) │
│ - HMAC publish │ │ (CloudFront+S3) │──POST───┐ │ → jobs SQS │
│ - Slack notify │ └──────────────────┘ │ └─────────┬────────┘
└─────────────────┘ ▼ │
┌──────────┐ │
Thu 10am: Slack DM │ ALB + │◀──────────┘
reminders to employees │ Fargate │
who haven't ordered │ Flask │
└────┬─────┘
▼
┌──────────┐
@ -29,6 +29,8 @@ who haven't ordered │ submit │
└──────────┘
```
The production HTTP app is `src/server/app.py` (gunicorn). Close, aggregate/PDF, Slack reminder, and roster sync run in the same task from a dedicated SQS consumer (`src/server/worker.py`). Playwright scrape stays in GitHub Actions.
### Form frontend decisions
- **Theme:** Retain the current order-form palette and typography. There is no shared Sea Haven web design system to adopt, and changing the theme without one would be an isolated visual redesign. Future theme changes should remap the existing CSS custom properties instead of adding scattered color values or inline styles.
@ -42,30 +44,22 @@ the generated HTML, and the generated deployment artifact remains self-contained
| When | What | How |
|------|------|-----|
| Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge → Lambda → DynamoDB |
| Monday 7:30am ET | Scrape menu, generate form, upload to S3, post link to Slack | GitHub Actions cron |
| Mon–Thu | Employees visit `orders.seahaven.com` and submit orders | S3 static form → API Gateway → Lambda → DynamoDB |
| Thursday 10am ET | DM employees who haven't ordered yet | EventBridge → Lambda → Slack DM |
| Thursday 6pm ET | Close form, aggregate orders, write CSV reports + weekly summary PDF, post Redefine order summary to Slack | EventBridge → Lambda chain |
| Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge Scheduler → jobs SQS → Fargate |
| Monday 7:30am ET | Scrape menu, generate form, HMAC-publish menu, upload form to S3, post link to Slack | GitHub Actions cron |
| Mon–Thu | Employees visit `orders.seahaven.com` and submit orders | S3 form → CloudFront `/api/*` → ALB → Flask → DynamoDB |
| Thursday 10am ET | DM employees who haven't ordered yet | EventBridge Scheduler → jobs SQS |
| Thursday 11:59pm ET | Close form, aggregate orders, write CSV reports + weekly summary PDF, post Redefine order summary to Slack | EventBridge Scheduler → jobs SQS |
### Weekly menu publication boundary
The scheduled GitHub workflow has no DynamoDB permissions. It signs two requests
with its short-lived OIDC role credentials:
The scheduled GitHub workflow has no DynamoDB permissions. It sends two HMAC
requests with `X-Meals-Publish-Key` from Parameter Store to the ALB:
- `GET /api/publish/settings` returns only the bulk discount and company subsidy.
- `POST /api/publish/menu` validates and writes the current Eastern-time week's menu.
Both routes use API Gateway `AWS_IAM` authorization and invoke the existing
submit-order Lambda. The GitHub role can invoke only these method and path
combinations. Employee orders, the roster, admin configuration, and all direct
DynamoDB actions remain inaccessible to the role.
Deploy the API routes before switching the workflow and IAM policy. No data
migration is required because the Lambda writes the existing `WEEK#...` / `MENU`
record shape. To roll back, restore the previous workflow and its DynamoDB policy
together; restoring only the policy does not make the API-based workflow depend on
DynamoDB access.
Publish routes are omitted from CloudFront. The generated form uses relative
`/api/...` paths so it stays same-origin on `orders.seahaven.com`.
### Reports (written to `meal-order-manager-reports-*` at Thursday close)
@ -77,37 +71,31 @@ DynamoDB access.
## AWS Resources
Stack name: `meal-order-manager` (us-east-1)
Workspace: `meal-order-manager-prod` / `meal-order-manager-dev` (us-east-1)
- **ECS Fargate** — Flask + gunicorn + SQS job consumer. Desired count 2 in prod, 1 in dev.
- **ALB** — origin for CloudFront `/api` behaviors and weekly-menu HMAC publish. Idle timeout 120s.
- **ECR** — API image. GitHub Actions `deploy-api.yaml` owns the image; Terraform ignores `container_definitions`.
- **S3** — `meal-order-manager-form-*` (static form hosting), `meal-order-manager-reports-*` (CSV reports + weekly summary PDF)
- **CloudFront** — HTTPS distribution with custom domain `orders.seahaven.com`
- **CloudFront** — HTTPS distribution with custom domain `orders.seahaven.com`. API origin is the ALB (HTTP-only).
- **DynamoDB** — `meal-order-manager-orders` (orders, menu, roster, config)
- **API Gateway** — HttpApi for order submission and admin operations
- **Lambda** — 6 functions: submit-order, admin-authorizer, close-form, aggregate-orders, slack-notifier, sync-roster
- **EventBridge** — scheduled rules (dual EST/EDT) for close, reminders, roster sync
- **SQS** — `meal-order-manager-jobs` (+ DLQ). EventBridge Scheduler in `America/New_York` enqueues close, reminder, and roster sync.
- **Secrets Manager** — Slack bot token
- Migration note: the existing `meal-order-manager/form-api-key` secret remains until this change is deployed and verified, then must be deleted during post-deploy cleanup.
- **CloudWatch Alarms** — coverage across the stack, all notifying the shared `site-alerts` SNS topic (see Monitoring)
- **HCP Terraform** — workspace `meal-order-manager-prod` in project `seahaven-prod` is the sole apply path. Working directory `terraform/`. VCS file triggers use `trigger-patterns = [terraform/**/*, src/**/*, functions/**/*]` because `terraform/build_packages.sh` packages the shared layer from `src/shared` and the handlers from `functions/`. A `src/`-only or `functions/`-only merge must still queue a run. Do not `terraform apply` locally to prod.
- **CloudWatch Alarms** — ALB 5xx, ECS CPU, jobs DLQ, DynamoDB throttles, all notifying `site-alerts`
- **HCP Terraform** — workspace `meal-order-manager-<env>` in project `seahaven-<env>`. Working directory `terraform/`. VCS file triggers should be `terraform/**` only after the image deploy workflow owns `src/`. Do not `terraform apply` locally to prod.
GitHub Environments `dev` and `prod` need `DEPLOY_ROLE_ARN` (the `githubdeploy-meal-order-manager` output). Prod requires reviewers and branch policy `main` plus `v*`.
## Monitoring
CloudWatch alarms are defined in `template.yaml`. Every alarm sends to the shared
`site-alerts` SNS topic (`arn:aws:sns:us-east-1:328440206208:site-alerts`), has no
OKActions, and treats missing data as not breaching (so idle/cron functions don't
sit in ALARM between runs). Alarm names follow `meal-order-manager-<fn>-<signal>`.
CloudWatch alarms are defined in `terraform/alarms.tf`. Prod alarms send to the shared
`site-alerts` SNS topic, have no OKActions, and treat missing data as not breaching.
- **Lambda Errors / Throttles** — one alarm each per function (6 functions), Sum
over 5 min, fires on any error/throttle (threshold 0).
- **Lambda Duration** — p99 over 5 min at ~80% of each function's timeout.
API-fronted functions (submit-order, admin-authorizer) evaluate 3/3 datapoints;
cron/async functions evaluate a single datapoint.
- **DynamoDB orders table** — `ReadThrottleEvents` and `WriteThrottleEvents`
(TableName dimension). DynamoDB does not publish `ThrottledRequests`/`SystemErrors`
at the table-only dimension, so those are intentionally not alarmed.
- **API Gateway (OrderApi, HTTP API v2)** — 5xx (threshold 0), 4xx (threshold 20,
3/2 datapoints to absorb routine 401s from the token authorizer), and p99 Latency
(~3000ms). The submit route is limited to 5 requests/second with a burst of 10.
- **ALB target 5xx** — Sum over 5 min, threshold 0.
- **ECS CPU** — Average over 5 min above 80 percent, 2 evaluation periods.
- **Jobs DLQ** — visible messages, threshold 0.
- **DynamoDB orders table** — `ReadThrottleEvents` and `WriteThrottleEvents`.
- Submit is throttled in the Flask process at 5 requests/second per worker.
## Authentication
@ -147,21 +135,21 @@ The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This pr
### Local development
Local `:5050` is the same Flask app as production. DynamoDB is used when AWS credentials are present. Portal `VITE_MEALS_API_BASE` can keep pointing at `http://127.0.0.1:5050`.
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install -r requirements.txt -r requirements-api.txt
playwright install chromium
PYTHONPATH=src:src/shared python3 -m server.app
```
### Deploy to AWS
```bash
cp samconfig.toml.example samconfig.toml
# Edit samconfig.toml with your certificate ARN, etc.
sam build
sam deploy
```
Image deploys are GitHub Actions `deploy-api.yaml` (push to `main` → dev, GitHub Release → prod). Infrastructure applies through HCP Terraform. First apply of the new `tf-managed` IAM policies needs the hcptf-bootstrap window.
Rollback is `workflow_dispatch` of `deploy-api.yaml` at the previous tag. Terraform does not revert the image.
### Post-deploy
@ -173,12 +161,12 @@ sam deploy
## Local Workflow (no AWS)
The scraper, form generator, Flask server, and aggregator still work locally:
The scraper, form generator, Flask server, and aggregator still work locally. Order persistence in this app is DynamoDB; file-backed orders are not the happy path.
```bash
python3 src/scraper/scrape_menu.py # scrape menu
python3 src/server/generate_form.py # generate form (local mode)
python3 src/server/app.py # serve on localhost:5050
PYTHONPATH=src:src/shared python3 -m server.app # serve on localhost:5050
python3 src/aggregator/aggregate.py # generate CSV reports
```
@ -196,27 +184,21 @@ python3 src/aggregator/aggregate.py # generate CSV reports
```
meal-order-manager/
├── .github/workflows/
│ ├── weekly-menu.yml # Monday cron: scrape + publish + notify
│ ├── weekly-menu.yml # Monday cron: scrape + HMAC publish + notify
│ ├── deploy-api.yaml # Image CD to Fargate
│ ├── ci.yml # PR checks
│ └── deploy.yml # Push to main: sam deploy
├── terraform/ # HCP Terraform (workspace meal-order-manager-prod)
│ └── ci-terraform.yaml # terraform fmt / validate
├── terraform/ # HCP Terraform (cluster, ALB, ECR, jobs queue)
├── Dockerfile
├── src/
│ ├── scraper/ # Playwright menu scraper
│ ├── server/ # Form generator + local Flask server
│ ├── server/ # Flask API, form generator, job handlers
│ ├── aggregator/ # Order aggregation + CSV reports
│ └── shared/shared/ # Lambda layer (db, secrets, slack, pdf helpers)
├── functions/ # Lambda handlers
│ ├── submit_order/
│ ├── close_form/
│ ├── aggregate_orders/
│ ├── slack_notifier/
│ └── sync_roster/
│ └── shared/shared/ # db, secrets, slack, pdf helpers
├── scripts/ # CI/CD helper scripts
│ ├── upload_menu.py
│ ├── notify_slack.py
│ └── seed_roster.py
├── template.yaml # SAM template
├── samconfig.toml.example
├── slack-app-manifest.yml # Slack app manifest (paste into api.slack.com)
└── config.json
```

View file

@ -1 +0,0 @@
boto3==1.43.97

View file

@ -1 +0,0 @@
boto3==1.43.97

View file

@ -1,41 +0,0 @@
import json
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import boto3
from shared.db import current_week, get_form_status, set_form_status
_lambda = boto3.client("lambda")
EASTERN = ZoneInfo("America/New_York")
def lambda_handler(event, context):
now_et = datetime.now(EASTERN)
# EventBridge can fire slightly after midnight ET; accept Thu 23:xx or Fri 00–03
# ET so a delayed cron still closes the form. Idempotency: already-closed is a no-op.
in_close_window = (now_et.weekday() == 3 and now_et.hour == 23) or (
now_et.weekday() == 4 and now_et.hour < 4
)
if not in_close_window:
return {
"status": "skipped",
"reason": "outside close window (must be Thu 23:xx or Fri 00–03 ET)",
}
week = event.get("week", current_week())
status = get_form_status(week)
if status == "closed":
return {"status": "already_closed", "week": week}
set_form_status(week, "closed")
_lambda.invoke(
FunctionName=os.environ["AGGREGATE_FUNCTION_ARN"],
InvocationType="Event",
Payload=json.dumps({"week": week}),
)
return {"status": "closed", "week": week, "aggregate_triggered": True}

View file

@ -1 +0,0 @@
boto3==1.43.97

View file

@ -1 +0,0 @@
boto3==1.43.97

View file

@ -1 +0,0 @@
boto3==1.43.97

View file

@ -1,39 +0,0 @@
# 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`.

View file

@ -1,26 +0,0 @@
{
"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": "*"
}
]
}

View file

@ -1,19 +0,0 @@
{
"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"
}
}
}
]
}

6
requirements-api.txt Normal file
View file

@ -0,0 +1,6 @@
flask==3.1.3
gunicorn==23.0.0
boto3==1.43.97
jinja2==3.1.6
fpdf2==2.8.8
PyJWT[crypto]==2.14.0

View file

@ -1,10 +0,0 @@
version = 0.1
[default.deploy.parameters]
stack_name = "meal-order-manager"
resolve_s3 = true
s3_prefix = "meal-order-manager"
region = "us-east-1"
confirm_changeset = true
capabilities = "CAPABILITY_IAM"
parameter_overrides = "CustomDomain=orders.seahaven.com CertificateArn=arn:aws:acm:us-east-1:328440206208:certificate/CHANGE-ME"

View file

@ -1,4 +1,4 @@
"""Call the IAM-protected weekly-menu publication API with SigV4."""
"""Call the HMAC-protected weekly-menu publication API."""
import argparse
import json
@ -8,43 +8,29 @@ import urllib.error
import urllib.request
from pathlib import Path
import boto3
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
OUTPUT_DIR = PROJECT_ROOT / "output"
def signed_request(
def api_request(
api_url: str,
path: str,
method: str = "GET",
body: dict | None = None,
region: str = "us-east-1",
publish_key: str = "",
) -> dict:
if not api_url.startswith(("http://", "https://")):
raise ValueError(f"api_url must use http(s) scheme: {api_url!r}")
data = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"} if data is not None else {}
request = AWSRequest(
method=method,
url=f"{api_url.rstrip('/')}{path}",
headers = {}
if data is not None:
headers["Content-Type"] = "application/json"
if publish_key:
headers["X-Meals-Publish-Key"] = publish_key
http_request = urllib.request.Request(
f"{api_url.rstrip('/')}{path}",
data=data,
headers=headers,
)
credentials = boto3.Session().get_credentials()
if credentials is None:
raise RuntimeError("AWS credentials are required")
SigV4Auth(credentials.get_frozen_credentials(), "execute-api", region).add_auth(
request
)
prepared = request.prepare()
http_request = urllib.request.Request(
prepared.url,
data=prepared.body,
headers=dict(prepared.headers),
method=method,
)
try:
@ -57,23 +43,25 @@ def signed_request(
) from exc
def get_settings(api_url: str, region: str) -> dict:
return signed_request(api_url, "/api/publish/settings", method="GET", region=region)
def get_settings(api_url: str, publish_key: str) -> dict:
return api_request(
api_url, "/api/publish/settings", method="GET", publish_key=publish_key
)
def publish_menu(api_url: str, region: str) -> dict:
def publish_menu(api_url: str, publish_key: str) -> dict:
files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True)
if not files:
raise RuntimeError("No menu JSON found. Run scrape_menu.py first.")
with files[0].open() as menu_file:
menu = json.load(menu_file)
return signed_request(
return api_request(
api_url,
"/api/publish/menu",
method="POST",
body=menu,
region=region,
publish_key=publish_key,
)
@ -81,14 +69,18 @@ def main():
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=("settings", "publish"))
parser.add_argument("--api-url", required=True)
parser.add_argument("--region", default=os.environ.get("AWS_REGION", "us-east-1"))
parser.add_argument(
"--publish-key",
default=os.environ.get("MEALS_PUBLISH_KEY", ""),
help="Shared secret for /api/publish/* (or MEALS_PUBLISH_KEY)",
)
args = parser.parse_args()
try:
result = (
get_settings(args.api_url, args.region)
get_settings(args.api_url, args.publish_key)
if args.action == "settings"
else publish_menu(args.api_url, args.region)
else publish_menu(args.api_url, args.publish_key)
)
except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
print(f"Error: {exc}", file=sys.stderr)

1
src/server/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Production Flask API package."""

View file

@ -1,377 +1,133 @@
"""
Lightweight Flask server for the meal order form.
"""Production Flask app for meal-order-manager.
Serves the generated HTML form and handles order submissions.
Orders are saved as JSON files in the orders directory, one per employee per week.
Local: PYTHONPATH=src:src/shared python3 -m server.app
Prod: gunicorn server.wsgi:app
"""
import json
import time
import urllib.request
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from __future__ import annotations
import os
from pathlib import Path
import boto3
from flask import Flask, jsonify, request, send_file
import json
from flask import Flask, Response, jsonify, request, send_file
from server import http_api
CORS_ORIGINS = [
"https://orders.seahaven.com",
"https://internal.seahaven.com",
"https://internal.dev.seahaven.com",
"http://localhost:5173",
"http://localhost:4173",
"http://127.0.0.1:5173",
"http://127.0.0.1:5050",
]
PROJECT_ROOT = Path(__file__).resolve().parents[2]
CONFIG_PATH = PROJECT_ROOT / "config.json"
OUTPUT_DIR = PROJECT_ROOT / "output"
ORDERS_DIR = PROJECT_ROOT / "orders"
app = Flask(__name__)
def load_config():
with open(CONFIG_PATH) as f:
return json.load(f)
def create_app() -> Flask:
app = Flask(__name__)
extra = os.environ.get("CORS_ORIGINS", "")
origins = list(CORS_ORIGINS)
if extra:
origins.extend(o.strip() for o in extra.split(",") if o.strip())
form_url = os.environ.get("FORM_URL", "").rstrip("/")
if form_url and form_url not in origins:
origins.append(form_url)
@app.after_request
def add_cors(resp: Response) -> Response:
origin = request.headers.get("Origin", "")
if origin in origins:
resp.headers["Access-Control-Allow-Origin"] = origin
resp.headers["Vary"] = "Origin"
resp.headers["Access-Control-Allow-Headers"] = (
"Authorization, Content-Type, X-Meals-Publish-Key"
)
resp.headers["Access-Control-Allow-Methods"] = (
"GET, POST, PUT, DELETE, OPTIONS"
)
resp.headers["Access-Control-Max-Age"] = "3600"
return resp
def current_week() -> str:
return datetime.now().strftime("%Y-W%U")
def latest_menu_file() -> Path | None:
files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True)
return files[0] if files else None
def _official_menu_retail_by_name() -> dict[str, Decimal]:
menu_file = latest_menu_file()
if not menu_file:
return {}
with open(menu_file) as f:
meals = (json.load(f) or {}).get("meals") or []
out: dict[str, Decimal] = {}
for meal in meals:
name = (meal.get("name") or "").strip()
if not name or meal.get("price") is None:
continue
out[name] = Decimal(str(meal["price"]))
return out
@app.route("/")
def index():
form_file = OUTPUT_DIR / f"order-form-{current_week()}.html"
if not form_file.exists():
return "No order form generated for this week. Run generate_form.py first.", 404
return send_file(form_file)
@app.route("/api/menu")
def get_menu():
menu_file = latest_menu_file()
if not menu_file:
@app.route("/api/health")
def health():
return jsonify(
{"error": "No menu data available. Run scrape_menu.py first."}
), 404
with open(menu_file) as f:
return jsonify(json.load(f))
@app.route("/api/roster")
def get_roster():
config = load_config()
return jsonify(config.get("roster", []))
# Match functions/submit_order/handler.py: TTL so a transient SSM failure cannot
# pin client_id to "" for the process lifetime (which would skip Google auth).
_GOOGLE_CLIENT_ID_CACHE_TTL_SECONDS = 300
_google_client_id_cache: str | None = None
_google_client_id_cache_ts = 0.0
def _get_google_client_id() -> str:
global _google_client_id_cache, _google_client_id_cache_ts
now = time.monotonic()
if (
_google_client_id_cache is not None
and (now - _google_client_id_cache_ts) <= _GOOGLE_CLIENT_ID_CACHE_TTL_SECONDS
):
return _google_client_id_cache
config = load_config()
from_config = (config.get("google_client_id") or "").strip()
if from_config:
_google_client_id_cache = from_config
_google_client_id_cache_ts = now
return _google_client_id_cache
try:
ssm = boto3.client("ssm")
resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id")
_google_client_id_cache = (resp["Parameter"].get("Value") or "").strip()
except Exception:
_google_client_id_cache = ""
_google_client_id_cache_ts = now
return _google_client_id_cache
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
def _verify_google_token(token: str, client_id: str) -> dict | None:
if not client_id:
return None
try:
req = urllib.request.Request(
f"https://oauth2.googleapis.com/tokeninfo?id_token={token}"
)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read())
if data.get("aud") != client_id:
return None
if data.get("hd") not in ALLOWED_DOMAINS:
return None
return {"name": data.get("name", ""), "email": data.get("email", "")}
except Exception:
return None
@app.route("/api/submit-order", methods=["POST"])
def submit_order():
data = request.get_json()
if not data:
return jsonify({"error": "No data received"}), 400
client_id = _get_google_client_id()
google_token = data.get("google_id_token")
if client_id:
if not google_token:
return jsonify({"error": "Google authentication is required"}), 403
user_info = _verify_google_token(google_token, client_id)
if not user_info:
return jsonify({"error": "Invalid or unauthorized Google account"}), 403
name = user_info["name"]
email = user_info["email"]
else:
name = data.get("employee_name", "").strip()
email = data.get("employee_email", "").strip()
items = data.get("items", [])
if not name:
return jsonify({"error": "Employee name is required"}), 400
if not email:
return jsonify({"error": "Employee email is required"}), 400
if not items or not any(i.get("quantity", 0) > 0 for i in items):
return jsonify({"error": "Please select at least one meal"}), 400
config = load_config()
TWO_PLACES = Decimal("0.01")
bulk_pct = Decimal(str(config.get("bulk_discount_percent", 0)))
subsidy_pct = Decimal(str(config.get("company_subsidy_percent", 0)))
bulk_pct = max(Decimal("0"), min(Decimal("100"), bulk_pct))
subsidy_pct = max(Decimal("0"), min(Decimal("100"), subsidy_pct))
bulk_mult = Decimal("1") - (bulk_pct / Decimal("100"))
subsidy_mult = Decimal("1") - (subsidy_pct / Decimal("100"))
filtered = [i for i in items if i.get("quantity", 0) > 0]
official_retail = _official_menu_retail_by_name()
if not official_retail:
return jsonify({"error": "Menu temporarily unavailable"}), 503
for item in filtered:
meal_name = (item.get("name") or "").strip()
if meal_name not in official_retail:
return jsonify(
{"error": "One or more meals are not on this week's menu"}
), 400
for item in filtered:
meal_name = (item.get("name") or "").strip()
retail = official_retail[meal_name]
qty = Decimal(str(item.get("quantity", 0)))
bulk_price = (retail * bulk_mult).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
emp_price = (bulk_price * subsidy_mult).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
subtotal = (emp_price * qty).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
item["retail_price"] = float(retail)
item["bulk_price"] = float(bulk_price)
item["price"] = float(emp_price)
item["subtotal"] = float(subtotal)
week = current_week()
week_dir = ORDERS_DIR / week
week_dir.mkdir(parents=True, exist_ok=True)
# Match Lambda: one order file per employee email (not display name).
slug = email.strip().lower()
slug_safe = slug.replace("/", "_").replace("\\", "_")
order_file = week_dir / f"{slug_safe}.json"
total = float(
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
)
order = {
"employee_name": name,
"employee_email": email,
"week": week,
"submitted_at": datetime.now().isoformat(),
"items": filtered,
"total": total,
}
with open(order_file, "w") as f:
json.dump(order, f, indent=2)
return jsonify(
{"status": "ok", "message": f"Order saved for {name}", "total": order["total"]}
)
@app.route("/api/form-status/<week>")
def form_status(week: str):
return jsonify({"week": week, "status": "open"})
@app.route("/api/admin/orders")
def admin_orders():
week = request.args.get("week")
if not week:
weeks = []
for f in sorted(ORDERS_DIR.iterdir(), reverse=True):
if f.is_dir():
order_count = len(list(f.glob("*.json")))
weeks.append(
{
"week": f.name,
"form_status": "open",
"meal_count": 0,
"order_count": order_count,
}
)
return jsonify({"weeks": weeks})
week_dir = ORDERS_DIR / week
if not week_dir.exists():
return jsonify(
{"week": week, "orders": [], "total_employees": 0, "grand_total": 0}
{
"stage": os.environ.get("STAGE", "local"),
"sha": os.environ.get("GIT_SHA", "dev"),
}
)
orders = []
for f in sorted(week_dir.glob("*.json")):
with open(f) as fh:
orders.append(json.load(fh))
orders.sort(key=lambda o: o.get("employee_name", ""))
@app.route("/", methods=["GET"])
def root():
if os.environ.get("STAGE", "local") == "local":
from datetime import datetime
return jsonify(
{
"week": week,
"orders": orders,
"total_employees": len(orders),
"grand_total": round(sum(o.get("total", 0) for o in orders), 2),
form_file = OUTPUT_DIR / (
f"order-form-{datetime.now().strftime('%Y-W%U')}.html"
)
if form_file.exists():
return send_file(form_file)
return "ok", 200
def _dispatch(path: str):
if request.method == "OPTIONS":
return "", 204
qs = request.args.to_dict(flat=True)
path_params = {}
parts = path.strip("/").split("/")
if (
len(parts) >= 3
and parts[0] == "api"
and parts[1]
in {
"menu",
"orders",
"form-status",
}
):
path_params["week"] = parts[2]
event = {
"requestContext": {"http": {"method": request.method, "path": path}},
"rawPath": path,
"headers": {k: v for k, v in request.headers.items()},
"body": request.get_data(as_text=True) or "{}",
"pathParameters": path_params,
"queryStringParameters": qs or None,
}
)
result = http_api.lambda_handler(event, None)
try:
payload = json.loads(result["body"])
except (TypeError, KeyError, json.JSONDecodeError):
resp = jsonify({"error": "Internal error"})
resp.status_code = 500
return resp
if not isinstance(payload, dict):
resp = jsonify({"error": "Internal error"})
resp.status_code = 500
return resp
resp = jsonify(payload)
resp.status_code = int(result.get("statusCode") or 500)
return resp
@app.route("/api/<path:rest>", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
def api(rest: str):
return _dispatch("/api/" + rest)
return app
@app.route("/api/admin/orders", methods=["DELETE"])
def admin_delete_order():
week = request.args.get("week", "")
email = request.args.get("email", "")
if not week or not email:
return jsonify({"error": "week and email are required"}), 400
slug = email.strip().lower().replace("/", "_").replace("\\", "_")
order_file = ORDERS_DIR / week / f"{slug}.json"
if not order_file.exists():
return jsonify({"error": "Order not found"}), 404
order_file.unlink()
return jsonify({"status": "deleted", "week": week, "email": email})
app = create_app()
@app.route("/api/admin/orders", methods=["PUT"])
def admin_update_order():
data = request.get_json()
if not data:
return jsonify({"error": "No data received"}), 400
week = data.get("week", "")
email = data.get("email", "")
new_items = data.get("items", [])
if not week or not email:
return jsonify({"error": "week and email are required"}), 400
slug = email.strip().lower().replace("/", "_").replace("\\", "_")
order_file = ORDERS_DIR / week / f"{slug}.json"
if not order_file.exists():
return jsonify({"error": "Order not found"}), 404
with open(order_file) as f:
existing = json.load(f)
filtered = [i for i in new_items if i.get("quantity", 0) > 0]
if not filtered:
return jsonify({"error": "At least one item required"}), 400
config = load_config()
TWO_PLACES = Decimal("0.01")
bulk_pct = Decimal(str(config.get("bulk_discount_percent", 0)))
subsidy_pct = Decimal(str(config.get("company_subsidy_percent", 0)))
bulk_mult = Decimal("1") - (
max(Decimal("0"), min(Decimal("100"), bulk_pct)) / Decimal("100")
)
subsidy_mult = Decimal("1") - (
max(Decimal("0"), min(Decimal("100"), subsidy_pct)) / Decimal("100")
)
official_retail = _official_menu_retail_by_name()
for item in filtered:
meal_name = (item.get("name") or "").strip()
retail = official_retail.get(meal_name)
if retail is None:
return jsonify({"error": f"'{meal_name}' not on this week's menu"}), 400
qty = Decimal(str(item["quantity"]))
bulk_price = (retail * bulk_mult).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
emp_price = (bulk_price * subsidy_mult).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
subtotal = (emp_price * qty).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
item["retail_price"] = float(retail)
item["bulk_price"] = float(bulk_price)
item["price"] = float(emp_price)
item["subtotal"] = float(subtotal)
total = float(
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
TWO_PLACES, rounding=ROUND_HALF_UP
)
)
existing["items"] = filtered
existing["total"] = total
with open(order_file, "w") as f:
json.dump(existing, f, indent=2)
return jsonify({"status": "updated", "week": week, "email": email, "total": total})
@app.route("/api/orders/<week>")
def get_orders(week: str):
week_dir = ORDERS_DIR / week
if not week_dir.exists():
return jsonify({"orders": [], "week": week})
orders = []
for f in sorted(week_dir.glob("*.json")):
with open(f) as fh:
orders.append(json.load(fh))
return jsonify({"orders": orders, "week": week})
def main():
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "5050")))
if __name__ == "__main__":
ORDERS_DIR.mkdir(exist_ok=True)
print(f"Menu file: {latest_menu_file()}")
print(f"Orders dir: {ORDERS_DIR}")
app.run(host="0.0.0.0", port=5050, debug=False)
main()

71
src/server/entrypoint.py Normal file
View file

@ -0,0 +1,71 @@
"""Gunicorn + SQS worker in one task. Stay a parent so SIGTERM reaches both."""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import time
def main() -> None:
env = os.environ.copy()
worker = subprocess.Popen(
[sys.executable, "-m", "server.worker"],
env=env,
)
gunicorn = subprocess.Popen(
[
"gunicorn",
"--bind",
"0.0.0.0:8080",
"--workers",
os.environ.get("GUNICORN_WORKERS", "2"),
"--threads",
"2",
"--timeout",
"120",
"--graceful-timeout",
"30",
"--access-logfile",
"-",
"--error-logfile",
"-",
"server.wsgi:app",
],
env=env,
)
def shutdown(signum: int, _frame) -> None:
for proc in (gunicorn, worker):
if proc.poll() is None:
proc.send_signal(signum)
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
while True:
g_code = gunicorn.poll()
w_code = worker.poll()
if g_code is not None:
if worker.poll() is None:
worker.terminate()
try:
worker.wait(timeout=30)
except subprocess.TimeoutExpired:
worker.kill()
sys.exit(g_code)
if w_code is not None:
if gunicorn.poll() is None:
gunicorn.terminate()
try:
gunicorn.wait(timeout=30)
except subprocess.TimeoutExpired:
gunicorn.kill()
sys.exit(w_code or 1)
time.sleep(1)
if __name__ == "__main__":
main()

View file

@ -2,7 +2,7 @@
Generates a self-contained HTML order form from the latest scraped menu.
The form shows this week's meals with quantity selectors, a running total,
and submits orders to the backend (local Flask or cloud API Gateway).
and submits orders to the backend (local Flask or CloudFront same-origin /api).
Usage:
python3 generate_form.py # local mode
@ -56,6 +56,7 @@ def generate_form(
google_client_id: str = "",
) -> str:
google_client_id = google_client_id.strip()
api_url = (api_url or "").rstrip("/")
if api_url and not google_client_id:
raise ValueError("Google client ID is required in cloud mode")
@ -116,7 +117,7 @@ def generate_form(
def main():
parser = argparse.ArgumentParser(description="Generate meal order form HTML")
parser.add_argument(
"--api-url", default="", help="API Gateway base URL (cloud mode)"
"--api-url", default="", help="API base URL. Empty uses same-origin /api paths."
)
parser.add_argument(
"--bulk-discount",
@ -182,11 +183,15 @@ def main():
with open(output_file, "w") as f:
f.write(html)
mode = "cloud" if args.api_url else "local"
mode = "cloud" if args.api_url else ("same-origin" if google_client_id else "local")
print(f"Generated order form ({mode} mode): {output_file}")
print(f" {menu['meal_count']} meals from menu scraped {menu['scraped_at'][:10]}")
if mode == "local":
print(" Start the server with: python3 src/server/app.py")
print(
" Start the server with: PYTHONPATH=src:src/shared python3 -m server.app"
)
elif mode == "same-origin":
print(" API paths are relative /api/* on the form origin")
else:
print(f" API endpoint: {args.api_url}")

View file

@ -1,8 +1,10 @@
import hmac
import json
import logging
import os
import re
import sys
import threading
import time
import urllib.error
import urllib.request
@ -43,6 +45,11 @@ EASTERN = ZoneInfo("America/New_York")
CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values
PRESIGNED_URL_TTL_SECONDS = 300 # summary-PDF presigned URL lifetime
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
SUBMIT_RATE_PER_SEC = 5.0
_submit_lock = threading.Lock()
_submit_tokens = SUBMIT_RATE_PER_SEC
_submit_last = time.monotonic()
def _eastern_now() -> _dt.datetime:
@ -54,10 +61,16 @@ _settings = None
_settings_ts = 0.0
_google_client_id = None
_google_client_id_ts = 0.0
_lambda = boto3.client("lambda")
_s3 = boto3.client("s3")
def _dispatch_job(payload: dict) -> None:
"""Enqueue a background job. Tests patch this name in place of Lambda invoke."""
from server.jobs import enqueue_job
enqueue_job(payload)
def _get_discount_settings() -> tuple[Decimal, Decimal]:
global _settings, _settings_ts
now = time.monotonic()
@ -236,7 +249,7 @@ def lambda_handler(event, context):
return handle_menu(event)
if path == "/api/publish/settings" and method == "GET":
return handle_publish_settings()
return handle_publish_settings(event)
if path == "/api/publish/menu" and method == "POST":
return handle_publish_menu(event)
@ -300,8 +313,55 @@ def handle_menu(event):
)
def handle_publish_settings():
def _keys_match(provided: str, expected: str) -> bool:
if not provided or not expected:
return False
if len(provided) != len(expected):
hmac.compare_digest(provided, provided)
return False
return hmac.compare_digest(provided, expected)
def _allow_submit() -> bool:
"""Per-process token bucket approximating the old 5 rps API Gateway throttle."""
if os.environ.get("STAGE", "local") not in {"prod", "dev"}:
return True
global _submit_tokens, _submit_last
with _submit_lock:
now = time.monotonic()
_submit_tokens = min(
SUBMIT_RATE_PER_SEC,
_submit_tokens + (now - _submit_last) * SUBMIT_RATE_PER_SEC,
)
_submit_last = now
if _submit_tokens < 1:
return False
_submit_tokens -= 1
return True
def _require_publish_key(event) -> dict | None:
"""HMAC-style shared secret for /api/publish/*. Skip when unset (unit tests)."""
param = os.environ.get("PUBLISH_KEY_PARAM", "")
if not param:
return None
expected = get_parameter(param, decrypt=True)
if not expected:
return response(403, {"error": "Forbidden"})
headers = event.get("headers") or {}
provided = (
headers.get("x-meals-publish-key") or headers.get("X-Meals-Publish-Key") or ""
)
if not _keys_match(provided, expected):
return response(403, {"error": "Forbidden"})
return None
def handle_publish_settings(event=None):
"""Return only the pricing fields needed by the weekly-menu workflow."""
denied = _require_publish_key(event or {})
if denied:
return denied
settings = get_settings()
return response(
200,
@ -316,6 +376,9 @@ def handle_publish_settings():
def handle_publish_menu(event):
"""Persist a scraped menu for the current Eastern-time week."""
denied = _require_publish_key(event)
if denied:
return denied
try:
body = json.loads(event.get("body", "{}"))
except json.JSONDecodeError:
@ -586,6 +649,8 @@ def handle_roster():
def handle_submit(event):
if not _allow_submit():
return response(429, {"error": "Rate limit exceeded"})
try:
body = json.loads(event.get("body", "{}"))
except json.JSONDecodeError:
@ -702,23 +767,18 @@ def handle_submit(event):
# Slack notification is best-effort — order is already persisted above,
# so we return success to the user even if this invocation fails.
try:
_lambda.invoke(
FunctionName=os.environ["SLACK_NOTIFIER_ARN"],
InvocationType="Event",
Payload=json.dumps(
{
"event": "order_confirmed",
"employee_name": name,
"employee_email": email,
"items": filtered_items,
"total": order_data["total"],
"week": week,
},
default=float,
),
_dispatch_job(
{
"event": "order_confirmed",
"employee_name": name,
"employee_email": email,
"items": filtered_items,
"total": order_data["total"],
"week": week,
}
)
except Exception as exc:
logger.error("Slack notifier invocation failed (order already saved): %s", exc)
logger.error("Slack notifier dispatch failed (order already saved): %s", exc)
return response(
200,

View file

@ -0,0 +1,48 @@
"""In-process job dispatch. SQS when JOBS_QUEUE_URL is set; otherwise run inline."""
from __future__ import annotations
import json
import logging
import os
import boto3
logger = logging.getLogger(__name__)
_sqs = None
def _client():
global _sqs
if _sqs is None:
_sqs = boto3.client("sqs")
return _sqs
def enqueue_job(payload: dict) -> None:
queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip()
body = json.dumps(payload, default=str)
if not queue_url:
run_job(payload)
return
_client().send_message(QueueUrl=queue_url, MessageBody=body)
def run_job(payload: dict) -> dict:
event_type = payload.get("event", "close")
if event_type == "close":
from server.jobs.close_form import lambda_handler
return lambda_handler(payload, None)
if event_type == "aggregate":
from server.jobs.aggregate import lambda_handler
return lambda_handler(payload, None)
if event_type == "sync_roster":
from server.jobs.sync_roster import lambda_handler
return lambda_handler(payload, None)
from server.jobs.notify import lambda_handler
return lambda_handler(payload, None)

View file

@ -17,11 +17,16 @@ EASTERN = ZoneInfo("America/New_York")
logger = logging.getLogger()
logger.setLevel(logging.INFO)
_s3 = boto3.client("s3")
_lambda = boto3.client("lambda")
_sqs = boto3.client("sqs")
KIND_MEAL = "meal_deduction"
def _dispatch_job(payload: dict) -> None:
from server.jobs import enqueue_job
enqueue_job(payload)
class DecimalEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, Decimal):
@ -79,13 +84,7 @@ def lambda_handler(event, context):
except Exception:
logger.exception("checkcomponents send failed week=%s", week)
_lambda.invoke(
FunctionName=os.environ["SLACK_NOTIFIER_ARN"],
InvocationType="Event",
Payload=json.dumps(
{"event": "orders_aggregated", "week": week}, cls=DecimalEncoder
),
)
_dispatch_job({"event": "orders_aggregated", "week": week})
return {"status": "aggregated", "week": week, "total_employees": len(orders)}

View file

@ -0,0 +1,24 @@
from shared.db import current_week, get_form_status, set_form_status
def _dispatch_job(payload: dict) -> None:
from server.jobs import enqueue_job
enqueue_job(payload)
def lambda_handler(event, context):
# Scheduler fires Thursday 23:59 America/New_York. Do not skip on wall
# clock: a delayed SQS delivery must still close the week. already-closed
# is the idempotency gate for redelivery.
week = event.get("week", current_week())
status = get_form_status(week)
if status == "closed":
return {"status": "already_closed", "week": week}
set_form_status(week, "closed")
_dispatch_job({"event": "aggregate", "week": week})
return {"status": "closed", "week": week, "aggregate_triggered": True}

View file

@ -1,8 +1,6 @@
import json
import os
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo
from shared.db import current_week, get_orders, get_roster, get_settings, get_summary
from shared.slack import post_channel_message, send_dm
@ -28,10 +26,6 @@ def lambda_handler(event, context):
elif event_type == "orders_aggregated":
return handle_orders_aggregated(event)
elif event_type == "reminder":
# Guard against duplicate triggers from dual EST/EDT schedules
now_et = datetime.now(ZoneInfo("America/New_York"))
if now_et.hour != 10 or now_et.weekday() != 3: # 10am Thursday
return {"status": "skipped", "reason": "outside reminder window"}
return handle_reminder(event)
elif event_type == "order_confirmed":
return handle_order_confirmed(event)
@ -187,11 +181,9 @@ def handle_order_confirmed(event):
def handle_reminder(event):
# Guard against duplicate triggers from dual EST/EDT schedules
now_et = datetime.now(ZoneInfo("America/New_York"))
if now_et.hour != 10 or now_et.weekday() != 3: # 10am Thursday
return {"status": "skipped", "reason": "outside reminder window"}
# Scheduler fires Thursday 10:00 America/New_York. Delayed SQS delivery
# must still DM anyone who has not ordered; already-ordered emails are
# the idempotency gate for redelivery.
week = event.get("week", current_week())
form_url = os.environ.get("FORM_URL", "")

64
src/server/worker.py Normal file
View file

@ -0,0 +1,64 @@
"""SQS long-poll consumer. One process per task, not per gunicorn worker."""
from __future__ import annotations
import json
import logging
import os
import signal
import sys
import time
import boto3
from server.jobs import run_job
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
_running = True
def _stop(_signum, _frame) -> None:
global _running
_running = False
def main() -> None:
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip()
if not queue_url:
logger.info("JOBS_QUEUE_URL unset; worker idle")
while _running:
time.sleep(1)
return
sqs = boto3.client("sqs")
logger.info("Polling jobs queue")
while _running:
resp = sqs.receive_message(
QueueUrl=queue_url,
MaxNumberOfMessages=1,
WaitTimeSeconds=20,
VisibilityTimeout=180,
)
for msg in resp.get("Messages", []):
receipt = msg["ReceiptHandle"]
try:
payload = json.loads(msg["Body"])
result = run_job(payload)
status = result.get("status") if isinstance(result, dict) else None
logger.info("job event=%s status=%s", payload.get("event"), status)
if status == "skipped":
# Leave the message visible after timeout so a later
# receive can run it. Deleting here dropped the only
# weekly close/reminder when delivery landed late.
continue
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt)
except Exception:
logger.exception("job failed; leaving message for retry")
if __name__ == "__main__":
main()

5
src/server/wsgi.py Normal file
View file

@ -0,0 +1,5 @@
"""Gunicorn entrypoint."""
from server.app import app
__all__ = ["app"]

View file

@ -2,21 +2,27 @@ import time
import boto3
_secret_cache: dict[str, str] = {}
_secret_cache: dict[str, tuple[str, float]] = {}
_parameter_cache: dict[str, tuple[str, float]] = {}
_sm = boto3.client("secretsmanager")
_ssm = boto3.client("ssm")
# SSM reads use a TTL so callers (e.g. submit_order Google client ID) can refresh
# on the same cadence as their own caches. Secrets stay cached for the process lifetime.
# SSM and Secrets Manager reads use a TTL so a long-lived Fargate task
# picks up rotations without a restart.
PARAM_CACHE_TTL_SECONDS = 300.0
def get_secret(secret_id: str) -> str:
if secret_id not in _secret_cache:
resp = _sm.get_secret_value(SecretId=secret_id)
_secret_cache[secret_id] = resp["SecretString"]
return _secret_cache[secret_id]
now = time.monotonic()
entry = _secret_cache.get(secret_id)
if entry is not None:
value, cached_at = entry
if now - cached_at < PARAM_CACHE_TTL_SECONDS:
return value
resp = _sm.get_secret_value(SecretId=secret_id)
value = resp["SecretString"]
_secret_cache[secret_id] = (value, now)
return value
def get_parameter(name: str, decrypt: bool = True) -> str:

View file

@ -1,17 +1,21 @@
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from shared.secrets import get_secret, get_parameter
from shared.secrets import PARAM_CACHE_TTL_SECONDS, get_secret, get_parameter
_token = None
_token_ts = 0.0
def _get_token() -> str:
global _token
if _token is None:
global _token, _token_ts
now = time.monotonic()
if _token is None or (now - _token_ts) > PARAM_CACHE_TTL_SECONDS:
_token = get_secret(os.environ["SLACK_BOT_SM_NAME"])
_token_ts = now
return _token

File diff suppressed because it is too large Load diff

View file

@ -1,38 +1,6 @@
# 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.1"
constraints = "2.8.1"
hashes = [
"h1:KHd+q58PrZfAHh6hThm5b9z8uZ3Fy/g7F1XQTAH4WfA=",
"h1:KfIRyazppfpvRKIW5URe4sIVRPPdcbtt4ddkYd1Bw3Y=",
"h1:Lc8kS9DBvvKbl7klWyHTI406NU4mFHJcEgKN0wUaroM=",
"h1:TKUVBhC4A1uEerXrssAgudziMw3ybiecKswVsH3aDAA=",
"h1:W+SKtC8w0d/RNYlYc0Pz7IHotwlVXXiccFQ3Vs/Ykm8=",
"h1:Z4YQ0fn73Qt5AoFKeBcKYNDuWpnIRM0rVtDzV9pNhWk=",
"h1:aLNmq6dc3cDcqZc8s/8eKtn0I+UQXyJMGrmo4rRFtNw=",
"h1:bQBSVj62u4hNd6xqDLKvuQ8IbZHHmWv2d9YQrSG5QPc=",
"h1:eehhIUcuegkswQDKArYBAhVV9wQmRVMhyYGaD7kHIj0=",
"h1:qy2edUBBRcDC9frArT5EVbuShLx+EuFpb7/O7ZeRoTM=",
"h1:sVkac3fUlYGsTEO4F3x55D9zRm6xW+okSRpxi72cR/M=",
"h1:xVTMBOmMFXyLhO4yhr9an1CaRKcuiA6Wi0P8DAzUVcg=",
"zh:03de290604114a89fcd45c2e5bc7787d5a1ebfc5f964fb5989306bea7a4c79ec",
"zh:0a7d69dc9fbbc48960bc2f04588c8fb1bd92c78a8f306566b7fb17fc4a4f2058",
"zh:4df1f3981379c35f1757da957470f7f7724496b57219da3485177cf1647bdf59",
"zh:50f0e72ba53bfe6e11b03b7fc899e1f72536354381d302a743a274ae2a3f45f4",
"zh:5c4e15a04c98e2a8cafb1cd9632b48ad318051e8462d105b1153006277985c35",
"zh:66069e604bcf5c4af0278e15997d9e6bd755c54fb3801d78885838b889729c5f",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:979765db3f42601870ab377104ba70befb029547b278337ec4ada980e3582de6",
"zh:b165254da774f49945a73fbccc3ef1b63d70ea00a98fa9e14716665ba80ecaad",
"zh:c0bb2697b525da9fec4511f569ed2bd2b42f25d5c1f9fa00f8f3645b50cfc2e9",
"zh:c48f6695d12df0d0fa5231f7c1b8d518a4feae91a733fdd35a5804af3a303831",
"zh:d589954c93f075180c9f4e2ce91780d5edcb56dfd0d3cda8ba08e13c10b52249",
"zh:f5792ed06da65d0daf7ca3711f5399ff78c7cb4400afe53fbce9a926fbde4477",
]
}
provider "registry.terraform.io/hashicorp/aws" {
version = "6.65.0"
constraints = "6.65.0"
@ -71,34 +39,24 @@ provider "registry.terraform.io/hashicorp/aws" {
]
}
provider "registry.terraform.io/hashicorp/external" {
version = "2.4.2"
constraints = "2.4.2"
provider "registry.terraform.io/hashicorp/random" {
version = "3.8.1"
constraints = "3.8.1"
hashes = [
"h1:4UInMFuK4GNw4uf2vkUwwDtc0CajvJ88BkAE6xLKOa4=",
"h1:8KPRXwNezVs9S/xEpGcKkPNMez+Kv5bKJNfLWivGekY=",
"h1:B8SUNH8ToFJjQwz10rFU8k9soEhnj2OzzTwKrcH9cFI=",
"h1:ERhVaFFS4/WRonirXUJV1DWN+cSTyEKEfs2ZnoP1BgY=",
"h1:Ev3S467Z+WcjiY/MroSWX492k017jYU1eGtZLhXr9x8=",
"h1:O6uC9yKr7swQtc/kHVyaV9yETT20A39oUi5X+k/b290=",
"h1:QP724n6VWM/iitpILCwO079UOlzx6I0Ylh7g8Gwv1Y0=",
"h1:famcgOUn8RzdgcZe+GUptA59vdgh4pXzIu/y9GMX8SU=",
"h1:h5n+iCc1zwT5mKIATjIYS8hcjJxoFAPSNxPsZbZLc3Q=",
"h1:l0Z5YlRsbjRUU0+u4U8BPIDGv7PAszOrNLO9ZIxQrG4=",
"h1:rwlUbh50HZYdRQWKW12nG4+3Giu2rp+mQXjqF0NzmVg=",
"h1:tP4PPkaGoG60uUYEH3bUnYBSbhUmlk2vsh9uJbBmjUU=",
"zh:0b51793be4f66934a3666339e44c01fd56e1c6a56256dfc66d1cb391584b4c2f",
"zh:31cdd9b30e4ec63d130befc89471757ef3937b99a8f6cc006769d215365c5ba8",
"zh:61f86de4a3166cfa5da6800eeba8e6a2e4ab6403fc3d7b396508260b45d3de7d",
"h1:Eexl06+6J+s75uD46+WnZtpJZYRVUMB0AiuPBifK6Jc=",
"h1:fdfOl1HabDT42XLH8qjmfTbVZpgQZ5lyOyOa+GQhm0w=",
"h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=",
"zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4",
"zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae",
"zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57",
"zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0",
"zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66",
"zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:817e8d5946aed6ca692e0bb2f6463c28774ef8fb2fdd3922543a495a249229ea",
"zh:b35f1bd1be09ed1a1620b43ab8cb43fe93407cf419586d53fe965691cc1b4a8e",
"zh:bcb170063ec8b5728bc2a4568bc48f539a1991cdaaf31667aa35568aebc34725",
"zh:c0d2c824cc7c047f26ce793bb0cbb6746f9745d1fc6e630d34a0afb5b92850d1",
"zh:cde68f51089b02db50e2c5a17f6e132dc1ead2fc08d3dd267b8dd54ec58133fa",
"zh:d1f3c497aa41f17e8d61067122f6d94e51ef942ab08be5465489864d900854ab",
"zh:e62568bfc0934b63e14f3547c823b01ed60d7475ff16bf12c0e55d5ac8d98ba7",
"zh:ed8890c29dba2b0ac27afcefa75e7395e27253b7025aaaa4258345fc59dad23e",
"zh:f220c56c7e487f01fd158126066f6525178bbd82805b27205354bc523cc7c413",
"zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9",
"zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05",
"zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8",
"zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b",
"zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699",
]
}

View file

@ -1,127 +1,71 @@
# 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.
# CloudWatch alarms for the Fargate API and orders table.
locals {
alarm_functions = local.is_prod ? {
"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
}
} : {}
}
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[0].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[0].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[0].arn]
dimensions = {
FunctionName = each.value.function_name
}
}
# ---------------------------------------------------------------------------
# DynamoDB
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_metric_alarm" "orders_read_throttle" {
resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
count = local.is_prod ? 1 : 0
alarm_name = "${local.project}-orders-read-throttle"
alarm_description = "orders table read requests were throttled in the last 5 minutes."
alarm_name = "${local.project}-alb-5xx"
alarm_description = "ALB 5xx from meal-order-manager"
namespace = "AWS/ApplicationELB"
metric_name = "HTTPCode_Target_5XX_Count"
statistic = "Sum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts[0].arn]
dimensions = {
LoadBalancer = aws_lb.api.arn_suffix
}
}
resource "aws_cloudwatch_metric_alarm" "ecs_cpu" {
count = local.is_prod ? 1 : 0
alarm_name = "${local.project}-ecs-cpu"
alarm_description = "meal-order-manager ECS CPU above 80 percent"
namespace = "AWS/ECS"
metric_name = "CPUUtilization"
statistic = "Average"
period = 300
evaluation_periods = 2
threshold = 80
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts[0].arn]
dimensions = {
ClusterName = aws_ecs_cluster.api.name
ServiceName = aws_ecs_service.api.name
}
}
resource "aws_cloudwatch_metric_alarm" "jobs_dlq" {
count = local.is_prod ? 1 : 0
alarm_name = "${local.project}-jobs-dlq"
alarm_description = "Jobs DLQ is not empty"
namespace = "AWS/SQS"
metric_name = "ApproximateNumberOfMessagesVisible"
statistic = "Maximum"
period = 300
evaluation_periods = 1
threshold = 0
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
alarm_actions = [data.aws_sns_topic.site_alerts[0].arn]
dimensions = {
QueueName = aws_sqs_queue.jobs_dlq.name
}
}
resource "aws_cloudwatch_metric_alarm" "dynamodb_read_throttles" {
count = local.is_prod ? 1 : 0
alarm_name = "${local.project}-ddb-read-throttles"
alarm_description = "Orders table read throttles"
namespace = "AWS/DynamoDB"
metric_name = "ReadThrottleEvents"
statistic = "Sum"
@ -137,11 +81,11 @@ resource "aws_cloudwatch_metric_alarm" "orders_read_throttle" {
}
}
resource "aws_cloudwatch_metric_alarm" "orders_write_throttle" {
resource "aws_cloudwatch_metric_alarm" "dynamodb_write_throttles" {
count = local.is_prod ? 1 : 0
alarm_name = "${local.project}-orders-write-throttle"
alarm_description = "orders table write requests were throttled in the last 5 minutes."
alarm_name = "${local.project}-ddb-write-throttles"
alarm_description = "Orders table write throttles"
namespace = "AWS/DynamoDB"
metric_name = "WriteThrottleEvents"
statistic = "Sum"
@ -156,96 +100,3 @@ resource "aws_cloudwatch_metric_alarm" "orders_write_throttle" {
TableName = aws_dynamodb_table.orders.name
}
}
# ---------------------------------------------------------------------------
# HTTP API
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_metric_alarm" "api_5xx" {
count = local.is_prod ? 1 : 0
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[0].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" {
count = local.is_prod ? 1 : 0
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[0].arn]
dimensions = {
ApiId = aws_apigatewayv2_api.order_api.id
}
}
resource "aws_cloudwatch_metric_alarm" "api_latency" {
count = local.is_prod ? 1 : 0
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[0].arn]
dimensions = {
ApiId = aws_apigatewayv2_api.order_api.id
}
}
moved {
from = aws_cloudwatch_metric_alarm.orders_read_throttle
to = aws_cloudwatch_metric_alarm.orders_read_throttle[0]
}
moved {
from = aws_cloudwatch_metric_alarm.orders_write_throttle
to = aws_cloudwatch_metric_alarm.orders_write_throttle[0]
}
moved {
from = aws_cloudwatch_metric_alarm.api_5xx
to = aws_cloudwatch_metric_alarm.api_5xx[0]
}
moved {
from = aws_cloudwatch_metric_alarm.api_4xx
to = aws_cloudwatch_metric_alarm.api_4xx[0]
}
moved {
from = aws_cloudwatch_metric_alarm.api_latency
to = aws_cloudwatch_metric_alarm.api_latency[0]
}

View file

@ -1,136 +0,0 @@
# HTTP API fronting the order form.
#
# Three authorization modes coexist, matching template.yaml:
# NONE — public form routes plus GET /api/orders/{week} (bearer checked in Lambda)
# 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 eleven 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"
cors_configuration {
allow_origins = [
"https://orders.seahaven.com",
"https://internal.seahaven.com",
"https://internal.dev.seahaven.com",
"http://localhost:5173",
"http://localhost:4173",
]
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allow_headers = ["Authorization", "Content-Type"]
allow_credentials = false
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.
#
# Invoke permission is a Lambda resource policy (below), not AuthorizerCredentialsArn.
# The credentials-role path returned 500 without invoking the authorizer in prod
# (PLAT-102); resource policy matches the submit-order route grants and the live hotfix.
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_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}"
}
# Grant API Gateway permission to invoke the admin authorizer Lambda. Source ARN
# is the authorizer itself (not a route), matching the AWS HTTP API docs.
# The PLAT-102 live hotfix was imported into meal-order-manager-prod state; do
# not keep a workspace-global import block or seahaven-dev cannot create this
# permission.
resource "aws_lambda_permission" "admin_authorizer" {
statement_id = "AllowApiGatewayInvokeAuthorizer"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.admin_authorizer.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.order_api.execution_arn}/authorizers/${aws_apigatewayv2_authorizer.admin_google.id}"
}
# PLAT-102 follow-up: role already deleted in AWS after apply failed on
# iam:ListInstanceProfilesForRole. Drop from state without a destroy call.
removed {
from = aws_iam_role.admin_authorizer_invoke
lifecycle {
destroy = false
}
}

View file

@ -1,130 +0,0 @@
# 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
}

View file

@ -0,0 +1,12 @@
# Bootstrap image for the ECS service before the first GitHub Actions deploy.
# Terraform ignores later container_definitions; this command only has to pass
# the ALB health check on GET /api/health until deploy-api.yaml ships the real image.
#
# First apply of `aws_iam_policy.ecs_task_boundary` and
# `aws_iam_policy.github_deploy_boundary` needs the hcptf-bootstrap window:
# CreatePolicy on `policy/tf-managed/*` lives only on hcptf-bootstrap
# (seahaven-org-baseline). Point this workspace's TFC_AWS_* at bootstrap,
# apply, then retarget at hcptf-meal-order-manager.
python:3.12-slim with an inlined ThreadingHTTPServer on :8080 that returns
`{"stage":"bootstrap","sha":"bootstrap"}` for any GET.

View file

@ -1,107 +0,0 @@
#!/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
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

View file

@ -1,24 +0,0 @@
#!/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}"

View file

@ -99,12 +99,12 @@ resource "aws_cloudfront_distribution" "form" {
origin {
origin_id = "OrderApiOrigin"
domain_name = trimprefix(aws_apigatewayv2_api.order_api.api_endpoint, "https://")
domain_name = aws_lb.api.dns_name
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_protocol_policy = "http-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
@ -120,7 +120,18 @@ resource "aws_cloudfront_distribution" "form" {
origin_request_policy_id = aws_cloudfront_origin_request_policy.menu_api.id
}
# Do not use path `/api/*`. That would front IAM-only publish routes without SigV4.
# Do not use path `/api/*`. That would front HMAC publish routes.
ordered_cache_behavior {
path_pattern = "/api/roster"
target_origin_id = "OrderApiOrigin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = local.api_cache_policy_id
origin_request_policy_id = local.api_origin_request_policy_id
}
ordered_cache_behavior {
path_pattern = "/api/form-status/*"
target_origin_id = "OrderApiOrigin"

View file

@ -33,7 +33,7 @@ data "aws_ssm_parameter" "app_web_acl_arn" {
# 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.
# the ECS task.
data "aws_ssm_parameter" "google_client_id" {
name = local.google_client_id_param
}

252
terraform/ecs.tf Normal file
View file

@ -0,0 +1,252 @@
# Always-on meals API: Fargate behind an ALB. GitHub Actions owns the image;
# Terraform ignores container_definitions after the bootstrap task definition.
resource "aws_ecr_repository" "api" {
name = local.project
image_tag_mutability = "MUTABLE"
force_delete = !local.is_prod
image_scanning_configuration {
scan_on_push = true
}
encryption_configuration {
encryption_type = "AES256"
}
}
resource "aws_ecr_lifecycle_policy" "api" {
repository = aws_ecr_repository.api.name
policy = jsonencode({
rules = [
{
rulePriority = 1
description = "Keep the last 20 images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 20
}
action = {
type = "expire"
}
}
]
})
}
resource "aws_security_group" "alb" {
name = "${local.project}-alb"
description = "Public ALB for meal-order-manager"
vpc_id = aws_vpc.this.id
ingress {
# CloudFront prefix lists cannot cover GitHub-hosted weekly-menu HMAC
# publish, so this ALB is internet-reachable on :80. Flask HMAC and
# Bearer checks are the application gate. Security review required.
description = "HTTP from CloudFront and weekly-menu HMAC publish"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "api" {
name = "${local.project}-api"
description = "Fargate tasks for meal-order-manager"
vpc_id = aws_vpc.this.id
ingress {
description = "From ALB"
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_lb" "api" {
name = local.project
load_balancer_type = "application"
idle_timeout = 120
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
drop_invalid_header_fields = true
}
resource "aws_lb_target_group" "api" {
name = "${local.project}-api"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.this.id
target_type = "ip"
health_check {
enabled = true
path = "/api/health"
matcher = "200"
interval = 30
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 3
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.api.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.api.arn
}
}
resource "aws_ecs_cluster" "api" {
name = local.project
setting {
name = "containerInsights"
value = local.is_prod ? "enabled" : "disabled"
}
}
locals {
api_container_name = "api"
bootstrap_command = [
"python",
"-c",
"from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler\nclass H(BaseHTTPRequestHandler):\n def do_GET(self):\n body = b'{\"stage\":\"bootstrap\",\"sha\":\"bootstrap\"}'\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.send_header('Content-Length', str(len(body)))\n self.end_headers()\n self.wfile.write(body)\nThreadingHTTPServer(('0.0.0.0', 8080), H).serve_forever()",
]
api_environment = [
{ name = "STAGE", value = var.environment },
{ name = "GIT_SHA", value = "bootstrap" },
{ name = "TABLE_NAME", value = local.table_name },
{ name = "REPORTS_BUCKET", value = local.reports_bucket_name },
{ name = "SLACK_CHANNEL_PARAM", value = local.slack_channel_param },
{ name = "FORM_URL", value = local.form_url },
{ name = "SLACK_BOT_SM_NAME", value = local.slack_bot_secret_name },
{ name = "GOOGLE_CLIENT_ID_PARAM", value = local.google_client_id_param },
{ name = "PORTAL_COGNITO_ISSUER_PARAM", value = aws_ssm_parameter.portal_cognito_issuer.name },
{ name = "PORTAL_COGNITO_AUDIENCE_PARAM", value = aws_ssm_parameter.portal_cognito_audience.name },
{ name = "PORTAL_COGNITO_TRUST_PARAM", value = aws_ssm_parameter.portal_cognito_trust.name },
{ name = "PUBLISH_KEY_PARAM", value = aws_ssm_parameter.publish_key.name },
{ name = "JOBS_QUEUE_URL", value = aws_sqs_queue.jobs.id },
{ name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url },
{ name = "AWS_DEFAULT_REGION", value = var.aws_region },
]
}
resource "aws_ecs_task_definition" "api" {
family = local.project
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
container_definitions = jsonencode([
{
name = local.api_container_name
image = "public.ecr.aws/docker/library/python:3.12-slim"
essential = true
command = local.bootstrap_command
portMappings = [
{
containerPort = 8080
protocol = "tcp"
}
]
environment = local.api_environment
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.api.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "ecs"
}
}
}
])
lifecycle {
ignore_changes = [container_definitions]
}
}
resource "aws_ecs_service" "api" {
name = local.project
cluster = aws_ecs_cluster.api.id
task_definition = aws_ecs_task_definition.api.arn
desired_count = local.is_prod ? 2 : 1
launch_type = "FARGATE"
network_configuration {
subnets = aws_subnet.public[*].id
security_groups = [aws_security_group.api.id]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.api.arn
container_name = local.api_container_name
container_port = 8080
}
health_check_grace_period_seconds = 60
deployment_minimum_healthy_percent = local.is_prod ? 50 : 0
deployment_maximum_percent = 200
lifecycle {
ignore_changes = [task_definition, desired_count]
}
depends_on = [aws_lb_listener.http]
}
resource "aws_sqs_queue" "jobs_dlq" {
name = "${local.project}-jobs-dlq"
message_retention_seconds = 1209600
}
resource "aws_sqs_queue" "jobs" {
name = "${local.project}-jobs"
visibility_timeout_seconds = 180
receive_wait_time_seconds = 20
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.jobs_dlq.arn
maxReceiveCount = 3
})
}
resource "random_password" "publish_key" {
length = 48
special = false
}
resource "aws_ssm_parameter" "publish_key" {
name = "${local.ssm_prefix}/publish-key"
type = "SecureString"
value = random_password.publish_key.result
description = "Shared secret for /api/publish/* (weekly-menu HMAC)"
}

View file

@ -1,85 +0,0 @@
# 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
}
}
}
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 = local.is_prod ? "ENABLED" : "DISABLED"
}
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
}

View file

@ -164,7 +164,7 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
}
statement {
sid = "PassExecRolesToLambda"
sid = "PassExecRolesToCompute"
effect = "Allow"
actions = ["iam:PassRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/meal-order-manager-*"]
@ -172,10 +172,64 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
condition {
test = "StringEquals"
variable = "iam:PassedToService"
values = ["lambda.amazonaws.com"]
values = ["ecs-tasks.amazonaws.com", "scheduler.amazonaws.com"]
}
}
statement {
sid = "CreateDeployRole"
effect = "Allow"
actions = ["iam:CreateRole"]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/githubdeploy-meal-order-manager"]
condition {
test = "StringEquals"
variable = "iam:PermissionsBoundary"
values = [aws_iam_policy.github_deploy_boundary.arn]
}
}
statement {
sid = "WriteGithubDeployRole"
effect = "Allow"
actions = [
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/githubdeploy-meal-order-manager"]
}
statement {
sid = "MutateGithubDeployRoleWithBoundary"
effect = "Allow"
actions = [
"iam:PutRolePolicy",
"iam:PutRolePermissionsBoundary",
]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/githubdeploy-meal-order-manager"]
condition {
test = "StringEquals"
variable = "iam:PermissionsBoundary"
values = [aws_iam_policy.github_deploy_boundary.arn]
}
}
statement {
sid = "DenyGithubDeployAttach"
effect = "Deny"
actions = [
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
]
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/githubdeploy-meal-order-manager"]
}
statement {
sid = "WriteDeployRoles"
effect = "Allow"
@ -292,6 +346,164 @@ resource "aws_iam_role_policy" "hcptf_apply_services" {
Effect = "Allow"
Sid = "LambdaList"
},
{
Action = [
"ecs:*",
]
Resource = [
"arn:aws:ecs:us-east-1:${local.account_id}:cluster/meal-order-manager",
"arn:aws:ecs:us-east-1:${local.account_id}:service/meal-order-manager/meal-order-manager",
"arn:aws:ecs:us-east-1:${local.account_id}:task-definition/meal-order-manager:*",
"arn:aws:ecs:us-east-1:${local.account_id}:task-definition/meal-order-manager",
]
Effect = "Allow"
Sid = "EcsWorkload"
},
{
Action = [
"ecs:CreateCluster",
"ecs:CreateService",
"ecs:DeleteService",
"ecs:DeregisterTaskDefinition",
"ecs:DescribeClusters",
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:ListClusters",
"ecs:ListServices",
"ecs:ListTaskDefinitions",
"ecs:RegisterTaskDefinition",
"ecs:TagResource",
"ecs:UntagResource",
"ecs:UpdateService",
]
Resource = "*"
Effect = "Allow"
Sid = "EcsAccount"
},
{
Action = [
"elasticloadbalancing:*",
]
Resource = [
"arn:aws:elasticloadbalancing:us-east-1:${local.account_id}:loadbalancer/app/meal-order-manager/*",
"arn:aws:elasticloadbalancing:us-east-1:${local.account_id}:targetgroup/meal-order-manager-api/*",
"arn:aws:elasticloadbalancing:us-east-1:${local.account_id}:listener/app/meal-order-manager/*",
]
Effect = "Allow"
Sid = "ElbWorkload"
},
{
Action = [
"elasticloadbalancing:Describe*",
"elasticloadbalancing:CreateLoadBalancer",
"elasticloadbalancing:CreateTargetGroup",
"elasticloadbalancing:CreateListener",
"elasticloadbalancing:CreateRule",
"elasticloadbalancing:AddTags",
"elasticloadbalancing:ModifyLoadBalancerAttributes",
"elasticloadbalancing:ModifyTargetGroup",
"elasticloadbalancing:ModifyTargetGroupAttributes",
"elasticloadbalancing:ModifyListener",
"elasticloadbalancing:SetSecurityGroups",
"elasticloadbalancing:SetSubnets",
]
Resource = "*"
Effect = "Allow"
Sid = "ElbDescribe"
},
{
Action = [
"ecr:*",
]
Resource = [
"arn:aws:ecr:us-east-1:${local.account_id}:repository/meal-order-manager",
]
Effect = "Allow"
Sid = "EcrRepo"
},
{
Action = [
"ecr:DescribeRepositories",
"ecr:GetAuthorizationToken",
]
Resource = "*"
Effect = "Allow"
Sid = "EcrAccount"
},
{
Action = [
"sqs:*",
]
Resource = [
"arn:aws:sqs:us-east-1:${local.account_id}:meal-order-manager-jobs",
"arn:aws:sqs:us-east-1:${local.account_id}:meal-order-manager-jobs-dlq",
]
Effect = "Allow"
Sid = "JobsQueues"
},
{
Action = [
"scheduler:*",
]
Resource = [
"arn:aws:scheduler:us-east-1:${local.account_id}:schedule/meal-order-manager/*",
"arn:aws:scheduler:us-east-1:${local.account_id}:schedule-group/meal-order-manager",
]
Effect = "Allow"
Sid = "SchedulerJobs"
},
{
Action = [
"scheduler:CreateScheduleGroup",
"scheduler:ListScheduleGroups",
"scheduler:ListSchedules",
]
Resource = "*"
Effect = "Allow"
Sid = "SchedulerAccount"
},
{
Action = [
"ec2:AssociateRouteTable",
"ec2:AttachInternetGateway",
"ec2:AuthorizeSecurityGroupEgress",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:CreateInternetGateway",
"ec2:CreateRoute",
"ec2:CreateRouteTable",
"ec2:CreateSecurityGroup",
"ec2:CreateSubnet",
"ec2:CreateTags",
"ec2:CreateVpc",
"ec2:DeleteInternetGateway",
"ec2:DeleteRoute",
"ec2:DeleteRouteTable",
"ec2:DeleteSecurityGroup",
"ec2:DeleteSubnet",
"ec2:DeleteTags",
"ec2:DeleteVpc",
"ec2:DescribeAccountAttributes",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeInternetGateways",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeRouteTables",
"ec2:DescribeSecurityGroupRules",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeTags",
"ec2:DescribeVpcAttribute",
"ec2:DescribeVpcs",
"ec2:DetachInternetGateway",
"ec2:DisassociateRouteTable",
"ec2:ModifySubnetAttribute",
"ec2:ModifyVpcAttribute",
"ec2:RevokeSecurityGroupEgress",
"ec2:RevokeSecurityGroupIngress",
]
Resource = "*"
Effect = "Allow"
Sid = "Ec2VpcManagement"
},
{
Action = [
"events:*",
@ -318,6 +530,8 @@ resource "aws_iam_role_policy" "hcptf_apply_services" {
Resource = [
"arn:aws:logs:us-east-1:${local.account_id}:log-group:/aws/lambda/meal-order-manager-*",
"arn:aws:logs:us-east-1:${local.account_id}:log-group:/aws/apigateway/meal-order-manager*",
"arn:aws:logs:us-east-1:${local.account_id}:log-group:/ecs/meal-order-manager",
"arn:aws:logs:us-east-1:${local.account_id}:log-group:/ecs/meal-order-manager:*",
]
Effect = "Allow"
Sid = "CloudWatchLogs"
@ -596,6 +810,92 @@ resource "aws_iam_role_policy" "hcptf_plan_refresh" {
Effect = "Allow"
Sid = "RefreshLambda"
},
{
Action = [
"ecs:DescribeClusters",
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:DescribeTaskSets",
"ecs:ListClusters",
"ecs:ListServices",
"ecs:ListTaskDefinitions",
"ecs:ListTagsForResource",
]
Resource = "*"
Effect = "Allow"
Sid = "RefreshEcs"
},
{
Action = [
"elasticloadbalancing:DescribeLoadBalancers",
"elasticloadbalancing:DescribeLoadBalancerAttributes",
"elasticloadbalancing:DescribeListeners",
"elasticloadbalancing:DescribeListenerAttributes",
"elasticloadbalancing:DescribeTargetGroups",
"elasticloadbalancing:DescribeTargetGroupAttributes",
"elasticloadbalancing:DescribeTags",
"elasticloadbalancing:DescribeRules",
]
Resource = "*"
Effect = "Allow"
Sid = "RefreshElb"
},
{
Action = [
"ecr:DescribeRepositories",
"ecr:DescribeImages",
"ecr:GetLifecyclePolicy",
"ecr:ListTagsForResource",
]
Resource = [
"arn:aws:ecr:us-east-1:${local.account_id}:repository/meal-order-manager",
]
Effect = "Allow"
Sid = "RefreshEcr"
},
{
Action = [
"sqs:GetQueueAttributes",
"sqs:GetQueueUrl",
"sqs:ListQueueTags",
]
Resource = [
"arn:aws:sqs:us-east-1:${local.account_id}:meal-order-manager-jobs",
"arn:aws:sqs:us-east-1:${local.account_id}:meal-order-manager-jobs-dlq",
]
Effect = "Allow"
Sid = "RefreshJobsQueues"
},
{
Action = [
"scheduler:GetSchedule",
"scheduler:GetScheduleGroup",
"scheduler:ListSchedules",
"scheduler:ListScheduleGroups",
"scheduler:ListTagsForResource",
]
Resource = "*"
Effect = "Allow"
Sid = "RefreshScheduler"
},
{
Action = [
"ec2:DescribeAccountAttributes",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeInternetGateways",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeRouteTables",
"ec2:DescribeSecurityGroupRules",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeTags",
"ec2:DescribeVpcAttribute",
"ec2:DescribeVpcs",
]
Resource = "*"
Effect = "Allow"
Sid = "RefreshVpc"
},
{
Action = [
"s3:Get*",

View file

@ -1,34 +1,108 @@
# Execution roles for the six Lambda functions plus the API Gateway role that
# invokes the admin authorizer.
#
# Every Lambda execution role is created under the /tf-managed/ path and
# carries seahaven-lambda-execution-boundary-meal-order-manager (PLAT-52).
# The path distinguishes Terraform-owned roles from the retired SAM
# /cfn-managed/ roles.
#
# 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.
# Execution, task, and EventBridge Scheduler roles for the Fargate API.
data "aws_iam_policy_document" "lambda_assume" {
data "aws_iam_policy_document" "ecs_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
# ---------------------------------------------------------------------------
# Reusable policy documents
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "scheduler_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["scheduler.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "ecs_task_boundary" {
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,
]
statement {
sid = "ReportsWrite"
effect = "Allow"
actions = ["s3:PutObject", "s3:GetObject"]
resources = ["${aws_s3_bucket.reports.arn}/*"]
}
statement {
sid = "JobsQueue"
effect = "Allow"
actions = ["sqs:SendMessage", "sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]
resources = [aws_sqs_queue.jobs.arn]
}
statement {
sid = "CheckcomponentsSend"
effect = "Allow"
actions = ["sqs:SendMessage"]
resources = compact([var.checkcomponents_queue_arn])
}
statement {
sid = "EcrAuth"
effect = "Allow"
actions = ["ecr:GetAuthorizationToken"]
resources = ["*"]
}
statement {
sid = "EcrPull"
effect = "Allow"
actions = [
"ecr:BatchCheckLayerAvailability",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
]
resources = [aws_ecr_repository.api.arn]
}
statement {
sid = "TaskLogs"
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:CreateLogGroup",
]
resources = [
aws_cloudwatch_log_group.api.arn,
"${aws_cloudwatch_log_group.api.arn}:*",
]
}
}
resource "aws_iam_policy" "ecs_task_boundary" {
name = "${local.project}-ecs-task-boundary"
path = "/tf-managed/"
description = "Permissions boundary for the meal-order-manager ECS task role"
policy = data.aws_iam_policy_document.ecs_task_boundary.json
}
resource "aws_iam_role" "ecs_execution" {
name = "${local.project}-ecs-exec"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
}
resource "aws_iam_role_policy_attachment" "ecs_execution" {
role = aws_iam_role.ecs_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
data "aws_iam_policy_document" "dynamodb_crud" {
statement {
@ -55,27 +129,6 @@ data "aws_iam_policy_document" "dynamodb_crud" {
}
}
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"
@ -85,10 +138,6 @@ data "aws_iam_policy_document" "ssm_read" {
}
}
# 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"
@ -98,233 +147,37 @@ data "aws_iam_policy_document" "slack_bot_secret_read" {
}
}
# ---------------------------------------------------------------------------
# submit-order
# ---------------------------------------------------------------------------
resource "aws_iam_role" "submit_order" {
name = "${local.project}-submit-order"
resource "aws_iam_role" "ecs_task" {
name = "${local.project}-api"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
permissions_boundary = aws_iam_policy.ecs_task_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"
resource "aws_iam_role_policy" "ecs_task" {
name = "api-runtime"
role = aws_iam_role.ecs_task.id
policy = data.aws_iam_policy_document.ecs_task_boundary.json
}
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,
]
resource "aws_iam_role" "scheduler" {
name = "${local.project}-scheduler"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.scheduler_assume.json
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
}
data "aws_iam_policy_document" "scheduler" {
statement {
sid = "InvokeSlackNotifier"
sid = "SendJobs"
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"]
actions = ["sqs:SendMessage"]
resources = [aws_sqs_queue.jobs.arn]
}
}
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
}
# ---------------------------------------------------------------------------
# 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]
}
dynamic "statement" {
for_each = var.checkcomponents_queue_arn != "" ? [var.checkcomponents_queue_arn] : []
content {
sid = "CheckcomponentsSend"
effect = "Allow"
actions = ["sqs:SendMessage"]
resources = [statement.value]
}
}
}
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
resource "aws_iam_role_policy" "scheduler" {
name = "enqueue-jobs"
role = aws_iam_role.scheduler.id
policy = data.aws_iam_policy_document.scheduler.json
}

View file

@ -0,0 +1,129 @@
# GitHub Actions OIDC role for .github/workflows/deploy-api.yaml.
data "aws_iam_policy_document" "github_deploy_assume" {
statement {
sid = "GithubDeployOidc"
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [local.github_oidc_provider_arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = [
"repo:Sea-Haven-Industries/meal-order-manager:environment:dev",
"repo:Sea-Haven-Industries/meal-order-manager:environment:prod",
]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:job_workflow_ref"
values = [
"Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/heads/main",
"Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/tags/v*",
]
}
}
}
data "aws_iam_policy_document" "github_deploy" {
statement {
sid = "EcrAuth"
effect = "Allow"
actions = [
"ecr:GetAuthorizationToken",
]
resources = ["*"]
}
statement {
sid = "EcrPush"
effect = "Allow"
actions = [
"ecr:BatchCheckLayerAvailability",
"ecr:BatchGetImage",
"ecr:CompleteLayerUpload",
"ecr:GetDownloadUrlForLayer",
"ecr:InitiateLayerUpload",
"ecr:PutImage",
"ecr:UploadLayerPart",
"ecr:DescribeRepositories",
"ecr:DescribeImages",
]
resources = [aws_ecr_repository.api.arn]
}
statement {
sid = "EcsDeploy"
effect = "Allow"
actions = [
"ecs:DescribeServices",
"ecs:DescribeTaskDefinition",
"ecs:DescribeTasks",
"ecs:ListTasks",
"ecs:RegisterTaskDefinition",
"ecs:UpdateService",
]
resources = ["*"]
}
statement {
sid = "PassTaskRoles"
effect = "Allow"
actions = ["iam:PassRole"]
resources = [
aws_iam_role.ecs_task.arn,
aws_iam_role.ecs_execution.arn,
]
}
statement {
sid = "DeployParams"
effect = "Allow"
actions = [
"ssm:GetParameter",
]
resources = [
aws_ssm_parameter.deploy_cluster.arn,
aws_ssm_parameter.deploy_service.arn,
aws_ssm_parameter.deploy_task_family.arn,
aws_ssm_parameter.deploy_ecr_repository.arn,
aws_ssm_parameter.deploy_container_name.arn,
aws_ssm_parameter.deploy_form_url.arn,
aws_ssm_parameter.deploy_api_url.arn,
]
}
}
resource "aws_iam_policy" "github_deploy_boundary" {
name = "${local.project}-githubdeploy-boundary"
path = "/tf-managed/"
description = "Permissions boundary for githubdeploy-meal-order-manager"
policy = data.aws_iam_policy_document.github_deploy.json
}
resource "aws_iam_role" "github_deploy" {
name = "githubdeploy-meal-order-manager"
path = "/tf-managed/"
description = "GitHub Actions API image deploy for meal-order-manager"
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
permissions_boundary = aws_iam_policy.github_deploy_boundary.arn
max_session_duration = 3600
}
resource "aws_iam_role_policy" "github_deploy" {
name = "api-image-deploy"
role = aws_iam_role.github_deploy.id
policy = data.aws_iam_policy_document.github_deploy.json
}

View file

@ -8,10 +8,6 @@
# OIDC provider ARN is literal (not a data source): hcptf-meal-order-manager-plan
# lacks iam:GetOpenIDConnectProvider, and the provider is account-stable.
locals {
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
}
data "aws_iam_policy_document" "weekly_menu_assume" {
statement {
effect = "Allow"
@ -84,18 +80,7 @@ data "aws_iam_policy_document" "weekly_menu" {
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/deploy/form-url",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.google_client_id_param}",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.slack_channel_param}",
]
}
statement {
sid = "PublishApi"
effect = "Allow"
actions = [
"execute-api:Invoke",
]
resources = [
"arn:aws:execute-api:${var.aws_region}:${local.account_id}:${aws_apigatewayv2_api.order_api.id}/*/GET/api/publish/settings",
"arn:aws:execute-api:${var.aws_region}:${local.account_id}:${aws_apigatewayv2_api.order_api.id}/*/POST/api/publish/menu",
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/publish-key",
]
}

View file

@ -1,213 +0,0 @@
# The shared layer and the six 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
PORTAL_COGNITO_ISSUER_PARAM = aws_ssm_parameter.portal_cognito_issuer.name
PORTAL_COGNITO_AUDIENCE_PARAM = aws_ssm_parameter.portal_cognito_audience.name
PORTAL_COGNITO_TRUST_PARAM = aws_ssm_parameter.portal_cognito_trust.name
})
}
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
PORTAL_COGNITO_ISSUER_PARAM = aws_ssm_parameter.portal_cognito_issuer.name
PORTAL_COGNITO_AUDIENCE_PARAM = aws_ssm_parameter.portal_cognito_audience.name
PORTAL_COGNITO_TRUST_PARAM = aws_ssm_parameter.portal_cognito_trust.name
})
}
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
CHECKCOMPONENTS_QUEUE_URL = var.checkcomponents_queue_url
})
}
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,
]
}

View file

@ -5,13 +5,15 @@ locals {
hcp_project = "seahaven-${var.environment}"
hcp_workspace = "${local.project}-${var.environment}"
# Lambda execution roles under /tf-managed/ carry the per-workload ceiling
# (PLAT-52). githubdeploy-meal-order-manager-weekly-menu must not.
boundary_arn = "arn:aws:iam::${local.account_id}:policy/seahaven-lambda-execution-boundary-meal-order-manager"
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
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}"
# Prod has no default VPC. 10.60 is unused in 011934824531
# (10.0 proposal-system, 10.20 payments-dashboard, 10.40 syslog, 10.80 apm-wo).
vpc_cidr = "10.60.0.0/16"
public_subnet_cidrs = ["10.60.0.0/24", "10.60.1.0/24"]
form_bucket_name = "${local.project}-form-${local.account_id}"
reports_bucket_name = "${local.project}-reports-${local.account_id}"
table_name = "${local.project}-orders"
# Pre-DNS: use the CloudFront domain. After cutover (attach_custom_domain),
@ -35,36 +37,22 @@ locals {
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",
"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
}
cors_origins = [
"https://orders.seahaven.com",
"https://internal.seahaven.com",
"https://internal.dev.seahaven.com",
"http://localhost:5173",
"http://localhost:4173",
]
# AWS managed CachingDisabled / AllViewerExceptHostHeader. Authenticated
# portal calls must not be cached and must not send the viewer Host to the
# HTTP API (Forbidden).
# portal calls must not be cached. ALB origin uses Host of the load balancer
# DNS name (http-only).
api_cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
api_origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac"
cloudfront_all_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
# 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 `*`).
# HTTP routes served by the Fargate Flask app. authorizer is in-process now.
api_routes = {
submit_order = {
route_key = "POST /api/submit-order"
@ -93,32 +81,32 @@ locals {
}
publish_settings = {
route_key = "GET /api/publish/settings"
authorizer = "AWS_IAM"
authorizer = "HMAC"
permission_source = "GET/api/publish/settings"
}
publish_menu = {
route_key = "POST /api/publish/menu"
authorizer = "AWS_IAM"
authorizer = "HMAC"
permission_source = "POST/api/publish/menu"
}
admin_orders_get = {
route_key = "GET /api/admin/orders"
authorizer = "CUSTOM"
authorizer = "BEARER"
permission_source = "GET/api/admin/orders"
}
admin_orders_put = {
route_key = "PUT /api/admin/orders"
authorizer = "CUSTOM"
authorizer = "BEARER"
permission_source = "PUT/api/admin/orders"
}
admin_orders_delete = {
route_key = "DELETE /api/admin/orders"
authorizer = "CUSTOM"
authorizer = "BEARER"
permission_source = "DELETE/api/admin/orders"
}
admin_summary_pdf = {
route_key = "GET /api/admin/summary-pdf"
authorizer = "CUSTOM"
authorizer = "BEARER"
permission_source = "GET/api/admin/summary-pdf"
}
}

View file

@ -1,17 +1,4 @@
# 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, "_", "-")}"
resource "aws_cloudwatch_log_group" "api" {
name = "/ecs/${local.project}"
retention_in_days = local.is_prod ? 60 : 14
}
# 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 = local.is_prod ? 90 : 14
}

View file

@ -1,6 +1,11 @@
output "api_url" {
description = "HTTP API endpoint URL."
value = aws_apigatewayv2_api.order_api.api_endpoint
description = "Public meals API origin (CloudFront)."
value = local.form_url
}
output "publish_api_url" {
description = "ALB URL for weekly-menu HMAC publish."
value = "http://${aws_lb.api.dns_name}"
}
output "form_url" {
@ -28,9 +33,9 @@ output "reports_bucket_name" {
value = aws_s3_bucket.reports.id
}
output "artifacts_bucket_name" {
description = "S3 bucket holding Lambda deployment packages."
value = aws_s3_bucket.artifacts.id
output "ecr_repository_url" {
description = "ECR repository for the API image."
value = aws_ecr_repository.api.repository_url
}
output "orders_table_name" {
@ -43,19 +48,12 @@ output "weekly_menu_role_arn" {
value = aws_iam_role.weekly_menu.arn
}
output "shared_layer_arn" {
description = "Version ARN of the shared Lambda layer."
value = aws_lambda_layer_version.shared.arn
output "github_deploy_role_arn" {
description = "OIDC role ARN for .github/workflows/deploy-api.yaml (Environment DEPLOY_ROLE_ARN)."
value = aws_iam_role.github_deploy.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
slack_notifier = aws_lambda_function.slack_notifier.arn
submit_order = aws_lambda_function.submit_order.arn
sync_roster = aws_lambda_function.sync_roster.arn
}
output "ecs_task_role_arn" {
description = "ECS task role; paychex-checkcomponents queue policy must allow this ARN."
value = aws_iam_role.ecs_task.arn
}

View file

@ -1,5 +1,4 @@
# Form-hosting and reports buckets. The Lambda artifact bucket lives in
# artifacts.tf.
# Form-hosting and reports buckets.
# ---------------------------------------------------------------------------
# Form bucket — private origin for the CloudFront distribution

45
terraform/scheduler.tf Normal file
View file

@ -0,0 +1,45 @@
# EventBridge Scheduler in Eastern time. Replaces dual EST/EDT Lambda crons.
locals {
job_schedules = {
close = {
description = "Close form Thursday 11:59pm Eastern"
schedule = "cron(59 23 ? * THU *)"
event = "close"
}
reminder = {
description = "DM reminders Thursday 10am Eastern"
schedule = "cron(0 10 ? * THU *)"
event = "reminder"
}
sync-roster = {
description = "Sync roster Monday 6:55am Eastern, before menu publish"
schedule = "cron(55 6 ? * MON *)"
event = "sync_roster"
}
}
}
resource "aws_scheduler_schedule_group" "jobs" {
name = local.project
}
resource "aws_scheduler_schedule" "jobs" {
for_each = local.job_schedules
name = "${local.project}-${each.key}"
group_name = aws_scheduler_schedule_group.jobs.name
description = each.value.description
schedule_expression = each.value.schedule
schedule_expression_timezone = "America/New_York"
state = local.is_prod ? "ENABLED" : "DISABLED"
flexible_time_window {
mode = "OFF"
}
target {
arn = aws_sqs_queue.jobs.arn
role_arn = aws_iam_role.scheduler.arn
input = jsonencode({ event = each.value.event })
}
}

View file

@ -46,8 +46,43 @@ resource "aws_ssm_parameter" "portal_cognito_trust" {
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"
value = "http://${aws_lb.api.dns_name}"
description = "ALB URL for weekly-menu HMAC publish (not on CloudFront)"
}
resource "aws_ssm_parameter" "deploy_cluster" {
name = "${local.ssm_prefix}/deploy/cluster"
type = "String"
value = aws_ecs_cluster.api.name
description = "ECS cluster name for deploy-api.yaml"
}
resource "aws_ssm_parameter" "deploy_service" {
name = "${local.ssm_prefix}/deploy/service"
type = "String"
value = aws_ecs_service.api.name
description = "ECS service name for deploy-api.yaml"
}
resource "aws_ssm_parameter" "deploy_task_family" {
name = "${local.ssm_prefix}/deploy/task-family"
type = "String"
value = aws_ecs_task_definition.api.family
description = "ECS task definition family for deploy-api.yaml"
}
resource "aws_ssm_parameter" "deploy_ecr_repository" {
name = "${local.ssm_prefix}/deploy/ecr-repository"
type = "String"
value = aws_ecr_repository.api.repository_url
description = "ECR repository URL for deploy-api.yaml"
}
resource "aws_ssm_parameter" "deploy_container_name" {
name = "${local.ssm_prefix}/deploy/container-name"
type = "String"
value = local.api_container_name
description = "Container name in the ECS task definition"
}
resource "aws_ssm_parameter" "deploy_form_bucket" {

View file

@ -6,13 +6,9 @@ terraform {
source = "hashicorp/aws"
version = "6.65.0"
}
archive = {
source = "hashicorp/archive"
version = "2.8.1"
}
external = {
source = "hashicorp/external"
version = "2.4.2"
random = {
source = "hashicorp/random"
version = "3.8.1"
}
}

58
terraform/vpc.tf Normal file
View file

@ -0,0 +1,58 @@
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "this" {
cidr_block = local.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${local.project}-vpc"
}
# First apply updates the live hcptf apply role before CreateVpc.
depends_on = [aws_iam_role_policy.hcptf_apply_services]
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = {
Name = "${local.project}-igw"
}
}
resource "aws_subnet" "public" {
count = length(local.public_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = local.public_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${local.project}-public-${count.index}"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
tags = {
Name = "${local.project}-public"
}
}
resource "aws_route" "public_default" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
resource "aws_route_table_association" "public" {
count = length(local.public_subnet_cidrs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}

View file

@ -17,7 +17,11 @@ os.environ.setdefault("AWS_REGION", "us-east-1")
_repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
sys.path.insert(0, _repo_root)
# Add src/ so `from server.http_api import ...` and `from server.jobs import ...` work.
_src_dir = os.path.join(os.path.dirname(__file__), os.pardir, "src")
sys.path.insert(0, os.path.abspath(_src_dir))
# Add the shared layer source directory so `from shared.db import ...` works
# without requiring a real Lambda layer or .aws-sam build.
_shared_layer_dir = os.path.join(os.path.dirname(__file__), os.pardir, "src", "shared")
_shared_layer_dir = os.path.join(_src_dir, "shared")
sys.path.insert(0, os.path.abspath(_shared_layer_dir))

View file

@ -16,7 +16,7 @@ sys.path.insert(0, os.path.abspath(_shared))
os.environ.setdefault("TABLE_NAME", "meal-order-manager-orders-test")
_handler_path = os.path.join(
os.path.dirname(__file__), os.pardir, "functions", "admin_authorizer", "handler.py"
os.path.dirname(__file__), os.pardir, "src", "server", "admin_authorizer.py"
)
_spec = importlib.util.spec_from_file_location(
"admin_authorizer_handler", os.path.abspath(_handler_path)

View file

@ -75,23 +75,21 @@ def handler_module():
"""Import the handler with boto3 patched at module level."""
with patch("boto3.client") as mock_client, patch("boto3.resource"):
mock_s3 = MagicMock()
mock_lambda = MagicMock()
mock_sqs = MagicMock()
mock_client.side_effect = lambda svc, **kw: {
"s3": mock_s3,
"lambda": mock_lambda,
"sqs": mock_sqs,
}[svc]
}.get(svc, MagicMock())
import importlib
import functions.aggregate_orders.handler as mod
import server.jobs.aggregate as mod
importlib.reload(mod)
# Inject mocked clients so tests can assert on them
mod._s3 = mock_s3
mod._lambda = mock_lambda
mod._sqs = mock_sqs
mod._dispatch_job = MagicMock()
yield mod
@ -397,9 +395,9 @@ class TestBuildPayrollCsvSubtotalFallback:
class TestAggregateAlreadyAggregated:
"""test_aggregate_already_aggregated — summary exists, returns early, no S3 upload."""
@patch("functions.aggregate_orders.handler.get_summary")
@patch("functions.aggregate_orders.handler.get_orders")
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
@patch("server.jobs.aggregate.get_summary")
@patch("server.jobs.aggregate.get_orders")
@patch("server.jobs.aggregate.current_week", return_value="2026-W20")
def test_aggregate_already_aggregated(
self, mock_week, mock_orders, mock_summary, handler_module
):
@ -419,9 +417,9 @@ class TestAggregateAlreadyAggregated:
class TestAggregateNoOrders:
"""test_aggregate_no_orders — no orders returns no_orders status."""
@patch("functions.aggregate_orders.handler.get_summary")
@patch("functions.aggregate_orders.handler.get_orders")
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
@patch("server.jobs.aggregate.get_summary")
@patch("server.jobs.aggregate.get_orders")
@patch("server.jobs.aggregate.current_week", return_value="2026-W20")
def test_aggregate_no_orders(
self, mock_week, mock_orders, mock_summary, handler_module
):
@ -441,10 +439,10 @@ class TestAggregateNoOrders:
class TestAggregateHappyPath:
"""test_aggregate_happy_path — orders exist, builds summary, uploads CSVs, saves, triggers Slack."""
@patch("functions.aggregate_orders.handler.put_summary")
@patch("functions.aggregate_orders.handler.get_summary")
@patch("functions.aggregate_orders.handler.get_orders")
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
@patch("server.jobs.aggregate.put_summary")
@patch("server.jobs.aggregate.get_summary")
@patch("server.jobs.aggregate.get_orders")
@patch("server.jobs.aggregate.current_week", return_value="2026-W20")
def test_aggregate_happy_path(
self, mock_week, mock_orders, mock_get_summary, mock_put_summary, handler_module
):
@ -516,14 +514,10 @@ class TestAggregateHappyPath:
== "reports/2026-W20/weekly-summary-2026-W20.pdf"
)
# Verify Slack notifier Lambda invoked asynchronously
handler_module._lambda.invoke.assert_called_once()
invoke_kwargs = handler_module._lambda.invoke.call_args.kwargs
assert invoke_kwargs["FunctionName"] == os.environ["SLACK_NOTIFIER_ARN"]
assert invoke_kwargs["InvocationType"] == "Event"
payload = json.loads(invoke_kwargs["Payload"])
assert payload["event"] == "orders_aggregated"
assert payload["week"] == "2026-W20"
# Verify Slack notifier job is dispatched
handler_module._dispatch_job.assert_called_once_with(
{"event": "orders_aggregated", "week": "2026-W20"}
)
handler_module._sqs.send_message.assert_called_once()
send_kwargs = handler_module._sqs.send_message.call_args.kwargs
@ -574,10 +568,10 @@ class TestCheckcomponentsPayload:
class TestCheckcomponentsSend:
@patch("functions.aggregate_orders.handler.put_summary")
@patch("functions.aggregate_orders.handler.get_summary")
@patch("functions.aggregate_orders.handler.get_orders")
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
@patch("server.jobs.aggregate.put_summary")
@patch("server.jobs.aggregate.get_summary")
@patch("server.jobs.aggregate.get_orders")
@patch("server.jobs.aggregate.current_week", return_value="2026-W20")
def test_sqs_failure_still_aggregates_and_notifies(
self, mock_week, mock_orders, mock_get_summary, mock_put_summary, handler_module
):
@ -591,12 +585,12 @@ class TestCheckcomponentsSend:
assert result["status"] == "aggregated"
mock_put_summary.assert_called_once()
handler_module._lambda.invoke.assert_called_once()
handler_module._dispatch_job.assert_called_once()
@patch("functions.aggregate_orders.handler.put_summary")
@patch("functions.aggregate_orders.handler.get_summary")
@patch("functions.aggregate_orders.handler.get_orders")
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
@patch("server.jobs.aggregate.put_summary")
@patch("server.jobs.aggregate.get_summary")
@patch("server.jobs.aggregate.get_orders")
@patch("server.jobs.aggregate.current_week", return_value="2026-W20")
def test_empty_queue_url_skips_send(
self,
mock_week,
@ -616,4 +610,4 @@ class TestCheckcomponentsSend:
assert result["status"] == "aggregated"
handler_module._sqs.send_message.assert_not_called()
handler_module._lambda.invoke.assert_called_once()
handler_module._dispatch_job.assert_called_once()

47
tests/test_app.py Normal file
View file

@ -0,0 +1,47 @@
"""Flask app health and CORS preflight."""
import os
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
os.environ.setdefault("TABLE_NAME", "test-orders-table")
from server.app import create_app
def test_health_returns_stage_and_sha(monkeypatch):
monkeypatch.setenv("STAGE", "local")
monkeypatch.setenv("GIT_SHA", "abc123")
client = create_app().test_client()
response = client.get("/api/health")
assert response.status_code == 200
assert response.get_json() == {"stage": "local", "sha": "abc123"}
def test_options_preflight_from_portal_origin():
client = create_app().test_client()
response = client.options(
"/api/submit-order",
headers={"Origin": "https://internal.seahaven.com"},
)
assert response.status_code == 204
assert (
response.headers["Access-Control-Allow-Origin"]
== "https://internal.seahaven.com"
)
assert "Authorization" in response.headers["Access-Control-Allow-Headers"]
def test_api_dispatch_jsonifies_handler_body():
from unittest.mock import patch
with patch("server.http_api.lambda_handler") as mock_handler:
mock_handler.return_value = {
"statusCode": 400,
"headers": {"Content-Type": "application/json"},
"body": '{"error": "Bad request"}',
}
client = create_app().test_client()
response = client.get("/api/menu")
assert response.status_code == 400
assert response.get_json() == {"error": "Bad request"}
assert response.content_type.startswith("application/json")

View file

@ -1,10 +1,8 @@
"""Unit tests for functions/close_form/handler.py — wall-clock guard."""
"""Unit tests for src/server/jobs/close_form.py."""
import os
import sys
from datetime import datetime
from unittest.mock import patch
from zoneinfo import ZoneInfo
os.environ.setdefault(
"AGGREGATE_FUNCTION_ARN",
@ -14,7 +12,7 @@ os.environ.setdefault(
import importlib.util
_handler_path = os.path.join(
os.path.dirname(__file__), os.pardir, "functions", "close_form", "handler.py"
os.path.dirname(__file__), os.pardir, "src", "server", "jobs", "close_form.py"
)
_spec = importlib.util.spec_from_file_location(
"close_form_handler", os.path.abspath(_handler_path)
@ -23,95 +21,35 @@ close_form_handler = importlib.util.module_from_spec(_spec)
sys.modules["close_form_handler"] = close_form_handler
_spec.loader.exec_module(close_form_handler)
ET = ZoneInfo("America/New_York")
def _make_datetime(year, month, day, hour, minute=0):
return datetime(year, month, day, hour, minute, tzinfo=ET)
class TestCloseFormGuard:
@patch("close_form_handler.datetime")
def test_skipped_on_wednesday(self, mock_dt):
"""Wednesday 11pm ET -> skipped (not Thursday)."""
mock_dt.now.return_value = _make_datetime(2026, 5, 13, 23) # Wednesday
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "skipped"
@patch("close_form_handler.datetime")
def test_skipped_on_friday_after_catchup_window(self, mock_dt):
"""Friday 4am ET -> skipped (past Thu 23 / Fri 00–03 catch-up window)."""
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 4) # Friday 4am
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "skipped"
@patch("close_form_handler.datetime")
def test_skipped_thursday_before_11pm(self, mock_dt):
"""Thursday 10pm ET -> skipped (too early)."""
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 22) # Thursday 10pm
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "skipped"
class TestCloseForm:
@patch("close_form_handler.set_form_status")
@patch("close_form_handler.get_form_status", return_value="open")
@patch("close_form_handler.current_week", return_value="2026-W19")
@patch("close_form_handler._lambda")
@patch("close_form_handler.datetime")
def test_runs_thursday_at_11pm(
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
):
"""Thursday 11pm ET -> proceeds to close."""
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23) # Thursday 11pm
@patch("close_form_handler._dispatch_job")
def test_closes_open_form(self, mock_lam, mock_week, mock_status, mock_set):
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "closed"
mock_set.assert_called_once()
mock_set.assert_called_once_with("2026-W19", "closed")
mock_lam.assert_called_once_with({"event": "aggregate", "week": "2026-W19"})
@patch("close_form_handler.set_form_status")
@patch("close_form_handler.get_form_status", return_value="open")
@patch("close_form_handler.current_week", return_value="2026-W19")
@patch("close_form_handler._lambda")
@patch("close_form_handler.datetime")
def test_runs_thursday_at_1159pm(
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
):
"""Thursday 11:59pm ET -> proceeds to close."""
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23, 59)
result = close_form_handler.lambda_handler({}, None)
@patch("close_form_handler._dispatch_job")
def test_uses_week_from_event(self, mock_lam, mock_status, mock_set):
result = close_form_handler.lambda_handler({"week": "2026-W20"}, None)
assert result["status"] == "closed"
mock_set.assert_called_once_with("2026-W20", "closed")
@patch("close_form_handler.set_form_status")
@patch("close_form_handler.get_form_status", return_value="open")
@patch("close_form_handler.current_week", return_value="2026-W19")
@patch("close_form_handler._lambda")
@patch("close_form_handler.datetime")
def test_runs_friday_just_after_midnight(
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
):
"""Friday 12:30am ET -> proceeds (delayed EventBridge past Thu 23:59)."""
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 0, 30)
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "closed"
mock_set.assert_called_once()
@patch("close_form_handler.get_form_status", return_value="closed")
@patch("close_form_handler.current_week", return_value="2026-W19")
@patch("close_form_handler.datetime")
def test_already_closed(self, mock_dt, mock_week, mock_status):
"""Thursday 11pm but already closed -> returns already_closed."""
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23)
@patch("close_form_handler._dispatch_job")
def test_already_closed(self, mock_lam, mock_week, mock_status, mock_set):
result = close_form_handler.lambda_handler({}, None)
assert result["status"] == "already_closed"
mock_set.assert_not_called()
mock_lam.assert_not_called()

View file

@ -156,45 +156,28 @@ class TestGenerateFormStructural:
assert "x-api-key" not in html
def test_cloud_configuration_has_no_form_api_key(self):
template = (REPO_ROOT / "template.yaml").read_text()
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
readme = (REPO_ROOT / "README.md").read_text()
for text in (template, workflow):
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
for text in (workflow, http_api):
assert "FORM_APIKEY" not in text
assert "form-api-key" not in text
assert "x-api-key" not in text
assert "--api-key" not in text
assert (
"`meal-order-manager/form-api-key` secret remains until this change "
"is deployed and verified"
) in readme
def test_submit_route_has_explicit_throttling(self):
template = (REPO_ROOT / "template.yaml").read_text()
assert "'POST /api/submit-order':" in template
submit_settings = template.split("'POST /api/submit-order':", 1)[1].split(
"CorsConfiguration:", 1
)[0]
assert "ThrottlingBurstLimit: 10" in submit_settings
assert "ThrottlingRateLimit: 5" in submit_settings
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
assert "SUBMIT_RATE_PER_SEC = 5.0" in http_api
assert "def _allow_submit()" in http_api
def test_weekly_menu_uses_iam_protected_api_without_dynamodb(self):
template = (REPO_ROOT / "template.yaml").read_text()
def test_weekly_menu_uses_hmac_publish_without_dynamodb(self):
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text()
locals_tf = (REPO_ROOT / "terraform" / "locals.tf").read_text()
for route in ("/api/publish/settings", "/api/publish/menu"):
next_event = (
" PublishMenu:"
if route == "/api/publish/settings"
else " AdminOrders:"
)
route_config = template.split(f"Path: {route}", 1)[1].split(next_event, 1)[
0
]
assert "Authorizer: AWS_IAM" in route_config
assert route in script
assert 'authorizer = "HMAC"' in locals_tf
assert "X-Meals-Publish-Key" in script
assert "scripts/upload_menu.py settings" in workflow
assert "scripts/upload_menu.py publish" in workflow
assert "aws dynamodb" not in workflow

View file

@ -29,8 +29,8 @@ def test_get_parameter_refetches_after_ttl():
assert mock_ssm.get_parameter.call_count == 2
def test_get_secret_stays_cached():
"""Secrets Manager values remain cached (no TTL)."""
def test_get_secret_refetches_after_ttl():
"""Secrets Manager values expire so a long-lived task observes rotation."""
from shared import secrets
secrets._secret_cache.clear()
@ -43,8 +43,13 @@ def test_get_secret_stays_cached():
]
with patch.object(secrets, "_sm", mock_sm):
with patch("shared.secrets.time.monotonic", side_effect=[0.0, 5000.0]):
assert secrets.get_secret("arn:aws:secret") == "a"
assert secrets.get_secret("arn:aws:secret") == "a"
with patch.object(secrets, "PARAM_CACHE_TTL_SECONDS", 10.0):
with patch(
"shared.secrets.time.monotonic",
side_effect=[0.0, 5.0, 15.0],
):
assert secrets.get_secret("arn:aws:secret") == "a"
assert secrets.get_secret("arn:aws:secret") == "a"
assert secrets.get_secret("arn:aws:secret") == "b"
assert mock_sm.get_secret_value.call_count == 1
assert mock_sm.get_secret_value.call_count == 2

View file

@ -2,105 +2,52 @@
import sys
import os
from datetime import datetime
from decimal import Decimal
from unittest.mock import patch
from zoneinfo import ZoneInfo
# ---------------------------------------------------------------------------
# Ensure the handler module can be imported. The shared layer lives under
# src/shared/ and the handler lives under functions/slack_notifier/.
# src/shared/ and the handler lives under src/server/jobs/.
# ---------------------------------------------------------------------------
import importlib.util
_repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(_repo, "src", "shared"))
_handler_path = os.path.join(_repo, "functions", "slack_notifier", "handler.py")
_handler_path = os.path.join(_repo, "src", "server", "jobs", "notify.py")
_spec = importlib.util.spec_from_file_location("slack_notifier_handler", _handler_path)
handler = importlib.util.module_from_spec(_spec)
sys.modules["slack_notifier_handler"] = handler
_spec.loader.exec_module(handler)
ET = ZoneInfo("America/New_York")
# ────────────────────────────────────────────────────────────────────────────
# Helpers
# Reminder delayed SQS delivery
# ────────────────────────────────────────────────────────────────────────────
def _make_datetime(year, month, day, hour, minute=0):
"""Return a timezone-aware datetime in America/New_York."""
return datetime(year, month, day, hour, minute, tzinfo=ET)
def _thursday_10am():
"""2026-05-14 is a Thursday."""
return _make_datetime(2026, 5, 14, 10)
def _thursday_11am():
return _make_datetime(2026, 5, 14, 11)
def _wednesday_10am():
"""2026-05-13 is a Wednesday."""
return _make_datetime(2026, 5, 13, 10)
# ────────────────────────────────────────────────────────────────────────────
# Reminder Dedup Guard (Critical)
# ────────────────────────────────────────────────────────────────────────────
class TestReminderDedupGuard:
@patch("slack_notifier_handler.datetime")
def test_reminder_skipped_wrong_hour(self, mock_dt):
"""Invoked at 11am Thursday ET -> returns skipped."""
mock_dt.now.return_value = _thursday_11am()
class TestReminderDelayedDelivery:
@patch("slack_notifier_handler.send_dm")
@patch("slack_notifier_handler.get_orders", return_value=[])
@patch("slack_notifier_handler.get_roster", return_value=[])
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
def test_reminder_runs_after_scheduled_hour(
self, mock_week, mock_roster, mock_orders, mock_dm
):
result = handler.handle_reminder({"event": "reminder"})
assert result["status"] == "skipped", "Should skip when hour is not 10"
assert "outside reminder window" in result["reason"]
@patch("slack_notifier_handler.datetime")
def test_reminder_skipped_wrong_day(self, mock_dt):
"""Invoked at 10am Wednesday ET -> returns skipped."""
mock_dt.now.return_value = _wednesday_10am()
result = handler.handle_reminder({"event": "reminder"})
assert result["status"] == "skipped", "Should skip when day is not Thursday"
assert "outside reminder window" in result["reason"]
assert result["status"] != "skipped"
@patch("slack_notifier_handler.send_dm")
@patch("slack_notifier_handler.get_orders", return_value=[])
@patch("slack_notifier_handler.get_roster", return_value=[])
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
@patch("slack_notifier_handler.datetime")
def test_reminder_runs_at_correct_time(
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
def test_lambda_handler_reminder_does_not_skip(
self, mock_week, mock_roster, mock_orders, mock_dm
):
"""Invoked at 10am Thursday ET -> proceeds (does not skip)."""
mock_dt.now.return_value = _thursday_10am()
result = handler.handle_reminder({"event": "reminder"})
assert result["status"] != "skipped", "Should not skip at 10am Thursday"
@patch("slack_notifier_handler.datetime")
def test_lambda_handler_reminder_guard(self, mock_dt):
"""lambda_handler lines 32-34 also return skipped for wrong time."""
mock_dt.now.return_value = _thursday_11am()
result = handler.lambda_handler({"event": "reminder"}, None)
assert result["status"] == "skipped", (
"lambda_handler should short-circuit before calling handle_reminder"
)
assert "outside reminder window" in result["reason"]
assert result["status"] != "skipped"
# ────────────────────────────────────────────────────────────────────────────
@ -113,12 +60,10 @@ class TestReminderDMs:
@patch("slack_notifier_handler.get_orders")
@patch("slack_notifier_handler.get_roster")
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
@patch("slack_notifier_handler.datetime")
def test_reminder_sends_to_non_ordered(
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
self, mock_week, mock_roster, mock_orders, mock_dm
):
"""Roster of 3, 1 has ordered -> DMs sent to 2 others."""
mock_dt.now.return_value = _thursday_10am()
mock_roster.return_value = [
{"email": "alice@x.com", "name": "Alice A", "slack_user_id": "U001"},
{"email": "bob@x.com", "name": "Bob B", "slack_user_id": "U002"},
@ -141,12 +86,10 @@ class TestReminderDMs:
@patch("slack_notifier_handler.get_orders", return_value=[])
@patch("slack_notifier_handler.get_roster")
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
@patch("slack_notifier_handler.datetime")
def test_reminder_skips_no_slack_id(
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
self, mock_week, mock_roster, mock_orders, mock_dm
):
"""Employee without slack_user_id is counted as missing but not DM'd."""
mock_dt.now.return_value = _thursday_10am()
mock_roster.return_value = [
{"email": "dave@x.com", "name": "Dave D"}, # no slack_user_id
{"email": "eve@x.com", "name": "Eve E", "slack_user_id": "U005"},
@ -163,12 +106,10 @@ class TestReminderDMs:
@patch("slack_notifier_handler.get_orders")
@patch("slack_notifier_handler.get_roster")
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
@patch("slack_notifier_handler.datetime")
def test_reminder_case_insensitive_email(
self, mock_dt, mock_week, mock_roster, mock_orders, mock_dm
self, mock_week, mock_roster, mock_orders, mock_dm
):
"""'Adam@x.com' in roster matches 'adam@x.com' in orders."""
mock_dt.now.return_value = _thursday_10am()
mock_roster.return_value = [
{"email": "Adam@x.com", "name": "Adam M", "slack_user_id": "U010"},
]

View file

@ -1,4 +1,4 @@
"""Unit tests for functions/submit_order/handler.py
"""Unit tests for src/server/http_api.py
All external dependencies (DynamoDB, SSM, Google tokeninfo, Lambda invoke) are
mocked — no real AWS calls are made.
@ -7,6 +7,7 @@ mocked — no real AWS calls are made.
import json
import os
import sys
import time
import urllib.error
from datetime import datetime
from decimal import Decimal
@ -28,7 +29,7 @@ os.environ.setdefault("GOOGLE_CLIENT_ID_PARAM", "")
import importlib.util
_handler_path = os.path.join(
os.path.dirname(__file__), os.pardir, "functions", "submit_order", "handler.py"
os.path.dirname(__file__), os.pardir, "src", "server", "http_api.py"
)
_spec = importlib.util.spec_from_file_location(
"submit_order_handler", os.path.abspath(_handler_path)
@ -147,6 +148,56 @@ def test_publish_settings_returns_only_pricing(mock_settings):
}
@patch.dict(os.environ, {"PUBLISH_KEY_PARAM": "/meal-order-manager/publish-key"})
@patch("submit_order_handler.get_parameter", return_value="publish-secret")
def test_publish_settings_rejects_missing_key(mock_param):
status, body = _parse_response(
submit_order_handler.lambda_handler(
_make_event(method="GET", path="/api/publish/settings"), None
)
)
assert status == 403
assert body == {"error": "Forbidden"}
@patch.dict(os.environ, {"PUBLISH_KEY_PARAM": "/meal-order-manager/publish-key"})
@patch("submit_order_handler.get_parameter", return_value="publish-secret")
@patch(
"submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 50},
)
def test_publish_settings_accepts_matching_key(mock_settings, mock_param):
status, body = _parse_response(
submit_order_handler.lambda_handler(
_make_event(
method="GET",
path="/api/publish/settings",
headers={"X-Meals-Publish-Key": "publish-secret"},
),
None,
)
)
assert status == 200
assert body["bulk_discount_percent"] == 10.0
@patch.dict(os.environ, {"STAGE": "prod"})
def test_submit_throttle_returns_429_when_tokens_exhausted():
original_tokens = submit_order_handler._submit_tokens
original_last = submit_order_handler._submit_last
submit_order_handler._submit_tokens = 0.0
submit_order_handler._submit_last = time.monotonic()
try:
status, body = _parse_response(
submit_order_handler.lambda_handler(_submit_event([{"name": "x"}]), None)
)
finally:
submit_order_handler._submit_tokens = original_tokens
submit_order_handler._submit_last = original_last
assert status == 429
assert body == {"error": "Rate limit exceeded"}
@patch("submit_order_handler.put_menu")
@patch("submit_order_handler.current_week", return_value="2026-W30")
def test_publish_menu_uses_current_week_and_server_meal_count(mock_week, mock_put):
@ -359,7 +410,7 @@ def _mock_google_tokeninfo():
# ===========================================================================
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -410,7 +461,7 @@ def test_discount_two_step_rounding(
)
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -456,7 +507,7 @@ def test_discount_rounding_half_up_boundary(
)
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -502,7 +553,7 @@ def test_discount_clamping(
)
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -551,7 +602,7 @@ def test_discount_both_zero(
)
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -610,7 +661,7 @@ def test_total_summation_multiple_items(
# ===========================================================================
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -720,7 +771,7 @@ def test_in_handler_admin_check_accepts_portal_token(
assert user == {"name": "Portal Admin", "email": "portal.admin@seahaven.com"}
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -761,7 +812,7 @@ def test_missing_ssm_param_with_env_var_fails_closed(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -798,7 +849,7 @@ def test_google_auth_required_when_configured(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -840,7 +891,7 @@ def test_google_auth_bypass_prevention(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -889,7 +940,7 @@ def test_google_token_audience_mismatch(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -937,7 +988,7 @@ def test_google_token_domain_mismatch(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -978,7 +1029,7 @@ def test_google_token_service_unavailable(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1027,7 +1078,7 @@ def test_google_token_http_error_returns_403(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1066,7 +1117,7 @@ def test_ssm_failure_fails_closed(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1121,7 +1172,7 @@ def test_google_token_valid_seahaven_com_domain(
assert saved_order["employee_email"] == "test@seahaven.com"
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1185,7 +1236,7 @@ def test_google_token_valid_success(
)
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1219,7 +1270,7 @@ def test_missing_google_client_id_fails_closed(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1258,7 +1309,7 @@ def test_submit_requires_no_api_key(
# ===========================================================================
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1307,7 +1358,7 @@ def test_slug_from_email(
)
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1491,7 +1542,7 @@ def test_form_status_closed_saturday_before_dst_end(mock_week, mock_status):
# ===========================================================================
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1526,7 +1577,7 @@ def test_submit_missing_name(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1559,7 +1610,7 @@ def test_submit_missing_email(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1588,7 +1639,7 @@ def test_submit_zero_quantity_only(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="closed")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1617,7 +1668,7 @@ def test_submit_form_closed(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="closed")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1672,7 +1723,7 @@ def test_admin_can_submit_when_form_closed(
mock_put.assert_called_once()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="closed")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1727,7 +1778,7 @@ def test_non_admin_blocked_when_form_closed(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="not_found")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1756,7 +1807,7 @@ def test_submit_no_menu(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1792,7 +1843,7 @@ def test_unknown_meal_returns_400(
mock_put.assert_not_called()
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1828,7 +1879,7 @@ def test_retail_price_from_menu_not_request_body(
assert saved["total"] == 100.0, saved["total"]
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")
@ -1866,7 +1917,7 @@ def test_menu_missing_priced_meals_returns_503(
# ===========================================================================
@patch("submit_order_handler._lambda")
@patch("submit_order_handler._dispatch_job")
@patch("submit_order_handler.put_order")
@patch("submit_order_handler.get_form_status", return_value="open")
@patch("submit_order_handler.current_week", return_value="2026-W20")

View file

@ -1,4 +1,4 @@
"""Structural checks for portal Cognito ID-token configuration."""
"""Portal Cognito ID-token configuration is wired into the Fargate task."""
from pathlib import Path
@ -35,15 +35,15 @@ def test_portal_cognito_parameters_are_managed():
assert 'variable "portal_cognito_extra_trust"' in variables_tf
def test_both_api_lambdas_receive_parameter_names():
lambda_tf = _read("lambda.tf")
def test_ecs_task_receives_cognito_parameter_names():
ecs_tf = _read("ecs.tf")
assert lambda_tf.count("PORTAL_COGNITO_ISSUER_PARAM") == 2
assert lambda_tf.count("PORTAL_COGNITO_AUDIENCE_PARAM") == 2
assert lambda_tf.count("PORTAL_COGNITO_TRUST_PARAM") == 2
assert lambda_tf.count("aws_ssm_parameter.portal_cognito_issuer.name") == 2
assert lambda_tf.count("aws_ssm_parameter.portal_cognito_audience.name") == 2
assert lambda_tf.count("aws_ssm_parameter.portal_cognito_trust.name") == 2
assert ecs_tf.count("PORTAL_COGNITO_ISSUER_PARAM") == 1
assert ecs_tf.count("PORTAL_COGNITO_AUDIENCE_PARAM") == 1
assert ecs_tf.count("PORTAL_COGNITO_TRUST_PARAM") == 1
assert "aws_ssm_parameter.portal_cognito_issuer.name" in ecs_tf
assert "aws_ssm_parameter.portal_cognito_audience.name" in ecs_tf
assert "aws_ssm_parameter.portal_cognito_trust.name" in ecs_tf
def test_submit_route_remains_public_for_google_compatibility():
@ -52,4 +52,6 @@ def test_submit_route_remains_public_for_google_compatibility():
assert 'route_key = "POST /api/submit-order"' in submit_route
assert 'authorizer = "NONE"' in submit_route
assert 'authorizer_type = "JWT"' not in _read("apigateway.tf")
assert "aws_apigatewayv2" not in "\n".join(
(TERRAFORM / name).read_text() for name in ("cloudfront.tf", "ecs.tf")
)

View file

@ -1,4 +1,4 @@
"""email_report is removed from Terraform, SAM, and the functions tree (PLAT-135)."""
"""email_report and the Lambda/API Gateway surface stay gone (PLAT-135 / PLAT-215)."""
from pathlib import Path
@ -6,36 +6,31 @@ ROOT = Path(__file__).resolve().parents[1]
TERRAFORM = ROOT / "terraform"
def _read(name: str) -> str:
return (TERRAFORM / name).read_text()
def test_email_report_handler_removed():
assert not (ROOT / "functions" / "email_report").exists()
assert not (ROOT / "functions").exists()
def test_email_report_not_in_function_packages():
locals_tf = _read("locals.tf")
def test_lambda_and_api_gateway_packaging_removed():
for name in (
"lambda.tf",
"apigateway.tf",
"events.tf",
"artifacts.tf",
"build_packages.sh",
"build_packages_external.sh",
):
assert not (TERRAFORM / name).exists()
assert not (ROOT / "template.yaml").exists()
def test_email_report_not_in_remaining_config():
locals_tf = (TERRAFORM / "locals.tf").read_text()
iam_tf = (TERRAFORM / "iam.tf").read_text()
alarms = (TERRAFORM / "alarms.tf").read_text()
outputs = (TERRAFORM / "outputs.tf").read_text()
variables = (TERRAFORM / "variables.tf").read_text()
assert '"email_report"' not in locals_tf
packages_sh = _read("build_packages.sh")
assert "email_report" not in packages_sh
def test_payroll_email_schedules_removed():
events = _read("events.tf")
assert "payroll-email-est" not in events
assert "payroll-email-edt" not in events
assert "email_report" not in events
def test_email_report_lambda_and_role_removed():
lambda_tf = _read("lambda.tf")
iam_tf = _read("iam.tf")
alarms = _read("alarms.tf")
outputs = _read("outputs.tf")
variables = _read("variables.tf")
assert "aws_lambda_function.email_report" not in lambda_tf
assert "aws_iam_role.email_report" not in iam_tf
assert "ses:SendRawEmail" not in iam_tf
assert "email-report" not in alarms
assert "email_report" not in outputs
@ -43,10 +38,9 @@ def test_email_report_lambda_and_role_removed():
assert "sender_email" not in variables
def test_email_report_removed_from_sam_template():
template = (ROOT / "template.yaml").read_text()
assert "EmailReportFunction" not in template
assert "functions/email_report/" not in template
assert "PayrollEmail" not in template
assert "SenderEmail" not in template
assert "ses:SendRawEmail" not in template
def test_job_schedules_disabled_outside_prod():
scheduler = (TERRAFORM / "scheduler.tf").read_text()
assert (
'state = local.is_prod ? "ENABLED" : "DISABLED"'
in scheduler
)

View file

@ -4,22 +4,24 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TERRAFORM = ROOT / "terraform"
SERVER = ROOT / "src" / "server"
def _read(name: str) -> str:
return (TERRAFORM / name).read_text()
def test_public_menu_route_and_scoped_lambda_permission():
def test_public_menu_route_and_hmac_publish():
locals_tf = _read("locals.tf")
assert 'route_key = "GET /api/menu/{week}"' in locals_tf
assert 'authorizer = "NONE"' in locals_tf
assert 'authorizer = "HMAC"' in locals_tf
assert 'permission_source = "GET/api/menu/*"' in locals_tf
def test_cors_allows_form_portal_and_local_origins():
api = _read("apigateway.tf")
app = (SERVER / "app.py").read_text()
for origin in (
"https://orders.seahaven.com",
@ -28,20 +30,17 @@ def test_cors_allows_form_portal_and_local_origins():
"http://localhost:5173",
"http://localhost:4173",
):
assert f'"{origin}"' in api
assert 'allow_headers = ["Authorization", "Content-Type"]' in api
assert 'allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]' in api
assert "allow_credentials = false" in api
assert f'"{origin}"' in app
assert "Authorization, Content-Type, X-Meals-Publish-Key" in app
assert "GET, POST, PUT, DELETE, OPTIONS" in app
def test_cloudfront_forwards_and_caches_menu_by_origin_for_60_seconds():
cloudfront = _read("cloudfront.tf")
assert 'origin_id = "OrderApiOrigin"' in cloudfront
assert (
'domain_name = trimprefix(aws_apigatewayv2_api.order_api.api_endpoint, "https://")'
in cloudfront
)
assert "domain_name = aws_lb.api.dns_name" in cloudfront
assert 'origin_protocol_policy = "http-only"' in cloudfront
assert 'path_pattern = "/api/menu/*"' in cloudfront
assert 'target_origin_id = "OrderApiOrigin"' in cloudfront
assert (
@ -58,7 +57,7 @@ def test_cloudfront_forwards_and_caches_menu_by_origin_for_60_seconds():
assert 'items = ["Origin"]' in cloudfront
def test_cloudfront_forwards_portal_api_paths_without_publish_or_roster():
def test_cloudfront_forwards_portal_api_paths_without_publish_wildcard():
cloudfront = _read("cloudfront.tf")
locals_tf = _read("locals.tf")
@ -69,11 +68,11 @@ def test_cloudfront_forwards_portal_api_paths_without_publish_or_roster():
'"/api/orders/*"',
'"/api/submit-order"',
'"/api/admin/*"',
'"/api/roster"',
):
assert pattern in cloudfront
assert 'path_pattern = "/api/*"' not in cloudfront
assert "/api/publish" not in cloudfront
assert "/api/roster" not in cloudfront
assert "custom_error_response" not in cloudfront
assert 'resource "aws_cloudfront_function" "form_spa_rewrite"' in cloudfront
assert "function_arn = aws_cloudfront_function.form_spa_rewrite.arn" in cloudfront

View file

@ -0,0 +1,26 @@
"""Meals owns a dedicated VPC. Prod has no default VPC."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TERRAFORM = ROOT / "terraform"
def _read(name: str) -> str:
return (TERRAFORM / name).read_text()
def test_meals_owns_a_vpc_instead_of_looking_up_default():
vpc = _read("vpc.tf")
ecs = _read("ecs.tf")
locals_tf = _read("locals.tf")
assert 'resource "aws_vpc" "this"' in vpc
assert "cidr_block = local.vpc_cidr" in vpc
assert 'vpc_cidr = "10.60.0.0/16"' in locals_tf
assert 'data "aws_vpc" "default"' not in ecs
assert "data.aws_vpc.default" not in ecs
assert "data.aws_subnets.default" not in ecs
assert "aws_vpc.this.id" in ecs
assert "aws_subnet.public[*].id" in ecs
assert "ec2:CreateVpc" in _read("hcp_iam.tf")

53
tests/test_worker.py Normal file
View file

@ -0,0 +1,53 @@
"""SQS worker deletes completed jobs and leaves skipped messages for retry."""
import json
from unittest.mock import MagicMock, patch
from server import worker
def _one_message_then_stop(body: dict):
def receive_message(**_kwargs):
worker._stop(None, None)
return {
"Messages": [
{
"ReceiptHandle": "rh-1",
"Body": json.dumps(body),
}
]
}
return receive_message
@patch("server.worker.boto3.client")
@patch("server.worker.run_job")
def test_worker_does_not_delete_skipped_jobs(mock_run, mock_client):
sqs = MagicMock()
mock_client.return_value = sqs
sqs.receive_message.side_effect = _one_message_then_stop({"event": "close"})
mock_run.return_value = {"status": "skipped", "reason": "outside window"}
with patch.dict("os.environ", {"JOBS_QUEUE_URL": "https://sqs.example/jobs"}):
worker._running = True
worker.main()
sqs.delete_message.assert_not_called()
@patch("server.worker.boto3.client")
@patch("server.worker.run_job")
def test_worker_deletes_completed_jobs(mock_run, mock_client):
sqs = MagicMock()
mock_client.return_value = sqs
sqs.receive_message.side_effect = _one_message_then_stop({"event": "close"})
mock_run.return_value = {"status": "closed", "week": "2026-W19"}
with patch.dict("os.environ", {"JOBS_QUEUE_URL": "https://sqs.example/jobs"}):
worker._running = True
worker.main()
sqs.delete_message.assert_called_once_with(
QueueUrl="https://sqs.example/jobs", ReceiptHandle="rh-1"
)