feat: project skeleton

- infra (k8s, kind, helm, docker) backbone is implemented
- security: implementation + unit tests are done
This commit is contained in:
2025-11-21 12:00:00 -05:00
commit fcce32bca9
46 changed files with 3468 additions and 0 deletions

0
app/__init__.py Normal file
View File

0
app/api/__init__.py Normal file
View File

0
app/api/v1/__init__.py Normal file
View File

46
app/api/v1/health.py Normal file
View File

@@ -0,0 +1,46 @@
"""Health check endpoints."""
from fastapi import APIRouter, Response, status
from app.db import db, redis_client
router = APIRouter()
@router.get("/healthz")
async def healthz() -> dict[str, str]:
"""Liveness probe - returns 200 if the service is running."""
return {"status": "ok"}
@router.get("/readyz")
async def readyz(response: Response) -> dict[str, str | dict[str, bool]]:
"""
Readiness probe - checks database and Redis connectivity.
- Check Postgres status
- Check Redis status
- Return overall healthiness
"""
checks = {
"postgres": False,
"redis": False,
}
try:
if db.pool:
async with db.connection() as conn:
await conn.fetchval("SELECT 1")
checks["postgres"] = True
except Exception:
pass
checks["redis"] = await redis_client.ping()
all_healthy = all(checks.values())
if not all_healthy:
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {
"status": "ok" if all_healthy else "degraded",
"checks": checks,
}

34
app/config.py Normal file
View File

@@ -0,0 +1,34 @@
"""Application configuration via pydantic-settings."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Database
database_url: str
# Redis
redis_url: str = "redis://localhost:6379/0"
# JWT
jwt_secret_key: str
jwt_algorithm: str = "HS256"
jwt_issuer: str = "incidentops"
jwt_audience: str = "incidentops-api"
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 30
# Application
debug: bool = False
api_v1_prefix: str = "/v1"
settings = Settings()

0
app/core/__init__.py Normal file
View File

59
app/core/exceptions.py Normal file
View File

@@ -0,0 +1,59 @@
"""Custom HTTP exceptions for the API."""
from fastapi import HTTPException, status
class NotFoundError(HTTPException):
"""Resource not found."""
def __init__(self, detail: str = "Resource not found") -> None:
super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
class ConflictError(HTTPException):
"""Conflict with current state (e.g., version mismatch)."""
def __init__(self, detail: str = "Conflict with current state") -> None:
super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail)
class UnauthorizedError(HTTPException):
"""Authentication required or failed."""
def __init__(self, detail: str = "Not authenticated") -> None:
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers={"WWW-Authenticate": "Bearer"},
)
class ForbiddenError(HTTPException):
"""Insufficient permissions."""
def __init__(self, detail: str = "Insufficient permissions") -> None:
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
class BadRequestError(HTTPException):
"""Invalid request data."""
def __init__(self, detail: str = "Invalid request") -> None:
super().__init__(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
class ValidationError(HTTPException):
"""Validation failed."""
def __init__(self, detail: str = "Validation failed") -> None:
super().__init__(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
__all__ = [
"BadRequestError",
"ConflictError",
"ForbiddenError",
"NotFoundError",
"UnauthorizedError",
"ValidationError",
]

106
app/core/security.py Normal file
View File

@@ -0,0 +1,106 @@
"""Security utilities for JWT and password hashing."""
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID, uuid4
import bcrypt
from jose import JWTError, jwt
from app.config import settings
def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash."""
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
def create_access_token(
sub: str,
org_id: str,
org_role: str,
expires_delta: timedelta | None = None,
) -> str:
"""Create a JWT access token with org context."""
if expires_delta is None:
expires_delta = timedelta(minutes=settings.access_token_expire_minutes)
now = datetime.now(UTC)
expire = now + expires_delta
payload = {
"sub": sub,
"org_id": org_id,
"org_role": org_role,
"iss": settings.jwt_issuer,
"aud": settings.jwt_audience,
"jti": str(uuid4()),
"iat": now,
"exp": expire,
}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict[str, Any]:
"""Decode and validate a JWT access token.
Raises:
JWTError: If token is invalid or expired.
"""
return jwt.decode(
token,
settings.jwt_secret_key,
algorithms=[settings.jwt_algorithm],
issuer=settings.jwt_issuer,
audience=settings.jwt_audience,
)
def generate_refresh_token() -> str:
"""Generate a secure random refresh token."""
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
"""Hash a refresh token for storage."""
return hashlib.sha256(token.encode()).hexdigest()
def get_refresh_token_expiry() -> datetime:
"""Get expiry datetime for a new refresh token."""
return datetime.now(UTC) + timedelta(days=settings.refresh_token_expire_days)
class TokenPayload:
"""Parsed JWT token payload."""
def __init__(self, payload: dict[str, Any]) -> None:
self.user_id = UUID(payload["sub"])
self.org_id = UUID(payload["org_id"])
self.org_role = payload["org_role"]
self.issuer = payload["iss"]
self.audience = payload["aud"]
self.jti = UUID(payload["jti"])
self.issued_at = payload["iat"]
self.expires_at = payload["exp"]
__all__ = [
"JWTError",
"TokenPayload",
"create_access_token",
"decode_access_token",
"generate_refresh_token",
"get_refresh_token_expiry",
"hash_password",
"hash_token",
"verify_password",
]

80
app/db.py Normal file
View File

@@ -0,0 +1,80 @@
"""Database connection management using asyncpg."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import asyncpg
import redis.asyncio as redis
class Database:
"""Manages asyncpg connection pool."""
pool: asyncpg.Pool | None = None
async def connect(self, dsn: str) -> None:
"""Create connection pool."""
self.pool = await asyncpg.create_pool(
dsn,
min_size=5,
max_size=20,
command_timeout=60,
)
async def disconnect(self) -> None:
"""Close connection pool."""
if self.pool:
await self.pool.close()
@asynccontextmanager
async def connection(self) -> AsyncGenerator[asyncpg.Connection, None]:
"""Acquire a connection from the pool."""
if not self.pool:
raise RuntimeError("Database not connected")
async with self.pool.acquire() as conn:
yield conn
@asynccontextmanager
async def transaction(self) -> AsyncGenerator[asyncpg.Connection, None]:
"""Acquire a connection with an active transaction."""
if not self.pool:
raise RuntimeError("Database not connected")
async with self.pool.acquire() as conn:
async with conn.transaction():
yield conn
class RedisClient:
"""Manages Redis connection."""
client: redis.Redis | None = None
async def connect(self, url: str) -> None:
"""Create Redis connection."""
self.client = redis.from_url(url, decode_responses=True)
async def disconnect(self) -> None:
"""Close Redis connection."""
if self.client:
await self.client.aclose()
async def ping(self) -> bool:
"""Check if Redis is reachable."""
if not self.client:
return False
try:
await self.client.ping()
return True
except redis.RedisError:
return False
# Global instances
db = Database()
redis_client = RedisClient()
async def get_conn() -> AsyncGenerator[asyncpg.Connection, None]:
"""Dependency for getting a database connection."""
async with db.connection() as conn:
yield conn

33
app/main.py Normal file
View File

@@ -0,0 +1,33 @@
"""FastAPI application entry point."""
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI
from app.api.v1 import health
from app.config import settings
from app.db import db, redis_client
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Manage application lifecycle - connect/disconnect resources."""
# Startup
await db.connect(settings.database_url)
await redis_client.connect(settings.redis_url)
yield
# Shutdown
await redis_client.disconnect()
await db.disconnect()
app = FastAPI(
title="IncidentOps",
description="Incident management API with multi-tenant org support",
version="0.1.0",
lifespan=lifespan,
)
# Include routers
app.include_router(health.router, prefix=settings.api_v1_prefix, tags=["health"])