diff --git a/tests/dev_server.py b/tests/dev_server.py index 0a35dea..e6142a2 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -222,5 +222,5 @@ if __name__ == "__main__": seed(Path(tempfile.mkdtemp(prefix="virtuality-dev-"))) # The demo operations are seeded as "running": keep them instead of closing them as orphaned on startup. - app.app.router.on_startup.clear() + app.run_startup_tasks = lambda: None uvicorn.run(app.app, host="127.0.0.1", port=int(sys.argv[1]) if len(sys.argv) > 1 else 8765, log_level="warning") diff --git a/tests/test_audit_fixes.py b/tests/test_audit_fixes.py index 953fe9b..b4eb977 100644 --- a/tests/test_audit_fixes.py +++ b/tests/test_audit_fixes.py @@ -139,6 +139,17 @@ def test_orphaned_operations_are_closed_on_startup(data_dirs): assert core.active_operations_for("web01") == [] +def test_panel_startup_closes_orphaned_operations(data_dirs): + """Стартовые задачи панели запускает lifespan (FastAPI on_event устарел).""" + import core + from fastapi.testclient import TestClient + + running = core.new_operation("snapshot_create", "Снимок web01", vm_name="web01") + core.update_operation(running, status="running") + with TestClient(app.app, base_url="http://panel.test"): + assert core.read_operation(running["id"])["interrupted"] is True + + def test_interrupt_leaves_operations_of_other_live_processes(data_dirs): """Копии по расписанию идут в отдельной службе: перезапуск панели не должен объявлять их прерванными.""" import subprocess diff --git a/web/app.py b/web/app.py index 54bbc9a..5a3f225 100644 --- a/web/app.py +++ b/web/app.py @@ -18,6 +18,7 @@ from pathlib import Path from typing import Any from urllib.parse import quote, urlsplit import xml.etree.ElementTree as ET +from contextlib import asynccontextmanager from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse @@ -45,7 +46,22 @@ OPERATIONS_DIR = Path("/var/log/virtuality/operations") DEFAULT_BRIDGE = "br0" NOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None) -app = FastAPI(title="Virtuality Panel") +def run_startup_tasks() -> None: + """После перезапуска панели: закрыть задачи, оборванные перезапуском, и убрать недокачанные загрузки.""" + import core + from features import downloads + + core.interrupt_orphaned_operations() + downloads.close_stale_downloads() + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + run_startup_tasks() + yield + + +app = FastAPI(title="Virtuality Panel", lifespan=lifespan) templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) static_dir = BASE_DIR / "static" static_dir.mkdir(exist_ok=True) @@ -1260,7 +1276,7 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str: def vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200): profile = host_profile.load_host_profile() default_mode = profile.get("recommended_network", "nat") - return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "os_types": catalog.OS_TYPES, "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code) + return templates.TemplateResponse(request, "vm_create.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "os_types": catalog.OS_TYPES, "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code) # Virtuality noVNC console patch @@ -1296,20 +1312,20 @@ async def proxy_websocket_to_vnc(websocket: WebSocket, writer: asyncio.StreamWri def login_page(request: Request): if get_current_user(request): return RedirectResponse(url="/", status_code=303) - return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": None, "configured": is_configured(), "auth_user": AUTH_USER}) + return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": None, "configured": is_configured(), "auth_user": AUTH_USER}) @app.post("/login", response_class=HTMLResponse) def login_submit(request: Request, username: str = Form(...), password: str = Form(...)): if not is_configured(): - return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Панель ещё не настроена. Запусти установщик веб-панели повторно.", "configured": False, "auth_user": AUTH_USER}, status_code=500) + return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": "Панель ещё не настроена. Запусти установщик веб-панели повторно.", "configured": False, "auth_user": AUTH_USER}, status_code=500) key = client_key(request) retry_after = login_throttle.retry_after(key) if retry_after: - return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": f"Слишком много неудачных попыток входа. Повтори через {retry_after} сек.", "configured": True, "auth_user": AUTH_USER}, status_code=429, headers={"Retry-After": str(retry_after)}) + return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": f"Слишком много неудачных попыток входа. Повтори через {retry_after} сек.", "configured": True, "auth_user": AUTH_USER}, status_code=429, headers={"Retry-After": str(retry_after)}) if not verify_linux_password(username, password): login_throttle.record_failure(key) - return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Неверный логин или пароль Linux-пользователя", "configured": True, "auth_user": AUTH_USER}, status_code=401) + return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": "Неверный логин или пароль Linux-пользователя", "configured": True, "auth_user": AUTH_USER}, status_code=401) login_throttle.record_success(key) token = serializer.dumps({"user": AUTH_USER}) response = RedirectResponse(url="/", status_code=303) @@ -1333,7 +1349,7 @@ def dashboard(request: Request): return RedirectResponse(url="/setup", status_code=303) services = {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "web": service_state("virtuality-web.service")} service_rows = [{"key": key, "name": presenters.SERVICE_LABELS.get(key, key), "state": state, **presenters.service_state(state)} for key, state in services.items()] - return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "service_rows": service_rows, "services_ok": all(row["tone"] == "success" for row in service_rows if row["key"] in ("libvirtd", "web")), "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css, "host": presenters.host_stats(STORAGE_DIR), "greeting": presenters.greeting()}) + return templates.TemplateResponse(request, "dashboard.html", {"app_name": APP_NAME, "system": system_summary(), "services": services, "service_rows": service_rows, "services_ok": all(row["tone"] == "success" for row in service_rows if row["key"] in ("libvirtd", "web")), "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css, "host": presenters.host_stats(STORAGE_DIR), "greeting": presenters.greeting()}) @app.get("/help", response_class=HTMLResponse) @@ -1341,7 +1357,7 @@ def help_page(request: Request): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect - return templates.TemplateResponse("help.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "host_ip": system_summary()["ip"]}) + return templates.TemplateResponse(request, "help.html", {"app_name": APP_NAME, "user": AUTH_USER, "host_ip": system_summary()["ip"]}) @app.get("/host", response_class=HTMLResponse) @@ -1350,7 +1366,7 @@ def host_page(request: Request): if auth_redirect: return auth_redirect profile = host_profile.load_host_profile() - return templates.TemplateResponse("host.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "profile": profile, "profile_json": json.dumps(profile, ensure_ascii=False, indent=2)}) + return templates.TemplateResponse(request, "host.html", {"app_name": APP_NAME, "user": AUTH_USER, "profile": profile, "profile_json": json.dumps(profile, ensure_ascii=False, indent=2)}) @app.post("/host/refresh") @@ -1368,7 +1384,7 @@ def iso_page(request: Request, error: str | None = None): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect - return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error}) + return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error}) @app.post("/iso/upload", response_class=HTMLResponse) @@ -1378,11 +1394,11 @@ def iso_upload(request: Request, iso_file: UploadFile = File(...)): return auth_redirect safe_name = safe_iso_filename(iso_file.filename or "") if not safe_name: - return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400) + return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400) ISO_DIR.mkdir(parents=True, exist_ok=True) target = ISO_DIR / safe_name if target.exists(): - return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"ISO уже существует: {safe_name}"}, status_code=400) + return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"ISO уже существует: {safe_name}"}, status_code=400) tmp_target = ISO_DIR / f".{safe_name}.uploading" try: with tmp_target.open("wb") as out: @@ -1391,7 +1407,7 @@ def iso_upload(request: Request, iso_file: UploadFile = File(...)): refresh_iso_pool() except Exception as exc: tmp_target.unlink(missing_ok=True) - return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500) + return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500) return RedirectResponse(url="/iso", status_code=303) @@ -1424,7 +1440,7 @@ def disk_images_page(request: Request, error: str | None = None): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": error}) + return templates.TemplateResponse(request, "disk_images.html", {"app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": error}) @app.post("/disk-images/upload", response_class=HTMLResponse) @@ -1434,7 +1450,7 @@ def disk_image_upload(request: Request, image_file: UploadFile = File(...)): return auth_redirect safe_name = safe_disk_upload_filename(image_file.filename or "") if not safe_name: - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400) + return templates.TemplateResponse(request, "disk_images.html", {"app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400) DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True) tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading" saved_paths: list[Path] = [] @@ -1455,7 +1471,7 @@ def disk_image_upload(request: Request, image_file: UploadFile = File(...)): tmp_target.unlink(missing_ok=True) for path in saved_paths: path.unlink(missing_ok=True) - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500) + return templates.TemplateResponse(request, "disk_images.html", {"app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500) operations = [] for path in saved_paths: @@ -1507,8 +1523,7 @@ def safe_network_template(request: Request, error: str | None = None, status_cod "nft_rules": "", } error = error or f"Ошибка чтения сетевого состояния: {exc}" - return templates.TemplateResponse("network.html", { - "request": request, + return templates.TemplateResponse(request, "network.html", { "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), @@ -1535,7 +1550,7 @@ def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: st return RedirectResponse(url="/network", status_code=303) manual_ip = (manual_ip or "").strip() if manual_ip and not re.fullmatch(r"(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}", manual_ip): - return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": "Некорректный ручной IP VM"}, status_code=400) + return templates.TemplateResponse(request, "network.html", {"app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": "Некорректный ручной IP VM"}, status_code=400) path = Path("/var/lib/virtuality/network/vm_ips.json") path.parent.mkdir(parents=True, exist_ok=True) try: @@ -1616,8 +1631,7 @@ def network_diagnose(request: Request, vm_name: str = Form(...), external_port: diagnostics = network_core.diagnose_public_access(vm_name, external_port, guest_port, protocol) except NetworkError as exc: error = str(exc) - return templates.TemplateResponse("network.html", { - "request": request, + return templates.TemplateResponse(request, "network.html", { "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), @@ -1657,7 +1671,7 @@ def update_page(request: Request): } current = str(info.get("current_version", "")) current_release = next((item for item in update_core.load_manifest().get("versions", []) if str(item.get("version")) == current), None) - return templates.TemplateResponse("update.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error, "current_release": current_release}) + return templates.TemplateResponse(request, "update.html", {"app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error, "current_release": current_release}) @app.get("/update/status") @@ -1702,7 +1716,7 @@ def operations_page(request: Request): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect - return templates.TemplateResponse("operations.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "operations": list_operations(50), "operation_css": operation_css}) + return templates.TemplateResponse(request, "operations.html", {"app_name": APP_NAME, "user": AUTH_USER, "operations": list_operations(50), "operation_css": operation_css}) @app.get("/operations/{operation_id}", response_class=HTMLResponse) @@ -1713,7 +1727,7 @@ def operation_detail_page(request: Request, operation_id: str): operation = read_operation(operation_id) if not operation: return RedirectResponse(url="/operations", status_code=303) - return templates.TemplateResponse("operation_detail.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css}) + return templates.TemplateResponse(request, "operation_detail.html", {"app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css}) @app.get("/api/operations") @@ -1739,8 +1753,7 @@ def logs_page(request: Request, source: str = "web", lines: int = 220): if auth_redirect: return auth_redirect selected = read_log_source(source, lines) - return templates.TemplateResponse("logs.html", { - "request": request, + return templates.TemplateResponse(request, "logs.html", { "app_name": APP_NAME, "user": AUTH_USER, "sources": LOG_SOURCES, @@ -1905,7 +1918,7 @@ def vm_console_page(request: Request, name: str): return auth_redirect if not valid_vm_name(name) or not vm_exists(name): return RedirectResponse(url="/", status_code=303) - return templates.TemplateResponse("console.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)}) + return templates.TemplateResponse(request, "console.html", {"app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)}) @app.websocket("/console/ws/{token}") @@ -1955,7 +1968,7 @@ def vm_detail_page(request: Request, name: str): return auth_redirect if not valid_vm_name(name) or not vm_exists(name): return RedirectResponse(url="/", status_code=303) - return templates.TemplateResponse("vm_detail.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "host_ip": system_summary()["ip"], "boot_options": vm_boot_order_options(), "current_boot_order": current_vm_boot_order(name), "boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")}) + return templates.TemplateResponse(request, "vm_detail.html", {"app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "host_ip": system_summary()["ip"], "boot_options": vm_boot_order_options(), "current_boot_order": current_vm_boot_order(name), "boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")}) @app.post("/vm/{name}/resources") @@ -2085,12 +2098,6 @@ def api_health(request: Request): # Feature modules register template globals into app.templates, so they are imported once it exists. from features import backup_schedule, backups, downloads, settings, setup, snapshots, vmops # noqa: E402 -@app.on_event("startup") -def close_orphaned_operations() -> None: - import core - - core.interrupt_orphaned_operations() - app.include_router(setup.router) app.include_router(settings.router) diff --git a/web/core.py b/web/core.py index a4edc69..3574a03 100644 --- a/web/core.py +++ b/web/core.py @@ -112,7 +112,7 @@ def render(request: Request, template: str, context: dict[str, Any] | None = Non data = {"request": request, "app_name": APP_NAME, "user": AUTH_USER} data.update(context or {}) data.update(extra) - return templates.TemplateResponse(template, data, status_code=status_code) + return templates.TemplateResponse(request, template, data, status_code=status_code) # ---------------------------------------------------------------- auth diff --git a/web/features/downloads.py b/web/features/downloads.py index 1fc98c0..d8e0369 100644 --- a/web/features/downloads.py +++ b/web/features/downloads.py @@ -783,9 +783,9 @@ def api_cancel_operation(request: Request, operation_id: str): return {"ok": True, "operation": read_operation(operation_id) or operation} -@router.on_event("startup") def close_stale_downloads() -> None: - """После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part.""" + """После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part. + Вызывается из app.run_startup_tasks() при старте панели.""" reap_stale_downloads()