mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 23:01:58 -06:00
Fixed Engine Flask Server Accessibility
This commit is contained in:
@@ -32,11 +32,11 @@ Lastly, everytime that you complete a stage, you will create a pull request name
|
||||
- [x] Replace legacy API routes with Engine-provided blueprints gated by a flag.
|
||||
- [x] Emit transitional logging when Engine handles requests.
|
||||
- [ ] **Stage 6 — Plan WebUI migration**
|
||||
- [ ] Move static/template handling into Data/Engine/services/WebUI.
|
||||
- [x] Move static/template handling into Data/Engine/services/WebUI.
|
||||
- [x] Ensure that data from /Data/Server/WebUI is copied into /Engine/web-interface during engine Deployment via Borealis.ps1
|
||||
- [ ] Preserve TLS-aware URL generation and caching.
|
||||
- [x] Preserve TLS-aware URL generation and caching.
|
||||
- [ ] Add migration switch in the legacy server for WebUI delegation.
|
||||
- [ ] Extend tests to cover critical WebUI routes.
|
||||
- [x] Extend tests to cover critical WebUI routes.
|
||||
- [ ] **Stage 7 — Plan WebSocket migration**
|
||||
- [ ] Extract Socket.IO handlers into Data/Engine/services/WebSocket.
|
||||
- [ ] Provide register_realtime hook for the Engine factory.
|
||||
@@ -44,5 +44,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 5 — Bridge the legacy server to Engine APIs (completed)
|
||||
- **Active Task:** Awaiting next stage instructions.
|
||||
- **Stage:** Stage 6 — Plan WebUI migration
|
||||
- **Active Task:** Prepare legacy WebUI delegation switch (pending approval to touch legacy server).
|
||||
|
||||
@@ -117,6 +117,13 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[
|
||||
log_path = logs_dir / "server.log"
|
||||
error_log_path = logs_dir / "error.log"
|
||||
|
||||
static_dir = tmp_path / "static"
|
||||
static_dir.mkdir(parents=True, exist_ok=True)
|
||||
(static_dir / "index.html").write_text("<html><body>Engine Test UI</body></html>", encoding="utf-8")
|
||||
assets_dir = static_dir / "assets"
|
||||
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
(assets_dir / "example.txt").write_text("asset", encoding="utf-8")
|
||||
|
||||
config = {
|
||||
"DATABASE_PATH": str(db_path),
|
||||
"TLS_CERT_PATH": str(cert_path),
|
||||
@@ -124,7 +131,8 @@ def engine_harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[
|
||||
"TLS_BUNDLE_PATH": str(bundle_path),
|
||||
"LOG_FILE": str(log_path),
|
||||
"ERROR_LOG_FILE": str(error_log_path),
|
||||
"API_GROUPS": ("tokens", "enrollment"),
|
||||
"STATIC_FOLDER": str(static_dir),
|
||||
"API_GROUPS": ("core", "tokens", "enrollment"),
|
||||
}
|
||||
|
||||
app, _socketio, _context = create_app(config)
|
||||
|
||||
9
Data/Engine/Unit_Tests/test_core_api.py
Normal file
9
Data/Engine/Unit_Tests/test_core_api.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_health_endpoint(engine_harness):
|
||||
client = engine_harness.app.test_client()
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload == {"status": "ok"}
|
||||
23
Data/Engine/Unit_Tests/test_web_ui.py
Normal file
23
Data/Engine/Unit_Tests/test_web_ui.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_web_ui_root_serves_index(engine_harness):
|
||||
client = engine_harness.app.test_client()
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
body = response.get_data(as_text=True)
|
||||
assert "Engine Test UI" in body
|
||||
|
||||
|
||||
def test_web_ui_serves_static_assets(engine_harness):
|
||||
client = engine_harness.app.test_client()
|
||||
response = client.get("/assets/example.txt")
|
||||
assert response.status_code == 200
|
||||
assert response.get_data(as_text=True) == "asset"
|
||||
|
||||
|
||||
def test_web_ui_spa_fallback(engine_harness):
|
||||
client = engine_harness.app.test_client()
|
||||
response = client.get("/devices")
|
||||
assert response.status_code == 200
|
||||
assert "Engine Test UI" in response.get_data(as_text=True)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
legacy server defaults by binding to ``0.0.0.0:5000`` and honouring the
|
||||
``BOREALIS_ENGINE_*`` environment overrides for bind host/port.
|
||||
"""
|
||||
|
||||
@@ -11,14 +11,16 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .server import EngineContext, create_app
|
||||
|
||||
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 5001
|
||||
DEFAULT_PORT = 5000
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
@@ -37,16 +39,34 @@ def _project_root() -> Path:
|
||||
|
||||
|
||||
def _build_runtime_config() -> Dict[str, Any]:
|
||||
api_groups_override = os.environ.get("BOREALIS_ENGINE_API_GROUPS")
|
||||
if api_groups_override:
|
||||
api_groups: Any = api_groups_override
|
||||
else:
|
||||
api_groups = ("core", "tokens", "enrollment")
|
||||
|
||||
return {
|
||||
"HOST": os.environ.get("BOREALIS_ENGINE_HOST", DEFAULT_HOST),
|
||||
"PORT": int(os.environ.get("BOREALIS_ENGINE_PORT", DEFAULT_PORT)),
|
||||
"TLS_CERT_PATH": os.environ.get("BOREALIS_TLS_CERT"),
|
||||
"TLS_KEY_PATH": os.environ.get("BOREALIS_TLS_KEY"),
|
||||
"TLS_BUNDLE_PATH": os.environ.get("BOREALIS_TLS_BUNDLE"),
|
||||
"API_GROUPS": api_groups,
|
||||
}
|
||||
|
||||
|
||||
def _stage_web_interface_assets(logger: logging.Logger) -> None:
|
||||
def _stage_web_interface_assets(logger: Optional[logging.Logger] = None, *, force: bool = False) -> Path:
|
||||
"""Ensure Engine web interface assets are staged and return the staging root."""
|
||||
|
||||
if logger is None:
|
||||
logger = logging.getLogger("borealis.engine.bootstrap")
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s-%(name)s-%(levelname)s: %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
logger.propagate = False
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
project_root = _project_root()
|
||||
engine_web_root = project_root / "Engine" / "web-interface"
|
||||
legacy_source = project_root / "Data" / "Server" / "WebUI"
|
||||
@@ -56,20 +76,114 @@ def _stage_web_interface_assets(logger: logging.Logger) -> None:
|
||||
f"Engine web interface source missing: {legacy_source}"
|
||||
)
|
||||
|
||||
index_path = engine_web_root / "index.html"
|
||||
if engine_web_root.exists() and index_path.is_file() and not force:
|
||||
logger.info("Engine web interface already staged at %s; skipping copy.", engine_web_root)
|
||||
return engine_web_root
|
||||
|
||||
if engine_web_root.exists():
|
||||
shutil.rmtree(engine_web_root)
|
||||
|
||||
shutil.copytree(legacy_source, engine_web_root)
|
||||
|
||||
index_path = engine_web_root / "index.html"
|
||||
if not index_path.is_file():
|
||||
raise RuntimeError(
|
||||
f"Engine web interface staging failed; missing {index_path}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Engine web interface staged from %s to %s", legacy_source, engine_web_root
|
||||
)
|
||||
logger.info("Engine web interface staged from %s to %s", legacy_source, engine_web_root)
|
||||
return engine_web_root
|
||||
|
||||
|
||||
def _determine_static_folder(staging_root: Path) -> str:
|
||||
for candidate in (staging_root / "build", staging_root / "dist", staging_root):
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
return str(staging_root)
|
||||
|
||||
|
||||
def _resolve_npm_executable() -> str:
|
||||
env_cmd = os.environ.get("BOREALIS_NPM_CMD")
|
||||
if env_cmd:
|
||||
candidate = Path(env_cmd).expanduser()
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
|
||||
node_dir = os.environ.get("BOREALIS_NODE_DIR")
|
||||
if node_dir:
|
||||
candidate = Path(node_dir) / "npm.cmd"
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
candidate = Path(node_dir) / "npm.exe"
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
candidate = Path(node_dir) / "npm"
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
|
||||
if os.name == "nt":
|
||||
return "npm.cmd"
|
||||
return "npm"
|
||||
|
||||
|
||||
def _run_npm(args: list[str], cwd: Path, logger: logging.Logger) -> None:
|
||||
command = [_resolve_npm_executable()] + args
|
||||
logger.info("Running npm command: %s", " ".join(command))
|
||||
start = time.time()
|
||||
try:
|
||||
completed = subprocess.run(command, cwd=str(cwd), capture_output=True, text=True, check=False)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError("npm executable not found; ensure Node.js dependencies are installed.") from exc
|
||||
duration = time.time() - start
|
||||
if completed.returncode != 0:
|
||||
logger.error(
|
||||
"npm command failed (%ss): %s\nstdout: %s\nstderr: %s",
|
||||
f"{duration:.2f}",
|
||||
" ".join(command),
|
||||
completed.stdout.strip(),
|
||||
completed.stderr.strip(),
|
||||
)
|
||||
raise RuntimeError(f"npm command {' '.join(command)} failed with exit code {completed.returncode}")
|
||||
stdout = completed.stdout.strip()
|
||||
if stdout:
|
||||
logger.debug("npm stdout: %s", stdout)
|
||||
stderr = completed.stderr.strip()
|
||||
if stderr:
|
||||
logger.debug("npm stderr: %s", stderr)
|
||||
logger.info("npm command completed in %.2fs", duration)
|
||||
|
||||
|
||||
def _ensure_web_ui_build(staging_root: Path, logger: logging.Logger, *, mode: str) -> str:
|
||||
package_json = staging_root / "package.json"
|
||||
node_modules = staging_root / "node_modules"
|
||||
build_dir = staging_root / "build"
|
||||
needs_install = not node_modules.is_dir()
|
||||
needs_build = not build_dir.is_dir()
|
||||
|
||||
if not needs_build and package_json.is_file():
|
||||
build_index = build_dir / "index.html"
|
||||
if build_index.is_file():
|
||||
needs_build = build_index.stat().st_mtime < package_json.stat().st_mtime
|
||||
else:
|
||||
needs_build = True
|
||||
|
||||
if mode == "developer":
|
||||
logger.info("Engine launched in developer mode; skipping WebUI build step.")
|
||||
return _determine_static_folder(staging_root)
|
||||
|
||||
if not package_json.is_file():
|
||||
logger.warning("WebUI package.json not found at %s; continuing without rebuild.", package_json)
|
||||
return _determine_static_folder(staging_root)
|
||||
|
||||
if needs_install:
|
||||
_run_npm(["install", "--silent", "--no-fund", "--audit=false"], staging_root, logger)
|
||||
|
||||
if needs_build:
|
||||
_run_npm(["run", "build"], staging_root, logger)
|
||||
else:
|
||||
logger.info("Existing WebUI build found at %s; reuse.", build_dir)
|
||||
|
||||
return _determine_static_folder(staging_root)
|
||||
|
||||
|
||||
def _ensure_tls_material(context: EngineContext) -> None:
|
||||
@@ -137,13 +251,24 @@ def _prepare_tls_run_kwargs(context: EngineContext) -> Dict[str, Any]:
|
||||
|
||||
def main() -> None:
|
||||
config = _build_runtime_config()
|
||||
mode = os.environ.get("BOREALIS_ENGINE_MODE", "production").strip().lower() or "production"
|
||||
try:
|
||||
staging_root = _stage_web_interface_assets()
|
||||
except Exception as exc:
|
||||
logging.getLogger("borealis.engine.bootstrap").error(
|
||||
"Failed to stage Engine web interface: %s", exc
|
||||
)
|
||||
raise
|
||||
|
||||
if staging_root:
|
||||
bootstrap_logger = logging.getLogger("borealis.engine.bootstrap")
|
||||
static_folder = _ensure_web_ui_build(staging_root, bootstrap_logger, mode=mode)
|
||||
config.setdefault("STATIC_FOLDER", static_folder)
|
||||
|
||||
app, socketio, context = create_app(config)
|
||||
|
||||
try:
|
||||
_stage_web_interface_assets(context.logger)
|
||||
except Exception as exc:
|
||||
context.logger.error("Failed to stage Engine web interface: %s", exc)
|
||||
raise
|
||||
if staging_root:
|
||||
context.logger.info("Engine WebUI assets ready at %s", config["STATIC_FOLDER"])
|
||||
|
||||
host = config.get("HOST", DEFAULT_HOST)
|
||||
port = int(config.get("PORT", DEFAULT_PORT))
|
||||
|
||||
@@ -47,7 +47,26 @@ except Exception: # pragma: no-cover - Engine configuration still works without
|
||||
|
||||
|
||||
ENGINE_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = ENGINE_DIR.parent.parent
|
||||
|
||||
|
||||
def _discover_project_root() -> Path:
|
||||
"""Locate the project root by searching for Borealis.ps1 or using overrides."""
|
||||
|
||||
env_override = os.environ.get("BOREALIS_PROJECT_ROOT")
|
||||
if env_override:
|
||||
env_path = Path(env_override).expanduser().resolve()
|
||||
if env_path.is_dir():
|
||||
return env_path
|
||||
|
||||
current = ENGINE_DIR
|
||||
for candidate in (current, *current.parents):
|
||||
if (candidate / "Borealis.ps1").is_file():
|
||||
return candidate
|
||||
|
||||
return ENGINE_DIR.parent.parent
|
||||
|
||||
|
||||
PROJECT_ROOT = _discover_project_root()
|
||||
DEFAULT_DATABASE_PATH = PROJECT_ROOT / "database.db"
|
||||
LOG_ROOT = PROJECT_ROOT / "Logs" / "Engine"
|
||||
LOG_FILE_PATH = LOG_ROOT / "engine.log"
|
||||
@@ -66,26 +85,28 @@ def _ensure_parent(path: Path) -> None:
|
||||
def _resolve_static_folder() -> str:
|
||||
candidate_roots = [
|
||||
PROJECT_ROOT / "Engine" / "web-interface",
|
||||
PROJECT_ROOT / "web-interface",
|
||||
ENGINE_DIR.parent / "Engine" / "web-interface",
|
||||
ENGINE_DIR.parent / "web-interface",
|
||||
ENGINE_DIR / "web-interface",
|
||||
PROJECT_ROOT / "Data" / "Server" / "web-interface",
|
||||
]
|
||||
|
||||
candidates = []
|
||||
resolved_roots: List[Path] = []
|
||||
for root in candidate_roots:
|
||||
absolute_root = root.resolve()
|
||||
candidates.extend(
|
||||
[
|
||||
absolute_root / "build",
|
||||
absolute_root / "dist",
|
||||
absolute_root,
|
||||
]
|
||||
)
|
||||
absolute_root = root.expanduser().resolve()
|
||||
if absolute_root not in resolved_roots:
|
||||
resolved_roots.append(absolute_root)
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
for root in resolved_roots:
|
||||
for candidate in (root / "build", root / "dist", root):
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
|
||||
return str(candidates[0])
|
||||
if resolved_roots:
|
||||
return str(resolved_roots[0])
|
||||
|
||||
return str((PROJECT_ROOT / "Engine" / "web-interface").resolve())
|
||||
|
||||
|
||||
def _parse_origins(raw: Optional[Any]) -> Optional[List[str]]:
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 Blueprint, Flask, jsonify
|
||||
|
||||
from Modules.auth import jwt_service as jwt_service_module
|
||||
from Modules.auth.dpop import DPoPValidator
|
||||
@@ -191,18 +191,36 @@ _GROUP_REGISTRARS: Mapping[str, Callable[[Flask, LegacyServiceAdapters], None]]
|
||||
}
|
||||
|
||||
|
||||
def _register_core(app: Flask, context: EngineContext) -> None:
|
||||
"""Register core utility endpoints that do not require legacy adapters."""
|
||||
|
||||
blueprint = Blueprint("engine_core", __name__)
|
||||
|
||||
@blueprint.route("/health", methods=["GET"])
|
||||
def health() -> Any:
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
app.register_blueprint(blueprint)
|
||||
context.logger.info("Engine registered API group 'core'.")
|
||||
|
||||
|
||||
def register_api(app: Flask, context: EngineContext) -> None:
|
||||
"""Register Engine API blueprints based on the enabled groups."""
|
||||
|
||||
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)
|
||||
adapters: Optional[LegacyServiceAdapters] = None
|
||||
|
||||
for group in normalized:
|
||||
if group == "core":
|
||||
_register_core(app, context)
|
||||
continue
|
||||
|
||||
if adapters is None:
|
||||
adapters = LegacyServiceAdapters(context)
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,17 +1,86 @@
|
||||
"""WebUI service stubs for the Borealis Engine runtime.
|
||||
|
||||
The future WebUI migration will centralise static asset serving, template
|
||||
rendering, and dev-server proxying here. Stage 1 keeps the placeholder so the
|
||||
application factory can stub out registration calls.
|
||||
"""
|
||||
"""WebUI static asset handling for the Borealis Engine runtime."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Flask
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from flask import Blueprint, Flask, request, send_from_directory
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from ...server import EngineContext
|
||||
|
||||
_WEBUI_BLUEPRINT_NAME = "engine_webui"
|
||||
|
||||
|
||||
def _resolve_static_root(app: Flask, context: EngineContext) -> Optional[Path]:
|
||||
static_folder = app.static_folder
|
||||
if not static_folder:
|
||||
context.logger.error("Engine WebUI static folder is not configured.")
|
||||
return None
|
||||
|
||||
static_path = Path(static_folder).resolve()
|
||||
if not static_path.is_dir():
|
||||
context.logger.error("Engine WebUI static folder missing: %s", static_path)
|
||||
return None
|
||||
|
||||
index_path = static_path / "index.html"
|
||||
if not index_path.is_file():
|
||||
context.logger.error("Engine WebUI missing index.html at %s", index_path)
|
||||
return None
|
||||
|
||||
return static_path
|
||||
|
||||
|
||||
def _register_spa_routes(app: Flask, static_root: Path, logger: logging.Logger) -> None:
|
||||
blueprint = Blueprint(
|
||||
_WEBUI_BLUEPRINT_NAME,
|
||||
__name__,
|
||||
static_folder=str(static_root),
|
||||
static_url_path="",
|
||||
)
|
||||
|
||||
def send_index():
|
||||
return send_from_directory(str(static_root), "index.html")
|
||||
|
||||
@blueprint.route("/", defaults={"requested_path": ""})
|
||||
@blueprint.route("/<path:requested_path>")
|
||||
def spa_entry(requested_path: str) -> object:
|
||||
if requested_path:
|
||||
try:
|
||||
return send_from_directory(str(static_root), requested_path)
|
||||
except NotFound:
|
||||
logger.debug("Engine WebUI asset not found: %s", requested_path)
|
||||
return send_index()
|
||||
|
||||
app.register_blueprint(blueprint)
|
||||
|
||||
if getattr(app, "_engine_webui_fallback_installed", False):
|
||||
return
|
||||
|
||||
def _spa_fallback(error):
|
||||
request_path = (request.path or "").strip()
|
||||
if request_path.startswith("/api") or request_path.startswith("/socket.io"):
|
||||
return error
|
||||
if "." in Path(request_path).name:
|
||||
return error
|
||||
if request.method not in {"GET", "HEAD"}:
|
||||
return error
|
||||
try:
|
||||
return send_index()
|
||||
except Exception:
|
||||
return error
|
||||
|
||||
app.register_error_handler(404, _spa_fallback)
|
||||
setattr(app, "_engine_webui_fallback_installed", True)
|
||||
|
||||
|
||||
def register_web_ui(app: Flask, context: EngineContext) -> None:
|
||||
"""Placeholder hook for WebUI route registration."""
|
||||
"""Register static asset routes for the Engine WebUI."""
|
||||
|
||||
context.logger.debug("Engine WebUI services are not yet implemented.")
|
||||
static_root = _resolve_static_root(app, context)
|
||||
if static_root is None:
|
||||
return
|
||||
|
||||
_register_spa_routes(app, static_root, context.logger)
|
||||
context.logger.info("Engine WebUI registered static assets from %s", static_root)
|
||||
|
||||
Reference in New Issue
Block a user