From 5ec5ee8f7a4f538af851e5055bc0a312fde417cc Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 22 Oct 2025 05:33:30 -0600 Subject: [PATCH] Scaffold Engine application interfaces --- Data/Engine/CURRENT_STAGE.md | 2 +- Data/Engine/README.md | 6 ++- Data/Engine/bootstrapper.py | 7 ++- Data/Engine/config/environment.py | 16 ++++++- Data/Engine/interfaces/__init__.py | 3 +- Data/Engine/interfaces/http/__init__.py | 14 +++++- Data/Engine/interfaces/http/admin.py | 21 +++++++++ Data/Engine/interfaces/http/agents.py | 21 +++++++++ Data/Engine/interfaces/http/enrollment.py | 21 +++++++++ Data/Engine/interfaces/http/health.py | 21 +++++++++ Data/Engine/interfaces/http/tokens.py | 21 +++++++++ Data/Engine/interfaces/ws/__init__.py | 16 ++++++- Data/Engine/interfaces/ws/agents/__init__.py | 16 +++++++ Data/Engine/interfaces/ws/agents/events.py | 20 ++++++++ .../interfaces/ws/job_management/__init__.py | 16 +++++++ .../interfaces/ws/job_management/events.py | 19 ++++++++ Data/Engine/server.py | 47 +++++++++++++++++-- 17 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 Data/Engine/interfaces/http/admin.py create mode 100644 Data/Engine/interfaces/http/agents.py create mode 100644 Data/Engine/interfaces/http/enrollment.py create mode 100644 Data/Engine/interfaces/http/health.py create mode 100644 Data/Engine/interfaces/http/tokens.py create mode 100644 Data/Engine/interfaces/ws/agents/__init__.py create mode 100644 Data/Engine/interfaces/ws/agents/events.py create mode 100644 Data/Engine/interfaces/ws/job_management/__init__.py create mode 100644 Data/Engine/interfaces/ws/job_management/events.py diff --git a/Data/Engine/CURRENT_STAGE.md b/Data/Engine/CURRENT_STAGE.md index 81b2c1c..70cb520 100644 --- a/Data/Engine/CURRENT_STAGE.md +++ b/Data/Engine/CURRENT_STAGE.md @@ -10,7 +10,7 @@ - 2.2 Add settings dataclasses for Flask, Socket.IO, and DB paths; inject them via `server.py`. - 2.3 Commit once the Engine can start with equivalent config but no real routes. -3. Copy Flask application scaffolding +[COMPLETED] 3. Copy Flask application scaffolding - 3.1 Port proxy/CORS/static setup from `Data/Server/server.py` into Engine `server.py` using dependency injection. - 3.2 Stub out blueprint/Socket.IO registration hooks that mirror names from legacy code (no logic yet). - 3.3 Smoke-test app startup via `python Data/Engine/bootstrapper.py` (or Flask CLI) to ensure no regressions. diff --git a/Data/Engine/README.md b/Data/Engine/README.md index c6f55df..e7a888c 100644 --- a/Data/Engine/README.md +++ b/Data/Engine/README.md @@ -10,7 +10,7 @@ The Engine mirrors the legacy defaults so it can boot without additional configu | --- | --- | --- | | `BOREALIS_ROOT` | Overrides automatic project root detection. Useful when running from a packaged location. | Directory two levels above `Data/Engine/` | | `BOREALIS_DATABASE_PATH` | Path to the SQLite database. | `/database.db` | -| `BOREALIS_STATIC_ROOT` | Directory that serves static assets for the SPA. | `/Data/Server/dist` | +| `BOREALIS_STATIC_ROOT` | Directory that serves static assets for the SPA. | First existing path among `Data/Server/web-interface/build`, `Data/Server/WebUI/build`, `Data/WebUI/build` | | `BOREALIS_CORS_ALLOWED_ORIGINS` | Comma-delimited list of origins granted CORS access. Use `*` for all origins. | `*` | | `BOREALIS_FLASK_SECRET_KEY` | Secret key for Flask session signing. | `change-me` | | `BOREALIS_DEBUG` | Enables debug logging, disables secure-cookie requirements, and allows Werkzeug debug mode. | `false` | @@ -28,3 +28,7 @@ The Engine mirrors the legacy defaults so it can boot without additional configu 3. The resulting runtime object exposes the Flask app, resolved settings, and optional Socket.IO server. `bootstrapper.main()` runs the appropriate server based on whether Socket.IO is present. As migration continues, services, repositories, interfaces, and integrations will live under their respective subpackages while maintaining isolation from the legacy server. + +## Interface scaffolding + +The Engine currently exposes placeholder HTTP blueprints under `Data/Engine/interfaces/http/` (agents, enrollment, tokens, admin, and health) so that future commits can drop in real routes without reshaping the bootstrap wiring. WebSocket namespaces follow the same pattern in `Data/Engine/interfaces/ws/`, with feature-oriented modules (e.g., `agents`, `job_management`) registered by `bootstrapper.bootstrap()` when Socket.IO is available. These stubs intentionally contain no business logic yet—they merely ensure the application factory exercises the full wiring path. diff --git a/Data/Engine/bootstrapper.py b/Data/Engine/bootstrapper.py index 6dcb77e..a3d19b3 100644 --- a/Data/Engine/bootstrapper.py +++ b/Data/Engine/bootstrapper.py @@ -8,7 +8,11 @@ from typing import Optional from flask import Flask from .config import EngineSettings, configure_logging, load_environment -from .interfaces import create_socket_server, register_http_interfaces +from .interfaces import ( + create_socket_server, + register_http_interfaces, + register_ws_interfaces, +) from .server import create_app @@ -30,6 +34,7 @@ def bootstrap() -> EngineRuntime: app = create_app(settings) register_http_interfaces(app) socketio = create_socket_server(app, settings.socketio) + register_ws_interfaces(socketio) logger.info("bootstrap-complete") return EngineRuntime(app=app, settings=settings, socketio=socketio) diff --git a/Data/Engine/config/environment.py b/Data/Engine/config/environment.py index 56205f5..2480864 100644 --- a/Data/Engine/config/environment.py +++ b/Data/Engine/config/environment.py @@ -81,7 +81,21 @@ def _resolve_static_root(project_root: Path) -> Path: candidate = os.getenv("BOREALIS_STATIC_ROOT") if candidate: return Path(candidate).expanduser().resolve() - return (project_root / "Data" / "Server" / "dist").resolve() + + candidates = ( + project_root / "Data" / "Server" / "web-interface" / "build", + project_root / "Data" / "Server" / "WebUI" / "build", + project_root / "Data" / "WebUI" / "build", + ) + for path in candidates: + resolved = path.resolve() + if resolved.is_dir(): + return resolved + + # Fall back to the first candidate even if it does not yet exist so the + # Flask factory still initialises; individual requests will surface 404s + # until an asset build is available, matching the legacy behaviour. + return candidates[0].resolve() def _parse_origins(raw: str | None) -> Tuple[str, ...]: diff --git a/Data/Engine/interfaces/__init__.py b/Data/Engine/interfaces/__init__.py index 1db05a4..d7ffdba 100644 --- a/Data/Engine/interfaces/__init__.py +++ b/Data/Engine/interfaces/__init__.py @@ -3,9 +3,10 @@ from __future__ import annotations from .http import register_http_interfaces -from .ws import create_socket_server +from .ws import create_socket_server, register_ws_interfaces __all__ = [ "register_http_interfaces", "create_socket_server", + "register_ws_interfaces", ] diff --git a/Data/Engine/interfaces/http/__init__.py b/Data/Engine/interfaces/http/__init__.py index 75d4b5f..cc625c7 100644 --- a/Data/Engine/interfaces/http/__init__.py +++ b/Data/Engine/interfaces/http/__init__.py @@ -4,6 +4,16 @@ from __future__ import annotations from flask import Flask +from . import admin, agents, enrollment, health, tokens + +_REGISTRARS = ( + health.register, + agents.register, + enrollment.register, + tokens.register, + admin.register, +) + def register_http_interfaces(app: Flask) -> None: """Attach HTTP blueprints to *app*. @@ -11,8 +21,8 @@ def register_http_interfaces(app: Flask) -> None: The implementation is intentionally minimal for the initial scaffolding. """ - # Future phases will import and register blueprints here. - return None + for registrar in _REGISTRARS: + registrar(app) __all__ = ["register_http_interfaces"] diff --git a/Data/Engine/interfaces/http/admin.py b/Data/Engine/interfaces/http/admin.py new file mode 100644 index 0000000..fb95511 --- /dev/null +++ b/Data/Engine/interfaces/http/admin.py @@ -0,0 +1,21 @@ +"""Administrative HTTP interface placeholders for the Engine.""" + +from __future__ import annotations + +from flask import Blueprint, Flask + + +blueprint = Blueprint("engine_admin", __name__, url_prefix="/api/admin") + + +def register(app: Flask) -> None: + """Attach administrative routes to *app*. + + Concrete endpoints will be migrated in subsequent phases. + """ + + if "engine_admin" not in app.blueprints: + app.register_blueprint(blueprint) + + +__all__ = ["register", "blueprint"] diff --git a/Data/Engine/interfaces/http/agents.py b/Data/Engine/interfaces/http/agents.py new file mode 100644 index 0000000..618ade6 --- /dev/null +++ b/Data/Engine/interfaces/http/agents.py @@ -0,0 +1,21 @@ +"""Agent HTTP interface placeholders for the Engine.""" + +from __future__ import annotations + +from flask import Blueprint, Flask + + +blueprint = Blueprint("engine_agents", __name__, url_prefix="/api/agents") + + +def register(app: Flask) -> None: + """Attach agent management routes to *app*. + + Implementation will be populated as services migrate from the legacy server. + """ + + if "engine_agents" not in app.blueprints: + app.register_blueprint(blueprint) + + +__all__ = ["register", "blueprint"] diff --git a/Data/Engine/interfaces/http/enrollment.py b/Data/Engine/interfaces/http/enrollment.py new file mode 100644 index 0000000..a514011 --- /dev/null +++ b/Data/Engine/interfaces/http/enrollment.py @@ -0,0 +1,21 @@ +"""Enrollment HTTP interface placeholders for the Engine.""" + +from __future__ import annotations + +from flask import Blueprint, Flask + + +blueprint = Blueprint("engine_enrollment", __name__, url_prefix="/api/enrollment") + + +def register(app: Flask) -> None: + """Attach enrollment routes to *app*. + + Implementation will be ported during later migration phases. + """ + + if "engine_enrollment" not in app.blueprints: + app.register_blueprint(blueprint) + + +__all__ = ["register", "blueprint"] diff --git a/Data/Engine/interfaces/http/health.py b/Data/Engine/interfaces/http/health.py new file mode 100644 index 0000000..4cbfa46 --- /dev/null +++ b/Data/Engine/interfaces/http/health.py @@ -0,0 +1,21 @@ +"""Health check HTTP interface placeholders for the Engine.""" + +from __future__ import annotations + +from flask import Blueprint, Flask + + +blueprint = Blueprint("engine_health", __name__) + + +def register(app: Flask) -> None: + """Attach health-related routes to *app*. + + Routes will be populated in later migration phases. + """ + + if "engine_health" not in app.blueprints: + app.register_blueprint(blueprint) + + +__all__ = ["register", "blueprint"] diff --git a/Data/Engine/interfaces/http/tokens.py b/Data/Engine/interfaces/http/tokens.py new file mode 100644 index 0000000..6aa4bbc --- /dev/null +++ b/Data/Engine/interfaces/http/tokens.py @@ -0,0 +1,21 @@ +"""Token management HTTP interface placeholders for the Engine.""" + +from __future__ import annotations + +from flask import Blueprint, Flask + + +blueprint = Blueprint("engine_tokens", __name__, url_prefix="/api/tokens") + + +def register(app: Flask) -> None: + """Attach token management routes to *app*. + + Implementation will be introduced as authentication services are migrated. + """ + + if "engine_tokens" not in app.blueprints: + app.register_blueprint(blueprint) + + +__all__ = ["register", "blueprint"] diff --git a/Data/Engine/interfaces/ws/__init__.py b/Data/Engine/interfaces/ws/__init__.py index 322bc90..2850637 100644 --- a/Data/Engine/interfaces/ws/__init__.py +++ b/Data/Engine/interfaces/ws/__init__.py @@ -2,11 +2,13 @@ from __future__ import annotations -from typing import Optional +from typing import Any, Optional from flask import Flask from ...config import SocketIOSettings +from .agents import register as register_agent_events +from .job_management import register as register_job_events try: # pragma: no cover - import guard from flask_socketio import SocketIO @@ -31,4 +33,14 @@ def create_socket_server(app: Flask, settings: SocketIOSettings) -> Optional[Soc return socketio -__all__ = ["create_socket_server"] +def register_ws_interfaces(socketio: Any) -> None: + """Attach placeholder namespaces for the Engine Socket.IO server.""" + + if socketio is None: # pragma: no cover - guard + return + + for registrar in (register_agent_events, register_job_events): + registrar(socketio) + + +__all__ = ["create_socket_server", "register_ws_interfaces"] diff --git a/Data/Engine/interfaces/ws/agents/__init__.py b/Data/Engine/interfaces/ws/agents/__init__.py new file mode 100644 index 0000000..b048906 --- /dev/null +++ b/Data/Engine/interfaces/ws/agents/__init__.py @@ -0,0 +1,16 @@ +"""Agent WebSocket namespace wiring for the Engine.""" + +from __future__ import annotations + +from typing import Any + +from . import events + + +def register(socketio: Any) -> None: + """Register agent namespaces on the given Socket.IO *socketio* instance.""" + + events.register(socketio) + + +__all__ = ["register"] diff --git a/Data/Engine/interfaces/ws/agents/events.py b/Data/Engine/interfaces/ws/agents/events.py new file mode 100644 index 0000000..e47aed0 --- /dev/null +++ b/Data/Engine/interfaces/ws/agents/events.py @@ -0,0 +1,20 @@ +"""Agent WebSocket event placeholders for the Engine.""" + +from __future__ import annotations + +from typing import Any + + +def register(socketio: Any) -> None: + """Register agent-related namespaces on *socketio*. + + The concrete event handlers will be migrated in later phases. + """ + + if socketio is None: # pragma: no cover - guard + return + # Placeholder for namespace registration, e.g. ``socketio.on_namespace(...)``. + return + + +__all__ = ["register"] diff --git a/Data/Engine/interfaces/ws/job_management/__init__.py b/Data/Engine/interfaces/ws/job_management/__init__.py new file mode 100644 index 0000000..e47eee7 --- /dev/null +++ b/Data/Engine/interfaces/ws/job_management/__init__.py @@ -0,0 +1,16 @@ +"""Job management WebSocket namespace wiring for the Engine.""" + +from __future__ import annotations + +from typing import Any + +from . import events + + +def register(socketio: Any) -> None: + """Register job management namespaces on the given Socket.IO *socketio*.""" + + events.register(socketio) + + +__all__ = ["register"] diff --git a/Data/Engine/interfaces/ws/job_management/events.py b/Data/Engine/interfaces/ws/job_management/events.py new file mode 100644 index 0000000..b364ee1 --- /dev/null +++ b/Data/Engine/interfaces/ws/job_management/events.py @@ -0,0 +1,19 @@ +"""Job management WebSocket event placeholders for the Engine.""" + +from __future__ import annotations + +from typing import Any + + +def register(socketio: Any) -> None: + """Register job management namespaces on *socketio*. + + Concrete handlers will be migrated in later phases. + """ + + if socketio is None: # pragma: no cover - guard + return + return + + +__all__ = ["register"] diff --git a/Data/Engine/server.py b/Data/Engine/server.py index 732dfde..b8263a4 100644 --- a/Data/Engine/server.py +++ b/Data/Engine/server.py @@ -4,17 +4,51 @@ from __future__ import annotations from pathlib import Path -from flask import Flask +from flask import Flask, request, send_from_directory from flask_cors import CORS +from werkzeug.exceptions import NotFound from werkzeug.middleware.proxy_fix import ProxyFix from .config import EngineSettings -def _resolve_static_folder(static_root: Path) -> tuple[str | None, str]: - if static_root.exists(): - return str(static_root), "/" - return None, "/static" +def _resolve_static_folder(static_root: Path) -> tuple[str, str]: + return str(static_root), "" + + +def _register_spa_routes(app: Flask, assets_root: Path) -> None: + """Serve the Borealis single-page application from *assets_root*. + + The logic mirrors the legacy server by routing any unknown front-end paths + back to ``index.html`` so the React router can take over. + """ + + static_folder = assets_root + + @app.route("/", defaults={"path": ""}) + @app.route("/") + def serve_frontend(path: str) -> object: + candidate = (static_folder / path).resolve() + if path and candidate.is_file(): + return send_from_directory(str(static_folder), path) + try: + return send_from_directory(str(static_folder), "index.html") + except Exception as exc: # pragma: no cover - passthrough + raise NotFound() from exc + + @app.errorhandler(404) + def spa_fallback(error: Exception) -> object: # pragma: no cover - routing + request_path = (request.path or "").strip() + if request_path.startswith("/api") or request_path.startswith("/socket.io"): + return error + if "." in Path(request_path).name: + return error + if request.method not in {"GET", "HEAD"}: + return error + try: + return send_from_directory(str(static_folder), "index.html") + except Exception: + return error def create_app(settings: EngineSettings) -> Flask: @@ -35,6 +69,7 @@ def create_app(settings: EngineSettings) -> Flask: SESSION_COOKIE_SAMESITE="Lax", ENGINE_DATABASE_PATH=str(settings.database_path), ) + app.config.setdefault("PREFERRED_URL_SCHEME", "https") # Respect upstream proxy headers when Borealis is hosted behind a TLS terminator. app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) # type: ignore[assignment] @@ -45,6 +80,8 @@ def create_app(settings: EngineSettings) -> Flask: supports_credentials=True, ) + _register_spa_routes(app, Path(static_folder)) + return app