diff --git a/tests/test_audit_fixes.py b/tests/test_audit_fixes.py index 2353b9f..17ef862 100644 --- a/tests/test_audit_fixes.py +++ b/tests/test_audit_fixes.py @@ -122,3 +122,25 @@ def test_ufw_rules_follow_forwards(data_dirs, monkeypatch): assert find(calls, "ufw", "delete", "allow", "2222/tcp") assert find(calls, "ufw", "route", "delete", "allow", "in", "on", "eth0") assert json.loads(network_core.UFW_STATE_FILE.read_text()) == [] + + +# ---------------------------------------------------------------- operations orphaned by a restart +def test_orphaned_operations_are_closed_on_startup(data_dirs): + import core + + running = core.new_operation("snapshot_create", "Снимок web01", vm_name="web01") + core.update_operation(running, status="running") + done = core.new_operation("backup", "Копия web01", vm_name="web01") + core.finish_operation(done, True, "Готово") + assert core.active_operations_for("web01") + assert core.interrupt_orphaned_operations() == 1 + assert core.read_operation(running["id"])["status"] == "error" and core.read_operation(running["id"])["interrupted"] + assert core.read_operation(done["id"])["status"] == "success" + assert core.active_operations_for("web01") == [] + + +def test_snapshot_names_with_spaces_are_listed(): + from features import snapshots + + rows = snapshots.parse_snapshot_list(" Name Creation Time State\n------\n before update 2026-09-25 10:12:03 +0000 shutoff\n clean 2026-09-10 12:05:00 +0000 running\n") + assert [row["name"] for row in rows] == ["before update", "clean"] diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index c60632c..c70b00d 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -273,7 +273,8 @@ def test_delete_snapshot_error(logged_in, data_dirs, recorder, monkeypatch): def test_snapshot_routes_validate_names(logged_in, data_dirs, recorder): - assert logged_in.post("/vm/web01/snapshots/-bad/revert", follow_redirects=False).status_code == 400 + bad = logged_in.post("/vm/web01/snapshots/-bad/revert", follow_redirects=False) + assert bad.status_code == 303 and "snapshot_error=" in bad.headers["location"] assert logged_in.post("/vm/web01/snapshots/..%2Fx/delete", follow_redirects=False).status_code in (400, 404) assert logged_in.post("/vm/-bad/snapshots/create", data={}, follow_redirects=False).status_code == 400 assert not find(recorder, "virsh", "snapshot-delete") and not find(recorder, "virsh", "snapshot-revert") diff --git a/web/app.py b/web/app.py index 3d83625..fc5cbe4 100644 --- a/web/app.py +++ b/web/app.py @@ -2007,6 +2007,13 @@ def api_health(request: Request): # Feature modules register template globals into app.templates, so they are imported once it exists. from features import 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) app.include_router(snapshots.router) diff --git a/web/core.py b/web/core.py index cf7b9a1..c87124c 100644 --- a/web/core.py +++ b/web/core.py @@ -271,6 +271,41 @@ def list_operations(limit: int = 25) -> list[dict[str, Any]]: return operations +def interrupt_orphaned_operations() -> int: + """Operations run in threads of the panel process: after a restart the ones still marked + queued/running will never finish. Close them so they do not block the next attempt.""" + if not OPERATIONS_DIR.exists(): + return 0 + count = 0 + for path in OPERATIONS_DIR.glob("*.json"): + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + continue + if data.get("status") not in ("queued", "running"): + continue + data.pop("log_tail", None) + message = "Прервано: панель перезапускалась во время операции. Проверьте состояние машины и запустите действие снова." + update_operation(data, status="error", progress=100, message=message, finished_at=utc_now(), interrupted=True) + append_operation_log(data["id"], message) + count += 1 + return count + + +def active_operations_for(vm_name: str, kinds: tuple[str, ...] | set[str] | None = None) -> list[dict[str, Any]]: + """Unfinished operations of one machine, over the whole operations directory (not only the latest N).""" + ensure_operations_dir() + found = [] + for path in OPERATIONS_DIR.glob("*.json"): + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + continue + if data.get("vm_name") == vm_name and data.get("status") in ("queued", "running") and (kinds is None or data.get("type") in kinds): + found.append(data) + return found + + def running_operations(kind: str | None = None) -> list[dict[str, Any]]: return [op for op in list_operations(50) if op.get("status") in ("queued", "running") and (kind is None or op.get("type") == kind)] diff --git a/web/features/snapshots.py b/web/features/snapshots.py index a4ed371..50394d1 100644 --- a/web/features/snapshots.py +++ b/web/features/snapshots.py @@ -6,6 +6,7 @@ libvirt через `virsh snapshot-*`. только диск. Создание и возврат выполняются как фоновые операции. """ import re +import threading import xml.etree.ElementTree as ET from datetime import datetime, timedelta from typing import Any @@ -15,6 +16,7 @@ from fastapi.responses import JSONResponse, RedirectResponse import presenters from core import ( + active_operations_for, append_operation_log, cmd_error, finish_operation, @@ -62,7 +64,7 @@ KNOWN_ERRORS = [ ("is not running", "Машина выключена, поэтому это действие сейчас недоступно."), ] -_ROW_RE = re.compile(r"^\s*(\S+)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})(?:\s+([+-]\d{4}))?\s+(\S+)\s*$") +_ROW_RE = re.compile(r"^\s*(\S.*?)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})(?:\s+([+-]\d{4}))?\s+(\S+)\s*$") # ---------------------------------------------------------------- parsers @@ -181,6 +183,8 @@ def list_snapshots(vm: str) -> list[dict[str, Any]]: row["state_info"] = snapshot_state(row["state"]) row["when"] = format_when(row["created_at"]) row["title"] = row["description"] or row["name"] + # Snapshots made outside the panel (virsh, virt-manager) may have names the panel's URLs do not accept. + row["manageable"] = valid_label(row["name"]) return rows @@ -194,7 +198,7 @@ def vm_operations(vm: str) -> list[dict[str, Any]]: def active_operations(vm: str) -> list[dict[str, Any]]: - return [op for op in vm_operations(vm) if op.get("status") in ("queued", "running")] + return active_operations_for(vm, OPERATION_TYPES) def create_blockers(vm: str, snapshots: list[dict[str, Any]], checks: dict[str, Any]) -> list[str]: @@ -239,7 +243,7 @@ def revert_worker(operation: dict[str, Any]) -> None: update_operation(operation, progress=15, message="Возвращаем машину к сохранённому состоянию…", cmd=" ".join(cmd)) result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT) _log_result(operation, result) - if not result["ok"] and "force" in (result.get("stderr") or "").lower(): + if not result["ok"] and "revert requires force" in (result.get("stderr") or "").lower(): # libvirt просит --force, когда конфигурация машины изменилась после снимка: пользователь уже подтвердил возврат. append_operation_log(operation["id"], "libvirt требует подтверждения (--force): повторяем принудительно.") cmd.append("--force") @@ -275,7 +279,7 @@ def snapshots_page(request: Request, name: str): info = presenters.parse_dominfo(dominfo) snapshots = list_snapshots(name) checks = domain_checks(name) - running = presenters.vm_state(info["state"])["running"] + running = vm_active(info["state"]) op_id = request.query_params.get("op", "") operation = read_operation(op_id) if op_id else None if operation and (operation.get("vm_name") != name or operation.get("type") not in OPERATION_TYPES): @@ -297,8 +301,21 @@ def snapshots_page(request: Request, name: str): }) +def vm_active(state: str) -> bool: + """Running or paused: libvirt saves the memory of both into the snapshot.""" + return presenters.vm_state(state)["running"] or "paus" in (state or "").lower() + + +_create_lock = threading.Lock() + + @router.post("/vm/{name}/snapshots/create") def snapshot_create(request: Request, name: str, description: str = Form("")): + with _create_lock: # two quick clicks must not both pass the checks below + return _snapshot_create(request, name, description) + + +def _snapshot_create(request: Request, name: str, description: str): blocked, dominfo = _guard(request, name) if blocked: return blocked @@ -306,7 +323,7 @@ def snapshot_create(request: Request, name: str, description: str = Form("")): blockers = create_blockers(name, snapshots, domain_checks(name)) if blockers: return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", blockers[0]) - running = presenters.vm_state(presenters.parse_dominfo(dominfo)["state"])["running"] + running = vm_active(presenters.parse_dominfo(dominfo)["state"]) snap = next_snapshot_name([s["name"] for s in snapshots]) if not valid_label(snap): return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Не удалось подобрать имя снимка.") @@ -321,7 +338,7 @@ def snapshot_revert(request: Request, name: str, snap: str): if blocked: return blocked if not valid_label(snap): - return JSONResponse({"ok": False, "error": "Invalid snapshot name"}, status_code=400) + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Этот снимок создан вне панели и имеет необычное имя — управляйте им через virsh.") if active_operations(name): return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Дождитесь окончания предыдущей операции со снимками этой машины.") operation = new_operation("snapshot_revert", f"Возврат машины {name} к снимку {snap}", vm_name=name, snapshot=snap) @@ -335,7 +352,7 @@ def snapshot_delete(request: Request, name: str, snap: str): if blocked: return blocked if not valid_label(snap): - return JSONResponse({"ok": False, "error": "Invalid snapshot name"}, status_code=400) + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Этот снимок создан вне панели и имеет необычное имя — управляйте им через virsh.") if active_operations(name): return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Дождитесь окончания предыдущей операции со снимками этой машины.") result = run_cmd(["virsh", "snapshot-delete", "--domain", name, "--snapshotname", snap], timeout=180) diff --git a/web/templates/vm_snapshots.html b/web/templates/vm_snapshots.html index fd7724b..82da02f 100644 --- a/web/templates/vm_snapshots.html +++ b/web/templates/vm_snapshots.html @@ -69,6 +69,7 @@
+ {% if snap.manageable %} + {% else %} + Создан вне панели + {% endif %} {% endfor %} @@ -105,7 +109,7 @@