From 1b6d0151249e7ed22fb0739bee8cb03bdd35c691 Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 26 Oct 2025 02:02:05 -0600 Subject: [PATCH] Fix Engine WebUI staging and logging outputs --- Borealis.ps1 | 6 +- Borealis.sh | 4 +- Data/Engine/Unit_Tests/conftest.py | 7 +- Data/Engine/config.py | 51 +++++++++++--- Data/Engine/server.py | 105 ++++++++++++++++------------- 5 files changed, 107 insertions(+), 66 deletions(-) diff --git a/Borealis.ps1 b/Borealis.ps1 index 6c1d373..8f33225 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -198,7 +198,7 @@ function Ensure-EngineWebInterface { [string]$ProjectRoot ) - $engineSource = Join-Path $ProjectRoot 'Data\Engine\web-interface' + $engineSource = Join-Path $ProjectRoot 'Engine\web-interface' $legacySource = Join-Path $ProjectRoot 'Data\Server\WebUI' if (-not (Test-Path $legacySource)) { @@ -1108,7 +1108,7 @@ switch ($choice) { $venvFolder = "Server" $dataSource = "Data" $dataDestination = "$venvFolder\Borealis" - $customUIPath = "$dataSource\Engine\web-interface" + $customUIPath = (Join-Path $scriptDir 'Engine\web-interface') $webUIDestination = "$venvFolder\web-interface" $venvPython = Join-Path $venvFolder 'Scripts\python.exe' @@ -1408,7 +1408,7 @@ switch ($choice) { Run-Step "Copy Borealis Engine WebUI Files into: $webUIDestination" { Ensure-EngineWebInterface -ProjectRoot $scriptDir - $engineWebUISource = Join-Path $engineSourceAbsolute 'web-interface' + $engineWebUISource = Join-Path $scriptDir 'Engine\web-interface' if (Test-Path $engineWebUISource) { $webUIDestinationAbsolute = Join-Path $scriptDir $webUIDestination if (Test-Path $webUIDestinationAbsolute) { diff --git a/Borealis.sh b/Borealis.sh index 1235f13..8a01512 100644 --- a/Borealis.sh +++ b/Borealis.sh @@ -331,7 +331,7 @@ PY } ensure_engine_webui_source() { - local engineSource="Data/Engine/web-interface" + local engineSource="Engine/web-interface" local legacySource="Data/Server/WebUI" if [[ -d "${engineSource}/src" && -f "${engineSource}/package.json" ]]; then return 0 @@ -351,7 +351,7 @@ ensure_engine_webui_source() { } prepare_webui() { - local customUIPath="Data/Engine/web-interface" + local customUIPath="Engine/web-interface" local webUIDestination="Server/web-interface" ensure_engine_webui_source || return 1 mkdir -p "$webUIDestination" diff --git a/Data/Engine/Unit_Tests/conftest.py b/Data/Engine/Unit_Tests/conftest.py index 7f1a01b..9256826 100644 --- a/Data/Engine/Unit_Tests/conftest.py +++ b/Data/Engine/Unit_Tests/conftest.py @@ -112,8 +112,10 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[ key_path.write_text("test-key", encoding="utf-8") bundle_path.write_text(bundle_contents, encoding="utf-8") - log_path = tmp_path / "logs" / "server.log" - log_path.parent.mkdir(parents=True, exist_ok=True) + logs_dir = tmp_path / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + log_path = logs_dir / "server.log" + error_log_path = logs_dir / "error.log" config = { "DATABASE_PATH": str(db_path), @@ -121,6 +123,7 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[ "TLS_KEY_PATH": str(key_path), "TLS_BUNDLE_PATH": str(bundle_path), "LOG_FILE": str(log_path), + "ERROR_LOG_FILE": str(error_log_path), "API_GROUPS": ("tokens", "enrollment"), } diff --git a/Data/Engine/config.py b/Data/Engine/config.py index 6c30a70..2c83265 100644 --- a/Data/Engine/config.py +++ b/Data/Engine/config.py @@ -26,8 +26,9 @@ defaults that mirror the legacy server runtime. Key environment variables are When TLS values are not provided explicitly the Engine falls back to the certificate helper shipped with the legacy server, ensuring bundling parity. -Logs are written to ``Logs/Server/server.log`` with daily rotation so the new -runtime integrates with existing operational practices. +Logs are written to ``Logs/Engine/engine.log`` with daily rotation and +errors are additionally duplicated to ``Logs/Engine/error.log`` so the +runtime integrates with the platform's logging policy. """ from __future__ import annotations @@ -48,7 +49,9 @@ except Exception: # pragma: no-cover - Engine configuration still works without ENGINE_DIR = Path(__file__).resolve().parent PROJECT_ROOT = ENGINE_DIR.parent.parent DEFAULT_DATABASE_PATH = PROJECT_ROOT / "database.db" -LOG_FILE_PATH = PROJECT_ROOT / "Logs" / "Server" / "server.log" +LOG_ROOT = PROJECT_ROOT / "Logs" / "Engine" +LOG_FILE_PATH = LOG_ROOT / "engine.log" +ERROR_LOG_FILE_PATH = LOG_ROOT / "error.log" def _ensure_parent(path: Path) -> None: @@ -61,16 +64,27 @@ def _ensure_parent(path: Path) -> None: def _resolve_static_folder() -> str: - candidates = [ - ENGINE_DIR / "web-interface" / "build", - ENGINE_DIR / "web-interface" / "dist", + candidate_roots = [ + ENGINE_DIR.parent / "Engine" / "web-interface", ENGINE_DIR / "web-interface", ] + + candidates = [] + for root in candidate_roots: + absolute_root = root.resolve() + candidates.extend( + [ + absolute_root / "build", + absolute_root / "dist", + absolute_root, + ] + ) + for candidate in candidates: - absolute = candidate.resolve() - if absolute.is_dir(): - return str(absolute) - return str(candidates[0].resolve()) + if candidate.is_dir(): + return str(candidate) + + return str(candidates[0]) def _parse_origins(raw: Optional[Any]) -> Optional[List[str]]: @@ -139,6 +153,7 @@ class EngineSettings: tls_key_path: Optional[str] tls_bundle_path: Optional[str] log_file: str + error_log_file: str api_groups: Tuple[str, ...] raw: MutableMapping[str, Any] = field(default_factory=dict) @@ -219,6 +234,9 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine log_file = str(runtime_config.get("LOG_FILE") or LOG_FILE_PATH) _ensure_parent(Path(log_file)) + error_log_file = str(runtime_config.get("ERROR_LOG_FILE") or ERROR_LOG_FILE_PATH) + _ensure_parent(Path(error_log_file)) + api_groups = _parse_api_groups( runtime_config.get("API_GROUPS") or os.environ.get("BOREALIS_API_GROUPS") ) @@ -237,6 +255,7 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine tls_key_path=tls_key_path if tls_key_path else None, tls_bundle_path=tls_bundle_path if tls_bundle_path else None, log_file=str(log_file), + error_log_file=str(error_log_file), api_groups=api_groups, raw=runtime_config, ) @@ -244,7 +263,7 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine def initialise_engine_logger(settings: EngineSettings, name: str = "borealis.engine") -> logging.Logger: - """Configure the Engine logger to write to the shared server log.""" + """Configure the Engine logger to write to Engine log files.""" logger = logging.getLogger(name) if not logger.handlers: @@ -263,6 +282,16 @@ def initialise_engine_logger(settings: EngineSettings, name: str = "borealis.eng file_handler.setFormatter(formatter) logger.addHandler(file_handler) + error_handler = TimedRotatingFileHandler( + settings.error_log_file, + when="midnight", + backupCount=0, + encoding="utf-8", + ) + error_handler.setLevel(logging.ERROR) + error_handler.setFormatter(formatter) + logger.addHandler(error_handler) + logger.setLevel(logging.INFO) logger.propagate = False return logger diff --git a/Data/Engine/server.py b/Data/Engine/server.py index c8c84cf..afccc7c 100644 --- a/Data/Engine/server.py +++ b/Data/Engine/server.py @@ -4,8 +4,8 @@ Stage 1 introduced the structural skeleton for the Engine runtime. Stage 2 builds upon that foundation by centralising configuration handling and logging initialisation so the Engine mirrors the legacy server's start-up behaviour. The factory delegates configuration resolution to :mod:`Data.Engine.config` -and emits structured logs to ``Logs/Server/server.log`` to align with the -project's operational practices. +and emits structured logs to ``Logs/Engine/engine.log`` (with an accompanying +error log) to align with the project's operational practices. """ from __future__ import annotations @@ -16,63 +16,72 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Mapping, Optional, Sequence, Tuple -import eventlet +try: # pragma: no-cover - optional dependency when running without eventlet + import eventlet # type: ignore +except Exception: # pragma: no-cover - fall back to threading mode + eventlet = None # type: ignore[assignment] + logging.getLogger(__name__).warning( + "Eventlet is not available; Engine will run Socket.IO in threading mode." + ) +else: # pragma: no-cover - monkey patch only when eventlet is present + eventlet.monkey_patch(thread=False) + from flask import Flask, request from flask_cors import CORS from flask_socketio import SocketIO from werkzeug.middleware.proxy_fix import ProxyFix -# Eventlet ensures Socket.IO long-polling and WebSocket support parity with the -# legacy server. We keep thread pools enabled for compatibility with blocking -# filesystem/database operations. -eventlet.monkey_patch(thread=False) +if eventlet: + try: # pragma: no-cover - defensive import mirroring the legacy runtime. + from eventlet.wsgi import HttpProtocol # type: ignore + except Exception: # pragma: no-cover - the Engine should still operate without it. + HttpProtocol = None # type: ignore[assignment] + else: + _original_handle_one_request = HttpProtocol.handle_one_request -try: # pragma: no-cover - defensive import mirroring the legacy runtime. - from eventlet.wsgi import HttpProtocol # type: ignore -except Exception: # pragma: no-cover - the Engine should still operate without it. - HttpProtocol = None # type: ignore[assignment] -else: - _original_handle_one_request = HttpProtocol.handle_one_request + def _quiet_tls_http_mismatch(self): # type: ignore[override] + """Mirror the legacy suppression of noisy TLS handshake errors.""" - def _quiet_tls_http_mismatch(self): # type: ignore[override] - """Mirror the legacy suppression of noisy TLS handshake errors.""" + def _close_connection_quietly(): + try: + self.close_connection = True # type: ignore[attr-defined] + except Exception: + pass + try: + conn = getattr(self, "socket", None) or getattr(self, "connection", None) + if conn: + conn.close() + except Exception: + pass - def _close_connection_quietly(): try: - self.close_connection = True # type: ignore[attr-defined] - except Exception: - pass - try: - conn = getattr(self, "socket", None) or getattr(self, "connection", None) - if conn: - conn.close() - except Exception: - pass - - try: - return _original_handle_one_request(self) - except ssl.SSLError as exc: # type: ignore[arg-type] - reason = getattr(exc, "reason", "") - reason_text = str(reason).lower() if reason else "" - message = " ".join(str(arg) for arg in exc.args if arg).lower() - if ( - "http_request" in message - or reason_text == "http request" - or "unknown ca" in message - or reason_text == "unknown ca" - or "unknown_ca" in message - ): + return _original_handle_one_request(self) + except ssl.SSLError as exc: # type: ignore[arg-type] + reason = getattr(exc, "reason", "") + reason_text = str(reason).lower() if reason else "" + message = " ".join(str(arg) for arg in exc.args if arg).lower() + if ( + "http_request" in message + or reason_text == "http request" + or "unknown ca" in message + or reason_text == "unknown ca" + or "unknown_ca" in message + ): + _close_connection_quietly() + return None + raise + except ssl.SSLEOFError: + _close_connection_quietly() + return None + except ConnectionAbortedError: _close_connection_quietly() return None - raise - except ssl.SSLEOFError: - _close_connection_quietly() - return None - except ConnectionAbortedError: - _close_connection_quietly() - return None - HttpProtocol.handle_one_request = _quiet_tls_http_mismatch # type: ignore[assignment] + HttpProtocol.handle_one_request = _quiet_tls_http_mismatch # type: ignore[assignment] +else: + HttpProtocol = None # type: ignore[assignment] + +_SOCKETIO_ASYNC_MODE = "eventlet" if eventlet else "threading" # Ensure the legacy ``Modules`` package is importable when running from the @@ -171,7 +180,7 @@ def create_app(config: Optional[Mapping[str, Any]] = None) -> Tuple[Flask, Socke socketio = SocketIO( app, cors_allowed_origins="*", - async_mode="eventlet", + async_mode=_SOCKETIO_ASYNC_MODE, engineio_options={ "max_http_buffer_size": 100_000_000, "max_websocket_message_size": 100_000_000,