Merge branch 'feature/snapshots' into claude/production-system-image-smcxyr

# Conflicts:
#	web/app.py
Этот коммит содержится в:
Claude
2026-09-25 14:55:49 +00:00
родитель 2745a1280f 4198c18ff4
Коммит 42ac092272
8 изменённых файлов: 867 добавлений и 4 удалений
+33 -1
Просмотреть файл
@@ -35,12 +35,37 @@ DUMPXML = """<domain type='kvm'>
"""
FIXTURES = ROOT / "tests" / "fixtures"
# Снимки web01: имя → (состояние, описание); список берётся из fixtures/snapshot-list.txt.
SNAPSHOTS = {"before-upd": ("shutoff", "Перед обновлением ядра"), "clean": ("running", "Чистая система после установки")}
def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict:
return {"ok": ok, "code": 0 if ok else 1, "stdout": stdout, "stderr": stderr, "cmd": ""}
def option(cmd, name):
"""Значение опции virsh вида --name VALUE (или '')."""
return cmd[cmd.index(name) + 1] if name in cmd and cmd.index(name) + 1 < len(cmd) else ""
def fake_snapshot_xml(snap: str) -> str:
state, description = SNAPSHOTS.get(snap, ("shutoff", ""))
memory = "<memory snapshot='internal'/>" if state == "running" else "<memory snapshot='no'/>"
return f"<domainsnapshot><name>{snap}</name><description>{description}</description><state>{state}</state>{memory}<creationTime>1758794400</creationTime></domainsnapshot>"
def fake_run_cmd(cmd, timeout=12, **_kwargs):
joined = " ".join(cmd)
if cmd[:2] == ["virsh", "snapshot-list"]:
return fake_result((FIXTURES / "snapshot-list.txt").read_text())
if cmd[:2] == ["virsh", "snapshot-current"]:
return fake_result("clean")
if cmd[:2] == ["virsh", "snapshot-dumpxml"]:
return fake_result(fake_snapshot_xml(option(cmd, "--snapshotname")))
if cmd[:2] in (["virsh", "snapshot-create-as"], ["virsh", "snapshot-revert"], ["virsh", "snapshot-delete"]):
return fake_result("Domain snapshot %s created" % option(cmd, "--name"))
if cmd[:2] == ["virsh", "list"]:
return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
if cmd[:2] == ["virsh", "dominfo"]:
@@ -72,9 +97,15 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
return fake_result()
def feature_modules():
"""Загруженные модули web/features/* — им тоже подменяем run_cmd."""
return [module for name, module in sys.modules.items() if name.startswith("features.") and hasattr(module, "run_cmd")]
@pytest.fixture()
def data_dirs(tmp_path, monkeypatch):
import app
import core
import host_profile
import network_core
import update_core
@@ -95,6 +126,7 @@ def data_dirs(tmp_path, monkeypatch):
monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"])
monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"])
monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"])
monkeypatch.setattr(core, "OPERATIONS_DIR", dirs["operations"])
monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"])
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
@@ -108,7 +140,7 @@ def data_dirs(tmp_path, monkeypatch):
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
for module in (app, network_core, host_profile):
for module in (app, network_core, host_profile, core, *feature_modules()):
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
return dirs
+33 -2
Просмотреть файл
@@ -14,16 +14,44 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import conftest # noqa: E402 (sets sys.path and env)
import app # noqa: E402
import core # noqa: E402
import host_profile # noqa: E402
import network_core # noqa: E402
import update_core # noqa: E402
# Снимки для демо: имя, дата, состояние, описание.
DEMO_SNAPSHOTS = [
("snap-20260925-1012", "2026-09-25 10:12:03 +0000", "shutoff", "Перед обновлением ядра"),
("snap-20260924-0900", "2026-09-24 09:00:41 +0000", "running", "Настроен nginx и сертификаты"),
("snap-20260918-1830", "2026-09-18 18:30:12 +0000", "running", "Чистая система после установки"),
("clean", "2026-09-10 12:05:00 +0000", "shutoff", ""),
]
def demo_run_cmd(cmd, timeout=12, **kwargs):
"""Как conftest.fake_run_cmd, но со списком снимков побогаче."""
if cmd[:2] == ["virsh", "snapshot-list"]:
rows = "\n".join(f" {name:<20} {when} {state}" for name, when, state, _ in DEMO_SNAPSHOTS)
return conftest.fake_result(" Name Creation Time State\n" + "-" * 62 + "\n" + rows)
if cmd[:2] == ["virsh", "snapshot-current"]:
return conftest.fake_result("snap-20260924-0900")
if cmd[:2] == ["virsh", "snapshot-create-as"] and "uefi" in conftest.option(cmd, "--description").lower():
# Описание со словом «uefi» показывает, как выглядит ошибка libvirt.
return conftest.fake_result(ok=False, stderr="error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
if cmd[:2] == ["virsh", "snapshot-dumpxml"]:
snap = conftest.option(cmd, "--snapshotname")
state, description = next(((s, d) for n, _, s, d in DEMO_SNAPSHOTS if n == snap), ("shutoff", ""))
memory = "internal" if state == "running" else "no"
return conftest.fake_result(f"<domainsnapshot><name>{snap}</name><description>{description}</description><state>{state}</state><memory snapshot='{memory}'/></domainsnapshot>")
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
def seed(root: Path) -> None:
dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")}
for path in dirs.values():
path.mkdir(parents=True, exist_ok=True)
app.ISO_DIR, app.IMAGES_DIR, app.DISK_IMAGES_DIR, app.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"]
core.OPERATIONS_DIR = dirs["operations"]
network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"]
network_core.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json"
network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml"
@@ -33,8 +61,8 @@ def seed(root: Path) -> None:
host_profile.PROFILE_FILE = dirs["config"] / "host_profile.json"
update_core.STATE_DIR, update_core.STATE_FILE, update_core.LOG_FILE = dirs["update"], dirs["update"] / "state.json", dirs["update"] / "update.log"
update_core.SOURCE_DIR = Path(__file__).resolve().parents[1]
for module in (app, network_core, host_profile):
module.run_cmd = conftest.fake_run_cmd
for module in (app, network_core, host_profile, core, *conftest.feature_modules()):
module.run_cmd = demo_run_cmd
app.auth.verify_password = lambda user, password: True
import nodectl
@@ -73,6 +101,9 @@ def seed(root: Path) -> None:
op_id = str(uuid.uuid4())
app.write_operation({"id": op_id, "type": "vm_create", "title": title, "status": status, "progress": progress, "message": "Готово" if status == "success" else "qemu-img: 64%" if status == "running" else "virt-install завершился с ошибкой: 1", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester"})
app.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...")
# Идущий снимок машины db01 — видно на /vm/db01/snapshots.
snap_op = core.new_operation("snapshot_create", "Снимок машины db01", vm_name="db01", snapshot="snap-20260925-1500", vm_running=True)
core.update_operation(snap_op, status="running", progress=15, message="Сохраняем состояние машины и памяти — она на несколько секунд замрёт…")
if __name__ == "__main__":
+285
Просмотреть файл
@@ -0,0 +1,285 @@
"""Снимки (точки восстановления) машины: парсеры и маршруты /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):
assert logged_in.post("/vm/web01/snapshots/-bad/revert", follow_redirects=False).status_code == 400
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"