Homework #5: Flask API and EC2 Deployment (DRAFT)

EE 547: Fall 2026

ImportantAssignment Details

Assigned: 27 October
Due: Monday, 9 November at 23:59

Gradescope: Homework 5 | How to Submit

WarningRequirements
  • 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#4 load_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-starter

Problem 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:

  1. 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}
      }
    }
  2. POST /api/login - Authenticate and receive a JWT

    Request:

    {"username": "admin", "password": "secret"}

    A body that is not a JSON object, or that lacks a string username or password, returns 400.

    Success (200):

    {"token": "eyJhbGciOiJIUzI1NiIs...", "expires_in": 3600}

    Invalid credentials (401):

    {"error": "Invalid credentials"}

Protected Endpoints (require Authorization: Bearer <token>):

  1. GET /api/papers?category={category}&limit={limit} - Recent papers in a category, newest first; category required

    {
      "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
    }
  2. 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"}
  3. 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
    }
  4. 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, start and end are required. Dates are YYYY-MM-DD, and both ends are inclusive. A missing parameter, a date that does not parse, or start after end returns 400.

  5. GET /api/papers/keyword/{keyword}?limit={limit} - Papers with a keyword

    {
      "keyword": "model",
      "papers": [...],
      "count": 3
    }
  6. GET /api/papers/{arxiv_id}/arxiv - Redirect to the paper on arxiv.org

    Returns 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 Allow listing 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 username
  • iat - Issued At: Unix timestamp of token creation
  • exp - Expiration: iat plus 3600 seconds; the token is rejected after it
  • role - the user’s role from USERS

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:

  1. Reads the Authorization header
  2. Takes the token after Bearer
  3. Decodes and validates it with PyJWT
  4. 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_auth distinguishes a missing header, a malformed header, a bad signature and an expired token, and what each returns
  • Why role travels 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
ImportantGrading Commands

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 Location header
  • 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 with Allow)
  • 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:

  1. ECR repository ee547-hw5-api
  2. SSM parameter /ee547/hw5/jwt-secret, a SecureString holding a random 64-character hex key (secrets.token_hex(32))
  3. IAM role ee547-hw5-role whose trust policy allows ec2.amazonaws.com to assume it, with one inline policy granting:
    • dynamodb:Query and dynamodb:GetItem on the arxiv-papers table and its indexes
    • ssm:GetParameter on the one parameter
    • ecr:GetAuthorizationToken (resource *; the token covers the whole registry), and ecr:BatchGetImage and ecr:GetDownloadUrlForLayer on the one repository
  4. Instance profile ee547-hw5-role containing the role
  5. Security group ee547-hw5-sg in the default VPC, allowing inbound TCP 8080 and 22 from 0.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/install

Then add the steps that:

  • Add the ubuntu user to the docker group, and enable and start Docker
  • Install python3 and python3-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-decryption on /ee547/hw5/jwt-secret
  • Run the image detached on port 8080 with --restart unless-stopped, passing AWS_REGION, DYNAMODB_TABLE, and the key as JWT_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:

  1. Tag check. If an instance tagged Project=ee547-hw5 is in state pending, running, stopping or stopped, 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.
  2. AMI. Find the newest Ubuntu 26.04 AMI with describe_images: owner 099720109477 (Canonical), name ubuntu/images/hvm-ssd-gp3/ubuntu-resolute-26.04-amd64-server-*, the latest CreationDate.
  3. Launch one t3.micro with the instance profile, the security group, user-data.sh as user data, the tag, and metadata options HttpTokens=required and HttpPutResponseHopLimit=2. --key-name is optional; without it the instance has no SSH access.
  4. 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:

  1. Terminate the tagged instances and wait for instance_terminated; a security group in use by an instance cannot be deleted
  2. Delete the security group
  3. Remove the role from the instance profile, then delete the profile
  4. Delete the role’s inline policy, then the role
  5. Delete the parameter
  6. 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
  • --all leaves the arxiv-papers table 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.py guards against
  • Why teardown.py --all deletes 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
ImportantGrading Commands

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.py and teardown.py check for existing resources and act only on the names and tag in Part A
  • launch.py refuses a second instance and retries the instance-profile error
  • user-data.sh takes a fresh instance to a running API with no manual step
  • README answers all four questions

TipSubmission
README.md
q1/
├── app.py
├── .dockerignore
├── Dockerfile
├── requirements.txt
└── README.md
q2/
├── setup.py
├── launch.py
├── teardown.py
├── user-data.sh
└── README.md