mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 15:41:57 -06:00
Fixed Engine Flask Server Accessibility
This commit is contained in:
30
.vscode/tasks.json
vendored
30
.vscode/tasks.json
vendored
@@ -31,6 +31,36 @@
|
|||||||
},
|
},
|
||||||
"problemMatcher": []
|
"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",
|
"label": "Borealis - Agent - Deploy",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
|
|||||||
48
Borealis.ps1
48
Borealis.ps1
@@ -10,6 +10,8 @@ param(
|
|||||||
[switch]$Flask,
|
[switch]$Flask,
|
||||||
[switch]$Quick,
|
[switch]$Quick,
|
||||||
[switch]$EngineTests,
|
[switch]$EngineTests,
|
||||||
|
[switch]$EngineProduction,
|
||||||
|
[switch]$EngineDev,
|
||||||
[string]$InstallerCode = ''
|
[string]$InstallerCode = ''
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ param(
|
|||||||
$choice = $null
|
$choice = $null
|
||||||
$modeChoice = $null
|
$modeChoice = $null
|
||||||
$agentSubChoice = $null
|
$agentSubChoice = $null
|
||||||
|
$engineModeChoice = $null
|
||||||
|
|
||||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||||
|
|
||||||
@@ -48,6 +51,16 @@ if ($Vite -and $Flask) {
|
|||||||
exit 1
|
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) {
|
if ($Server) {
|
||||||
# Auto-select main menu option for Server when -Server flag is provided
|
# Auto-select main menu option for Server when -Server flag is provided
|
||||||
$choice = '1'
|
$choice = '1'
|
||||||
@@ -60,6 +73,10 @@ if ($Server) {
|
|||||||
'launch' { $agentSubChoice = '4' }
|
'launch' { $agentSubChoice = '4' }
|
||||||
default { $agentSubChoice = '1' }
|
default { $agentSubChoice = '1' }
|
||||||
}
|
}
|
||||||
|
} elseif ($EngineProduction -or $EngineDev) {
|
||||||
|
$choice = '5'
|
||||||
|
if ($EngineProduction) { $engineModeChoice = '1' }
|
||||||
|
if ($EngineDev) { $engineModeChoice = '3' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($Server) {
|
if ($Server) {
|
||||||
@@ -1078,7 +1095,11 @@ switch ($choice) {
|
|||||||
foreach ($tool in @($pythonExe, $nodeExe, $npmCmd, $npxCmd)) {
|
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 }
|
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) {
|
if (-not $modeChoice) {
|
||||||
Write-Host " "
|
Write-Host " "
|
||||||
@@ -1315,14 +1336,22 @@ switch ($choice) {
|
|||||||
exit 1
|
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 $engineModeChoice) {
|
||||||
Write-Host " "
|
Write-Host " "
|
||||||
Write-Host "Configure Borealis Engine Mode:" -ForegroundColor DarkYellow
|
Write-Host "Configure Borealis Engine Mode:" -ForegroundColor DarkYellow
|
||||||
Write-Host " 1) Build & Launch > Production Flask Server @ http://localhost:5001" -ForegroundColor DarkCyan
|
Write-Host " 1) Build & Launch > Production Flask Server @ https://localhost:5000" -ForegroundColor DarkCyan
|
||||||
Write-Host " 2) [Skip Build] & Immediately Launch > Production Flask Server @ http://localhost:5001" -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
|
Write-Host " 3) Launch > [Hotload-Ready] Vite Dev Server @ http://localhost:5173" -ForegroundColor DarkCyan
|
||||||
$engineModeChoice = Read-Host "Enter choice [1/2/3]"
|
$engineModeChoice = Read-Host "Enter choice [1/2/3]"
|
||||||
|
} else {
|
||||||
|
Write-Host "Auto-selecting Borealis Engine mode option $engineModeChoice." -ForegroundColor DarkYellow
|
||||||
|
}
|
||||||
|
|
||||||
$engineOperationMode = "production"
|
$engineOperationMode = "production"
|
||||||
$engineImmediateLaunch = $false
|
$engineImmediateLaunch = $false
|
||||||
@@ -1347,7 +1376,7 @@ switch ($choice) {
|
|||||||
$previousEngineMode = $env:BOREALIS_ENGINE_MODE
|
$previousEngineMode = $env:BOREALIS_ENGINE_MODE
|
||||||
$previousEnginePort = $env:BOREALIS_ENGINE_PORT
|
$previousEnginePort = $env:BOREALIS_ENGINE_PORT
|
||||||
$env:BOREALIS_ENGINE_MODE = $engineOperationMode
|
$env:BOREALIS_ENGINE_MODE = $engineOperationMode
|
||||||
$env:BOREALIS_ENGINE_PORT = "5001"
|
$env:BOREALIS_ENGINE_PORT = "5000"
|
||||||
Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green
|
Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green
|
||||||
Write-Host "===================================================================================="
|
Write-Host "===================================================================================="
|
||||||
Write-Host "$($symbols.Running) Engine Socket Server Started..."
|
Write-Host "$($symbols.Running) Engine Socket Server Started..."
|
||||||
@@ -1448,10 +1477,13 @@ switch ($choice) {
|
|||||||
$webUIDestinationAbsolute = Join-Path $scriptDir $webUIDestination
|
$webUIDestinationAbsolute = Join-Path $scriptDir $webUIDestination
|
||||||
if (Test-Path $webUIDestinationAbsolute) {
|
if (Test-Path $webUIDestinationAbsolute) {
|
||||||
Push-Location $webUIDestinationAbsolute
|
Push-Location $webUIDestinationAbsolute
|
||||||
if ($engineOperationMode -eq "developer") { $viteSubCommand = "dev" } else { $viteSubCommand = "build" }
|
|
||||||
$certRoot = Join-Path $scriptDir 'Certificates\Server'
|
$certRoot = Join-Path $scriptDir 'Certificates\Server'
|
||||||
Ensure-EngineTlsMaterial -PythonPath $venvPython -CertificateRoot $certRoot
|
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
|
Pop-Location
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1462,7 +1494,7 @@ switch ($choice) {
|
|||||||
$previousEngineMode = $env:BOREALIS_ENGINE_MODE
|
$previousEngineMode = $env:BOREALIS_ENGINE_MODE
|
||||||
$previousEnginePort = $env:BOREALIS_ENGINE_PORT
|
$previousEnginePort = $env:BOREALIS_ENGINE_PORT
|
||||||
$env:BOREALIS_ENGINE_MODE = $engineOperationMode
|
$env:BOREALIS_ENGINE_MODE = $engineOperationMode
|
||||||
$env:BOREALIS_ENGINE_PORT = "5001"
|
$env:BOREALIS_ENGINE_PORT = "5000"
|
||||||
Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green
|
Write-Host "`nLaunching Borealis Engine..." -ForegroundColor Green
|
||||||
Write-Host "===================================================================================="
|
Write-Host "===================================================================================="
|
||||||
Write-Host "$($symbols.Running) Engine Socket Server Started..."
|
Write-Host "$($symbols.Running) Engine Socket Server Started..."
|
||||||
|
|||||||
@@ -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] Replace legacy API routes with Engine-provided blueprints gated by a flag.
|
||||||
- [x] Emit transitional logging when Engine handles requests.
|
- [x] Emit transitional logging when Engine handles requests.
|
||||||
- [ ] **Stage 6 — Plan WebUI migration**
|
- [ ] **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
|
- [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.
|
- [ ] 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**
|
- [ ] **Stage 7 — Plan WebSocket migration**
|
||||||
- [ ] Extract Socket.IO handlers into Data/Engine/services/WebSocket.
|
- [ ] Extract Socket.IO handlers into Data/Engine/services/WebSocket.
|
||||||
- [ ] Provide register_realtime hook for the Engine factory.
|
- [ ] 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.
|
- [ ] Update legacy server to consume Engine WebSocket registration.
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
- **Stage:** Stage 5 — Bridge the legacy server to Engine APIs (completed)
|
- **Stage:** Stage 6 — Plan WebUI migration
|
||||||
- **Active Task:** Awaiting next stage instructions.
|
- **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"
|
log_path = logs_dir / "server.log"
|
||||||
error_log_path = logs_dir / "error.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 = {
|
config = {
|
||||||
"DATABASE_PATH": str(db_path),
|
"DATABASE_PATH": str(db_path),
|
||||||
"TLS_CERT_PATH": str(cert_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),
|
"TLS_BUNDLE_PATH": str(bundle_path),
|
||||||
"LOG_FILE": str(log_path),
|
"LOG_FILE": str(log_path),
|
||||||
"ERROR_LOG_FILE": str(error_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)
|
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`
|
The bootstrapper assembles configuration via :func:`Data.Engine.config.load_runtime_config`
|
||||||
before delegating to :func:`Data.Engine.server.create_app`. It mirrors the
|
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.
|
``BOREALIS_ENGINE_*`` environment overrides for bind host/port.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -11,14 +11,16 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from .server import EngineContext, create_app
|
from .server import EngineContext, create_app
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_HOST = "0.0.0.0"
|
DEFAULT_HOST = "0.0.0.0"
|
||||||
DEFAULT_PORT = 5001
|
DEFAULT_PORT = 5000
|
||||||
|
|
||||||
|
|
||||||
def _project_root() -> Path:
|
def _project_root() -> Path:
|
||||||
@@ -37,16 +39,34 @@ def _project_root() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _build_runtime_config() -> Dict[str, Any]:
|
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 {
|
return {
|
||||||
"HOST": os.environ.get("BOREALIS_ENGINE_HOST", DEFAULT_HOST),
|
"HOST": os.environ.get("BOREALIS_ENGINE_HOST", DEFAULT_HOST),
|
||||||
"PORT": int(os.environ.get("BOREALIS_ENGINE_PORT", DEFAULT_PORT)),
|
"PORT": int(os.environ.get("BOREALIS_ENGINE_PORT", DEFAULT_PORT)),
|
||||||
"TLS_CERT_PATH": os.environ.get("BOREALIS_TLS_CERT"),
|
"TLS_CERT_PATH": os.environ.get("BOREALIS_TLS_CERT"),
|
||||||
"TLS_KEY_PATH": os.environ.get("BOREALIS_TLS_KEY"),
|
"TLS_KEY_PATH": os.environ.get("BOREALIS_TLS_KEY"),
|
||||||
"TLS_BUNDLE_PATH": os.environ.get("BOREALIS_TLS_BUNDLE"),
|
"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()
|
project_root = _project_root()
|
||||||
engine_web_root = project_root / "Engine" / "web-interface"
|
engine_web_root = project_root / "Engine" / "web-interface"
|
||||||
legacy_source = project_root / "Data" / "Server" / "WebUI"
|
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}"
|
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():
|
if engine_web_root.exists():
|
||||||
shutil.rmtree(engine_web_root)
|
shutil.rmtree(engine_web_root)
|
||||||
|
|
||||||
shutil.copytree(legacy_source, engine_web_root)
|
shutil.copytree(legacy_source, engine_web_root)
|
||||||
|
|
||||||
index_path = engine_web_root / "index.html"
|
|
||||||
if not index_path.is_file():
|
if not index_path.is_file():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Engine web interface staging failed; missing {index_path}"
|
f"Engine web interface staging failed; missing {index_path}"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info("Engine web interface staged from %s to %s", legacy_source, engine_web_root)
|
||||||
"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:
|
def _ensure_tls_material(context: EngineContext) -> None:
|
||||||
@@ -137,13 +251,24 @@ def _prepare_tls_run_kwargs(context: EngineContext) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
config = _build_runtime_config()
|
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)
|
app, socketio, context = create_app(config)
|
||||||
|
|
||||||
try:
|
if staging_root:
|
||||||
_stage_web_interface_assets(context.logger)
|
context.logger.info("Engine WebUI assets ready at %s", config["STATIC_FOLDER"])
|
||||||
except Exception as exc:
|
|
||||||
context.logger.error("Failed to stage Engine web interface: %s", exc)
|
|
||||||
raise
|
|
||||||
|
|
||||||
host = config.get("HOST", DEFAULT_HOST)
|
host = config.get("HOST", DEFAULT_HOST)
|
||||||
port = int(config.get("PORT", DEFAULT_PORT))
|
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
|
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"
|
DEFAULT_DATABASE_PATH = PROJECT_ROOT / "database.db"
|
||||||
LOG_ROOT = PROJECT_ROOT / "Logs" / "Engine"
|
LOG_ROOT = PROJECT_ROOT / "Logs" / "Engine"
|
||||||
LOG_FILE_PATH = LOG_ROOT / "engine.log"
|
LOG_FILE_PATH = LOG_ROOT / "engine.log"
|
||||||
@@ -66,26 +85,28 @@ def _ensure_parent(path: Path) -> None:
|
|||||||
def _resolve_static_folder() -> str:
|
def _resolve_static_folder() -> str:
|
||||||
candidate_roots = [
|
candidate_roots = [
|
||||||
PROJECT_ROOT / "Engine" / "web-interface",
|
PROJECT_ROOT / "Engine" / "web-interface",
|
||||||
|
PROJECT_ROOT / "web-interface",
|
||||||
|
ENGINE_DIR.parent / "Engine" / "web-interface",
|
||||||
|
ENGINE_DIR.parent / "web-interface",
|
||||||
ENGINE_DIR / "web-interface",
|
ENGINE_DIR / "web-interface",
|
||||||
PROJECT_ROOT / "Data" / "Server" / "web-interface",
|
PROJECT_ROOT / "Data" / "Server" / "web-interface",
|
||||||
]
|
]
|
||||||
|
|
||||||
candidates = []
|
resolved_roots: List[Path] = []
|
||||||
for root in candidate_roots:
|
for root in candidate_roots:
|
||||||
absolute_root = root.resolve()
|
absolute_root = root.expanduser().resolve()
|
||||||
candidates.extend(
|
if absolute_root not in resolved_roots:
|
||||||
[
|
resolved_roots.append(absolute_root)
|
||||||
absolute_root / "build",
|
|
||||||
absolute_root / "dist",
|
|
||||||
absolute_root,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
for candidate in candidates:
|
for root in resolved_roots:
|
||||||
|
for candidate in (root / "build", root / "dist", root):
|
||||||
if candidate.is_dir():
|
if candidate.is_dir():
|
||||||
return str(candidate)
|
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]]:
|
def _parse_origins(raw: Optional[Any]) -> Optional[List[str]]:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Iterable, Mapping, Optional, Sequence
|
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 import jwt_service as jwt_service_module
|
||||||
from Modules.auth.dpop import DPoPValidator
|
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:
|
def register_api(app: Flask, context: EngineContext) -> None:
|
||||||
"""Register Engine API blueprints based on the enabled groups."""
|
"""Register Engine API blueprints based on the enabled groups."""
|
||||||
|
|
||||||
enabled_groups: Iterable[str] = context.api_groups or DEFAULT_API_GROUPS
|
enabled_groups: Iterable[str] = context.api_groups or DEFAULT_API_GROUPS
|
||||||
normalized = [group.strip().lower() for group in enabled_groups if group]
|
normalized = [group.strip().lower() for group in enabled_groups if group]
|
||||||
adapters = LegacyServiceAdapters(context)
|
adapters: Optional[LegacyServiceAdapters] = None
|
||||||
|
|
||||||
for group in normalized:
|
for group in normalized:
|
||||||
|
if group == "core":
|
||||||
|
_register_core(app, context)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if adapters is None:
|
||||||
|
adapters = LegacyServiceAdapters(context)
|
||||||
registrar = _GROUP_REGISTRARS.get(group)
|
registrar = _GROUP_REGISTRARS.get(group)
|
||||||
if registrar is None:
|
if registrar is None:
|
||||||
context.logger.info("Engine API group '%s' is not implemented; skipping.", group)
|
context.logger.info("Engine API group '%s' is not implemented; skipping.", group)
|
||||||
continue
|
continue
|
||||||
registrar(app, adapters)
|
registrar(app, adapters)
|
||||||
context.logger.info("Engine registered API group '%s'.", group)
|
context.logger.info("Engine registered API group '%s'.", group)
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,86 @@
|
|||||||
"""WebUI service stubs for the Borealis Engine runtime.
|
"""WebUI static asset handling 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.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
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
|
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:
|
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