Skip to content

Commit 23c29f1

Browse files
committed
add aws deployment iac
1 parent f0838b7 commit 23c29f1

21 files changed

Lines changed: 868 additions & 54 deletions

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,17 @@ test_data/
2424
# OS
2525
.DS_Store
2626
Thumbs.db
27+
28+
# Deployment artifacts
29+
deployment/lambda_package.zip
30+
31+
# Terraform
32+
terraform/.terraform/
33+
terraform/.terraform.lock.hcl
34+
terraform/terraform.tfstate
35+
terraform/terraform.tfstate.backup
36+
terraform/*.tfvars
37+
terraform/plan.out
38+
39+
# AWS
40+
.aws/

README.md

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@
55

66
A simple Python/FastAPI server for syncing reading progress across KOReader devices.
77

8-
## Running Sync Server
8+
## Quick Start (Hosted Server)
9+
10+
Don't want to deploy your own? Use the public sync server:
11+
12+
**`https://null-space.xyz/reader`**
13+
14+
Just point your KOReader or Readest app to this URL and create an account.
15+
16+
## Running Sync Server (Self-Hosted)
917

1018
### Local Development
1119

@@ -22,12 +30,69 @@ uvicorn main:app --reload --port 8080
2230
docker compose up -d
2331
```
2432

33+
### AWS Lambda
34+
35+
Deploy as a serverless Lambda function with DynamoDB storage.
36+
37+
#### Prerequisites
38+
39+
- AWS CLI configured with credentials
40+
- Terraform >= 1.0
41+
- Python 3.12
42+
43+
#### One-Time Bootstrap
44+
45+
Create the shared S3 bucket for Terraform state:
46+
47+
```bash
48+
cd deployment
49+
./bootstrap.sh
50+
```
51+
52+
#### Deploy
53+
54+
1. Create terraform variables file:
55+
56+
```bash
57+
cp deployment/terraform.tfvars.example terraform/terraform.tfvars
58+
```
59+
60+
2. Edit `terraform/terraform.tfvars` with your password salt:
61+
62+
```hcl
63+
password_salt = "your-secure-random-salt" # Generate with: openssl rand -hex 32
64+
```
65+
66+
3. Build and deploy:
67+
68+
```bash
69+
./deployment/deploy.sh
70+
```
71+
72+
This creates:
73+
- Lambda function (`reader-progress-prod`)
74+
- DynamoDB tables (`reader-progress-prod-users`, `reader-progress-prod-progress`)
75+
- IAM role with minimal permissions
76+
2577
## Configuration
2678

79+
### Docker / Local Development
80+
2781
| Environment Variable | Default | Description |
2882
|---------------------|---------|-------------|
29-
| PASSWORD_SALT | `default-salt-change-me` | Salt prepended to passwords before hashing |
30-
| DATABASE_URL | `sqlite:///./data/koreader.db` | SQLite database path |
83+
| `DB_BACKEND` | `sql` | Database backend (`sql` or `dynamodb`) |
84+
| `PASSWORD_SALT` | `default-salt-change-me` | Salt prepended to passwords before hashing |
85+
| `DATABASE_URL` | `sqlite:///./data/koreader.db` | SQLite/PostgreSQL database URL |
86+
87+
### AWS Lambda
88+
89+
| Environment Variable | Description |
90+
|---------------------|-------------|
91+
| `DB_BACKEND` | Set to `dynamodb` |
92+
| `PASSWORD_SALT` | Salt for password hashing (set via Terraform) |
93+
| `DYNAMODB_USERS_TABLE` | Users table name (set via Terraform) |
94+
| `DYNAMODB_PROGRESS_TABLE` | Progress table name (set via Terraform) |
95+
| `AWS_REGION` | AWS region (set via Terraform) |
3196

3297
## KOReader Setup
3398

@@ -77,15 +142,17 @@ The document hash ensures the same book is identified across devices regardless
77142

78143
## Database Structure
79144

80-
### Users Table
145+
### SQLite/PostgreSQL (Docker/Local)
146+
147+
#### Users Table
81148

82149
| Column | Type | Description |
83150
|--------|------|-------------|
84151
| id | INTEGER | Primary key |
85152
| username | TEXT | Unique username |
86153
| password_hash | TEXT | Bcrypt hash of salted MD5 password |
87154

88-
### Progress Table
155+
#### Progress Table
89156

90157
| Column | Type | Description |
91158
|--------|------|-------------|
@@ -100,6 +167,27 @@ The document hash ensures the same book is identified across devices regardless
100167

101168
**Key constraint**: One progress record per (user_id, document) pair. Updates replace existing records.
102169

170+
### DynamoDB (AWS Lambda)
171+
172+
#### Users Table
173+
174+
| Attribute | Type | Key |
175+
|-----------|------|-----|
176+
| username | String | Partition Key |
177+
| password_hash | String | - |
178+
179+
#### Progress Table
180+
181+
| Attribute | Type | Key |
182+
|-----------|------|-----|
183+
| user_id | String | Partition Key |
184+
| document | String | Sort Key |
185+
| progress | String | - |
186+
| percentage | Number | - |
187+
| device | String | - |
188+
| device_id | String | - |
189+
| timestamp | Number | - |
190+
103191
## API Reference
104192

105193
### Authentication

auth.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
import hashlib
44
import bcrypt
55
from fastapi import Header, HTTPException, Depends
6-
from sqlalchemy.orm import Session
7-
from database import get_db
8-
from models import User
6+
7+
from repositories import get_user_repository
8+
from repositories.protocols import UserEntity
99

1010
logging.basicConfig(level=logging.DEBUG)
1111
logger = logging.getLogger(__name__)
@@ -33,15 +33,15 @@ def verify_password(password_md5: str, password_hash: str) -> bool:
3333
def get_current_user(
3434
x_auth_user: str = Header(None),
3535
x_auth_key: str = Header(None),
36-
db: Session = Depends(get_db),
37-
) -> User:
36+
user_repo=Depends(get_user_repository),
37+
) -> UserEntity:
3838
key_hint = f"'{x_auth_key[:8]}...' len={len(x_auth_key)}" if x_auth_key else "None"
3939
logger.debug(f"Auth: user='{x_auth_user}' key={key_hint}")
4040

4141
if not x_auth_user or not x_auth_key:
4242
raise HTTPException(status_code=401, detail="Unauthorized")
4343

44-
user = db.query(User).filter(User.username == x_auth_user).first()
44+
user = user_repo.get_by_username(x_auth_user)
4545
if not user:
4646
logger.debug(f"User '{x_auth_user}' not found")
4747
raise HTTPException(status_code=401, detail="Unauthorized")

deployment/bootstrap.sh

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/bin/bash
2+
set -e
3+
4+
BUCKET_NAME="nullspace-terraform-state"
5+
REGION="us-east-1"
6+
7+
echo "=== Terraform State Bucket Bootstrap ==="
8+
echo ""
9+
echo "This script creates the S3 bucket for Terraform remote state."
10+
echo "Run this once before deploying reader-progress or nullspace-website."
11+
echo ""
12+
13+
# Check if bucket already exists
14+
if aws s3api head-bucket --bucket "$BUCKET_NAME" 2>/dev/null; then
15+
echo "Bucket '$BUCKET_NAME' already exists."
16+
exit 0
17+
fi
18+
19+
echo "Creating S3 bucket: $BUCKET_NAME"
20+
aws s3api create-bucket \
21+
--bucket "$BUCKET_NAME" \
22+
--region "$REGION"
23+
24+
echo "Enabling versioning..."
25+
aws s3api put-bucket-versioning \
26+
--bucket "$BUCKET_NAME" \
27+
--versioning-configuration Status=Enabled
28+
29+
echo "Enabling encryption..."
30+
aws s3api put-bucket-encryption \
31+
--bucket "$BUCKET_NAME" \
32+
--server-side-encryption-configuration '{
33+
"Rules": [
34+
{
35+
"ApplyServerSideEncryptionByDefault": {
36+
"SSEAlgorithm": "AES256"
37+
}
38+
}
39+
]
40+
}'
41+
42+
echo "Blocking public access..."
43+
aws s3api put-public-access-block \
44+
--bucket "$BUCKET_NAME" \
45+
--public-access-block-configuration '{
46+
"BlockPublicAcls": true,
47+
"IgnorePublicAcls": true,
48+
"BlockPublicPolicy": true,
49+
"RestrictPublicBuckets": true
50+
}'
51+
52+
echo ""
53+
echo "=== Bootstrap Complete ==="
54+
echo "S3 bucket '$BUCKET_NAME' is ready for Terraform state storage."

deployment/build_lambda.sh

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#!/bin/bash
2+
set -e
3+
4+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5+
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
6+
BUILD_DIR="$SCRIPT_DIR/build"
7+
OUTPUT_FILE="$SCRIPT_DIR/lambda_package.zip"
8+
9+
echo "Building Lambda package..."
10+
11+
# Clean previous build
12+
rm -rf "$BUILD_DIR"
13+
mkdir -p "$BUILD_DIR"
14+
15+
# Check if Docker is available for cross-platform build
16+
if command -v docker &> /dev/null; then
17+
echo "Using Docker for cross-platform build (Amazon Linux 2023)..."
18+
19+
# Install dependencies using Lambda Python image
20+
docker run --rm \
21+
--entrypoint "" \
22+
-v "$PROJECT_ROOT:/var/task" \
23+
-v "$BUILD_DIR:/var/build" \
24+
public.ecr.aws/lambda/python:3.12 \
25+
pip install -r /var/task/requirements-aws.txt -t /var/build --quiet --upgrade
26+
else
27+
echo "Docker not found, using local pip (may not work on Lambda)..."
28+
pip3 install -r "$PROJECT_ROOT/requirements-aws.txt" -t "$BUILD_DIR" --quiet --upgrade
29+
fi
30+
31+
# Copy application code
32+
echo "Copying application code..."
33+
cp "$PROJECT_ROOT/main.py" "$BUILD_DIR/"
34+
cp "$PROJECT_ROOT/auth.py" "$BUILD_DIR/"
35+
cp "$PROJECT_ROOT/schemas.py" "$BUILD_DIR/"
36+
cp "$PROJECT_ROOT/lambda_handler.py" "$BUILD_DIR/"
37+
cp -r "$PROJECT_ROOT/repositories" "$BUILD_DIR/"
38+
39+
# Create zip
40+
echo "Creating zip archive..."
41+
cd "$BUILD_DIR"
42+
rm -f "$OUTPUT_FILE"
43+
zip -r "$OUTPUT_FILE" . -x "*.pyc" -x "__pycache__/*" -x "*.dist-info/*" -x "*.egg-info/*"
44+
45+
# Cleanup
46+
rm -rf "$BUILD_DIR"
47+
48+
echo ""
49+
echo "Lambda package created: $OUTPUT_FILE"
50+
echo "Size: $(du -h "$OUTPUT_FILE" | cut -f1)"

deployment/deploy.sh

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/bin/bash
2+
set -e
3+
4+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5+
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
6+
TERRAFORM_DIR="$PROJECT_ROOT/terraform"
7+
8+
echo "=== Reader Progress AWS Deployment ==="
9+
echo ""
10+
11+
# Check prerequisites
12+
echo "Checking prerequisites..."
13+
14+
if ! command -v terraform &> /dev/null; then
15+
echo "Error: Terraform is not installed"
16+
exit 1
17+
fi
18+
19+
if ! command -v aws &> /dev/null; then
20+
echo "Error: AWS CLI is not installed"
21+
exit 1
22+
fi
23+
24+
if ! aws sts get-caller-identity &> /dev/null; then
25+
echo "Error: AWS credentials not configured"
26+
exit 1
27+
fi
28+
29+
echo "All prerequisites met."
30+
echo ""
31+
32+
# Build Lambda package
33+
echo "Building Lambda package..."
34+
bash "$SCRIPT_DIR/build_lambda.sh"
35+
echo ""
36+
37+
# Initialize Terraform
38+
echo "Initializing Terraform..."
39+
cd "$TERRAFORM_DIR"
40+
terraform init
41+
42+
echo ""
43+
echo "Planning deployment..."
44+
terraform plan
45+
46+
echo ""
47+
read -p "Do you want to apply these changes? (yes/no): " confirm
48+
if [ "$confirm" != "yes" ]; then
49+
echo "Deployment cancelled."
50+
exit 0
51+
fi
52+
53+
echo ""
54+
echo "Applying changes..."
55+
terraform apply -auto-approve
56+
57+
echo ""
58+
echo "=== Deployment Complete ==="
59+
echo ""
60+
echo "Lambda function deployed. Now update nullspace-website to add API Gateway routes."
61+
echo ""
62+
echo "Outputs:"
63+
terraform output
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copy this file to terraform/terraform.tfvars and fill in the values
2+
3+
# AWS region for deployment
4+
aws_region = "us-east-1"
5+
6+
# Project name (used as prefix for all resources)
7+
project_name = "reader-progress"
8+
9+
# Environment (dev, staging, prod)
10+
environment = "prod"
11+
12+
# Password salt for bcrypt hashing (keep this secret!)
13+
# Generate with: openssl rand -hex 32
14+
password_salt = "your-secure-random-salt-here"

lambda_handler.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import os
2+
3+
# Set DynamoDB backend before importing app
4+
os.environ.setdefault("DB_BACKEND", "dynamodb")
5+
6+
from mangum import Mangum
7+
from main import app
8+
9+
# Configure root_path for API Gateway path prefix (/reader)
10+
# This ensures FastAPI routes work correctly when accessed via /reader/...
11+
app.root_path = "/reader"
12+
13+
# Mangum wraps the FastAPI ASGI app for Lambda
14+
handler = Mangum(app, lifespan="off")

0 commit comments

Comments
 (0)