diff --git a/.gitignore b/.gitignore index e0e018462..596fe5d59 100644 --- a/.gitignore +++ b/.gitignore @@ -64,4 +64,5 @@ packages/docs-nextra/public/bundle .next cspell.json *.timestamp-* -.cspell \ No newline at end of file +.cspell +**/__pycache__/ \ No newline at end of file diff --git a/prefigure-lambda/Dockerfile b/prefigure-lambda/Dockerfile new file mode 100644 index 000000000..6a8b51f8a --- /dev/null +++ b/prefigure-lambda/Dockerfile @@ -0,0 +1,71 @@ +# 1. Use the AWS Lambda Python 3.12 base image (Amazon Linux 2023) +FROM public.ecr.aws/lambda/python:3.12 + +# 2. Install System Build Tools & Dependencies +# Amazon Linux 2023 uses 'dnf' (like Fedora). +# We install the exact dependencies requested for Pycairo: +# - cairo-devel +# - pkgconf-pkg-config (This provides the 'pkg-config' tool on AWS Linux) +# - python3-devel +# +# We also need: +# - gcc, make (to compile cairo/liblouis) +# - librsvg2-tools (for rsvg-convert) +# - nodejs/npm (for prefigure math) +# - git (to clone repos) + +RUN dnf update -y && \ + dnf install -y \ + gcc \ + gcc-c++ \ + make \ + automake \ + libtool \ + git \ + tar \ + gzip \ + zip \ + cairo-devel \ + pkgconf-pkg-config \ + python3-devel \ + librsvg2-tools \ + libxml2-devel \ + nodejs \ + npm \ + && dnf clean all + +# 3. Install Liblouis (Braille support) from Source +# (Standard yum/dnf does not have liblouis, so we build it) +WORKDIR /tmp/liblouis-build +RUN git clone https://github.com/liblouis/liblouis.git . && \ + ./autogen.sh && \ + ./configure --enable-ucs4 --prefix=/usr && \ + make && \ + make install && \ + cd python && \ + pip install . && \ + cd / && \ + rm -rf /tmp/liblouis-build + +# 4. Install Pycairo explicitly +# We do this before prefigure to ensure the C compilation succeeds. +RUN pip install pycairo + +# 5. Pin Prefigure version and share it with runtime cache keying. +ARG PREFIG_VERSION=0.5.15 +ENV PREFIG_CACHE_VERSION=${PREFIG_VERSION} + +# 5. Install Prefigure +# We use the [pycairo] extra to tell prefig we have it. +RUN pip install "prefig[pycairo]==${PREFIG_VERSION}" + +# 6. Initialize Prefigure +# This downloads MathJax and fonts. +RUN prefig init + +# 7. Copy Handler Code +WORKDIR ${LAMBDA_TASK_ROOT} +COPY app.py . + +# 8. Set the CMD to your handler +CMD [ "app.lambda_handler" ] \ No newline at end of file diff --git a/prefigure-lambda/ENDPOINT_TESTING.md b/prefigure-lambda/ENDPOINT_TESTING.md new file mode 100644 index 000000000..f577b7267 --- /dev/null +++ b/prefigure-lambda/ENDPOINT_TESTING.md @@ -0,0 +1,114 @@ +# PreFigure Endpoint Testing + +This file is a quick checklist for validating the deployed endpoint at: + +- `https://prefigure.doenet.org/build` +- `https://prefigure.doenet.org/version` + +## Prerequisites + +- `curl` installed +- Optional: `jq` for easier JSON inspection + +## 0) Version check + +```bash +curl -sS https://prefigure.doenet.org/version +``` + +Expected response: + +```json +{"version": "0.5.15"} +``` + + +## 1) Success path: empty graph, no annotations + +Create a minimal input file: + +```bash +cat > /tmp/prefigure-empty.xml <<'XML' + + + +XML +``` + +Send request: + +```bash +curl -sS -X POST "https://prefigure.doenet.org/build" \ + -H "Content-Type: application/xml" \ + --data-binary @/tmp/prefigure-empty.xml +``` + +Expected response: + +- HTTP status: `200` +- JSON fields include: + - `svg` (non-empty string) + - `annotationsXml` is `null` + - `annotationsGenerated` is `false` + - `cached` is `true` or `false` + +Optional quick assert with `jq`: + +```bash +curl -sS -X POST "https://prefigure.doenet.org/build" \ + -H "Content-Type: application/xml" \ + --data-binary @/tmp/prefigure-empty.xml \ +| jq '{hasSvg:(.svg|type=="string" and (.svg|length>0)), annotationsXmlIsNull:(.annotationsXml==null), annotationsGenerated, cached}' +``` + +## 2) Error path: malformed XML + debug diagnostics + +Send malformed XML and keep status/body: + +```bash +curl -sS -o /tmp/prefigure-bad.body -w "%{http_code}\n" \ + -X POST "https://prefigure.doenet.org/build?debug=1" \ + -H "Content-Type: application/xml" \ + --data-binary '' +cat /tmp/prefigure-bad.body +``` + +Expected response: + +- HTTP status: `422` +- `errorCode` is `build_failed` +- Includes diagnostics: + - `prefigReturnCode` + - `command` + - `cwd` + - `stderr` + - `stdout` + - `work_dir_contents` (when `debug=1`) + - `output_dir_contents` (when `debug=1`) + +Optional quick assert with `jq`: + +```bash +cat /tmp/prefigure-bad.body | jq '{errorCode, hasDebugLists:(has("work_dir_contents") and has("output_dir_contents"))}' +``` + +## 3) Cache sanity check + +Run the same success request twice and compare `cached`: + +```bash +for i in 1 2; do + curl -sS -X POST "https://prefigure.doenet.org/build" \ + -H "Content-Type: application/xml" \ + --data-binary @/tmp/prefigure-empty.xml \ + | jq '{run:'"$i"', cached, hash}' +done +``` + +Typically first run is `cached: false` and second run becomes `cached: true` for the same input/hash. + +## Notes + +- Use `?debug=1` only for troubleshooting error responses. +- Endpoint contract details are documented in: + - `PreFigure on AWS Lambda: Complete Setup.md` diff --git a/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md b/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md new file mode 100644 index 000000000..3439f306b --- /dev/null +++ b/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md @@ -0,0 +1,313 @@ +# PreFigure on AWS Lambda: Complete Setup Guide + +This guide documents deploying `prefigure` to AWS Lambda as a container image, with hybrid caching (RAM + DynamoDB). + +Quick endpoint verification commands are maintained in `ENDPOINT_TESTING.md`. + +## Before Running the Deploy Script + +### 1. Start Docker Desktop + +Open Docker Desktop (press the Super/Windows key, type "Docker", select Docker Desktop). Wait until the Docker Desktop icon in the system tray shows it is running. + +Verify Docker is ready: + +```bash +docker info +``` + +### 2. Log in to AWS + +Authenticate your AWS CLI session. + +Log in: + +```bash +aws login +``` + +Verify you are logged in: + +```bash +aws sts get-caller-identity +``` + +- The verify command returns JSON with `Account` and `Arn`. +- If you see a session expired error, run `aws login` again. +- The deployment script also performs this check and exits early with login instructions if not authenticated. + +Optional checks: + +- Show configured profiles: + + aws configure list-profiles + +- Confirm default region: + + aws configure get region --profile default + +## Quick Upgrade Checklist + +For routine releases: + +1. Start Docker Desktop (Super/Windows key → "Docker" → Docker Desktop) +2. Log in to AWS: `aws login` +3. Run from `prefigure-lambda/`: + +```bash +./deploy-prefigure-release.sh --version 0.5.15 --smoke-test +``` + +That command handles ECR login, build, push, Lambda update, wait, and an optional smoke test. + +--- + +## API response contract (current) + +The Lambda endpoint returns JSON and treats `foo.xml` (annotations) as optional. + +- **Success (`200`)** + - `svg`: always present on success + - `annotationsXml`: annotation XML string or `null` + - `annotationsGenerated`: `true` when annotations exist + - `cached`: whether result came from cache + - `hash`: cache key + +- **Client/build issues (`400`/`422`)** + - `400` with `errorCode` of: + - `invalid_encoding` + - `empty_body` + - `422` with `errorCode` of: + - `build_failed` (prefig exited non-zero) + - `svg_missing` (prefig exited zero but no SVG output) + +- **Diagnostics on failures** + - `prefigReturnCode`, `command`, `cwd`, `stdout`, `stderr` + - Add query param `?debug=1` to include `work_dir_contents` and `output_dir_contents`. + +--- + +## 1. One-Time Infrastructure Setup + +Complete these once per environment/account. + +### 1.1 Project files + +Use this folder: + +```text +prefigure-lambda/ +├── app.py +├── Dockerfile +├── ENDPOINT_TESTING.md +├── PreFigure on AWS Lambda: Complete Setup.md +└── test-prefigure.html +``` + +### 1.2 Dockerfile (build recipe) + +The Dockerfile handles all runtime dependency installation: + +- OS/build dependencies (`dnf` packages) +- `liblouis` source build/install +- `pycairo` install +- pinned `prefig` install +- `prefig init` + +Canonical file: `prefigure-lambda/Dockerfile` + +Current pinning pattern: + +```dockerfile +ARG PREFIG_VERSION=0.5.15 +ENV PREFIG_CACHE_VERSION=${PREFIG_VERSION} +RUN pip install "prefig[pycairo]==${PREFIG_VERSION}" +``` + +This keeps deployed prefigure version and cache versioning aligned. + +### 1.3 DynamoDB table (persistent L2 cache) + +Create table: + +- Table name: `PrefigureCache` +- Partition key: `xml_hash` (String) + +Enable TTL: + +- TTL attribute: `expiration_time` + +### 1.4 IAM permissions (least privilege) + +Grant the Lambda execution role access to the cache table. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DynamoDBAccess", + "Effect": "Allow", + "Action": [ + "dynamodb:PutItem", + "dynamodb:GetItem" + ], + "Resource": "arn:aws:dynamodb:us-east-2:[YOUR_ACCOUNT_ID]:table/PrefigureCache" + } + ] +} +``` + +### 1.5 Handler behavior summary + +Canonical implementation: `prefigure-lambda/app.py` + +- Hybrid cache: RAM (L1) + DynamoDB (L2) +- Cache key salt derives from `PREFIG_CACHE_VERSION`, with fallback to installed `prefig` metadata +- `foo.svg` required for success; `foo.xml` optional +- Error semantics: + - `400`: `invalid_encoding`, `empty_body` + - `422`: `build_failed`, `svg_missing` +- `?debug=1` adds directory listings in error payloads + +Example success payload: + +```json +{ + "cached": false, + "hash": "...", + "annotationsXml": null, + "svg": "", + "annotationsGenerated": false +} +``` + +--- + +## 2. Per-Release Upgrade Procedure + +Use this each time you upgrade prefigure (for example 0.5.15 -> 0.5.16). + +### 2.1 Change pinned version + +Either: + +- edit Dockerfile default: + +```dockerfile +ARG PREFIG_VERSION=0.5.15 +``` + +or pass the version at build time: + +```bash +docker build --build-arg PREFIG_VERSION=0.5.15 ... +``` + +No app.py cache constant edit is required; cache version follows `PREFIG_CACHE_VERSION` from the image. + +### 2.2 Build and push a versioned image + +Run from `prefigure-lambda/` and replace `ACCOUNT_ID`. + +```bash +# 1) Login to ECR +aws ecr get-login-password --region us-east-2 \ + | docker login --username AWS --password-stdin ACCOUNT_ID.dkr.ecr.us-east-2.amazonaws.com + +# 2) Build (provenance=false is required for Lambda container images) +docker build --provenance=false --platform linux/amd64 \ + --build-arg PREFIG_VERSION=0.5.15 \ + -t prefigure-repo:0.5.15 . + +# 3) Tag and push +docker tag prefigure-repo:0.5.15 \ + ACCOUNT_ID.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:0.5.15 +docker push ACCOUNT_ID.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:0.5.15 +``` + +### 2.3 Update Lambda to new image + +```bash +aws lambda update-function-code \ + --function-name prefigure-function \ + --image-uri ACCOUNT_ID.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:0.5.15 + +aws lambda wait function-updated --function-name prefigure-function +``` + +### 2.4 Validate deployment + +Run checks from `ENDPOINT_TESTING.md`: + +- success path (`200`, non-empty `svg`) +- malformed XML path (`422`, diagnostics present) +- cache sanity (second identical request should typically return cached result) + +### 2.5 Rollback (if needed) + +Point Lambda back to the previous known-good image tag: + +```bash +aws lambda update-function-code \ + --function-name prefigure-function \ + --image-uri ACCOUNT_ID.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo: +``` + +### 2.6 Automated script (recommended) + +Use `deploy-prefigure-release.sh` to run the release flow end-to-end. + +Examples: + +```bash +# Standard release +./deploy-prefigure-release.sh --version 0.5.15 + +# Release plus endpoint smoke test +./deploy-prefigure-release.sh --version 0.5.15 --smoke-test + +# Dry run (print commands only) +./deploy-prefigure-release.sh --version 0.5.15 --dry-run +``` + +Key options: + +- `--version` (required): prefigure version to pin and deploy +- `--region`: defaults to `us-east-2` +- `--account-id`: optional (auto-detected via `aws sts get-caller-identity`) +- `--ecr-repo`: defaults to `prefigure-repo` +- `--lambda-function`: defaults to `prefigure-function` +- `--endpoint`: endpoint used by `--smoke-test` (defaults to `https://prefigure.doenet.org/build`) + +--- + +## 3. Checking the Deployed Version + +To check which prefigure version is currently running: + +```bash +curl https://prefigure.doenet.org/version +``` + +Example response: + +```json +{"version": "0.5.15"} +``` + +Or via AWS CLI (no deployment needed): + +```bash +aws lambda get-function --no-cli-pager --function-name prefigure-function --query 'Code.ImageUri' --output text +``` + +## 4. Manual Smoke Test + +```bash +curl -X POST https://prefigure.doenet.org/build \ + -H "Content-Type: application/xml" \ + --data-binary @test.xml +``` + +For browser testing, use `test-prefigure.html`. diff --git a/prefigure-lambda/app.py b/prefigure-lambda/app.py new file mode 100644 index 000000000..b83cabf29 --- /dev/null +++ b/prefigure-lambda/app.py @@ -0,0 +1,269 @@ +import json +import os +import subprocess +import hashlib +import boto3 +import time +import base64 +import shutil +import importlib.metadata +from botocore.exceptions import ClientError + +# --- CONFIGURATION --- + +def resolve_cache_version(): + pinned_version = os.getenv('PREFIG_CACHE_VERSION') + if pinned_version: + return f"prefig-{pinned_version}" + + try: + detected_version = importlib.metadata.version('prefig') + return f"prefig-{detected_version}" + except importlib.metadata.PackageNotFoundError: + return "prefig-unknown" + + +CACHE_VERSION = resolve_cache_version() +CACHE_DURATION_DAYS = 30 + +# --- INITIALIZATION --- +LOCAL_CACHE = {} +dynamodb = boto3.resource('dynamodb') +table_name = "PrefigureCache" +table = dynamodb.Table(table_name) + +DEFAULT_HEADERS = { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' +} + +# --- HELPER FUNCTIONS --- +def compute_hash(content): + unique_string = content + CACHE_VERSION + return hashlib.sha256(unique_string.encode('utf-8')).hexdigest() + +def get_from_cache(xml_hash): + # 1. Check L1 (RAM) + if xml_hash in LOCAL_CACHE: + print(f"L1 MEMORY HIT: {xml_hash}") + return LOCAL_CACHE[xml_hash] + + # 2. Check L2 (DynamoDB) + try: + response = table.get_item(Key={'xml_hash': xml_hash}) + if 'Item' in response: + print(f"L2 DYNAMO HIT: {xml_hash}") + item = response['Item'] + + # Retrieve both files + result = { + 'xml_content': item.get('xml_content', None), + 'svg_content': item.get('svg_content', '') + } + + # Backfill RAM + LOCAL_CACHE[xml_hash] = result + return result + except ClientError as e: + print(f"DynamoDB Read Error: {e.response['Error']['Message']}") + + print(f"CACHE MISS: {xml_hash}") + return None + +def save_to_cache(xml_hash, xml_content, svg_content): + result = { + 'xml_content': xml_content, + 'svg_content': svg_content + } + + # 1. Save to RAM + LOCAL_CACHE[xml_hash] = result + + # 2. Save to DynamoDB + try: + ttl_seconds = CACHE_DURATION_DAYS * 24 * 60 * 60 + expiration_time = int(time.time()) + ttl_seconds + + table.put_item(Item={ + 'xml_hash': xml_hash, + 'xml_content': xml_content, + 'svg_content': svg_content, + 'expiration_time': expiration_time + }) + print(f"Saved to L1 & L2 Cache: {xml_hash}") + except ClientError as e: + print(f"Failed to save to DynamoDB: {e.response['Error']['Message']}") + +def get_debug_directory_contents(work_dir, output_dir): + try: + files_in_work = os.listdir(work_dir) + if os.path.exists(output_dir): + files_in_output = os.listdir(output_dir) + else: + files_in_output = "Output directory not created" + except Exception: + files_in_work = "Error listing files" + files_in_output = "Error listing files" + + return files_in_work, files_in_output + +# --- MAIN HANDLER --- +def lambda_handler(event, context): + # Version endpoint + raw_path = event.get('rawPath', '') + path = event.get('path', '') + route_key = event.get('routeKey', '') + if raw_path == '/version' or path == '/version' or route_key == 'GET /version': + version = os.getenv('PREFIG_CACHE_VERSION') or 'unknown' + return { + 'statusCode': 200, + 'headers': DEFAULT_HEADERS, + 'body': json.dumps({'version': version}) + } + + debug = False + query_params = event.get('queryStringParameters') or {} + if query_params.get('debug') in ('1', 'true', 'True', 'yes'): + debug = True + + # 1. Parse Input + try: + body = event.get('body', '') + if event.get('isBase64Encoded', False): + body = base64.b64decode(body).decode('utf-8') + except Exception as e: + return { + 'statusCode': 400, + 'headers': DEFAULT_HEADERS, + 'body': json.dumps({ + 'errorCode': 'invalid_encoding', + 'error': 'Invalid encoding' + }) + } + + if not body: + return { + 'statusCode': 400, + 'headers': DEFAULT_HEADERS, + 'body': json.dumps({ + 'errorCode': 'empty_body', + 'error': 'Empty body' + }) + } + + # 2. Check Cache + xml_hash = compute_hash(body) + cached_data = get_from_cache(xml_hash) + + if cached_data: + return { + 'statusCode': 200, + 'headers': DEFAULT_HEADERS, + 'body': json.dumps({ + 'cached': True, + 'hash': xml_hash, + 'annotationsXml': cached_data['xml_content'], + 'svg': cached_data['svg_content'], + 'annotationsGenerated': bool(cached_data['xml_content']) + }) + } + + # 3. Setup Paths (Matching your working script) + work_dir = "/tmp/prefigure_work" + + # Clean up previous runs + if os.path.exists(work_dir): + shutil.rmtree(work_dir) + os.makedirs(work_dir) + + # 4. Write Input + input_filename = "foo.xml" + input_path = os.path.join(work_dir, input_filename) + + with open(input_path, 'w') as f: + f.write(body) + + # 5. Run Prefigure + cmd = ["prefig", "build", input_filename] + + # We switch CWD to work_dir so 'output/' is created there + result = subprocess.run( + cmd, + cwd=work_dir, + capture_output=True, + text=True + ) + + if result.returncode != 0: + output_dir = os.path.join(work_dir, "output") + payload = { + 'errorCode': 'build_failed', + 'error': 'Prefigure build failed', + 'prefigReturnCode': result.returncode, + 'command': ' '.join(cmd), + 'cwd': work_dir, + 'stderr': result.stderr, + 'stdout': result.stdout + } + + if debug: + files_in_work, files_in_output = get_debug_directory_contents(work_dir, output_dir) + payload['work_dir_contents'] = files_in_work + payload['output_dir_contents'] = files_in_output + + return { + 'statusCode': 422, + 'headers': DEFAULT_HEADERS, + 'body': json.dumps(payload) + } + + # 6. Read Outputs + # Prefigure creates an 'output' folder inside the work_dir + output_dir = os.path.join(work_dir, "output") + out_xml_path = os.path.join(output_dir, "foo.xml") + out_svg_path = os.path.join(output_dir, "foo.svg") + + if os.path.exists(out_svg_path): + xml_result = None + if os.path.exists(out_xml_path): + with open(out_xml_path, 'r') as f: + xml_result = f.read() + + with open(out_svg_path, 'r') as f: + svg_result = f.read() + + # Save both to cache + save_to_cache(xml_hash, xml_result, svg_result) + + return { + 'statusCode': 200, + 'headers': DEFAULT_HEADERS, + 'body': json.dumps({ + 'cached': False, + 'hash': xml_hash, + 'annotationsXml': xml_result, + 'svg': svg_result, + 'annotationsGenerated': bool(xml_result) + }) + } + else: + payload = { + 'errorCode': 'svg_missing', + 'error': 'SVG output file not found', + 'prefigReturnCode': result.returncode, + 'command': ' '.join(cmd), + 'cwd': work_dir, + 'stderr': result.stderr, + 'stdout': result.stdout, + } + + if debug: + files_in_work, files_in_output = get_debug_directory_contents(work_dir, output_dir) + payload['work_dir_contents'] = files_in_work + payload['output_dir_contents'] = files_in_output + + return { + 'statusCode': 422, + 'headers': DEFAULT_HEADERS, + 'body': json.dumps(payload) + } \ No newline at end of file diff --git a/prefigure-lambda/cloud formation instructions.md b/prefigure-lambda/cloud formation instructions.md new file mode 100644 index 000000000..25ee41c82 --- /dev/null +++ b/prefigure-lambda/cloud formation instructions.md @@ -0,0 +1,81 @@ +To migrate your manual setup to a production-ready CloudFormation stack, you will need to define all the resources (Lambda, Role, DynamoDB, API Gateway, and Domain) in a single YAML template. + +Here is the step-by-step guide and the complete CloudFormation template. +Prerequisites + + Docker Image: You must still build and push your Docker image to ECR manually (or via CI/CD) before running this stack, as CloudFormation reads existing images. + + ACM Certificate: You must have the Certificate ARN for prefigure.doenet.org ready (from AWS Certificate Manager). + +1. The CloudFormation Template (template.yaml) + +Saved file as prefigure-stack.yaml. This template replaces all the manual steps we did earlier. + +2. Steps to Deploy +Step 1: Prepare Parameters + +You need three pieces of information: + + Image URI: Copy from your docker push command (e.g., 123456789012.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest). + + Certificate ARN: Go to AWS Certificate Manager, find the certificate for prefigure.doenet.org, and copy its ARN. + + Hosted Zone ID (Optional): If you manage DNS in Route53, copy the Hosted Zone ID for doenet.org. + +Step 2: Deploy Stack + +You can deploy this via the AWS Console or CLI. + +Option A: AWS CLI (Recommended) +Bash + +aws cloudformation deploy \ + --template-file prefigure-stack.yaml \ + --stack-name prefigure-prod \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameter-overrides \ + ContainerImageUri="[YOUR_IMAGE_URI]" \ + CertificateArn="[YOUR_CERT_ARN]" \ + DomainName="prefigure.doenet.org" + +Option B: AWS Console + + Go to CloudFormation > Create stack > With new resources (standard). + + Upload a template file: Select prefigure-stack.yaml. + + Stack name: prefigure-prod. + + Parameters: Paste your Image URI and Cert ARN. + + Capabilities: Check "I acknowledge that AWS CloudFormation might create IAM resources." + + Click Submit. + +Step 3: Final DNS Update + +If you did not uncomment the Route53 section in the template (or if your DNS is not on Route53): + + Wait for the stack to reach CREATE_COMPLETE. + + Go to the Outputs tab of the stack. + + Find the ApiEndpoint (it will look like https://d-xyz.execute-api...). + + Wait! Actually, for custom domains, you need the API Gateway Domain Name target. + + Go to API Gateway console > Custom domain names. + + Click prefigure.doenet.org. + + Copy the API Gateway Domain Name (e.g., d-12345.execute-api.us-east-2.amazonaws.com). + + Update your DNS provider (Namecheap/GoDaddy) to point prefigure CNAME to that address. + +Key Changes from Manual to CloudFormation + + Infrastructure as Code: If you delete the stack, everything (Role, API, DynamoDB table) is cleanly removed. + + Drift Detection: If someone manually changes the IAM role permissions, CloudFormation can detect and fix it. + + IAM Policy: The policy is strictly scoped to the PrefigureCacheTable created within this stack, ensuring perfect isolation. \ No newline at end of file diff --git a/prefigure-lambda/deploy-prefigure-release.sh b/prefigure-lambda/deploy-prefigure-release.sh new file mode 100755 index 000000000..f88852af8 --- /dev/null +++ b/prefigure-lambda/deploy-prefigure-release.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Deploy a pinned PreFigure version to AWS Lambda (container image flow). + +Usage: + ./deploy-prefigure-release.sh --version [options] + +Required: + --version PreFigure version (example: 0.5.16) + +Options: + --region AWS region (default: us-east-2) + --account-id AWS account ID (auto-detected if omitted) + --ecr-repo ECR repository name (default: prefigure-repo) + --lambda-function Lambda function name (default: prefigure-function) + --endpoint Endpoint URL for optional smoke test + (default: https://prefigure.doenet.org/build) + --smoke-test Run a basic HTTP smoke test after deployment + --dry-run Print commands without executing + -h, --help Show this help message + +Examples: + ./deploy-prefigure-release.sh --version 0.5.16 + ./deploy-prefigure-release.sh --version 0.5.16 --smoke-test +EOF +} + +VERSION="" +REGION="us-east-2" +ACCOUNT_ID="" +ECR_REPO="prefigure-repo" +LAMBDA_FUNCTION="prefigure-function" +ENDPOINT_URL="https://prefigure.doenet.org/build" +SMOKE_TEST="false" +DRY_RUN="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="${2:-}" + shift 2 + ;; + --region) + REGION="${2:-}" + shift 2 + ;; + --account-id) + ACCOUNT_ID="${2:-}" + shift 2 + ;; + --ecr-repo) + ECR_REPO="${2:-}" + shift 2 + ;; + --lambda-function) + LAMBDA_FUNCTION="${2:-}" + shift 2 + ;; + --endpoint) + ENDPOINT_URL="${2:-}" + shift 2 + ;; + --smoke-test) + SMOKE_TEST="true" + shift + ;; + --dry-run) + DRY_RUN="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$VERSION" ]]; then + echo "Error: --version is required." >&2 + usage + exit 1 +fi + +if [[ -z "$ACCOUNT_ID" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + ACCOUNT_ID="ACCOUNT_ID" + else + ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)" + fi +fi + +if [[ -z "$ACCOUNT_ID" || "$ACCOUNT_ID" == "None" ]]; then + echo "Error: Could not determine AWS account ID. Provide --account-id." >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +IMAGE_TAG="$VERSION" +IMAGE_LOCAL="$ECR_REPO:$IMAGE_TAG" +IMAGE_REMOTE="$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$ECR_REPO:$IMAGE_TAG" + +run_cmd() { + echo "+ $*" + if [[ "$DRY_RUN" == "false" ]]; then + "$@" + fi +} + +echo "Deploying PreFigure version: $VERSION" +echo "Region: $REGION" +echo "Account ID: $ACCOUNT_ID" +echo "ECR image: $IMAGE_REMOTE" +echo "Lambda function: $LAMBDA_FUNCTION" + +if [[ "$DRY_RUN" == "true" ]]; then + echo "Dry run mode enabled. No commands will be executed." +fi + +if [[ "$DRY_RUN" == "false" ]]; then + if ! aws sts get-caller-identity --no-cli-pager >/dev/null 2>&1; then + echo "Error: AWS CLI session is not authenticated." >&2 + echo "Run: aws login" >&2 + echo "Then verify with: aws sts get-caller-identity" >&2 + exit 1 + fi + + if ! docker info >/dev/null 2>&1; then + echo "Error: Docker daemon is not running." >&2 + echo "Start Docker Desktop (Super/Windows key → 'Docker' → Docker Desktop) and wait for it to be ready." >&2 + exit 1 + fi +fi + +if [[ "$DRY_RUN" == "false" ]]; then + aws ecr describe-repositories --region "$REGION" --repository-names "$ECR_REPO" >/dev/null +fi + +if [[ "$DRY_RUN" == "false" ]]; then + aws ecr get-login-password --region "$REGION" \ + | docker login --username AWS --password-stdin "$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com" +else + echo "+ aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com" +fi + +run_cmd docker build --provenance=false --platform linux/amd64 \ + --build-arg "PREFIG_VERSION=$VERSION" \ + -t "$IMAGE_LOCAL" . + +run_cmd docker tag "$IMAGE_LOCAL" "$IMAGE_REMOTE" +run_cmd docker push "$IMAGE_REMOTE" + +run_cmd aws lambda update-function-code \ + --no-cli-pager \ + --region "$REGION" \ + --function-name "$LAMBDA_FUNCTION" \ + --image-uri "$IMAGE_REMOTE" + +run_cmd aws lambda wait function-updated \ + --no-cli-pager \ + --region "$REGION" \ + --function-name "$LAMBDA_FUNCTION" + +echo "Deployment complete." + +if [[ "$SMOKE_TEST" == "true" ]]; then + echo "Running smoke test against: $ENDPOINT_URL" + TMP_XML="/tmp/prefigure-smoke.xml" + cat > "$TMP_XML" <<'XML' + + + +XML + + if [[ "$DRY_RUN" == "false" ]]; then + # Check /version endpoint reports expected version + VERSION_ENDPOINT="${ENDPOINT_URL%/build}/version" + echo "Checking version endpoint: $VERSION_ENDPOINT" + VERSION_RESPONSE="$(curl -sS "$VERSION_ENDPOINT")" + echo "Version endpoint response: $VERSION_RESPONSE" + DEPLOYED_VERSION="$(echo "$VERSION_RESPONSE" | grep -o '"version": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"' || true)" + if [[ "$DEPLOYED_VERSION" == "$VERSION" ]]; then + echo "Version check passed: $DEPLOYED_VERSION" + elif [[ -z "$DEPLOYED_VERSION" ]]; then + echo "Warning: /version endpoint did not return a version field (response: $VERSION_RESPONSE)" >&2 + else + echo "Warning: /version returned '$DEPLOYED_VERSION', expected '$VERSION'" >&2 + fi + + HTTP_CODE="$(curl -sS -o /tmp/prefigure-smoke-response.json -w "%{http_code}" \ + -X POST "$ENDPOINT_URL" \ + -H "Content-Type: application/xml" \ + --data-binary @"$TMP_XML")" + + echo "Smoke test HTTP status: $HTTP_CODE" + echo "Smoke test response saved: /tmp/prefigure-smoke-response.json" + + if [[ "$HTTP_CODE" != "200" ]]; then + echo "Smoke test failed (expected HTTP 200)." >&2 + exit 1 + fi + else + echo "+ curl -X POST $ENDPOINT_URL -H Content-Type: application/xml --data-binary @/tmp/prefigure-smoke.xml" + fi +fi + +echo "Done." diff --git a/prefigure-lambda/prefigure-stack.yml b/prefigure-lambda/prefigure-stack.yml new file mode 100644 index 000000000..96edde3ff --- /dev/null +++ b/prefigure-lambda/prefigure-stack.yml @@ -0,0 +1,181 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Prefigure Lambda Service with DynamoDB Caching and Custom Domain + +Parameters: + ContainerImageUri: + Type: String + Description: Full URI of the ECR image (e.g., 123456789012.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest) + + DomainName: + Type: String + Default: prefigure.doenet.org + Description: The custom domain name for the API + + CertificateArn: + Type: String + Description: The ARN of the ACM Certificate for the domain (must be in the same region) + + HostedZoneId: + Type: String + Description: Route53 Hosted Zone ID for doenet.org (Optional - if you want CF to create the DNS record automatically) + +Resources: + # ------------------------------------------------------------------------- + # 1. DynamoDB Table (Cache) + # ------------------------------------------------------------------------- + PrefigureCacheTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: PrefigureCache + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: xml_hash + AttributeType: S + KeySchema: + - AttributeName: xml_hash + KeyType: HASH + TimeToLiveSpecification: + AttributeName: expiration_time + Enabled: true + + # ------------------------------------------------------------------------- + # 2. IAM Role (Least Privilege) + # ------------------------------------------------------------------------- + LambdaExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Policies: + # Basic Logging + - PolicyName: LambdaBasicExecution + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: "*" + # DynamoDB Access (Specific Table Only) + - PolicyName: DynamoDBCacheAccess + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - dynamodb:GetItem + - dynamodb:PutItem + Resource: !GetAtt PrefigureCacheTable.Arn + + # ------------------------------------------------------------------------- + # 3. Lambda Function + # ------------------------------------------------------------------------- + PrefigureFunction: + Type: AWS::Lambda::Function + Properties: + FunctionName: prefigure-function + PackageType: Image + Code: + ImageUri: !Ref ContainerImageUri + Role: !GetAtt LambdaExecutionRole.Arn + Timeout: 30 + MemorySize: 1024 + Architectures: + - x86_64 + + # ------------------------------------------------------------------------- + # 4. API Gateway (HTTP API) + # ------------------------------------------------------------------------- + HttpApi: + Type: AWS::ApiGatewayV2::Api + Properties: + Name: PrefigureAPI + ProtocolType: HTTP + CorsConfiguration: + AllowOrigins: + - "*" + AllowMethods: + - POST + AllowHeaders: + - content-type + + # Integration: Connect API to Lambda + LambdaIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref HttpApi + IntegrationType: AWS_PROXY + IntegrationUri: !GetAtt PrefigureFunction.Arn + PayloadFormatVersion: "2.0" + + # Route: POST /build + BuildRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref HttpApi + RouteKey: "POST /build" + Target: !Join ["/", ["integrations", !Ref LambdaIntegration]] + + # Permission: Allow API Gateway to invoke Lambda + ApiGatewayPermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !GetAtt PrefigureFunction.Arn + Principal: apigateway.amazonaws.com + SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/*/*/build" + + # Stage: Default deployment + DefaultStage: + Type: AWS::ApiGatewayV2::Stage + Properties: + ApiId: !Ref HttpApi + StageName: "$default" + AutoDeploy: true + + # ------------------------------------------------------------------------- + # 5. Custom Domain Name Mapping + # ------------------------------------------------------------------------- + ApiDomainName: + Type: AWS::ApiGatewayV2::DomainName + Properties: + DomainName: !Ref DomainName + DomainNameConfigurations: + - CertificateArn: !Ref CertificateArn + EndpointType: REGIONAL + + ApiMapping: + Type: AWS::ApiGatewayV2::ApiMapping + Properties: + ApiId: !Ref HttpApi + DomainName: !Ref ApiDomainName + Stage: !Ref DefaultStage + + # ------------------------------------------------------------------------- + # 6. DNS Record (Optional - Requires Hosted Zone ID) + # ------------------------------------------------------------------------- + # Uncomment if you want CloudFormation to manage the Route53 record + # DnsRecord: + # Type: AWS::Route53::RecordSet + # Properties: + # HostedZoneId: !Ref HostedZoneId + # Name: !Ref DomainName + # Type: A + # AliasTarget: + # DNSName: !GetAtt ApiDomainName.RegionalDomainName + # HostedZoneId: !GetAtt ApiDomainName.RegionalHostedZoneId + +Outputs: + ApiEndpoint: + Description: "API Gateway Endpoint URL" + Value: !GetAtt HttpApi.ApiEndpoint + DomainEndpoint: + Description: "Custom Domain URL" + Value: !Sub "https://${DomainName}/build" diff --git a/prefigure-lambda/test-prefigure.html b/prefigure-lambda/test-prefigure.html new file mode 100644 index 000000000..f1184f3d0 --- /dev/null +++ b/prefigure-lambda/test-prefigure.html @@ -0,0 +1,160 @@ + + + + + Prefigure API Test + + + + +

Prefigure API Test

+

Edit the XML below and click Build.

+ + +

+ + +
+

Output:

+
+ +
+
+
+
+
+ + + +