From ef32e8b42dac4a1756c1ba3b7d63a3d71f9d6221 Mon Sep 17 00:00:00 2001 From: Roland Date: Sun, 8 Mar 2026 16:05:56 -0600 Subject: [PATCH] Checking readiness of main app in Controller instead of javascript requests, which break with CORS. --- controller/api/ready.py | 30 ++++++++++++ controller/api/schemas.py | 11 +++++ controller/lib/readiness.py | 50 ++++++++++++++++++++ controller/main.py | 12 +++++ controller/templates/index.html | 76 ++++++++++++++----------------- controller/test/test_readiness.py | 48 +++++++++++++++++++ controller/test/test_ready_api.py | 43 +++++++++++++++++ 7 files changed, 227 insertions(+), 43 deletions(-) create mode 100644 controller/api/ready.py create mode 100644 controller/lib/readiness.py create mode 100644 controller/test/test_readiness.py create mode 100644 controller/test/test_ready_api.py diff --git a/controller/api/ready.py b/controller/api/ready.py new file mode 100644 index 00000000..dbed9ca3 --- /dev/null +++ b/controller/api/ready.py @@ -0,0 +1,30 @@ +""" +Readiness API endpoint for WROLPi Controller. + +Allows the controller UI to check if the main API and React app are up +without making cross-origin browser requests. +""" + +import asyncio + +from fastapi import APIRouter + +from controller.api.schemas import ReadyResponse +from controller.lib.readiness import check_api_ready, check_app_ready + +router = APIRouter(tags=["readiness"]) + + +@router.get("/api/ready", response_model=ReadyResponse) +async def readiness_check() -> ReadyResponse: + """ + Check if the main WROLPi API and React app are responding. + + Performs server-side HTTP requests to avoid CORS and certificate issues + that arise from browser-based cross-origin fetch requests. + """ + api_ready, app_ready = await asyncio.gather( + check_api_ready(), + check_app_ready(), + ) + return ReadyResponse(api=api_ready, app=app_ready) diff --git a/controller/api/schemas.py b/controller/api/schemas.py index 2d3b90c6..b1b5065c 100644 --- a/controller/api/schemas.py +++ b/controller/api/schemas.py @@ -325,3 +325,14 @@ class BranchResponse(BaseModel): """Response model for current git branch.""" branch: Optional[str] = Field(description="Current git branch, or null if unavailable") + + +# ============================================================================ +# Readiness +# ============================================================================ + +class ReadyResponse(BaseModel): + """Response model for /api/ready endpoint.""" + + api: bool = Field(description="Whether the main WROLPi API is responding") + app: bool = Field(description="Whether the React app is responding") diff --git a/controller/lib/readiness.py b/controller/lib/readiness.py new file mode 100644 index 00000000..ac1dc706 --- /dev/null +++ b/controller/lib/readiness.py @@ -0,0 +1,50 @@ +""" +Readiness checks for WROLPi services. + +Makes server-side HTTP requests to the API and React app to determine +if they are responding. Used by the controller UI during fallback and upgrade +modes to avoid cross-origin browser issues (CORS, self-signed certificates). +""" + +import aiohttp + +from controller.lib.config import is_docker_mode + +# Timeout for individual readiness checks (seconds). +CHECK_TIMEOUT = 3 + + +def _get_api_url() -> str: + """Get the internal URL for the WROLPi API health check.""" + if is_docker_mode(): + return "http://api:8081/api/echo" + return "http://127.0.0.1:8081/api/echo" + + +def _get_app_url() -> str: + """Get the internal URL for the React app health check.""" + if is_docker_mode(): + return "http://app:3000" + return "http://127.0.0.1:3000" + + +async def check_api_ready() -> bool: + """Check if the main WROLPi API is responding.""" + try: + timeout = aiohttp.ClientTimeout(total=CHECK_TIMEOUT) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(_get_api_url()) as resp: + return resp.status == 200 + except Exception: + return False + + +async def check_app_ready() -> bool: + """Check if the React app is responding.""" + try: + timeout = aiohttp.ClientTimeout(total=CHECK_TIMEOUT) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(_get_app_url()) as resp: + return 200 <= resp.status < 400 + except Exception: + return False diff --git a/controller/main.py b/controller/main.py index c1b2c96b..f63bc63f 100644 --- a/controller/main.py +++ b/controller/main.py @@ -18,8 +18,11 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from controller import __version__ +from fastapi.middleware.cors import CORSMiddleware + from controller.api.admin import router as admin_router from controller.api.disks import router as disks_router +from controller.api.ready import router as ready_router from controller.api.schemas import HealthResponse from controller.api.scripts import router as scripts_router from controller.api.services import router as services_router @@ -93,9 +96,18 @@ app = FastAPI( lifespan=lifespan, ) +# CORS middleware - matches Caddy's permissive policy for offline/local use. +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + # Include routers app.include_router(admin_router) app.include_router(disks_router) +app.include_router(ready_router) app.include_router(scripts_router) app.include_router(services_router) app.include_router(status_router) diff --git a/controller/templates/index.html b/controller/templates/index.html index 751ac069..59e51f73 100644 --- a/controller/templates/index.html +++ b/controller/templates/index.html @@ -1881,7 +1881,7 @@ updateElapsed(); setInterval(updateElapsed, 1000); - // Poll main API to check if upgrade is complete + // Poll controller's readiness endpoint (same-origin, avoids CORS/cert issues). const upgradeIsDocker = {{ 'true' if docker_mode else 'false' }}; let upgradeHttpsBase; if (window.location.protocol === 'https:') { @@ -1891,25 +1891,27 @@ } else { upgradeHttpsBase = `https://${window.location.hostname}`; } - const mainApiUrl = `${upgradeHttpsBase}/api/status`; const checkUpgradeComplete = async () => { try { - const resp = await fetch(mainApiUrl, { + const resp = await fetch('/api/ready', { method: 'GET', headers: {'Accept': 'application/json'}, signal: AbortSignal.timeout(5000) }); if (resp.ok) { - // Upgrade complete! - document.getElementById('upgrade-spinner').style.display = 'none'; - document.getElementById('upgrade-success').style.display = 'inline'; - document.getElementById('upgrade-message').textContent = 'Upgrade complete! Redirecting...'; - document.getElementById('upgrade-banner').classList.add('complete'); + const data = await resp.json(); + if (data.api && data.app) { + // Upgrade complete! + document.getElementById('upgrade-spinner').style.display = 'none'; + document.getElementById('upgrade-success').style.display = 'inline'; + document.getElementById('upgrade-message').textContent = 'Upgrade complete! Redirecting...'; + document.getElementById('upgrade-banner').classList.add('complete'); - // Redirect to main app after 2 seconds - setTimeout(() => { - window.location.href = `${upgradeHttpsBase}/`; - }, 2000); + // Redirect to main app after 2 seconds + setTimeout(() => { + window.location.href = `${upgradeHttpsBase}/`; + }, 2000); + } } } catch (e) { // Still upgrading, continue polling @@ -1931,40 +1933,28 @@ // Show fallback banner document.getElementById('fallback-banner').classList.add('show'); - // Poll main API and React app to check if they're back up + // Poll controller's readiness endpoint (same-origin, avoids CORS/cert issues). const checkMainApp = async () => { try { - // Determine the HTTPS base URL for the main app. - // - If already on HTTPS (via /controller path), use same origin without port. - // - If on HTTP:8087 in Docker mode, HTTPS is on 8443. - // - If on HTTP:8087 in production (systemd), HTTPS is on 443 (default). - const isDocker = {{ 'true' if docker_mode else 'false' }}; - let httpsBase; - if (window.location.protocol === 'https:') { - httpsBase = `https://${window.location.hostname}`; - } else if (isDocker) { - httpsBase = `https://${window.location.hostname}:8443`; - } else { - httpsBase = `https://${window.location.hostname}`; - } - // Check both API and React app are up before redirecting. - // API check uses fetch; app check uses an Image probe to avoid CORS (http→https). - // When the app is down, Caddy redirects to the controller, so the image src won't - // resolve to a valid image and will error. - const appUp = new Promise((resolve) => { - const img = new Image(); - img.onload = () => resolve(true); - img.onerror = () => resolve(false); - img.src = `${httpsBase}/favicon.ico?_=${Date.now()}`; - setTimeout(() => resolve(false), 5000); + const resp = await fetch('/api/ready', { + method: 'GET', + headers: {'Accept': 'application/json'}, + signal: AbortSignal.timeout(5000) }); - const [apiResp, isAppUp] = await Promise.all([ - fetch(`${httpsBase}/api/status`, {method: 'HEAD', signal: AbortSignal.timeout(5000)}), - appUp - ]); - // API must respond OK; app must serve its favicon (not redirect to controller). - if (apiResp.ok && isAppUp) { - window.location.href = `${httpsBase}/`; + if (resp.ok) { + const data = await resp.json(); + if (data.api && data.app) { + const isDocker = {{ 'true' if docker_mode else 'false' }}; + let httpsBase; + if (window.location.protocol === 'https:') { + httpsBase = `https://${window.location.hostname}`; + } else if (isDocker) { + httpsBase = `https://${window.location.hostname}:8443`; + } else { + httpsBase = `https://${window.location.hostname}`; + } + window.location.href = `${httpsBase}/`; + } } } catch (e) { // Still down, continue polling diff --git a/controller/test/test_readiness.py b/controller/test/test_readiness.py new file mode 100644 index 00000000..e032bf17 --- /dev/null +++ b/controller/test/test_readiness.py @@ -0,0 +1,48 @@ +""" +Tests for readiness check functions. +""" + +from unittest import mock + +import aiohttp +import pytest + +from controller.lib.readiness import ( + _get_api_url, + _get_app_url, + check_api_ready, + check_app_ready, +) + + +class TestReadinessUrls: + + def test_api_url_docker_mode(self): + with mock.patch("controller.lib.readiness.is_docker_mode", return_value=True): + assert _get_api_url() == "http://api:8081/api/echo" + + def test_api_url_systemd_mode(self): + with mock.patch("controller.lib.readiness.is_docker_mode", return_value=False): + assert _get_api_url() == "http://127.0.0.1:8081/api/echo" + + def test_app_url_docker_mode(self): + with mock.patch("controller.lib.readiness.is_docker_mode", return_value=True): + assert _get_app_url() == "http://app:3000" + + def test_app_url_systemd_mode(self): + with mock.patch("controller.lib.readiness.is_docker_mode", return_value=False): + assert _get_app_url() == "http://127.0.0.1:3000" + + +@pytest.mark.asyncio +class TestReadinessChecks: + + async def test_check_api_ready_returns_false_on_connection_error(self): + with mock.patch("aiohttp.ClientSession.get", side_effect=aiohttp.ClientError()): + result = await check_api_ready() + assert result is False + + async def test_check_app_ready_returns_false_on_connection_error(self): + with mock.patch("aiohttp.ClientSession.get", side_effect=aiohttp.ClientError()): + result = await check_app_ready() + assert result is False diff --git a/controller/test/test_ready_api.py b/controller/test/test_ready_api.py new file mode 100644 index 00000000..4ad99d85 --- /dev/null +++ b/controller/test/test_ready_api.py @@ -0,0 +1,43 @@ +""" +Tests for the /api/ready readiness endpoint. +""" + +from unittest import mock + + +class TestReadyEndpoint: + """Tests for /api/ready endpoint.""" + + def test_ready_both_down(self, test_client): + with mock.patch("controller.api.ready.check_api_ready", return_value=False): + with mock.patch("controller.api.ready.check_app_ready", return_value=False): + response = test_client.get("/api/ready") + assert response.status_code == 200 + data = response.json() + assert data["api"] is False + assert data["app"] is False + + def test_ready_both_up(self, test_client): + with mock.patch("controller.api.ready.check_api_ready", return_value=True): + with mock.patch("controller.api.ready.check_app_ready", return_value=True): + response = test_client.get("/api/ready") + assert response.status_code == 200 + data = response.json() + assert data["api"] is True + assert data["app"] is True + + def test_ready_api_up_app_down(self, test_client): + with mock.patch("controller.api.ready.check_api_ready", return_value=True): + with mock.patch("controller.api.ready.check_app_ready", return_value=False): + response = test_client.get("/api/ready") + data = response.json() + assert data["api"] is True + assert data["app"] is False + + def test_ready_api_down_app_up(self, test_client): + with mock.patch("controller.api.ready.check_api_ready", return_value=False): + with mock.patch("controller.api.ready.check_app_ready", return_value=True): + response = test_client.get("/api/ready") + data = response.json() + assert data["api"] is False + assert data["app"] is True