Fixed Engine Flask Server Accessibility

This commit is contained in:
2025-10-26 21:46:23 -06:00
parent d3e728c127
commit 95b3e55bc7
10 changed files with 392 additions and 57 deletions

30
.vscode/tasks.json vendored
View File

@@ -31,6 +31,36 @@
},
"problemMatcher": []
},
{
"label": "Borealis - Engine (Production)",
"type": "shell",
"command": "powershell.exe",
"args": [
"-ExecutionPolicy", "Bypass",
"-File", "${workspaceFolder}/Borealis.ps1",
"-EngineProduction"
],
"presentation": {
"reveal": "always",
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "Borealis - Engine (Dev)",
"type": "shell",
"command": "powershell.exe",
"args": [
"-ExecutionPolicy", "Bypass",
"-File", "${workspaceFolder}/Borealis.ps1",
"-EngineDev"
],
"presentation": {
"reveal": "always",
"panel": "shared"
},
"problemMatcher": []
},
{
"label": "Borealis - Agent - Deploy",
"type": "shell",

View File

@@ -10,6 +10,8 @@ param(
[switch]$Flask,
[switch]$Quick,
[switch]$EngineTests,
[switch]$EngineProduction,
[switch]$EngineDev,
[string]$InstallerCode = ''
)
@@ -17,6 +19,7 @@ param(
$choice = $null
$modeChoice = $null
$agentSubChoice = $null
$engineModeChoice = $null
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
@@ -48,6 +51,16 @@ if ($Vite -and $Flask) {
exit 1
}
if ($EngineProduction -and $EngineDev) {
Write-Host "Cannot combine -EngineProduction and -EngineDev." -ForegroundColor Red
exit 1
}
if (($EngineProduction -or $EngineDev) -and ($Server -or $Agent)) {
Write-Host "Engine automation switches cannot be combined with -Server or -Agent." -ForegroundColor Red
exit 1
}
if ($Server) {
# Auto-select main menu option for Server when -Server flag is provided
$choice = '1'
@@ -60,6 +73,10 @@ if ($Server) {
'launch' { $agentSubChoice = '4' }
default { $agentSubChoice = '1' }
}
} elseif ($EngineProduction -or $EngineDev) {
$choice = '5'
if ($EngineProduction) { $engineModeChoice = '1' }
if ($EngineDev) { $engineModeChoice = '3' }
}
if ($Server) {
@@ -1078,7 +1095,11 @@ switch ($choice) {
foreach ($tool in @($pythonExe, $nodeExe, $npmCmd, $npxCmd)) {
if (-not (Test-Path $tool)) { Write-Host "`r$($symbols.Fail) Bundled executable not found at '$tool'." -ForegroundColor Red; exit 1 }
}
$env:PATH = '{0};{1};{2}' -f (Split-Path $pythonExe), (Split-Path $nodeExe), $env:PATH
$nodeDir = Split-Path $nodeExe
$env:BOREALIS_NODE_DIR = $nodeDir
$env:BOREALIS_NPM_CMD = $npmCmd
$env:BOREALIS_NPX_CMD = $npxCmd
$env:PATH = '{0};{1};{2}' -f (Split-Path $pythonExe), $nodeDir, $env:PATH
if (-not $modeChoice) {
Write-Host " "
@@ -1315,14 +1336,22 @@ switch ($choice) {
exit 1
}
}
$env:PATH = '{0};{1};{2}' -f (Split-Path $pythonExe), (Split-Path $nodeExe), $env:PATH
$nodeDir = Split-Path $nodeExe
$env:BOREALIS_NODE_DIR = $nodeDir
$env:BOREALIS_NPM_CMD = $npmCmd
$env:BOREALIS_NPX_CMD = $npxCmd
$env:PATH = '{0};{1};{2}' -f (Split-Path $pythonExe), $nodeDir, $env:PATH
Write-Host " "
Write-Host "Configure Borealis Engine Mode:" -ForegroundColor DarkYellow
Write-Host " 1) Build & Launch > Production Flask Server @ http://localhost:5001" -ForegroundColor DarkCyan
Write-Host " 2) [Skip Build] & Immediately Launch > Production Flask Server @ http://localhost:5001" -ForegroundColor DarkCyan
Write-Host " 3) Launch > [Hotload-Ready] Vite Dev Server @ http://localhost:5173" -ForegroundColor DarkCyan
$engineModeChoice = Read-Host "Enter choice [1/2/3]"
if (-not $engineModeChoice) {
Write-Host " "
Write-Host "Configure Borealis Engine Mode:" -ForegroundColor DarkYellow
Write-Host " 1) Build & Launch > Production Flask Server @ https://localhost:5000" -ForegroundColor DarkCyan
Write-Host " 2) [Skip Build] & Immediately Launch > Production Flask Server @ https://localhost:5000" -ForegroundColor DarkCyan
Write-Host " 3) Launch > [Hotload-Ready] Vite Dev Server @ http://localhost:5173" -ForegroundColor DarkCyan
$engineModeChoice = Read-Host "Enter choice [1/2/3]"
} else {
Write-Host "Auto-selecting Borealis Engine mode option $engineModeChoice." -ForegroundColor DarkYellow
}
$engineOperationMode = "production"
$engineImmediateLaunch = $false
@@ -1347,7 +1376,7 @@ switch ($choice) {
$previousEngineMode = $env:BOREALIS_ENGINE_MODE
$previousEnginePort = $env:BOREALIS_ENGINE_PORT
$env:BOREALIS_ENGINE_MODE = $engineOperationMode
$env:BOREALIS_ENGINE_PORT = "5001"
$env:BOREALIS_ENGINE_PORT = "5000"
Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green
Write-Host "===================================================================================="
Write-Host "$($symbols.Running) Engine Socket Server Started..."
@@ -1448,10 +1477,13 @@ switch ($choice) {
$webUIDestinationAbsolute = Join-Path $scriptDir $webUIDestination
if (Test-Path $webUIDestinationAbsolute) {
Push-Location $webUIDestinationAbsolute
if ($engineOperationMode -eq "developer") { $viteSubCommand = "dev" } else { $viteSubCommand = "build" }
$certRoot = Join-Path $scriptDir 'Certificates\Server'
Ensure-EngineTlsMaterial -PythonPath $venvPython -CertificateRoot $certRoot
Start-Process -NoNewWindow -FilePath $npmCmd -ArgumentList @("run", $viteSubCommand)
if ($engineOperationMode -eq "developer") {
Start-Process -NoNewWindow -FilePath $npmCmd -ArgumentList @("run", "dev")
} else {
& $npmCmd run build
}
Pop-Location
}
}
@@ -1462,7 +1494,7 @@ switch ($choice) {
$previousEngineMode = $env:BOREALIS_ENGINE_MODE
$previousEnginePort = $env:BOREALIS_ENGINE_PORT
$env:BOREALIS_ENGINE_MODE = $engineOperationMode
$env:BOREALIS_ENGINE_PORT = "5001"
$env:BOREALIS_ENGINE_PORT = "5000"
Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green
Write-Host "===================================================================================="
Write-Host "$($symbols.Running) Engine Socket Server Started..."

View File

@@ -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 5Bridge the legacy server to Engine APIs (completed)
- **Active Task:** Awaiting next stage instructions.
- **Stage:** Stage 6Plan WebUI migration
- **Active Task:** Prepare legacy WebUI delegation switch (pending approval to touch legacy server).

View File

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

View 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"}

View 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)

View File

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

View File

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

View File

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

View File

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