Bridge legacy Flask APIs through Engine fallback

This commit is contained in:
2025-10-22 19:59:09 -06:00
parent 7a9feebde5
commit da4cb501e0
4 changed files with 212 additions and 7 deletions

View File

@@ -18,7 +18,7 @@ from .interfaces import (
from .interfaces.eventlet_compat import apply_eventlet_patches from .interfaces.eventlet_compat import apply_eventlet_patches
from .repositories.sqlite import connection as sqlite_connection from .repositories.sqlite import connection as sqlite_connection
from .repositories.sqlite import migrations as sqlite_migrations 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.container import build_service_container
from .services.crypto.certificates import ensure_certificate from .services.crypto.certificates import ensure_certificate
@@ -71,12 +71,19 @@ def bootstrap() -> EngineRuntime:
logger.info("default-admin-ensured") logger.info("default-admin-ensured")
app = create_app(settings, db_factory=db_factory) 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")) services = build_service_container(settings, db_factory=db_factory, logger=logger.getChild("services"))
app.extensions["engine_services"] = services app.extensions["engine_services"] = services
register_http_interfaces(app, services) register_http_interfaces(app, services)
socketio = create_socket_server(app, settings.socketio)
register_ws_interfaces(socketio, services) legacy_active = bool(app.config.get("ENGINE_LEGACY_BRIDGE_ACTIVE"))
services.scheduler_service.start(socketio) 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") logger.info("bootstrap-complete")
return EngineRuntime( return EngineRuntime(
app=app, app=app,

View File

@@ -26,7 +26,11 @@ def register_http_interfaces(app: Flask, services: EngineServiceContainer) -> No
The implementation is intentionally minimal for the initial scaffolding. 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) registrar(app, services)

View File

@@ -2,8 +2,11 @@
from __future__ import annotations from __future__ import annotations
import importlib
import logging
import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Iterable, Optional
from flask import Flask, request, send_from_directory from flask import Flask, request, send_from_directory
from flask_cors import CORS from flask_cors import CORS
@@ -100,4 +103,135 @@ def create_app(
return 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"]

View File

@@ -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"