From 9e2bb737eef3fa1b58ccb504b0e900c29447ad4f Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Wed, 11 Feb 2026 00:44:14 -0600 Subject: [PATCH 1/5] created an aws lambda api for prefigure --- prefigure-lambda/Dockerfile | 67 +++ ...PreFigure on AWS Lambda: Complete Setup.md | 380 ++++++++++++++++++ prefigure-lambda/app.py | 190 +++++++++ prefigure-lambda/test-prefigure.html | 160 ++++++++ 4 files changed, 797 insertions(+) create mode 100644 prefigure-lambda/Dockerfile create mode 100644 prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md create mode 100644 prefigure-lambda/app.py create mode 100644 prefigure-lambda/test-prefigure.html diff --git a/prefigure-lambda/Dockerfile b/prefigure-lambda/Dockerfile new file mode 100644 index 000000000..05095c075 --- /dev/null +++ b/prefigure-lambda/Dockerfile @@ -0,0 +1,67 @@ +# 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. Install Prefigure +# We use the [pycairo] extra to tell prefig we have it. +RUN pip install "git+https://github.com/davidaustinm/prefigure.git#egg=prefig[pycairo]" + +# 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/PreFigure on AWS Lambda: Complete Setup.md b/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md new file mode 100644 index 000000000..e0784f2d4 --- /dev/null +++ b/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md @@ -0,0 +1,380 @@ +# PreFigure on AWS Lambda: Complete Setup Guide + +This guide documents the process of deploying the `prefigure` Python library to AWS Lambda using a Docker container, with a hybrid caching layer (RAM + DynamoDB) to optimize performance and reduce costs. + +--- + +## 1. Project Structure + +Ensure your local directory is set up as follows: + +```text +prefigure-lambda/ +├── app.py # The Python handler code (Hybrid Caching + Prefigure) +├── Dockerfile # Instructions to build the image +├── event.json # Local test event (optional) +└── test.html # Browser-based test client + +2. Dockerfile Configuration + +We use Amazon Linux 2023 (via Python 3.12) to support modern build tools (dnf). This configuration compiles liblouis from source and installs pycairo dependencies. +Dockerfile + +# 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 +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 +WORKDIR /tmp/liblouis-build +RUN git clone [https://github.com/liblouis/liblouis.git](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 (Pre-requisite for prefigure) +RUN pip install pycairo + +# 5. Install Prefigure +RUN pip install "git+[https://github.com/davidaustinm/prefigure.git#egg=prefig](https://github.com/davidaustinm/prefigure.git#egg=prefig)[pycairo]" + +# 6. Initialize Prefigure (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" ] + +3. Database Setup (DynamoDB) + +We use DynamoDB for persistent caching (L2 Cache). + + Go to AWS Console > DynamoDB > Create table. + + Table name: PrefigureCache + + Partition key: xml_hash (String) + + Create table. + + Enable TTL (Auto-Delete Old Items): + + Select the table > Additional settings. + + Turn on Time to Live (TTL). + + TTL attribute name: expiration_time + +4. Application Code (app.py) + +This handler implements Hybrid Caching: + + Checks RAM (L1) -> Instant return (0ms). + + Checks DynamoDB (L2) -> Fast return (~15ms). + + Runs Prefigure (Build) -> Slow (~2s), then saves to L1 & L2. + +Python + +import json +import os +import subprocess +import hashlib +import boto3 +import time +import base64 +import shutil +from botocore.exceptions import ClientError + +# --- CONFIGURATION --- +# Change this version string to invalidate old cache entries when you update the library. +CACHE_VERSION = "v0.5.7" +CACHE_DURATION_DAYS = 30 + +# --- INITIALIZATION --- +LOCAL_CACHE = {} +dynamodb = boto3.resource('dynamodb') +table_name = "PrefigureCache" +table = dynamodb.Table(table_name) + +# --- 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', ''), + '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']}") + +# --- MAIN HANDLER --- +def lambda_handler(event, context): + # 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, 'body': json.dumps({'error': 'Invalid encoding'})} + + if not body: + return {'statusCode': 400, 'body': json.dumps({'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': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'body': json.dumps({ + 'cached': True, + 'hash': xml_hash, + 'foo.xml': cached_data['xml_content'], + 'foo.svg': cached_data['svg_content'] + }) + } + + # 3. Setup Paths + work_dir = "/tmp/prefigure_work" + 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 + # We switch CWD to work_dir so 'output/' is created there + cmd = ["prefig", "build", input_filename] + result = subprocess.run(cmd, cwd=work_dir, capture_output=True, text=True) + + if result.returncode != 0: + return { + 'statusCode': 500, + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'body': json.dumps({ + 'error': 'Prefigure build failed', + 'stderr': result.stderr + }) + } + + # 6. Read Outputs + 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_xml_path) and os.path.exists(out_svg_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_to_cache(xml_hash, xml_result, svg_result) + + return { + 'statusCode': 200, + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'body': json.dumps({ + 'cached': False, + 'hash': xml_hash, + 'foo.xml': xml_result, + 'foo.svg': svg_result + }) + } + else: + return { + 'statusCode': 500, + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'body': json.dumps({'error': 'Output files not found', 'stderr': result.stderr}) + } + +5. IAM Permissions (Least Privilege) + +To allow the Lambda to write to DynamoDB safely: + + Go to IAM > Roles > Select prefigure-lambda-role. + + Add permissions > Create inline policy. + + Use the following JSON (replace [YOUR_ACCOUNT_ID]): + +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" + } + ] +} + +6. Build & Deploy + +Run these commands in your project folder. Replace [ACCOUNT_ID] with your AWS 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 Image (provenance=false is critical for Lambda) +docker build --provenance=false --platform linux/amd64 -t prefigure-repo . + +# 3. Tag & Push +docker tag prefigure-repo:latest [ACCOUNT_ID][.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest](https://.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest) +docker push [ACCOUNT_ID][.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest](https://.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest) + +# 4. Update Lambda Function +aws lambda update-function-code --function-name prefigure-function --image-uri [ACCOUNT_ID][.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest](https://.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest) + +7. Testing +Terminal (Curl) +Bash + +curl -X POST [https://prefigure.doenet.org/build](https://prefigure.doenet.org/build) \ + -H "Content-Type: application/xml" \ + --data-binary @test.xml + +Browser (test.html) + +Save this as test.html. It handles the JSON response containing both the XML and SVG keys. +HTML + + + + + + Prefigure API Test + + +

Prefigure API Test

+ +

+ +
+ + + + +``` diff --git a/prefigure-lambda/app.py b/prefigure-lambda/app.py new file mode 100644 index 000000000..4988e607c --- /dev/null +++ b/prefigure-lambda/app.py @@ -0,0 +1,190 @@ +import json +import os +import subprocess +import hashlib +import boto3 +import time +import base64 +import shutil +from botocore.exceptions import ClientError + +# --- CONFIGURATION --- +CACHE_VERSION = "v0.5.7" +CACHE_DURATION_DAYS = 30 + +# --- INITIALIZATION --- +LOCAL_CACHE = {} +dynamodb = boto3.resource('dynamodb') +table_name = "PrefigureCache" +table = dynamodb.Table(table_name) + +# --- 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', ''), + '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, # Store both + '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']}") + +# --- MAIN HANDLER --- +def lambda_handler(event, context): + # 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, 'body': json.dumps({'error': 'Invalid encoding'})} + + if not body: + return {'statusCode': 400, 'body': json.dumps({'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': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'body': json.dumps({ + 'cached': True, + 'hash': xml_hash, + 'xml': cached_data['xml_content'], + 'svg': cached_data['svg_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: + return { + 'statusCode': 500, + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'body': json.dumps({ + 'error': 'Prefigure build failed', + 'stderr': result.stderr, + 'stdout': result.stdout + }) + } + + # 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_xml_path) and os.path.exists(out_svg_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': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'body': json.dumps({ + 'cached': False, + 'hash': xml_hash, + 'xml': xml_result, + 'svg': svg_result + }) + } + else: + # Debugging info if files are missing + 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 { + 'statusCode': 500, + 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'body': json.dumps({ + 'error': 'Output files not found', + 'work_dir_contents': files_in_work, + 'output_dir_contents': files_in_output, + 'stderr': result.stderr + }) + } \ No newline at end of file diff --git a/prefigure-lambda/test-prefigure.html b/prefigure-lambda/test-prefigure.html new file mode 100644 index 000000000..010a21abc --- /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:

+
+ +
+
+
+
+
+ + + + From 61df95536a7a2ea6eac5b64a823165e4f7d34c15 Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Wed, 11 Feb 2026 09:57:42 -0600 Subject: [PATCH 2/5] CloudFormation instructions --- .../cloud formation instructions.md | 81 ++++++++ prefigure-lambda/prefigure-stack.yml | 181 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 prefigure-lambda/cloud formation instructions.md create mode 100644 prefigure-lambda/prefigure-stack.yml 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/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" From ae17f84b97d169293180024ec6d2bf161f12ff11 Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Tue, 10 Mar 2026 12:07:55 -0500 Subject: [PATCH 3/5] prefigure-lambda: make annotation XML optional and add endpoint validation docs --- .gitignore | 3 +- prefigure-lambda/ENDPOINT_TESTING.md | 100 +++++++ ...PreFigure on AWS Lambda: Complete Setup.md | 244 +++++------------- prefigure-lambda/app.py | 125 ++++++--- 4 files changed, 251 insertions(+), 221 deletions(-) create mode 100644 prefigure-lambda/ENDPOINT_TESTING.md 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/ENDPOINT_TESTING.md b/prefigure-lambda/ENDPOINT_TESTING.md new file mode 100644 index 000000000..b19fc4394 --- /dev/null +++ b/prefigure-lambda/ENDPOINT_TESTING.md @@ -0,0 +1,100 @@ +# PreFigure Endpoint Testing + +This file is a quick checklist for validating the deployed endpoint at: + +- `https://prefigure.doenet.org/build` + +## Prerequisites + +- `curl` installed +- Optional: `jq` for easier JSON inspection + +## 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) + - `xml` 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)), xmlIsNull:(.xml==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 index e0784f2d4..5a2e82f21 100644 --- a/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md +++ b/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md @@ -2,6 +2,33 @@ This guide documents the process of deploying the `prefigure` Python library to AWS Lambda using a Docker container, with a hybrid caching layer (RAM + DynamoDB) to optimize performance and reduce costs. +Quick endpoint verification commands are maintained in `ENDPOINT_TESTING.md`. + +--- + +## API response contract (current) + +The Lambda endpoint returns JSON and now treats `foo.xml` (annotations) as optional. + +- **Success (`200`)** + - `svg`: always present on success + - `xml`: annotation XML string or `null` when annotations are not produced + - `annotationsGenerated`: boolean flag indicating whether annotation XML exists + - `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 directory listings (`work_dir_contents`, `output_dir_contents`) for deeper troubleshooting. + --- ## 1. Project Structure @@ -46,7 +73,7 @@ RUN dnf update -y && \ # 3. Install Liblouis (Braille support) from Source WORKDIR /tmp/liblouis-build -RUN git clone [https://github.com/liblouis/liblouis.git](https://github.com/liblouis/liblouis.git) . && \ +RUN git clone https://github.com/liblouis/liblouis.git . && \ ./autogen.sh && \ ./configure --enable-ucs4 --prefix=/usr && \ make && \ @@ -60,7 +87,7 @@ RUN git clone [https://github.com/liblouis/liblouis.git](https://github.com/libl RUN pip install pycairo # 5. Install Prefigure -RUN pip install "git+[https://github.com/davidaustinm/prefigure.git#egg=prefig](https://github.com/davidaustinm/prefigure.git#egg=prefig)[pycairo]" +RUN pip install "git+https://github.com/davidaustinm/prefigure.git#egg=prefig[pycairo]" # 6. Initialize Prefigure (Downloads MathJax and fonts) RUN prefig init @@ -94,179 +121,30 @@ We use DynamoDB for persistent caching (L2 Cache). 4. Application Code (app.py) -This handler implements Hybrid Caching: - - Checks RAM (L1) -> Instant return (0ms). - - Checks DynamoDB (L2) -> Fast return (~15ms). - - Runs Prefigure (Build) -> Slow (~2s), then saves to L1 & L2. - -Python - -import json -import os -import subprocess -import hashlib -import boto3 -import time -import base64 -import shutil -from botocore.exceptions import ClientError - -# --- CONFIGURATION --- -# Change this version string to invalidate old cache entries when you update the library. -CACHE_VERSION = "v0.5.7" -CACHE_DURATION_DAYS = 30 - -# --- INITIALIZATION --- -LOCAL_CACHE = {} -dynamodb = boto3.resource('dynamodb') -table_name = "PrefigureCache" -table = dynamodb.Table(table_name) - -# --- 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', ''), - 'svg_content': item.get('svg_content', '') - } +The canonical implementation is maintained in `prefigure-lambda/app.py`. - # 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']}") - -# --- MAIN HANDLER --- -def lambda_handler(event, context): - # 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, 'body': json.dumps({'error': 'Invalid encoding'})} - - if not body: - return {'statusCode': 400, 'body': json.dumps({'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': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, - 'body': json.dumps({ - 'cached': True, - 'hash': xml_hash, - 'foo.xml': cached_data['xml_content'], - 'foo.svg': cached_data['svg_content'] - }) - } +Current handler behavior summary: - # 3. Setup Paths - work_dir = "/tmp/prefigure_work" - 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 - # We switch CWD to work_dir so 'output/' is created there - cmd = ["prefig", "build", input_filename] - result = subprocess.run(cmd, cwd=work_dir, capture_output=True, text=True) - - if result.returncode != 0: - return { - 'statusCode': 500, - 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, - 'body': json.dumps({ - 'error': 'Prefigure build failed', - 'stderr': result.stderr - }) - } +- Hybrid cache: RAM (L1) + DynamoDB (L2) +- `foo.svg` is required for success; `foo.xml` (annotations) is optional +- `xml` may be `null` with `annotationsGenerated: false` +- Error semantics: + - `400`: `invalid_encoding`, `empty_body` + - `422`: `build_failed`, `svg_missing` +- Diagnostics on failures include: `prefigReturnCode`, `command`, `cwd`, `stdout`, `stderr` +- Optional `?debug=1` includes file listings in error payloads - # 6. Read Outputs - 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_xml_path) and os.path.exists(out_svg_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_to_cache(xml_hash, xml_result, svg_result) - - return { - 'statusCode': 200, - 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, - 'body': json.dumps({ - 'cached': False, - 'hash': xml_hash, - 'foo.xml': xml_result, - 'foo.svg': svg_result - }) - } - else: - return { - 'statusCode': 500, - 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, - 'body': json.dumps({'error': 'Output files not found', 'stderr': result.stderr}) - } +Example successful response: + +```json +{ + "cached": false, + "hash": "...", + "xml": null, + "svg": "", + "annotationsGenerated": false +} +``` 5. IAM Permissions (Least Privilege) @@ -301,29 +179,29 @@ Run these commands in your project folder. Replace [ACCOUNT_ID] with your AWS 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 +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 Image (provenance=false is critical for Lambda) docker build --provenance=false --platform linux/amd64 -t prefigure-repo . # 3. Tag & Push -docker tag prefigure-repo:latest [ACCOUNT_ID][.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest](https://.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest) -docker push [ACCOUNT_ID][.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest](https://.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest) +docker tag prefigure-repo:latest ACCOUNT_ID.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest +docker push ACCOUNT_ID.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest # 4. Update Lambda Function -aws lambda update-function-code --function-name prefigure-function --image-uri [ACCOUNT_ID][.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest](https://.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest) +aws lambda update-function-code --function-name prefigure-function --image-uri ACCOUNT_ID.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest 7. Testing Terminal (Curl) Bash -curl -X POST [https://prefigure.doenet.org/build](https://prefigure.doenet.org/build) \ +curl -X POST https://prefigure.doenet.org/build \ -H "Content-Type: application/xml" \ --data-binary @test.xml Browser (test.html) -Save this as test.html. It handles the JSON response containing both the XML and SVG keys. +Save this as test.html. It handles the JSON response and renders `svg` when present. HTML @@ -351,7 +229,7 @@ HTML container.innerHTML = "Building..."; try { - const response = await fetch('[https://prefigure.doenet.org/build](https://prefigure.doenet.org/build)', { + const response = await fetch('https://prefigure.doenet.org/build', { method: 'POST', headers: { 'Content-Type': 'application/xml' }, body: xml @@ -361,12 +239,10 @@ HTML const data = await response.json(); - // We look for 'foo.svg' or any key ending in .svg - const svgKey = Object.keys(data).find(key => key.endsWith('.svg')); - - if (svgKey) { - container.innerHTML = data[svgKey]; + if (data.svg) { + container.innerHTML = data.svg; console.log("Cache status:", data.cached ? "HIT" : "MISS"); + console.log("Annotations generated:", data.annotationsGenerated); } else { container.textContent = "Error: No SVG found in response."; } diff --git a/prefigure-lambda/app.py b/prefigure-lambda/app.py index 4988e607c..a9f06adc6 100644 --- a/prefigure-lambda/app.py +++ b/prefigure-lambda/app.py @@ -18,6 +18,11 @@ 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 @@ -38,7 +43,7 @@ def get_from_cache(xml_hash): # Retrieve both files result = { - 'xml_content': item.get('xml_content', ''), + 'xml_content': item.get('xml_content', None), 'svg_content': item.get('svg_content', '') } @@ -67,7 +72,7 @@ def save_to_cache(xml_hash, xml_content, svg_content): table.put_item(Item={ 'xml_hash': xml_hash, - 'xml_content': xml_content, # Store both + 'xml_content': xml_content, 'svg_content': svg_content, 'expiration_time': expiration_time }) @@ -75,18 +80,50 @@ def save_to_cache(xml_hash, xml_content, svg_content): 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): + 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, 'body': json.dumps({'error': 'Invalid encoding'})} + return { + 'statusCode': 400, + 'headers': DEFAULT_HEADERS, + 'body': json.dumps({ + 'errorCode': 'invalid_encoding', + 'error': 'Invalid encoding' + }) + } if not body: - return {'statusCode': 400, 'body': json.dumps({'error': 'Empty body'})} + return { + 'statusCode': 400, + 'headers': DEFAULT_HEADERS, + 'body': json.dumps({ + 'errorCode': 'empty_body', + 'error': 'Empty body' + }) + } # 2. Check Cache xml_hash = compute_hash(body) @@ -95,12 +132,13 @@ def lambda_handler(event, context): if cached_data: return { 'statusCode': 200, - 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'headers': DEFAULT_HEADERS, 'body': json.dumps({ 'cached': True, 'hash': xml_hash, 'xml': cached_data['xml_content'], - 'svg': cached_data['svg_content'] + 'svg': cached_data['svg_content'], + 'annotationsGenerated': bool(cached_data['xml_content']) }) } @@ -131,14 +169,26 @@ def lambda_handler(event, context): ) 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': 500, - 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, - 'body': json.dumps({ - 'error': 'Prefigure build failed', - 'stderr': result.stderr, - 'stdout': result.stdout - }) + 'statusCode': 422, + 'headers': DEFAULT_HEADERS, + 'body': json.dumps(payload) } # 6. Read Outputs @@ -147,9 +197,12 @@ def lambda_handler(event, context): 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_xml_path) and os.path.exists(out_svg_path): - with open(out_xml_path, 'r') as f: - xml_result = f.read() + 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() @@ -158,33 +211,33 @@ def lambda_handler(event, context): return { 'statusCode': 200, - 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, + 'headers': DEFAULT_HEADERS, 'body': json.dumps({ 'cached': False, 'hash': xml_hash, 'xml': xml_result, - 'svg': svg_result + 'svg': svg_result, + 'annotationsGenerated': bool(xml_result) }) } else: - # Debugging info if files are missing - 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" + 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': 500, - 'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'}, - 'body': json.dumps({ - 'error': 'Output files not found', - 'work_dir_contents': files_in_work, - 'output_dir_contents': files_in_output, - 'stderr': result.stderr - }) + 'statusCode': 422, + 'headers': DEFAULT_HEADERS, + 'body': json.dumps(payload) } \ No newline at end of file From a3db535944e32f474ba39cd571ddca832021224d Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Thu, 12 Mar 2026 00:57:02 -0500 Subject: [PATCH 4/5] change key to annotationsXml --- prefigure-lambda/ENDPOINT_TESTING.md | 4 ++-- prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md | 6 +++--- prefigure-lambda/app.py | 4 ++-- prefigure-lambda/test-prefigure.html | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/prefigure-lambda/ENDPOINT_TESTING.md b/prefigure-lambda/ENDPOINT_TESTING.md index b19fc4394..b151c2ce8 100644 --- a/prefigure-lambda/ENDPOINT_TESTING.md +++ b/prefigure-lambda/ENDPOINT_TESTING.md @@ -34,7 +34,7 @@ Expected response: - HTTP status: `200` - JSON fields include: - `svg` (non-empty string) - - `xml` is `null` + - `annotationsXml` is `null` - `annotationsGenerated` is `false` - `cached` is `true` or `false` @@ -44,7 +44,7 @@ Optional quick assert with `jq`: 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)), xmlIsNull:(.xml==null), annotationsGenerated, cached}' +| jq '{hasSvg:(.svg|type=="string" and (.svg|length>0)), annotationsXmlIsNull:(.annotationsXml==null), annotationsGenerated, cached}' ``` ## 2) Error path: malformed XML + debug diagnostics diff --git a/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md b/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md index 5a2e82f21..453fb9de7 100644 --- a/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md +++ b/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md @@ -12,7 +12,7 @@ The Lambda endpoint returns JSON and now treats `foo.xml` (annotations) as optio - **Success (`200`)** - `svg`: always present on success - - `xml`: annotation XML string or `null` when annotations are not produced + - `annotationsXml`: annotation XML string or `null` when annotations are not produced - `annotationsGenerated`: boolean flag indicating whether annotation XML exists - `cached`: whether result came from cache - `hash`: cache key @@ -127,7 +127,7 @@ Current handler behavior summary: - Hybrid cache: RAM (L1) + DynamoDB (L2) - `foo.svg` is required for success; `foo.xml` (annotations) is optional -- `xml` may be `null` with `annotationsGenerated: false` +- `annotationsXml` may be `null` with `annotationsGenerated: false` - Error semantics: - `400`: `invalid_encoding`, `empty_body` - `422`: `build_failed`, `svg_missing` @@ -140,7 +140,7 @@ Example successful response: { "cached": false, "hash": "...", - "xml": null, + "annotationsXml": null, "svg": "", "annotationsGenerated": false } diff --git a/prefigure-lambda/app.py b/prefigure-lambda/app.py index a9f06adc6..9e53e4b57 100644 --- a/prefigure-lambda/app.py +++ b/prefigure-lambda/app.py @@ -136,7 +136,7 @@ def lambda_handler(event, context): 'body': json.dumps({ 'cached': True, 'hash': xml_hash, - 'xml': cached_data['xml_content'], + 'annotationsXml': cached_data['xml_content'], 'svg': cached_data['svg_content'], 'annotationsGenerated': bool(cached_data['xml_content']) }) @@ -215,7 +215,7 @@ def lambda_handler(event, context): 'body': json.dumps({ 'cached': False, 'hash': xml_hash, - 'xml': xml_result, + 'annotationsXml': xml_result, 'svg': svg_result, 'annotationsGenerated': bool(xml_result) }) diff --git a/prefigure-lambda/test-prefigure.html b/prefigure-lambda/test-prefigure.html index 010a21abc..f1184f3d0 100644 --- a/prefigure-lambda/test-prefigure.html +++ b/prefigure-lambda/test-prefigure.html @@ -140,7 +140,7 @@

Output:

svgContainer.textContent = "Error: No SVG found in response: " + JSON.stringify(data); } - const cml = data.xml; + const cml = data.annotationsXml; if (cml) { cmlContainer.innerHTML = cml; From d660a8682288c9f1f873e597b4ecff41ea355950 Mon Sep 17 00:00:00 2001 From: Duane Nykamp Date: Sun, 12 Apr 2026 19:53:48 -0500 Subject: [PATCH 5/5] Improve prefigure Lambda deploy: pin version, /version endpoint, deploy script --- prefigure-lambda/Dockerfile | 6 +- prefigure-lambda/ENDPOINT_TESTING.md | 14 + ...PreFigure on AWS Lambda: Complete Setup.md | 421 ++++++++++-------- prefigure-lambda/app.py | 28 +- prefigure-lambda/deploy-prefigure-release.sh | 216 +++++++++ 5 files changed, 501 insertions(+), 184 deletions(-) create mode 100755 prefigure-lambda/deploy-prefigure-release.sh diff --git a/prefigure-lambda/Dockerfile b/prefigure-lambda/Dockerfile index 05095c075..6a8b51f8a 100644 --- a/prefigure-lambda/Dockerfile +++ b/prefigure-lambda/Dockerfile @@ -51,9 +51,13 @@ RUN git clone https://github.com/liblouis/liblouis.git . && \ # 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 "git+https://github.com/davidaustinm/prefigure.git#egg=prefig[pycairo]" +RUN pip install "prefig[pycairo]==${PREFIG_VERSION}" # 6. Initialize Prefigure # This downloads MathJax and fonts. diff --git a/prefigure-lambda/ENDPOINT_TESTING.md b/prefigure-lambda/ENDPOINT_TESTING.md index b151c2ce8..f577b7267 100644 --- a/prefigure-lambda/ENDPOINT_TESTING.md +++ b/prefigure-lambda/ENDPOINT_TESTING.md @@ -3,12 +3,26 @@ 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: diff --git a/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md b/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md index 453fb9de7..3439f306b 100644 --- a/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md +++ b/prefigure-lambda/PreFigure on AWS Lambda: Complete Setup.md @@ -1,140 +1,176 @@ # PreFigure on AWS Lambda: Complete Setup Guide -This guide documents the process of deploying the `prefigure` Python library to AWS Lambda using a Docker container, with a hybrid caching layer (RAM + DynamoDB) to optimize performance and reduce costs. +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 now treats `foo.xml` (annotations) as optional. +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` when annotations are not produced - - `annotationsGenerated`: boolean flag indicating whether annotation XML exists - - `cached`: whether result came from cache - - `hash`: cache key + - `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) + - `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 directory listings (`work_dir_contents`, `output_dir_contents`) for deeper troubleshooting. + - `prefigReturnCode`, `command`, `cwd`, `stdout`, `stderr` + - Add query param `?debug=1` to include `work_dir_contents` and `output_dir_contents`. --- -## 1. Project Structure - -Ensure your local directory is set up as follows: - -```text -prefigure-lambda/ -├── app.py # The Python handler code (Hybrid Caching + Prefigure) -├── Dockerfile # Instructions to build the image -├── event.json # Local test event (optional) -└── test.html # Browser-based test client - -2. Dockerfile Configuration - -We use Amazon Linux 2023 (via Python 3.12) to support modern build tools (dnf). This configuration compiles liblouis from source and installs pycairo dependencies. -Dockerfile +## 1. One-Time Infrastructure Setup -# 1. Use the AWS Lambda Python 3.12 base image (Amazon Linux 2023) -FROM public.ecr.aws/lambda/python:3.12 +Complete these once per environment/account. -# 2. Install System Build Tools & Dependencies -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 +### 1.1 Project files -# 3. Install Liblouis (Braille support) from Source -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 +Use this folder: -# 4. Install Pycairo explicitly (Pre-requisite for prefigure) -RUN pip install pycairo +```text +prefigure-lambda/ +├── app.py +├── Dockerfile +├── ENDPOINT_TESTING.md +├── PreFigure on AWS Lambda: Complete Setup.md +└── test-prefigure.html +``` -# 5. Install Prefigure -RUN pip install "git+https://github.com/davidaustinm/prefigure.git#egg=prefig[pycairo]" +### 1.2 Dockerfile (build recipe) -# 6. Initialize Prefigure (Downloads MathJax and fonts) -RUN prefig init +The Dockerfile handles all runtime dependency installation: -# 7. Copy Handler Code -WORKDIR ${LAMBDA_TASK_ROOT} -COPY app.py . +- OS/build dependencies (`dnf` packages) +- `liblouis` source build/install +- `pycairo` install +- pinned `prefig` install +- `prefig init` -# 8. Set the CMD to your handler -CMD [ "app.lambda_handler" ] +Canonical file: `prefigure-lambda/Dockerfile` -3. Database Setup (DynamoDB) +Current pinning pattern: -We use DynamoDB for persistent caching (L2 Cache). +```dockerfile +ARG PREFIG_VERSION=0.5.15 +ENV PREFIG_CACHE_VERSION=${PREFIG_VERSION} +RUN pip install "prefig[pycairo]==${PREFIG_VERSION}" +``` - Go to AWS Console > DynamoDB > Create table. +This keeps deployed prefigure version and cache versioning aligned. - Table name: PrefigureCache +### 1.3 DynamoDB table (persistent L2 cache) - Partition key: xml_hash (String) +Create table: - Create table. +- Table name: `PrefigureCache` +- Partition key: `xml_hash` (String) - Enable TTL (Auto-Delete Old Items): +Enable TTL: - Select the table > Additional settings. +- TTL attribute: `expiration_time` - Turn on Time to Live (TTL). +### 1.4 IAM permissions (least privilege) - TTL attribute name: expiration_time +Grant the Lambda execution role access to the cache table. -4. Application Code (app.py) +```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" + } + ] +} +``` -The canonical implementation is maintained in `prefigure-lambda/app.py`. +### 1.5 Handler behavior summary -Current handler behavior summary: +Canonical implementation: `prefigure-lambda/app.py` - Hybrid cache: RAM (L1) + DynamoDB (L2) -- `foo.svg` is required for success; `foo.xml` (annotations) is optional -- `annotationsXml` may be `null` with `annotationsGenerated: false` +- 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` -- Diagnostics on failures include: `prefigReturnCode`, `command`, `cwd`, `stdout`, `stderr` -- Optional `?debug=1` includes file listings in error payloads +- `?debug=1` adds directory listings in error payloads -Example successful response: +Example success payload: ```json { @@ -146,111 +182,132 @@ Example successful response: } ``` -5. IAM Permissions (Least Privilege) +--- -To allow the Lambda to write to DynamoDB safely: +## 2. Per-Release Upgrade Procedure - Go to IAM > Roles > Select prefigure-lambda-role. +Use this each time you upgrade prefigure (for example 0.5.15 -> 0.5.16). - Add permissions > Create inline policy. +### 2.1 Change pinned version - Use the following JSON (replace [YOUR_ACCOUNT_ID]): +Either: -JSON +- edit Dockerfile default: -{ - "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" - } - ] -} +```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. -6. Build & Deploy +### 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 +``` -Run these commands in your project folder. Replace [ACCOUNT_ID] with your AWS ID. -Bash +### 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: +``` -# 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.6 Automated script (recommended) -# 2. Build Image (provenance=false is critical for Lambda) -docker build --provenance=false --platform linux/amd64 -t prefigure-repo . +Use `deploy-prefigure-release.sh` to run the release flow end-to-end. -# 3. Tag & Push -docker tag prefigure-repo:latest ACCOUNT_ID.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest -docker push ACCOUNT_ID.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest +Examples: -# 4. Update Lambda Function -aws lambda update-function-code --function-name prefigure-function --image-uri ACCOUNT_ID.dkr.ecr.us-east-2.amazonaws.com/prefigure-repo:latest +```bash +# Standard release +./deploy-prefigure-release.sh --version 0.5.15 -7. Testing -Terminal (Curl) -Bash +# 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 - -Browser (test.html) - -Save this as test.html. It handles the JSON response and renders `svg` when present. -HTML - - - - - - Prefigure API Test - - -

Prefigure API Test

- -

- -
- - - - ``` + +For browser testing, use `test-prefigure.html`. diff --git a/prefigure-lambda/app.py b/prefigure-lambda/app.py index 9e53e4b57..b83cabf29 100644 --- a/prefigure-lambda/app.py +++ b/prefigure-lambda/app.py @@ -6,10 +6,24 @@ import time import base64 import shutil +import importlib.metadata from botocore.exceptions import ClientError # --- CONFIGURATION --- -CACHE_VERSION = "v0.5.7" + +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 --- @@ -95,6 +109,18 @@ def get_debug_directory_contents(work_dir, output_dir): # --- 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'): 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."