mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 05:23:13 +00:00
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
This commit is contained in:
parent
12df37dab8
commit
f48a82c476
74 changed files with 2264 additions and 3463 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.venv
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
tests
|
||||||
|
terraform
|
||||||
|
output
|
||||||
|
functions
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
infra
|
||||||
68
.github/workflows/build-layer.yml
vendored
68
.github/workflows/build-layer.yml
vendored
|
|
@ -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
|
|
||||||
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
|
|
@ -9,9 +9,11 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
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:
|
with:
|
||||||
run-tests: false
|
python-version: "3.12.14"
|
||||||
|
requirements: "requirements-api.txt"
|
||||||
|
collect-only: false
|
||||||
|
|
||||||
template-js:
|
template-js:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -48,6 +50,7 @@ jobs:
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
pip install pytest
|
pip install pytest
|
||||||
|
pip install -r requirements-api.txt
|
||||||
while IFS= read -r -d '' req; do
|
while IFS= read -r -d '' req; do
|
||||||
pip install -r "$req"
|
pip install -r "$req"
|
||||||
done < <(find . -name requirements.txt -not -path './.aws-sam/*' -print0)
|
done < <(find . -name requirements.txt -not -path './.aws-sam/*' -print0)
|
||||||
|
|
|
||||||
237
.github/workflows/deploy-api.yaml
vendored
Normal file
237
.github/workflows/deploy-api.yaml
vendored
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
name: Deploy API
|
||||||
|
|
||||||
|
# Fargate image CD (PLAT-215). GitHub Actions builds the Flask image, pushes
|
||||||
|
# to ECR, and registers a new task definition. Terraform owns the cluster,
|
||||||
|
# service, ALB, and ignores container_definitions / task_definition.
|
||||||
|
#
|
||||||
|
# push to main -> dev, at github.sha
|
||||||
|
# release: published -> prod, at the release tag
|
||||||
|
# workflow_dispatch -> chosen environment at a chosen ref
|
||||||
|
#
|
||||||
|
# Releases are cut by a human with `gh release create vX.Y.Z --target main`.
|
||||||
|
# Nothing here creates an HCP run.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "terraform/**"
|
||||||
|
- "docs/**"
|
||||||
|
- "*.md"
|
||||||
|
- ".github/workflows/weekly-menu.yml"
|
||||||
|
- ".github/workflows/ci.yml"
|
||||||
|
- ".github/workflows/ci-terraform.yaml"
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: "Target Environment"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options: [dev, prod]
|
||||||
|
ref:
|
||||||
|
description: "Git ref to build and deploy (tag, branch, or SHA). Empty means the workflow ref."
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
target:
|
||||||
|
name: Resolve target
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
outputs:
|
||||||
|
environment: ${{ steps.resolve.outputs.environment }}
|
||||||
|
ref: ${{ steps.resolve.outputs.ref }}
|
||||||
|
steps:
|
||||||
|
- id: resolve
|
||||||
|
env:
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
GITHUB_REF_NAME_IN: ${{ github.ref }}
|
||||||
|
GITHUB_SHA_IN: ${{ github.sha }}
|
||||||
|
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
INPUT_ENVIRONMENT: ${{ inputs.environment }}
|
||||||
|
INPUT_REF: ${{ inputs.ref }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
case "${EVENT_NAME}" in
|
||||||
|
push)
|
||||||
|
if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then
|
||||||
|
echo "push deploys only run from main" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
environment=dev
|
||||||
|
ref="${GITHUB_SHA_IN}"
|
||||||
|
;;
|
||||||
|
release)
|
||||||
|
environment=prod
|
||||||
|
ref="${RELEASE_TAG}"
|
||||||
|
status="$(gh api "repos/${REPO}/compare/main...${RELEASE_TAG}" --jq .status)"
|
||||||
|
if [ "${status}" != "behind" ] && [ "${status}" != "identical" ]; then
|
||||||
|
echo "release tag ${RELEASE_TAG} is not on main (compare status: ${status})" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
workflow_dispatch)
|
||||||
|
environment="${INPUT_ENVIRONMENT}"
|
||||||
|
ref="${INPUT_REF:-${GITHUB_SHA_IN}}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unsupported event ${EVENT_NAME}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
{
|
||||||
|
echo "environment=${environment}"
|
||||||
|
echo "ref=${ref}"
|
||||||
|
} >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "Deploying ${ref} to ${environment}"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy API to ${{ needs.target.outputs.environment }}
|
||||||
|
needs: target
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
environment: ${{ needs.target.outputs.environment }}
|
||||||
|
concurrency:
|
||||||
|
group: deploy-api-${{ needs.target.outputs.environment }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
env:
|
||||||
|
AWS_REGION: us-east-1
|
||||||
|
DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.target.outputs.ref }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Resolve commit
|
||||||
|
id: commit
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sha="$(git rev-parse HEAD)"
|
||||||
|
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "Building ${sha}"
|
||||||
|
|
||||||
|
- name: Configure AWS credentials using OIDC
|
||||||
|
uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
|
||||||
|
with:
|
||||||
|
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
|
||||||
|
aws-region: us-east-1
|
||||||
|
audience: sts.amazonaws.com
|
||||||
|
|
||||||
|
- name: Get deploy parameters
|
||||||
|
id: deploy
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
get_param() {
|
||||||
|
aws ssm get-parameter --name "$1" --query Parameter.Value --output text
|
||||||
|
}
|
||||||
|
CLUSTER=$(get_param /meal-order-manager/deploy/cluster)
|
||||||
|
SERVICE=$(get_param /meal-order-manager/deploy/service)
|
||||||
|
FAMILY=$(get_param /meal-order-manager/deploy/task-family)
|
||||||
|
ECR=$(get_param /meal-order-manager/deploy/ecr-repository)
|
||||||
|
CONTAINER=$(get_param /meal-order-manager/deploy/container-name)
|
||||||
|
API_URL=$(get_param /meal-order-manager/deploy/api-url)
|
||||||
|
{
|
||||||
|
echo "cluster=${CLUSTER}"
|
||||||
|
echo "service=${SERVICE}"
|
||||||
|
echo "family=${FAMILY}"
|
||||||
|
echo "ecr=${ECR}"
|
||||||
|
echo "container=${CONTAINER}"
|
||||||
|
echo "api_url=${API_URL}"
|
||||||
|
} >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Login to Amazon ECR
|
||||||
|
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
env:
|
||||||
|
ECR: ${{ steps.deploy.outputs.ecr }}
|
||||||
|
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||||
|
ENVIRONMENT: ${{ needs.target.outputs.environment }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker build \
|
||||||
|
--build-arg "GIT_SHA=${GIT_SHA}" \
|
||||||
|
-t "${ECR}:${GIT_SHA}" \
|
||||||
|
-t "${ECR}:${ENVIRONMENT}" \
|
||||||
|
.
|
||||||
|
docker push "${ECR}:${GIT_SHA}"
|
||||||
|
docker push "${ECR}:${ENVIRONMENT}"
|
||||||
|
|
||||||
|
- name: Register task definition and update service
|
||||||
|
env:
|
||||||
|
CLUSTER: ${{ steps.deploy.outputs.cluster }}
|
||||||
|
SERVICE: ${{ steps.deploy.outputs.service }}
|
||||||
|
FAMILY: ${{ steps.deploy.outputs.family }}
|
||||||
|
CONTAINER: ${{ steps.deploy.outputs.container }}
|
||||||
|
IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }}
|
||||||
|
GIT_SHA: ${{ steps.commit.outputs.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
aws ecs describe-task-definition \
|
||||||
|
--task-definition "${FAMILY}" \
|
||||||
|
--query taskDefinition \
|
||||||
|
--output json \
|
||||||
|
| python3 -c '
|
||||||
|
import json, os, sys
|
||||||
|
td = json.load(sys.stdin)
|
||||||
|
for key in (
|
||||||
|
"taskDefinitionArn",
|
||||||
|
"revision",
|
||||||
|
"status",
|
||||||
|
"requiresAttributes",
|
||||||
|
"compatibilities",
|
||||||
|
"registeredAt",
|
||||||
|
"registeredBy",
|
||||||
|
"deregisteredAt",
|
||||||
|
):
|
||||||
|
td.pop(key, None)
|
||||||
|
image = os.environ["IMAGE"]
|
||||||
|
sha = os.environ["GIT_SHA"]
|
||||||
|
name = os.environ["CONTAINER"]
|
||||||
|
for container in td["containerDefinitions"]:
|
||||||
|
if container["name"] != name:
|
||||||
|
continue
|
||||||
|
container["image"] = image
|
||||||
|
env = {item["name"]: item["value"] for item in container.get("environment", [])}
|
||||||
|
env["GIT_SHA"] = sha
|
||||||
|
container["environment"] = [{"name": key, "value": value} for key, value in env.items()]
|
||||||
|
container.pop("command", None)
|
||||||
|
json.dump(td, sys.stdout)
|
||||||
|
' > /tmp/task-def.json
|
||||||
|
REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)"
|
||||||
|
aws ecs update-service \
|
||||||
|
--cluster "${CLUSTER}" \
|
||||||
|
--service "${SERVICE}" \
|
||||||
|
--task-definition "${FAMILY}:${REV}" \
|
||||||
|
--force-new-deployment \
|
||||||
|
>/dev/null
|
||||||
|
aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}"
|
||||||
|
|
||||||
|
- name: Verify health SHA
|
||||||
|
env:
|
||||||
|
API_URL: ${{ steps.deploy.outputs.api_url }}
|
||||||
|
EXPECTED_SHA: ${{ steps.commit.outputs.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for _ in 1 2 3 4 5 6; do
|
||||||
|
BODY="$(curl -fsS "${API_URL}/api/health" || true)"
|
||||||
|
echo "${BODY}"
|
||||||
|
if echo "${BODY}" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("sha")==os.environ["EXPECTED_SHA"] else 1)'; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
echo "health SHA did not match ${EXPECTED_SHA}" >&2
|
||||||
|
exit 1
|
||||||
21
.github/workflows/weekly-menu.yml
vendored
21
.github/workflows/weekly-menu.yml
vendored
|
|
@ -96,7 +96,13 @@ jobs:
|
||||||
env:
|
env:
|
||||||
API_URL: ${{ steps.stack.outputs.api_url }}
|
API_URL: ${{ steps.stack.outputs.api_url }}
|
||||||
run: |
|
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")
|
BULK=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["bulk_discount_percent"])' "$SETTINGS")
|
||||||
SUBSIDY=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["company_subsidy_percent"])' "$SETTINGS")
|
SUBSIDY=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["company_subsidy_percent"])' "$SETTINGS")
|
||||||
echo "bulk_discount=$BULK" >> "$GITHUB_OUTPUT"
|
echo "bulk_discount=$BULK" >> "$GITHUB_OUTPUT"
|
||||||
|
|
@ -119,13 +125,13 @@ jobs:
|
||||||
- name: Generate order form
|
- name: Generate order form
|
||||||
if: env.SKIP_RUN != 'true'
|
if: env.SKIP_RUN != 'true'
|
||||||
env:
|
env:
|
||||||
API_URL: ${{ steps.stack.outputs.api_url }}
|
|
||||||
BULK_DISCOUNT: ${{ steps.discount.outputs.bulk_discount }}
|
BULK_DISCOUNT: ${{ steps.discount.outputs.bulk_discount }}
|
||||||
COMPANY_SUBSIDY: ${{ steps.discount.outputs.company_subsidy }}
|
COMPANY_SUBSIDY: ${{ steps.discount.outputs.company_subsidy }}
|
||||||
GOOGLE_CLIENT_ID: ${{ steps.google.outputs.client_id }}
|
GOOGLE_CLIENT_ID: ${{ steps.google.outputs.client_id }}
|
||||||
run: |
|
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 \
|
python3 src/server/generate_form.py \
|
||||||
--api-url "$API_URL" \
|
|
||||||
--bulk-discount "$BULK_DISCOUNT" \
|
--bulk-discount "$BULK_DISCOUNT" \
|
||||||
--company-subsidy "$COMPANY_SUBSIDY" \
|
--company-subsidy "$COMPANY_SUBSIDY" \
|
||||||
--google-client-id "$GOOGLE_CLIENT_ID"
|
--google-client-id "$GOOGLE_CLIENT_ID"
|
||||||
|
|
@ -134,7 +140,14 @@ jobs:
|
||||||
if: env.SKIP_RUN != 'true'
|
if: env.SKIP_RUN != 'true'
|
||||||
env:
|
env:
|
||||||
API_URL: ${{ steps.stack.outputs.api_url }}
|
API_URL: ${{ steps.stack.outputs.api_url }}
|
||||||
run: python3 scripts/upload_menu.py publish --api-url "$API_URL"
|
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
|
- name: Upload form to S3
|
||||||
if: env.SKIP_RUN != 'true'
|
if: env.SKIP_RUN != 'true'
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@
|
||||||
{
|
{
|
||||||
"id": "gitleaks-generic-api-key-45",
|
"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."
|
"justification": "False positive. tests/test_submit_order.py defines a synthetic Google OAuth audience used only for mocked token verification. OAuth client IDs are public identifiers, this fixture is not a credential, and the tests make no real Google or AWS calls."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "checkov-CKV_AWS_260-39",
|
||||||
|
"justification": "The meals API ALB is the CloudFront HTTP origin for orders.seahaven.com. TLS and WAF terminate at CloudFront. Restricting the security group to the CloudFront managed prefix list would block GitHub-hosted weekly-menu HMAC publish, which must call the origin with X-Meals-Publish-Key. Application gates are HMAC on /api/publish, Cognito or Google Bearer on admin, and public submit only. Accepted as the HTTP-origin design for PLAT-215; TLS on the ALB is a follow-up."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "checkov-CKV_AWS_111-40",
|
||||||
|
"justification": "githubdeploy-meal-order-manager needs ecr:GetAuthorizationToken and ecs:RegisterTaskDefinition, both of which AWS documents as Resource *. Trust is pinned to environment:dev or environment:prod plus job_workflow_ref on deploy-api.yaml at refs/heads/main or refs/tags/v*. iam:PassRole is limited to the ECS task and execution roles. ECR push and SSM parameter reads are already resource-scoped."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY src/shared/requirements.txt /tmp/shared-requirements.txt
|
||||||
|
COPY requirements-api.txt /tmp/requirements-api.txt
|
||||||
|
RUN pip install --no-cache-dir -r /tmp/shared-requirements.txt -r /tmp/requirements-api.txt
|
||||||
|
|
||||||
|
COPY src /app/src
|
||||||
|
|
||||||
|
ARG GIT_SHA=dev
|
||||||
|
ENV PYTHONPATH=/app/src:/app/src/shared \
|
||||||
|
GIT_SHA=${GIT_SHA} \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app/src
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["python", "-m", "server.entrypoint"]
|
||||||
126
README.md
126
README.md
|
|
@ -1,7 +1,7 @@
|
||||||
# meal-order-manager
|
# meal-order-manager
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
|
@ -10,17 +10,17 @@ Automates weekly meal ordering from [Redefine Meals](https://www.redefinemeals.c
|
||||||
## Architecture
|
## 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 │
|
│ GitHub Actions │ │ orders.seahaven │ │ EventBridge │
|
||||||
│ - Scrape menu │────S3 upload───▶│ ind.com │ │ - Close form │
|
│ - Scrape menu │────S3 upload───▶│ .com │ │ Scheduler (ET) │
|
||||||
│ - Generate form │ + signed API │ (CloudFront+S3) │──POST───┐ │ - Aggregate │
|
│ - HMAC publish │ │ (CloudFront+S3) │──POST───┐ │ → jobs SQS │
|
||||||
│ - Slack notify │ └──────────────────┘ │ │ - Slack summary │
|
│ - Slack notify │ └──────────────────┘ │ └─────────┬────────┘
|
||||||
└─────────────────┘ ▼ └──────────────────┘
|
└─────────────────┘ ▼ │
|
||||||
┌──────────┐
|
┌──────────┐ │
|
||||||
Thu 10am: Slack DM │ API GW + │
|
Thu 10am: Slack DM │ ALB + │◀──────────┘
|
||||||
reminders to employees │ Lambda │
|
reminders to employees │ Fargate │
|
||||||
who haven't ordered │ submit │
|
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
|
### 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.
|
- **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 |
|
| When | What | How |
|
||||||
|------|------|-----|
|
|------|------|-----|
|
||||||
| Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge → Lambda → DynamoDB |
|
| Monday 6:55am ET | Sync employee roster from Slack channel membership | EventBridge Scheduler → jobs SQS → Fargate |
|
||||||
| Monday 7:30am ET | Scrape menu, generate form, upload to S3, post link to Slack | GitHub Actions cron |
|
| 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 static form → API Gateway → Lambda → DynamoDB |
|
| 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 → Lambda → Slack DM |
|
| Thursday 10am ET | DM employees who haven't ordered yet | EventBridge Scheduler → jobs SQS |
|
||||||
| Thursday 6pm ET | Close form, aggregate orders, write CSV reports + weekly summary PDF, post Redefine order summary to Slack | EventBridge → Lambda chain |
|
| 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
|
### Weekly menu publication boundary
|
||||||
|
|
||||||
The scheduled GitHub workflow has no DynamoDB permissions. It signs two requests
|
The scheduled GitHub workflow has no DynamoDB permissions. It sends two HMAC
|
||||||
with its short-lived OIDC role credentials:
|
requests with `X-Meals-Publish-Key` from Parameter Store to the ALB:
|
||||||
|
|
||||||
- `GET /api/publish/settings` returns only the bulk discount and company subsidy.
|
- `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.
|
- `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
|
Publish routes are omitted from CloudFront. The generated form uses relative
|
||||||
submit-order Lambda. The GitHub role can invoke only these method and path
|
`/api/...` paths so it stays same-origin on `orders.seahaven.com`.
|
||||||
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.
|
|
||||||
|
|
||||||
### Reports (written to `meal-order-manager-reports-*` at Thursday close)
|
### Reports (written to `meal-order-manager-reports-*` at Thursday close)
|
||||||
|
|
||||||
|
|
@ -77,37 +71,31 @@ DynamoDB access.
|
||||||
|
|
||||||
## AWS Resources
|
## 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)
|
- **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)
|
- **DynamoDB** — `meal-order-manager-orders` (orders, menu, roster, config)
|
||||||
- **API Gateway** — HttpApi for order submission and admin operations
|
- **SQS** — `meal-order-manager-jobs` (+ DLQ). EventBridge Scheduler in `America/New_York` enqueues close, reminder, and roster sync.
|
||||||
- **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
|
|
||||||
- **Secrets Manager** — Slack bot token
|
- **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** — ALB 5xx, ECS CPU, jobs DLQ, DynamoDB throttles, all notifying `site-alerts`
|
||||||
- **CloudWatch Alarms** — coverage across the stack, all notifying the shared `site-alerts` SNS topic (see Monitoring)
|
- **HCP Terraform** — workspace `meal-order-manager-<env>` in project `seahaven-<env>`. Working directory `terraform/`. VCS file triggers should be `terraform/**` only after the image deploy workflow owns `src/`. Do not `terraform apply` locally to prod.
|
||||||
- **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.
|
|
||||||
|
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
|
## Monitoring
|
||||||
|
|
||||||
CloudWatch alarms are defined in `template.yaml`. Every alarm sends to the shared
|
CloudWatch alarms are defined in `terraform/alarms.tf`. Prod alarms send to the shared
|
||||||
`site-alerts` SNS topic (`arn:aws:sns:us-east-1:328440206208:site-alerts`), has no
|
`site-alerts` SNS topic, have no OKActions, and treat missing data as not breaching.
|
||||||
OKActions, and treats missing data as not breaching (so idle/cron functions don't
|
|
||||||
sit in ALARM between runs). Alarm names follow `meal-order-manager-<fn>-<signal>`.
|
|
||||||
|
|
||||||
- **Lambda Errors / Throttles** — one alarm each per function (6 functions), Sum
|
- **ALB target 5xx** — Sum over 5 min, threshold 0.
|
||||||
over 5 min, fires on any error/throttle (threshold 0).
|
- **ECS CPU** — Average over 5 min above 80 percent, 2 evaluation periods.
|
||||||
- **Lambda Duration** — p99 over 5 min at ~80% of each function's timeout.
|
- **Jobs DLQ** — visible messages, threshold 0.
|
||||||
API-fronted functions (submit-order, admin-authorizer) evaluate 3/3 datapoints;
|
- **DynamoDB orders table** — `ReadThrottleEvents` and `WriteThrottleEvents`.
|
||||||
cron/async functions evaluate a single datapoint.
|
- Submit is throttled in the Flask process at 5 requests/second per worker.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
|
|
@ -147,21 +135,21 @@ The canonical map of Sea Haven's AWS infrastructure lives in Confluence. This pr
|
||||||
|
|
||||||
### Local development
|
### 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
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt -r requirements-api.txt
|
||||||
playwright install chromium
|
playwright install chromium
|
||||||
|
PYTHONPATH=src:src/shared python3 -m server.app
|
||||||
```
|
```
|
||||||
|
|
||||||
### Deploy to AWS
|
### Deploy to AWS
|
||||||
|
|
||||||
```bash
|
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.
|
||||||
cp samconfig.toml.example samconfig.toml
|
|
||||||
# Edit samconfig.toml with your certificate ARN, etc.
|
Rollback is `workflow_dispatch` of `deploy-api.yaml` at the previous tag. Terraform does not revert the image.
|
||||||
sam build
|
|
||||||
sam deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
### Post-deploy
|
### Post-deploy
|
||||||
|
|
||||||
|
|
@ -173,12 +161,12 @@ sam deploy
|
||||||
|
|
||||||
## Local Workflow (no AWS)
|
## 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
|
```bash
|
||||||
python3 src/scraper/scrape_menu.py # scrape menu
|
python3 src/scraper/scrape_menu.py # scrape menu
|
||||||
python3 src/server/generate_form.py # generate form (local mode)
|
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
|
python3 src/aggregator/aggregate.py # generate CSV reports
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -196,27 +184,21 @@ python3 src/aggregator/aggregate.py # generate CSV reports
|
||||||
```
|
```
|
||||||
meal-order-manager/
|
meal-order-manager/
|
||||||
├── .github/workflows/
|
├── .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
|
│ ├── ci.yml # PR checks
|
||||||
│ └── deploy.yml # Push to main: sam deploy
|
│ └── ci-terraform.yaml # terraform fmt / validate
|
||||||
├── terraform/ # HCP Terraform (workspace meal-order-manager-prod)
|
├── terraform/ # HCP Terraform (cluster, ALB, ECR, jobs queue)
|
||||||
|
├── Dockerfile
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── scraper/ # Playwright menu scraper
|
│ ├── scraper/ # Playwright menu scraper
|
||||||
│ ├── server/ # Form generator + local Flask server
|
│ ├── server/ # Flask API, form generator, job handlers
|
||||||
│ ├── aggregator/ # Order aggregation + CSV reports
|
│ ├── aggregator/ # Order aggregation + CSV reports
|
||||||
│ └── shared/shared/ # Lambda layer (db, secrets, slack, pdf helpers)
|
│ └── shared/shared/ # db, secrets, slack, pdf helpers
|
||||||
├── functions/ # Lambda handlers
|
|
||||||
│ ├── submit_order/
|
|
||||||
│ ├── close_form/
|
|
||||||
│ ├── aggregate_orders/
|
|
||||||
│ ├── slack_notifier/
|
|
||||||
│ └── sync_roster/
|
|
||||||
├── scripts/ # CI/CD helper scripts
|
├── scripts/ # CI/CD helper scripts
|
||||||
│ ├── upload_menu.py
|
│ ├── upload_menu.py
|
||||||
│ ├── notify_slack.py
|
│ ├── notify_slack.py
|
||||||
│ └── seed_roster.py
|
│ └── seed_roster.py
|
||||||
├── template.yaml # SAM template
|
|
||||||
├── samconfig.toml.example
|
|
||||||
├── slack-app-manifest.yml # Slack app manifest (paste into api.slack.com)
|
├── slack-app-manifest.yml # Slack app manifest (paste into api.slack.com)
|
||||||
└── config.json
|
└── config.json
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
boto3==1.43.97
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
boto3==1.43.97
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
boto3==1.43.97
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
boto3==1.43.97
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
boto3==1.43.97
|
|
||||||
|
|
@ -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`.
|
|
||||||
|
|
@ -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": "*"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"Version": "2012-10-17",
|
|
||||||
"Statement": [
|
|
||||||
{
|
|
||||||
"Effect": "Allow",
|
|
||||||
"Principal": {
|
|
||||||
"Federated": "arn:aws:iam::011934824531:oidc-provider/token.actions.githubusercontent.com"
|
|
||||||
},
|
|
||||||
"Action": "sts:AssumeRoleWithWebIdentity",
|
|
||||||
"Condition": {
|
|
||||||
"StringEquals": {
|
|
||||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
|
||||||
"token.actions.githubusercontent.com:sub": "repo:Sea-Haven-Industries/meal-order-manager:ref:refs/heads/main",
|
|
||||||
"token.actions.githubusercontent.com:job_workflow_ref": "Sea-Haven-Industries/meal-order-manager/.github/workflows/build-layer.yml@refs/heads/main"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
6
requirements-api.txt
Normal file
6
requirements-api.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
flask==3.1.3
|
||||||
|
gunicorn==23.0.0
|
||||||
|
boto3==1.43.97
|
||||||
|
jinja2==3.1.6
|
||||||
|
fpdf2==2.8.8
|
||||||
|
PyJWT[crypto]==2.14.0
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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 argparse
|
||||||
import json
|
import json
|
||||||
|
|
@ -8,43 +8,29 @@ import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import boto3
|
|
||||||
from botocore.auth import SigV4Auth
|
|
||||||
from botocore.awsrequest import AWSRequest
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
OUTPUT_DIR = PROJECT_ROOT / "output"
|
OUTPUT_DIR = PROJECT_ROOT / "output"
|
||||||
|
|
||||||
|
|
||||||
def signed_request(
|
def api_request(
|
||||||
api_url: str,
|
api_url: str,
|
||||||
path: str,
|
path: str,
|
||||||
method: str = "GET",
|
method: str = "GET",
|
||||||
body: dict | None = None,
|
body: dict | None = None,
|
||||||
region: str = "us-east-1",
|
publish_key: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if not api_url.startswith(("http://", "https://")):
|
if not api_url.startswith(("http://", "https://")):
|
||||||
raise ValueError(f"api_url must use http(s) scheme: {api_url!r}")
|
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
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
headers = {"Content-Type": "application/json"} if data is not None else {}
|
headers = {}
|
||||||
request = AWSRequest(
|
if data is not None:
|
||||||
method=method,
|
headers["Content-Type"] = "application/json"
|
||||||
url=f"{api_url.rstrip('/')}{path}",
|
if publish_key:
|
||||||
|
headers["X-Meals-Publish-Key"] = publish_key
|
||||||
|
http_request = urllib.request.Request(
|
||||||
|
f"{api_url.rstrip('/')}{path}",
|
||||||
data=data,
|
data=data,
|
||||||
headers=headers,
|
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,
|
method=method,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|
@ -57,23 +43,25 @@ def signed_request(
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def get_settings(api_url: str, region: str) -> dict:
|
def get_settings(api_url: str, publish_key: str) -> dict:
|
||||||
return signed_request(api_url, "/api/publish/settings", method="GET", region=region)
|
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)
|
files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True)
|
||||||
if not files:
|
if not files:
|
||||||
raise RuntimeError("No menu JSON found. Run scrape_menu.py first.")
|
raise RuntimeError("No menu JSON found. Run scrape_menu.py first.")
|
||||||
|
|
||||||
with files[0].open() as menu_file:
|
with files[0].open() as menu_file:
|
||||||
menu = json.load(menu_file)
|
menu = json.load(menu_file)
|
||||||
return signed_request(
|
return api_request(
|
||||||
api_url,
|
api_url,
|
||||||
"/api/publish/menu",
|
"/api/publish/menu",
|
||||||
method="POST",
|
method="POST",
|
||||||
body=menu,
|
body=menu,
|
||||||
region=region,
|
publish_key=publish_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -81,14 +69,18 @@ def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("action", choices=("settings", "publish"))
|
parser.add_argument("action", choices=("settings", "publish"))
|
||||||
parser.add_argument("--api-url", required=True)
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = (
|
result = (
|
||||||
get_settings(args.api_url, args.region)
|
get_settings(args.api_url, args.publish_key)
|
||||||
if args.action == "settings"
|
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:
|
except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
print(f"Error: {exc}", file=sys.stderr)
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
|
|
||||||
1
src/server/__init__.py
Normal file
1
src/server/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Production Flask API package."""
|
||||||
|
|
@ -1,377 +1,133 @@
|
||||||
"""
|
"""Production Flask app for meal-order-manager.
|
||||||
Lightweight Flask server for the meal order form.
|
|
||||||
|
|
||||||
Serves the generated HTML form and handles order submissions.
|
Local: PYTHONPATH=src:src/shared python3 -m server.app
|
||||||
Orders are saved as JSON files in the orders directory, one per employee per week.
|
Prod: gunicorn server.wsgi:app
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
from __future__ import annotations
|
||||||
import time
|
|
||||||
import urllib.request
|
import os
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import boto3
|
import json
|
||||||
from flask import Flask, jsonify, request, send_file
|
|
||||||
|
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]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
CONFIG_PATH = PROJECT_ROOT / "config.json"
|
|
||||||
OUTPUT_DIR = PROJECT_ROOT / "output"
|
OUTPUT_DIR = PROJECT_ROOT / "output"
|
||||||
ORDERS_DIR = PROJECT_ROOT / "orders"
|
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> Flask:
|
||||||
app = Flask(__name__)
|
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 load_config():
|
@app.route("/api/health")
|
||||||
with open(CONFIG_PATH) as f:
|
def health():
|
||||||
return json.load(f)
|
return jsonify(
|
||||||
|
{
|
||||||
|
"stage": os.environ.get("STAGE", "local"),
|
||||||
|
"sha": os.environ.get("GIT_SHA", "dev"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/", methods=["GET"])
|
||||||
|
def root():
|
||||||
|
if os.environ.get("STAGE", "local") == "local":
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
def current_week() -> str:
|
form_file = OUTPUT_DIR / (
|
||||||
return datetime.now().strftime("%Y-W%U")
|
f"order-form-{datetime.now().strftime('%Y-W%U')}.html"
|
||||||
|
)
|
||||||
|
if form_file.exists():
|
||||||
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)
|
return send_file(form_file)
|
||||||
|
return "ok", 200
|
||||||
|
|
||||||
|
def _dispatch(path: str):
|
||||||
@app.route("/api/menu")
|
if request.method == "OPTIONS":
|
||||||
def get_menu():
|
return "", 204
|
||||||
menu_file = latest_menu_file()
|
qs = request.args.to_dict(flat=True)
|
||||||
if not menu_file:
|
path_params = {}
|
||||||
return jsonify(
|
parts = path.strip("/").split("/")
|
||||||
{"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 (
|
if (
|
||||||
_google_client_id_cache is not None
|
len(parts) >= 3
|
||||||
and (now - _google_client_id_cache_ts) <= _GOOGLE_CLIENT_ID_CACHE_TTL_SECONDS
|
and parts[0] == "api"
|
||||||
|
and parts[1]
|
||||||
|
in {
|
||||||
|
"menu",
|
||||||
|
"orders",
|
||||||
|
"form-status",
|
||||||
|
}
|
||||||
):
|
):
|
||||||
return _google_client_id_cache
|
path_params["week"] = parts[2]
|
||||||
|
event = {
|
||||||
config = load_config()
|
"requestContext": {"http": {"method": request.method, "path": path}},
|
||||||
from_config = (config.get("google_client_id") or "").strip()
|
"rawPath": path,
|
||||||
if from_config:
|
"headers": {k: v for k, v in request.headers.items()},
|
||||||
_google_client_id_cache = from_config
|
"body": request.get_data(as_text=True) or "{}",
|
||||||
_google_client_id_cache_ts = now
|
"pathParameters": path_params,
|
||||||
return _google_client_id_cache
|
"queryStringParameters": qs or None,
|
||||||
|
}
|
||||||
|
result = http_api.lambda_handler(event, None)
|
||||||
try:
|
try:
|
||||||
ssm = boto3.client("ssm")
|
payload = json.loads(result["body"])
|
||||||
resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id")
|
except (TypeError, KeyError, json.JSONDecodeError):
|
||||||
_google_client_id_cache = (resp["Parameter"].get("Value") or "").strip()
|
resp = jsonify({"error": "Internal error"})
|
||||||
except Exception:
|
resp.status_code = 500
|
||||||
_google_client_id_cache = ""
|
return resp
|
||||||
_google_client_id_cache_ts = now
|
if not isinstance(payload, dict):
|
||||||
return _google_client_id_cache
|
resp = jsonify({"error": "Internal error"})
|
||||||
|
resp.status_code = 500
|
||||||
|
return resp
|
||||||
|
resp = jsonify(payload)
|
||||||
|
resp.status_code = int(result.get("statusCode") or 500)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@app.route("/api/<path:rest>", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||||
|
def api(rest: str):
|
||||||
|
return _dispatch("/api/" + rest)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
def _verify_google_token(token: str, client_id: str) -> dict | None:
|
def main():
|
||||||
if not client_id:
|
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "5050")))
|
||||||
return None
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"https://oauth2.googleapis.com/tokeninfo?id_token={token}"
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
||||||
data = json.loads(resp.read())
|
|
||||||
if data.get("aud") != client_id:
|
|
||||||
return None
|
|
||||||
if data.get("hd") not in ALLOWED_DOMAINS:
|
|
||||||
return None
|
|
||||||
return {"name": data.get("name", ""), "email": data.get("email", "")}
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/submit-order", methods=["POST"])
|
|
||||||
def submit_order():
|
|
||||||
data = request.get_json()
|
|
||||||
if not data:
|
|
||||||
return jsonify({"error": "No data received"}), 400
|
|
||||||
|
|
||||||
client_id = _get_google_client_id()
|
|
||||||
google_token = data.get("google_id_token")
|
|
||||||
|
|
||||||
if client_id:
|
|
||||||
if not google_token:
|
|
||||||
return jsonify({"error": "Google authentication is required"}), 403
|
|
||||||
user_info = _verify_google_token(google_token, client_id)
|
|
||||||
if not user_info:
|
|
||||||
return jsonify({"error": "Invalid or unauthorized Google account"}), 403
|
|
||||||
name = user_info["name"]
|
|
||||||
email = user_info["email"]
|
|
||||||
else:
|
|
||||||
name = data.get("employee_name", "").strip()
|
|
||||||
email = data.get("employee_email", "").strip()
|
|
||||||
|
|
||||||
items = data.get("items", [])
|
|
||||||
|
|
||||||
if not name:
|
|
||||||
return jsonify({"error": "Employee name is required"}), 400
|
|
||||||
if not email:
|
|
||||||
return jsonify({"error": "Employee email is required"}), 400
|
|
||||||
if not items or not any(i.get("quantity", 0) > 0 for i in items):
|
|
||||||
return jsonify({"error": "Please select at least one meal"}), 400
|
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
TWO_PLACES = Decimal("0.01")
|
|
||||||
bulk_pct = Decimal(str(config.get("bulk_discount_percent", 0)))
|
|
||||||
subsidy_pct = Decimal(str(config.get("company_subsidy_percent", 0)))
|
|
||||||
bulk_pct = max(Decimal("0"), min(Decimal("100"), bulk_pct))
|
|
||||||
subsidy_pct = max(Decimal("0"), min(Decimal("100"), subsidy_pct))
|
|
||||||
bulk_mult = Decimal("1") - (bulk_pct / Decimal("100"))
|
|
||||||
subsidy_mult = Decimal("1") - (subsidy_pct / Decimal("100"))
|
|
||||||
|
|
||||||
filtered = [i for i in items if i.get("quantity", 0) > 0]
|
|
||||||
official_retail = _official_menu_retail_by_name()
|
|
||||||
if not official_retail:
|
|
||||||
return jsonify({"error": "Menu temporarily unavailable"}), 503
|
|
||||||
for item in filtered:
|
|
||||||
meal_name = (item.get("name") or "").strip()
|
|
||||||
if meal_name not in official_retail:
|
|
||||||
return jsonify(
|
|
||||||
{"error": "One or more meals are not on this week's menu"}
|
|
||||||
), 400
|
|
||||||
|
|
||||||
for item in filtered:
|
|
||||||
meal_name = (item.get("name") or "").strip()
|
|
||||||
retail = official_retail[meal_name]
|
|
||||||
qty = Decimal(str(item.get("quantity", 0)))
|
|
||||||
bulk_price = (retail * bulk_mult).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
|
||||||
emp_price = (bulk_price * subsidy_mult).quantize(
|
|
||||||
TWO_PLACES, rounding=ROUND_HALF_UP
|
|
||||||
)
|
|
||||||
subtotal = (emp_price * qty).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
|
||||||
item["retail_price"] = float(retail)
|
|
||||||
item["bulk_price"] = float(bulk_price)
|
|
||||||
item["price"] = float(emp_price)
|
|
||||||
item["subtotal"] = float(subtotal)
|
|
||||||
|
|
||||||
week = current_week()
|
|
||||||
week_dir = ORDERS_DIR / week
|
|
||||||
week_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Match Lambda: one order file per employee email (not display name).
|
|
||||||
slug = email.strip().lower()
|
|
||||||
slug_safe = slug.replace("/", "_").replace("\\", "_")
|
|
||||||
order_file = week_dir / f"{slug_safe}.json"
|
|
||||||
|
|
||||||
total = float(
|
|
||||||
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
|
|
||||||
TWO_PLACES, rounding=ROUND_HALF_UP
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
order = {
|
|
||||||
"employee_name": name,
|
|
||||||
"employee_email": email,
|
|
||||||
"week": week,
|
|
||||||
"submitted_at": datetime.now().isoformat(),
|
|
||||||
"items": filtered,
|
|
||||||
"total": total,
|
|
||||||
}
|
|
||||||
|
|
||||||
with open(order_file, "w") as f:
|
|
||||||
json.dump(order, f, indent=2)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{"status": "ok", "message": f"Order saved for {name}", "total": order["total"]}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/form-status/<week>")
|
|
||||||
def form_status(week: str):
|
|
||||||
return jsonify({"week": week, "status": "open"})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/admin/orders")
|
|
||||||
def admin_orders():
|
|
||||||
week = request.args.get("week")
|
|
||||||
if not week:
|
|
||||||
weeks = []
|
|
||||||
for f in sorted(ORDERS_DIR.iterdir(), reverse=True):
|
|
||||||
if f.is_dir():
|
|
||||||
order_count = len(list(f.glob("*.json")))
|
|
||||||
weeks.append(
|
|
||||||
{
|
|
||||||
"week": f.name,
|
|
||||||
"form_status": "open",
|
|
||||||
"meal_count": 0,
|
|
||||||
"order_count": order_count,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return jsonify({"weeks": weeks})
|
|
||||||
|
|
||||||
week_dir = ORDERS_DIR / week
|
|
||||||
if not week_dir.exists():
|
|
||||||
return jsonify(
|
|
||||||
{"week": week, "orders": [], "total_employees": 0, "grand_total": 0}
|
|
||||||
)
|
|
||||||
|
|
||||||
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", ""))
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"week": week,
|
|
||||||
"orders": orders,
|
|
||||||
"total_employees": len(orders),
|
|
||||||
"grand_total": round(sum(o.get("total", 0) for o in orders), 2),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@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.route("/api/admin/orders", methods=["PUT"])
|
|
||||||
def admin_update_order():
|
|
||||||
data = request.get_json()
|
|
||||||
if not data:
|
|
||||||
return jsonify({"error": "No data received"}), 400
|
|
||||||
|
|
||||||
week = data.get("week", "")
|
|
||||||
email = data.get("email", "")
|
|
||||||
new_items = data.get("items", [])
|
|
||||||
if not week or not email:
|
|
||||||
return jsonify({"error": "week and email are required"}), 400
|
|
||||||
|
|
||||||
slug = email.strip().lower().replace("/", "_").replace("\\", "_")
|
|
||||||
order_file = ORDERS_DIR / week / f"{slug}.json"
|
|
||||||
if not order_file.exists():
|
|
||||||
return jsonify({"error": "Order not found"}), 404
|
|
||||||
|
|
||||||
with open(order_file) as f:
|
|
||||||
existing = json.load(f)
|
|
||||||
|
|
||||||
filtered = [i for i in new_items if i.get("quantity", 0) > 0]
|
|
||||||
if not filtered:
|
|
||||||
return jsonify({"error": "At least one item required"}), 400
|
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
TWO_PLACES = Decimal("0.01")
|
|
||||||
bulk_pct = Decimal(str(config.get("bulk_discount_percent", 0)))
|
|
||||||
subsidy_pct = Decimal(str(config.get("company_subsidy_percent", 0)))
|
|
||||||
bulk_mult = Decimal("1") - (
|
|
||||||
max(Decimal("0"), min(Decimal("100"), bulk_pct)) / Decimal("100")
|
|
||||||
)
|
|
||||||
subsidy_mult = Decimal("1") - (
|
|
||||||
max(Decimal("0"), min(Decimal("100"), subsidy_pct)) / Decimal("100")
|
|
||||||
)
|
|
||||||
|
|
||||||
official_retail = _official_menu_retail_by_name()
|
|
||||||
for item in filtered:
|
|
||||||
meal_name = (item.get("name") or "").strip()
|
|
||||||
retail = official_retail.get(meal_name)
|
|
||||||
if retail is None:
|
|
||||||
return jsonify({"error": f"'{meal_name}' not on this week's menu"}), 400
|
|
||||||
qty = Decimal(str(item["quantity"]))
|
|
||||||
bulk_price = (retail * bulk_mult).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
|
||||||
emp_price = (bulk_price * subsidy_mult).quantize(
|
|
||||||
TWO_PLACES, rounding=ROUND_HALF_UP
|
|
||||||
)
|
|
||||||
subtotal = (emp_price * qty).quantize(TWO_PLACES, rounding=ROUND_HALF_UP)
|
|
||||||
item["retail_price"] = float(retail)
|
|
||||||
item["bulk_price"] = float(bulk_price)
|
|
||||||
item["price"] = float(emp_price)
|
|
||||||
item["subtotal"] = float(subtotal)
|
|
||||||
|
|
||||||
total = float(
|
|
||||||
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
|
|
||||||
TWO_PLACES, rounding=ROUND_HALF_UP
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
existing["items"] = filtered
|
|
||||||
existing["total"] = total
|
|
||||||
|
|
||||||
with open(order_file, "w") as f:
|
|
||||||
json.dump(existing, f, indent=2)
|
|
||||||
|
|
||||||
return jsonify({"status": "updated", "week": week, "email": email, "total": total})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/orders/<week>")
|
|
||||||
def get_orders(week: str):
|
|
||||||
week_dir = ORDERS_DIR / week
|
|
||||||
if not week_dir.exists():
|
|
||||||
return jsonify({"orders": [], "week": week})
|
|
||||||
|
|
||||||
orders = []
|
|
||||||
for f in sorted(week_dir.glob("*.json")):
|
|
||||||
with open(f) as fh:
|
|
||||||
orders.append(json.load(fh))
|
|
||||||
|
|
||||||
return jsonify({"orders": orders, "week": week})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
ORDERS_DIR.mkdir(exist_ok=True)
|
main()
|
||||||
print(f"Menu file: {latest_menu_file()}")
|
|
||||||
print(f"Orders dir: {ORDERS_DIR}")
|
|
||||||
app.run(host="0.0.0.0", port=5050, debug=False)
|
|
||||||
|
|
|
||||||
71
src/server/entrypoint.py
Normal file
71
src/server/entrypoint.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Gunicorn + SQS worker in one task. Stay a parent so SIGTERM reaches both."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
worker = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "server.worker"],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
gunicorn = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"gunicorn",
|
||||||
|
"--bind",
|
||||||
|
"0.0.0.0:8080",
|
||||||
|
"--workers",
|
||||||
|
os.environ.get("GUNICORN_WORKERS", "2"),
|
||||||
|
"--threads",
|
||||||
|
"2",
|
||||||
|
"--timeout",
|
||||||
|
"120",
|
||||||
|
"--graceful-timeout",
|
||||||
|
"30",
|
||||||
|
"--access-logfile",
|
||||||
|
"-",
|
||||||
|
"--error-logfile",
|
||||||
|
"-",
|
||||||
|
"server.wsgi:app",
|
||||||
|
],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
def shutdown(signum: int, _frame) -> None:
|
||||||
|
for proc in (gunicorn, worker):
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.send_signal(signum)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, shutdown)
|
||||||
|
signal.signal(signal.SIGINT, shutdown)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
g_code = gunicorn.poll()
|
||||||
|
w_code = worker.poll()
|
||||||
|
if g_code is not None:
|
||||||
|
if worker.poll() is None:
|
||||||
|
worker.terminate()
|
||||||
|
try:
|
||||||
|
worker.wait(timeout=30)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
worker.kill()
|
||||||
|
sys.exit(g_code)
|
||||||
|
if w_code is not None:
|
||||||
|
if gunicorn.poll() is None:
|
||||||
|
gunicorn.terminate()
|
||||||
|
try:
|
||||||
|
gunicorn.wait(timeout=30)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
gunicorn.kill()
|
||||||
|
sys.exit(w_code or 1)
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
Generates a self-contained HTML order form from the latest scraped menu.
|
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,
|
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:
|
Usage:
|
||||||
python3 generate_form.py # local mode
|
python3 generate_form.py # local mode
|
||||||
|
|
@ -56,6 +56,7 @@ def generate_form(
|
||||||
google_client_id: str = "",
|
google_client_id: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
google_client_id = google_client_id.strip()
|
google_client_id = google_client_id.strip()
|
||||||
|
api_url = (api_url or "").rstrip("/")
|
||||||
if api_url and not google_client_id:
|
if api_url and not google_client_id:
|
||||||
raise ValueError("Google client ID is required in cloud mode")
|
raise ValueError("Google client ID is required in cloud mode")
|
||||||
|
|
||||||
|
|
@ -116,7 +117,7 @@ def generate_form(
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Generate meal order form HTML")
|
parser = argparse.ArgumentParser(description="Generate meal order form HTML")
|
||||||
parser.add_argument(
|
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(
|
parser.add_argument(
|
||||||
"--bulk-discount",
|
"--bulk-discount",
|
||||||
|
|
@ -182,11 +183,15 @@ def main():
|
||||||
with open(output_file, "w") as f:
|
with open(output_file, "w") as f:
|
||||||
f.write(html)
|
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"Generated order form ({mode} mode): {output_file}")
|
||||||
print(f" {menu['meal_count']} meals from menu scraped {menu['scraped_at'][:10]}")
|
print(f" {menu['meal_count']} meals from menu scraped {menu['scraped_at'][:10]}")
|
||||||
if mode == "local":
|
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:
|
else:
|
||||||
print(f" API endpoint: {args.api_url}")
|
print(f" API endpoint: {args.api_url}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
@ -43,6 +45,11 @@ EASTERN = ZoneInfo("America/New_York")
|
||||||
CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values
|
CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values
|
||||||
PRESIGNED_URL_TTL_SECONDS = 300 # summary-PDF presigned URL lifetime
|
PRESIGNED_URL_TTL_SECONDS = 300 # summary-PDF presigned URL lifetime
|
||||||
ALLOWED_DOMAINS = {"seahavenind.com", "seahaven.com"}
|
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:
|
def _eastern_now() -> _dt.datetime:
|
||||||
|
|
@ -54,10 +61,16 @@ _settings = None
|
||||||
_settings_ts = 0.0
|
_settings_ts = 0.0
|
||||||
_google_client_id = None
|
_google_client_id = None
|
||||||
_google_client_id_ts = 0.0
|
_google_client_id_ts = 0.0
|
||||||
_lambda = boto3.client("lambda")
|
|
||||||
_s3 = boto3.client("s3")
|
_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]:
|
def _get_discount_settings() -> tuple[Decimal, Decimal]:
|
||||||
global _settings, _settings_ts
|
global _settings, _settings_ts
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
|
|
@ -236,7 +249,7 @@ def lambda_handler(event, context):
|
||||||
return handle_menu(event)
|
return handle_menu(event)
|
||||||
|
|
||||||
if path == "/api/publish/settings" and method == "GET":
|
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":
|
if path == "/api/publish/menu" and method == "POST":
|
||||||
return handle_publish_menu(event)
|
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."""
|
"""Return only the pricing fields needed by the weekly-menu workflow."""
|
||||||
|
denied = _require_publish_key(event or {})
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
return response(
|
return response(
|
||||||
200,
|
200,
|
||||||
|
|
@ -316,6 +376,9 @@ def handle_publish_settings():
|
||||||
|
|
||||||
def handle_publish_menu(event):
|
def handle_publish_menu(event):
|
||||||
"""Persist a scraped menu for the current Eastern-time week."""
|
"""Persist a scraped menu for the current Eastern-time week."""
|
||||||
|
denied = _require_publish_key(event)
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
try:
|
try:
|
||||||
body = json.loads(event.get("body", "{}"))
|
body = json.loads(event.get("body", "{}"))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
|
@ -586,6 +649,8 @@ def handle_roster():
|
||||||
|
|
||||||
|
|
||||||
def handle_submit(event):
|
def handle_submit(event):
|
||||||
|
if not _allow_submit():
|
||||||
|
return response(429, {"error": "Rate limit exceeded"})
|
||||||
try:
|
try:
|
||||||
body = json.loads(event.get("body", "{}"))
|
body = json.loads(event.get("body", "{}"))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
|
@ -702,10 +767,7 @@ def handle_submit(event):
|
||||||
# Slack notification is best-effort — order is already persisted above,
|
# Slack notification is best-effort — order is already persisted above,
|
||||||
# so we return success to the user even if this invocation fails.
|
# so we return success to the user even if this invocation fails.
|
||||||
try:
|
try:
|
||||||
_lambda.invoke(
|
_dispatch_job(
|
||||||
FunctionName=os.environ["SLACK_NOTIFIER_ARN"],
|
|
||||||
InvocationType="Event",
|
|
||||||
Payload=json.dumps(
|
|
||||||
{
|
{
|
||||||
"event": "order_confirmed",
|
"event": "order_confirmed",
|
||||||
"employee_name": name,
|
"employee_name": name,
|
||||||
|
|
@ -713,12 +775,10 @@ def handle_submit(event):
|
||||||
"items": filtered_items,
|
"items": filtered_items,
|
||||||
"total": order_data["total"],
|
"total": order_data["total"],
|
||||||
"week": week,
|
"week": week,
|
||||||
},
|
}
|
||||||
default=float,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
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(
|
return response(
|
||||||
200,
|
200,
|
||||||
48
src/server/jobs/__init__.py
Normal file
48
src/server/jobs/__init__.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""In-process job dispatch. SQS when JOBS_QUEUE_URL is set; otherwise run inline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_sqs = None
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
global _sqs
|
||||||
|
if _sqs is None:
|
||||||
|
_sqs = boto3.client("sqs")
|
||||||
|
return _sqs
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue_job(payload: dict) -> None:
|
||||||
|
queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip()
|
||||||
|
body = json.dumps(payload, default=str)
|
||||||
|
if not queue_url:
|
||||||
|
run_job(payload)
|
||||||
|
return
|
||||||
|
_client().send_message(QueueUrl=queue_url, MessageBody=body)
|
||||||
|
|
||||||
|
|
||||||
|
def run_job(payload: dict) -> dict:
|
||||||
|
event_type = payload.get("event", "close")
|
||||||
|
if event_type == "close":
|
||||||
|
from server.jobs.close_form import lambda_handler
|
||||||
|
|
||||||
|
return lambda_handler(payload, None)
|
||||||
|
if event_type == "aggregate":
|
||||||
|
from server.jobs.aggregate import lambda_handler
|
||||||
|
|
||||||
|
return lambda_handler(payload, None)
|
||||||
|
if event_type == "sync_roster":
|
||||||
|
from server.jobs.sync_roster import lambda_handler
|
||||||
|
|
||||||
|
return lambda_handler(payload, None)
|
||||||
|
from server.jobs.notify import lambda_handler
|
||||||
|
|
||||||
|
return lambda_handler(payload, None)
|
||||||
|
|
@ -17,11 +17,16 @@ EASTERN = ZoneInfo("America/New_York")
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
_s3 = boto3.client("s3")
|
_s3 = boto3.client("s3")
|
||||||
_lambda = boto3.client("lambda")
|
|
||||||
_sqs = boto3.client("sqs")
|
_sqs = boto3.client("sqs")
|
||||||
KIND_MEAL = "meal_deduction"
|
KIND_MEAL = "meal_deduction"
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_job(payload: dict) -> None:
|
||||||
|
from server.jobs import enqueue_job
|
||||||
|
|
||||||
|
enqueue_job(payload)
|
||||||
|
|
||||||
|
|
||||||
class DecimalEncoder(json.JSONEncoder):
|
class DecimalEncoder(json.JSONEncoder):
|
||||||
def default(self, o):
|
def default(self, o):
|
||||||
if isinstance(o, Decimal):
|
if isinstance(o, Decimal):
|
||||||
|
|
@ -79,13 +84,7 @@ def lambda_handler(event, context):
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("checkcomponents send failed week=%s", week)
|
logger.exception("checkcomponents send failed week=%s", week)
|
||||||
|
|
||||||
_lambda.invoke(
|
_dispatch_job({"event": "orders_aggregated", "week": week})
|
||||||
FunctionName=os.environ["SLACK_NOTIFIER_ARN"],
|
|
||||||
InvocationType="Event",
|
|
||||||
Payload=json.dumps(
|
|
||||||
{"event": "orders_aggregated", "week": week}, cls=DecimalEncoder
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"status": "aggregated", "week": week, "total_employees": len(orders)}
|
return {"status": "aggregated", "week": week, "total_employees": len(orders)}
|
||||||
|
|
||||||
24
src/server/jobs/close_form.py
Normal file
24
src/server/jobs/close_form.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from shared.db import current_week, get_form_status, set_form_status
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_job(payload: dict) -> None:
|
||||||
|
from server.jobs import enqueue_job
|
||||||
|
|
||||||
|
enqueue_job(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
# Scheduler fires Thursday 23:59 America/New_York. Do not skip on wall
|
||||||
|
# clock: a delayed SQS delivery must still close the week. already-closed
|
||||||
|
# is the idempotency gate for redelivery.
|
||||||
|
week = event.get("week", current_week())
|
||||||
|
|
||||||
|
status = get_form_status(week)
|
||||||
|
if status == "closed":
|
||||||
|
return {"status": "already_closed", "week": week}
|
||||||
|
|
||||||
|
set_form_status(week, "closed")
|
||||||
|
|
||||||
|
_dispatch_job({"event": "aggregate", "week": week})
|
||||||
|
|
||||||
|
return {"status": "closed", "week": week, "aggregate_triggered": True}
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from shared.db import current_week, get_orders, get_roster, get_settings, get_summary
|
from shared.db import current_week, get_orders, get_roster, get_settings, get_summary
|
||||||
from shared.slack import post_channel_message, send_dm
|
from shared.slack import post_channel_message, send_dm
|
||||||
|
|
@ -28,10 +26,6 @@ def lambda_handler(event, context):
|
||||||
elif event_type == "orders_aggregated":
|
elif event_type == "orders_aggregated":
|
||||||
return handle_orders_aggregated(event)
|
return handle_orders_aggregated(event)
|
||||||
elif event_type == "reminder":
|
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)
|
return handle_reminder(event)
|
||||||
elif event_type == "order_confirmed":
|
elif event_type == "order_confirmed":
|
||||||
return handle_order_confirmed(event)
|
return handle_order_confirmed(event)
|
||||||
|
|
@ -187,11 +181,9 @@ def handle_order_confirmed(event):
|
||||||
|
|
||||||
|
|
||||||
def handle_reminder(event):
|
def handle_reminder(event):
|
||||||
# Guard against duplicate triggers from dual EST/EDT schedules
|
# Scheduler fires Thursday 10:00 America/New_York. Delayed SQS delivery
|
||||||
now_et = datetime.now(ZoneInfo("America/New_York"))
|
# must still DM anyone who has not ordered; already-ordered emails are
|
||||||
if now_et.hour != 10 or now_et.weekday() != 3: # 10am Thursday
|
# the idempotency gate for redelivery.
|
||||||
return {"status": "skipped", "reason": "outside reminder window"}
|
|
||||||
|
|
||||||
week = event.get("week", current_week())
|
week = event.get("week", current_week())
|
||||||
form_url = os.environ.get("FORM_URL", "")
|
form_url = os.environ.get("FORM_URL", "")
|
||||||
|
|
||||||
64
src/server/worker.py
Normal file
64
src/server/worker.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""SQS long-poll consumer. One process per task, not per gunicorn worker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
from server.jobs import run_job
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
|
||||||
|
|
||||||
|
_running = True
|
||||||
|
|
||||||
|
|
||||||
|
def _stop(_signum, _frame) -> None:
|
||||||
|
global _running
|
||||||
|
_running = False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
signal.signal(signal.SIGTERM, _stop)
|
||||||
|
signal.signal(signal.SIGINT, _stop)
|
||||||
|
|
||||||
|
queue_url = os.environ.get("JOBS_QUEUE_URL", "").strip()
|
||||||
|
if not queue_url:
|
||||||
|
logger.info("JOBS_QUEUE_URL unset; worker idle")
|
||||||
|
while _running:
|
||||||
|
time.sleep(1)
|
||||||
|
return
|
||||||
|
sqs = boto3.client("sqs")
|
||||||
|
logger.info("Polling jobs queue")
|
||||||
|
while _running:
|
||||||
|
resp = sqs.receive_message(
|
||||||
|
QueueUrl=queue_url,
|
||||||
|
MaxNumberOfMessages=1,
|
||||||
|
WaitTimeSeconds=20,
|
||||||
|
VisibilityTimeout=180,
|
||||||
|
)
|
||||||
|
for msg in resp.get("Messages", []):
|
||||||
|
receipt = msg["ReceiptHandle"]
|
||||||
|
try:
|
||||||
|
payload = json.loads(msg["Body"])
|
||||||
|
result = run_job(payload)
|
||||||
|
status = result.get("status") if isinstance(result, dict) else None
|
||||||
|
logger.info("job event=%s status=%s", payload.get("event"), status)
|
||||||
|
if status == "skipped":
|
||||||
|
# Leave the message visible after timeout so a later
|
||||||
|
# receive can run it. Deleting here dropped the only
|
||||||
|
# weekly close/reminder when delivery landed late.
|
||||||
|
continue
|
||||||
|
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=receipt)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("job failed; leaving message for retry")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
5
src/server/wsgi.py
Normal file
5
src/server/wsgi.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Gunicorn entrypoint."""
|
||||||
|
|
||||||
|
from server.app import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
|
|
@ -2,21 +2,27 @@ import time
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
|
|
||||||
_secret_cache: dict[str, str] = {}
|
_secret_cache: dict[str, tuple[str, float]] = {}
|
||||||
_parameter_cache: dict[str, tuple[str, float]] = {}
|
_parameter_cache: dict[str, tuple[str, float]] = {}
|
||||||
_sm = boto3.client("secretsmanager")
|
_sm = boto3.client("secretsmanager")
|
||||||
_ssm = boto3.client("ssm")
|
_ssm = boto3.client("ssm")
|
||||||
|
|
||||||
# SSM reads use a TTL so callers (e.g. submit_order Google client ID) can refresh
|
# SSM and Secrets Manager reads use a TTL so a long-lived Fargate task
|
||||||
# on the same cadence as their own caches. Secrets stay cached for the process lifetime.
|
# picks up rotations without a restart.
|
||||||
PARAM_CACHE_TTL_SECONDS = 300.0
|
PARAM_CACHE_TTL_SECONDS = 300.0
|
||||||
|
|
||||||
|
|
||||||
def get_secret(secret_id: str) -> str:
|
def get_secret(secret_id: str) -> str:
|
||||||
if secret_id not in _secret_cache:
|
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)
|
resp = _sm.get_secret_value(SecretId=secret_id)
|
||||||
_secret_cache[secret_id] = resp["SecretString"]
|
value = resp["SecretString"]
|
||||||
return _secret_cache[secret_id]
|
_secret_cache[secret_id] = (value, now)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def get_parameter(name: str, decrypt: bool = True) -> str:
|
def get_parameter(name: str, decrypt: bool = True) -> str:
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
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 = None
|
||||||
|
_token_ts = 0.0
|
||||||
|
|
||||||
|
|
||||||
def _get_token() -> str:
|
def _get_token() -> str:
|
||||||
global _token
|
global _token, _token_ts
|
||||||
if _token is None:
|
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 = get_secret(os.environ["SLACK_BOT_SM_NAME"])
|
||||||
|
_token_ts = now
|
||||||
return _token
|
return _token
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
1096
template.yaml
1096
template.yaml
File diff suppressed because it is too large
Load diff
76
terraform/.terraform.lock.hcl
generated
76
terraform/.terraform.lock.hcl
generated
|
|
@ -1,38 +1,6 @@
|
||||||
# This file is maintained automatically by "terraform init".
|
# This file is maintained automatically by "terraform init".
|
||||||
# Manual edits may be lost in future updates.
|
# 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" {
|
provider "registry.terraform.io/hashicorp/aws" {
|
||||||
version = "6.65.0"
|
version = "6.65.0"
|
||||||
constraints = "6.65.0"
|
constraints = "6.65.0"
|
||||||
|
|
@ -71,34 +39,24 @@ provider "registry.terraform.io/hashicorp/aws" {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "registry.terraform.io/hashicorp/external" {
|
provider "registry.terraform.io/hashicorp/random" {
|
||||||
version = "2.4.2"
|
version = "3.8.1"
|
||||||
constraints = "2.4.2"
|
constraints = "3.8.1"
|
||||||
hashes = [
|
hashes = [
|
||||||
"h1:4UInMFuK4GNw4uf2vkUwwDtc0CajvJ88BkAE6xLKOa4=",
|
"h1:Eexl06+6J+s75uD46+WnZtpJZYRVUMB0AiuPBifK6Jc=",
|
||||||
"h1:8KPRXwNezVs9S/xEpGcKkPNMez+Kv5bKJNfLWivGekY=",
|
"h1:fdfOl1HabDT42XLH8qjmfTbVZpgQZ5lyOyOa+GQhm0w=",
|
||||||
"h1:B8SUNH8ToFJjQwz10rFU8k9soEhnj2OzzTwKrcH9cFI=",
|
"h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=",
|
||||||
"h1:ERhVaFFS4/WRonirXUJV1DWN+cSTyEKEfs2ZnoP1BgY=",
|
"zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4",
|
||||||
"h1:Ev3S467Z+WcjiY/MroSWX492k017jYU1eGtZLhXr9x8=",
|
"zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae",
|
||||||
"h1:O6uC9yKr7swQtc/kHVyaV9yETT20A39oUi5X+k/b290=",
|
"zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57",
|
||||||
"h1:QP724n6VWM/iitpILCwO079UOlzx6I0Ylh7g8Gwv1Y0=",
|
"zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0",
|
||||||
"h1:famcgOUn8RzdgcZe+GUptA59vdgh4pXzIu/y9GMX8SU=",
|
"zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66",
|
||||||
"h1:h5n+iCc1zwT5mKIATjIYS8hcjJxoFAPSNxPsZbZLc3Q=",
|
"zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511",
|
||||||
"h1:l0Z5YlRsbjRUU0+u4U8BPIDGv7PAszOrNLO9ZIxQrG4=",
|
|
||||||
"h1:rwlUbh50HZYdRQWKW12nG4+3Giu2rp+mQXjqF0NzmVg=",
|
|
||||||
"h1:tP4PPkaGoG60uUYEH3bUnYBSbhUmlk2vsh9uJbBmjUU=",
|
|
||||||
"zh:0b51793be4f66934a3666339e44c01fd56e1c6a56256dfc66d1cb391584b4c2f",
|
|
||||||
"zh:31cdd9b30e4ec63d130befc89471757ef3937b99a8f6cc006769d215365c5ba8",
|
|
||||||
"zh:61f86de4a3166cfa5da6800eeba8e6a2e4ab6403fc3d7b396508260b45d3de7d",
|
|
||||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||||
"zh:817e8d5946aed6ca692e0bb2f6463c28774ef8fb2fdd3922543a495a249229ea",
|
"zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9",
|
||||||
"zh:b35f1bd1be09ed1a1620b43ab8cb43fe93407cf419586d53fe965691cc1b4a8e",
|
"zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05",
|
||||||
"zh:bcb170063ec8b5728bc2a4568bc48f539a1991cdaaf31667aa35568aebc34725",
|
"zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8",
|
||||||
"zh:c0d2c824cc7c047f26ce793bb0cbb6746f9745d1fc6e630d34a0afb5b92850d1",
|
"zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b",
|
||||||
"zh:cde68f51089b02db50e2c5a17f6e132dc1ead2fc08d3dd267b8dd54ec58133fa",
|
"zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699",
|
||||||
"zh:d1f3c497aa41f17e8d61067122f6d94e51ef942ab08be5465489864d900854ab",
|
|
||||||
"zh:e62568bfc0934b63e14f3547c823b01ed60d7475ff16bf12c0e55d5ac8d98ba7",
|
|
||||||
"zh:ed8890c29dba2b0ac27afcefa75e7395e27253b7025aaaa4258345fc59dad23e",
|
|
||||||
"zh:f220c56c7e487f01fd158126066f6525178bbd82805b27205354bc523cc7c413",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,127 +1,71 @@
|
||||||
# CloudWatch alarms. All notify the shared site-alerts topic. No OK actions (no
|
# CloudWatch alarms for the Fargate API and orders table.
|
||||||
# recovery spam), and treat_missing_data = notBreaching so cron functions do not
|
|
||||||
# sit in ALARM between invocations.
|
|
||||||
#
|
|
||||||
# Duration alarms use the p99 extended statistic at ~80% of each function's
|
|
||||||
# timeout. API-fronted functions evaluate 3 datapoints; cron and async-invoked
|
|
||||||
# functions evaluate one, because they fire too rarely to fill a longer window.
|
|
||||||
#
|
|
||||||
# DynamoDB note: the table does not publish ThrottledRequests or SystemErrors at
|
|
||||||
# the TableName-only dimension, so no alarm on those would ever evaluate.
|
|
||||||
# ReadThrottleEvents and WriteThrottleEvents do carry TableName and are used
|
|
||||||
# here for throttle coverage.
|
|
||||||
|
|
||||||
locals {
|
resource "aws_cloudwatch_metric_alarm" "alb_5xx" {
|
||||||
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" {
|
|
||||||
count = local.is_prod ? 1 : 0
|
count = local.is_prod ? 1 : 0
|
||||||
|
|
||||||
alarm_name = "${local.project}-orders-read-throttle"
|
alarm_name = "${local.project}-alb-5xx"
|
||||||
alarm_description = "orders table read requests were throttled in the last 5 minutes."
|
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"
|
namespace = "AWS/DynamoDB"
|
||||||
metric_name = "ReadThrottleEvents"
|
metric_name = "ReadThrottleEvents"
|
||||||
statistic = "Sum"
|
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
|
count = local.is_prod ? 1 : 0
|
||||||
|
|
||||||
alarm_name = "${local.project}-orders-write-throttle"
|
alarm_name = "${local.project}-ddb-write-throttles"
|
||||||
alarm_description = "orders table write requests were throttled in the last 5 minutes."
|
alarm_description = "Orders table write throttles"
|
||||||
namespace = "AWS/DynamoDB"
|
namespace = "AWS/DynamoDB"
|
||||||
metric_name = "WriteThrottleEvents"
|
metric_name = "WriteThrottleEvents"
|
||||||
statistic = "Sum"
|
statistic = "Sum"
|
||||||
|
|
@ -156,96 +100,3 @@ resource "aws_cloudwatch_metric_alarm" "orders_write_throttle" {
|
||||||
TableName = aws_dynamodb_table.orders.name
|
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]
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
12
terraform/bootstrap/README.md
Normal file
12
terraform/bootstrap/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Bootstrap image for the ECS service before the first GitHub Actions deploy.
|
||||||
|
# Terraform ignores later container_definitions; this command only has to pass
|
||||||
|
# the ALB health check on GET /api/health until deploy-api.yaml ships the real image.
|
||||||
|
#
|
||||||
|
# First apply of `aws_iam_policy.ecs_task_boundary` and
|
||||||
|
# `aws_iam_policy.github_deploy_boundary` needs the hcptf-bootstrap window:
|
||||||
|
# CreatePolicy on `policy/tf-managed/*` lives only on hcptf-bootstrap
|
||||||
|
# (seahaven-org-baseline). Point this workspace's TFC_AWS_* at bootstrap,
|
||||||
|
# apply, then retarget at hcptf-meal-order-manager.
|
||||||
|
|
||||||
|
python:3.12-slim with an inlined ThreadingHTTPServer on :8080 that returns
|
||||||
|
`{"stage":"bootstrap","sha":"bootstrap"}` for any GET.
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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}"
|
|
||||||
|
|
@ -99,12 +99,12 @@ resource "aws_cloudfront_distribution" "form" {
|
||||||
|
|
||||||
origin {
|
origin {
|
||||||
origin_id = "OrderApiOrigin"
|
origin_id = "OrderApiOrigin"
|
||||||
domain_name = trimprefix(aws_apigatewayv2_api.order_api.api_endpoint, "https://")
|
domain_name = aws_lb.api.dns_name
|
||||||
|
|
||||||
custom_origin_config {
|
custom_origin_config {
|
||||||
http_port = 80
|
http_port = 80
|
||||||
https_port = 443
|
https_port = 443
|
||||||
origin_protocol_policy = "https-only"
|
origin_protocol_policy = "http-only"
|
||||||
origin_ssl_protocols = ["TLSv1.2"]
|
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
|
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 {
|
ordered_cache_behavior {
|
||||||
path_pattern = "/api/form-status/*"
|
path_pattern = "/api/form-status/*"
|
||||||
target_origin_id = "OrderApiOrigin"
|
target_origin_id = "OrderApiOrigin"
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ data "aws_ssm_parameter" "app_web_acl_arn" {
|
||||||
# parameter is created and rotated out-of-band because it varies per
|
# parameter is created and rotated out-of-band because it varies per
|
||||||
# environment; this lookup only asserts that it exists before an apply wires
|
# 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
|
# 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" {
|
data "aws_ssm_parameter" "google_client_id" {
|
||||||
name = local.google_client_id_param
|
name = local.google_client_id_param
|
||||||
}
|
}
|
||||||
|
|
|
||||||
252
terraform/ecs.tf
Normal file
252
terraform/ecs.tf
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
# Always-on meals API: Fargate behind an ALB. GitHub Actions owns the image;
|
||||||
|
# Terraform ignores container_definitions after the bootstrap task definition.
|
||||||
|
|
||||||
|
resource "aws_ecr_repository" "api" {
|
||||||
|
name = local.project
|
||||||
|
image_tag_mutability = "MUTABLE"
|
||||||
|
force_delete = !local.is_prod
|
||||||
|
|
||||||
|
image_scanning_configuration {
|
||||||
|
scan_on_push = true
|
||||||
|
}
|
||||||
|
|
||||||
|
encryption_configuration {
|
||||||
|
encryption_type = "AES256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecr_lifecycle_policy" "api" {
|
||||||
|
repository = aws_ecr_repository.api.name
|
||||||
|
|
||||||
|
policy = jsonencode({
|
||||||
|
rules = [
|
||||||
|
{
|
||||||
|
rulePriority = 1
|
||||||
|
description = "Keep the last 20 images"
|
||||||
|
selection = {
|
||||||
|
tagStatus = "any"
|
||||||
|
countType = "imageCountMoreThan"
|
||||||
|
countNumber = 20
|
||||||
|
}
|
||||||
|
action = {
|
||||||
|
type = "expire"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_security_group" "alb" {
|
||||||
|
name = "${local.project}-alb"
|
||||||
|
description = "Public ALB for meal-order-manager"
|
||||||
|
vpc_id = aws_vpc.this.id
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
# CloudFront prefix lists cannot cover GitHub-hosted weekly-menu HMAC
|
||||||
|
# publish, so this ALB is internet-reachable on :80. Flask HMAC and
|
||||||
|
# Bearer checks are the application gate. Security review required.
|
||||||
|
description = "HTTP from CloudFront and weekly-menu HMAC publish"
|
||||||
|
from_port = 80
|
||||||
|
to_port = 80
|
||||||
|
protocol = "tcp"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
|
||||||
|
egress {
|
||||||
|
from_port = 0
|
||||||
|
to_port = 0
|
||||||
|
protocol = "-1"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_security_group" "api" {
|
||||||
|
name = "${local.project}-api"
|
||||||
|
description = "Fargate tasks for meal-order-manager"
|
||||||
|
vpc_id = aws_vpc.this.id
|
||||||
|
|
||||||
|
ingress {
|
||||||
|
description = "From ALB"
|
||||||
|
from_port = 8080
|
||||||
|
to_port = 8080
|
||||||
|
protocol = "tcp"
|
||||||
|
security_groups = [aws_security_group.alb.id]
|
||||||
|
}
|
||||||
|
|
||||||
|
egress {
|
||||||
|
from_port = 0
|
||||||
|
to_port = 0
|
||||||
|
protocol = "-1"
|
||||||
|
cidr_blocks = ["0.0.0.0/0"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb" "api" {
|
||||||
|
name = local.project
|
||||||
|
load_balancer_type = "application"
|
||||||
|
idle_timeout = 120
|
||||||
|
security_groups = [aws_security_group.alb.id]
|
||||||
|
subnets = aws_subnet.public[*].id
|
||||||
|
|
||||||
|
drop_invalid_header_fields = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb_target_group" "api" {
|
||||||
|
name = "${local.project}-api"
|
||||||
|
port = 8080
|
||||||
|
protocol = "HTTP"
|
||||||
|
vpc_id = aws_vpc.this.id
|
||||||
|
target_type = "ip"
|
||||||
|
|
||||||
|
health_check {
|
||||||
|
enabled = true
|
||||||
|
path = "/api/health"
|
||||||
|
matcher = "200"
|
||||||
|
interval = 30
|
||||||
|
timeout = 5
|
||||||
|
healthy_threshold = 2
|
||||||
|
unhealthy_threshold = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_lb_listener" "http" {
|
||||||
|
load_balancer_arn = aws_lb.api.arn
|
||||||
|
port = 80
|
||||||
|
protocol = "HTTP"
|
||||||
|
|
||||||
|
default_action {
|
||||||
|
type = "forward"
|
||||||
|
target_group_arn = aws_lb_target_group.api.arn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_cluster" "api" {
|
||||||
|
name = local.project
|
||||||
|
|
||||||
|
setting {
|
||||||
|
name = "containerInsights"
|
||||||
|
value = local.is_prod ? "enabled" : "disabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
api_container_name = "api"
|
||||||
|
bootstrap_command = [
|
||||||
|
"python",
|
||||||
|
"-c",
|
||||||
|
"from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler\nclass H(BaseHTTPRequestHandler):\n def do_GET(self):\n body = b'{\"stage\":\"bootstrap\",\"sha\":\"bootstrap\"}'\n self.send_response(200)\n self.send_header('Content-Type', 'application/json')\n self.send_header('Content-Length', str(len(body)))\n self.end_headers()\n self.wfile.write(body)\nThreadingHTTPServer(('0.0.0.0', 8080), H).serve_forever()",
|
||||||
|
]
|
||||||
|
|
||||||
|
api_environment = [
|
||||||
|
{ name = "STAGE", value = var.environment },
|
||||||
|
{ name = "GIT_SHA", value = "bootstrap" },
|
||||||
|
{ name = "TABLE_NAME", value = local.table_name },
|
||||||
|
{ name = "REPORTS_BUCKET", value = local.reports_bucket_name },
|
||||||
|
{ name = "SLACK_CHANNEL_PARAM", value = local.slack_channel_param },
|
||||||
|
{ name = "FORM_URL", value = local.form_url },
|
||||||
|
{ name = "SLACK_BOT_SM_NAME", value = local.slack_bot_secret_name },
|
||||||
|
{ name = "GOOGLE_CLIENT_ID_PARAM", value = local.google_client_id_param },
|
||||||
|
{ name = "PORTAL_COGNITO_ISSUER_PARAM", value = aws_ssm_parameter.portal_cognito_issuer.name },
|
||||||
|
{ name = "PORTAL_COGNITO_AUDIENCE_PARAM", value = aws_ssm_parameter.portal_cognito_audience.name },
|
||||||
|
{ name = "PORTAL_COGNITO_TRUST_PARAM", value = aws_ssm_parameter.portal_cognito_trust.name },
|
||||||
|
{ name = "PUBLISH_KEY_PARAM", value = aws_ssm_parameter.publish_key.name },
|
||||||
|
{ name = "JOBS_QUEUE_URL", value = aws_sqs_queue.jobs.id },
|
||||||
|
{ name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url },
|
||||||
|
{ name = "AWS_DEFAULT_REGION", value = var.aws_region },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_task_definition" "api" {
|
||||||
|
family = local.project
|
||||||
|
requires_compatibilities = ["FARGATE"]
|
||||||
|
network_mode = "awsvpc"
|
||||||
|
cpu = "256"
|
||||||
|
memory = "512"
|
||||||
|
execution_role_arn = aws_iam_role.ecs_execution.arn
|
||||||
|
task_role_arn = aws_iam_role.ecs_task.arn
|
||||||
|
|
||||||
|
container_definitions = jsonencode([
|
||||||
|
{
|
||||||
|
name = local.api_container_name
|
||||||
|
image = "public.ecr.aws/docker/library/python:3.12-slim"
|
||||||
|
essential = true
|
||||||
|
command = local.bootstrap_command
|
||||||
|
portMappings = [
|
||||||
|
{
|
||||||
|
containerPort = 8080
|
||||||
|
protocol = "tcp"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
environment = local.api_environment
|
||||||
|
logConfiguration = {
|
||||||
|
logDriver = "awslogs"
|
||||||
|
options = {
|
||||||
|
"awslogs-group" = aws_cloudwatch_log_group.api.name
|
||||||
|
"awslogs-region" = var.aws_region
|
||||||
|
"awslogs-stream-prefix" = "ecs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [container_definitions]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ecs_service" "api" {
|
||||||
|
name = local.project
|
||||||
|
cluster = aws_ecs_cluster.api.id
|
||||||
|
task_definition = aws_ecs_task_definition.api.arn
|
||||||
|
desired_count = local.is_prod ? 2 : 1
|
||||||
|
launch_type = "FARGATE"
|
||||||
|
|
||||||
|
network_configuration {
|
||||||
|
subnets = aws_subnet.public[*].id
|
||||||
|
security_groups = [aws_security_group.api.id]
|
||||||
|
assign_public_ip = true
|
||||||
|
}
|
||||||
|
|
||||||
|
load_balancer {
|
||||||
|
target_group_arn = aws_lb_target_group.api.arn
|
||||||
|
container_name = local.api_container_name
|
||||||
|
container_port = 8080
|
||||||
|
}
|
||||||
|
|
||||||
|
health_check_grace_period_seconds = 60
|
||||||
|
deployment_minimum_healthy_percent = local.is_prod ? 50 : 0
|
||||||
|
deployment_maximum_percent = 200
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [task_definition, desired_count]
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [aws_lb_listener.http]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_sqs_queue" "jobs_dlq" {
|
||||||
|
name = "${local.project}-jobs-dlq"
|
||||||
|
message_retention_seconds = 1209600
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_sqs_queue" "jobs" {
|
||||||
|
name = "${local.project}-jobs"
|
||||||
|
visibility_timeout_seconds = 180
|
||||||
|
receive_wait_time_seconds = 20
|
||||||
|
redrive_policy = jsonencode({
|
||||||
|
deadLetterTargetArn = aws_sqs_queue.jobs_dlq.arn
|
||||||
|
maxReceiveCount = 3
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "random_password" "publish_key" {
|
||||||
|
length = 48
|
||||||
|
special = false
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_ssm_parameter" "publish_key" {
|
||||||
|
name = "${local.ssm_prefix}/publish-key"
|
||||||
|
type = "SecureString"
|
||||||
|
value = random_password.publish_key.result
|
||||||
|
description = "Shared secret for /api/publish/* (weekly-menu HMAC)"
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -164,7 +164,7 @@ data "aws_iam_policy_document" "hcptf_scoped_iam" {
|
||||||
}
|
}
|
||||||
|
|
||||||
statement {
|
statement {
|
||||||
sid = "PassExecRolesToLambda"
|
sid = "PassExecRolesToCompute"
|
||||||
effect = "Allow"
|
effect = "Allow"
|
||||||
actions = ["iam:PassRole"]
|
actions = ["iam:PassRole"]
|
||||||
resources = ["arn:aws:iam::${local.account_id}:role/tf-managed/meal-order-manager-*"]
|
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 {
|
condition {
|
||||||
test = "StringEquals"
|
test = "StringEquals"
|
||||||
variable = "iam:PassedToService"
|
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 {
|
statement {
|
||||||
sid = "WriteDeployRoles"
|
sid = "WriteDeployRoles"
|
||||||
effect = "Allow"
|
effect = "Allow"
|
||||||
|
|
@ -292,6 +346,164 @@ resource "aws_iam_role_policy" "hcptf_apply_services" {
|
||||||
Effect = "Allow"
|
Effect = "Allow"
|
||||||
Sid = "LambdaList"
|
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 = [
|
Action = [
|
||||||
"events:*",
|
"events:*",
|
||||||
|
|
@ -318,6 +530,8 @@ resource "aws_iam_role_policy" "hcptf_apply_services" {
|
||||||
Resource = [
|
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/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:/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"
|
Effect = "Allow"
|
||||||
Sid = "CloudWatchLogs"
|
Sid = "CloudWatchLogs"
|
||||||
|
|
@ -596,6 +810,92 @@ resource "aws_iam_role_policy" "hcptf_plan_refresh" {
|
||||||
Effect = "Allow"
|
Effect = "Allow"
|
||||||
Sid = "RefreshLambda"
|
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 = [
|
Action = [
|
||||||
"s3:Get*",
|
"s3:Get*",
|
||||||
|
|
|
||||||
373
terraform/iam.tf
373
terraform/iam.tf
|
|
@ -1,34 +1,108 @@
|
||||||
# Execution roles for the six Lambda functions plus the API Gateway role that
|
# Execution, task, and EventBridge Scheduler roles for the Fargate API.
|
||||||
# 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.
|
|
||||||
|
|
||||||
data "aws_iam_policy_document" "lambda_assume" {
|
data "aws_iam_policy_document" "ecs_assume" {
|
||||||
statement {
|
statement {
|
||||||
effect = "Allow"
|
effect = "Allow"
|
||||||
actions = ["sts:AssumeRole"]
|
actions = ["sts:AssumeRole"]
|
||||||
|
|
||||||
principals {
|
principals {
|
||||||
type = "Service"
|
type = "Service"
|
||||||
identifiers = ["lambda.amazonaws.com"]
|
identifiers = ["ecs-tasks.amazonaws.com"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
data "aws_iam_policy_document" "scheduler_assume" {
|
||||||
# Reusable policy documents
|
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" {
|
data "aws_iam_policy_document" "dynamodb_crud" {
|
||||||
statement {
|
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" {
|
data "aws_iam_policy_document" "ssm_read" {
|
||||||
statement {
|
statement {
|
||||||
sid = "ReadProjectParameters"
|
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" {
|
data "aws_iam_policy_document" "slack_bot_secret_read" {
|
||||||
statement {
|
statement {
|
||||||
sid = "ReadSlackBotToken"
|
sid = "ReadSlackBotToken"
|
||||||
|
|
@ -98,233 +147,37 @@ data "aws_iam_policy_document" "slack_bot_secret_read" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
resource "aws_iam_role" "ecs_task" {
|
||||||
# submit-order
|
name = "${local.project}-api"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
resource "aws_iam_role" "submit_order" {
|
|
||||||
name = "${local.project}-submit-order"
|
|
||||||
path = "/tf-managed/"
|
path = "/tf-managed/"
|
||||||
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
|
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
|
||||||
permissions_boundary = local.boundary_arn
|
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_role_policy_attachment" "submit_order_basic" {
|
resource "aws_iam_role_policy" "ecs_task" {
|
||||||
role = aws_iam_role.submit_order.name
|
name = "api-runtime"
|
||||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
role = aws_iam_role.ecs_task.id
|
||||||
|
policy = data.aws_iam_policy_document.ecs_task_boundary.json
|
||||||
}
|
}
|
||||||
|
|
||||||
data "aws_iam_policy_document" "submit_order" {
|
resource "aws_iam_role" "scheduler" {
|
||||||
source_policy_documents = [
|
name = "${local.project}-scheduler"
|
||||||
data.aws_iam_policy_document.dynamodb_crud.json,
|
|
||||||
data.aws_iam_policy_document.ssm_read.json,
|
|
||||||
]
|
|
||||||
|
|
||||||
statement {
|
|
||||||
sid = "InvokeSlackNotifier"
|
|
||||||
effect = "Allow"
|
|
||||||
actions = ["lambda:InvokeFunction"]
|
|
||||||
resources = [aws_lambda_function.slack_notifier.arn]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read-only access to the weekly summary PDFs only — not the payroll or order
|
|
||||||
# CSVs — for the admin summary-pdf presigned-URL endpoint.
|
|
||||||
statement {
|
|
||||||
sid = "ReadWeeklySummaryPdfs"
|
|
||||||
effect = "Allow"
|
|
||||||
actions = ["s3:GetObject"]
|
|
||||||
resources = ["${aws_s3_bucket.reports.arn}/reports/*/weekly-summary-*.pdf"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resource "aws_iam_role_policy" "submit_order" {
|
|
||||||
name = "submit-order"
|
|
||||||
role = aws_iam_role.submit_order.id
|
|
||||||
policy = data.aws_iam_policy_document.submit_order.json
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# admin-authorizer
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
resource "aws_iam_role" "admin_authorizer" {
|
|
||||||
name = "${local.project}-admin-authorizer"
|
|
||||||
path = "/tf-managed/"
|
path = "/tf-managed/"
|
||||||
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
|
assume_role_policy = data.aws_iam_policy_document.scheduler_assume.json
|
||||||
permissions_boundary = local.boundary_arn
|
permissions_boundary = aws_iam_policy.ecs_task_boundary.arn
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_role_policy_attachment" "admin_authorizer_basic" {
|
data "aws_iam_policy_document" "scheduler" {
|
||||||
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 {
|
statement {
|
||||||
sid = "InvokeAggregateOrders"
|
sid = "SendJobs"
|
||||||
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"
|
effect = "Allow"
|
||||||
actions = ["sqs:SendMessage"]
|
actions = ["sqs:SendMessage"]
|
||||||
resources = [statement.value]
|
resources = [aws_sqs_queue.jobs.arn]
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "aws_iam_role_policy" "aggregate_orders" {
|
resource "aws_iam_role_policy" "scheduler" {
|
||||||
name = "aggregate-orders"
|
name = "enqueue-jobs"
|
||||||
role = aws_iam_role.aggregate_orders.id
|
role = aws_iam_role.scheduler.id
|
||||||
policy = data.aws_iam_policy_document.aggregate_orders.json
|
policy = data.aws_iam_policy_document.scheduler.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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
129
terraform/iam_github_deploy.tf
Normal file
129
terraform/iam_github_deploy.tf
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# GitHub Actions OIDC role for .github/workflows/deploy-api.yaml.
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "github_deploy_assume" {
|
||||||
|
statement {
|
||||||
|
sid = "GithubDeployOidc"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["sts:AssumeRoleWithWebIdentity"]
|
||||||
|
|
||||||
|
principals {
|
||||||
|
type = "Federated"
|
||||||
|
identifiers = [local.github_oidc_provider_arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "token.actions.githubusercontent.com:aud"
|
||||||
|
values = ["sts.amazonaws.com"]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringEquals"
|
||||||
|
variable = "token.actions.githubusercontent.com:sub"
|
||||||
|
values = [
|
||||||
|
"repo:Sea-Haven-Industries/meal-order-manager:environment:dev",
|
||||||
|
"repo:Sea-Haven-Industries/meal-order-manager:environment:prod",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
condition {
|
||||||
|
test = "StringLike"
|
||||||
|
variable = "token.actions.githubusercontent.com:job_workflow_ref"
|
||||||
|
values = [
|
||||||
|
"Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/heads/main",
|
||||||
|
"Sea-Haven-Industries/meal-order-manager/.github/workflows/deploy-api.yaml@refs/tags/v*",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data "aws_iam_policy_document" "github_deploy" {
|
||||||
|
statement {
|
||||||
|
sid = "EcrAuth"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecr:GetAuthorizationToken",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcrPush"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecr:BatchCheckLayerAvailability",
|
||||||
|
"ecr:BatchGetImage",
|
||||||
|
"ecr:CompleteLayerUpload",
|
||||||
|
"ecr:GetDownloadUrlForLayer",
|
||||||
|
"ecr:InitiateLayerUpload",
|
||||||
|
"ecr:PutImage",
|
||||||
|
"ecr:UploadLayerPart",
|
||||||
|
"ecr:DescribeRepositories",
|
||||||
|
"ecr:DescribeImages",
|
||||||
|
]
|
||||||
|
resources = [aws_ecr_repository.api.arn]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "EcsDeploy"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ecs:DescribeServices",
|
||||||
|
"ecs:DescribeTaskDefinition",
|
||||||
|
"ecs:DescribeTasks",
|
||||||
|
"ecs:ListTasks",
|
||||||
|
"ecs:RegisterTaskDefinition",
|
||||||
|
"ecs:UpdateService",
|
||||||
|
]
|
||||||
|
resources = ["*"]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "PassTaskRoles"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = ["iam:PassRole"]
|
||||||
|
resources = [
|
||||||
|
aws_iam_role.ecs_task.arn,
|
||||||
|
aws_iam_role.ecs_execution.arn,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
statement {
|
||||||
|
sid = "DeployParams"
|
||||||
|
effect = "Allow"
|
||||||
|
actions = [
|
||||||
|
"ssm:GetParameter",
|
||||||
|
]
|
||||||
|
resources = [
|
||||||
|
aws_ssm_parameter.deploy_cluster.arn,
|
||||||
|
aws_ssm_parameter.deploy_service.arn,
|
||||||
|
aws_ssm_parameter.deploy_task_family.arn,
|
||||||
|
aws_ssm_parameter.deploy_ecr_repository.arn,
|
||||||
|
aws_ssm_parameter.deploy_container_name.arn,
|
||||||
|
aws_ssm_parameter.deploy_form_url.arn,
|
||||||
|
aws_ssm_parameter.deploy_api_url.arn,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_policy" "github_deploy_boundary" {
|
||||||
|
name = "${local.project}-githubdeploy-boundary"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
description = "Permissions boundary for githubdeploy-meal-order-manager"
|
||||||
|
policy = data.aws_iam_policy_document.github_deploy.json
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role" "github_deploy" {
|
||||||
|
name = "githubdeploy-meal-order-manager"
|
||||||
|
path = "/tf-managed/"
|
||||||
|
description = "GitHub Actions API image deploy for meal-order-manager"
|
||||||
|
assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json
|
||||||
|
permissions_boundary = aws_iam_policy.github_deploy_boundary.arn
|
||||||
|
max_session_duration = 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_iam_role_policy" "github_deploy" {
|
||||||
|
name = "api-image-deploy"
|
||||||
|
role = aws_iam_role.github_deploy.id
|
||||||
|
policy = data.aws_iam_policy_document.github_deploy.json
|
||||||
|
}
|
||||||
|
|
@ -8,10 +8,6 @@
|
||||||
# OIDC provider ARN is literal (not a data source): hcptf-meal-order-manager-plan
|
# OIDC provider ARN is literal (not a data source): hcptf-meal-order-manager-plan
|
||||||
# lacks iam:GetOpenIDConnectProvider, and the provider is account-stable.
|
# 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" {
|
data "aws_iam_policy_document" "weekly_menu_assume" {
|
||||||
statement {
|
statement {
|
||||||
effect = "Allow"
|
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.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.google_client_id_param}",
|
||||||
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.slack_channel_param}",
|
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.slack_channel_param}",
|
||||||
]
|
"arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/publish-key",
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -5,13 +5,15 @@ locals {
|
||||||
hcp_project = "seahaven-${var.environment}"
|
hcp_project = "seahaven-${var.environment}"
|
||||||
hcp_workspace = "${local.project}-${var.environment}"
|
hcp_workspace = "${local.project}-${var.environment}"
|
||||||
|
|
||||||
# Lambda execution roles under /tf-managed/ carry the per-workload ceiling
|
github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com"
|
||||||
# (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"
|
# 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}"
|
form_bucket_name = "${local.project}-form-${local.account_id}"
|
||||||
reports_bucket_name = "${local.project}-reports-${local.account_id}"
|
reports_bucket_name = "${local.project}-reports-${local.account_id}"
|
||||||
artifacts_bucket_name = "${local.project}-artifacts-${local.account_id}"
|
|
||||||
|
|
||||||
table_name = "${local.project}-orders"
|
table_name = "${local.project}-orders"
|
||||||
# Pre-DNS: use the CloudFront domain. After cutover (attach_custom_domain),
|
# 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}/*"
|
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
|
cors_origins = [
|
||||||
# terraform/build/functions/.
|
"https://orders.seahaven.com",
|
||||||
function_packages = toset([
|
"https://internal.seahaven.com",
|
||||||
"admin_authorizer",
|
"https://internal.dev.seahaven.com",
|
||||||
"aggregate_orders",
|
"http://localhost:5173",
|
||||||
"close_form",
|
"http://localhost:4173",
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
||||||
# AWS managed CachingDisabled / AllViewerExceptHostHeader. Authenticated
|
# AWS managed CachingDisabled / AllViewerExceptHostHeader. Authenticated
|
||||||
# portal calls must not be cached and must not send the viewer Host to the
|
# portal calls must not be cached. ALB origin uses Host of the load balancer
|
||||||
# HTTP API (Forbidden).
|
# DNS name (http-only).
|
||||||
api_cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
|
api_cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
|
||||||
api_origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac"
|
api_origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac"
|
||||||
cloudfront_all_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
|
cloudfront_all_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
|
||||||
|
|
||||||
# HTTP API routes, all integrated with submit-order. `authorizer` selects the
|
# HTTP routes served by the Fargate Flask app. authorizer is in-process now.
|
||||||
# authorization mode; `permission_source` is the method/path suffix of the
|
|
||||||
# per-route lambda:InvokeFunction grant (path parameters become `*`).
|
|
||||||
api_routes = {
|
api_routes = {
|
||||||
submit_order = {
|
submit_order = {
|
||||||
route_key = "POST /api/submit-order"
|
route_key = "POST /api/submit-order"
|
||||||
|
|
@ -93,32 +81,32 @@ locals {
|
||||||
}
|
}
|
||||||
publish_settings = {
|
publish_settings = {
|
||||||
route_key = "GET /api/publish/settings"
|
route_key = "GET /api/publish/settings"
|
||||||
authorizer = "AWS_IAM"
|
authorizer = "HMAC"
|
||||||
permission_source = "GET/api/publish/settings"
|
permission_source = "GET/api/publish/settings"
|
||||||
}
|
}
|
||||||
publish_menu = {
|
publish_menu = {
|
||||||
route_key = "POST /api/publish/menu"
|
route_key = "POST /api/publish/menu"
|
||||||
authorizer = "AWS_IAM"
|
authorizer = "HMAC"
|
||||||
permission_source = "POST/api/publish/menu"
|
permission_source = "POST/api/publish/menu"
|
||||||
}
|
}
|
||||||
admin_orders_get = {
|
admin_orders_get = {
|
||||||
route_key = "GET /api/admin/orders"
|
route_key = "GET /api/admin/orders"
|
||||||
authorizer = "CUSTOM"
|
authorizer = "BEARER"
|
||||||
permission_source = "GET/api/admin/orders"
|
permission_source = "GET/api/admin/orders"
|
||||||
}
|
}
|
||||||
admin_orders_put = {
|
admin_orders_put = {
|
||||||
route_key = "PUT /api/admin/orders"
|
route_key = "PUT /api/admin/orders"
|
||||||
authorizer = "CUSTOM"
|
authorizer = "BEARER"
|
||||||
permission_source = "PUT/api/admin/orders"
|
permission_source = "PUT/api/admin/orders"
|
||||||
}
|
}
|
||||||
admin_orders_delete = {
|
admin_orders_delete = {
|
||||||
route_key = "DELETE /api/admin/orders"
|
route_key = "DELETE /api/admin/orders"
|
||||||
authorizer = "CUSTOM"
|
authorizer = "BEARER"
|
||||||
permission_source = "DELETE/api/admin/orders"
|
permission_source = "DELETE/api/admin/orders"
|
||||||
}
|
}
|
||||||
admin_summary_pdf = {
|
admin_summary_pdf = {
|
||||||
route_key = "GET /api/admin/summary-pdf"
|
route_key = "GET /api/admin/summary-pdf"
|
||||||
authorizer = "CUSTOM"
|
authorizer = "BEARER"
|
||||||
permission_source = "GET/api/admin/summary-pdf"
|
permission_source = "GET/api/admin/summary-pdf"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,4 @@
|
||||||
# Log groups are created explicitly rather than left to Lambda's implicit
|
resource "aws_cloudwatch_log_group" "api" {
|
||||||
# on-first-invoke creation, so retention is enforced from the start. Each name
|
name = "/ecs/${local.project}"
|
||||||
# matches the runtime default (/aws/lambda/<function-name>), and every function
|
|
||||||
# depends on its group.
|
|
||||||
|
|
||||||
resource "aws_cloudwatch_log_group" "function" {
|
|
||||||
for_each = local.function_packages
|
|
||||||
|
|
||||||
name = "/aws/lambda/${local.project}-${replace(each.key, "_", "-")}"
|
|
||||||
retention_in_days = local.is_prod ? 60 : 14
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
output "api_url" {
|
output "api_url" {
|
||||||
description = "HTTP API endpoint URL."
|
description = "Public meals API origin (CloudFront)."
|
||||||
value = aws_apigatewayv2_api.order_api.api_endpoint
|
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" {
|
output "form_url" {
|
||||||
|
|
@ -28,9 +33,9 @@ output "reports_bucket_name" {
|
||||||
value = aws_s3_bucket.reports.id
|
value = aws_s3_bucket.reports.id
|
||||||
}
|
}
|
||||||
|
|
||||||
output "artifacts_bucket_name" {
|
output "ecr_repository_url" {
|
||||||
description = "S3 bucket holding Lambda deployment packages."
|
description = "ECR repository for the API image."
|
||||||
value = aws_s3_bucket.artifacts.id
|
value = aws_ecr_repository.api.repository_url
|
||||||
}
|
}
|
||||||
|
|
||||||
output "orders_table_name" {
|
output "orders_table_name" {
|
||||||
|
|
@ -43,19 +48,12 @@ output "weekly_menu_role_arn" {
|
||||||
value = aws_iam_role.weekly_menu.arn
|
value = aws_iam_role.weekly_menu.arn
|
||||||
}
|
}
|
||||||
|
|
||||||
output "shared_layer_arn" {
|
output "github_deploy_role_arn" {
|
||||||
description = "Version ARN of the shared Lambda layer."
|
description = "OIDC role ARN for .github/workflows/deploy-api.yaml (Environment DEPLOY_ROLE_ARN)."
|
||||||
value = aws_lambda_layer_version.shared.arn
|
value = aws_iam_role.github_deploy.arn
|
||||||
}
|
}
|
||||||
|
|
||||||
output "function_arns" {
|
output "ecs_task_role_arn" {
|
||||||
description = "ARNs of every Lambda function in this configuration."
|
description = "ECS task role; paychex-checkcomponents queue policy must allow this ARN."
|
||||||
value = {
|
value = aws_iam_role.ecs_task.arn
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
# Form-hosting and reports buckets. The Lambda artifact bucket lives in
|
# Form-hosting and reports buckets.
|
||||||
# artifacts.tf.
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Form bucket — private origin for the CloudFront distribution
|
# Form bucket — private origin for the CloudFront distribution
|
||||||
|
|
|
||||||
45
terraform/scheduler.tf
Normal file
45
terraform/scheduler.tf
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# EventBridge Scheduler in Eastern time. Replaces dual EST/EDT Lambda crons.
|
||||||
|
|
||||||
|
locals {
|
||||||
|
job_schedules = {
|
||||||
|
close = {
|
||||||
|
description = "Close form Thursday 11:59pm Eastern"
|
||||||
|
schedule = "cron(59 23 ? * THU *)"
|
||||||
|
event = "close"
|
||||||
|
}
|
||||||
|
reminder = {
|
||||||
|
description = "DM reminders Thursday 10am Eastern"
|
||||||
|
schedule = "cron(0 10 ? * THU *)"
|
||||||
|
event = "reminder"
|
||||||
|
}
|
||||||
|
sync-roster = {
|
||||||
|
description = "Sync roster Monday 6:55am Eastern, before menu publish"
|
||||||
|
schedule = "cron(55 6 ? * MON *)"
|
||||||
|
event = "sync_roster"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_scheduler_schedule_group" "jobs" {
|
||||||
|
name = local.project
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_scheduler_schedule" "jobs" {
|
||||||
|
for_each = local.job_schedules
|
||||||
|
|
||||||
|
name = "${local.project}-${each.key}"
|
||||||
|
group_name = aws_scheduler_schedule_group.jobs.name
|
||||||
|
description = each.value.description
|
||||||
|
schedule_expression = each.value.schedule
|
||||||
|
schedule_expression_timezone = "America/New_York"
|
||||||
|
state = local.is_prod ? "ENABLED" : "DISABLED"
|
||||||
|
flexible_time_window {
|
||||||
|
mode = "OFF"
|
||||||
|
}
|
||||||
|
|
||||||
|
target {
|
||||||
|
arn = aws_sqs_queue.jobs.arn
|
||||||
|
role_arn = aws_iam_role.scheduler.arn
|
||||||
|
input = jsonencode({ event = each.value.event })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,8 +46,43 @@ resource "aws_ssm_parameter" "portal_cognito_trust" {
|
||||||
resource "aws_ssm_parameter" "deploy_api_url" {
|
resource "aws_ssm_parameter" "deploy_api_url" {
|
||||||
name = "${local.ssm_prefix}/deploy/api-url"
|
name = "${local.ssm_prefix}/deploy/api-url"
|
||||||
type = "String"
|
type = "String"
|
||||||
value = aws_apigatewayv2_api.order_api.api_endpoint
|
value = "http://${aws_lb.api.dns_name}"
|
||||||
description = "API Gateway endpoint URL; read by the weekly-menu deploy job"
|
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" {
|
resource "aws_ssm_parameter" "deploy_form_bucket" {
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,9 @@ terraform {
|
||||||
source = "hashicorp/aws"
|
source = "hashicorp/aws"
|
||||||
version = "6.65.0"
|
version = "6.65.0"
|
||||||
}
|
}
|
||||||
archive = {
|
random = {
|
||||||
source = "hashicorp/archive"
|
source = "hashicorp/random"
|
||||||
version = "2.8.1"
|
version = "3.8.1"
|
||||||
}
|
|
||||||
external = {
|
|
||||||
source = "hashicorp/external"
|
|
||||||
version = "2.4.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
58
terraform/vpc.tf
Normal file
58
terraform/vpc.tf
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
data "aws_availability_zones" "available" {
|
||||||
|
state = "available"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_vpc" "this" {
|
||||||
|
cidr_block = local.vpc_cidr
|
||||||
|
enable_dns_support = true
|
||||||
|
enable_dns_hostnames = true
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.project}-vpc"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First apply updates the live hcptf apply role before CreateVpc.
|
||||||
|
depends_on = [aws_iam_role_policy.hcptf_apply_services]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_internet_gateway" "this" {
|
||||||
|
vpc_id = aws_vpc.this.id
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.project}-igw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_subnet" "public" {
|
||||||
|
count = length(local.public_subnet_cidrs)
|
||||||
|
|
||||||
|
vpc_id = aws_vpc.this.id
|
||||||
|
cidr_block = local.public_subnet_cidrs[count.index]
|
||||||
|
availability_zone = data.aws_availability_zones.available.names[count.index]
|
||||||
|
map_public_ip_on_launch = true
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.project}-public-${count.index}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route_table" "public" {
|
||||||
|
vpc_id = aws_vpc.this.id
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
Name = "${local.project}-public"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route" "public_default" {
|
||||||
|
route_table_id = aws_route_table.public.id
|
||||||
|
destination_cidr_block = "0.0.0.0/0"
|
||||||
|
gateway_id = aws_internet_gateway.this.id
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "aws_route_table_association" "public" {
|
||||||
|
count = length(local.public_subnet_cidrs)
|
||||||
|
|
||||||
|
subnet_id = aws_subnet.public[count.index].id
|
||||||
|
route_table_id = aws_route_table.public.id
|
||||||
|
}
|
||||||
|
|
@ -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))
|
_repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
sys.path.insert(0, _repo_root)
|
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
|
# Add the shared layer source directory so `from shared.db import ...` works
|
||||||
# without requiring a real Lambda layer or .aws-sam build.
|
# 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))
|
sys.path.insert(0, os.path.abspath(_shared_layer_dir))
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ sys.path.insert(0, os.path.abspath(_shared))
|
||||||
os.environ.setdefault("TABLE_NAME", "meal-order-manager-orders-test")
|
os.environ.setdefault("TABLE_NAME", "meal-order-manager-orders-test")
|
||||||
|
|
||||||
_handler_path = os.path.join(
|
_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(
|
_spec = importlib.util.spec_from_file_location(
|
||||||
"admin_authorizer_handler", os.path.abspath(_handler_path)
|
"admin_authorizer_handler", os.path.abspath(_handler_path)
|
||||||
|
|
|
||||||
|
|
@ -75,23 +75,21 @@ def handler_module():
|
||||||
"""Import the handler with boto3 patched at module level."""
|
"""Import the handler with boto3 patched at module level."""
|
||||||
with patch("boto3.client") as mock_client, patch("boto3.resource"):
|
with patch("boto3.client") as mock_client, patch("boto3.resource"):
|
||||||
mock_s3 = MagicMock()
|
mock_s3 = MagicMock()
|
||||||
mock_lambda = MagicMock()
|
|
||||||
mock_sqs = MagicMock()
|
mock_sqs = MagicMock()
|
||||||
mock_client.side_effect = lambda svc, **kw: {
|
mock_client.side_effect = lambda svc, **kw: {
|
||||||
"s3": mock_s3,
|
"s3": mock_s3,
|
||||||
"lambda": mock_lambda,
|
|
||||||
"sqs": mock_sqs,
|
"sqs": mock_sqs,
|
||||||
}[svc]
|
}.get(svc, MagicMock())
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import functions.aggregate_orders.handler as mod
|
import server.jobs.aggregate as mod
|
||||||
|
|
||||||
importlib.reload(mod)
|
importlib.reload(mod)
|
||||||
|
|
||||||
# Inject mocked clients so tests can assert on them
|
# Inject mocked clients so tests can assert on them
|
||||||
mod._s3 = mock_s3
|
mod._s3 = mock_s3
|
||||||
mod._lambda = mock_lambda
|
|
||||||
mod._sqs = mock_sqs
|
mod._sqs = mock_sqs
|
||||||
|
mod._dispatch_job = MagicMock()
|
||||||
yield mod
|
yield mod
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -397,9 +395,9 @@ class TestBuildPayrollCsvSubtotalFallback:
|
||||||
class TestAggregateAlreadyAggregated:
|
class TestAggregateAlreadyAggregated:
|
||||||
"""test_aggregate_already_aggregated — summary exists, returns early, no S3 upload."""
|
"""test_aggregate_already_aggregated — summary exists, returns early, no S3 upload."""
|
||||||
|
|
||||||
@patch("functions.aggregate_orders.handler.get_summary")
|
@patch("server.jobs.aggregate.get_summary")
|
||||||
@patch("functions.aggregate_orders.handler.get_orders")
|
@patch("server.jobs.aggregate.get_orders")
|
||||||
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
|
@patch("server.jobs.aggregate.current_week", return_value="2026-W20")
|
||||||
def test_aggregate_already_aggregated(
|
def test_aggregate_already_aggregated(
|
||||||
self, mock_week, mock_orders, mock_summary, handler_module
|
self, mock_week, mock_orders, mock_summary, handler_module
|
||||||
):
|
):
|
||||||
|
|
@ -419,9 +417,9 @@ class TestAggregateAlreadyAggregated:
|
||||||
class TestAggregateNoOrders:
|
class TestAggregateNoOrders:
|
||||||
"""test_aggregate_no_orders — no orders returns no_orders status."""
|
"""test_aggregate_no_orders — no orders returns no_orders status."""
|
||||||
|
|
||||||
@patch("functions.aggregate_orders.handler.get_summary")
|
@patch("server.jobs.aggregate.get_summary")
|
||||||
@patch("functions.aggregate_orders.handler.get_orders")
|
@patch("server.jobs.aggregate.get_orders")
|
||||||
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
|
@patch("server.jobs.aggregate.current_week", return_value="2026-W20")
|
||||||
def test_aggregate_no_orders(
|
def test_aggregate_no_orders(
|
||||||
self, mock_week, mock_orders, mock_summary, handler_module
|
self, mock_week, mock_orders, mock_summary, handler_module
|
||||||
):
|
):
|
||||||
|
|
@ -441,10 +439,10 @@ class TestAggregateNoOrders:
|
||||||
class TestAggregateHappyPath:
|
class TestAggregateHappyPath:
|
||||||
"""test_aggregate_happy_path — orders exist, builds summary, uploads CSVs, saves, triggers Slack."""
|
"""test_aggregate_happy_path — orders exist, builds summary, uploads CSVs, saves, triggers Slack."""
|
||||||
|
|
||||||
@patch("functions.aggregate_orders.handler.put_summary")
|
@patch("server.jobs.aggregate.put_summary")
|
||||||
@patch("functions.aggregate_orders.handler.get_summary")
|
@patch("server.jobs.aggregate.get_summary")
|
||||||
@patch("functions.aggregate_orders.handler.get_orders")
|
@patch("server.jobs.aggregate.get_orders")
|
||||||
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
|
@patch("server.jobs.aggregate.current_week", return_value="2026-W20")
|
||||||
def test_aggregate_happy_path(
|
def test_aggregate_happy_path(
|
||||||
self, mock_week, mock_orders, mock_get_summary, mock_put_summary, handler_module
|
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"
|
== "reports/2026-W20/weekly-summary-2026-W20.pdf"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify Slack notifier Lambda invoked asynchronously
|
# Verify Slack notifier job is dispatched
|
||||||
handler_module._lambda.invoke.assert_called_once()
|
handler_module._dispatch_job.assert_called_once_with(
|
||||||
invoke_kwargs = handler_module._lambda.invoke.call_args.kwargs
|
{"event": "orders_aggregated", "week": "2026-W20"}
|
||||||
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"
|
|
||||||
|
|
||||||
handler_module._sqs.send_message.assert_called_once()
|
handler_module._sqs.send_message.assert_called_once()
|
||||||
send_kwargs = handler_module._sqs.send_message.call_args.kwargs
|
send_kwargs = handler_module._sqs.send_message.call_args.kwargs
|
||||||
|
|
@ -574,10 +568,10 @@ class TestCheckcomponentsPayload:
|
||||||
|
|
||||||
|
|
||||||
class TestCheckcomponentsSend:
|
class TestCheckcomponentsSend:
|
||||||
@patch("functions.aggregate_orders.handler.put_summary")
|
@patch("server.jobs.aggregate.put_summary")
|
||||||
@patch("functions.aggregate_orders.handler.get_summary")
|
@patch("server.jobs.aggregate.get_summary")
|
||||||
@patch("functions.aggregate_orders.handler.get_orders")
|
@patch("server.jobs.aggregate.get_orders")
|
||||||
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
|
@patch("server.jobs.aggregate.current_week", return_value="2026-W20")
|
||||||
def test_sqs_failure_still_aggregates_and_notifies(
|
def test_sqs_failure_still_aggregates_and_notifies(
|
||||||
self, mock_week, mock_orders, mock_get_summary, mock_put_summary, handler_module
|
self, mock_week, mock_orders, mock_get_summary, mock_put_summary, handler_module
|
||||||
):
|
):
|
||||||
|
|
@ -591,12 +585,12 @@ class TestCheckcomponentsSend:
|
||||||
|
|
||||||
assert result["status"] == "aggregated"
|
assert result["status"] == "aggregated"
|
||||||
mock_put_summary.assert_called_once()
|
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("server.jobs.aggregate.put_summary")
|
||||||
@patch("functions.aggregate_orders.handler.get_summary")
|
@patch("server.jobs.aggregate.get_summary")
|
||||||
@patch("functions.aggregate_orders.handler.get_orders")
|
@patch("server.jobs.aggregate.get_orders")
|
||||||
@patch("functions.aggregate_orders.handler.current_week", return_value="2026-W20")
|
@patch("server.jobs.aggregate.current_week", return_value="2026-W20")
|
||||||
def test_empty_queue_url_skips_send(
|
def test_empty_queue_url_skips_send(
|
||||||
self,
|
self,
|
||||||
mock_week,
|
mock_week,
|
||||||
|
|
@ -616,4 +610,4 @@ class TestCheckcomponentsSend:
|
||||||
|
|
||||||
assert result["status"] == "aggregated"
|
assert result["status"] == "aggregated"
|
||||||
handler_module._sqs.send_message.assert_not_called()
|
handler_module._sqs.send_message.assert_not_called()
|
||||||
handler_module._lambda.invoke.assert_called_once()
|
handler_module._dispatch_job.assert_called_once()
|
||||||
|
|
|
||||||
47
tests/test_app.py
Normal file
47
tests/test_app.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""Flask app health and CORS preflight."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
||||||
|
os.environ.setdefault("TABLE_NAME", "test-orders-table")
|
||||||
|
|
||||||
|
from server.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_returns_stage_and_sha(monkeypatch):
|
||||||
|
monkeypatch.setenv("STAGE", "local")
|
||||||
|
monkeypatch.setenv("GIT_SHA", "abc123")
|
||||||
|
client = create_app().test_client()
|
||||||
|
response = client.get("/api/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.get_json() == {"stage": "local", "sha": "abc123"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_options_preflight_from_portal_origin():
|
||||||
|
client = create_app().test_client()
|
||||||
|
response = client.options(
|
||||||
|
"/api/submit-order",
|
||||||
|
headers={"Origin": "https://internal.seahaven.com"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert (
|
||||||
|
response.headers["Access-Control-Allow-Origin"]
|
||||||
|
== "https://internal.seahaven.com"
|
||||||
|
)
|
||||||
|
assert "Authorization" in response.headers["Access-Control-Allow-Headers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_dispatch_jsonifies_handler_body():
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with patch("server.http_api.lambda_handler") as mock_handler:
|
||||||
|
mock_handler.return_value = {
|
||||||
|
"statusCode": 400,
|
||||||
|
"headers": {"Content-Type": "application/json"},
|
||||||
|
"body": '{"error": "Bad request"}',
|
||||||
|
}
|
||||||
|
client = create_app().test_client()
|
||||||
|
response = client.get("/api/menu")
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.get_json() == {"error": "Bad request"}
|
||||||
|
assert response.content_type.startswith("application/json")
|
||||||
|
|
@ -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 os
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
os.environ.setdefault(
|
os.environ.setdefault(
|
||||||
"AGGREGATE_FUNCTION_ARN",
|
"AGGREGATE_FUNCTION_ARN",
|
||||||
|
|
@ -14,7 +12,7 @@ os.environ.setdefault(
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
|
||||||
_handler_path = os.path.join(
|
_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(
|
_spec = importlib.util.spec_from_file_location(
|
||||||
"close_form_handler", os.path.abspath(_handler_path)
|
"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
|
sys.modules["close_form_handler"] = close_form_handler
|
||||||
_spec.loader.exec_module(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.set_form_status")
|
||||||
@patch("close_form_handler.get_form_status", return_value="open")
|
@patch("close_form_handler.get_form_status", return_value="open")
|
||||||
@patch("close_form_handler.current_week", return_value="2026-W19")
|
@patch("close_form_handler.current_week", return_value="2026-W19")
|
||||||
@patch("close_form_handler._lambda")
|
@patch("close_form_handler._dispatch_job")
|
||||||
@patch("close_form_handler.datetime")
|
def test_closes_open_form(self, mock_lam, mock_week, mock_status, mock_set):
|
||||||
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
|
|
||||||
|
|
||||||
result = close_form_handler.lambda_handler({}, None)
|
result = close_form_handler.lambda_handler({}, None)
|
||||||
|
|
||||||
assert result["status"] == "closed"
|
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.set_form_status")
|
||||||
@patch("close_form_handler.get_form_status", return_value="open")
|
@patch("close_form_handler.get_form_status", return_value="open")
|
||||||
@patch("close_form_handler.current_week", return_value="2026-W19")
|
@patch("close_form_handler._dispatch_job")
|
||||||
@patch("close_form_handler._lambda")
|
def test_uses_week_from_event(self, mock_lam, mock_status, mock_set):
|
||||||
@patch("close_form_handler.datetime")
|
result = close_form_handler.lambda_handler({"week": "2026-W20"}, None)
|
||||||
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)
|
|
||||||
|
|
||||||
assert result["status"] == "closed"
|
assert result["status"] == "closed"
|
||||||
|
mock_set.assert_called_once_with("2026-W20", "closed")
|
||||||
|
|
||||||
@patch("close_form_handler.set_form_status")
|
@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.get_form_status", return_value="closed")
|
||||||
@patch("close_form_handler.current_week", return_value="2026-W19")
|
@patch("close_form_handler.current_week", return_value="2026-W19")
|
||||||
@patch("close_form_handler.datetime")
|
@patch("close_form_handler._dispatch_job")
|
||||||
def test_already_closed(self, mock_dt, mock_week, mock_status):
|
def test_already_closed(self, mock_lam, mock_week, mock_status, mock_set):
|
||||||
"""Thursday 11pm but already closed -> returns already_closed."""
|
|
||||||
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23)
|
|
||||||
|
|
||||||
result = close_form_handler.lambda_handler({}, None)
|
result = close_form_handler.lambda_handler({}, None)
|
||||||
|
|
||||||
assert result["status"] == "already_closed"
|
assert result["status"] == "already_closed"
|
||||||
|
mock_set.assert_not_called()
|
||||||
|
mock_lam.assert_not_called()
|
||||||
|
|
|
||||||
|
|
@ -156,45 +156,28 @@ class TestGenerateFormStructural:
|
||||||
assert "x-api-key" not in html
|
assert "x-api-key" not in html
|
||||||
|
|
||||||
def test_cloud_configuration_has_no_form_api_key(self):
|
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()
|
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
|
||||||
readme = (REPO_ROOT / "README.md").read_text()
|
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
|
||||||
for text in (template, workflow):
|
for text in (workflow, http_api):
|
||||||
assert "FORM_APIKEY" not in text
|
assert "FORM_APIKEY" not in text
|
||||||
assert "form-api-key" not in text
|
assert "form-api-key" not in text
|
||||||
assert "x-api-key" not in text
|
assert "x-api-key" not in text
|
||||||
assert "--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):
|
def test_submit_route_has_explicit_throttling(self):
|
||||||
template = (REPO_ROOT / "template.yaml").read_text()
|
http_api = (REPO_ROOT / "src" / "server" / "http_api.py").read_text()
|
||||||
assert "'POST /api/submit-order':" in template
|
assert "SUBMIT_RATE_PER_SEC = 5.0" in http_api
|
||||||
submit_settings = template.split("'POST /api/submit-order':", 1)[1].split(
|
assert "def _allow_submit()" in http_api
|
||||||
"CorsConfiguration:", 1
|
|
||||||
)[0]
|
|
||||||
assert "ThrottlingBurstLimit: 10" in submit_settings
|
|
||||||
assert "ThrottlingRateLimit: 5" in submit_settings
|
|
||||||
|
|
||||||
def test_weekly_menu_uses_iam_protected_api_without_dynamodb(self):
|
def test_weekly_menu_uses_hmac_publish_without_dynamodb(self):
|
||||||
template = (REPO_ROOT / "template.yaml").read_text()
|
|
||||||
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
|
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
|
||||||
script = (REPO_ROOT / "scripts" / "upload_menu.py").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"):
|
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 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 settings" in workflow
|
||||||
assert "scripts/upload_menu.py publish" in workflow
|
assert "scripts/upload_menu.py publish" in workflow
|
||||||
assert "aws dynamodb" not in workflow
|
assert "aws dynamodb" not in workflow
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ def test_get_parameter_refetches_after_ttl():
|
||||||
assert mock_ssm.get_parameter.call_count == 2
|
assert mock_ssm.get_parameter.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
def test_get_secret_stays_cached():
|
def test_get_secret_refetches_after_ttl():
|
||||||
"""Secrets Manager values remain cached (no TTL)."""
|
"""Secrets Manager values expire so a long-lived task observes rotation."""
|
||||||
from shared import secrets
|
from shared import secrets
|
||||||
|
|
||||||
secrets._secret_cache.clear()
|
secrets._secret_cache.clear()
|
||||||
|
|
@ -43,8 +43,13 @@ def test_get_secret_stays_cached():
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch.object(secrets, "_sm", mock_sm):
|
with patch.object(secrets, "_sm", mock_sm):
|
||||||
with patch("shared.secrets.time.monotonic", side_effect=[0.0, 5000.0]):
|
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") == "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
|
||||||
|
|
|
||||||
|
|
@ -2,105 +2,52 @@
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Ensure the handler module can be imported. The shared layer lives under
|
# 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
|
import importlib.util
|
||||||
|
|
||||||
_repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
_repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
sys.path.insert(0, os.path.join(_repo, "src", "shared"))
|
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)
|
_spec = importlib.util.spec_from_file_location("slack_notifier_handler", _handler_path)
|
||||||
handler = importlib.util.module_from_spec(_spec)
|
handler = importlib.util.module_from_spec(_spec)
|
||||||
sys.modules["slack_notifier_handler"] = handler
|
sys.modules["slack_notifier_handler"] = handler
|
||||||
_spec.loader.exec_module(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):
|
class TestReminderDelayedDelivery:
|
||||||
"""Return a timezone-aware datetime in America/New_York."""
|
@patch("slack_notifier_handler.send_dm")
|
||||||
return datetime(year, month, day, hour, minute, tzinfo=ET)
|
@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 _thursday_10am():
|
def test_reminder_runs_after_scheduled_hour(
|
||||||
"""2026-05-14 is a Thursday."""
|
self, mock_week, mock_roster, mock_orders, mock_dm
|
||||||
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()
|
|
||||||
|
|
||||||
result = handler.handle_reminder({"event": "reminder"})
|
result = handler.handle_reminder({"event": "reminder"})
|
||||||
|
|
||||||
assert result["status"] == "skipped", "Should skip when hour is not 10"
|
assert result["status"] != "skipped"
|
||||||
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"]
|
|
||||||
|
|
||||||
@patch("slack_notifier_handler.send_dm")
|
@patch("slack_notifier_handler.send_dm")
|
||||||
@patch("slack_notifier_handler.get_orders", return_value=[])
|
@patch("slack_notifier_handler.get_orders", return_value=[])
|
||||||
@patch("slack_notifier_handler.get_roster", return_value=[])
|
@patch("slack_notifier_handler.get_roster", return_value=[])
|
||||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||||
@patch("slack_notifier_handler.datetime")
|
def test_lambda_handler_reminder_does_not_skip(
|
||||||
def test_reminder_runs_at_correct_time(
|
self, mock_week, mock_roster, mock_orders, mock_dm
|
||||||
self, mock_dt, 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)
|
result = handler.lambda_handler({"event": "reminder"}, None)
|
||||||
|
|
||||||
assert result["status"] == "skipped", (
|
assert result["status"] != "skipped"
|
||||||
"lambda_handler should short-circuit before calling handle_reminder"
|
|
||||||
)
|
|
||||||
assert "outside reminder window" in result["reason"]
|
|
||||||
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -113,12 +60,10 @@ class TestReminderDMs:
|
||||||
@patch("slack_notifier_handler.get_orders")
|
@patch("slack_notifier_handler.get_orders")
|
||||||
@patch("slack_notifier_handler.get_roster")
|
@patch("slack_notifier_handler.get_roster")
|
||||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||||
@patch("slack_notifier_handler.datetime")
|
|
||||||
def test_reminder_sends_to_non_ordered(
|
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."""
|
"""Roster of 3, 1 has ordered -> DMs sent to 2 others."""
|
||||||
mock_dt.now.return_value = _thursday_10am()
|
|
||||||
mock_roster.return_value = [
|
mock_roster.return_value = [
|
||||||
{"email": "alice@x.com", "name": "Alice A", "slack_user_id": "U001"},
|
{"email": "alice@x.com", "name": "Alice A", "slack_user_id": "U001"},
|
||||||
{"email": "bob@x.com", "name": "Bob B", "slack_user_id": "U002"},
|
{"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_orders", return_value=[])
|
||||||
@patch("slack_notifier_handler.get_roster")
|
@patch("slack_notifier_handler.get_roster")
|
||||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||||
@patch("slack_notifier_handler.datetime")
|
|
||||||
def test_reminder_skips_no_slack_id(
|
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."""
|
"""Employee without slack_user_id is counted as missing but not DM'd."""
|
||||||
mock_dt.now.return_value = _thursday_10am()
|
|
||||||
mock_roster.return_value = [
|
mock_roster.return_value = [
|
||||||
{"email": "dave@x.com", "name": "Dave D"}, # no slack_user_id
|
{"email": "dave@x.com", "name": "Dave D"}, # no slack_user_id
|
||||||
{"email": "eve@x.com", "name": "Eve E", "slack_user_id": "U005"},
|
{"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_orders")
|
||||||
@patch("slack_notifier_handler.get_roster")
|
@patch("slack_notifier_handler.get_roster")
|
||||||
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
@patch("slack_notifier_handler.current_week", return_value="2026-W19")
|
||||||
@patch("slack_notifier_handler.datetime")
|
|
||||||
def test_reminder_case_insensitive_email(
|
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."""
|
"""'Adam@x.com' in roster matches 'adam@x.com' in orders."""
|
||||||
mock_dt.now.return_value = _thursday_10am()
|
|
||||||
mock_roster.return_value = [
|
mock_roster.return_value = [
|
||||||
{"email": "Adam@x.com", "name": "Adam M", "slack_user_id": "U010"},
|
{"email": "Adam@x.com", "name": "Adam M", "slack_user_id": "U010"},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
All external dependencies (DynamoDB, SSM, Google tokeninfo, Lambda invoke) are
|
||||||
mocked — no real AWS calls are made.
|
mocked — no real AWS calls are made.
|
||||||
|
|
@ -7,6 +7,7 @@ mocked — no real AWS calls are made.
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
@ -28,7 +29,7 @@ os.environ.setdefault("GOOGLE_CLIENT_ID_PARAM", "")
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
|
||||||
_handler_path = os.path.join(
|
_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(
|
_spec = importlib.util.spec_from_file_location(
|
||||||
"submit_order_handler", os.path.abspath(_handler_path)
|
"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.put_menu")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W30")
|
@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):
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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"}
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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"
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
||||||
|
|
@ -1526,7 +1577,7 @@ def test_submit_missing_name(
|
||||||
mock_put.assert_not_called()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
||||||
|
|
@ -1559,7 +1610,7 @@ def test_submit_missing_email(
|
||||||
mock_put.assert_not_called()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="closed")
|
@patch("submit_order_handler.get_form_status", return_value="closed")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
||||||
|
|
@ -1617,7 +1668,7 @@ def test_submit_form_closed(
|
||||||
mock_put.assert_not_called()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="closed")
|
@patch("submit_order_handler.get_form_status", return_value="closed")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="closed")
|
@patch("submit_order_handler.get_form_status", return_value="closed")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="not_found")
|
@patch("submit_order_handler.get_form_status", return_value="not_found")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
||||||
|
|
@ -1756,7 +1807,7 @@ def test_submit_no_menu(
|
||||||
mock_put.assert_not_called()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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()
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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"]
|
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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@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.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
||||||
|
|
|
||||||
|
|
@ -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
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -35,15 +35,15 @@ def test_portal_cognito_parameters_are_managed():
|
||||||
assert 'variable "portal_cognito_extra_trust"' in variables_tf
|
assert 'variable "portal_cognito_extra_trust"' in variables_tf
|
||||||
|
|
||||||
|
|
||||||
def test_both_api_lambdas_receive_parameter_names():
|
def test_ecs_task_receives_cognito_parameter_names():
|
||||||
lambda_tf = _read("lambda.tf")
|
ecs_tf = _read("ecs.tf")
|
||||||
|
|
||||||
assert lambda_tf.count("PORTAL_COGNITO_ISSUER_PARAM") == 2
|
assert ecs_tf.count("PORTAL_COGNITO_ISSUER_PARAM") == 1
|
||||||
assert lambda_tf.count("PORTAL_COGNITO_AUDIENCE_PARAM") == 2
|
assert ecs_tf.count("PORTAL_COGNITO_AUDIENCE_PARAM") == 1
|
||||||
assert lambda_tf.count("PORTAL_COGNITO_TRUST_PARAM") == 2
|
assert ecs_tf.count("PORTAL_COGNITO_TRUST_PARAM") == 1
|
||||||
assert lambda_tf.count("aws_ssm_parameter.portal_cognito_issuer.name") == 2
|
assert "aws_ssm_parameter.portal_cognito_issuer.name" in ecs_tf
|
||||||
assert lambda_tf.count("aws_ssm_parameter.portal_cognito_audience.name") == 2
|
assert "aws_ssm_parameter.portal_cognito_audience.name" in ecs_tf
|
||||||
assert lambda_tf.count("aws_ssm_parameter.portal_cognito_trust.name") == 2
|
assert "aws_ssm_parameter.portal_cognito_trust.name" in ecs_tf
|
||||||
|
|
||||||
|
|
||||||
def test_submit_route_remains_public_for_google_compatibility():
|
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 'route_key = "POST /api/submit-order"' in submit_route
|
||||||
assert 'authorizer = "NONE"' 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")
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -6,36 +6,31 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||||
TERRAFORM = ROOT / "terraform"
|
TERRAFORM = ROOT / "terraform"
|
||||||
|
|
||||||
|
|
||||||
def _read(name: str) -> str:
|
|
||||||
return (TERRAFORM / name).read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def test_email_report_handler_removed():
|
def test_email_report_handler_removed():
|
||||||
assert not (ROOT / "functions" / "email_report").exists()
|
assert not (ROOT / "functions" / "email_report").exists()
|
||||||
|
assert not (ROOT / "functions").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_email_report_not_in_function_packages():
|
def test_lambda_and_api_gateway_packaging_removed():
|
||||||
locals_tf = _read("locals.tf")
|
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
|
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 "ses:SendRawEmail" not in iam_tf
|
||||||
assert "email-report" not in alarms
|
assert "email-report" not in alarms
|
||||||
assert "email_report" not in outputs
|
assert "email_report" not in outputs
|
||||||
|
|
@ -43,10 +38,9 @@ def test_email_report_lambda_and_role_removed():
|
||||||
assert "sender_email" not in variables
|
assert "sender_email" not in variables
|
||||||
|
|
||||||
|
|
||||||
def test_email_report_removed_from_sam_template():
|
def test_job_schedules_disabled_outside_prod():
|
||||||
template = (ROOT / "template.yaml").read_text()
|
scheduler = (TERRAFORM / "scheduler.tf").read_text()
|
||||||
assert "EmailReportFunction" not in template
|
assert (
|
||||||
assert "functions/email_report/" not in template
|
'state = local.is_prod ? "ENABLED" : "DISABLED"'
|
||||||
assert "PayrollEmail" not in template
|
in scheduler
|
||||||
assert "SenderEmail" not in template
|
)
|
||||||
assert "ses:SendRawEmail" not in template
|
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,24 @@ from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
TERRAFORM = ROOT / "terraform"
|
TERRAFORM = ROOT / "terraform"
|
||||||
|
SERVER = ROOT / "src" / "server"
|
||||||
|
|
||||||
|
|
||||||
def _read(name: str) -> str:
|
def _read(name: str) -> str:
|
||||||
return (TERRAFORM / name).read_text()
|
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")
|
locals_tf = _read("locals.tf")
|
||||||
|
|
||||||
assert 'route_key = "GET /api/menu/{week}"' in locals_tf
|
assert 'route_key = "GET /api/menu/{week}"' in locals_tf
|
||||||
assert 'authorizer = "NONE"' in locals_tf
|
assert 'authorizer = "NONE"' in locals_tf
|
||||||
|
assert 'authorizer = "HMAC"' in locals_tf
|
||||||
assert 'permission_source = "GET/api/menu/*"' in locals_tf
|
assert 'permission_source = "GET/api/menu/*"' in locals_tf
|
||||||
|
|
||||||
|
|
||||||
def test_cors_allows_form_portal_and_local_origins():
|
def test_cors_allows_form_portal_and_local_origins():
|
||||||
api = _read("apigateway.tf")
|
app = (SERVER / "app.py").read_text()
|
||||||
|
|
||||||
for origin in (
|
for origin in (
|
||||||
"https://orders.seahaven.com",
|
"https://orders.seahaven.com",
|
||||||
|
|
@ -28,20 +30,17 @@ def test_cors_allows_form_portal_and_local_origins():
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://localhost:4173",
|
"http://localhost:4173",
|
||||||
):
|
):
|
||||||
assert f'"{origin}"' in api
|
assert f'"{origin}"' in app
|
||||||
assert 'allow_headers = ["Authorization", "Content-Type"]' in api
|
assert "Authorization, Content-Type, X-Meals-Publish-Key" in app
|
||||||
assert 'allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]' in api
|
assert "GET, POST, PUT, DELETE, OPTIONS" in app
|
||||||
assert "allow_credentials = false" in api
|
|
||||||
|
|
||||||
|
|
||||||
def test_cloudfront_forwards_and_caches_menu_by_origin_for_60_seconds():
|
def test_cloudfront_forwards_and_caches_menu_by_origin_for_60_seconds():
|
||||||
cloudfront = _read("cloudfront.tf")
|
cloudfront = _read("cloudfront.tf")
|
||||||
|
|
||||||
assert 'origin_id = "OrderApiOrigin"' in cloudfront
|
assert 'origin_id = "OrderApiOrigin"' in cloudfront
|
||||||
assert (
|
assert "domain_name = aws_lb.api.dns_name" in cloudfront
|
||||||
'domain_name = trimprefix(aws_apigatewayv2_api.order_api.api_endpoint, "https://")'
|
assert 'origin_protocol_policy = "http-only"' in cloudfront
|
||||||
in cloudfront
|
|
||||||
)
|
|
||||||
assert 'path_pattern = "/api/menu/*"' in cloudfront
|
assert 'path_pattern = "/api/menu/*"' in cloudfront
|
||||||
assert 'target_origin_id = "OrderApiOrigin"' in cloudfront
|
assert 'target_origin_id = "OrderApiOrigin"' in cloudfront
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -58,7 +57,7 @@ def test_cloudfront_forwards_and_caches_menu_by_origin_for_60_seconds():
|
||||||
assert 'items = ["Origin"]' in cloudfront
|
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")
|
cloudfront = _read("cloudfront.tf")
|
||||||
locals_tf = _read("locals.tf")
|
locals_tf = _read("locals.tf")
|
||||||
|
|
||||||
|
|
@ -69,11 +68,11 @@ def test_cloudfront_forwards_portal_api_paths_without_publish_or_roster():
|
||||||
'"/api/orders/*"',
|
'"/api/orders/*"',
|
||||||
'"/api/submit-order"',
|
'"/api/submit-order"',
|
||||||
'"/api/admin/*"',
|
'"/api/admin/*"',
|
||||||
|
'"/api/roster"',
|
||||||
):
|
):
|
||||||
assert pattern in cloudfront
|
assert pattern in cloudfront
|
||||||
assert 'path_pattern = "/api/*"' not in cloudfront
|
assert 'path_pattern = "/api/*"' not in cloudfront
|
||||||
assert "/api/publish" not in cloudfront
|
assert "/api/publish" not in cloudfront
|
||||||
assert "/api/roster" not in cloudfront
|
|
||||||
assert "custom_error_response" not in cloudfront
|
assert "custom_error_response" not in cloudfront
|
||||||
assert 'resource "aws_cloudfront_function" "form_spa_rewrite"' in cloudfront
|
assert 'resource "aws_cloudfront_function" "form_spa_rewrite"' in cloudfront
|
||||||
assert "function_arn = aws_cloudfront_function.form_spa_rewrite.arn" in cloudfront
|
assert "function_arn = aws_cloudfront_function.form_spa_rewrite.arn" in cloudfront
|
||||||
|
|
|
||||||
26
tests/test_terraform_vpc.py
Normal file
26
tests/test_terraform_vpc.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
"""Meals owns a dedicated VPC. Prod has no default VPC."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
TERRAFORM = ROOT / "terraform"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(name: str) -> str:
|
||||||
|
return (TERRAFORM / name).read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_meals_owns_a_vpc_instead_of_looking_up_default():
|
||||||
|
vpc = _read("vpc.tf")
|
||||||
|
ecs = _read("ecs.tf")
|
||||||
|
locals_tf = _read("locals.tf")
|
||||||
|
|
||||||
|
assert 'resource "aws_vpc" "this"' in vpc
|
||||||
|
assert "cidr_block = local.vpc_cidr" in vpc
|
||||||
|
assert 'vpc_cidr = "10.60.0.0/16"' in locals_tf
|
||||||
|
assert 'data "aws_vpc" "default"' not in ecs
|
||||||
|
assert "data.aws_vpc.default" not in ecs
|
||||||
|
assert "data.aws_subnets.default" not in ecs
|
||||||
|
assert "aws_vpc.this.id" in ecs
|
||||||
|
assert "aws_subnet.public[*].id" in ecs
|
||||||
|
assert "ec2:CreateVpc" in _read("hcp_iam.tf")
|
||||||
53
tests/test_worker.py
Normal file
53
tests/test_worker.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""SQS worker deletes completed jobs and leaves skipped messages for retry."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from server import worker
|
||||||
|
|
||||||
|
|
||||||
|
def _one_message_then_stop(body: dict):
|
||||||
|
def receive_message(**_kwargs):
|
||||||
|
worker._stop(None, None)
|
||||||
|
return {
|
||||||
|
"Messages": [
|
||||||
|
{
|
||||||
|
"ReceiptHandle": "rh-1",
|
||||||
|
"Body": json.dumps(body),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return receive_message
|
||||||
|
|
||||||
|
|
||||||
|
@patch("server.worker.boto3.client")
|
||||||
|
@patch("server.worker.run_job")
|
||||||
|
def test_worker_does_not_delete_skipped_jobs(mock_run, mock_client):
|
||||||
|
sqs = MagicMock()
|
||||||
|
mock_client.return_value = sqs
|
||||||
|
sqs.receive_message.side_effect = _one_message_then_stop({"event": "close"})
|
||||||
|
mock_run.return_value = {"status": "skipped", "reason": "outside window"}
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"JOBS_QUEUE_URL": "https://sqs.example/jobs"}):
|
||||||
|
worker._running = True
|
||||||
|
worker.main()
|
||||||
|
|
||||||
|
sqs.delete_message.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("server.worker.boto3.client")
|
||||||
|
@patch("server.worker.run_job")
|
||||||
|
def test_worker_deletes_completed_jobs(mock_run, mock_client):
|
||||||
|
sqs = MagicMock()
|
||||||
|
mock_client.return_value = sqs
|
||||||
|
sqs.receive_message.side_effect = _one_message_then_stop({"event": "close"})
|
||||||
|
mock_run.return_value = {"status": "closed", "week": "2026-W19"}
|
||||||
|
|
||||||
|
with patch.dict("os.environ", {"JOBS_QUEUE_URL": "https://sqs.example/jobs"}):
|
||||||
|
worker._running = True
|
||||||
|
worker.main()
|
||||||
|
|
||||||
|
sqs.delete_message.assert_called_once_with(
|
||||||
|
QueueUrl="https://sqs.example/jobs", ReceiptHandle="rh-1"
|
||||||
|
)
|
||||||
Loading…
Add table
Reference in a new issue