From f48a82c47617008da2a4b831d9d13757a46fa80f Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:34:24 +0000 Subject: [PATCH] feat(api): serve meals on ECS Fargate instead of Lambda (PLAT-215) (#199) * 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 --- .dockerignore | 13 + .github/workflows/build-layer.yml | 68 - .github/workflows/ci.yml | 7 +- .github/workflows/deploy-api.yaml | 237 ++++ .github/workflows/weekly-menu.yml | 21 +- .security-review/suppressions.json | 8 + Dockerfile | 17 + README.md | 126 +- functions/admin_authorizer/requirements.txt | 1 - functions/aggregate_orders/requirements.txt | 1 - functions/close_form/handler.py | 41 - functions/close_form/requirements.txt | 1 - functions/slack_notifier/requirements.txt | 1 - functions/submit_order/requirements.txt | 1 - infra/layer-artifacts-role/README.md | 39 - .../permissions-policy.json | 26 - infra/layer-artifacts-role/trust-policy.json | 19 - requirements-api.txt | 6 + samconfig.toml.example | 10 - scripts/upload_menu.py | 56 +- src/server/__init__.py | 1 + .../server/admin_authorizer.py | 0 src/server/app.py | 462 ++----- src/server/entrypoint.py | 71 ++ src/server/generate_form.py | 13 +- .../handler.py => src/server/http_api.py | 96 +- src/server/jobs/__init__.py | 48 + .../server/jobs/aggregate.py | 15 +- src/server/jobs/close_form.py | 24 + .../handler.py => src/server/jobs/notify.py | 14 +- .../server/jobs/sync_roster.py | 0 src/server/worker.py | 64 + src/server/wsgi.py | 5 + src/shared/shared/secrets.py | 20 +- src/shared/shared/slack.py | 10 +- template.yaml | 1096 ----------------- terraform/.terraform.lock.hcl | 76 +- terraform/alarms.tf | 285 +---- terraform/apigateway.tf | 136 -- terraform/artifacts.tf | 130 -- terraform/bootstrap/README.md | 12 + terraform/build_packages.sh | 107 -- terraform/build_packages_external.sh | 24 - terraform/cloudfront.tf | 17 +- terraform/data.tf | 2 +- terraform/ecs.tf | 252 ++++ terraform/events.tf | 85 -- terraform/hcp_iam.tf | 304 ++++- terraform/iam.tf | 379 ++---- terraform/iam_github_deploy.tf | 129 ++ terraform/iam_github_weekly_menu.tf | 17 +- terraform/lambda.tf | 213 ---- terraform/locals.tf | 60 +- terraform/logs.tf | 17 +- terraform/outputs.tf | 34 +- terraform/s3.tf | 3 +- terraform/scheduler.tf | 45 + terraform/ssm.tf | 39 +- terraform/versions.tf | 10 +- terraform/vpc.tf | 58 + tests/conftest.py | 6 +- tests/test_admin_authorizer.py | 2 +- tests/test_aggregate_orders.py | 60 +- tests/test_app.py | 47 + tests/test_close_form.py | 92 +- tests/test_generate_form.py | 35 +- tests/test_secrets.py | 17 +- tests/test_slack_notifier.py | 95 +- tests/test_submit_order.py | 117 +- tests/test_terraform_cognito_auth.py | 22 +- tests/test_terraform_email_report.py | 60 +- tests/test_terraform_menu_api.py | 23 +- tests/test_terraform_vpc.py | 26 + tests/test_worker.py | 53 + 74 files changed, 2264 insertions(+), 3463 deletions(-) create mode 100644 .dockerignore delete mode 100644 .github/workflows/build-layer.yml create mode 100644 .github/workflows/deploy-api.yaml create mode 100644 Dockerfile delete mode 100644 functions/admin_authorizer/requirements.txt delete mode 100644 functions/aggregate_orders/requirements.txt delete mode 100644 functions/close_form/handler.py delete mode 100644 functions/close_form/requirements.txt delete mode 100644 functions/slack_notifier/requirements.txt delete mode 100644 functions/submit_order/requirements.txt delete mode 100644 infra/layer-artifacts-role/README.md delete mode 100644 infra/layer-artifacts-role/permissions-policy.json delete mode 100644 infra/layer-artifacts-role/trust-policy.json create mode 100644 requirements-api.txt delete mode 100644 samconfig.toml.example create mode 100644 src/server/__init__.py rename functions/admin_authorizer/handler.py => src/server/admin_authorizer.py (100%) create mode 100644 src/server/entrypoint.py rename functions/submit_order/handler.py => src/server/http_api.py (90%) create mode 100644 src/server/jobs/__init__.py rename functions/aggregate_orders/handler.py => src/server/jobs/aggregate.py (96%) create mode 100644 src/server/jobs/close_form.py rename functions/slack_notifier/handler.py => src/server/jobs/notify.py (92%) rename functions/sync_roster/handler.py => src/server/jobs/sync_roster.py (100%) create mode 100644 src/server/worker.py create mode 100644 src/server/wsgi.py delete mode 100644 template.yaml delete mode 100644 terraform/apigateway.tf delete mode 100644 terraform/artifacts.tf create mode 100644 terraform/bootstrap/README.md delete mode 100755 terraform/build_packages.sh delete mode 100755 terraform/build_packages_external.sh create mode 100644 terraform/ecs.tf delete mode 100644 terraform/events.tf create mode 100644 terraform/iam_github_deploy.tf delete mode 100644 terraform/lambda.tf create mode 100644 terraform/scheduler.tf create mode 100644 terraform/vpc.tf create mode 100644 tests/test_app.py create mode 100644 tests/test_terraform_vpc.py create mode 100644 tests/test_worker.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7d11db5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.venv +**/__pycache__ +**/*.pyc +.pytest_cache +tests +terraform +output +functions +docs +*.md +infra diff --git a/.github/workflows/build-layer.yml b/.github/workflows/build-layer.yml deleted file mode 100644 index 3af7b10..0000000 --- a/.github/workflows/build-layer.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32fe449..dbf3f84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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) diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml new file mode 100644 index 0000000..cb5d63c --- /dev/null +++ b/.github/workflows/deploy-api.yaml @@ -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 diff --git a/.github/workflows/weekly-menu.yml b/.github/workflows/weekly-menu.yml index a96f0a6..30ab53b 100644 --- a/.github/workflows/weekly-menu.yml +++ b/.github/workflows/weekly-menu.yml @@ -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' diff --git a/.security-review/suppressions.json b/.security-review/suppressions.json index bd04a35..220a6c3 100644 --- a/.security-review/suppressions.json +++ b/.security-review/suppressions.json @@ -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." } ] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2960b08 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 1cb48d6..dbdba00 100644 --- a/README.md +++ b/README.md @@ -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-` in project `seahaven-`. 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--`. +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 ``` diff --git a/functions/admin_authorizer/requirements.txt b/functions/admin_authorizer/requirements.txt deleted file mode 100644 index 34c72b0..0000000 --- a/functions/admin_authorizer/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -boto3==1.43.97 diff --git a/functions/aggregate_orders/requirements.txt b/functions/aggregate_orders/requirements.txt deleted file mode 100644 index 34c72b0..0000000 --- a/functions/aggregate_orders/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -boto3==1.43.97 diff --git a/functions/close_form/handler.py b/functions/close_form/handler.py deleted file mode 100644 index 2a509d9..0000000 --- a/functions/close_form/handler.py +++ /dev/null @@ -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} diff --git a/functions/close_form/requirements.txt b/functions/close_form/requirements.txt deleted file mode 100644 index 34c72b0..0000000 --- a/functions/close_form/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -boto3==1.43.97 diff --git a/functions/slack_notifier/requirements.txt b/functions/slack_notifier/requirements.txt deleted file mode 100644 index 34c72b0..0000000 --- a/functions/slack_notifier/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -boto3==1.43.97 diff --git a/functions/submit_order/requirements.txt b/functions/submit_order/requirements.txt deleted file mode 100644 index 34c72b0..0000000 --- a/functions/submit_order/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -boto3==1.43.97 diff --git a/infra/layer-artifacts-role/README.md b/infra/layer-artifacts-role/README.md deleted file mode 100644 index a2b6950..0000000 --- a/infra/layer-artifacts-role/README.md +++ /dev/null @@ -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`. diff --git a/infra/layer-artifacts-role/permissions-policy.json b/infra/layer-artifacts-role/permissions-policy.json deleted file mode 100644 index d4b85ce..0000000 --- a/infra/layer-artifacts-role/permissions-policy.json +++ /dev/null @@ -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": "*" - } - ] -} diff --git a/infra/layer-artifacts-role/trust-policy.json b/infra/layer-artifacts-role/trust-policy.json deleted file mode 100644 index 65df815..0000000 --- a/infra/layer-artifacts-role/trust-policy.json +++ /dev/null @@ -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" - } - } - } - ] -} diff --git a/requirements-api.txt b/requirements-api.txt new file mode 100644 index 0000000..1b0b2dc --- /dev/null +++ b/requirements-api.txt @@ -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 diff --git a/samconfig.toml.example b/samconfig.toml.example deleted file mode 100644 index 75d7b60..0000000 --- a/samconfig.toml.example +++ /dev/null @@ -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" diff --git a/scripts/upload_menu.py b/scripts/upload_menu.py index b9b5f12..ff9f832 100644 --- a/scripts/upload_menu.py +++ b/scripts/upload_menu.py @@ -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) diff --git a/src/server/__init__.py b/src/server/__init__.py new file mode 100644 index 0000000..2eac950 --- /dev/null +++ b/src/server/__init__.py @@ -0,0 +1 @@ +"""Production Flask API package.""" diff --git a/functions/admin_authorizer/handler.py b/src/server/admin_authorizer.py similarity index 100% rename from functions/admin_authorizer/handler.py rename to src/server/admin_authorizer.py diff --git a/src/server/app.py b/src/server/app.py index c3539b6..6148b86 100644 --- a/src/server/app.py +++ b/src/server/app.py @@ -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/") -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/", 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/") -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() diff --git a/src/server/entrypoint.py b/src/server/entrypoint.py new file mode 100644 index 0000000..3b50990 --- /dev/null +++ b/src/server/entrypoint.py @@ -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() diff --git a/src/server/generate_form.py b/src/server/generate_form.py index 26819d2..0cc2c22 100644 --- a/src/server/generate_form.py +++ b/src/server/generate_form.py @@ -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}") diff --git a/functions/submit_order/handler.py b/src/server/http_api.py similarity index 90% rename from functions/submit_order/handler.py rename to src/server/http_api.py index a006de3..cc32671 100644 --- a/functions/submit_order/handler.py +++ b/src/server/http_api.py @@ -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, diff --git a/src/server/jobs/__init__.py b/src/server/jobs/__init__.py new file mode 100644 index 0000000..116abb8 --- /dev/null +++ b/src/server/jobs/__init__.py @@ -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) diff --git a/functions/aggregate_orders/handler.py b/src/server/jobs/aggregate.py similarity index 96% rename from functions/aggregate_orders/handler.py rename to src/server/jobs/aggregate.py index 7094328..e2f8f42 100644 --- a/functions/aggregate_orders/handler.py +++ b/src/server/jobs/aggregate.py @@ -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)} diff --git a/src/server/jobs/close_form.py b/src/server/jobs/close_form.py new file mode 100644 index 0000000..cad934f --- /dev/null +++ b/src/server/jobs/close_form.py @@ -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} diff --git a/functions/slack_notifier/handler.py b/src/server/jobs/notify.py similarity index 92% rename from functions/slack_notifier/handler.py rename to src/server/jobs/notify.py index f4324dd..9cf7baa 100644 --- a/functions/slack_notifier/handler.py +++ b/src/server/jobs/notify.py @@ -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", "") diff --git a/functions/sync_roster/handler.py b/src/server/jobs/sync_roster.py similarity index 100% rename from functions/sync_roster/handler.py rename to src/server/jobs/sync_roster.py diff --git a/src/server/worker.py b/src/server/worker.py new file mode 100644 index 0000000..524e297 --- /dev/null +++ b/src/server/worker.py @@ -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() diff --git a/src/server/wsgi.py b/src/server/wsgi.py new file mode 100644 index 0000000..23b3233 --- /dev/null +++ b/src/server/wsgi.py @@ -0,0 +1,5 @@ +"""Gunicorn entrypoint.""" + +from server.app import app + +__all__ = ["app"] diff --git a/src/shared/shared/secrets.py b/src/shared/shared/secrets.py index 3738453..81dea3c 100644 --- a/src/shared/shared/secrets.py +++ b/src/shared/shared/secrets.py @@ -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: diff --git a/src/shared/shared/slack.py b/src/shared/shared/slack.py index 8b28545..706f3de 100644 --- a/src/shared/shared/slack.py +++ b/src/shared/shared/slack.py @@ -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 diff --git a/template.yaml b/template.yaml deleted file mode 100644 index 19ae498..0000000 --- a/template.yaml +++ /dev/null @@ -1,1096 +0,0 @@ -AWSTemplateFormatVersion: '2010-09-09' -Transform: AWS::Serverless-2016-10-31 -Description: > - meal-order-manager — automated weekly meal ordering from Redefine Meals - with employee order collection, Slack notifications, and payroll deduction reports. - -Parameters: - CustomDomain: - Type: String - Default: orders.seahaven.com - Description: Custom domain for the order form (requires ACM cert) - CertificateArn: - Type: String - Default: '' - Description: ACM certificate ARN for the custom domain (us-east-1) - # Shared CloudFront WAF WebACL ARN (audit M-17), published to SSM by - # seahaven-account-baseline. Resolved at deploy time. - WebAclArn: - Type: AWS::SSM::Parameter::Value - Default: /seahaven/waf/app-web-acl-arn - Description: ARN of the shared seahaven-app-waf CloudFront WebACL -Conditions: - HasCustomDomain: !Not [!Equals [!Ref CertificateArn, '']] - -Globals: - Function: - Runtime: python3.12 - Architectures: - - arm64 - Timeout: 30 - MemorySize: 256 - PermissionsBoundary: arn:aws:iam::328440206208:policy/seahaven-lambda-execution-boundary - Environment: - Variables: - TABLE_NAME: !Ref OrdersTable - REPORTS_BUCKET: !Ref ReportsBucket - SLACK_CHANNEL_PARAM: /meal-order-manager/slack-channel-id - FORM_URL: !If - - HasCustomDomain - - !Sub 'https://${CustomDomain}' - - !Sub 'https://${FormDistribution.DomainName}' - SLACK_BOT_SM_NAME: meal-order-manager/slack-bot-token - Layers: - - !Ref SharedLayer - -Resources: - - # ─── Shared Layer ─────────────────────────────────────────────── - - SharedLayer: - Type: AWS::Serverless::LayerVersion - Properties: - LayerName: meal-order-manager-shared - ContentUri: src/shared/ - CompatibleRuntimes: - - python3.12 - CompatibleArchitectures: - - arm64 - Metadata: - BuildMethod: python3.12 - BuildArchitecture: arm64 - - # ─── DynamoDB ─────────────────────────────────────────────────── - - OrdersTable: - Type: AWS::DynamoDB::Table - Properties: - TableName: meal-order-manager-orders - BillingMode: PAY_PER_REQUEST - AttributeDefinitions: - - AttributeName: PK - AttributeType: S - - AttributeName: SK - AttributeType: S - KeySchema: - - AttributeName: PK - KeyType: HASH - - AttributeName: SK - KeyType: RANGE - TimeToLiveSpecification: - AttributeName: ttl - Enabled: true - - # ─── S3 Buckets ──────────────────────────────────────────────── - - FormBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: !Sub 'meal-order-manager-form-${AWS::AccountId}' - PublicAccessBlockConfiguration: - BlockPublicAcls: true - BlockPublicPolicy: true - IgnorePublicAcls: true - RestrictPublicBuckets: true - LifecycleConfiguration: - Rules: - - Id: delete-old-archives - Prefix: archive/ - Status: Enabled - ExpirationInDays: 90 - Tags: - - Key: Purpose - Value: meal-order-form-hosting - - Key: ManagedBy - Value: meal-order-manager - - FormBucketPolicy: - Type: AWS::S3::BucketPolicy - Properties: - Bucket: !Ref FormBucket - PolicyDocument: - Version: '2012-10-17' - Statement: - - Sid: AllowCloudFrontOAC - Effect: Allow - Principal: - Service: cloudfront.amazonaws.com - Action: s3:GetObject - Resource: !Sub '${FormBucket.Arn}/*' - Condition: - StringEquals: - AWS:SourceArn: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${FormDistribution}' - - ReportsBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: !Sub 'meal-order-manager-reports-${AWS::AccountId}' - PublicAccessBlockConfiguration: - BlockPublicAcls: true - BlockPublicPolicy: true - IgnorePublicAcls: true - RestrictPublicBuckets: true - LifecycleConfiguration: - Rules: - - Id: archive-old-reports - Status: Enabled - Transitions: - - StorageClass: GLACIER_IR - TransitionInDays: 90 - Tags: - - Key: Purpose - Value: meal-order-reports - - Key: ManagedBy - Value: meal-order-manager - - # ─── CloudFront ──────────────────────────────────────────────── - - FormOAC: - Type: AWS::CloudFront::OriginAccessControl - Properties: - OriginAccessControlConfig: - Name: meal-order-manager-oac - OriginAccessControlOriginType: s3 - SigningBehavior: always - SigningProtocol: sigv4 - - FormDistribution: - Type: AWS::CloudFront::Distribution - Properties: - DistributionConfig: - Enabled: true - DefaultRootObject: index.html - Comment: meal-order-manager form hosting - PriceClass: PriceClass_100 - HttpVersion: http2and3 - WebACLId: !Ref WebAclArn # shared CloudFront WAF (audit M-17) - Aliases: !If - - HasCustomDomain - - [!Ref CustomDomain] - - !Ref AWS::NoValue - ViewerCertificate: !If - - HasCustomDomain - - AcmCertificateArn: !Ref CertificateArn - SslSupportMethod: sni-only - MinimumProtocolVersion: TLSv1.2_2021 - - CloudFrontDefaultCertificate: true - Origins: - - Id: S3FormOrigin - DomainName: !GetAtt FormBucket.RegionalDomainName - OriginAccessControlId: !Ref FormOAC - S3OriginConfig: - OriginAccessIdentity: '' - DefaultCacheBehavior: - TargetOriginId: S3FormOrigin - ViewerProtocolPolicy: redirect-to-https - CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled - Compress: true - AllowedMethods: - - GET - - HEAD - CachedMethods: - - GET - - HEAD - CustomErrorResponses: - - ErrorCode: 403 - ResponseCode: 200 - ResponsePagePath: /index.html - - # ─── API Gateway ─────────────────────────────────────────────── - - ApiAccessLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: /aws/apigateway/meal-order-manager - RetentionInDays: 90 - - OrderApi: - Type: AWS::Serverless::HttpApi - Properties: - StageName: $default - # Gateway-level auth for /api/admin/* routes (INFRA-100). A Lambda - # authorizer validates the same Google ID token (Authorization: Bearer) - # the admin panel already sends, so admin routes are no longer - # AuthorizationType NONE. Public routes (submit-order, form-status, - # roster) stay open, while weekly-menu publication routes opt into IAM. - Auth: - EnableIamAuthorizer: true - Authorizers: - AdminGoogleAuthorizer: - FunctionArn: !GetAtt AdminAuthorizerFunction.Arn - FunctionInvokeRole: !GetAtt AdminAuthorizerInvokeRole.Arn - Identity: - Headers: - - Authorization - AuthorizerPayloadFormatVersion: '2.0' - EnableSimpleResponses: true - # Disable result caching: with caching, an expired Google token or - # an admin removed from admin_emails would stay authorized for the - # cache TTL. The tokeninfo call is the dominant latency anyway. - AuthorizerResultTtlInSeconds: 0 - # No DefaultAuthorizer — routes opt in individually so the public - # routes remain unauthenticated. - # Access logging + default throttling (audit M-18). - AccessLogSettings: - DestinationArn: !GetAtt ApiAccessLogGroup.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"}' - DefaultRouteSettings: - ThrottlingBurstLimit: 50 - ThrottlingRateLimit: 100 - RouteSettings: - 'POST /api/submit-order': - ThrottlingBurstLimit: 10 - ThrottlingRateLimit: 5 - 'POST /api/publish/menu': - ThrottlingBurstLimit: 2 - ThrottlingRateLimit: 1 - # CORS only allows the production domain. For local development, use the - # Flask dev server (app.py) which proxies API requests and doesn't enforce CORS. - CorsConfiguration: - AllowOrigins: - - !If - - HasCustomDomain - - !Sub 'https://${CustomDomain}' - - !Sub 'https://${FormDistribution.DomainName}' - AllowMethods: - - GET - - POST - - PUT - - DELETE - - OPTIONS - AllowHeaders: - - Content-Type - - Authorization - MaxAge: 3600 - - # ─── Lambda Functions ────────────────────────────────────────── - - SubmitOrderFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: meal-order-manager-submit-order - Handler: handler.lambda_handler - CodeUri: functions/submit_order/ - MemorySize: 128 - Timeout: 10 - Environment: - Variables: - SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn - GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id - REPORTS_BUCKET: !Ref ReportsBucket - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref OrdersTable - - Statement: - - Effect: Allow - Action: lambda:InvokeFunction - Resource: !GetAtt SlackNotifierFunction.Arn - - Effect: Allow - Action: ssm:GetParameter - Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*' - # Read-only access to weekly summary PDFs (only — not the - # payroll/order CSVs) for the admin summary-pdf presigned-URL - # endpoint. - - Effect: Allow - Action: s3:GetObject - Resource: !Sub '${ReportsBucket.Arn}/reports/*/weekly-summary-*.pdf' - Events: - SubmitOrder: - Type: HttpApi - Properties: - ApiId: !Ref OrderApi - Path: /api/submit-order - Method: POST - FormStatus: - Type: HttpApi - Properties: - ApiId: !Ref OrderApi - Path: /api/form-status/{week} - Method: GET - Roster: - Type: HttpApi - Properties: - ApiId: !Ref OrderApi - Path: /api/roster - Method: GET - PublishSettings: - Type: HttpApi - Properties: - ApiId: !Ref OrderApi - Path: /api/publish/settings - Method: GET - Auth: - Authorizer: AWS_IAM - PublishMenu: - Type: HttpApi - Properties: - ApiId: !Ref OrderApi - Path: /api/publish/menu - Method: POST - Auth: - Authorizer: AWS_IAM - AdminOrders: - Type: HttpApi - Properties: - ApiId: !Ref OrderApi - Path: /api/admin/orders - Method: GET - Auth: - Authorizer: AdminGoogleAuthorizer - AdminOrdersUpdate: - Type: HttpApi - Properties: - ApiId: !Ref OrderApi - Path: /api/admin/orders - Method: PUT - Auth: - Authorizer: AdminGoogleAuthorizer - AdminOrdersDelete: - Type: HttpApi - Properties: - ApiId: !Ref OrderApi - Path: /api/admin/orders - Method: DELETE - Auth: - Authorizer: AdminGoogleAuthorizer - AdminSummaryPdf: - Type: HttpApi - Properties: - ApiId: !Ref OrderApi - Path: /api/admin/summary-pdf - Method: GET - Auth: - Authorizer: AdminGoogleAuthorizer - - # ─── Admin API Authorizer (INFRA-100) ───────────────────────── - # Lambda authorizer validating the Google ID token + admin-email allow-list - # for every /api/admin/* route. Mirrors submit_order's _verify_admin so the - # existing admin panel works unchanged. - - AdminAuthorizerFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: meal-order-manager-admin-authorizer - Handler: handler.lambda_handler - CodeUri: functions/admin_authorizer/ - MemorySize: 128 - Timeout: 10 - Environment: - Variables: - GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id - Policies: - - DynamoDBReadPolicy: - TableName: !Ref OrdersTable - - Statement: - - Effect: Allow - Action: ssm:GetParameter - Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*' - - AdminAuthorizerLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: !Sub '/aws/lambda/${AdminAuthorizerFunction}' - RetentionInDays: 60 - - # IAM role API Gateway assumes to invoke the authorizer Lambda. - AdminAuthorizerInvokeRole: - Type: AWS::IAM::Role - Properties: - Path: /cfn-managed/ - PermissionsBoundary: arn:aws:iam::328440206208:policy/seahaven-lambda-execution-boundary - AssumeRolePolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Principal: - Service: apigateway.amazonaws.com - Action: sts:AssumeRole - Policies: - - PolicyName: invoke-admin-authorizer - PolicyDocument: - Version: '2012-10-17' - Statement: - - Effect: Allow - Action: lambda:InvokeFunction - Resource: !GetAtt AdminAuthorizerFunction.Arn - - CloseFormFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: meal-order-manager-close-form - Handler: handler.lambda_handler - CodeUri: functions/close_form/ - MemorySize: 128 - Timeout: 30 - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref OrdersTable - - Statement: - - Effect: Allow - Action: lambda:InvokeFunction - Resource: !GetAtt AggregateOrdersFunction.Arn - Environment: - Variables: - AGGREGATE_FUNCTION_ARN: !GetAtt AggregateOrdersFunction.Arn - # Both EST and EDT schedules fire every week year-round. The handler is - # idempotent, so the "wrong timezone" firing is a harmless no-op. - Events: - CloseEST: - Type: Schedule - Properties: - Schedule: cron(59 4 ? * FRI *) - Description: 'Close form Thursday 11:59pm EST (04:59 UTC Friday)' - Enabled: true - CloseEDT: - Type: Schedule - Properties: - Schedule: cron(59 3 ? * FRI *) - Description: 'Close form Thursday 11:59pm EDT (03:59 UTC Friday)' - Enabled: true - - AggregateOrdersFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: meal-order-manager-aggregate-orders - Handler: handler.lambda_handler - CodeUri: functions/aggregate_orders/ - MemorySize: 256 - Timeout: 60 - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref OrdersTable - - S3CrudPolicy: - BucketName: !Ref ReportsBucket - - Statement: - - Effect: Allow - Action: lambda:InvokeFunction - Resource: !GetAtt SlackNotifierFunction.Arn - Environment: - Variables: - SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn - - SlackNotifierFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: meal-order-manager-slack-notifier - Handler: handler.lambda_handler - CodeUri: functions/slack_notifier/ - MemorySize: 128 - Timeout: 30 - Policies: - - DynamoDBReadPolicy: - TableName: !Ref OrdersTable - - Statement: - - Effect: Allow - Action: secretsmanager:GetSecretValue - Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*' - - Effect: Allow - Action: ssm:GetParameter - Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*' - Events: - ReminderEST: - Type: Schedule - Properties: - Schedule: cron(0 15 ? * THU *) - Description: 'DM reminders Thursday 10am EST (15:00 UTC)' - Enabled: true - Input: '{"event": "reminder"}' - ReminderEDT: - Type: Schedule - Properties: - Schedule: cron(0 14 ? * THU *) - Description: 'DM reminders Thursday 10am EDT (14:00 UTC)' - Enabled: true - Input: '{"event": "reminder"}' - - SyncRosterFunction: - Type: AWS::Serverless::Function - Properties: - FunctionName: meal-order-manager-sync-roster - Handler: handler.lambda_handler - CodeUri: functions/sync_roster/ - MemorySize: 128 - Timeout: 60 - Policies: - - DynamoDBCrudPolicy: - TableName: !Ref OrdersTable - - Statement: - - Effect: Allow - Action: secretsmanager:GetSecretValue - Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*' - - Effect: Allow - Action: ssm:GetParameter - Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/meal-order-manager/*' - Events: - SyncEST: - Type: Schedule - Properties: - Schedule: cron(55 11 ? * MON *) - Description: 'Sync roster Monday 6:55am EST (11:55 UTC) — before menu publish' - Enabled: true - SyncEDT: - Type: Schedule - Properties: - Schedule: cron(55 10 ? * MON *) - Description: 'Sync roster Monday 6:55am EDT (10:55 UTC) — before menu publish' - Enabled: true - - # ─── CloudWatch Log Groups (60-day retention) ────────────────── - - SubmitOrderLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: !Sub '/aws/lambda/${SubmitOrderFunction}' - RetentionInDays: 60 - - CloseFormLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: !Sub '/aws/lambda/${CloseFormFunction}' - RetentionInDays: 60 - - AggregateOrdersLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: !Sub '/aws/lambda/${AggregateOrdersFunction}' - RetentionInDays: 60 - - SlackNotifierLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: !Sub '/aws/lambda/${SlackNotifierFunction}' - RetentionInDays: 60 - - SyncRosterLogGroup: - Type: AWS::Logs::LogGroup - Properties: - LogGroupName: !Sub '/aws/lambda/${SyncRosterFunction}' - RetentionInDays: 60 - - # ─── CloudWatch Alarms ───────────────────────────────────────── - # All alarms notify the shared site-alerts SNS topic. No OKActions - # (no recovery spam); TreatMissingData notBreaching so idle / cron - # functions don't sit in ALARM between invocations. - # - # Naming: meal-order-manager-- (repo-namespaced kebab-case). - # - # Duration alarms use ExtendedStatistic p99 at ~80% of each function's - # configured timeout. Synchronous (API-fronted) functions evaluate over - # 3 datapoints; cron / async-invoked functions evaluate a single datapoint - # (they fire too rarely for a multi-datapoint window). - - # Lambda Errors (Sum, 5min, any error breaches) - SubmitOrderErrorsAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-submit-order-errors - AlarmDescription: submit-order Lambda reported one or more errors in 5 minutes. - Namespace: AWS/Lambda - MetricName: Errors - Dimensions: - - Name: FunctionName - Value: !Ref SubmitOrderFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - AdminAuthorizerErrorsAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-admin-authorizer-errors - AlarmDescription: admin-authorizer Lambda reported one or more errors in 5 minutes. - Namespace: AWS/Lambda - MetricName: Errors - Dimensions: - - Name: FunctionName - Value: !Ref AdminAuthorizerFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - CloseFormErrorsAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-close-form-errors - AlarmDescription: close-form Lambda reported one or more errors in 5 minutes. - Namespace: AWS/Lambda - MetricName: Errors - Dimensions: - - Name: FunctionName - Value: !Ref CloseFormFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - AggregateOrdersErrorsAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-aggregate-orders-errors - AlarmDescription: aggregate-orders Lambda reported one or more errors in 5 minutes. - Namespace: AWS/Lambda - MetricName: Errors - Dimensions: - - Name: FunctionName - Value: !Ref AggregateOrdersFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - SlackNotifierErrorsAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-slack-notifier-errors - AlarmDescription: slack-notifier Lambda reported one or more errors in 5 minutes. - Namespace: AWS/Lambda - MetricName: Errors - Dimensions: - - Name: FunctionName - Value: !Ref SlackNotifierFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - SyncRosterErrorsAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-sync-roster-errors - AlarmDescription: sync-roster Lambda reported one or more errors in 5 minutes. - Namespace: AWS/Lambda - MetricName: Errors - Dimensions: - - Name: FunctionName - Value: !Ref SyncRosterFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - # Lambda Throttles (Sum, 5min, any throttle breaches) - SubmitOrderThrottlesAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-submit-order-throttles - AlarmDescription: submit-order Lambda was throttled in the last 5 minutes. - Namespace: AWS/Lambda - MetricName: Throttles - Dimensions: - - Name: FunctionName - Value: !Ref SubmitOrderFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - AdminAuthorizerThrottlesAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-admin-authorizer-throttles - AlarmDescription: admin-authorizer Lambda was throttled in the last 5 minutes. - Namespace: AWS/Lambda - MetricName: Throttles - Dimensions: - - Name: FunctionName - Value: !Ref AdminAuthorizerFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - CloseFormThrottlesAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-close-form-throttles - AlarmDescription: close-form Lambda was throttled in the last 5 minutes. - Namespace: AWS/Lambda - MetricName: Throttles - Dimensions: - - Name: FunctionName - Value: !Ref CloseFormFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - AggregateOrdersThrottlesAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-aggregate-orders-throttles - AlarmDescription: aggregate-orders Lambda was throttled in the last 5 minutes. - Namespace: AWS/Lambda - MetricName: Throttles - Dimensions: - - Name: FunctionName - Value: !Ref AggregateOrdersFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - SlackNotifierThrottlesAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-slack-notifier-throttles - AlarmDescription: slack-notifier Lambda was throttled in the last 5 minutes. - Namespace: AWS/Lambda - MetricName: Throttles - Dimensions: - - Name: FunctionName - Value: !Ref SlackNotifierFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - SyncRosterThrottlesAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-sync-roster-throttles - AlarmDescription: sync-roster Lambda was throttled in the last 5 minutes. - Namespace: AWS/Lambda - MetricName: Throttles - Dimensions: - - Name: FunctionName - Value: !Ref SyncRosterFunction - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - # Lambda Duration p99 (~80% of timeout) - # Synchronous (API-fronted) functions: eval 3 / datapoints 3. - SubmitOrderDurationAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-submit-order-duration - AlarmDescription: submit-order p99 duration exceeded 8000ms (80% of 10s timeout). - Namespace: AWS/Lambda - MetricName: Duration - Dimensions: - - Name: FunctionName - Value: !Ref SubmitOrderFunction - ExtendedStatistic: p99 - Period: 300 - EvaluationPeriods: 3 - DatapointsToAlarm: 3 - Threshold: 8000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - AdminAuthorizerDurationAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-admin-authorizer-duration - AlarmDescription: admin-authorizer p99 duration exceeded 8000ms (80% of 10s timeout). - Namespace: AWS/Lambda - MetricName: Duration - Dimensions: - - Name: FunctionName - Value: !Ref AdminAuthorizerFunction - ExtendedStatistic: p99 - Period: 300 - EvaluationPeriods: 3 - DatapointsToAlarm: 3 - Threshold: 8000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - # Cron / async-invoked functions: single datapoint (eval 1). - CloseFormDurationAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-close-form-duration - AlarmDescription: close-form p99 duration exceeded 24000ms (80% of 30s timeout). - Namespace: AWS/Lambda - MetricName: Duration - Dimensions: - - Name: FunctionName - Value: !Ref CloseFormFunction - ExtendedStatistic: p99 - Period: 300 - EvaluationPeriods: 1 - DatapointsToAlarm: 1 - Threshold: 24000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - AggregateOrdersDurationAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-aggregate-orders-duration - AlarmDescription: aggregate-orders p99 duration exceeded 48000ms (80% of 60s timeout). - Namespace: AWS/Lambda - MetricName: Duration - Dimensions: - - Name: FunctionName - Value: !Ref AggregateOrdersFunction - ExtendedStatistic: p99 - Period: 300 - EvaluationPeriods: 1 - DatapointsToAlarm: 1 - Threshold: 48000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - SlackNotifierDurationAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-slack-notifier-duration - AlarmDescription: slack-notifier p99 duration exceeded 24000ms (80% of 30s timeout). - Namespace: AWS/Lambda - MetricName: Duration - Dimensions: - - Name: FunctionName - Value: !Ref SlackNotifierFunction - ExtendedStatistic: p99 - Period: 300 - EvaluationPeriods: 1 - DatapointsToAlarm: 1 - Threshold: 24000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - SyncRosterDurationAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-sync-roster-duration - AlarmDescription: sync-roster p99 duration exceeded 48000ms (80% of 60s timeout). - Namespace: AWS/Lambda - MetricName: Duration - Dimensions: - - Name: FunctionName - Value: !Ref SyncRosterFunction - ExtendedStatistic: p99 - Period: 300 - EvaluationPeriods: 1 - DatapointsToAlarm: 1 - Threshold: 48000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - # DynamoDB orders table. - # NOTE: DynamoDB does NOT publish ThrottledRequests or SystemErrors at the - # TableName-only dimension (verified via cloudwatch list-metrics on - # 2026-06-17 — those metrics carry a TableName+Operation dimension pair and - # only on-occurrence). A TableName-dim alarm on them would never evaluate. - # The codifiable table-level throttle signals are ReadThrottleEvents and - # WriteThrottleEvents, which DO carry a TableName-only dimension. Those are - # used here for throttle coverage; a TableName-dim SystemErrors alarm is - # omitted (no such metric is emitted). See PR body. - OrdersTableReadThrottleAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-orders-read-throttle - AlarmDescription: orders table read requests were throttled in the last 5 minutes. - Namespace: AWS/DynamoDB - MetricName: ReadThrottleEvents - Dimensions: - - Name: TableName - Value: !Ref OrdersTable - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - OrdersTableWriteThrottleAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-orders-write-throttle - AlarmDescription: orders table write requests were throttled in the last 5 minutes. - Namespace: AWS/DynamoDB - MetricName: WriteThrottleEvents - Dimensions: - - Name: TableName - Value: !Ref OrdersTable - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - # API Gateway (HTTP API v2) — OrderApi. Metrics carry the ApiId dimension. - OrderApi5xxAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-order-api-5xx - AlarmDescription: OrderApi returned one or more 5xx responses in 5 minutes. - Namespace: AWS/ApiGateway - MetricName: 5xx - Dimensions: - - Name: ApiId - Value: !Ref OrderApi - Statistic: Sum - Period: 300 - EvaluationPeriods: 1 - Threshold: 0 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - # 4xx threshold raised + eval 3 / dp 2 to absorb routine 401s from the - # token-based admin authorizer without paging. - OrderApi4xxAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-order-api-4xx - AlarmDescription: OrderApi 4xx responses exceeded 20 in 5 minutes (beyond routine auth noise). - Namespace: AWS/ApiGateway - MetricName: 4xx - Dimensions: - - Name: ApiId - Value: !Ref OrderApi - Statistic: Sum - Period: 300 - EvaluationPeriods: 3 - DatapointsToAlarm: 2 - Threshold: 20 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - # Latency p99 ~3000ms (see PR body). - OrderApiLatencyAlarm: - Type: AWS::CloudWatch::Alarm - Properties: - AlarmName: meal-order-manager-order-api-latency - AlarmDescription: OrderApi p99 latency exceeded 3000ms. - Namespace: AWS/ApiGateway - MetricName: Latency - Dimensions: - - Name: ApiId - Value: !Ref OrderApi - ExtendedStatistic: p99 - Period: 300 - EvaluationPeriods: 3 - DatapointsToAlarm: 3 - Threshold: 3000 - ComparisonOperator: GreaterThanThreshold - TreatMissingData: notBreaching - AlarmActions: - - arn:aws:sns:us-east-1:328440206208:site-alerts - - # ─── SSM Parameters ──────────────────────────────────────────── - - SlackChannelParam: - Type: AWS::SSM::Parameter - Properties: - Name: /meal-order-manager/slack-channel-id - Type: String - Value: CHANGE_ME - Description: Slack channel ID for meal order notifications - - # GoogleClientIdParam (/meal-order-manager/google-client-id) is managed - # manually via AWS CLI since it varies per environment. Create it with: - # aws ssm put-parameter --name /meal-order-manager/google-client-id \ - # --type String --value "" - -Outputs: - ApiUrl: - Description: API Gateway endpoint URL - Value: !Sub 'https://${OrderApi}.execute-api.${AWS::Region}.amazonaws.com' - FormUrl: - Description: Order form URL - Value: !If - - HasCustomDomain - - !Sub 'https://${CustomDomain}' - - !Sub 'https://${FormDistribution.DomainName}' - DistributionId: - Description: CloudFront distribution ID (for cache invalidation) - Value: !Ref FormDistribution - FormBucketName: - Description: S3 bucket for form HTML - Value: !Ref FormBucket - ReportsBucketName: - Description: S3 bucket for CSV reports - Value: !Ref ReportsBucket - OrdersTableName: - Description: DynamoDB table name - Value: !Ref OrdersTable - SubmitOrderFunctionArn: - Description: Submit Order Lambda ARN - Value: !GetAtt SubmitOrderFunction.Arn - CloseFormFunctionArn: - Description: Close Form Lambda ARN - Value: !GetAtt CloseFormFunction.Arn - AggregateOrdersFunctionArn: - Description: Aggregate Orders Lambda ARN - Value: !GetAtt AggregateOrdersFunction.Arn - SlackNotifierFunctionArn: - Description: Slack Notifier Lambda ARN - Value: !GetAtt SlackNotifierFunction.Arn - SyncRosterFunctionArn: - Description: Sync Roster Lambda ARN - Value: !GetAtt SyncRosterFunction.Arn diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl index 4b58051..74219f6 100644 --- a/terraform/.terraform.lock.hcl +++ b/terraform/.terraform.lock.hcl @@ -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", ] } diff --git a/terraform/alarms.tf b/terraform/alarms.tf index 49132ee..96a02d5 100644 --- a/terraform/alarms.tf +++ b/terraform/alarms.tf @@ -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] -} diff --git a/terraform/apigateway.tf b/terraform/apigateway.tf deleted file mode 100644 index 05a8cbd..0000000 --- a/terraform/apigateway.tf +++ /dev/null @@ -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 - } -} diff --git a/terraform/artifacts.tf b/terraform/artifacts.tf deleted file mode 100644 index b2b8c93..0000000 --- a/terraform/artifacts.tf +++ /dev/null @@ -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 -} diff --git a/terraform/bootstrap/README.md b/terraform/bootstrap/README.md new file mode 100644 index 0000000..4293bd6 --- /dev/null +++ b/terraform/bootstrap/README.md @@ -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. diff --git a/terraform/build_packages.sh b/terraform/build_packages.sh deleted file mode 100755 index d5b2540..0000000 --- a/terraform/build_packages.sh +++ /dev/null @@ -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 diff --git a/terraform/build_packages_external.sh b/terraform/build_packages_external.sh deleted file mode 100755 index 799d9cb..0000000 --- a/terraform/build_packages_external.sh +++ /dev/null @@ -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}" diff --git a/terraform/cloudfront.tf b/terraform/cloudfront.tf index d11ec46..156b618 100644 --- a/terraform/cloudfront.tf +++ b/terraform/cloudfront.tf @@ -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" diff --git a/terraform/data.tf b/terraform/data.tf index a21a972..859daee 100644 --- a/terraform/data.tf +++ b/terraform/data.tf @@ -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 } diff --git a/terraform/ecs.tf b/terraform/ecs.tf new file mode 100644 index 0000000..d15fecc --- /dev/null +++ b/terraform/ecs.tf @@ -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)" +} diff --git a/terraform/events.tf b/terraform/events.tf deleted file mode 100644 index 736fc68..0000000 --- a/terraform/events.tf +++ /dev/null @@ -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 -} diff --git a/terraform/hcp_iam.tf b/terraform/hcp_iam.tf index a5fc6eb..96ffb17 100644 --- a/terraform/hcp_iam.tf +++ b/terraform/hcp_iam.tf @@ -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*", diff --git a/terraform/iam.tf b/terraform/iam.tf index c55e82e..dbb9ac6 100644 --- a/terraform/iam.tf +++ b/terraform/iam.tf @@ -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 } diff --git a/terraform/iam_github_deploy.tf b/terraform/iam_github_deploy.tf new file mode 100644 index 0000000..856a6b5 --- /dev/null +++ b/terraform/iam_github_deploy.tf @@ -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 +} diff --git a/terraform/iam_github_weekly_menu.tf b/terraform/iam_github_weekly_menu.tf index 4596b22..a5ea4eb 100644 --- a/terraform/iam_github_weekly_menu.tf +++ b/terraform/iam_github_weekly_menu.tf @@ -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", ] } diff --git a/terraform/lambda.tf b/terraform/lambda.tf deleted file mode 100644 index 077c0ba..0000000 --- a/terraform/lambda.tf +++ /dev/null @@ -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, - ] -} diff --git a/terraform/locals.tf b/terraform/locals.tf index d63dc38..f6dc243 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -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" } } diff --git a/terraform/logs.tf b/terraform/logs.tf index 1097f89..424a204 100644 --- a/terraform/logs.tf +++ b/terraform/logs.tf @@ -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/), 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 -} diff --git a/terraform/outputs.tf b/terraform/outputs.tf index 1b901d7..a28d665 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -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 } diff --git a/terraform/s3.tf b/terraform/s3.tf index b84dca8..6674184 100644 --- a/terraform/s3.tf +++ b/terraform/s3.tf @@ -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 diff --git a/terraform/scheduler.tf b/terraform/scheduler.tf new file mode 100644 index 0000000..9cdfbb6 --- /dev/null +++ b/terraform/scheduler.tf @@ -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 }) + } +} diff --git a/terraform/ssm.tf b/terraform/ssm.tf index 337c249..da030d6 100644 --- a/terraform/ssm.tf +++ b/terraform/ssm.tf @@ -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" { diff --git a/terraform/versions.tf b/terraform/versions.tf index d24f049..4187d4b 100644 --- a/terraform/versions.tf +++ b/terraform/versions.tf @@ -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" } } diff --git a/terraform/vpc.tf b/terraform/vpc.tf new file mode 100644 index 0000000..1a8e625 --- /dev/null +++ b/terraform/vpc.tf @@ -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 +} diff --git a/tests/conftest.py b/tests/conftest.py index 21173fd..4cfadd6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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)) diff --git a/tests/test_admin_authorizer.py b/tests/test_admin_authorizer.py index 443b8f7..5f8ac2e 100644 --- a/tests/test_admin_authorizer.py +++ b/tests/test_admin_authorizer.py @@ -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) diff --git a/tests/test_aggregate_orders.py b/tests/test_aggregate_orders.py index d11ab2e..ac3ade4 100644 --- a/tests/test_aggregate_orders.py +++ b/tests/test_aggregate_orders.py @@ -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() diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..77661a4 --- /dev/null +++ b/tests/test_app.py @@ -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") diff --git a/tests/test_close_form.py b/tests/test_close_form.py index 791e64b..da709a8 100644 --- a/tests/test_close_form.py +++ b/tests/test_close_form.py @@ -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() diff --git a/tests/test_generate_form.py b/tests/test_generate_form.py index 9c6d49b..9bcadbf 100644 --- a/tests/test_generate_form.py +++ b/tests/test_generate_form.py @@ -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 diff --git a/tests/test_secrets.py b/tests/test_secrets.py index 5db2d44..bb5ee51 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -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 diff --git a/tests/test_slack_notifier.py b/tests/test_slack_notifier.py index 0b9f778..f350e45 100644 --- a/tests/test_slack_notifier.py +++ b/tests/test_slack_notifier.py @@ -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"}, ] diff --git a/tests/test_submit_order.py b/tests/test_submit_order.py index 6f30017..c69fde9 100644 --- a/tests/test_submit_order.py +++ b/tests/test_submit_order.py @@ -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") diff --git a/tests/test_terraform_cognito_auth.py b/tests/test_terraform_cognito_auth.py index 0aa3911..c6664b2 100644 --- a/tests/test_terraform_cognito_auth.py +++ b/tests/test_terraform_cognito_auth.py @@ -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") + ) diff --git a/tests/test_terraform_email_report.py b/tests/test_terraform_email_report.py index 98fe3bd..bf7a2d0 100644 --- a/tests/test_terraform_email_report.py +++ b/tests/test_terraform_email_report.py @@ -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 + ) diff --git a/tests/test_terraform_menu_api.py b/tests/test_terraform_menu_api.py index 8b5e93c..f2241f6 100644 --- a/tests/test_terraform_menu_api.py +++ b/tests/test_terraform_menu_api.py @@ -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 diff --git a/tests/test_terraform_vpc.py b/tests/test_terraform_vpc.py new file mode 100644 index 0000000..2e22439 --- /dev/null +++ b/tests/test_terraform_vpc.py @@ -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") diff --git a/tests/test_worker.py b/tests/test_worker.py new file mode 100644 index 0000000..00fa848 --- /dev/null +++ b/tests/test_worker.py @@ -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" + )