"""«Снимки» (точки восстановления) виртуальной машины — внутренние снимки libvirt через `virsh snapshot-*`. Снимок хранится внутри qcow2-диска машины. У работающей машины в снимок попадает и память (машина на несколько секунд замирает), у выключенной — только диск. Создание и возврат выполняются как фоновые операции. """ import re import xml.etree.ElementTree as ET from datetime import datetime, timedelta from typing import Any from fastapi import APIRouter, Form, Request from fastapi.responses import JSONResponse, RedirectResponse import presenters from core import ( append_operation_log, cmd_error, finish_operation, list_operations, new_operation, read_operation, redirect_with_message, render, require_auth, run_cmd, run_operation, update_operation, valid_label, valid_vm_name, ) router = APIRouter() MAX_SNAPSHOTS = 8 DESCRIPTION_MAX = 200 SNAPSHOT_TIMEOUT = 1800 # секунд: снимок памяти большой машины идёт минуты OPERATION_TYPES = ("snapshot_create", "snapshot_revert") SNAPSHOT_STATES = { "running": ("Работала", "success"), "paused": ("Была на паузе", "warning"), "shutoff": ("Была выключена", "neutral"), "disk-snapshot": ("Только диск", "info"), } MONTHS = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"] # Известные ошибки libvirt → понятное объяснение (подстрока ищется без учёта регистра). KNOWN_ERRORS = [ ("pflash", "Для машин с UEFI-прошивкой эта версия libvirt не умеет делать внутренние снимки."), ("nvram", "Для машин с UEFI-прошивкой эта версия libvirt не умеет делать внутренние снимки."), ("unsupported for storage type", "Внутренние снимки работают только с дисками qcow2."), ("does not support snapshot", "Внутренние снимки работают только с дисками qcow2."), ("internal snapshot for disk", "Внутренние снимки работают только с дисками qcow2."), ("cannot acquire state change lock", "Машина занята другой операцией. Подождите минуту и попробуйте снова."), ("timed out during operation", "Машина занята другой операцией. Подождите минуту и попробуйте снова."), ("snapshot not found", "Такого снимка уже нет — возможно, его удалили."), ("already exists", "Снимок с таким именем уже есть."), ("no space left", "На диске сервера закончилось место."), ("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*$") # ---------------------------------------------------------------- parsers def parse_snapshot_list(text: str) -> list[dict[str, Any]]: """Таблица `virsh snapshot-list` → список снимков, новые сверху.""" rows = [] for line in (text or "").splitlines(): match = _ROW_RE.match(line) if not match: continue name, date, time, tz, state = match.groups() rows.append({"name": name, "created_at": f"{date} {time}", "tz": tz or "", "state": state}) return sorted(rows, key=lambda row: row["created_at"], reverse=True) def parse_snapshot_xml(text: str) -> dict[str, Any]: """`virsh snapshot-dumpxml` → описание, состояние, есть ли память.""" try: root = ET.fromstring(text or "") except ET.ParseError: return {} memory = root.find("memory") parent = root.find("parent/name") return { "description": (root.findtext("description") or "").strip(), "state": (root.findtext("state") or "").strip(), "with_memory": memory is not None and memory.get("snapshot", "no") not in ("no", ""), "parent": (parent.text or "").strip() if parent is not None else "", } def inspect_domain_xml(text: str) -> dict[str, Any]: """`virsh dumpxml` → форматы дисков и признак UEFI (для проверок перед снимком).""" info: dict[str, Any] = {"disks": [], "unsupported": [], "uefi": False} try: root = ET.fromstring(text or "") except ET.ParseError: return info for disk in root.iter("disk"): if disk.get("device", "disk") != "disk": continue driver = disk.find("driver") target = disk.find("target") fmt = (driver.get("type") if driver is not None else "") or "" dev = (target.get("dev") if target is not None else "") or "?" info["disks"].append({"target": dev, "format": fmt or "неизвестно"}) if fmt and fmt != "qcow2": info["unsupported"].append({"target": dev, "format": fmt}) os_node = root.find("os") if os_node is not None: loader = os_node.find("loader") info["uefi"] = os_node.get("firmware") == "efi" or os_node.find("nvram") is not None or (loader is not None and loader.get("type") == "pflash") return info def snapshot_state(state: str) -> dict[str, str]: label, tone = SNAPSHOT_STATES.get((state or "").strip().lower(), (state or "—", "neutral")) return {"label": label, "tone": tone} def format_when(created_at: str, now: datetime | None = None) -> str: """'2026-09-25 10:12:03' → 'Сегодня, 10:12' / 'Вчера, 10:12' / '25 сентября 2026, 10:12'.""" try: moment = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S") except (TypeError, ValueError): return created_at or "—" today = (now or datetime.now()).date() clock = moment.strftime("%H:%M") if moment.date() == today: return f"Сегодня, {clock}" if moment.date() == today - timedelta(days=1): return f"Вчера, {clock}" return f"{moment.day} {MONTHS[moment.month - 1]} {moment.year}, {clock}" def explain_error(raw: str) -> str: """Текст ошибки libvirt → понятная фраза; технический текст остаётся в скобках.""" text = re.sub(r"^error:\s*", "", (raw or "").strip(), flags=re.IGNORECASE) first = text.splitlines()[0] if text else "" low = first.lower() for needle, explanation in KNOWN_ERRORS: if needle in low: return f"{explanation} (libvirt: {first})" return first or "libvirt не выполнил команду и не объяснил причину." def next_snapshot_name(existing: list[str], now: datetime | None = None) -> str: stamp = (now or datetime.now()).strftime("snap-%Y%m%d-%H%M") name, counter = stamp, 2 while name in existing: name, counter = f"{stamp}-{counter}", counter + 1 return name def clean_description(text: str) -> str: text = re.sub(r"[\x00-\x1f\x7f]+", " ", text or "") return re.sub(r"\s+", " ", text).strip().lstrip("-").strip()[:DESCRIPTION_MAX] # ---------------------------------------------------------------- virsh def current_snapshot(vm: str) -> str: result = run_cmd(["virsh", "snapshot-current", "--domain", vm, "--name"], timeout=10) return result["stdout"].strip() if result["ok"] else "" def list_snapshots(vm: str) -> list[dict[str, Any]]: result = run_cmd(["virsh", "snapshot-list", "--domain", vm], timeout=20) rows = parse_snapshot_list(result["stdout"]) if result["ok"] else [] current = current_snapshot(vm) if rows else "" for row in rows: xml = run_cmd(["virsh", "snapshot-dumpxml", "--domain", vm, "--snapshotname", row["name"]], timeout=10) meta = parse_snapshot_xml(xml["stdout"]) if xml["ok"] else {} row["description"] = meta.get("description", "") row["with_memory"] = meta.get("with_memory", row["state"] == "running") row["is_current"] = row["name"] == current row["state_info"] = snapshot_state(row["state"]) row["when"] = format_when(row["created_at"]) row["title"] = row["description"] or row["name"] return rows def domain_checks(vm: str) -> dict[str, Any]: result = run_cmd(["virsh", "dumpxml", vm], timeout=10) return inspect_domain_xml(result["stdout"] if result["ok"] else "") def vm_operations(vm: str) -> list[dict[str, Any]]: return [op for op in list_operations(50) if op.get("type") in OPERATION_TYPES and op.get("vm_name") == vm] def active_operations(vm: str) -> list[dict[str, Any]]: return [op for op in vm_operations(vm) if op.get("status") in ("queued", "running")] def create_blockers(vm: str, snapshots: list[dict[str, Any]], checks: dict[str, Any]) -> list[str]: """Почему снимок сейчас нельзя создать (пустой список — можно).""" reasons = [] if len(snapshots) >= MAX_SNAPSHOTS: reasons.append(f"Уже {MAX_SNAPSHOTS} снимков — это максимум для одной машины. Удалите ненужный снимок, чтобы создать новый.") if checks.get("unsupported"): disks = ", ".join(f"{d['target']} ({d['format']})" for d in checks["unsupported"]) reasons.append(f"Внутренние снимки работают только с дисками qcow2, а у этой машины диск другого формата: {disks}.") if active_operations(vm): reasons.append("Дождитесь окончания предыдущей операции со снимками этой машины.") return reasons def _log_result(operation: dict[str, Any], result: dict[str, Any]) -> None: for stream in ("stdout", "stderr"): if result.get(stream): append_operation_log(operation["id"], result[stream]) def create_worker(operation: dict[str, Any]) -> None: vm, snap, description = operation["vm_name"], operation["snapshot"], operation.get("description", "") cmd = ["virsh", "snapshot-create-as", "--domain", vm, "--name", snap] if description: cmd += ["--description", description] cmd.append("--atomic") append_operation_log(operation["id"], "$ " + " ".join(cmd)) update_operation(operation, progress=15, message="Сохраняем состояние машины и памяти — она на несколько секунд замрёт…" if operation.get("vm_running") else "Сохраняем состояние диска…", cmd=" ".join(cmd)) result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT) _log_result(operation, result) if result["ok"]: finish_operation(operation, True, f"Снимок «{snap}» создан") else: finish_operation(operation, False, explain_error(cmd_error(result, "virsh завершился с ошибкой"))) def revert_worker(operation: dict[str, Any]) -> None: vm, snap = operation["vm_name"], operation["snapshot"] cmd = ["virsh", "snapshot-revert", "--domain", vm, "--snapshotname", snap] append_operation_log(operation["id"], "$ " + " ".join(cmd)) 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(): # libvirt просит --force, когда конфигурация машины изменилась после снимка: пользователь уже подтвердил возврат. append_operation_log(operation["id"], "libvirt требует подтверждения (--force): повторяем принудительно.") cmd.append("--force") append_operation_log(operation["id"], "$ " + " ".join(cmd)) result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT) _log_result(operation, result) if not result["ok"]: finish_operation(operation, False, explain_error(cmd_error(result, "virsh завершился с ошибкой"))) return state = presenters.vm_state(run_cmd(["virsh", "domstate", vm], timeout=8)["stdout"]) finish_operation(operation, True, f"Машина возвращена к снимку «{snap}». Сейчас она {'работает' if state['running'] else 'выключена'}.") # ---------------------------------------------------------------- routes def _guard(request: Request, name: str): """Редирект на /login, 400 для плохого имени, редирект на / для несуществующей машины — или None + dominfo.""" auth_redirect = require_auth(request) if auth_redirect: return auth_redirect, "" if not valid_vm_name(name): return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400), "" dominfo = run_cmd(["virsh", "dominfo", name], timeout=8) if not dominfo["ok"]: return RedirectResponse(url="/", status_code=303), "" return None, dominfo["stdout"] @router.get("/vm/{name}/snapshots") def snapshots_page(request: Request, name: str): blocked, dominfo = _guard(request, name) if blocked: return blocked info = presenters.parse_dominfo(dominfo) snapshots = list_snapshots(name) checks = domain_checks(name) running = presenters.vm_state(info["state"])["running"] 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): operation = None return render(request, "vm_snapshots.html", { "vm": {"name": name, "info": info, "dominfo": dominfo}, "vm_running": running, "snapshots": snapshots, "max_snapshots": MAX_SNAPSHOTS, "checks": checks, "blockers": create_blockers(name, snapshots, checks), "suggested_name": next_snapshot_name([s["name"] for s in snapshots]), "description_max": DESCRIPTION_MAX, "operation": operation, "active_operations": active_operations(name), "snapshot_message": request.query_params.get("snapshot_message", ""), "snapshot_error": request.query_params.get("snapshot_error", ""), "raw_list": run_cmd(["virsh", "snapshot-list", "--domain", name], timeout=20)["stdout"], }) @router.post("/vm/{name}/snapshots/create") def snapshot_create(request: Request, name: str, description: str = Form("")): blocked, dominfo = _guard(request, name) if blocked: return blocked snapshots = list_snapshots(name) 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"] 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", "Не удалось подобрать имя снимка.") operation = new_operation("snapshot_create", f"Снимок машины {name}", vm_name=name, snapshot=snap, description=clean_description(description), vm_running=running) run_operation(operation, create_worker) return RedirectResponse(url=f"/vm/{name}/snapshots?op={operation['id']}", status_code=303) @router.post("/vm/{name}/snapshots/{snap}/revert") def snapshot_revert(request: Request, name: str, snap: str): blocked, _ = _guard(request, name) if blocked: return blocked if not valid_label(snap): return JSONResponse({"ok": False, "error": "Invalid snapshot name"}, status_code=400) 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) run_operation(operation, revert_worker) return RedirectResponse(url=f"/vm/{name}/snapshots?op={operation['id']}", status_code=303) @router.post("/vm/{name}/snapshots/{snap}/delete") def snapshot_delete(request: Request, name: str, snap: str): blocked, _ = _guard(request, name) if blocked: return blocked if not valid_label(snap): return JSONResponse({"ok": False, "error": "Invalid snapshot name"}, status_code=400) 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) if not result["ok"]: return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Не удалось удалить снимок: " + explain_error(cmd_error(result, "virsh завершился с ошибкой"))) return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_message", f"Снимок «{snap}» удалён")