diff --git a/Data/Engine/bootstrapper.py b/Data/Engine/bootstrapper.py index d70a60a..46d0194 100644 --- a/Data/Engine/bootstrapper.py +++ b/Data/Engine/bootstrapper.py @@ -18,7 +18,7 @@ from .interfaces import ( from .interfaces.eventlet_compat import apply_eventlet_patches from .repositories.sqlite import connection as sqlite_connection from .repositories.sqlite import migrations as sqlite_migrations -from .server import create_app +from .server import attach_legacy_bridge, create_app from .services.container import build_service_container from .services.crypto.certificates import ensure_certificate @@ -71,12 +71,19 @@ def bootstrap() -> EngineRuntime: logger.info("default-admin-ensured") app = create_app(settings, db_factory=db_factory) + attach_legacy_bridge(app, settings, logger=logger) services = build_service_container(settings, db_factory=db_factory, logger=logger.getChild("services")) app.extensions["engine_services"] = services register_http_interfaces(app, services) - socketio = create_socket_server(app, settings.socketio) - register_ws_interfaces(socketio, services) - services.scheduler_service.start(socketio) + + legacy_active = bool(app.config.get("ENGINE_LEGACY_BRIDGE_ACTIVE")) + if legacy_active: + socketio = None + logger.info("legacy-ws-deferred") + else: + socketio = create_socket_server(app, settings.socketio) + register_ws_interfaces(socketio, services) + services.scheduler_service.start(socketio) logger.info("bootstrap-complete") return EngineRuntime( app=app, diff --git a/Data/Engine/interfaces/http/__init__.py b/Data/Engine/interfaces/http/__init__.py index e388b81..c8a568a 100644 --- a/Data/Engine/interfaces/http/__init__.py +++ b/Data/Engine/interfaces/http/__init__.py @@ -26,7 +26,11 @@ def register_http_interfaces(app: Flask, services: EngineServiceContainer) -> No The implementation is intentionally minimal for the initial scaffolding. """ - for registrar in _REGISTRARS: + registrars = list(_REGISTRARS) + if app.config.get("ENGINE_LEGACY_BRIDGE_ACTIVE"): + registrars = [r for r in registrars if r is not job_management.register] + + for registrar in registrars: registrar(app, services) diff --git a/Data/Engine/server.py b/Data/Engine/server.py index 77fb8ea..3cb7376 100644 --- a/Data/Engine/server.py +++ b/Data/Engine/server.py @@ -2,8 +2,11 @@ from __future__ import annotations +import importlib +import logging +import os from pathlib import Path -from typing import Optional +from typing import Any, Iterable, Optional from flask import Flask, request, send_from_directory from flask_cors import CORS @@ -100,4 +103,135 @@ def create_app( return app -__all__ = ["create_app"] +def attach_legacy_bridge( + app: Flask, + settings: EngineSettings, + *, + logger: Optional[logging.Logger] = None, +) -> None: + """Attach the legacy Flask application as a fallback dispatcher. + + Borealis ships a mature API surface inside ``Data/Server/server.py``. The + Engine will eventually supersede it, but during the migration the React + frontend still expects the historical endpoints to exist. This helper + attempts to load the legacy application and wires it as a fallback WSGI + dispatcher so any route the Engine does not yet implement transparently + defers to the proven implementation. + """ + + log = logger or logging.getLogger("borealis.engine.legacy") + + if not _legacy_bridge_enabled(): + log.info("legacy-bridge-disabled") + return + + legacy = _load_legacy_app(settings, app, log) + if legacy is None: + log.warning("legacy-bridge-unavailable") + return + + app.config["ENGINE_LEGACY_BRIDGE_ACTIVE"] = True + app.wsgi_app = _FallbackDispatcher(app.wsgi_app, legacy.wsgi_app) # type: ignore[assignment] + app.extensions["legacy_flask_app"] = legacy + log.info("legacy-bridge-active") + + +def _legacy_bridge_enabled() -> bool: + raw = os.getenv("BOREALIS_ENGINE_ENABLE_LEGACY_BRIDGE", "1") + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _load_legacy_app( + settings: EngineSettings, + engine_app: Flask, + logger: logging.Logger, +) -> Optional[Flask]: + try: + legacy_module = importlib.import_module("Data.Server.server") + except Exception as exc: # pragma: no cover - defensive + logger.exception("legacy-import-failed", exc_info=exc) + return None + + legacy_app = getattr(legacy_module, "app", None) + if not isinstance(legacy_app, Flask): + logger.error("legacy-app-missing") + return None + + # Align runtime configuration so both applications share database and + # session state. + try: + setattr(legacy_module, "DB_PATH", str(settings.database_path)) + except Exception: # pragma: no cover - defensive + logger.warning("legacy-db-path-sync-failed", extra={"path": str(settings.database_path)}) + + _synchronise_session_config(engine_app, legacy_app) + + return legacy_app + + +def _synchronise_session_config(engine_app: Flask, legacy_app: Flask) -> None: + legacy_app.secret_key = engine_app.config.get("SECRET_KEY", legacy_app.secret_key) + for key in ( + "SESSION_COOKIE_HTTPONLY", + "SESSION_COOKIE_SECURE", + "SESSION_COOKIE_SAMESITE", + "SESSION_COOKIE_DOMAIN", + ): + value = engine_app.config.get(key) + if value is not None: + legacy_app.config[key] = value + + +class _FallbackDispatcher: + """WSGI dispatcher that retries a secondary app when the primary 404s.""" + + __slots__ = ("_primary", "_fallback", "_retry_statuses") + + def __init__( + self, + primary: Any, + fallback: Any, + *, + retry_statuses: Iterable[int] = (404,), + ) -> None: + self._primary = primary + self._fallback = fallback + self._retry_statuses = {int(status) for status in retry_statuses} + + def __call__(self, environ: dict[str, Any], start_response: Any) -> Iterable[bytes]: + captured_body: list[bytes] = [] + captured_status: dict[str, Any] = {} + + def _capture_start_response(status: str, headers: list[tuple[str, str]], exc_info: Any = None): + captured_status["status"] = status + captured_status["headers"] = headers + captured_status["exc_info"] = exc_info + + def _write(data: bytes) -> None: + captured_body.append(data) + + return _write + + primary_iterable = self._primary(environ, _capture_start_response) + try: + for chunk in primary_iterable: + captured_body.append(chunk) + finally: + close = getattr(primary_iterable, "close", None) + if callable(close): + close() + + status_line = str(captured_status.get("status") or "500 Internal Server Error") + try: + status_code = int(status_line.split()[0]) + except Exception: # pragma: no cover - defensive + status_code = 500 + + if status_code not in self._retry_statuses: + start_response(status_line, captured_status.get("headers", []), captured_status.get("exc_info")) + return captured_body + + return self._fallback(environ, start_response) + + +__all__ = ["attach_legacy_bridge", "create_app"] diff --git a/Data/Engine/tests/test_server_legacy_bridge.py b/Data/Engine/tests/test_server_legacy_bridge.py new file mode 100644 index 0000000..1c3f77f --- /dev/null +++ b/Data/Engine/tests/test_server_legacy_bridge.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Callable, Iterable + +import pytest + +pytest.importorskip("flask") + +from Data.Engine.server import _FallbackDispatcher + + +def _wsgi_app(status: str, body: bytes) -> Callable: + def _app(environ, start_response): # type: ignore[override] + start_response(status, [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))]) + return [body] + + return _app + + +def _invoke(app: Callable, path: str = "/") -> tuple[str, bytes]: + status_holder: dict[str, str] = {} + body_parts: list[bytes] = [] + + def _start_response(status: str, headers: Iterable[tuple[str, str]], exc_info=None): # type: ignore[override] + status_holder["status"] = status + return body_parts.append + + environ = {"PATH_INFO": path, "REQUEST_METHOD": "GET", "wsgi.input": None} + result = app(environ, _start_response) + try: + for chunk in result: + body_parts.append(chunk) + finally: + close = getattr(result, "close", None) + if callable(close): + close() + + return status_holder.get("status", ""), b"".join(body_parts) + + +def test_fallback_dispatcher_primary_wins() -> None: + primary = _wsgi_app("200 OK", b"engine") + fallback = _wsgi_app("200 OK", b"legacy") + dispatcher = _FallbackDispatcher(primary, fallback) + + status, body = _invoke(dispatcher) + + assert status == "200 OK" + assert body == b"engine" + + +def test_fallback_dispatcher_uses_fallback_on_404() -> None: + primary = _wsgi_app("404 Not Found", b"missing") + fallback = _wsgi_app("200 OK", b"legacy") + dispatcher = _FallbackDispatcher(primary, fallback) + + status, body = _invoke(dispatcher) + + assert status == "200 OK" + assert body == b"legacy"