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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -130,6 +130,7 @@ Thumbs.db
|
|||||||
.vscode
|
.vscode
|
||||||
flask_config.py
|
flask_config.py
|
||||||
config.py
|
config.py
|
||||||
|
!app/config.py
|
||||||
# config.cfg
|
# config.cfg
|
||||||
# users.cfg
|
# users.cfg
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
|||||||
# Create a reference of actual installed versions
|
# Create a reference of actual installed versions
|
||||||
RUN pip freeze >> /tmp/aether_fastapi_requirements_current.txt
|
RUN pip freeze >> /tmp/aether_fastapi_requirements_current.txt
|
||||||
|
|
||||||
# The application source is mounted as a volume in docker-compose.yml
|
# Docker health check — verifies DB + Redis connectivity via the /health route.
|
||||||
|
# Interval/timeout tuned for Gunicorn startup time.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -f http://localhost/health || exit 1
|
||||||
|
|
||||||
|
# The application source is mounted as a volume in docker-compose.yml
|
||||||
# for real-time development, but we set the default command here.
|
# for real-time development, but we set the default command here.
|
||||||
CMD ["gunicorn", "--conf", "/conf/gunicorn_fastapi_conf.py"]
|
CMD ["gunicorn", "--conf", "/conf/gunicorn_fastapi_conf.py"]
|
||||||
|
|||||||
109
app/config.py
Normal file
109
app/config.py
Normal 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
42
app/routers/health.py
Normal 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)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import FastAPI, Depends
|
from fastapi import FastAPI, Depends
|
||||||
from app.routers.dependencies_v3 import DeprecationParams
|
from app.routers.dependencies_v3 import DeprecationParams
|
||||||
from app.routers import (
|
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,
|
account, contact, data_store,
|
||||||
event, event_badge, event_badge_importing, event_badge_template,
|
event, event_badge, event_badge_importing, event_badge_template,
|
||||||
event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing,
|
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.
|
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(ae_obj.router, prefix='/ae_obj', tags=['AE Object'])
|
||||||
app.include_router(aether_cfg.router, tags=['Aether Config'])
|
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)])
|
# app.include_router(api_crud.router, prefix='/crud', tags=['CRUD v1.2 (Legacy)'], dependencies=[Depends(DeprecationParams)])
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
- [x] **Unified Orchestration:** API now builds as part of the `aether_container_env` stack.
|
- [x] **Unified Orchestration:** API now builds as part of the `aether_container_env` stack.
|
||||||
|
|
||||||
## 📋 Operational Hardening (Next Steps)
|
## 📋 Operational Hardening (Next Steps)
|
||||||
- [ ] **Healthcheck:** Implement `/health` route to verify DB/Redis status for Docker orchestration.
|
- [x] **Healthcheck:** Implement `/health` route to verify DB/Redis status for Docker orchestration.
|
||||||
- [ ] **Config Refactor:** Switch `app/config.py` to `pydantic-settings` to use direct Env Vars (Stop mounting config files).
|
- [x] **Config Refactor:** Switch `app/config.py` to `pydantic-settings` to use direct Env Vars (Stop mounting config files).
|
||||||
- [ ] **Locking:** Generate a `requirements.lock` for bit-identical builds.
|
- [ ] **Locking:** Generate a `requirements.lock` for bit-identical builds.
|
||||||
|
|
||||||
## 📋 Feature Tasks
|
## 📋 Feature Tasks
|
||||||
|
|||||||
80
requirements.lock
Normal file
80
requirements.lock
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
aiofiles==25.1.0
|
||||||
|
annotated-doc==0.0.4
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.12.0
|
||||||
|
argon2-cffi==25.1.0
|
||||||
|
argon2-cffi-bindings==25.1.0
|
||||||
|
certifi==2025.11.12
|
||||||
|
cffi==2.0.0
|
||||||
|
charset-normalizer==3.4.5
|
||||||
|
click==8.3.1
|
||||||
|
Deprecated==1.3.1
|
||||||
|
dnspython==2.8.0
|
||||||
|
email-validator==2.3.0
|
||||||
|
et_xmlfile==2.0.0
|
||||||
|
fastapi==0.115.5
|
||||||
|
fastapi-cli==0.0.20
|
||||||
|
fastapi-cloud-cli==0.8.0
|
||||||
|
fastar==0.8.0
|
||||||
|
greenlet==3.3.2
|
||||||
|
gunicorn==23.0.0
|
||||||
|
h11==0.16.0
|
||||||
|
hiredis==3.3.0
|
||||||
|
html2text==2025.4.15
|
||||||
|
httpcore==1.0.9
|
||||||
|
httptools==0.7.1
|
||||||
|
httpx==0.28.1
|
||||||
|
idna==3.11
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
markdown-it-py==4.0.0
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
mdurl==0.1.2
|
||||||
|
mysqlclient==2.2.8
|
||||||
|
numpy==2.4.3
|
||||||
|
openpyxl==3.1.5
|
||||||
|
orjson==3.11.5
|
||||||
|
packaging==25.0
|
||||||
|
pandas==3.0.1
|
||||||
|
passlib==1.7.4
|
||||||
|
pdf2image==1.17.0
|
||||||
|
pillow==12.1.1
|
||||||
|
pycparser==3.0
|
||||||
|
pydantic==1.10.26
|
||||||
|
pydantic-extra-types==2.10.6
|
||||||
|
pydantic-settings==2.12.0
|
||||||
|
pydantic_core==2.41.5
|
||||||
|
Pygments==2.19.2
|
||||||
|
PyJWT==2.11.0
|
||||||
|
pyparsing==3.3.2
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
python-dotenv==1.2.1
|
||||||
|
python-multipart==0.0.21
|
||||||
|
pytz==2026.1.post1
|
||||||
|
PyYAML==6.0.3
|
||||||
|
qrcode==8.2
|
||||||
|
redis==7.3.0
|
||||||
|
requests==2.32.5
|
||||||
|
rfc3986==2.0.0
|
||||||
|
rich==14.2.0
|
||||||
|
rich-toolkit==0.17.1
|
||||||
|
rignore==0.7.6
|
||||||
|
sentry-sdk==2.48.0
|
||||||
|
shellingham==1.5.4
|
||||||
|
six==1.17.0
|
||||||
|
sniffio==1.3.1
|
||||||
|
SQLAlchemy==1.4.52
|
||||||
|
starlette==0.41.3
|
||||||
|
stripe==14.4.1
|
||||||
|
typer==0.21.0
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
ujson==5.11.0
|
||||||
|
urllib3==2.6.2
|
||||||
|
uvicorn==0.40.0
|
||||||
|
uvloop==0.22.1
|
||||||
|
Wand==0.7.0
|
||||||
|
watchfiles==1.1.1
|
||||||
|
websockets==15.0.1
|
||||||
|
wrapt==2.1.2
|
||||||
|
xlrd==2.0.2
|
||||||
Reference in New Issue
Block a user