Implement Stage 2 Engine configuration handling

This commit is contained in:
2025-10-26 01:10:07 -06:00
parent bcb8ccaeb6
commit 1fdc9ffc13
5 changed files with 299 additions and 106 deletions

View File

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

View File

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

View File

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

254
Data/Engine/config.py Normal file
View File

@@ -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
``<ProjectRoot>/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",
]

View File

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