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

View file

@ -14,7 +14,7 @@ Monday 7:30am ET Employees (Mon–Thu) Thursda
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ GitHub Actions │ │ orders.seahaven │ │ EventBridge │ │ GitHub Actions │ │ orders.seahaven │ │ EventBridge │
│ - Scrape menu │────S3 upload───▶│ ind.com │ │ - Close form │ │ - 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 │ │ - 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 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 | | 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) ### Reports (written to `meal-order-manager-reports-*` at Thursday close)
| Key | Contents | | Key | Contents |

View file

@ -1,60 +1,102 @@
""" """Call the IAM-protected weekly-menu publication API with SigV4."""
Uploads the latest scraped menu JSON to DynamoDB.
Used by the GitHub Actions weekly workflow after scraping.
"""
import argparse
import json import json
import os import os
import sys import sys
from datetime import datetime import urllib.error
from decimal import Decimal import urllib.request
from pathlib import Path from pathlib import Path
import boto3 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"
TABLE_NAME = os.environ.get("TABLE_NAME", "meal-order-manager-orders")
def convert_floats(obj): def signed_request(
if isinstance(obj, float): api_url: str,
return Decimal(str(obj)) path: str,
if isinstance(obj, dict): method: str = "GET",
return {k: convert_floats(v) for k, v in obj.items()} body: dict | None = None,
if isinstance(obj, list): region: str = "us-east-1",
return [convert_floats(i) for i in obj] ) -> dict:
return obj 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(): def main():
files = sorted(OUTPUT_DIR.glob("menu-*.json"), reverse=True) parser = argparse.ArgumentParser()
if not files: parser.add_argument("action", choices=("settings", "publish"))
print("Error: No menu JSON found. Run scrape_menu.py first.", file=sys.stderr) parser.add_argument("--api-url", required=True)
sys.exit(1) parser.add_argument(
"--region", default=os.environ.get("AWS_REGION", "us-east-1")
)
args = parser.parse_args()
with open(files[0]) as f: try:
menu = json.load(f) 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") print(json.dumps(result))
table = boto3.resource("dynamodb").Table(TABLE_NAME) return 0
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)")
if __name__ == "__main__": if __name__ == "__main__":
main() sys.exit(main())

View file

@ -153,6 +153,28 @@ class TestGenerateFormStructural:
assert "ThrottlingBurstLimit: 10" in submit_settings assert "ThrottlingBurstLimit: 10" in submit_settings
assert "ThrottlingRateLimit: 5" 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): def test_local_and_google_render(self):
local = _render(google=False) local = _render(google=False)
google = _render(google=True) google = _render(google=True)