mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 17:41:58 -06:00
Bridge legacy Flask APIs through Engine fallback
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
60
Data/Engine/tests/test_server_legacy_bridge.py
Normal file
60
Data/Engine/tests/test_server_legacy_bridge.py
Normal 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"
|
||||
Reference in New Issue
Block a user