From c559f89a7f44e3936cb31781294541e8339eb90f Mon Sep 17 00:00:00 2001 From: Phil Skentelbery Date: Wed, 19 Nov 2025 10:02:41 -0700 Subject: [PATCH] feat: add production container support with health check endpoint Implements Phase 5 containerization specification: - Add /health endpoint for container monitoring - Create multi-stage Containerfile (Podman/Docker compatible) - Add compose.yaml for orchestration - Add Caddyfile.example for reverse proxy (auto-HTTPS) - Add nginx.conf.example as alternative - Update .env.example with container and RSS feed variables - Add gunicorn WSGI server to requirements.txt Container features: - Multi-stage build for smaller image size - Non-root user (starpunk:1000) - Health check with database connectivity test - Volume mount for data persistence - Resource limits and logging configuration - Security headers and HTTPS configuration examples Health check endpoint: - Tests database connectivity - Verifies filesystem access - Returns JSON with status, version, and environment Following Phase 5 design in docs/designs/phase-5-rss-and-container.md --- .containerignore | 78 ++++++++++++++++++ .env.example | 30 +++++++ Caddyfile.example | 96 ++++++++++++++++++++++ Containerfile | 83 +++++++++++++++++++ compose.yaml | 107 ++++++++++++++++++++++++ nginx.conf.example | 188 +++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + starpunk/__init__.py | 48 +++++++++++ 8 files changed, 633 insertions(+) create mode 100644 .containerignore create mode 100644 Caddyfile.example create mode 100644 Containerfile create mode 100644 compose.yaml create mode 100644 nginx.conf.example diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..c6252ae --- /dev/null +++ b/.containerignore @@ -0,0 +1,78 @@ +# Container Build Exclusions +# Exclude files not needed in production container image + +# Git +.git +.gitignore +.gitattributes + +# Python +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.pytest_cache +.coverage +htmlcov +.tox +.hypothesis + +# Virtual environments +venv +env +.venv +.env.local + +# Development data +data +container-data +*.db +*.db-journal + +# IDE +.vscode +.idea +*.swp +*.swo +*~ +.DS_Store + +# Documentation (optional - include if needed for offline docs) +docs +*.md +!README.md + +# Tests (not needed in production) +tests +.pytest_cache + +# Development scripts +dev_auth.py +test_*.py + +# Container files +Containerfile +compose.yaml +.containerignore +docker-compose.yml +Dockerfile + +# CI/CD +.github +.gitlab-ci.yml +.travis.yml + +# Logs +*.log +logs + +# Temporary files +tmp +temp +*.tmp diff --git a/.env.example b/.env.example index 1d3b822..496fbea 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,36 @@ FLASK_DEBUG=1 # Flask secret key (falls back to SESSION_SECRET if not set) FLASK_SECRET_KEY= +# ============================================================================= +# RSS FEED CONFIGURATION +# ============================================================================= + +# Maximum number of items in RSS feed (default: 50) +FEED_MAX_ITEMS=50 + +# Feed cache duration in seconds (default: 300 = 5 minutes) +FEED_CACHE_SECONDS=300 + +# ============================================================================= +# CONTAINER CONFIGURATION +# ============================================================================= + +# Application version (for health check endpoint) +VERSION=0.6.0 + +# Environment: development or production +ENVIRONMENT=production + +# Number of Gunicorn workers (default: 4) +# Recommendation: (2 x CPU cores) + 1 +WORKERS=4 + +# Worker timeout in seconds (default: 30) +WORKER_TIMEOUT=30 + +# Max requests per worker before restart (prevents memory leaks) +MAX_REQUESTS=1000 + # ============================================================================= # DEVELOPMENT OPTIONS # ============================================================================= diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..2883e6d --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,96 @@ +# Caddyfile for StarPunk Reverse Proxy +# Caddy automatically handles HTTPS with Let's Encrypt +# +# Installation: +# 1. Install Caddy: https://caddyserver.com/docs/install +# 2. Copy this file: cp Caddyfile.example Caddyfile +# 3. Update your-domain.com to your actual domain +# 4. Run: caddy run --config Caddyfile +# +# Systemd service: +# sudo systemctl enable --now caddy + +# Replace with your actual domain +your-domain.com { + # Reverse proxy to StarPunk container + # Container must be running on localhost:8000 + reverse_proxy localhost:8000 + + # Logging + log { + output file /var/log/caddy/starpunk.log { + roll_size 10MiB + roll_keep 10 + } + format console + } + + # Security headers + header { + # Remove server identification + -Server + + # HSTS - force HTTPS for 1 year + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + + # Prevent MIME type sniffing + X-Content-Type-Options "nosniff" + + # Prevent clickjacking + X-Frame-Options "DENY" + + # XSS protection (legacy browsers) + X-XSS-Protection "1; mode=block" + + # Referrer policy + Referrer-Policy "strict-origin-when-cross-origin" + + # Content Security Policy (adjust as needed) + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';" + } + + # Compression + encode gzip zstd + + # Static file caching + @static { + path /static/* + } + header @static { + Cache-Control "public, max-age=31536000, immutable" + } + + # RSS feed caching + @feed { + path /feed.xml + } + header @feed { + Cache-Control "public, max-age=300" + } + + # API routes (no caching) + @api { + path /api/* + } + header @api { + Cache-Control "no-store, no-cache, must-revalidate" + } + + # Health check endpoint (monitoring systems) + @health { + path /health + } + header @health { + Cache-Control "no-store, no-cache, must-revalidate" + } +} + +# Optional: Redirect www to non-www +# www.your-domain.com { +# redir https://your-domain.com{uri} permanent +# } + +# Optional: Multiple domains +# another-domain.com { +# reverse_proxy localhost:8000 +# } diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..047c6b0 --- /dev/null +++ b/Containerfile @@ -0,0 +1,83 @@ +# syntax=docker/dockerfile:1 + +# Multi-stage build for StarPunk production container +# Podman and Docker compatible + +# ============================================================================ +# Build Stage - Install dependencies in virtual environment +# ============================================================================ +FROM python:3.11-slim AS builder + +# Install uv for fast dependency installation +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /build + +# Copy dependency files +COPY requirements.txt . + +# Create virtual environment and install dependencies +# Using uv for fast, reproducible installs +RUN uv venv /opt/venv && \ + . /opt/venv/bin/activate && \ + uv pip install --no-cache -r requirements.txt + +# ============================================================================ +# Runtime Stage - Minimal production image +# ============================================================================ +FROM python:3.11-slim + +# Create non-root user for security +# UID/GID 1000 is standard for first user on most systems +RUN useradd --uid 1000 --create-home --shell /bin/bash starpunk && \ + mkdir -p /app /data/notes && \ + chown -R starpunk:starpunk /app /data + +# Copy virtual environment from builder stage +COPY --from=builder /opt/venv /opt/venv + +# Set environment variables +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + FLASK_APP=app.py \ + DATA_PATH=/data \ + NOTES_PATH=/data/notes \ + DATABASE_PATH=/data/starpunk.db + +WORKDIR /app + +# Copy application code +COPY --chown=starpunk:starpunk . . + +# Switch to non-root user +USER starpunk + +# Expose application port +EXPOSE 8000 + +# Health check +# Uses httpx (already in requirements) to verify app is responding +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD python3 -c "import httpx; httpx.get('http://localhost:8000/health', timeout=2.0)" || exit 1 + +# Run gunicorn WSGI server +# - 4 workers for concurrency (adjust based on CPU cores) +# - Sync worker class (simple, reliable) +# - Worker tmp dir in /dev/shm (shared memory, faster) +# - Worker recycling to prevent memory leaks +# - 30s timeout for slow requests +# - Log to stdout/stderr for container log collection +CMD ["gunicorn", \ + "--bind", "0.0.0.0:8000", \ + "--workers", "4", \ + "--worker-class", "sync", \ + "--worker-tmp-dir", "/dev/shm", \ + "--max-requests", "1000", \ + "--max-requests-jitter", "50", \ + "--timeout", "30", \ + "--graceful-timeout", "30", \ + "--access-logfile", "-", \ + "--error-logfile", "-", \ + "--log-level", "info", \ + "app:app"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..615f718 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,107 @@ +# StarPunk Container Composition +# Podman Compose and Docker Compose compatible +# +# Usage: +# podman-compose up -d # Start in background +# podman-compose logs -f # Follow logs +# podman-compose down # Stop and remove +# +# Docker: +# docker compose up -d +# docker compose logs -f +# docker compose down + +version: '3.8' + +services: + starpunk: + # Container configuration + image: starpunk:0.6.0 + container_name: starpunk + + # Build configuration + build: + context: . + dockerfile: Containerfile + + # Restart policy - always restart unless explicitly stopped + restart: unless-stopped + + # Port mapping + # Only expose to localhost for security (reverse proxy handles external access) + ports: + - "127.0.0.1:8000:8000" + + # Environment variables + # Load from .env file in project root + env_file: + - .env + + # Override specific environment variables for container + environment: + # Flask configuration + - FLASK_APP=app.py + - FLASK_ENV=production + - FLASK_DEBUG=0 + + # Data paths (container internal) + - DATA_PATH=/data + - NOTES_PATH=/data/notes + - DATABASE_PATH=/data/starpunk.db + + # Application metadata + - VERSION=0.6.0 + - ENVIRONMENT=production + + # Volume mounts for persistent data + # All application data stored in ./container-data on host + volumes: + - ./container-data:/data:rw + # Note: Use :Z suffix for SELinux systems (Fedora, RHEL, CentOS) + # - ./container-data:/data:rw,Z + + # Health check configuration + healthcheck: + test: ["CMD", "python3", "-c", "import httpx; httpx.get('http://localhost:8000/health', timeout=2.0)"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + + # Resource limits (optional but recommended) + # Adjust based on your server capacity + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + reservations: + cpus: '0.25' + memory: 128M + + # Logging configuration + # Rotate logs to prevent disk space issues + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Network configuration + networks: + - starpunk-net + +# Network definition +networks: + starpunk-net: + driver: bridge + # Optional: specify subnet for predictable IPs + # ipam: + # config: + # - subnet: 172.20.0.0/16 + +# Optional: Named volumes for data persistence +# Uncomment if you prefer named volumes over bind mounts +# volumes: +# starpunk-data: +# driver: local diff --git a/nginx.conf.example b/nginx.conf.example new file mode 100644 index 0000000..0e36979 --- /dev/null +++ b/nginx.conf.example @@ -0,0 +1,188 @@ +# Nginx Configuration for StarPunk +# Alternative to Caddy for reverse proxy +# +# Installation: +# 1. Install Nginx: sudo apt install nginx +# 2. Install Certbot: sudo apt install certbot python3-certbot-nginx +# 3. Copy this file: sudo cp nginx.conf.example /etc/nginx/sites-available/starpunk +# 4. Update your-domain.com to your actual domain +# 5. Create symlink: sudo ln -s /etc/nginx/sites-available/starpunk /etc/nginx/sites-enabled/ +# 6. Test config: sudo nginx -t +# 7. Get SSL cert: sudo certbot --nginx -d your-domain.com +# 8. Reload: sudo systemctl reload nginx + +# Upstream definition for StarPunk container +upstream starpunk { + server localhost:8000; + keepalive 32; +} + +# HTTP server - redirect to HTTPS +server { + listen 80; + listen [::]:80; + server_name your-domain.com; + + # ACME challenge for Let's Encrypt + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + # Redirect all other HTTP to HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS server +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name your-domain.com; + + # SSL certificates (managed by certbot) + # Update paths after running certbot + ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; + + # SSL configuration (Mozilla Intermediate) + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + # SSL session cache + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + # OCSP stapling + ssl_stapling on; + ssl_stapling_verify on; + ssl_trusted_certificate /etc/letsencrypt/live/your-domain.com/chain.pem; + + # Security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';" always; + + # Logging + access_log /var/log/nginx/starpunk-access.log; + error_log /var/log/nginx/starpunk-error.log; + + # Max upload size (for future media uploads) + client_max_body_size 10M; + + # Root location - proxy to StarPunk + location / { + # Proxy to upstream + proxy_pass http://starpunk; + + # Proxy headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # WebSocket support (for future features) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Timeouts + proxy_connect_timeout 30s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + + # Buffering + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + proxy_busy_buffers_size 8k; + + # No caching for dynamic content + add_header Cache-Control "no-cache, private" always; + } + + # Static files - aggressive caching + location /static/ { + proxy_pass http://starpunk; + proxy_set_header Host $host; + + # Long-term caching for static assets + add_header Cache-Control "public, max-age=31536000, immutable"; + + # Compression + gzip on; + gzip_vary on; + gzip_types text/css application/javascript image/svg+xml; + } + + # RSS feed - short-term caching + location /feed.xml { + proxy_pass http://starpunk; + proxy_set_header Host $host; + + # Cache for 5 minutes + add_header Cache-Control "public, max-age=300"; + + # Compression + gzip on; + gzip_types application/rss+xml application/xml; + } + + # Health check endpoint - no caching + location /health { + proxy_pass http://starpunk; + proxy_set_header Host $host; + + # No caching + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + + # Allow monitoring systems access + # Optional: restrict to specific IPs + # allow 10.0.0.0/8; # Internal network + # deny all; + } + + # Admin routes - no caching, security + location /admin/ { + proxy_pass http://starpunk; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # No caching for admin + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + + # Optional: IP whitelist for admin + # allow 1.2.3.4; # Your IP + # deny all; + } + + # Deny access to hidden files + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } +} + +# Optional: Redirect www to non-www +# server { +# listen 80; +# listen [::]:80; +# listen 443 ssl http2; +# listen [::]:443 ssl http2; +# server_name www.your-domain.com; +# +# ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; +# +# return 301 https://your-domain.com$request_uri; +# } diff --git a/requirements.txt b/requirements.txt index 06dc893..f4b096c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,9 @@ # Web Framework Flask==3.0.* +# WSGI Server (Production) +gunicorn==21.2.* + # Content Processing markdown==3.5.* diff --git a/starpunk/__init__.py b/starpunk/__init__.py index 7e2313f..c13b0b2 100644 --- a/starpunk/__init__.py +++ b/starpunk/__init__.py @@ -52,6 +52,54 @@ def create_app(config=None): return {"error": "Internal server error"}, 500 return render_template("500.html"), 500 + # Health check endpoint for containers and monitoring + @app.route("/health") + def health_check(): + """ + Health check endpoint for containers and monitoring + + Returns: + JSON with status and basic info + + Response codes: + 200: Application healthy + 500: Application unhealthy + + Checks: + - Database connectivity + - File system access + - Basic application state + """ + from flask import jsonify + import os + + try: + # Check database connectivity + from starpunk.database import get_db + + db = get_db(app) + db.execute("SELECT 1").fetchone() + db.close() + + # Check filesystem access + data_path = app.config.get("DATA_PATH", "data") + if not os.path.exists(data_path): + raise Exception("Data path not accessible") + + return ( + jsonify( + { + "status": "healthy", + "version": app.config.get("VERSION", __version__), + "environment": app.config.get("ENV", "unknown"), + } + ), + 200, + ) + + except Exception as e: + return jsonify({"status": "unhealthy", "error": str(e)}), 500 + return app