diff --git a/.gitignore b/.gitignore index c592366..fbc8e84 100755 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,7 @@ Thumbs.db .vscode flask_config.py config.py +!app/config.py # config.cfg # users.cfg diff --git a/Dockerfile b/Dockerfile index 4301344..830ea4e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,11 @@ RUN pip install --no-cache-dir -r /tmp/requirements.txt # Create a reference of actual installed versions 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. CMD ["gunicorn", "--conf", "/conf/gunicorn_fastapi_conf.py"] diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..85224f6 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/routers/health.py b/app/routers/health.py new file mode 100644 index 0000000..cd987a3 --- /dev/null +++ b/app/routers/health.py @@ -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) diff --git a/app/routers/registry.py b/app/routers/registry.py index ee9ec39..730e93e 100644 --- a/app/routers/registry.py +++ b/app/routers/registry.py @@ -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)]) diff --git a/documentation/TODO__Agents.md b/documentation/TODO__Agents.md index dd95be8..c3868df 100644 --- a/documentation/TODO__Agents.md +++ b/documentation/TODO__Agents.md @@ -8,8 +8,8 @@ - [x] **Unified Orchestration:** API now builds as part of the `aether_container_env` stack. ## 📋 Operational Hardening (Next Steps) -- [ ] **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] **Healthcheck:** Implement `/health` route to verify DB/Redis status for Docker orchestration. +- [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. ## 📋 Feature Tasks diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..a778292 --- /dev/null +++ b/requirements.lock @@ -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