mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-07 16:18:54 +00:00
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:
parent
1ee291f8bf
commit
f0912e56df
4 changed files with 134 additions and 59 deletions
28
.github/workflows/weekly-menu.yml
vendored
28
.github/workflows/weekly-menu.yml
vendored
|
|
@ -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'
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue