skillhub/scanner/skillhub_scanner_app.py
XiaoSeS fc7c59534a
fix(platform): harden sessions, scanner recovery, and CLI guidance (#801)
* fix(auth): recover from unreadable sessions

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(scanner): defer unavailable scan tasks safely

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(web): prefer the SkillHub CLI install command

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(auth): decode session cookies during recovery

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(runtime): address scanner and session review findings

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(scanner): defer all server-side outages

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* docs(scanner): clarify deferred failure semantics

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(scanner): cover recovery boundaries

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(scanner): register startup hook on router

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(e2e): align install defaults and reuse auth session

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-09-02 20:15:42 +08:00

87 lines
3.1 KiB
Python

"""Runtime safeguards around the upstream Cisco Skill Scanner ASGI application."""
import asyncio
import logging
import os
import shutil
import tempfile
from pathlib import Path
from typing import NoReturn
from fastapi import Request
from fastapi.responses import JSONResponse
from skill_scanner.api.api import app
_MAX_CONCURRENT_SCANS = max(1, int(os.getenv("SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS", "1")))
_HARD_TIMEOUT_SECONDS = max(1, int(os.getenv("SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS", "930")))
_active_scans = 0
_active_scans_guard = asyncio.Lock()
_SCAN_PATHS = {"/scan", "/scan-upload"}
_log = logging.getLogger(__name__)
def _cleanup_stale_scan_directories(temp_root: Path | None = None) -> None:
"""Remove incomplete upstream extraction directories left by a process restart."""
root = temp_root or Path(tempfile.gettempdir())
for candidate in root.glob("skill_scanner_*"):
if not candidate.is_dir():
continue
try:
shutil.rmtree(candidate)
except OSError as error:
_log.warning("Could not remove stale scanner directory %s: %s", candidate, error)
def _restart_after_hard_timeout(request_path: str) -> NoReturn:
"""Terminate the single-scan worker so the container runtime can recover it."""
_log.critical(
"Security scan exceeded the %s second hard timeout: path=%s; restarting scanner",
_HARD_TIMEOUT_SECONDS,
request_path,
)
logging.shutdown()
os._exit(124)
async def _await_scan_until(scan_task: asyncio.Task, deadline: float, request_path: str):
remaining = deadline - asyncio.get_running_loop().time()
if remaining <= 0:
_restart_after_hard_timeout(request_path)
try:
return await asyncio.wait_for(asyncio.shield(scan_task), timeout=remaining)
except asyncio.TimeoutError:
_restart_after_hard_timeout(request_path)
app.router.add_event_handler("startup", _cleanup_stale_scan_directories)
@app.middleware("http")
async def limit_concurrent_scans(request: Request, call_next):
"""Reject excess scan work so timed-out client retries cannot multiply memory use."""
global _active_scans
if request.method != "POST" or request.url.path not in _SCAN_PATHS:
return await call_next(request)
async with _active_scans_guard:
if _active_scans >= _MAX_CONCURRENT_SCANS:
return JSONResponse(
status_code=503,
content={"detail": "Scanner is busy; retry later"},
headers={"Retry-After": "30"},
)
_active_scans += 1
scan_task = asyncio.create_task(call_next(request))
deadline = asyncio.get_running_loop().time() + _HARD_TIMEOUT_SECONDS
try:
# Keep the capacity slot until upstream work really ends, even if the HTTP client
# disconnects while the scanner's worker thread is still running.
return await _await_scan_until(scan_task, deadline, request.url.path)
except asyncio.CancelledError:
await _await_scan_until(scan_task, deadline, request.url.path)
raise
finally:
async with _active_scans_guard:
_active_scans -= 1