diff --git a/Data/Engine/CODE_MIGRATION_TRACKER.md b/Data/Engine/CODE_MIGRATION_TRACKER.md index 9c7ab70..34e3bd9 100644 --- a/Data/Engine/CODE_MIGRATION_TRACKER.md +++ b/Data/Engine/CODE_MIGRATION_TRACKER.md @@ -13,11 +13,11 @@ Lastly, everytime that you complete a stage, you will create a pull request name - [x] Scaffold Data/Engine/server.py with the create_app(config) factory and stub service registration hooks. - [x] Return a shared context object containing handles such as the database path, logger, and scheduler. - [x] Update project tooling so the Engine runtime can be launched alongside the legacy path. -- [ ] **Stage 2 — Port configuration and dependency loading into the Engine factory** - - [ ] Extract configuration loading logic from Data/Server/server.py into Data/Engine/config.py helpers. - - [ ] Verify context parity between Engine and legacy startup. - - [ ] Initialize logging to Logs/Server/server.log when Engine mode is active. - - [ ] Document Engine launch paths and configuration requirements in module docstrings. +- [x] **Stage 2 — Port configuration and dependency loading into the Engine factory** + - [x] Extract configuration loading logic from Data/Server/server.py into Data/Engine/config.py helpers. + - [x] Verify context parity between Engine and legacy startup. + - [x] Initialize logging to Logs/Server/server.log when Engine mode is active. + - [x] Document Engine launch paths and configuration requirements in module docstrings. - [ ] **Stage 3 — Introduce API blueprints and service adapters** - [ ] Create domain-focused API blueprints and register_api entry point. - [ ] Mirror route behaviour from the legacy server via service adapters. @@ -41,3 +41,7 @@ Lastly, everytime that you complete a stage, you will create a pull request name - [ ] Provide register_realtime hook for the Engine factory. - [ ] Add integration tests or smoke checks for key events. - [ ] Update legacy server to consume Engine WebSocket registration. + +## Current Status +- **Stage:** Stage 2 — Port configuration and dependency loading into the Engine factory (completed) +- **Active Task:** Awaiting next stage instructions. diff --git a/Data/Engine/__init__.py b/Data/Engine/__init__.py index 6af044f..1412480 100644 --- a/Data/Engine/__init__.py +++ b/Data/Engine/__init__.py @@ -1,9 +1,10 @@ """Borealis Engine runtime package. This package houses the next-generation server runtime that will gradually -replace :mod:`Data.Server.server`. Stage 1 focuses on providing a skeleton -application factory and service placeholders so later stages can port -features incrementally. +replace :mod:`Data.Server.server`. Stage 1 delivered the structural skeleton +for the Flask/Socket.IO factory; Stage 2 layers in configuration loading and +logging parity via :mod:`Data.Engine.config` so Engine launches honour the +same environment variables and log destinations as the legacy server. """ from .server import create_app, EngineContext # re-export for convenience diff --git a/Data/Engine/bootstrapper.py b/Data/Engine/bootstrapper.py index cb3bd8e..3658ba3 100644 --- a/Data/Engine/bootstrapper.py +++ b/Data/Engine/bootstrapper.py @@ -1,4 +1,11 @@ -"""Command-line bootstrapper for the Stage 1 Engine runtime.""" +"""Entrypoint helpers for running the Borealis Engine runtime. + +The bootstrapper assembles configuration via :func:`Data.Engine.config.load_runtime_config` +before delegating to :func:`Data.Engine.server.create_app`. It mirrors the +legacy server defaults by binding to ``0.0.0.0:5001`` and honouring the +``BOREALIS_ENGINE_*`` environment overrides for bind host/port. +""" + from __future__ import annotations import os diff --git a/Data/Engine/config.py b/Data/Engine/config.py new file mode 100644 index 0000000..afd52d6 --- /dev/null +++ b/Data/Engine/config.py @@ -0,0 +1,254 @@ +"""Configuration helpers for the Borealis Engine runtime. + +Stage 2 of the migration focuses on lifting the legacy configuration loading +behaviour from :mod:`Data.Server.server` into reusable helpers so the Engine +start-up path honours the same environment variables, filesystem layout, and +logging expectations. This module documents the supported launch parameters +and exposes typed helpers that the application factory consumes. + +Launch overview +--------------- +The Engine can be started via :func:`Data.Engine.bootstrapper.main` or by +invoking :func:`Data.Engine.server.create_app` manually. Configuration is +assembled from (in precedence order): + +``config`` mapping overrides provided to :func:`load_runtime_config`, +environment variables prefixed with ``BOREALIS_``, and finally built-in +defaults that mirror the legacy server runtime. Key environment variables are + +``BOREALIS_DATABASE_PATH`` path to the SQLite database file. Defaults to +``/database.db``. +``BOREALIS_CORS_ORIGINS`` comma separated list of allowed origins for CORS. +``BOREALIS_SECRET`` Flask session secret key. +``BOREALIS_COOKIE_*`` Session cookie policies (``SAMESITE``, ``SECURE``, + ``DOMAIN``). +``BOREALIS_TLS_*`` TLS certificate, private key, and bundle paths. + +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. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import asdict, dataclass, field +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path +from typing import Any, List, Mapping, MutableMapping, Optional, Sequence + +try: # pragma: no-cover - optional dependency during early migration stages. + from Modules.crypto import certificates # type: ignore +except Exception: # pragma: no-cover - Engine configuration still works without it. + certificates = None # type: ignore[assignment] + + +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" + + +def _ensure_parent(path: Path) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + except Exception: + # Directory creation failure is non-fatal; subsequent file operations + # will surface the issue with clearer context. + pass + + +def _resolve_static_folder() -> str: + candidates = [ + ENGINE_DIR / "web-interface" / "build", + ENGINE_DIR / "web-interface" / "dist", + ENGINE_DIR / "web-interface", + ] + for candidate in candidates: + absolute = candidate.resolve() + if absolute.is_dir(): + return str(absolute) + return str(candidates[0].resolve()) + + +def _parse_origins(raw: Optional[Any]) -> Optional[List[str]]: + if raw is None: + return None + if isinstance(raw, str): + parts = [part.strip() for part in raw.split(",")] + elif isinstance(raw, Sequence): + parts = [str(part).strip() for part in raw] + else: + return None + origins = [part for part in parts if part] + return origins or None + + +def _parse_bool(raw: Any, *, default: bool = False) -> bool: + if raw is None: + return default + if isinstance(raw, bool): + return raw + lowered = str(raw).strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return default + + +def _discover_tls_material(config: Mapping[str, Any]) -> Sequence[Optional[str]]: + cert_path = config.get("TLS_CERT_PATH") or os.environ.get("BOREALIS_TLS_CERT") or None + key_path = config.get("TLS_KEY_PATH") or os.environ.get("BOREALIS_TLS_KEY") or None + bundle_path = config.get("TLS_BUNDLE_PATH") or os.environ.get("BOREALIS_TLS_BUNDLE") or None + + if certificates and not all([cert_path, key_path, bundle_path]): + try: + auto_cert, auto_key, auto_bundle = certificates.certificate_paths() + except Exception: + auto_cert = auto_key = auto_bundle = None + else: + cert_path = cert_path or auto_cert + key_path = key_path or auto_key + bundle_path = bundle_path or auto_bundle + + if cert_path: + os.environ.setdefault("BOREALIS_TLS_CERT", str(cert_path)) + if key_path: + os.environ.setdefault("BOREALIS_TLS_KEY", str(key_path)) + if bundle_path: + os.environ.setdefault("BOREALIS_TLS_BUNDLE", str(bundle_path)) + + return cert_path, key_path, bundle_path + + +@dataclass +class EngineSettings: + """Resolved configuration values for the Engine runtime.""" + + database_path: str + static_folder: str + cors_origins: Optional[List[str]] + secret_key: str + session_cookie_samesite: str + session_cookie_secure: bool + session_cookie_domain: Optional[str] + tls_cert_path: Optional[str] + tls_key_path: Optional[str] + tls_bundle_path: Optional[str] + log_file: str + raw: MutableMapping[str, Any] = field(default_factory=dict) + + def to_flask_config(self) -> MutableMapping[str, Any]: + config: MutableMapping[str, Any] = { + "SESSION_COOKIE_HTTPONLY": True, + "SESSION_COOKIE_SAMESITE": self.session_cookie_samesite, + "SESSION_COOKIE_SECURE": self.session_cookie_secure, + "PREFERRED_URL_SCHEME": "https", + } + if self.session_cookie_domain: + config["SESSION_COOKIE_DOMAIN"] = self.session_cookie_domain + return config + + def as_dict(self) -> MutableMapping[str, Any]: + data = asdict(self) + data["raw"] = dict(self.raw) + return data + + +def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> EngineSettings: + """Resolve Engine configuration values. + + Parameters + ---------- + overrides: + Optional mapping of explicit configuration values. These take + precedence over environment variables and built-in defaults. + """ + + runtime_config: MutableMapping[str, Any] = dict(overrides or {}) + + database_path = str( + runtime_config.get("DATABASE_PATH") + or os.environ.get("BOREALIS_DATABASE_PATH") + or DEFAULT_DATABASE_PATH + ) + database_path = os.path.abspath(database_path) + _ensure_parent(Path(database_path)) + + static_folder = str(runtime_config.get("STATIC_FOLDER") or _resolve_static_folder()) + + cors_origins = _parse_origins( + runtime_config.get("CORS_ORIGINS") or os.environ.get("BOREALIS_CORS_ORIGINS") + ) + + secret_key = str(runtime_config.get("SECRET_KEY") or os.environ.get("BOREALIS_SECRET") or "borealis-dev-secret") + + session_cookie_samesite = str( + runtime_config.get("SESSION_COOKIE_SAMESITE") + or os.environ.get("BOREALIS_COOKIE_SAMESITE") + or "Lax" + ) + + session_cookie_secure = _parse_bool( + runtime_config.get("SESSION_COOKIE_SECURE"), + default=_parse_bool(os.environ.get("BOREALIS_COOKIE_SECURE"), default=False), + ) + + session_cookie_domain = runtime_config.get("SESSION_COOKIE_DOMAIN") or os.environ.get("BOREALIS_COOKIE_DOMAIN") + session_cookie_domain = str(session_cookie_domain) if session_cookie_domain else None + + tls_cert_path, tls_key_path, tls_bundle_path = _discover_tls_material(runtime_config) + + log_file = str(runtime_config.get("LOG_FILE") or LOG_FILE_PATH) + _ensure_parent(Path(log_file)) + + settings = EngineSettings( + database_path=database_path, + static_folder=static_folder, + cors_origins=cors_origins, + secret_key=secret_key, + session_cookie_samesite=session_cookie_samesite, + session_cookie_secure=session_cookie_secure, + session_cookie_domain=session_cookie_domain, + tls_cert_path=tls_cert_path if tls_cert_path else None, + 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), + raw=runtime_config, + ) + return settings + + +def initialise_engine_logger(settings: EngineSettings, name: str = "borealis.engine") -> logging.Logger: + """Configure the Engine logger to write to the shared server log.""" + + logger = logging.getLogger(name) + if not logger.handlers: + formatter = logging.Formatter("%(asctime)s-%(name)s-%(levelname)s: %(message)s") + + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(formatter) + logger.addHandler(stream_handler) + + file_handler = TimedRotatingFileHandler( + settings.log_file, + when="midnight", + backupCount=0, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + logger.setLevel(logging.INFO) + logger.propagate = False + return logger + + +__all__ = [ + "EngineSettings", + "initialise_engine_logger", + "load_runtime_config", +] diff --git a/Data/Engine/server.py b/Data/Engine/server.py index d446e26..244577d 100644 --- a/Data/Engine/server.py +++ b/Data/Engine/server.py @@ -1,18 +1,20 @@ -"""Stage 1 Borealis Engine application factory. +"""Stage 2 Borealis Engine application factory. -This module establishes the foundational structure for the Engine runtime so -subsequent migration stages can progressively assume responsibility for the -API, WebUI, and WebSocket layers. +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. """ from __future__ import annotations import logging -import os import ssl import sys from dataclasses import dataclass from pathlib import Path -from typing import Any, Mapping, MutableMapping, Optional, Tuple +from typing import Any, Mapping, Optional, Tuple import eventlet from flask import Flask @@ -87,10 +89,7 @@ for root in _SEARCH_ROOTS: if root_str not in sys.path: sys.path.insert(0, root_str) -try: # pragma: no-cover - optional during Stage 1 scaffolding. - from Modules.crypto import certificates # type: ignore -except Exception: # pragma: no-cover - Engine can start without certificate helpers. - certificates = None # type: ignore[assignment] +from .config import EngineSettings, initialise_engine_logger, load_runtime_config @dataclass @@ -109,103 +108,27 @@ class EngineContext: __all__ = ["EngineContext", "create_app"] -def _resolve_static_folder() -> str: - candidates = [ - _ENGINE_DIR / "web-interface" / "build", - _ENGINE_DIR / "web-interface" / "dist", - _ENGINE_DIR / "web-interface", - ] - for candidate in candidates: - absolute = candidate.resolve() - if absolute.is_dir(): - return str(absolute) - # Fall back to the first candidate to maintain parity with the legacy - # behaviour where the folder may not exist yet. - return str(candidates[0].resolve()) - - -def _initialise_logger(name: str = "borealis.engine") -> logging.Logger: - logger = logging.getLogger(name) - if not logger.handlers: - handler = logging.StreamHandler() - formatter = logging.Formatter("%(asctime)s-%(name)s-%(levelname)s: %(message)s") - handler.setFormatter(formatter) - logger.addHandler(handler) - logger.setLevel(logging.INFO) - logger.propagate = False - return logger - - -def _discover_tls_material(config: Mapping[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str]]: - cert_path = str(config.get("TLS_CERT_PATH") or os.environ.get("BOREALIS_TLS_CERT") or "") or None - key_path = str(config.get("TLS_KEY_PATH") or os.environ.get("BOREALIS_TLS_KEY") or "") or None - bundle_path = str(config.get("TLS_BUNDLE_PATH") or os.environ.get("BOREALIS_TLS_BUNDLE") or "") or None - - if certificates and not all([cert_path, key_path, bundle_path]): - try: - auto_cert, auto_key, auto_bundle = certificates.certificate_paths() - except Exception: - auto_cert = auto_key = auto_bundle = None - else: - cert_path = cert_path or auto_cert - key_path = key_path or auto_key - bundle_path = bundle_path or auto_bundle - - if cert_path: - os.environ.setdefault("BOREALIS_TLS_CERT", cert_path) - if key_path: - os.environ.setdefault("BOREALIS_TLS_KEY", key_path) - if bundle_path: - os.environ.setdefault("BOREALIS_TLS_BUNDLE", bundle_path) - return cert_path, key_path, bundle_path - - -def _coerce_config(source: Optional[Mapping[str, Any]]) -> MutableMapping[str, Any]: - if source is None: - return {} - return dict(source) - - def create_app(config: Optional[Mapping[str, Any]] = None) -> Tuple[Flask, SocketIO, EngineContext]: - """Create the Stage 1 Engine Flask application.""" + """Create the Stage 2 Engine Flask application.""" - runtime_config: MutableMapping[str, Any] = _coerce_config(config) - logger = _initialise_logger() + settings: EngineSettings = load_runtime_config(config) + logger = initialise_engine_logger(settings) - database_path = runtime_config.get("DATABASE_PATH") or os.path.abspath( - os.path.join(_ENGINE_DIR, "..", "..", "database.db") - ) - os.makedirs(os.path.dirname(database_path), exist_ok=True) + database_path = settings.database_path - static_folder = _resolve_static_folder() + static_folder = settings.static_folder app = Flask(__name__, static_folder=static_folder, static_url_path="") app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) - cors_origins = runtime_config.get("CORS_ORIGINS") or os.environ.get("BOREALIS_CORS_ORIGINS") + cors_origins = settings.cors_origins if cors_origins: - origins = [origin.strip() for origin in str(cors_origins).split(",") if origin.strip()] - CORS(app, supports_credentials=True, origins=origins) + CORS(app, supports_credentials=True, origins=cors_origins) else: CORS(app, supports_credentials=True) - app.secret_key = runtime_config.get("SECRET_KEY") or os.environ.get("BOREALIS_SECRET", "borealis-dev-secret") + app.secret_key = settings.secret_key - app.config.update( - SESSION_COOKIE_HTTPONLY=True, - SESSION_COOKIE_SAMESITE=runtime_config.get( - "SESSION_COOKIE_SAMESITE", - os.environ.get("BOREALIS_COOKIE_SAMESITE", "Lax"), - ), - SESSION_COOKIE_SECURE=bool( - str(runtime_config.get("SESSION_COOKIE_SECURE", os.environ.get("BOREALIS_COOKIE_SECURE", "0"))).lower() - in {"1", "true", "yes"} - ), - ) - app.config.setdefault("PREFERRED_URL_SCHEME", "https") - - cookie_domain = runtime_config.get("SESSION_COOKIE_DOMAIN") or os.environ.get("BOREALIS_COOKIE_DOMAIN") - if cookie_domain: - app.config["SESSION_COOKIE_DOMAIN"] = cookie_domain + app.config.update(settings.to_flask_config()) socketio = SocketIO( app, @@ -217,7 +140,11 @@ def create_app(config: Optional[Mapping[str, Any]] = None) -> Tuple[Flask, Socke }, ) - tls_cert_path, tls_key_path, tls_bundle_path = _discover_tls_material(runtime_config) + tls_cert_path, tls_key_path, tls_bundle_path = ( + settings.tls_cert_path, + settings.tls_key_path, + settings.tls_bundle_path, + ) context = EngineContext( database_path=database_path, @@ -226,7 +153,7 @@ def create_app(config: Optional[Mapping[str, Any]] = None) -> Tuple[Flask, Socke tls_cert_path=tls_cert_path, tls_key_path=tls_key_path, tls_bundle_path=tls_bundle_path, - config=runtime_config, + config=settings.as_dict(), ) from .services import API, WebSocket, WebUI # Local import to avoid circular deps during bootstrap