mirror of
				https://github.com/bunny-lab-io/Borealis.git
				synced 2025-10-26 15:41:58 -06:00 
			
		
		
		
	Implement Stage 3 Engine API adapters
This commit is contained in:
		| @@ -18,10 +18,10 @@ Lastly, everytime that you complete a stage, you will create a pull request name | ||||
|   - [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. | ||||
|   - [ ] Add configuration toggles for enabling API groups incrementally. | ||||
| - [x] **Stage 3 — Introduce API blueprints and service adapters** | ||||
|   - [x] Create domain-focused API blueprints and register_api entry point. | ||||
|   - [x] Mirror route behaviour from the legacy server via service adapters. | ||||
|   - [x] Add configuration toggles for enabling API groups incrementally. | ||||
| - [ ] **Stage 4 — Build unit and smoke tests for Engine APIs** | ||||
|   - [ ] Add pytest modules under Data/Engine/Unit_Tests exercising API blueprints. | ||||
|   - [ ] Provide fixtures that mirror the legacy SQLite schema and seed data. | ||||
| @@ -43,5 +43,5 @@ Lastly, everytime that you complete a stage, you will create a pull request name | ||||
|   - [ ] Update legacy server to consume Engine WebSocket registration. | ||||
|  | ||||
| ## Current Status | ||||
| - **Stage:** Stage 2 — Port configuration and dependency loading into the Engine factory (completed) | ||||
| - **Stage:** Stage 3 — Introduce API blueprints and service adapters (completed) | ||||
| - **Active Task:** Awaiting next stage instructions. | ||||
|   | ||||
| @@ -37,7 +37,7 @@ 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 | ||||
| from typing import Any, Iterable, List, Mapping, MutableMapping, Optional, Sequence, Tuple | ||||
|  | ||||
| try:  # pragma: no-cover - optional dependency during early migration stages. | ||||
|     from Modules.crypto import certificates  # type: ignore | ||||
| @@ -139,6 +139,7 @@ class EngineSettings: | ||||
|     tls_key_path: Optional[str] | ||||
|     tls_bundle_path: Optional[str] | ||||
|     log_file: str | ||||
|     api_groups: Tuple[str, ...] | ||||
|     raw: MutableMapping[str, Any] = field(default_factory=dict) | ||||
|  | ||||
|     def to_flask_config(self) -> MutableMapping[str, Any]: | ||||
| @@ -158,6 +159,19 @@ class EngineSettings: | ||||
|         return data | ||||
|  | ||||
|  | ||||
| def _parse_api_groups(raw: Optional[Any]) -> Tuple[str, ...]: | ||||
|     if raw is None: | ||||
|         return tuple() | ||||
|     if isinstance(raw, str): | ||||
|         parts: Iterable[str] = (part.strip() for part in raw.split(",")) | ||||
|     elif isinstance(raw, Sequence): | ||||
|         parts = (str(part).strip() for part in raw) | ||||
|     else: | ||||
|         return tuple() | ||||
|     cleaned = [part.lower() for part in parts if part] | ||||
|     return tuple(dict.fromkeys(cleaned)) | ||||
|  | ||||
|  | ||||
| def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> EngineSettings: | ||||
|     """Resolve Engine configuration values. | ||||
|  | ||||
| @@ -205,6 +219,12 @@ 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)) | ||||
|  | ||||
|     api_groups = _parse_api_groups( | ||||
|         runtime_config.get("API_GROUPS") or os.environ.get("BOREALIS_API_GROUPS") | ||||
|     ) | ||||
|     if not api_groups: | ||||
|         api_groups = ("tokens", "enrollment") | ||||
|  | ||||
|     settings = EngineSettings( | ||||
|         database_path=database_path, | ||||
|         static_folder=static_folder, | ||||
| @@ -217,6 +237,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), | ||||
|         api_groups=api_groups, | ||||
|         raw=runtime_config, | ||||
|     ) | ||||
|     return settings | ||||
|   | ||||
| @@ -14,7 +14,7 @@ import ssl | ||||
| import sys | ||||
| from dataclasses import dataclass | ||||
| from pathlib import Path | ||||
| from typing import Any, Mapping, Optional, Tuple | ||||
| from typing import Any, Mapping, Optional, Sequence, Tuple | ||||
|  | ||||
| import eventlet | ||||
| from flask import Flask | ||||
| @@ -103,6 +103,7 @@ class EngineContext: | ||||
|     tls_key_path: Optional[str] | ||||
|     tls_bundle_path: Optional[str] | ||||
|     config: Mapping[str, Any] | ||||
|     api_groups: Sequence[str] | ||||
|  | ||||
|  | ||||
| __all__ = ["EngineContext", "create_app"] | ||||
| @@ -154,6 +155,7 @@ def create_app(config: Optional[Mapping[str, Any]] = None) -> Tuple[Flask, Socke | ||||
|         tls_key_path=tls_key_path, | ||||
|         tls_bundle_path=tls_bundle_path, | ||||
|         config=settings.as_dict(), | ||||
|         api_groups=settings.api_groups, | ||||
|     ) | ||||
|  | ||||
|     from .services import API, WebSocket, WebUI  # Local import to avoid circular deps during bootstrap | ||||
|   | ||||
| @@ -1,21 +1,208 @@ | ||||
| """API service stubs for the Borealis Engine runtime. | ||||
| """API service adapters for the Borealis Engine runtime. | ||||
|  | ||||
| Stage 1 only establishes the package layout. Future stages will populate this | ||||
| module with blueprint factories that wrap the legacy API helpers. | ||||
| Stage 3 of the migration introduces blueprint registration that mirrors the | ||||
| behaviour of :mod:`Data.Server.server` by delegating to the existing domain | ||||
| modules under ``Data/Server/Modules``.  Each adapter wires the Engine context | ||||
| into the legacy registration helpers so routes continue to function while | ||||
| configuration toggles control which API groups are exposed. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import datetime as _dt | ||||
| import logging | ||||
| import re | ||||
| import sqlite3 | ||||
| import time | ||||
| from dataclasses import dataclass, field | ||||
| from pathlib import Path | ||||
| from typing import Any, Callable, Iterable, Mapping, Optional, Sequence | ||||
|  | ||||
| from flask import Flask | ||||
|  | ||||
| from Modules.auth import jwt_service as jwt_service_module | ||||
| from Modules.auth.dpop import DPoPValidator | ||||
| from Modules.auth.rate_limit import SlidingWindowRateLimiter | ||||
| from Modules.crypto import signing | ||||
| from Modules.enrollment import routes as enrollment_routes | ||||
| from Modules.enrollment.nonce_store import NonceCache | ||||
| from Modules.tokens import routes as token_routes | ||||
|  | ||||
| from ...server import EngineContext | ||||
|  | ||||
| DEFAULT_API_GROUPS: Sequence[str] = ("tokens", "enrollment") | ||||
|  | ||||
| _SERVER_SCOPE_PATTERN = re.compile(r"\b(?:scope|context|agent_context)=([A-Za-z0-9_-]+)", re.IGNORECASE) | ||||
| _SERVER_AGENT_ID_PATTERN = re.compile(r"\bagent_id=([^\s,]+)", re.IGNORECASE) | ||||
|  | ||||
|  | ||||
| def _canonical_server_scope(raw: Optional[str]) -> Optional[str]: | ||||
|     if not raw: | ||||
|         return None | ||||
|     value = "".join(ch for ch in str(raw) if ch.isalnum() or ch in ("_", "-")) | ||||
|     if not value: | ||||
|         return None | ||||
|     return value.upper() | ||||
|  | ||||
|  | ||||
| def _scope_from_agent_id(agent_id: Optional[str]) -> Optional[str]: | ||||
|     candidate = _canonical_server_scope(agent_id) | ||||
|     if not candidate: | ||||
|         return None | ||||
|     if candidate.endswith("_SYSTEM"): | ||||
|         return "SYSTEM" | ||||
|     if candidate.endswith("_CURRENTUSER"): | ||||
|         return "CURRENTUSER" | ||||
|     return candidate | ||||
|  | ||||
|  | ||||
| def _infer_server_scope(message: str, explicit: Optional[str]) -> Optional[str]: | ||||
|     scope = _canonical_server_scope(explicit) | ||||
|     if scope: | ||||
|         return scope | ||||
|     match = _SERVER_SCOPE_PATTERN.search(message or "") | ||||
|     if match: | ||||
|         scope = _canonical_server_scope(match.group(1)) | ||||
|         if scope: | ||||
|             return scope | ||||
|     agent_match = _SERVER_AGENT_ID_PATTERN.search(message or "") | ||||
|     if agent_match: | ||||
|         scope = _scope_from_agent_id(agent_match.group(1)) | ||||
|         if scope: | ||||
|             return scope | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def _rotate_daily(path: Path) -> None: | ||||
|     try: | ||||
|         if not path.is_file(): | ||||
|             return | ||||
|         stat = path.stat() | ||||
|         modified = _dt.datetime.fromtimestamp(stat.st_mtime) | ||||
|         if modified.date() == _dt.datetime.now().date(): | ||||
|             return | ||||
|         suffix = modified.strftime("%Y-%m-%d") | ||||
|         rotated = path.with_name(f"{path.name}.{suffix}") | ||||
|         if rotated.exists(): | ||||
|             return | ||||
|         path.rename(rotated) | ||||
|     except Exception: | ||||
|         pass | ||||
|  | ||||
|  | ||||
| def _make_service_logger(base: Path, logger: logging.Logger) -> Callable[[str, str, Optional[str]], None]: | ||||
|     def _log(service: str, msg: str, scope: Optional[str] = None, *, level: str = "INFO") -> None: | ||||
|         level_upper = level.upper() | ||||
|         try: | ||||
|             base.mkdir(parents=True, exist_ok=True) | ||||
|             path = base / f"{service}.log" | ||||
|             _rotate_daily(path) | ||||
|             timestamp = time.strftime("%Y-%m-%d %H:%M:%S") | ||||
|             resolved_scope = _infer_server_scope(msg, scope) | ||||
|             prefix_parts = [f"[{level_upper}]"] | ||||
|             if resolved_scope: | ||||
|                 prefix_parts.append(f"[CONTEXT-{resolved_scope}]") | ||||
|             prefix = "".join(prefix_parts) | ||||
|             with path.open("a", encoding="utf-8") as handle: | ||||
|                 handle.write(f"[{timestamp}] {prefix} {msg}\n") | ||||
|         except Exception: | ||||
|             logger.debug("Failed to write service log entry", exc_info=True) | ||||
|  | ||||
|         numeric_level = getattr(logging, level_upper, logging.INFO) | ||||
|         logger.log(numeric_level, "[service:%s] %s", service, msg) | ||||
|  | ||||
|     return _log | ||||
|  | ||||
|  | ||||
| def _make_db_conn_factory(database_path: str) -> Callable[[], sqlite3.Connection]: | ||||
|     def _factory() -> sqlite3.Connection: | ||||
|         conn = sqlite3.connect(database_path, timeout=15) | ||||
|         try: | ||||
|             cur = conn.cursor() | ||||
|             cur.execute("PRAGMA journal_mode=WAL") | ||||
|             cur.execute("PRAGMA busy_timeout=5000") | ||||
|             cur.execute("PRAGMA synchronous=NORMAL") | ||||
|             conn.commit() | ||||
|         except Exception: | ||||
|             pass | ||||
|         return conn | ||||
|  | ||||
|     return _factory | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class LegacyServiceAdapters: | ||||
|     context: EngineContext | ||||
|     db_conn_factory: Callable[[], sqlite3.Connection] = field(init=False) | ||||
|     jwt_service: Any = field(init=False) | ||||
|     dpop_validator: DPoPValidator = field(init=False) | ||||
|     ip_rate_limiter: SlidingWindowRateLimiter = field(init=False) | ||||
|     fp_rate_limiter: SlidingWindowRateLimiter = field(init=False) | ||||
|     nonce_cache: NonceCache = field(init=False) | ||||
|     script_signer: Any = field(init=False) | ||||
|     service_log: Callable[[str, str, Optional[str]], None] = field(init=False) | ||||
|  | ||||
|     def __post_init__(self) -> None: | ||||
|         self.db_conn_factory = _make_db_conn_factory(self.context.database_path) | ||||
|         self.jwt_service = jwt_service_module.load_service() | ||||
|         self.dpop_validator = DPoPValidator() | ||||
|         self.ip_rate_limiter = SlidingWindowRateLimiter() | ||||
|         self.fp_rate_limiter = SlidingWindowRateLimiter() | ||||
|         self.nonce_cache = NonceCache() | ||||
|         try: | ||||
|             self.script_signer = signing.load_signer() | ||||
|         except Exception: | ||||
|             self.script_signer = None | ||||
|  | ||||
|         log_file = str(self.context.config.get("log_file") or self.context.config.get("LOG_FILE") or "") | ||||
|         if log_file: | ||||
|             base = Path(log_file).resolve().parent | ||||
|         else: | ||||
|             base = Path(self.context.database_path).resolve().parent | ||||
|         self.service_log = _make_service_logger(base, self.context.logger) | ||||
|  | ||||
|  | ||||
| def _register_tokens(app: Flask, adapters: LegacyServiceAdapters) -> None: | ||||
|     token_routes.register( | ||||
|         app, | ||||
|         db_conn_factory=adapters.db_conn_factory, | ||||
|         jwt_service=adapters.jwt_service, | ||||
|         dpop_validator=adapters.dpop_validator, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def _register_enrollment(app: Flask, adapters: LegacyServiceAdapters) -> None: | ||||
|     tls_bundle = adapters.context.tls_bundle_path or "" | ||||
|     enrollment_routes.register( | ||||
|         app, | ||||
|         db_conn_factory=adapters.db_conn_factory, | ||||
|         log=adapters.service_log, | ||||
|         jwt_service=adapters.jwt_service, | ||||
|         tls_bundle_path=tls_bundle, | ||||
|         ip_rate_limiter=adapters.ip_rate_limiter, | ||||
|         fp_rate_limiter=adapters.fp_rate_limiter, | ||||
|         nonce_cache=adapters.nonce_cache, | ||||
|         script_signer=adapters.script_signer, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| _GROUP_REGISTRARS: Mapping[str, Callable[[Flask, LegacyServiceAdapters], None]] = { | ||||
|     "tokens": _register_tokens, | ||||
|     "enrollment": _register_enrollment, | ||||
| } | ||||
|  | ||||
|  | ||||
| def register_api(app: Flask, context: EngineContext) -> None: | ||||
|     """Placeholder hook for API blueprint registration. | ||||
|     """Register Engine API blueprints based on the enabled groups.""" | ||||
|  | ||||
|     Later migration stages will import domain-specific blueprint modules and | ||||
|     attach them to ``app`` using the shared :class:`EngineContext`. For now we | ||||
|     simply log the intent so tooling can verify the hook is wired. | ||||
|     """ | ||||
|     enabled_groups: Iterable[str] = context.api_groups or DEFAULT_API_GROUPS | ||||
|     normalized = [group.strip().lower() for group in enabled_groups if group] | ||||
|     adapters = LegacyServiceAdapters(context) | ||||
|  | ||||
|     for group in normalized: | ||||
|         registrar = _GROUP_REGISTRARS.get(group) | ||||
|         if registrar is None: | ||||
|             context.logger.info("Engine API group '%s' is not implemented; skipping.", group) | ||||
|             continue | ||||
|         registrar(app, adapters) | ||||
|         context.logger.info("Engine registered API group '%s'.", group) | ||||
|  | ||||
|     context.logger.debug("Engine API services are not yet implemented.") | ||||
|   | ||||
		Reference in New Issue
	
	Block a user