Homework #5: Flask API and EC2 Deployment (DRAFT)
EE 547: Fall 2026
Assigned: 27 October
Due: Monday, 9 November at 23:59
Gradescope: Homework 5 | How to Submit
- Docker Desktop must be installed and running on your machine
- Python 3.11+ required
- AWS CLI configured with valid credentials
- The HW#4 Problem 2 DynamoDB table (
arxiv-papers) loaded; if you deleted it, reload it with your HW#4load_data.py - Use only Python standard library modules unless explicitly permitted
Overview
Build an authenticated REST API with Flask and deploy it to EC2 from scripts. The API serves paper metadata from DynamoDB; the image, the signing key and the permissions live in AWS services, so the instance running the API can be replaced at any time.
Getting Started
Download the starter code: hw5-starter.zip
unzip hw5-starter.zip
cd hw5-starterProblem 1: Flask REST API for arXiv Papers
Implement a REST API that serves arXiv paper metadata from DynamoDB, with JWT authentication on protected endpoints.
Use only Flask, boto3, PyJWT, gunicorn and Python standard library modules (json, os, sys, time, datetime, functools, threading). Do not use Django, FastAPI, Flask extensions, or other web frameworks. AWS CLI must be configured with valid credentials.
Part A: API Endpoints
Create app.py that implements the following endpoints. All endpoints except /api/stats, /api/login and the arXiv redirect require a valid JWT in the Authorization header.
The examples are computed from the reference papers.json of HW#4 Problem 2 (ten cs.LG papers from December 2020).
A paper in a list carries arxiv_id, title, authors, published and categories; the detail endpoint adds abstract. Each paper appears at most once in a list. Every list comes from one Query: the denormalized items of HW#4 already carry these fields, and fetching each paper separately adds one request per result.
Unprotected Endpoints:
GET /api/stats- Server statistics: uptime and request counts since this process started{ "status": "healthy", "uptime_seconds": 3842, "region": "us-west-2", "table": "arxiv-papers", "requests": { "total": 247, "by_status": {"200": 210, "401": 25, "404": 8, "302": 4} } }POST /api/login- Authenticate and receive a JWTRequest:
{"username": "admin", "password": "secret"}A body that is not a JSON object, or that lacks a string
usernameorpassword, returns 400.Success (200):
{"token": "eyJhbGciOiJIUzI1NiIs...", "expires_in": 3600}Invalid credentials (401):
{"error": "Invalid credentials"}
Protected Endpoints (require Authorization: Bearer <token>):
GET /api/papers?category={category}&limit={limit}- Recent papers in a category, newest first;categoryrequired{ "category": "cs.LG", "papers": [ { "arxiv_id": "2012.12056v1", "title": "Paper Title", "authors": ["Maddalena Amendola", "Rossella Arcucci", "Laetitia Mottet"], "published": "2020-12-22T14:43:50Z", "categories": ["cs.LG", "stat.ML"] }, ... ], "count": 5 }GET /api/papers/{arxiv_id}- Full paper details{ "arxiv_id": "2012.11510v1", "title": "Design Rule Checking with a CNN Based Feature Extractor", "authors": ["Luis Francisco", "Tanmay Lagare", ...], "abstract": "Full abstract text...", "categories": ["cs.LG"], "published": "2020-12-21T17:26:31Z" }A paper not in the table returns 404:
{"error": "Paper not found: 2012.99999v1"}GET /api/papers/author/{author_name}- Papers by an author; a name with spaces is URL-encoded in the path (Yassien%20Shaalan){ "author": "Yassien Shaalan", "papers": [...], "count": 2 }GET /api/papers/search?category={category}&start={date}&end={date}- Papers in a category published in a date range{ "category": "cs.LG", "start": "2020-12-15", "end": "2020-12-18", "papers": [...], "count": 6 }category,startandendare required. Dates areYYYY-MM-DD, and both ends are inclusive. A missing parameter, a date that does not parse, orstartafterendreturns 400.GET /api/papers/keyword/{keyword}?limit={limit}- Papers with a keyword{ "keyword": "model", "papers": [...], "count": 3 }GET /api/papers/{arxiv_id}/arxiv- Redirect to the paper on arxiv.orgReturns 302 Found with
Location: https://arxiv.org/abs/{arxiv_id}and no JSON body. A paper not in the table returns 404, not a redirect.
limit (endpoints 3 and 7) is an integer from 1 to 100, default 20; any other value returns 400.
Error Handling:
Every reply, for every path whether it exists or not, is JSON with Content-Type: application/json. An error reply carries the code in its status line and the message in its body, in one shape:
{"error": "Paper not found: 2012.99999v1"}The status code and headers carry what the client acts on; the body does not repeat them:
- 400 for missing or invalid parameters
- 401 for a missing or invalid token, with
WWW-Authenticate: Bearer - 404 for an unknown path or a paper not in the table
- 405 for a method the path does not accept, with
Allowlisting the methods it does - 500 for any other failure; the body carries no traceback
Flask answers an unknown path or method with its own HTML page unless the application registers error handlers. Register one for werkzeug.exceptions.HTTPException, which covers abort() and Flask’s own 404 and 405, and one for Exception, which covers everything else. Route handlers raise with abort(); only the error handlers build error replies.
Request counts: record the status of every reply, errors included, in an @app.after_request hook. The server runs one process with several threads (see Part D), so requests update the counts concurrently; guard them with a threading.Lock.
Part B: JWT Authentication
Implement token-based authentication with PyJWT.
Users: app.py defines a USERS dict with two users, passwords in plaintext (password storage is outside this problem):
| Username | Password | Role |
|---|---|---|
admin |
secret |
admin |
viewer |
readonly |
viewer |
Token creation (/api/login): sign with HS256 and these claims:
sub- Subject: the usernameiat- Issued At: Unix timestamp of token creationexp- Expiration:iatplus 3600 seconds; the token is rejected after itrole- the user’s role fromUSERS
sub, iat and exp are registered claims (RFC 7519). role is an application-specific claim; it is available on decode without a database lookup.
Signing key: the key is configuration, not code. app.py reads it once at startup from the JWT_SECRET environment variable and exits with an error message if the variable is unset or empty. The key never appears in app.py, in the image, or in your repository.
For local runs, keep the key in a .env file in q1/:
JWT_SECRET=<64 hex characters>
Generate a value with python3 -c 'import secrets; print(secrets.token_hex(32))'. Create a .dockerignore that lists .env: the Dockerfile copies the whole directory into the image, and a key copied into a layer ships with every copy of the image. Do not submit .env.
Token validation (protected endpoints): a decorator wraps a function with additional behavior; @app.route is one. A @require_auth decorator runs before the route handler and checks the token; the handler runs only if the token is valid.
@require_auth:
- Reads the
Authorizationheader - Takes the token after
Bearer - Decodes and validates it with PyJWT
- Returns 401 if the header is missing or malformed, or the token is invalid or expired
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Part C: DynamoDB Integration
The API reads from the arxiv-papers DynamoDB table and its GSIs from HW#4 Problem 2. Two environment variables configure it: AWS_REGION (default us-west-2) and DYNAMODB_TABLE (default arxiv-papers).
Each endpoint is one DynamoDB access pattern:
| Endpoint | DynamoDB Operation |
|---|---|
GET /api/papers?category= |
Main table query, sort key descending |
GET /api/papers/{arxiv_id} |
GSI (PaperIdIndex) |
GET /api/papers/author/{name} |
GSI (AuthorIndex) |
GET /api/papers/search?... |
Main table, sort key range |
GET /api/papers/keyword/{kw} |
GSI (KeywordIndex) |
Part D: Docker Configuration
A Dockerfile and requirements.txt are provided in the starter code:
Code: Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PYTHONUNBUFFERED=1
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "4", "app:app"]
Code: requirements.txt
flask>=3.0
boto3>=1.28.0
pyjwt>=2.8.0
gunicorn>=21.2.0
The image serves the application with gunicorn, a WSGI server, rather than Flask’s development server. It runs one worker process with four threads: every request reaches the same process, so the request counts in that process cover all of them. With several worker processes, each would keep its own counts. With JWT_SECRET set in your shell, python app.py starts the development server on port 8080 for local work:
pip install -r requirements.txt
python app.py
Part E: Building and Running
Build your container image:
docker build -t arxiv-api:latest .
Run it with your local AWS credentials and the key from .env:
docker run --rm -p 8080:8080 \
-e AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY \
-e AWS_SESSION_TOKEN \
--env-file .env \
arxiv-api:latest
Each -e passes a variable from your shell to the container; --env-file passes every variable in the file. With named profiles, export the credentials first. The credentials must allow reading the table.
Part F: Testing
With the container running, test your endpoints using curl:
# Stats (no token)
curl http://localhost:8080/api/stats
# Login; copy the token from the reply
curl -s -X POST http://localhost:8080/api/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "secret"}'
# Protected endpoints (replace <token> with the value from login)
curl -H "Authorization: Bearer <token>" \
"http://localhost:8080/api/papers?category=cs.LG&limit=5"
curl -H "Authorization: Bearer <token>" \
http://localhost:8080/api/papers/author/Yassien%20Shaalan
# 401: no token
curl "http://localhost:8080/api/papers?category=cs.LG"
# 401: invalid token
curl -H "Authorization: Bearer invalid-token" \
"http://localhost:8080/api/papers?category=cs.LG"
Deliverables
See Submission. Your README should explain:
- How
@require_authdistinguishes a missing header, a malformed header, a bad signature and an expired token, and what each returns - Why
roletravels in the token rather than in a lookup, and what a user whose role is revoked can still do until the token expires - What a client holding a token observes when the signing key is replaced
We will validate your submission by running the following commands from your q1/ directory:
docker build -t arxiv-api:latest .
docker run --rm -p 8080:8080 \
-e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN \
-e DYNAMODB_TABLE=arxiv-papers -e JWT_SECRET=<grading key> \
arxiv-api:latest
These commands must complete without errors. We will then verify:
- The stats endpoint responds without a token and includes uptime and request counts
- Login returns a valid JWT for correct credentials and 401 for incorrect ones
- All five query endpoints return the JSON structure above
- The arXiv redirect returns 302 with the correct
Locationheader - Protected endpoints return 401 without a token, and with an expired or invalid token
- Every path, including unknown ones, returns JSON; error replies use the status codes and headers above (400, 401 with
WWW-Authenticate, 404, 405 withAllow) - All endpoints respond within 2 seconds
- README answers all three questions
Problem 2: Disposable Deployment on EC2
Deploy your Problem 1 API to EC2 from scripts, so that any instance can be terminated and replaced with no loss of configuration or state.
setup.py, launch.py and teardown.py use only boto3 and Python standard library modules (json, sys, os, time, argparse, secrets). user-data.sh is a Bash script that runs on the instance. Do not use the AWS console, CloudFormation, Terraform, or other provisioning tools to create resources. AWS CLI must be configured with valid credentials.
Part A: What Lives Outside the Instance
The instance holds nothing that cannot be rebuilt. Everything it needs lives in a service that outlasts it:
| Resource | Name | Holds |
|---|---|---|
| ECR repository | ee547-hw5-api |
The application image |
| SSM parameter | /ee547/hw5/jwt-secret |
The JWT signing key |
| DynamoDB table | arxiv-papers |
The paper data (HW#4 Problem 2) |
| IAM role and instance profile | ee547-hw5-role |
The instance’s permissions |
| Security group | ee547-hw5-sg |
The inbound rules |
| User data | user-data.sh |
Every step that turns a fresh instance into a running API |
All resources are in us-west-2. Every instance carries the tag Project=ee547-hw5; the scripts find instances by this tag and by no other means.
Terminating the instance loses nothing. launch.py builds a working replacement from these resources alone, with no SSH and no manual step.
Part B: Image in ECR
Push the Problem 1 image to the repository (setup.py creates it; see Part C). From your q1/ directory:
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
REGISTRY=$ACCOUNT.dkr.ecr.us-west-2.amazonaws.com
aws ecr get-login-password --region us-west-2 | \
docker login --username AWS --password-stdin $REGISTRY
docker build --platform linux/amd64 -t $REGISTRY/ee547-hw5-api:latest .
docker push $REGISTRY/ee547-hw5-api:latest
The instance is x86-64. An image built on an Apple-silicon Mac without --platform linux/amd64 is arm64 and fails on the instance with exec format error.
Push again after every change to app.py; a new instance pulls whatever latest names at boot.
Part C: Setup Script
Create setup.py, which creates every resource in Part A except the table and the instance:
python setup.py
Required resources:
- ECR repository
ee547-hw5-api - SSM parameter
/ee547/hw5/jwt-secret, a SecureString holding a random 64-character hex key (secrets.token_hex(32)) - IAM role
ee547-hw5-rolewhose trust policy allowsec2.amazonaws.comto assume it, with one inline policy granting:dynamodb:Queryanddynamodb:GetItemon thearxiv-paperstable and its indexesssm:GetParameteron the one parameterecr:GetAuthorizationToken(resource*; the token covers the whole registry), andecr:BatchGetImageandecr:GetDownloadUrlForLayeron the one repository
- Instance profile
ee547-hw5-rolecontaining the role - Security group
ee547-hw5-sgin the default VPC, allowing inbound TCP 8080 and 22 from0.0.0.0/0
Requirements:
- Each resource is created only if it does not exist; a second run completes without error and changes nothing
- An existing parameter is never overwritten: replacing the key invalidates every token already issued
- Resource ARNs are built from the account ID (
sts.get_caller_identity), never typed in - The script prints, for each resource, whether it was created or already existed
Part D: User Data
Create user-data.sh. EC2 runs it as root on the instance’s first boot. It must take a fresh Ubuntu 26.04 instance to a running API with no further input.
Start from these commands, which install Docker and the AWS CLI:
#!/bin/bash
exec > >(tee /var/log/user-data.log) 2>&1
set -e
# Boot-time unattended-upgrades may hold the apt lock
echo 'DPkg::Lock::Timeout "300";' > /etc/apt/apt.conf.d/99-lock-timeout
apt-get update
# Docker
apt-get install -y ca-certificates curl unzip
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io
# AWS CLI v2
curl -fsSL https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip -o /tmp/awscliv2.zip
unzip -q /tmp/awscliv2.zip -d /tmp
/tmp/aws/installThen add the steps that:
- Add the
ubuntuuser to thedockergroup, and enable and start Docker - Install
python3andpython3-venv - Log Docker in to the account’s ECR registry, finding the account ID with
aws sts get-caller-identity(the instance role supplies the credentials) - Pull
ee547-hw5-api:latest - Read the signing key with
aws ssm get-parameter --with-decryptionon/ee547/hw5/jwt-secret - Run the image detached on port 8080 with
--restart unless-stopped, passingAWS_REGION,DYNAMODB_TABLE, and the key asJWT_SECRET - Write a final line marking completion to the log
app.py is unchanged from Problem 1: it reads JWT_SECRET from its environment, and only the source of the value differs. Locally the value comes from .env; on EC2 it comes from Parameter Store, so every instance, and every replacement, signs and validates with the same key. The container reaches the instance role’s credentials through the instance metadata service; no access keys appear anywhere on the instance.
Part E: Launch Script
Create launch.py, which starts one instance and prints its ID and public IP:
python launch.py [--key-name KEY_NAME]
Behavior:
- Tag check. If an instance tagged
Project=ee547-hw5is in statepending,running,stoppingorstopped, print its ID and exit with a non-zero status without launching. Terminated instances stay visible, with their tags, for about an hour; the state filter keeps them from blocking a launch. - AMI. Find the newest Ubuntu 26.04 AMI with
describe_images: owner099720109477(Canonical), nameubuntu/images/hvm-ssd-gp3/ubuntu-resolute-26.04-amd64-server-*, the latestCreationDate. - Launch one
t3.microwith the instance profile, the security group,user-data.shas user data, the tag, and metadata optionsHttpTokens=requiredandHttpPutResponseHopLimit=2.--key-nameis optional; without it the instance has no SSH access. - Wait for
instance_running, then print the instance ID and public IP.
The hop limit of 2 lets a response from the metadata service cross Docker’s bridge network; at 1, the container receives no credentials.
Instance profile propagation. An instance profile created seconds earlier can be visible in IAM and still be rejected by EC2: run_instances fails with InvalidParameterValue naming the profile. Wait for the profile with iam.get_waiter("instance_profile_exists"), then retry run_instances on that error every 5 seconds, for up to 60 seconds, before exiting with an error.
The API answers a few minutes after launch.py returns, once user data finishes.
Part F: Teardown Script
Create teardown.py, which removes what the other scripts created:
python teardown.py # terminate tagged instances
python teardown.py --all # also delete every resource setup.py created
The script takes no resource IDs. It acts only on instances tagged Project=ee547-hw5 and on the names in Part A; it never touches other instances or resources in the account.
--all deletes in the reverse of the order setup created them, because each deletion depends on the one before it:
- Terminate the tagged instances and wait for
instance_terminated; a security group in use by an instance cannot be deleted - Delete the security group
- Remove the role from the instance profile, then delete the profile
- Delete the role’s inline policy, then the role
- Delete the parameter
- Delete the repository with
force=True, which deletes its images
Requirements:
- Print what matched before acting; with nothing matched, print that and exit with status 0
- A resource that is already gone is reported and skipped, not an error
--allleaves thearxiv-paperstable in place
Part G: Testing
Deploy from nothing:
python setup.py
# push the image (Part B)
python launch.py
Wait for user data to finish, then from your local machine:
curl http://<public-ip>:8080/api/stats
TOKEN=$(curl -s -X POST http://<public-ip>:8080/api/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "secret"}' | \
python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
curl -H "Authorization: Bearer $TOKEN" \
"http://<public-ip>:8080/api/papers?category=cs.LG&limit=3"
Then replace the instance:
python teardown.py
python launch.py
On the new instance, at its new IP, the same $TOKEN is accepted and the same query returns the same papers. Nothing was carried over from the old instance.
Run python setup.py and python launch.py a second time each: setup.py changes nothing, launch.py refuses to start a second instance.
Viewing resources in the console. The scripts create and delete; the console shows what exists, in us-west-2:
- EC2 → Instances, filtered by the tag
Project = ee547-hw5 - EC2 → Instances → an instance → Actions → Monitor and troubleshoot → Get system log: user data output, without SSH
- EC2 → Security Groups
- IAM → Roles
- ECR → Repositories
- Systems Manager → Parameter Store
- Billing and Cost Management → Bills
Keep one instance running until grades are posted, then run python teardown.py --all.
Deliverables
See Submission. Your README should state the public IP of your running instance, and explain:
- What clients would observe after a replacement if the signing key were in the image or on the instance
- What the tag check in
launch.pyguards against - Why
teardown.py --alldeletes in the order it does, and what fails if two steps are swapped - One failure you hit in user data, and how you found it
We will validate your submission by running the following commands from outside AWS, against the public IP in your README:
curl http://<public-ip>:8080/api/stats
curl -X POST http://<public-ip>:8080/api/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "secret"}'
curl -H "Authorization: Bearer <token>" \
"http://<public-ip>:8080/api/papers?category=cs.LG&limit=5"
These commands must complete without errors. We will then verify:
- The instance is reachable on port 8080 and the three endpoints respond correctly
- DynamoDB queries return data
setup.pyandteardown.pycheck for existing resources and act only on the names and tag in Part Alaunch.pyrefuses a second instance and retries the instance-profile erroruser-data.shtakes a fresh instance to a running API with no manual step- README answers all four questions
README.md
q1/
├── app.py
├── .dockerignore
├── Dockerfile
├── requirements.txt
└── README.md
q2/
├── setup.py
├── launch.py
├── teardown.py
├── user-data.sh
└── README.md