2026-09-19 13:13:29 +03:00
|
|
|
import asyncio
|
|
|
|
|
from contextlib import asynccontextmanager, suppress
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from botocore.exceptions import BotoCoreError, ClientError
|
|
|
|
|
from fastapi import FastAPI, Request
|
|
|
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
|
|
2026-09-28 12:18:32 +03:00
|
|
|
from app import process_lock
|
2026-09-19 13:13:29 +03:00
|
|
|
from app.api import v1
|
2026-09-28 12:41:58 +03:00
|
|
|
from app.config import get_settings, insecure_settings
|
2026-09-19 13:13:29 +03:00
|
|
|
from app.db import init_db
|
2026-09-19 17:01:52 +03:00
|
|
|
from app.services import backups, jobs, poller, rotation
|
2026-09-19 13:13:29 +03:00
|
|
|
from app.ui import routes as ui
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
async def lifespan(app: FastAPI):
|
2026-09-28 12:41:58 +03:00
|
|
|
problems = insecure_settings(get_settings())
|
|
|
|
|
if problems:
|
|
|
|
|
raise RuntimeError("Небезопасная конфигурация: " + "; ".join(problems) + "; см. README «Настройки»")
|
2026-09-28 12:18:32 +03:00
|
|
|
process_lock.acquire(get_settings().database_url) # один процесс на файловую БД (--workers 1)
|
|
|
|
|
try:
|
|
|
|
|
init_db()
|
|
|
|
|
jobs.fail_stale_jobs()
|
|
|
|
|
task = asyncio.create_task(poller.run()) if get_settings().poll_interval > 0 else None
|
|
|
|
|
reconcile = asyncio.create_task(backups.reconcile()) # связать файлы бакета с метаданными (best-effort)
|
|
|
|
|
rotator = asyncio.create_task(rotation.run()) # ротация журнала по настройкам
|
|
|
|
|
yield
|
|
|
|
|
for t in (task, reconcile, rotator):
|
|
|
|
|
if t:
|
|
|
|
|
t.cancel()
|
|
|
|
|
with suppress(asyncio.CancelledError):
|
|
|
|
|
await t
|
|
|
|
|
finally:
|
|
|
|
|
process_lock.release()
|
2026-09-19 13:13:29 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
|
|
|
app = FastAPI(title="ros_control", lifespan=lifespan)
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
SessionMiddleware, secret_key=get_settings().session_secret,
|
2026-09-28 12:41:58 +03:00
|
|
|
same_site="strict", https_only=get_settings().session_cookie_secure,
|
2026-09-19 13:13:29 +03:00
|
|
|
)
|
|
|
|
|
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "ui" / "static"), name="static")
|
|
|
|
|
app.include_router(v1.router)
|
|
|
|
|
app.include_router(ui.router)
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(ui.LoginRequired)
|
|
|
|
|
async def _login(_: Request, e: ui.LoginRequired):
|
|
|
|
|
return RedirectResponse("/login", status_code=303)
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(LookupError)
|
|
|
|
|
async def _404(_: Request, e: LookupError):
|
|
|
|
|
return JSONResponse({"detail": str(e).strip("'\"")}, status_code=404)
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(ValueError)
|
|
|
|
|
async def _400(_: Request, e: ValueError):
|
|
|
|
|
return JSONResponse({"detail": str(e)}, status_code=400)
|
|
|
|
|
|
|
|
|
|
@app.exception_handler(BotoCoreError)
|
|
|
|
|
@app.exception_handler(ClientError)
|
|
|
|
|
async def _502(_: Request, e: Exception):
|
|
|
|
|
return JSONResponse({"detail": f"S3: {e}"}, status_code=502)
|
|
|
|
|
|
|
|
|
|
return app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = create_app()
|