# FastAPI Ops Portal Backend Pattern

Reference architecture for the FastAPI backend built Jul 9, 2026 at `/opt/ops-portal/server.py`.

## Route ordering (CRITICAL)

FastAPI processes routes in definition order. The route list must be:

1. **API routes first** (`/api/auth/login`, `/api/health`, `/api/status`, ...)
2. **Metrics** (`/metrics`)
3. **Static assets** (`/css/{path}`, `/js/{path}`)
4. **Page routes** (`/`, `/{page}.html`)

If a catch-all `/{page}.html` comes before `/api/*`, ALL API calls return "Page not found".

## Static file serving pattern

```python
# Do NOT use app.mount("/", StaticFiles(...)) — this swallows API routes.
# Instead, serve explicitly:

STATIC_DIR = Path(__file__).parent / "static"

@app.get("/css/{file_path:path}")
async def serve_css(file_path: str):
    fpath = STATIC_DIR / "css" / file_path
    if fpath.exists() and fpath.is_file():
        return FileResponse(str(fpath))
    raise HTTPException(status_code=404, detail="Not found")

@app.get("/js/{file_path:path}")
async def serve_js(file_path: str):
    fpath = STATIC_DIR / "js" / file_path
    if fpath.exists() and fpath.is_file():
        return FileResponse(str(fpath))
    raise HTTPException(status_code=404, detail="Not found")

@app.get("/")
async def serve_index():
    index_path = STATIC_DIR / "index.html"
    if index_path.exists():
        return FileResponse(str(index_path))
    return JSONResponse({"status": "ok", "message": "Ops Portal API ready"})

@app.get("/{page}.html")
async def serve_html_page(page: str):
    fpath = STATIC_DIR / f"{page}.html"
    if fpath.exists():
        return FileResponse(str(fpath))
    raise HTTPException(status_code=404, detail="Page not found")
```

## JWT auth pattern

```python
from jose import jwt, JWTError
from fastapi import Depends, HTTPException, Request as FastAPIRequest
from datetime import datetime, timezone, timedelta

ADMIN_USERNAME = "germaine"
ADMIN_PASSWORD = "..."  # from .env
JWT_SECRET = "..."      # from .env
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24

def create_jwt(username: str) -> str:
    payload = {
        "sub": username,
        "iat": datetime.now(timezone.utc),
        "exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS),
        "jti": secrets.token_hex(16),
    }
    return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)

async def get_current_user(request: FastAPIRequest) -> str:
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
    token = auth[7:]
    payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
    return payload["sub"]

# Usage:
@app.get("/api/status")
async def get_status(user: str = Depends(get_current_user)):
    ...
```

## Audit log pattern (SQLite)

```python
import sqlite3

DB_PATH = "/opt/ops-portal/ops.db"

def get_db():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    return conn

def init_db():
    conn = get_db()
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS audit_log (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            timestamp TEXT NOT NULL DEFAULT (datetime('now')),
            user TEXT NOT NULL,
            action TEXT NOT NULL,
            target TEXT,
            result TEXT NOT NULL,
            detail TEXT
        );
        CREATE TABLE IF NOT EXISTS api_tokens (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user TEXT NOT NULL,
            token_hash TEXT NOT NULL UNIQUE,
            created TEXT NOT NULL DEFAULT (datetime('now')),
            last_used TEXT
        );
    """)
    conn.commit()
    conn.close()

def log_audit(user: str, action: str, target, result: str, detail=None):
    conn = get_db()
    conn.execute(
        "INSERT INTO audit_log (user, action, target, result, detail) VALUES (?, ?, ?, ?, ?)",
        (user, action, target, result, detail)
    )
    conn.commit()
    conn.close()
```

## Service control pattern (systemd)

```python
import subprocess
from fastapi import HTTPException

@app.post("/api/services/{name}/restart")
async def restart_service(name: str, user: str = Depends(get_current_user)):
    svc = name if name.endswith(".service") else f"{name}.service"
    try:
        result = subprocess.run(
            ["systemctl", "restart", svc],
            capture_output=True, text=True, timeout=30
        )
        success = result.returncode == 0
        log_audit(user, "restart_service", name, "ok" if success else "error", result.stderr[:500])
        if success:
            return {"status": "ok", "message": f"Service {name} restarted"}
        return JSONResponse(status_code=500, content={"status": "error", "message": result.stderr[:500]})
    except subprocess.TimeoutExpired:
        raise HTTPException(status_code=504, detail="Service restart timed out")
```

## Prometheus counters

```python
from prometheus_client import Counter, generate_latest, CONTENT_TYPE_LATEST
from fastapi.responses import JSONResponse

HTTP_REQUESTS = Counter("ops_portal_http_requests_total", "Total HTTP requests", ["method", "endpoint"])
SERVICE_ACTIONS = Counter("ops_portal_service_actions_total", "Service actions", ["action", "service"])
BACKUP_TRIGGERS = Counter("ops_portal_backup_triggers_total", "Backup triggers", ["bucket"])

@app.get("/metrics")
async def metrics():
    return JSONResponse(
        content=generate_latest().decode(),
        media_type=CONTENT_TYPE_LATEST
    )
```

## .env file layout

```
# /opt/ops-portal/.env — chmod 600
ADMIN_USERNAME=germaine
ADMIN_PASSWORD=...
JWT_SECRET=...
CLOUDFLARE_API_TOKEN=...
CLOUDFLARE_EMAIL=...
HETZNER_API_TOKEN=...
```

## Systemd service

```ini
[Unit]
Description=ITPP Ops Portal Backend
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/ops-portal
ExecStart=/opt/awscli-venv/bin/uvicorn server:app --host 127.0.0.1 --port 8090
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
EnvironmentFile=/opt/ops-portal/.env

[Install]
WantedBy=multi-user.target
```

## Caddy routing

```caddy
ops.itpropartner.com {
    reverse_proxy 127.0.0.1:8090
    encode gzip
    log {
        output file /var/log/caddy/ops.log
    }
}
```

The entire Caddy block is just a reverse proxy — no static file serving, no API matchers. The FastAPI backend handles everything.
