Files
virtuality/web/features/snapshots.py
T
Claude 4198c18ff4 Снимки машин (точки восстановления) были добавлены
Новая страница /vm/{name}/snapshots и вкладка «Снимки» на странице машины:
список внутренних снимков libvirt с описанием, датой, состоянием и меткой
«Текущий», создание снимка с автоматическим именем snap-ГГГГММДД-ЧЧММ,
возврат к снимку и удаление. Создание и возврат идут как фоновые задачи
с журналом; страница сама обновляется, когда задача завершится.

Ограничения и ошибки объясняются по-русски: не больше 8 снимков на машину,
только диски qcow2, предупреждение для машин с UEFI; известные ошибки libvirt
переводятся, а исходный текст показывается рядом. Возврат повторяется
с --force, если libvirt этого требует.

Модуль web/features/snapshots.py, парсеры с тестами на образцах вывода virsh,
fake_run_cmd в тестах расширен командами snapshot-*, demo-сервер показывает
снимки и идущую задачу. В спрайт добавлены иконки camera и history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 14:30:25 +00:00

345 строки
18 KiB
Python

"""«Снимки» (точки восстановления) виртуальной машины — внутренние снимки
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}» удалён")