Checking readiness of main app in Controller instead of javascript requests, which break with CORS.
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run

This commit is contained in:
Roland 2026-03-08 16:05:56 -06:00
parent 8c294dad39
commit ef32e8b42d
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
7 changed files with 227 additions and 43 deletions

30
controller/api/ready.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -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,15 +1891,16 @@
} 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) {
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';
@ -1911,6 +1912,7 @@
window.location.href = `${upgradeHttpsBase}/`;
}, 2000);
}
}
} catch (e) {
// Still upgrading, continue polling
}
@ -1931,13 +1933,17 @@
// 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 resp = await fetch('/api/ready', {
method: 'GET',
headers: {'Accept': 'application/json'},
signal: AbortSignal.timeout(5000)
});
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:') {
@ -1947,25 +1953,9 @@
} 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 [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}/`;
}
}
} catch (e) {
// Still down, continue polling
}

View file

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

View file

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