refactor(workflow): publish weekly menus through API

Use SigV4 requests for settings and menu publication so the scheduled workflow no longer needs direct DynamoDB access.
This commit is contained in:
Adam Moussa 2026-07-31 11:44:06 -04:00
parent 1ee291f8bf
commit f0912e56df
No known key found for this signature in database
4 changed files with 134 additions and 59 deletions

View file

@ -92,22 +92,12 @@ jobs:
if: env.SKIP_RUN != 'true'
id: discount
run: |
RESULT=$(aws dynamodb get-item \
--table-name meal-order-manager-orders \
--key '{"PK":{"S":"CONFIG"},"SK":{"S":"SETTINGS"}}' \
--output json 2>/dev/null || echo '{}')
BULK=$(echo "$RESULT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('Item',{}).get('bulk_discount_percent',{}).get('N','0'))
" 2>/dev/null || echo "0")
SUBSIDY=$(echo "$RESULT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('Item',{}).get('company_subsidy_percent',{}).get('N','0'))
" 2>/dev/null || echo "0")
echo "bulk_discount=$BULK" >> $GITHUB_OUTPUT
echo "company_subsidy=$SUBSIDY" >> $GITHUB_OUTPUT
SETTINGS=$(python3 scripts/upload_menu.py settings \
--api-url "${{ steps.stack.outputs.api_url }}")
BULK=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["bulk_discount_percent"])' "$SETTINGS")
SUBSIDY=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["company_subsidy_percent"])' "$SETTINGS")
echo "bulk_discount=$BULK" >> "$GITHUB_OUTPUT"
echo "company_subsidy=$SUBSIDY" >> "$GITHUB_OUTPUT"
- name: Get Google Client ID
if: env.SKIP_RUN != 'true'
@ -132,9 +122,11 @@ jobs:
--company-subsidy "${{ steps.discount.outputs.company_subsidy }}" \
--google-client-id "${{ steps.google.outputs.client_id }}"
- name: Upload menu to DynamoDB
- name: Publish menu through API
if: env.SKIP_RUN != 'true'
run: python3 scripts/upload_menu.py
run: |
python3 scripts/upload_menu.py publish \
--api-url "${{ steps.stack.outputs.api_url }}"
- name: Upload form to S3
if: env.SKIP_RUN != 'true'

View file

@ -14,7 +14,7 @@ Monday 7:30am ET Employees (Mon–Thu) Thursda
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ GitHub Actions │ │ orders.seahaven │ │ EventBridge │
│ - Scrape menu │────S3 upload───▶│ ind.com │ │ - Close form │
│ - Generate form │ + DynamoDB │ (CloudFront+S3) │──POST───┐ │ - Aggregate │
│ - Generate form │ + signed API │ (CloudFront+S3) │──POST───┐ │ - Aggregate │
│ - Slack notify │ └──────────────────┘ │ │ - Slack summary │
└─────────────────┘ ▼ └──────────────────┘
┌──────────┐
@ -50,6 +50,25 @@ the generated HTML, and the generated deployment artifact remains self-contained
| Thursday 10am ET | DM employees who haven't ordered yet | EventBridge → Lambda → Slack DM |
| Thursday 6pm ET | Close form, aggregate orders, write CSV reports + weekly summary PDF, post Redefine order summary to Slack | EventBridge → Lambda chain |
### Weekly menu publication boundary
The scheduled GitHub workflow has no DynamoDB permissions. It signs two requests
with its short-lived OIDC role credentials:
- `GET /api/publish/settings` returns only the bulk discount and company subsidy.
- `POST /api/publish/menu` validates and writes the current Eastern-time week's menu.
Both routes use API Gateway `AWS_IAM` authorization and invoke the existing
submit-order Lambda. The GitHub role can invoke only these method and path
combinations. Employee orders, the roster, admin configuration, and all direct
DynamoDB actions remain inaccessible to the role.
Deploy the API routes before switching the workflow and IAM policy. No data
migration is required because the Lambda writes the existing `WEEK#...` / `MENU`
record shape. To roll back, restore the previous workflow and its DynamoDB policy
together; restoring only the policy does not make the API-based workflow depend on
DynamoDB access.
### Reports (written to `meal-order-manager-reports-*` at Thursday close)
| Key | Contents |

View file

@ -1,60 +1,102 @@
"""
Uploads the latest scraped menu JSON to DynamoDB.
Used by the GitHub Actions weekly workflow after scraping.
"""
"""Call the IAM-protected weekly-menu publication API with SigV4."""
import argparse
import json
import os
import sys
from datetime import datetime
from decimal import Decimal
import urllib.error
import urllib.request
from pathlib import Path
import boto3
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
OUTPUT_DIR = PROJECT_ROOT / "output"
TABLE_NAME = os.environ.get("TABLE_NAME", "meal-order-manager-orders")
def convert_floats(obj):
if isinstance(obj, float):
return Decimal(str(obj))
if isinstance(obj, dict):
return {k: convert_floats(v) for k, v in obj.items()}
if isinstance(obj, list):
return [convert_floats(i) for i in obj]
return obj
def signed_request(
api_url: str,
path: str,
method: str = "GET",
body: dict | None = None,
region: str = "us-east-1",
) -> dict:
data = json.dumps(body).encode() if body is not None else None
headers = {"Content-Type": "application/json"} if data is not None else {}
request = AWSRequest(
method=method,
url=f"{api_url.rstrip('/')}{path}",
data=data,
headers=headers,
)
credentials = boto3.Session().get_credentials()
if credentials is None:
raise RuntimeError("AWS credentials are required")
SigV4Auth(credentials.get_frozen_credentials(), "execute-api", region).add_auth(
request
)
prepared = request.prepare()
http_request = urllib.request.Request(
prepared.url,
data=prepared.body,
headers=dict(prepared.headers),
method=method,
)
try:
with urllib.request.urlopen(http_request, timeout=30) as response:
return json.loads(response.read())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
raise RuntimeError(f"Publication API returned HTTP {exc.code}: {detail}") from exc
def get_settings(api_url: str, region: str) -> dict:
return signed_request(
api_url, "/api/publish/settings", method="GET", region=region
)
def publish_menu(api_url: str, region: str) -> dict:
files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True)
if not files:
raise RuntimeError("No menu JSON found. Run scrape_menu.py first.")
with files[0].open() as menu_file:
menu = json.load(menu_file)
return signed_request(
api_url,
"/api/publish/menu",
method="POST",
body=menu,
region=region,
)
def main():
files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True)
if not files:
print("Error: No menu JSON found. Run scrape_menu.py first.", file=sys.stderr)
sys.exit(1)
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=("settings", "publish"))
parser.add_argument("--api-url", required=True)
parser.add_argument(
"--region", default=os.environ.get("AWS_REGION", "us-east-1")
)
args = parser.parse_args()
with open(files[0]) as f:
menu = json.load(f)
try:
result = (
get_settings(args.api_url, args.region)
if args.action == "settings"
else publish_menu(args.api_url, args.region)
)
except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
week = datetime.now().strftime("%Y-W%U")
table = boto3.resource("dynamodb").Table(TABLE_NAME)
import time
item = {
"PK": f"WEEK#{week}",
"SK": "MENU",
"form_status": "open",
"scraped_at": menu.get("scraped_at"),
"menu_url": menu.get("menu_url"),
"meal_count": menu.get("meal_count"),
"meals": convert_floats(menu.get("meals", [])),
"ttl": int(time.time()) + (90 * 86400),
}
table.put_item(Item=item)
print(f"Uploaded menu for {week} to DynamoDB ({len(menu.get('meals', []))} meals)")
print(json.dumps(result))
return 0
if __name__ == "__main__":
main()
sys.exit(main())

View file

@ -153,6 +153,28 @@ class TestGenerateFormStructural:
assert "ThrottlingBurstLimit: 10" in submit_settings
assert "ThrottlingRateLimit: 5" in submit_settings
def test_weekly_menu_uses_iam_protected_api_without_dynamodb(self):
template = (REPO_ROOT / "template.yaml").read_text()
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
script = (REPO_ROOT / "scripts" / "upload_menu.py").read_text()
for route in ("/api/publish/settings", "/api/publish/menu"):
next_event = (
" PublishMenu:"
if route == "/api/publish/settings"
else " AdminOrders:"
)
route_config = template.split(f"Path: {route}", 1)[1].split(
next_event, 1
)[0]
assert "Authorizer: AWS_IAM" in route_config
assert route in script
assert "scripts/upload_menu.py settings" in workflow
assert "scripts/upload_menu.py publish" in workflow
assert "aws dynamodb" not in workflow
assert "boto3.resource(\"dynamodb\")" not in script
def test_local_and_google_render(self):
local = _render(google=False)
google = _render(google=True)