Новая страница /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
345 строки
18 KiB
Python
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}» удалён")
|