import os from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional from fastapi import FastAPI from fastapi.routing import APIRoute from loguru import logger from sqlalchemy import inspect, text from services.database import ( WORKSPACE_DIR, get_all_user_ids, get_engine_for_user, get_session_for_user, get_user_db_path, init_database, default_engine, ) from services.user_api_key_context import get_user_api_keys _REQUIRED_SCHEMA: Dict[str, List[str]] = { "onboarding_sessions": ["id", "user_id", "updated_at"], "daily_workflow_plans": ["id", "user_id", "generation_mode", "fallback_used"], } _STARTUP_STATUS: Dict[str, Any] = { "status": "unknown", "mode": "multi_tenant" if default_engine is None else "single_tenant", "checks": [], "errors": [], "warnings": [], "checked_at": None, } def _env_true(name: str, default: bool = False) -> bool: raw = os.getenv(name) if raw is None: return default return raw.strip().lower() in {"1", "true", "yes", "y", "on"} def should_fail_fast() -> bool: if os.getenv("ALWRITY_FAIL_FAST_STARTUP") is not None: return _env_true("ALWRITY_FAIL_FAST_STARTUP", default=False) app_env = os.getenv("APP_ENV", os.getenv("ENV", "")).strip().lower() return app_env in {"prod", "production"} def _record_check(checks: List[Dict[str, Any]], name: str, ok: bool, detail: str) -> None: checks.append({"name": name, "ok": ok, "detail": detail}) def _check_workspace_root(checks: List[Dict[str, Any]], errors: List[str]) -> None: workspace = Path(WORKSPACE_DIR) if not workspace.exists(): errors.append(f"Workspace root does not exist: {workspace}") _record_check(checks, "workspace_root_exists", False, str(workspace)) return _record_check(checks, "workspace_root_exists", True, str(workspace)) if not os.access(workspace, os.W_OK): errors.append(f"Workspace root is not writable: {workspace}") _record_check(checks, "workspace_root_writable", False, str(workspace)) return probe_file = workspace / ".startup_health_write_probe" try: probe_file.write_text("ok", encoding="utf-8") probe_file.unlink(missing_ok=True) _record_check(checks, "workspace_root_writable", True, "write probe passed") except Exception as exc: errors.append(f"Workspace root write probe failed: {exc}") _record_check(checks, "workspace_root_writable", False, f"write probe failed: {exc}") def _check_schema_for_user(user_id: str, checks: List[Dict[str, Any]], errors: List[str]) -> None: engine = get_engine_for_user(user_id) inspector = inspect(engine) for table, columns in _REQUIRED_SCHEMA.items(): if not inspector.has_table(table): errors.append(f"Missing required table '{table}' in tenant DB for user '{user_id}'") _record_check(checks, f"schema_{table}", False, f"table missing for {user_id}") continue existing_columns = {col["name"] for col in inspector.get_columns(table)} missing_columns = [col for col in columns if col not in existing_columns] if missing_columns: errors.append( f"Missing required columns in '{table}' for user '{user_id}': {', '.join(missing_columns)}" ) _record_check( checks, f"schema_{table}", False, f"missing columns for {user_id}: {', '.join(missing_columns)}", ) else: _record_check(checks, f"schema_{table}", True, f"schema ok for {user_id}") def _check_db_access(checks: List[Dict[str, Any]], errors: List[str], warnings: List[str]) -> Optional[str]: if default_engine is not None: try: init_database() with default_engine.connect() as conn: conn.execute(text("SELECT 1")) _record_check(checks, "single_tenant_db_connectivity", True, "SELECT 1 succeeded") return "single_tenant" except Exception as exc: errors.append(f"Single-tenant database check failed: {exc}") _record_check(checks, "single_tenant_db_connectivity", False, str(exc)) return None user_ids = get_all_user_ids() candidate_user = user_ids[0] if user_ids else "startup_synthetic" try: db_path = get_user_db_path(candidate_user) _record_check(checks, "tenant_db_path_resolution", True, f"{candidate_user} -> {db_path}") except Exception as exc: errors.append(f"Tenant DB path resolution failed: {exc}") _record_check(checks, "tenant_db_path_resolution", False, str(exc)) return None try: session = get_session_for_user(candidate_user) if not session: raise RuntimeError("session creation returned None") session.execute(text("SELECT 1")) _record_check(checks, "tenant_session_create", True, f"session opened for {candidate_user}") session.close() except Exception as exc: errors.append(f"Tenant DB open/create check failed for '{candidate_user}': {exc}") _record_check(checks, "tenant_session_create", False, str(exc)) return None if not user_ids: warnings.append( "No existing tenant workspace found during startup; synthetic tenant DB path was used for readiness validation." ) _check_schema_for_user(candidate_user, checks, errors) return candidate_user def _check_production_api_key_loading( checks: List[Dict[str, Any]], errors: List[str], warnings: List[str], ) -> None: deploy_env = os.getenv("DEPLOY_ENV", "local").strip().lower() if deploy_env == "local": _record_check(checks, "production_api_key_loading", True, "skipped in local deploy mode") return # Skip when in feature-limited mode (no production API keys needed) enabled_features = os.getenv("ALWRITY_ENABLED_FEATURES", "all").strip().lower() if enabled_features and enabled_features not in ("", "all"): _record_check(checks, "production_api_key_loading", True, f"skipped in feature-limited mode: {enabled_features}") return test_tenant_id = os.getenv("ALWRITY_STARTUP_TEST_TENANT_ID", "").strip() if not test_tenant_id: message = ( "Missing ALWRITY_STARTUP_TEST_TENANT_ID for production API key startup check." ) errors.append(message) _record_check(checks, "production_api_key_loading", False, message) return try: keys = get_user_api_keys(test_tenant_id) except Exception as exc: errors.append( f"Failed to load API keys for startup test tenant '{test_tenant_id}': {exc}" ) _record_check(checks, "production_api_key_loading", False, str(exc)) return if not isinstance(keys, dict): errors.append( f"API key loader returned invalid payload type for startup test tenant '{test_tenant_id}'." ) _record_check(checks, "production_api_key_loading", False, "invalid payload type") return non_empty_keys = [provider for provider, value in keys.items() if value] if not non_empty_keys: errors.append( f"No API keys could be loaded for startup test tenant '{test_tenant_id}'." ) _record_check(checks, "production_api_key_loading", False, "no non-empty keys loaded") return warning = None if len(non_empty_keys) < len(keys): warning = ( f"Startup test tenant '{test_tenant_id}' has {len(non_empty_keys)}/{len(keys)} non-empty API keys." ) warnings.append(warning) detail = f"loaded {len(non_empty_keys)} non-empty keys for tenant {test_tenant_id}" if warning: detail = f"{detail}; {warning}" _record_check(checks, "production_api_key_loading", True, detail) def _is_demo_mode() -> bool: app_env = os.getenv("APP_ENV", os.getenv("ENV", os.getenv("DEPLOY_ENV", ""))).strip().lower() if app_env == "demo": return True return _env_true("ALWRITY_DEMO_MODE", default=False) def _check_required_demo_routes( app: Optional[FastAPI], checks: List[Dict[str, Any]], errors: List[str], ) -> None: if not _is_demo_mode(): _record_check( checks, "demo_required_routes", True, "Skipped (not in demo mode). Set APP_ENV=demo or ALWRITY_DEMO_MODE=true to enforce.", ) return if app is None: errors.append( "Demo startup route check could not run because FastAPI app context was not provided to startup health routine." ) _record_check(checks, "demo_required_routes_context", False, "missing app context") return required_routes = { "/api/subscription/plans": "GET", "/api/podcast/projects": "GET", } available_routes = { (route.path, method) for route in app.router.routes if isinstance(route, APIRoute) for method in route.methods } missing: List[str] = [] for path, method in required_routes.items(): if (path, method) in available_routes: _record_check(checks, f"demo_route_{path}_{method}", True, "route registered") else: missing.append(f"{method} {path}") _record_check(checks, f"demo_route_{path}_{method}", False, "route missing") if missing: errors.append( "Demo mode startup check failed. Missing required API endpoints: " f"{', '.join(missing)}. Ensure subscription and podcast routers are imported and included during app setup." ) def run_startup_health_routine(app: Optional[FastAPI] = None) -> Dict[str, Any]: checks: List[Dict[str, Any]] = [] errors: List[str] = [] warnings: List[str] = [] _check_workspace_root(checks, errors) if not errors: _check_db_access(checks, errors, warnings) _check_required_demo_routes(app, checks, errors) if not errors: _check_production_api_key_loading(checks, errors, warnings) status = "healthy" if not errors else "failed" report = { "status": status, "mode": "multi_tenant" if default_engine is None else "single_tenant", "checks": checks, "errors": errors, "warnings": warnings, "checked_at": datetime.now(timezone.utc).isoformat(), } _STARTUP_STATUS.update(report) if errors: for message in errors: logger.error(f"Startup readiness check failed: {message}") for warning in warnings: logger.warning(f"Startup readiness warning: {warning}") if errors and should_fail_fast(): raise RuntimeError("Startup readiness checks failed") return report def get_startup_status() -> Dict[str, Any]: return dict(_STARTUP_STATUS) def readiness_under_auth_context(current_user: Dict[str, Any]) -> Dict[str, Any]: user_id = (current_user or {}).get("id") or (current_user or {}).get("clerk_user_id") if not user_id: return { "ready": False, "reason": "missing_user_context", "detail": "No authenticated user id was provided in auth context.", } try: db_path = get_user_db_path(user_id) session = get_session_for_user(user_id) if not session: raise RuntimeError("Session creation returned None") session.execute(text("SELECT 1")) session.close() return { "ready": True, "user_id": user_id, "tenant_db_path": db_path, "db_session": "ok", } except Exception as exc: logger.error(f"Readiness auth-context DB check failed for user '{user_id}': {exc}") return { "ready": False, "user_id": user_id, "tenant_db_path": get_user_db_path(user_id), "db_session": "failed", "reason": str(exc), }