mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 15:41:58 -06:00
Merge pull request #145 from bunny-lab-io:codex/review-code-migration-tracker-instructions
Stage 3 - Introduce API blueprints and service adapters Implemented
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] Verify context parity between Engine and legacy startup.
|
||||||
- [x] Initialize logging to Logs/Server/server.log when Engine mode is active.
|
- [x] Initialize logging to Logs/Server/server.log when Engine mode is active.
|
||||||
- [x] Document Engine launch paths and configuration requirements in module docstrings.
|
- [x] Document Engine launch paths and configuration requirements in module docstrings.
|
||||||
- [ ] **Stage 3 — Introduce API blueprints and service adapters**
|
- [x] **Stage 3 — Introduce API blueprints and service adapters**
|
||||||
- [ ] Create domain-focused API blueprints and register_api entry point.
|
- [x] Create domain-focused API blueprints and register_api entry point.
|
||||||
- [ ] Mirror route behaviour from the legacy server via service adapters.
|
- [x] Mirror route behaviour from the legacy server via service adapters.
|
||||||
- [ ] Add configuration toggles for enabling API groups incrementally.
|
- [x] Add configuration toggles for enabling API groups incrementally.
|
||||||
- [ ] **Stage 4 — Build unit and smoke tests for Engine APIs**
|
- [ ] **Stage 4 — Build unit and smoke tests for Engine APIs**
|
||||||
- [ ] Add pytest modules under Data/Engine/Unit_Tests exercising API blueprints.
|
- [ ] Add pytest modules under Data/Engine/Unit_Tests exercising API blueprints.
|
||||||
- [ ] Provide fixtures that mirror the legacy SQLite schema and seed data.
|
- [ ] 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.
|
- [ ] Update legacy server to consume Engine WebSocket registration.
|
||||||
|
|
||||||
## Current Status
|
## 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.
|
- **Active Task:** Awaiting next stage instructions.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import os
|
|||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from logging.handlers import TimedRotatingFileHandler
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
from pathlib import Path
|
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.
|
try: # pragma: no-cover - optional dependency during early migration stages.
|
||||||
from Modules.crypto import certificates # type: ignore
|
from Modules.crypto import certificates # type: ignore
|
||||||
@@ -139,6 +139,7 @@ class EngineSettings:
|
|||||||
tls_key_path: Optional[str]
|
tls_key_path: Optional[str]
|
||||||
tls_bundle_path: Optional[str]
|
tls_bundle_path: Optional[str]
|
||||||
log_file: str
|
log_file: str
|
||||||
|
api_groups: Tuple[str, ...]
|
||||||
raw: MutableMapping[str, Any] = field(default_factory=dict)
|
raw: MutableMapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
def to_flask_config(self) -> MutableMapping[str, Any]:
|
def to_flask_config(self) -> MutableMapping[str, Any]:
|
||||||
@@ -158,6 +159,19 @@ class EngineSettings:
|
|||||||
return data
|
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:
|
def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> EngineSettings:
|
||||||
"""Resolve Engine configuration values.
|
"""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)
|
log_file = str(runtime_config.get("LOG_FILE") or LOG_FILE_PATH)
|
||||||
_ensure_parent(Path(log_file))
|
_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(
|
settings = EngineSettings(
|
||||||
database_path=database_path,
|
database_path=database_path,
|
||||||
static_folder=static_folder,
|
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_key_path=tls_key_path if tls_key_path else None,
|
||||||
tls_bundle_path=tls_bundle_path if tls_bundle_path else None,
|
tls_bundle_path=tls_bundle_path if tls_bundle_path else None,
|
||||||
log_file=str(log_file),
|
log_file=str(log_file),
|
||||||
|
api_groups=api_groups,
|
||||||
raw=runtime_config,
|
raw=runtime_config,
|
||||||
)
|
)
|
||||||
return settings
|
return settings
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import ssl
|
|||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Mapping, Optional, Tuple
|
from typing import Any, Mapping, Optional, Sequence, Tuple
|
||||||
|
|
||||||
import eventlet
|
import eventlet
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -103,6 +103,7 @@ class EngineContext:
|
|||||||
tls_key_path: Optional[str]
|
tls_key_path: Optional[str]
|
||||||
tls_bundle_path: Optional[str]
|
tls_bundle_path: Optional[str]
|
||||||
config: Mapping[str, Any]
|
config: Mapping[str, Any]
|
||||||
|
api_groups: Sequence[str]
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["EngineContext", "create_app"]
|
__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_key_path=tls_key_path,
|
||||||
tls_bundle_path=tls_bundle_path,
|
tls_bundle_path=tls_bundle_path,
|
||||||
config=settings.as_dict(),
|
config=settings.as_dict(),
|
||||||
|
api_groups=settings.api_groups,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .services import API, WebSocket, WebUI # Local import to avoid circular deps during bootstrap
|
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
|
Stage 3 of the migration introduces blueprint registration that mirrors the
|
||||||
module with blueprint factories that wrap the legacy API helpers.
|
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
|
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 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
|
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:
|
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
|
enabled_groups: Iterable[str] = context.api_groups or DEFAULT_API_GROUPS
|
||||||
attach them to ``app`` using the shared :class:`EngineContext`. For now we
|
normalized = [group.strip().lower() for group in enabled_groups if group]
|
||||||
simply log the intent so tooling can verify the hook is wired.
|
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