mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
Checking readiness of main app in Controller instead of javascript requests, which break with CORS.
This commit is contained in:
parent
8c294dad39
commit
ef32e8b42d
7 changed files with 227 additions and 43 deletions
30
controller/api/ready.py
Normal file
30
controller/api/ready.py
Normal 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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
50
controller/lib/readiness.py
Normal file
50
controller/lib/readiness.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
controller/test/test_readiness.py
Normal file
48
controller/test/test_readiness.py
Normal 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
|
||||
43
controller/test/test_ready_api.py
Normal file
43
controller/test/test_ready_api.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue