feat: Operational hardening — healthcheck, config refactor, requirements lock

- Add GET /health route (DB + Redis ping, 200/503) with Dockerfile HEALTHCHECK directive
- Replace config.py stub with real pydantic BaseSettings reading directly from env vars;
  remove external config file mount from docker-compose
- Add requirements.lock (pip freeze snapshot for bit-identical builds)
- Untrack config.py globally but allow app/config.py via .gitignore negation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-10 18:44:58 -04:00
parent d35f374a45
commit 32560d2257
7 changed files with 242 additions and 4 deletions

109
app/config.py Normal file
View File

@@ -0,0 +1,109 @@
# Configuration for the Aether FastAPI application.
# All settings are read directly from environment variables (injected by Docker via .env).
# Previously this file was mounted from aether_container_env/conf/aether_api_config.py.
from pydantic import BaseSettings, Field
from typing import Any, Dict, List
class Settings(BaseSettings):
# --- Application ---
APP_NAME: str = "Aether API (FastAPI)"
# --- Aether Shared Config (DB-driven bootstrap) ---
AE_CFG_ID: int = Field(0, env='AE_CFG_ID')
# --- JWT ---
JWT_KEY: str = Field('EHmSXZFKfMEW65E8kxCKmQ', env='AE_API_JWT_KEY')
# --- Database ---
# These flat fields are mutated by the bootstrap process in main.py (lifespan),
# which swaps in production credentials after reading from the cfg table.
DB_SERVER: str = Field('mariadb', env='AE_DB_SERVER')
DB_PORT: str = Field('3306', env='AE_DB_PORT')
DB_NAME: str = Field('aether_dev', env='AE_DB_NAME')
DB_USER: str = Field('aether_dev', env='AE_DB_USERNAME')
DB_PASS: str = Field('', env='AE_DB_PASSWORD')
# Connection tuning
DB_CONNECT_TIMEOUT: int = Field(20, env='AE_DB_CONNECTION_TIMEOUT')
DB_POOL_RECYCLE: int = Field(1800, env='AE_DB_POOL_RECYCLE')
# --- Logging ---
LOG_PATH_APP: str = Field('/logs/aether_api.log', env='AE_API_LOG_PATH')
# --- Redis ---
REDIS_SERVER: str = Field('redis', env='AE_REDIS_SERVER')
REDIS_PORT: str = Field('6379', env='AE_REDIS_PORT')
# --- SMTP ---
SMTP_SERVER: str = Field('linode.oneskyit.com', env='AE_SMTP_SERVER')
SMTP_PORT: str = Field('465', env='AE_SMTP_PORT')
SMTP_USERNAME: str = Field('send_mail', env='AE_SMTP_USERNAME')
SMTP_PASSWORD: str = Field('set-in-ae-sql-db-cnf-tbl', env='AE_SMTP_PASSWORD')
# --- File Storage ---
FILES_PATH_ROOT: str = Field('/srv/hosted_files', env='AE_FILES_PATH_ROOT')
FILES_PATH_TMP: str = Field('/srv/hosted_tmp', env='AE_FILES_PATH_TMP')
# --- CORS ---
ORIGINS_REGEX: str = Field(
r'(https://.*\.oneskyit\.com)|(https://.*\.oneskyit\.com:4443)',
env='AE_API_ORIGINS_REGEX'
)
ORIGINS: List[str] = ['https://oneskyit.com']
# -------------------------------------------------------------------------
# Computed properties — maintain backwards-compatible dict interface used
# throughout the app (e.g. settings.DB['server'], settings.REDIS['port']).
# -------------------------------------------------------------------------
@property
def AETHER_CFG(self) -> Dict[str, Any]:
return {'id': self.AE_CFG_ID}
@property
def SQLALCHEMY_DB_URI(self) -> str:
return f"mysql://{self.DB_USER}:{self.DB_PASS}@{self.DB_SERVER}:{self.DB_PORT}/{self.DB_NAME}"
@property
def DB(self) -> Dict[str, Any]:
return {
'server': self.DB_SERVER,
'port': self.DB_PORT,
'name': self.DB_NAME,
'username': self.DB_USER,
'password': self.DB_PASS,
'connect_timeout': self.DB_CONNECT_TIMEOUT,
'pool_recycle': self.DB_POOL_RECYCLE,
}
@property
def LOG_PATH(self) -> Dict[str, str]:
return {'app': self.LOG_PATH_APP}
@property
def REDIS(self) -> Dict[str, str]:
return {'server': self.REDIS_SERVER, 'port': self.REDIS_PORT}
@property
def SMTP(self) -> Dict[str, str]:
return {
'server': self.SMTP_SERVER,
'port': self.SMTP_PORT,
'username': self.SMTP_USERNAME,
'password': self.SMTP_PASSWORD,
}
@property
def FILES_PATH(self) -> Dict[str, str]:
return {
'hosted_files_root': self.FILES_PATH_ROOT,
'hosted_tmp_root': self.FILES_PATH_TMP,
}
class Config:
case_sensitive = True
settings = Settings()

42
app/routers/health.py Normal file
View File

@@ -0,0 +1,42 @@
"""
Health check endpoint for Docker orchestration.
Verifies DB and Redis connectivity.
"""
import logging
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from sqlalchemy import text
log = logging.getLogger(__name__)
router = APIRouter()
@router.get('/health', tags=['Root'], include_in_schema=True)
async def health_check():
"""
Checks liveness of the DB and Redis connections.
Returns 200 if both are reachable, 503 otherwise.
"""
status = {'db': False, 'redis': False}
# --- DB check ---
try:
from app.lib_sql_core import engine
with engine.connect() as conn:
conn.execute(text('SELECT 1'))
status['db'] = True
except Exception as e:
log.error(f'Health check: DB ping failed: {e}')
# --- Redis check ---
try:
from app.lib_redis_helpers import redis_client
redis_client.ping()
status['redis'] = True
except Exception as e:
log.error(f'Health check: Redis ping failed: {e}')
all_ok = all(status.values())
http_status = 200 if all_ok else 503
return JSONResponse(content={'status': 'ok' if all_ok else 'degraded', **status}, status_code=http_status)

View File

@@ -1,7 +1,7 @@
from fastapi import FastAPI, Depends
from app.routers.dependencies_v3 import DeprecationParams
from app.routers import (
ae_obj, aether_cfg, api_crud, api_crud_v2, api_crud_v3, api, importing, sql,
ae_obj, aether_cfg, api_crud, api_crud_v2, api_crud_v3, api, health, importing, sql,
account, contact, data_store,
event, event_badge, event_badge_importing, event_badge_template,
event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing,
@@ -17,6 +17,7 @@ def setup_routers(app: FastAPI):
"""
Registers all application routers with their respective prefixes and tags.
"""
app.include_router(health.router, tags=['Root'])
app.include_router(ae_obj.router, prefix='/ae_obj', tags=['AE Object'])
app.include_router(aether_cfg.router, tags=['Aether Config'])
# app.include_router(api_crud.router, prefix='/crud', tags=['CRUD v1.2 (Legacy)'], dependencies=[Depends(DeprecationParams)])