Files
virtuality/tests/test_snapshots.py
T
Claude eb7b71f478 Операции, прерванные перезапуском панели, закрываются при старте; снимки: исправления по ревью
- core.interrupt_orphaned_operations() при запуске панели, active_operations_for() по всем задачам
- снимки: имена с пробелами, снимки вне панели без кнопок, --force только на «revert requires force»,
  память на паузе учитывается, защита от двойного нажатия, надпись «Создаём снимок…»

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:05:49 +00:00

287 строки
15 KiB
Python

"""Снимки (точки восстановления) машины: парсеры и маршруты /vm/{name}/snapshots."""
from datetime import datetime
from pathlib import Path
from urllib.parse import unquote
import pytest
import conftest
import core
from features import snapshots
FIXTURES = Path(__file__).resolve().parent / "fixtures"
# ---------------------------------------------------------------- parsers
def test_parse_snapshot_list_fixture():
rows = snapshots.parse_snapshot_list((FIXTURES / "snapshot-list.txt").read_text())
assert [row["name"] for row in rows] == ["before-upd", "clean"] # новые сверху
assert rows[0] == {"name": "before-upd", "created_at": "2026-09-25 10:12:03", "tz": "+0000", "state": "shutoff"}
assert rows[1]["state"] == "running"
def test_parse_snapshot_list_without_timezone_and_garbage():
text = " Name Creation Time State\n------\n snap1 2026-01-02 03:04:05 running\nerror: something\n\n"
rows = snapshots.parse_snapshot_list(text)
assert rows == [{"name": "snap1", "created_at": "2026-01-02 03:04:05", "tz": "", "state": "running"}]
assert snapshots.parse_snapshot_list("") == []
def test_parse_snapshot_xml():
meta = snapshots.parse_snapshot_xml(conftest.fake_snapshot_xml("clean"))
assert meta["description"] == "Чистая система после установки"
assert meta["state"] == "running" and meta["with_memory"] is True
assert snapshots.parse_snapshot_xml(conftest.fake_snapshot_xml("before-upd"))["with_memory"] is False
assert snapshots.parse_snapshot_xml("<broken") == {}
def test_inspect_domain_xml_qcow2_fixture():
info = snapshots.inspect_domain_xml((FIXTURES / "dumpxml-web01.xml").read_text())
assert info["disks"] == [{"target": "vda", "format": "qcow2"}] # cdrom пропущен
assert info["unsupported"] == [] and info["uefi"] is False
def test_inspect_domain_xml_raw_and_uefi():
xml = """<domain type='kvm'><name>x</name>
<os firmware='efi'><type arch='x86_64'>hvm</type><loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF_CODE.fd</loader><nvram>/var/lib/libvirt/qemu/nvram/x.fd</nvram></os>
<devices>
<disk type='file' device='disk'><driver name='qemu' type='raw'/><source file='/x.img'/><target dev='vda' bus='virtio'/></disk>
<disk type='file' device='disk'><driver name='qemu' type='qcow2'/><source file='/y.qcow2'/><target dev='vdb' bus='virtio'/></disk>
</devices></domain>"""
info = snapshots.inspect_domain_xml(xml)
assert info["unsupported"] == [{"target": "vda", "format": "raw"}]
assert info["uefi"] is True
assert snapshots.inspect_domain_xml("<nope") == {"disks": [], "unsupported": [], "uefi": False}
def test_format_when():
now = datetime(2026, 9, 25, 12, 0, 0)
assert snapshots.format_when("2026-09-25 10:12:03", now) == "Сегодня, 10:12"
assert snapshots.format_when("2026-09-24 09:00:41", now) == "Вчера, 09:00"
assert snapshots.format_when("2026-03-08 18:30:00", now) == "8 марта 2026, 18:30"
assert snapshots.format_when("garbage", now) == "garbage"
def test_explain_error_translates_known_libvirt_messages():
text = snapshots.explain_error("error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
assert text.startswith("Для машин с UEFI-прошивкой") and "libvirt:" in text
assert snapshots.explain_error("error: internal snapshot for disk vda unsupported for storage type raw").startswith("Внутренние снимки работают только с дисками qcow2")
assert snapshots.explain_error("error: Timed out during operation: cannot acquire state change lock").startswith("Машина занята")
assert snapshots.explain_error("error: something odd\nsecond line") == "something odd"
assert "не объяснил" in snapshots.explain_error("")
def test_next_snapshot_name_and_description():
now = datetime(2026, 9, 25, 10, 12)
assert snapshots.next_snapshot_name([], now) == "snap-20260925-1012"
assert snapshots.next_snapshot_name(["snap-20260925-1012"], now) == "snap-20260925-1012-2"
assert snapshots.next_snapshot_name(["snap-20260925-1012", "snap-20260925-1012-2"], now) == "snap-20260925-1012-3"
assert core.valid_label(snapshots.next_snapshot_name([], now))
assert snapshots.clean_description(" --force\x00 перед\nобновлением ") == "force перед обновлением"
assert len(snapshots.clean_description("x" * 500)) == snapshots.DESCRIPTION_MAX
def test_snapshot_state_labels():
assert snapshots.snapshot_state("running") == {"label": "Работала", "tone": "success"}
assert snapshots.snapshot_state("shutoff")["label"] == "Была выключена"
assert snapshots.snapshot_state("weird") == {"label": "weird", "tone": "neutral"}
# ---------------------------------------------------------------- helpers for routes
@pytest.fixture()
def recorder(monkeypatch):
"""run_cmd, который записывает команды virsh и выполняет операции сразу (без потоков)."""
calls: list[list[str]] = []
def run(cmd, timeout=12, **kwargs):
calls.append(list(cmd))
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
monkeypatch.setattr(snapshots, "run_operation", lambda operation, worker: worker(operation))
return calls
def find(calls, *prefix):
return [cmd for cmd in calls if cmd[: len(prefix)] == list(prefix)]
# ---------------------------------------------------------------- routes
def test_snapshots_page_requires_login(client):
response = client.get("/vm/web01/snapshots", follow_redirects=False)
assert response.status_code == 303 and response.headers["location"] == "/login"
def test_snapshots_page_renders_list(logged_in):
html = logged_in.get("/vm/web01/snapshots").text
assert "Перед обновлением ядра" in html and "Чистая система после установки" in html
assert "Текущий" in html and "Была выключена" in html and "Работала" in html
assert 'action="/vm/web01/snapshots/before-upd/revert"' in html
assert 'action="/vm/web01/snapshots/clean/delete"' in html
assert "2 из 8" in html
assert "snap-" in html # предложенное имя
assert "shut off" not in html
def test_snapshots_page_unknown_vm_redirects_home(logged_in):
response = logged_in.get("/vm/ghost/snapshots", follow_redirects=False)
assert response.status_code == 303 and response.headers["location"] == "/"
assert logged_in.get("/vm/-bad/snapshots", follow_redirects=False).status_code == 400
def test_vm_page_links_to_snapshots_tab(logged_in):
assert 'href="/vm/web01/snapshots"' in logged_in.get("/vm/web01").text
def test_create_snapshot_runs_virsh_and_reports_operation(logged_in, data_dirs, recorder):
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "Перед экспериментом"}, follow_redirects=False)
assert response.status_code == 303
location = response.headers["location"]
assert location.startswith("/vm/web01/snapshots?op=")
cmd = find(recorder, "virsh", "snapshot-create-as")[0]
assert cmd[2:6] == ["--domain", "web01", "--name", cmd[5]] and cmd[5].startswith("snap-")
assert cmd[6:] == ["--description", "Перед экспериментом", "--atomic"]
operation = core.read_operation(location.split("op=", 1)[1])
assert operation["status"] == "success" and operation["type"] == "snapshot_create" and operation["vm_name"] == "web01"
assert "создан" in operation["message"]
html = logged_in.get(location).text
assert "создан" in html
def test_create_snapshot_without_description_omits_option(logged_in, data_dirs, recorder):
logged_in.post("/vm/web01/snapshots/create", data={"description": " "}, follow_redirects=False)
cmd = find(recorder, "virsh", "snapshot-create-as")[0]
assert "--description" not in cmd and cmd[-1] == "--atomic"
def test_create_snapshot_limit(logged_in, data_dirs, recorder, monkeypatch):
rows = "\n".join(f" snap-{i:02d} 2026-09-{i + 1:02d} 10:00:00 +0000 shutoff" for i in range(8))
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "snapshot-list"]:
return conftest.fake_result(" Name Creation Time State\n---\n" + rows)
return original(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "x"}, follow_redirects=False)
assert response.status_code == 303
assert "максимум" in unquote(response.headers["location"])
assert not find(recorder, "virsh", "snapshot-create-as")
html = logged_in.get("/vm/web01/snapshots").text
assert "8 из 8" in html and "максимум" in html
def test_create_snapshot_rejects_raw_disk(logged_in, data_dirs, recorder, monkeypatch):
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "dumpxml"]:
return conftest.fake_result("<domain><devices><disk device='disk'><driver type='raw'/><target dev='vda'/></disk></devices></domain>")
return original(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "x"}, follow_redirects=False)
assert "qcow2" in unquote(response.headers["location"])
assert not find(recorder, "virsh", "snapshot-create-as")
def test_create_snapshot_surfaces_libvirt_error(logged_in, data_dirs, recorder, monkeypatch):
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "snapshot-create-as"]:
return conftest.fake_result(ok=False, stderr="error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
return original(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
response = logged_in.post("/vm/web01/snapshots/create", data={}, follow_redirects=False)
location = response.headers["location"]
operation = core.read_operation(location.split("op=", 1)[1])
assert operation["status"] == "error"
assert operation["message"].startswith("Для машин с UEFI-прошивкой") and "pflash" in operation["message"]
assert "pflash" in operation["log_tail"]
html = logged_in.get(location).text
assert "Для машин с UEFI-прошивкой" in html
def test_create_snapshot_waits_for_active_operation(logged_in, data_dirs, recorder):
core.new_operation("snapshot_create", "Снимок машины web01", vm_name="web01", snapshot="snap-x")
response = logged_in.post("/vm/web01/snapshots/create", data={}, follow_redirects=False)
assert "предыдущей операции" in unquote(response.headers["location"])
assert not find(recorder, "virsh", "snapshot-create-as")
html = logged_in.get("/vm/web01/snapshots").text
assert 'data-watch-operation=' in html and "/api/operations/" in html
def test_revert_snapshot(logged_in, data_dirs, recorder):
response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False)
assert response.status_code == 303
assert find(recorder, "virsh", "snapshot-revert") == [["virsh", "snapshot-revert", "--domain", "web01", "--snapshotname", "clean"]]
operation = core.read_operation(response.headers["location"].split("op=", 1)[1])
assert operation["status"] == "success" and "возвращена" in operation["message"] and "работает" in operation["message"]
def test_revert_retries_with_force(logged_in, data_dirs, recorder, monkeypatch):
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "snapshot-revert"] and "--force" not in cmd:
recorder.append(list(cmd))
return conftest.fake_result(ok=False, stderr="error: revert requires force: snapshot was created with a different configuration")
return original(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False)
reverts = find(recorder, "virsh", "snapshot-revert")
assert len(reverts) == 2 and reverts[1][-1] == "--force"
assert core.read_operation(response.headers["location"].split("op=", 1)[1])["status"] == "success"
def test_revert_reports_error(logged_in, data_dirs, recorder, monkeypatch):
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "snapshot-revert"]:
return conftest.fake_result(ok=False, stderr="error: Domain snapshot not found: no domain snapshot with matching name 'clean'")
return original(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False)
operation = core.read_operation(response.headers["location"].split("op=", 1)[1])
assert operation["status"] == "error" and operation["message"].startswith("Такого снимка уже нет")
def test_delete_snapshot(logged_in, data_dirs, recorder):
response = logged_in.post("/vm/web01/snapshots/before-upd/delete", follow_redirects=False)
assert response.status_code == 303
assert "удалён" in unquote(response.headers["location"])
assert find(recorder, "virsh", "snapshot-delete") == [["virsh", "snapshot-delete", "--domain", "web01", "--snapshotname", "before-upd"]]
def test_delete_snapshot_error(logged_in, data_dirs, recorder, monkeypatch):
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "snapshot-delete"]:
return conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock")
return original(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
response = logged_in.post("/vm/web01/snapshots/before-upd/delete", follow_redirects=False)
assert "snapshot_error=" in response.headers["location"] and "занята" in unquote(response.headers["location"])
def test_snapshot_routes_validate_names(logged_in, data_dirs, recorder):
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")
def test_snapshot_routes_require_login(client):
for path in ("/vm/web01/snapshots/create", "/vm/web01/snapshots/clean/revert", "/vm/web01/snapshots/clean/delete"):
response = client.post(path, follow_redirects=False)
assert response.status_code == 303 and response.headers["location"] == "/login"