From 2450278d936bf3b6cd4a045d7eb69635773bf862 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:32:05 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=88=D0=B8=D0=BD=20=D0=B1=D1=8B=D0=BB=D0=B8=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B:=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=B2=D0=BE=D1=81?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5,=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Первый модуль-фича на общем core.py — web/features/backups.py, подключён в app.py одной строкой include_router. - копия = папка /var/lib/virtuality/backups/<машина>/<ГГГГММДД-ЧЧММ>/ с vm.xml (virsh dumpxml --migratable), <диск>.qcow2 на каждый диск и meta.json (машина, дата, диски, размер, версия, заметка); - создание — фоновая операция: свободное место проверяется заранее по qemu-img info, работающая машина мягко выключается (ждём до 3 минут, без принудительного выключения), диски копируются qemu-img convert -c с прогрессом из строк «(NN.NN/100%)», после чего машина запускается; - восстановление — операция: диски распаковываются в images/<имя>[-<диск>] .qcow2, в XML меняется имя, убираются uuid, MAC и nvram, затем virsh define; восстановление под занятым именем требует галочку «заменить существующую» — старая машина удаляется с дисками, а ISO перед этим извлекается из привода, чтобы --remove-all-storage его не стёр; - страницы: «Резервные копии» в меню (все копии по машинам, занятое и свободное место), вкладка «Резервные копии» на странице машины с формой «Создать копию» и заметкой, страница восстановления с понятным списком шагов; - create_backup(vm, note, wait=True) можно вызывать без HTTP — задел под расписание (python3 web/features/backups.py web01 «заметка»); - tests/conftest.py: каталог backups, патчи core.*, вывод virsh domblklist --details, dumpxml --migratable и qemu-img info из fixtures; tests/test_backups.py — 33 теста парсеров, воркеров и маршрутов; tests/dev_server.py показывает демо-копии. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- tests/conftest.py | 29 +- tests/dev_server.py | 32 +- tests/test_backups.py | 418 ++++++++++++++++++ web/app.py | 4 + web/features/__init__.py | 0 web/features/backups.py | 691 ++++++++++++++++++++++++++++++ web/static/app.css | 9 + web/templates/_backup_rows.html | 18 + web/templates/backup_restore.html | 75 ++++ web/templates/backups.html | 82 ++++ web/templates/base.html | 1 + web/templates/vm_backups.html | 81 ++++ web/templates/vm_detail.html | 1 + 13 files changed, 1433 insertions(+), 8 deletions(-) create mode 100644 tests/test_backups.py create mode 100644 web/features/__init__.py create mode 100644 web/features/backups.py create mode 100644 web/templates/_backup_rows.html create mode 100644 web/templates/backup_restore.html create mode 100644 web/templates/backups.html create mode 100644 web/templates/vm_backups.html diff --git a/tests/conftest.py b/tests/conftest.py index 0491550..94596e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,12 @@ DUMPXML = """ """ +FIXTURES = Path(__file__).resolve().parent / "fixtures" +DOMBLKLIST_DETAILS = (FIXTURES / "domblklist-details.txt").read_text() +DUMPXML_MIGRATABLE = (FIXTURES / "dumpxml-web01.xml").read_text() +QEMU_IMG_INFO = (FIXTURES / "qemu-img-info.json").read_text() + + 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": ""} @@ -42,11 +48,17 @@ def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict: def fake_run_cmd(cmd, timeout=12, **_kwargs): joined = " ".join(cmd) if cmd[:2] == ["virsh", "list"]: + if "--name" in cmd: + return fake_result("web01\ndb01\n") return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off") if cmd[:2] == ["virsh", "dominfo"]: return fake_result(DOMINFO) if cmd[-1] in ("web01", "db01") else fake_result(ok=False, stderr="failed to get domain") if cmd[:2] == ["virsh", "dumpxml"]: - return fake_result(DUMPXML) + return fake_result(DUMPXML_MIGRATABLE if "--migratable" in cmd else DUMPXML) + if cmd[:2] == ["virsh", "domblklist"]: + return fake_result(DOMBLKLIST_DETAILS) + if cmd[:2] == ["qemu-img", "info"]: + return fake_result(QEMU_IMG_INFO) if cmd[:2] == ["virsh", "domstate"]: return fake_result("running") if cmd[:2] == ["virsh", "domifaddr"]: @@ -75,6 +87,7 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs): @pytest.fixture() def data_dirs(tmp_path, monkeypatch): import app + import core import host_profile import network_core import update_core @@ -83,6 +96,7 @@ def data_dirs(tmp_path, monkeypatch): "iso": tmp_path / "iso", "images": tmp_path / "images", "disk_images": tmp_path / "disk-images", + "backups": tmp_path / "backups", "operations": tmp_path / "operations", "network": tmp_path / "network", "config": tmp_path / "config", @@ -91,10 +105,13 @@ def data_dirs(tmp_path, monkeypatch): } for path in dirs.values(): path.mkdir() - monkeypatch.setattr(app, "ISO_DIR", dirs["iso"]) - monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"]) - monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"]) - monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"]) + for module in (app, core): + monkeypatch.setattr(module, "ISO_DIR", dirs["iso"]) + monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"]) + monkeypatch.setattr(module, "DISK_IMAGES_DIR", dirs["disk_images"]) + monkeypatch.setattr(module, "OPERATIONS_DIR", dirs["operations"]) + monkeypatch.setattr(core, "BACKUPS_DIR", dirs["backups"]) + monkeypatch.setattr(core, "CONFIG_DIR", dirs["config"]) 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 +125,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, core, network_core, host_profile): monkeypatch.setattr(module, "run_cmd", fake_run_cmd) return dirs diff --git a/tests/dev_server.py b/tests/dev_server.py index 9df2e62..dc87def 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -13,16 +13,40 @@ 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 +def seed_backups(backups_dir: Path) -> None: + """Несколько копий, как после пары недель работы: web01 — три, db01 — одна.""" + vm_xml = conftest.DUMPXML_MIGRATABLE + demo = [ + ("web01", "20260924-0300", 2_684_354_560, "", [("vda", 6_443_499_520)]), + ("web01", "20260917-0300", 2_598_000_000, "", [("vda", 6_300_000_000)]), + ("web01", "20260912-1842", 2_412_000_000, "Перед обновлением сайта", [("vda", 6_100_000_000)]), + ("db01", "20260921-2215", 9_126_805_504, "После переноса базы", [("vda", 12_884_901_888), ("vdb", 8_589_934_592)]), + ] + for vm, backup_id, size, note, disks in demo: + path = backups_dir / vm / backup_id + path.mkdir(parents=True, exist_ok=True) + (path / "vm.xml").write_text(vm_xml.replace("web01", vm)) + for target, _ in disks: + with (path / f"{target}.qcow2").open("wb") as handle: + handle.truncate(1024 * 1024) + meta = {"vm": vm, "created_at": f"{backup_id[:4]}-{backup_id[4:6]}-{backup_id[6:8]} {backup_id[9:11]}:{backup_id[11:]}:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": s} for t, s in disks], "size_bytes": size, "version": "0.11.0", "note": note} + (path / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2)) + + def seed(root: Path) -> None: - dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")} + dirs = {name: root / name for name in ("iso", "images", "disk-images", "backups", "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.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"] + core.BACKUPS_DIR, core.CONFIG_DIR = dirs["backups"], dirs["config"] + seed_backups(dirs["backups"]) 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" @@ -32,7 +56,7 @@ 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): + for module in (app, core, network_core, host_profile): module.run_cmd = conftest.fake_run_cmd app.auth.verify_password = lambda user, password: True @@ -49,6 +73,10 @@ 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 ...") + op_id = str(uuid.uuid4()) + app.write_operation({"id": op_id, "type": "backup", "title": "Резервная копия web01", "status": "success", "progress": 100, "message": "Резервная копия создана: 2.5 ГБ", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester", "vm_name": "web01", "backup_id": "20260924-0300"}) + for line in ("Машина работает — отправляем команду выключения.", "Машина выключена.", "Настройки машины сохранены в vm.xml.", "Копируем диск vda (1 из 1): 100%", "Машина запущена снова."): + app.append_operation_log(op_id, line) if __name__ == "__main__": diff --git a/tests/test_backups.py b/tests/test_backups.py new file mode 100644 index 0000000..3148727 --- /dev/null +++ b/tests/test_backups.py @@ -0,0 +1,418 @@ +import json +from pathlib import Path +from urllib.parse import unquote + +import pytest + +import core +import features.backups as backups +from conftest import DOMBLKLIST_DETAILS, DUMPXML_MIGRATABLE, QEMU_IMG_INFO, fake_result, fake_run_cmd + +FIXTURES = Path(__file__).resolve().parent / "fixtures" + + +def make_backup(dirs, vm="web01", backup_id="20260924-0300", size=2_684_354_560, note="", disks=("vda",), complete=True) -> Path: + path = dirs["backups"] / vm / backup_id + path.mkdir(parents=True) + (path / "vm.xml").write_text(DUMPXML_MIGRATABLE) + for target in disks: + if complete: + (path / f"{target}.qcow2").write_bytes(b"QFI\xfb" + b"\0" * 60) + meta = {"vm": vm, "created_at": "2026-09-24 03:00:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": 6_443_499_520} for t in disks], "size_bytes": size, "version": "0.11.0", "note": note} + (path / "meta.json").write_text(json.dumps(meta)) + return path + + +class FakeVirsh: + """Имитация virsh с состоянием: машина выключается после `virsh shutdown`.""" + + def __init__(self, running=True, shuts_down=True, exists=("web01", "db01")): + self.running = running + self.shuts_down = shuts_down + self.exists = set(exists) + self.calls: list[list[str]] = [] + self.defined_xml = "" + + def __call__(self, cmd, timeout=12, **_kwargs): + self.calls.append(list(cmd)) + if cmd[:2] == ["virsh", "domstate"]: + return fake_result("running" if self.running else "shut off") + if cmd[:2] == ["virsh", "shutdown"]: + if self.shuts_down: + self.running = False + return fake_result() + if cmd[:2] == ["virsh", "destroy"]: + self.running = False + return fake_result() + if cmd[:2] == ["virsh", "start"]: + self.running = True + return fake_result() + if cmd[:2] == ["virsh", "dominfo"]: + return fake_result(f"Name: {cmd[-1]}\nState: running\n") if cmd[-1] in self.exists else fake_result(ok=False, stderr="error: failed to get domain") + if cmd[:2] == ["virsh", "define"]: + self.defined_xml = Path(cmd[2]).read_text() + return fake_result(f"Domain defined from {cmd[2]}") + if cmd[:2] == ["virsh", "undefine"]: + self.exists.discard(cmd[2]) + return fake_result() + return fake_run_cmd(cmd, timeout) + + +def fake_stream(progress_lines=("(0.00/100%)", "(48.50/100%)", "(100.00/100%)"), code=0): + calls: list[list[str]] = [] + + def stream(cmd, on_line): + calls.append(list(cmd)) + Path(cmd[-1]).write_bytes(b"QFI\xfb" + b"\0" * 1000) + for line in progress_lines: + on_line(line) + return code + + stream.calls = calls + return stream + + +@pytest.fixture() +def fast_worker(monkeypatch, data_dirs): + """Операции выполняются синхронно, ожидание выключения — мгновенно.""" + monkeypatch.setattr(core, "run_operation", lambda operation, worker: worker(operation)) + monkeypatch.setattr(backups, "POLL_INTERVAL", 0.001) + monkeypatch.setattr(backups, "SHUTDOWN_TIMEOUT", 0.05) + monkeypatch.setattr(backups, "free_bytes", lambda path: 500 * 1024 ** 3) + return data_dirs + + +# ---------------------------------------------------------------- парсеры +def test_parse_progress(): + assert backups.parse_progress(" (12.34/100%)") == 12 + assert backups.parse_progress("(100.00/100%)") == 100 + assert backups.parse_progress("qemu-img: Could not open") is None + + +def test_parse_qemu_img_info_fixture(): + info = backups.parse_qemu_img_info(QEMU_IMG_INFO) + assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"} + assert backups.parse_qemu_img_info("not json")["actual_size"] == 0 + assert backups.parse_qemu_img_info("[1, 2]")["format"] == "" + + +def test_vm_disks_skips_cdrom(data_dirs): + disks = backups.vm_disks("web01") + assert [(d["target"], d["source"]) for d in disks] == [("vda", "/var/lib/virtuality/images/web01.qcow2")] + assert DOMBLKLIST_DETAILS.count("cdrom") == 1 + + +def test_stream_cmd_splits_carriage_returns(): + lines: list[str] = [] + code = backups.stream_cmd(["python3", "-c", "import sys; sys.stdout.write('a\\r (10.00/100%)\\r(55.50/100%)\\nend')"], lines.append) + assert code == 0 + assert lines == ["a", "(10.00/100%)", "(55.50/100%)", "end"] + + +def test_rewrite_xml_renames_and_detaches_identity(): + xml = backups.rewrite_xml((FIXTURES / "dumpxml-web01.xml").read_text(), "web02", {"vda": "/var/lib/virtuality/images/web02.qcow2"}) + assert "web02" in xml + assert "" not in xml and "id=" not in xml.split(">", 1)[0] + assert "" in xml or "" in xml # привод CD остаётся как был + + +def test_rewrite_xml_converts_block_disk_and_drops_nvram(): + source = """oldhvm/usr/share/OVMF/OVMF_CODE.fd/var/lib/libvirt/qemu/nvram/old_VARS.fd + """ + xml = backups.rewrite_xml(source, "new", {"vda": "/var/lib/virtuality/images/new.qcow2"}) + assert "" not in xml + assert 'type="file"' in xml and 'file="/var/lib/virtuality/images/new.qcow2"' in xml + assert 'type="qcow2"' in xml and "source dev=" not in xml and 'type="raw"' not in xml + + +def test_format_backup_date(): + assert backups.format_backup_date("20260924-0300") == "24 сентября 2026, 03:00" + assert backups.format_backup_date("garbage") == "garbage" + + +def test_clean_note_limits_length_and_control_chars(): + assert backups.clean_note(" до\x00обновления\n ") == "до обновления" + assert len(backups.clean_note("x" * 500)) == backups.NOTE_MAX + + +# ---------------------------------------------------------------- хранилище +def test_list_and_group_backups(data_dirs): + make_backup(data_dirs, "web01", "20260917-0300", size=100) + make_backup(data_dirs, "web01", "20260924-0300", size=200, note="перед обновлением") + make_backup(data_dirs, "old-vm", "20260901-1200", size=50) + (data_dirs["backups"] / "web01" / "junk").mkdir() + (data_dirs["backups"] / "..hidden").mkdir() + items = backups.list_backups() + assert [(i["vm"], i["id"]) for i in items] == [("web01", "20260924-0300"), ("web01", "20260917-0300"), ("old-vm", "20260901-1200")] + assert items[0]["note"] == "перед обновлением" and items[0]["complete"] is True + groups = backups.group_by_vm(items, {"web01"}) + assert [(g["vm"], g["count"], g["size_bytes"], g["exists"]) for g in groups] == [("old-vm", 1, 50, False), ("web01", 2, 300, True)] + assert backups.list_backups("web01")[0]["id"] == "20260924-0300" + assert backups.storage_summary(items)["used_bytes"] == 350 + + +def test_backup_path_rejects_traversal(data_dirs): + assert backups.backup_path("../etc", "20260924-0300") is None + assert backups.backup_path("web01", "../../x") is None + assert backups.backup_path("web01", "20260924-0300") == data_dirs["backups"] / "web01" / "20260924-0300" + + +def test_incomplete_backup_is_flagged(data_dirs): + make_backup(data_dirs, complete=False) + assert backups.read_backup("web01", "20260924-0300")["complete"] is False + + +# ---------------------------------------------------------------- создание копии +def test_create_backup_refuses_without_space(data_dirs, monkeypatch): + monkeypatch.setattr(backups, "free_bytes", lambda path: 1024) + with pytest.raises(backups.BackupError) as exc: + backups.create_backup("web01") + assert "Недостаточно места" in str(exc.value) and "6.0 ГБ" in str(exc.value) + + +def test_create_backup_rejects_unknown_vm(fast_worker): + with pytest.raises(backups.BackupError): + backups.create_backup("nope") + with pytest.raises(backups.BackupError): + backups.create_backup("bad name") + + +def test_create_backup_shuts_down_copies_and_restarts(fast_worker, monkeypatch): + virsh = FakeVirsh(running=True) + monkeypatch.setattr(core, "run_cmd", virsh) + stream = fake_stream() + monkeypatch.setattr(backups, "stream_cmd", stream) + queued = backups.create_backup("web01", " ночная\tкопия ") + operation = core.read_operation(queued["id"]) + assert operation["status"] == "success", operation["message"] + assert operation["progress"] == 100 and "Резервная копия создана" in operation["message"] + assert ["virsh", "shutdown", "web01"] in virsh.calls + assert ["virsh", "dumpxml", "web01", "--migratable"] in virsh.calls + assert virsh.calls[-1] == ["virsh", "start", "web01"] + assert ["virsh", "destroy", "web01"] not in virsh.calls + assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", "-c", "/var/lib/virtuality/images/web01.qcow2", str(fast_worker["backups"] / "web01" / operation["backup_id"] / "vda.qcow2")]] + path = fast_worker["backups"] / "web01" / operation["backup_id"] + meta = json.loads((path / "meta.json").read_text()) + assert meta["vm"] == "web01" and meta["note"] == "ночная копия" and meta["disks"][0]["target"] == "vda" + assert meta["size_bytes"] == sum(f.stat().st_size for f in path.iterdir() if f.name != "meta.json") + assert "Сайт компании" in (path / "vm.xml").read_text() + log = core.read_operation(operation["id"])["log_tail"] + assert "48%" in log and "Машина запущена снова" in log + + +def test_create_backup_wait_returns_finished_operation(fast_worker, monkeypatch): + monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False)) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + operation = backups.create_backup("db01", wait=True) + assert operation["status"] == "success" and operation["type"] == "backup" + + +def test_backup_fails_when_vm_does_not_shut_down(fast_worker, monkeypatch): + virsh = FakeVirsh(running=True, shuts_down=False) + monkeypatch.setattr(core, "run_cmd", virsh) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + operation = backups.create_backup("web01") + fresh = core.read_operation(operation["id"]) + assert fresh["status"] == "error" and "не выключилась" in fresh["message"] + assert ["virsh", "destroy", "web01"] not in virsh.calls + assert not (fast_worker["backups"] / "web01").exists() + + +def test_backup_cleans_up_and_restarts_after_copy_error(fast_worker, monkeypatch): + virsh = FakeVirsh(running=True) + monkeypatch.setattr(core, "run_cmd", virsh) + monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1)) + operation = backups.create_backup("web01") + fresh = core.read_operation(operation["id"]) + assert fresh["status"] == "error" and "vda" in fresh["message"] + assert not (fast_worker["backups"] / "web01").exists() + assert virsh.calls[-1] == ["virsh", "start", "web01"] + + +def test_create_backup_refuses_while_another_runs(fast_worker, monkeypatch): + monkeypatch.setattr(core, "run_operation", lambda operation, worker: None) # остаётся в очереди + backups.create_backup("web01") + with pytest.raises(backups.BackupError) as exc: + backups.create_backup("web01") + assert "уже идёт" in str(exc.value) + + +# ---------------------------------------------------------------- восстановление +def test_restore_under_new_name(fast_worker, monkeypatch): + make_backup(fast_worker) + virsh = FakeVirsh(running=False) + monkeypatch.setattr(core, "run_cmd", virsh) + stream = fake_stream() + monkeypatch.setattr(backups, "stream_cmd", stream) + operation = backups.start_restore("web01", "20260924-0300", "web02", False) + fresh = core.read_operation(operation["id"]) + assert fresh["status"] == "success", fresh["message"] + assert fresh["vm_name"] == "web02" + target = fast_worker["images"] / "web02.qcow2" + assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", str(fast_worker["backups"] / "web01" / "20260924-0300" / "vda.qcow2"), str(target)]] + assert target.exists() + assert "web02" in virsh.defined_xml and str(target) in virsh.defined_xml and "web01" in virsh.defined_xml + + +def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch): + make_backup(fast_worker) + virsh = FakeVirsh(running=False) + base = virsh.__call__ + + def with_iso(cmd, timeout=12, **kw): + if cmd[:2] == ["virsh", "domblklist"]: + virsh.calls.append(list(cmd)) + return fake_result(DOMBLKLIST_DETAILS.replace("sda -", "sda /var/lib/virtuality/iso/debian.iso")) + return base(cmd, timeout, **kw) + + monkeypatch.setattr(core, "run_cmd", with_iso) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + backups.start_restore("web01", "20260924-0300", "web01", True) + eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"] + assert eject in virsh.calls + assert virsh.calls.index(eject) < virsh.calls.index(["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"]) + + +def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker): + make_backup(fast_worker) + with pytest.raises(backups.BackupError) as exc: + backups.start_restore("web01", "20260924-0300", "-bad", False) + assert exc.value.code == "name" + make_backup(fast_worker, backup_id="20260901-0000", complete=False) + with pytest.raises(backups.BackupError) as exc: + backups.start_restore("web01", "20260901-0000", "web02", False) + assert "неполная" in str(exc.value) + + +def test_restore_cleans_up_on_define_failure(fast_worker, monkeypatch): + make_backup(fast_worker) + virsh = FakeVirsh(running=False) + base = virsh.__call__ + + def failing_define(cmd, timeout=12, **kw): + if cmd[:2] == ["virsh", "define"]: + return fake_result(ok=False, stderr="error: XML error: bad thing") + return base(cmd, timeout, **kw) + + monkeypatch.setattr(core, "run_cmd", failing_define) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + operation = backups.start_restore("web01", "20260924-0300", "web02", False) + fresh = core.read_operation(operation["id"]) + assert fresh["status"] == "error" and "bad thing" in fresh["message"] + assert not (fast_worker["images"] / "web02.qcow2").exists() + + +def test_restore_disk_path_avoids_collisions(data_dirs): + (data_dirs["images"] / "web02.qcow2").write_bytes(b"x") + assert backups.restore_disk_path("web02", "vda", 0) == data_dirs["images"] / "web02-vda.qcow2" + assert backups.restore_disk_path("web02", "vdb", 1) == data_dirs["images"] / "web02-vdb.qcow2" + (data_dirs["images"] / "web02-vda.qcow2").write_bytes(b"x") + assert backups.restore_disk_path("web02", "vda", 0) == data_dirs["images"] / "web02-vda-2.qcow2" + + +# ---------------------------------------------------------------- страницы +def test_backup_pages_require_login(client): + for path in ("/backups", "/vm/web01/backups", "/backups/web01/20260924-0300/restore"): + response = client.get(path, follow_redirects=False) + assert response.status_code == 303 and response.headers["location"] == "/login" + + +def test_backups_page_lists_groups(logged_in, data_dirs): + make_backup(data_dirs, "web01", "20260924-0300", size=2_684_354_560, note="перед обновлением") + make_backup(data_dirs, "gone-vm", "20260901-1200", size=1024 ** 3) + html = logged_in.get("/backups").text + assert "24 сентября 2026, 03:00" in html and "2.5 ГБ" in html and "перед обновлением" in html + assert 'href="/backups/web01/20260924-0300/restore"' in html + assert "машина удалена" in html and 'href="/vm/web01/backups"' in html + assert "Резервные копии" in html + + +def test_backups_page_empty_state(logged_in): + html = logged_in.get("/backups").text + assert "Копий пока нет" in html + + +def test_vm_backups_page_and_tab_link(logged_in, data_dirs): + make_backup(data_dirs) + html = logged_in.get("/vm/web01/backups").text + assert 'action="/vm/web01/backups/create"' in html and "Создать копию" in html + assert "24 сентября 2026, 03:00" in html + assert 'href="/vm/web01/backups"' in logged_in.get("/vm/web01").text + assert logged_in.get("/vm/nope/backups", follow_redirects=False).headers["location"] == "/" + + +def test_vm_backup_create_route(logged_in, fast_worker, monkeypatch): + monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False)) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + response = logged_in.post("/vm/web01/backups/create", data={"note": "перед обновлением"}, follow_redirects=False) + assert response.status_code == 303 and response.headers["location"].startswith("/operations/") + operation_id = response.headers["location"].rsplit("/", 1)[1] + assert core.read_operation(operation_id)["status"] == "success" + assert logged_in.get(f"/operations/{operation_id}").status_code == 200 + + +def test_vm_backup_create_route_shows_error(logged_in, data_dirs, monkeypatch): + monkeypatch.setattr(backups, "free_bytes", lambda path: 0) + response = logged_in.post("/vm/web01/backups/create", data={"note": ""}, follow_redirects=False) + assert response.status_code == 303 + assert "backup_error=" in response.headers["location"] and "Недостаточно" in unquote(response.headers["location"]) + assert "Недостаточно места" in logged_in.get(response.headers["location"]).text + + +def test_restore_page_and_submit(logged_in, fast_worker, monkeypatch): + make_backup(fast_worker) + monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False)) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + html = logged_in.get("/backups/web01/20260924-0300/restore").text + assert 'name="replace"' in html and "Заменить существующую машину web01" in html + response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web01", "replace": ""}) + assert response.status_code == 400 and "уже есть на сервере" in response.text + response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web02", "replace": ""}, follow_redirects=False) + assert response.status_code == 303 and response.headers["location"].startswith("/operations/") + response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "-bad"}) + assert response.status_code == 400 + assert logged_in.get("/backups/web01/20990101-0000/restore", follow_redirects=False).headers["location"].startswith("/backups?backup_error=") + + +def test_delete_backup_route(logged_in, data_dirs): + path = make_backup(data_dirs) + response = logged_in.post("/backups/web01/20260924-0300/delete", data={"next": "/vm/web01/backups"}, follow_redirects=False) + assert response.status_code == 303 and response.headers["location"].startswith("/vm/web01/backups?backup_message=") + assert not path.exists() and not path.parent.exists() + response = logged_in.post("/backups/web01/20260924-0300/delete", data={"next": "http://evil"}, follow_redirects=False) + assert response.headers["location"].startswith("/backups?backup_error=") + + +def test_delete_backup_path_traversal(logged_in, data_dirs, tmp_path): + victim = tmp_path / "victim" + victim.mkdir() + (victim / "20260924-0300").mkdir() + logged_in.post("/backups/..%2Fvictim/20260924-0300/delete", follow_redirects=False) + logged_in.post("/backups/web01/..%2F..%2Fvictim/delete", follow_redirects=False) + assert (victim / "20260924-0300").exists() diff --git a/web/app.py b/web/app.py index c911878..4017882 100644 --- a/web/app.py +++ b/web/app.py @@ -2016,3 +2016,7 @@ def api_health(request: Request): if not get_current_user(request): return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) return {"system": system_summary(), "host_profile": host_profile.load_host_profile(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()} + + +import features.backups as backups # noqa: E402 +app.include_router(backups.router) diff --git a/web/features/__init__.py b/web/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/features/backups.py b/web/features/backups.py new file mode 100644 index 0000000..ab2839c --- /dev/null +++ b/web/features/backups.py @@ -0,0 +1,691 @@ +"""Резервные копии виртуальных машин. + +Копия лежит в BACKUPS_DIR/<машина>/<ГГГГММДД-ЧЧММ>/ и состоит из vm.xml +(настройки), <диск>.qcow2 на каждый диск и meta.json. Создание и +восстановление выполняются как фоновые операции с прогрессом и журналом. + +`create_backup(vm_name, note)` можно вызывать без HTTP — например, из +расписания: `python3 web/features/backups.py web01 "ночная копия"`. +""" +import json +import re +import shutil +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET +from datetime import datetime +from pathlib import Path +from typing import Any, Callable + +if __name__ == "__main__": # запуск как скрипта: нужен web/ в sys.path + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from fastapi import APIRouter, Form, Request # noqa: E402 +from fastapi.responses import RedirectResponse # noqa: E402 + +import core # noqa: E402 +import presenters # noqa: E402 + +router = APIRouter() + +BACKUP_ID_RE = re.compile(r"\d{8}-\d{4}") +SHUTDOWN_TIMEOUT = 180.0 # секунд ждём мягкого выключения +POLL_INTERVAL = 2.0 +SPACE_MARGIN = 256 * 1024 * 1024 # запас, чтобы не забить диск под завязку +NOTE_MAX = 120 +MONTHS = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря") + + +class BackupError(Exception): + """Ошибка, текст которой можно показать пользователю как есть.""" + + def __init__(self, message: str, code: str = "") -> None: + super().__init__(message) + self.code = code + + +# ---------------------------------------------------------------- helpers +def run(cmd: list[str], timeout: int = 12) -> dict[str, Any]: + return core.run_cmd(cmd, timeout=timeout) + + +def stream_cmd(cmd: list[str], on_line: Callable[[str], None]) -> int: + """Запустить команду и отдавать каждую строку вывода (по \\r и \\n) в on_line. + + qemu-img с ключом -p печатает прогресс через \\r, поэтому обычное чтение + по строкам не подходит. Возвращает код завершения.""" + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + pending = b"" + assert process.stdout is not None + while True: + chunk = process.stdout.read(512) + if not chunk: + break + pending += chunk + parts = re.split(rb"[\r\n]+", pending) + pending = parts.pop() + for part in parts: + text = part.decode("utf-8", "replace").strip() + if text: + on_line(text) + tail = pending.decode("utf-8", "replace").strip() + if tail: + on_line(tail) + return process.wait() + + +def parse_progress(line: str) -> int | None: + """' (12.34/100%)' → 12. None, если в строке нет прогресса qemu-img.""" + match = re.search(r"\((\d+(?:\.\d+)?)/100%\)", line or "") + return min(100, int(float(match.group(1)))) if match else None + + +def parse_qemu_img_info(text: str) -> dict[str, Any]: + """Вывод `qemu-img info --output=json` → {virtual_size, actual_size, format}.""" + empty = {"virtual_size": 0, "actual_size": 0, "format": ""} + try: + data = json.loads(text or "") + except ValueError: + return empty + if not isinstance(data, dict): + return empty + try: + return {"virtual_size": int(data.get("virtual-size") or 0), "actual_size": int(data.get("actual-size") or 0), "format": str(data.get("format") or "")} + except (TypeError, ValueError): + return empty + + +def disk_info(path: str) -> dict[str, Any]: + result = run(["qemu-img", "info", "--output=json", path], timeout=30) + return parse_qemu_img_info(result["stdout"]) if result["ok"] else parse_qemu_img_info("") + + +def vm_disks(name: str) -> list[dict[str, str]]: + """Диски машины (без приводов CD/DVD) из `virsh domblklist --details`.""" + result = run(["virsh", "domblklist", name, "--details"], timeout=10) + if not result["ok"]: + raise BackupError("Не удалось получить список дисков: " + core.cmd_error(result, "virsh domblklist")) + return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "disk" and disk["source"]] + + +def vm_cdroms_with_media(name: str) -> list[dict[str, str]]: + result = run(["virsh", "domblklist", name, "--details"], timeout=10) + if not result["ok"]: + return [] + return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "cdrom" and disk["source"]] + + +def free_bytes(path: Path) -> int: + target = path + while not target.exists() and target != target.parent: + target = target.parent + try: + return shutil.disk_usage(target).free + except OSError: + return 0 + + +def format_bytes(value: int | float) -> str: + return presenters.format_bytes(value) + + +def format_backup_date(backup_id: str) -> str: + try: + moment = datetime.strptime(backup_id, "%Y%m%d-%H%M") + except ValueError: + return backup_id + return f"{moment.day} {MONTHS[moment.month - 1]} {moment.year}, {moment:%H:%M}" + + +def clean_note(note: str) -> str: + return re.sub(r"[\x00-\x1f\x7f]+", " ", note or "").strip()[:NOTE_MAX] + + +def existing_vm_names() -> set[str]: + result = run(["virsh", "list", "--all", "--name"], timeout=10) + return {line.strip() for line in (result.get("stdout") or "").splitlines() if line.strip()} if result["ok"] else set() + + +# ---------------------------------------------------------------- storage layout +def backup_root() -> Path: + return core.BACKUPS_DIR + + +def backup_path(vm: str, backup_id: str) -> Path | None: + """Папка копии или None, если имя машины/копии некорректно или уводит из BACKUPS_DIR.""" + if not core.valid_vm_name(vm) or not BACKUP_ID_RE.fullmatch(backup_id or ""): + return None + root = backup_root() + path = root / vm / backup_id + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return None + return path + + +def describe_backup(vm: str, backup_id: str, path: Path, meta: dict[str, Any]) -> dict[str, Any]: + disks = [disk for disk in meta.get("disks", []) if isinstance(disk, dict) and disk.get("target")] + size = int(meta.get("size_bytes") or 0) + complete = (path / "vm.xml").exists() and bool(disks) and all((path / f"{disk['target']}.qcow2").exists() for disk in disks) + return { + "vm": vm, + "id": backup_id, + "path": str(path), + "created_at": meta.get("created_at") or "", + "created_label": format_backup_date(backup_id), + "note": clean_note(str(meta.get("note") or "")), + "disks": disks, + "disk_count": len(disks), + "size_bytes": size, + "size": format_bytes(size), + "version": str(meta.get("version") or ""), + "complete": complete, + } + + +def read_backup(vm: str, backup_id: str) -> dict[str, Any] | None: + path = backup_path(vm, backup_id) + if not path or not (path / "meta.json").is_file(): + return None + try: + meta = json.loads((path / "meta.json").read_text()) + except (OSError, ValueError): + return None + if not isinstance(meta, dict): + return None + return describe_backup(vm, backup_id, path, meta) + + +def list_backups(vm: str | None = None) -> list[dict[str, Any]]: + """Все копии (или копии одной машины), новые сверху.""" + root = backup_root() + if not root.is_dir(): + return [] + if vm is not None: + vm_dirs = [root / vm] if core.valid_vm_name(vm) else [] + else: + vm_dirs = sorted(item for item in root.iterdir() if item.is_dir() and core.valid_vm_name(item.name)) + items: list[dict[str, Any]] = [] + for vm_dir in vm_dirs: + if not vm_dir.is_dir(): + continue + for item in vm_dir.iterdir(): + if item.is_dir() and BACKUP_ID_RE.fullmatch(item.name): + backup = read_backup(vm_dir.name, item.name) + if backup: + items.append(backup) + items.sort(key=lambda item: (item["id"], item["vm"]), reverse=True) + return items + + +def group_by_vm(items: list[dict[str, Any]], existing: set[str] | None = None) -> list[dict[str, Any]]: + groups: dict[str, dict[str, Any]] = {} + for item in items: + group = groups.setdefault(item["vm"], {"vm": item["vm"], "backups": [], "size_bytes": 0, "exists": existing is None or item["vm"] in existing}) + group["backups"].append(item) + group["size_bytes"] += item["size_bytes"] + for group in groups.values(): + group["size"] = format_bytes(group["size_bytes"]) + group["count"] = len(group["backups"]) + return sorted(groups.values(), key=lambda group: group["vm"]) + + +def storage_summary(items: list[dict[str, Any]] | None = None) -> dict[str, Any]: + items = list_backups() if items is None else items + used = sum(item["size_bytes"] for item in items) + free = free_bytes(backup_root()) + total = used + free + return { + "count": len(items), + "used_bytes": used, + "used": format_bytes(used), + "free_bytes": free, + "free": format_bytes(free), + "used_pct": int(round(used / total * 100)) if total else 0, + } + + +def remove_backup_dir(path: Path) -> None: + """Удалить папку копии; пустую папку машины над ней — тоже.""" + shutil.rmtree(path, ignore_errors=True) + parent = path.parent + try: + if parent != backup_root() and parent.is_dir() and not any(parent.iterdir()): + parent.rmdir() + except OSError: + pass + + +def delete_backup(vm: str, backup_id: str) -> None: + path = backup_path(vm, backup_id) + if not path or not path.is_dir(): + raise BackupError("Копия не найдена — возможно, её уже удалили") + for operation in core.running_operations(): + if operation.get("type") in ("backup", "restore") and operation.get("backup_id") == backup_id and operation.get("source_vm", operation.get("vm_name")) == vm: + raise BackupError("С этой копией сейчас идёт работа — дождитесь завершения задачи") + remove_backup_dir(path) + + +def active_operation(vm: str) -> dict[str, Any] | None: + """Идущая сейчас задача копирования/восстановления, связанная с машиной.""" + for operation in core.running_operations(): + if operation.get("type") in ("backup", "restore") and vm in (operation.get("vm_name"), operation.get("source_vm")): + return operation + return None + + +# ---------------------------------------------------------------- создание копии +def create_backup(vm_name: str, note: str = "", wait: bool = False) -> dict[str, Any]: + """Поставить создание копии в очередь и вернуть операцию. + + Проверки (машина есть, диски есть, места хватает) выполняются сразу и + поднимают BackupError с понятным текстом. С wait=True копия делается + в текущем потоке — удобно для расписания.""" + if not core.valid_vm_name(vm_name): + raise BackupError("Некорректное имя машины") + note = clean_note(note) + if not core.vm_exists(vm_name): + raise BackupError(f"Машина {vm_name} не найдена") + if active_operation(vm_name): + raise BackupError("С этой машиной уже идёт копирование или восстановление — дождитесь завершения") + disks = vm_disks(vm_name) + if not disks: + raise BackupError("У машины нет дисков — копировать нечего") + plan: list[dict[str, Any]] = [] + need = 0 + for disk in disks: + info = disk_info(disk["source"]) + size = info["actual_size"] or info["virtual_size"] + need += size + plan.append({"target": disk["target"], "source": disk["source"], "size": size}) + free = free_bytes(backup_root()) + if free < need + SPACE_MARGIN: + raise BackupError(f"Недостаточно места для копии: нужно около {format_bytes(need)}, свободно {format_bytes(free)}. Удалите старые копии или освободите место на сервере.", "space") + backup_id = datetime.now().strftime("%Y%m%d-%H%M") + if (backup_root() / vm_name / backup_id).exists(): + raise BackupError("Копия этой машины уже создана в эту минуту. Подождите минуту и повторите.") + operation = core.new_operation("backup", f"Резервная копия {vm_name}", "Ожидает начала", vm_name=vm_name, backup_id=backup_id, note=note, disks=plan, size_bytes=need) + if wait: + core.update_operation(operation, status="running", started_at=core.utc_now()) + backup_worker(operation) + return core.read_operation(operation["id"]) or operation + core.run_operation(operation, backup_worker) + return operation + + +def disk_progress_reporter(operation: dict[str, Any], index: int, total: int, label: str) -> Callable[[str], None]: + """on_line для qemu-img: общий прогресс = 5…95 %, разложенный по дискам.""" + state = {"last": -1, "logged": -10} + + def on_line(line: str) -> None: + pct = parse_progress(line) + if pct is None: + core.append_operation_log(operation["id"], line) + return + overall = int(5 + (index + pct / 100) / total * 90) + if overall == state["last"]: + return + state["last"] = overall + core.update_operation(operation, progress=overall, message=f"{label}: {pct}%") + if pct - state["logged"] >= 10 or pct == 100: + state["logged"] = pct + core.append_operation_log(operation["id"], f"{label}: {pct}%") + + return on_line + + +def shutdown_and_wait(operation: dict[str, Any], vm: str) -> bool: + log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731 + log("Машина работает — отправляем команду выключения (как кнопка питания, система завершит работу сама).") + core.update_operation(operation, progress=2, message="Выключаем машину…") + result = run(["virsh", "shutdown", vm], timeout=20) + if not result["ok"]: + log("Команда выключения не принята: " + core.cmd_error(result, "virsh shutdown")) + return False + deadline = time.monotonic() + SHUTDOWN_TIMEOUT + polls = 0 + while time.monotonic() < deadline: + time.sleep(POLL_INTERVAL) + polls += 1 + if not core.vm_is_running(vm): + log("Машина выключена.") + return True + if polls % 5 == 0: + waited = int(polls * POLL_INTERVAL) + core.update_operation(operation, message=f"Ждём выключения машины… {waited} с из {int(SHUTDOWN_TIMEOUT)}") + log(f"Всё ещё работает, ждём ({waited} с).") + return False + + +def start_vm(operation: dict[str, Any], vm: str) -> str: + """Запустить машину обратно; вернуть текст проблемы или пустую строку.""" + result = run(["virsh", "start", vm], timeout=40) + if result["ok"]: + core.append_operation_log(operation["id"], "Машина запущена снова.") + return "" + problem = core.cmd_error(result, "virsh start") + core.append_operation_log(operation["id"], "Не удалось запустить машину: " + problem) + return problem + + +def backup_worker(operation: dict[str, Any]) -> None: + vm = operation["vm_name"] + plan = operation["disks"] + log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731 + was_running = core.vm_is_running(vm) + if was_running and not shutdown_and_wait(operation, vm): + core.finish_operation(operation, False, "Машина не выключилась за 3 минуты. Сохраните работу внутри неё, выключите её вручную и создайте копию ещё раз.") + return + dest = backup_root() / vm / operation["backup_id"] + size = 0 + try: + dest.mkdir(parents=True, exist_ok=False) + result = run(["virsh", "dumpxml", vm, "--migratable"], timeout=20) + if not result["ok"]: + raise BackupError("Не удалось сохранить настройки машины: " + core.cmd_error(result, "virsh dumpxml")) + (dest / "vm.xml").write_text(result["stdout"].rstrip() + "\n", encoding="utf-8") + log("Настройки машины сохранены в vm.xml.") + total = len(plan) + for index, disk in enumerate(plan): + target_file = dest / f"{disk['target']}.qcow2" + label = f"Копируем диск {disk['target']} ({index + 1} из {total})" + core.update_operation(operation, progress=int(5 + index / total * 90), message=label) + log(f"{label}: {disk['source']} → {target_file}") + cmd = ["qemu-img", "convert", "-p", "-O", "qcow2", "-c", disk["source"], str(target_file)] + log(" ".join(cmd)) + code = stream_cmd(cmd, disk_progress_reporter(operation, index, total, label)) + if code != 0: + raise BackupError(f"Не удалось скопировать диск {disk['target']} (qemu-img завершился с кодом {code})") + size = sum(item.stat().st_size for item in dest.iterdir() if item.is_file()) + meta = {"vm": vm, "created_at": core.utc_now(), "disks": plan, "size_bytes": size, "version": core.APP_VERSION, "note": operation.get("note", "")} + (dest / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + log(f"Копия готова: {format_bytes(size)} (сжатие включено).") + except Exception as exc: # noqa: BLE001 — любая ошибка = операция с понятным текстом + remove_backup_dir(dest) + log(f"Ошибка: {exc}") + problem = start_vm(operation, vm) if was_running else "" + message = str(exc)[:200] + if problem: + message += f". Машину тоже не удалось запустить: {problem}" + core.finish_operation(operation, False, message) + return + problem = start_vm(operation, vm) if was_running else "" + message = f"Резервная копия создана: {format_bytes(size)}" + if problem: + message += f". Но машину не удалось запустить снова: {problem}" + core.finish_operation(operation, True, message, size_bytes=size) + + +# ---------------------------------------------------------------- восстановление +def rewrite_xml(xml_text: str, new_name: str, disk_map: dict[str, str]) -> str: + """Подготовить XML копии к `virsh define` под именем new_name. + + Меняет , убирает и id, переключает диски на новые файлы, + удаляет (libvirt выдаст новые адреса) и (libvirt заведёт + свой файл под новое имя, чтобы не делить его со старой машиной).""" + root = ET.fromstring(xml_text) + root.attrib.pop("id", None) + name = root.find("name") + if name is None: + name = ET.Element("name") + root.insert(0, name) + name.text = new_name + for element in root.findall("uuid"): + root.remove(element) + devices = root.find("devices") + if devices is not None: + for disk in devices.findall("disk"): + target = disk.find("target") + dev = target.get("dev") if target is not None else None + if disk.get("device") != "disk" or dev not in disk_map: + continue + source = disk.find("source") + if source is None: + source = ET.SubElement(disk, "source") + source.attrib.clear() + source.set("file", disk_map[dev]) + disk.set("type", "file") + driver = disk.find("driver") + if driver is None: + driver = ET.SubElement(disk, "driver") + driver.set("name", "qemu") + driver.set("type", "qcow2") + for interface in devices.findall("interface"): + for mac in interface.findall("mac"): + interface.remove(mac) + os_element = root.find("os") + if os_element is not None: + for nvram in os_element.findall("nvram"): + os_element.remove(nvram) + return ET.tostring(root, encoding="unicode") + + +def restore_disk_path(new_name: str, target: str, index: int) -> Path: + images = core.IMAGES_DIR + candidates = [images / f"{new_name}.qcow2"] if index == 0 else [] + candidates.append(images / f"{new_name}-{target}.qcow2") + for candidate in candidates: + if not candidate.exists(): + return candidate + counter = 2 + while (images / f"{new_name}-{target}-{counter}.qcow2").exists(): + counter += 1 + return images / f"{new_name}-{target}-{counter}.qcow2" + + +def plan_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict[str, Any]: + backup = read_backup(vm, backup_id) + if not backup: + raise BackupError("Копия не найдена") + if not backup["complete"]: + raise BackupError("Копия неполная — не хватает файлов. Восстановить из неё нельзя.") + new_name = (new_name or "").strip() or vm + if not core.valid_vm_name(new_name): + raise BackupError("Название машины: латинские буквы, цифры, точка, дефис, от 2 до 63 символов", "name") + exists = core.vm_exists(new_name) + if exists and not replace: + raise BackupError(f"Машина {new_name} уже есть на сервере. Укажите другое название или подтвердите замену.", "exists") + if active_operation(new_name) or active_operation(vm): + raise BackupError("С этой машиной уже идёт копирование или восстановление — дождитесь завершения") + need = sum(int(disk.get("size") or 0) for disk in backup["disks"]) + reclaim = 0 + if exists: + try: + reclaim = sum(disk_info(disk["source"])["actual_size"] for disk in vm_disks(new_name)) + except BackupError: + reclaim = 0 + free = free_bytes(core.IMAGES_DIR) + reclaim + if free < need + SPACE_MARGIN: + raise BackupError(f"Недостаточно места для дисков машины: нужно около {format_bytes(need)}, свободно {format_bytes(free)}.", "space") + return {"backup": backup, "new_name": new_name, "replace": exists, "need": need} + + +def start_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict[str, Any]: + plan = plan_restore(vm, backup_id, new_name, replace) + backup = plan["backup"] + title = f"Восстановление {plan['new_name']} из копии" + operation = core.new_operation("restore", title, "Ожидает начала", vm_name=plan["new_name"], source_vm=vm, backup_id=backup_id, replace=plan["replace"], disks=backup["disks"], size_bytes=plan["need"]) + core.run_operation(operation, restore_worker) + return operation + + +def remove_vm(operation: dict[str, Any], name: str) -> None: + """Удалить существующую машину вместе с дисками перед восстановлением.""" + log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731 + if core.vm_is_running(name): + log(f"Машина {name} работает — выключаем принудительно.") + result = run(["virsh", "destroy", name], timeout=30) + if not result["ok"]: + raise BackupError("Не удалось выключить старую машину: " + core.cmd_error(result, "virsh destroy")) + for cdrom in vm_cdroms_with_media(name): + # Иначе `undefine --remove-all-storage` удалит и вставленный ISO-образ. + result = run(["virsh", "change-media", name, cdrom["target"], "--eject", "--config"], timeout=20) + if not result["ok"]: + raise BackupError(f"Не удалось извлечь образ из привода {cdrom['target']}: " + core.cmd_error(result, "virsh change-media")) + log(f"Образ извлечён из привода {cdrom['target']}, чтобы он не был удалён вместе с машиной.") + result = run(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"], timeout=180) + if not result["ok"]: + raise BackupError("Не удалось удалить старую машину: " + core.cmd_error(result, "virsh undefine")) + log(f"Старая машина {name} удалена вместе с дисками.") + + +def restore_worker(operation: dict[str, Any]) -> None: + new_name = operation["vm_name"] + log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731 + backup = read_backup(operation["source_vm"], operation["backup_id"]) + created: list[Path] = [] + try: + if not backup or not backup["complete"]: + raise BackupError("Копия не найдена или неполная") + if operation.get("replace"): + core.update_operation(operation, progress=3, message=f"Удаляем существующую машину {new_name}…") + remove_vm(operation, new_name) + core.IMAGES_DIR.mkdir(parents=True, exist_ok=True) + disk_map: dict[str, str] = {} + total = len(backup["disks"]) + for index, disk in enumerate(backup["disks"]): + source = Path(backup["path"]) / f"{disk['target']}.qcow2" + target = restore_disk_path(new_name, disk["target"], index) + created.append(target) + label = f"Восстанавливаем диск {disk['target']} ({index + 1} из {total})" + core.update_operation(operation, progress=int(5 + index / total * 90), message=label) + log(f"{label}: {source} → {target}") + cmd = ["qemu-img", "convert", "-p", "-O", "qcow2", str(source), str(target)] + log(" ".join(cmd)) + code = stream_cmd(cmd, disk_progress_reporter(operation, index, total, label)) + if code != 0: + raise BackupError(f"Не удалось восстановить диск {disk['target']} (qemu-img завершился с кодом {code})") + disk_map[disk["target"]] = str(target) + core.update_operation(operation, progress=96, message="Регистрируем машину…") + xml = rewrite_xml((Path(backup["path"]) / "vm.xml").read_text(encoding="utf-8"), new_name, disk_map) + with tempfile.NamedTemporaryFile("w", dir=backup["path"], prefix="restore-", suffix=".xml", delete=False, encoding="utf-8") as handle: + handle.write(xml) + tmp_xml = Path(handle.name) + try: + result = run(["virsh", "define", str(tmp_xml)], timeout=30) + finally: + tmp_xml.unlink(missing_ok=True) + if not result["ok"]: + raise BackupError("Не удалось зарегистрировать машину: " + core.cmd_error(result, "virsh define")) + run(["virsh", "pool-refresh", core.IMAGES_POOL], timeout=20) + log(f"Машина {new_name} зарегистрирована.") + except Exception as exc: # noqa: BLE001 + for path in created: + path.unlink(missing_ok=True) + log(f"Ошибка: {exc}") + core.finish_operation(operation, False, str(exc)[:240]) + return + created_label = backup["created_label"] if backup else "" + core.finish_operation(operation, True, f"Машина {new_name} восстановлена из копии от {created_label}. Она выключена — запустите её, когда будете готовы.") + + +# ---------------------------------------------------------------- страницы +def page_messages(request: Request) -> dict[str, str]: + return {"backup_message": request.query_params.get("backup_message", ""), "backup_error": request.query_params.get("backup_error", "")} + + +def vm_summary(name: str) -> dict[str, Any]: + dominfo = run(["virsh", "dominfo", name], timeout=10)["stdout"] + info = presenters.parse_dominfo(dominfo) + return {"name": name, "info": info, "state": info["state"] or core.vm_state(name)} + + +@router.get("/backups") +def backups_page(request: Request): + auth = core.require_auth(request) + if auth: + return auth + items = list_backups() + context = { + "groups": group_by_vm(items, existing_vm_names()), + "storage": storage_summary(items), + "running": [op for op in core.running_operations() if op.get("type") in ("backup", "restore")], + **page_messages(request), + } + return core.render(request, "backups.html", context) + + +@router.post("/backups/{vm}/{backup_id}/delete") +def backup_delete(request: Request, vm: str, backup_id: str, back_to: str = Form("", alias="next")): + auth = core.require_auth(request) + if auth: + return auth + back = back_to if back_to == f"/vm/{vm}/backups" and core.valid_vm_name(vm) else "/backups" + try: + delete_backup(vm, backup_id) + except BackupError as exc: + return core.redirect_with_message(back, "backup_error", str(exc)) + return core.redirect_with_message(back, "backup_message", f"Копия от {format_backup_date(backup_id)} удалена") + + +def restore_form(request: Request, backup: dict[str, Any], name: str, exists: bool, error: str = "", status_code: int = 200): + need = sum(int(disk.get("size") or 0) for disk in backup["disks"]) + context = {"backup": backup, "name": name, "exists": exists, "error": error, "need": format_bytes(need), "free": format_bytes(free_bytes(core.IMAGES_DIR)), "images_dir": str(core.IMAGES_DIR)} + return core.render(request, "backup_restore.html", context, status_code=status_code) + + +@router.get("/backups/{vm}/{backup_id}/restore") +def backup_restore_page(request: Request, vm: str, backup_id: str): + auth = core.require_auth(request) + if auth: + return auth + backup = read_backup(vm, backup_id) + if not backup: + return core.redirect_with_message("/backups", "backup_error", "Копия не найдена") + return restore_form(request, backup, vm, core.vm_exists(vm)) + + +@router.post("/backups/{vm}/{backup_id}/restore") +def backup_restore_submit(request: Request, vm: str, backup_id: str, name: str = Form(""), replace: str = Form("")): + auth = core.require_auth(request) + if auth: + return auth + backup = read_backup(vm, backup_id) + if not backup: + return core.redirect_with_message("/backups", "backup_error", "Копия не найдена") + name = (name or "").strip() or vm + try: + operation = start_restore(vm, backup_id, name, replace == "1") + except BackupError as exc: + exists = exc.code == "exists" or (core.valid_vm_name(name) and core.vm_exists(name)) + return restore_form(request, backup, name, exists, str(exc), status_code=400) + return RedirectResponse(url=f"/operations/{operation['id']}", status_code=303) + + +@router.get("/vm/{name}/backups") +def vm_backups_page(request: Request, name: str): + auth = core.require_auth(request) + if auth: + return auth + if not core.valid_vm_name(name) or not core.vm_exists(name): + return RedirectResponse(url="/", status_code=303) + items = list_backups(name) + context = {"vm": vm_summary(name), "backups": items, "storage": storage_summary(), "active": active_operation(name), **page_messages(request)} + return core.render(request, "vm_backups.html", context) + + +@router.post("/vm/{name}/backups/create") +def vm_backup_create(request: Request, name: str, note: str = Form("")): + auth = core.require_auth(request) + if auth: + return auth + if not core.valid_vm_name(name): + return RedirectResponse(url="/", status_code=303) + try: + operation = create_backup(name, note) + except BackupError as exc: + return core.redirect_with_message(f"/vm/{name}/backups", "backup_error", str(exc)) + return RedirectResponse(url=f"/operations/{operation['id']}", status_code=303) + + +if __name__ == "__main__": # python3 web/features/backups.py <машина> [заметка] + try: + finished = create_backup(sys.argv[1], " ".join(sys.argv[2:]), wait=True) + except (IndexError, BackupError) as error: + print(f"Ошибка: {error or 'укажите имя машины'}") + sys.exit(2) + print(f"{finished['status']}: {finished['message']}") + sys.exit(0 if finished["status"] == "success" else 1) diff --git a/web/static/app.css b/web/static/app.css index 33b98fa..0410358 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -298,6 +298,15 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; } .switch[aria-checked="true"] { background: var(--success); } .switch[aria-checked="true"]::after { transform: translateX(18px); } +/* checkbox row (consent for destructive actions) */ +.check { display: flex; gap: 12px; align-items: flex-start; padding: 13px 16px; border: 1.5px solid var(--border); border-radius: var(--radius); background: var(--surface-2); cursor: pointer; } +.check input { width: 18px; height: 18px; flex: none; margin: 2px 0 0; accent-color: var(--brand); cursor: pointer; } +.check > span { display: grid; gap: 2px; } +.check > span > span { color: var(--text-3); font-size: 12.5px; } +.check.danger { border-color: color-mix(in srgb, var(--danger) 30%, transparent); background: var(--danger-soft); } +.check.danger input { accent-color: var(--danger); } +.check.danger strong { color: var(--danger); } + /* option cards (radio) */ .options { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); } .options-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } diff --git a/web/templates/_backup_rows.html b/web/templates/_backup_rows.html new file mode 100644 index 0000000..79cd92d --- /dev/null +++ b/web/templates/_backup_rows.html @@ -0,0 +1,18 @@ +{% from "_ui.html" import icon, badge %} +{#- Строки списка копий. Ожидает: rows (список копий), back (куда вернуться после удаления). -#} +{% for b in rows %} +
+ {{ icon('database' if b.complete else 'triangle-alert') }} +
+
{{ b.created_label }}{% if b.note %} {{ b.note }}{% endif %}
+
{{ b.size }} · {{ b.disk_count }} {{ plural(b.disk_count, 'диск', 'диска', 'дисков') }}{% if b.version %} · Virtuality {{ b.version }}{% endif %}{% if not b.complete %} · не хватает файлов{% endif %}
+
+ {% if b.complete %} + {{ icon('rotate-cw', 'icon-sm') }}Восстановить + {% endif %} +
+ + +
+
+{% endfor %} diff --git a/web/templates/backup_restore.html b/web/templates/backup_restore.html new file mode 100644 index 0000000..d9a365e --- /dev/null +++ b/web/templates/backup_restore.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, alert %} +{% set page_title = 'Восстановить ' ~ backup.vm %} + +{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% if error %}{{ alert('danger', error) }}{% endif %} +{% endblock %} + +{% block content %} +
+
+
+
+
+ + + Оставьте как есть, чтобы вернуть машину {{ backup.vm }}, или введите новое название — получится вторая независимая машина. +
+ + {% if exists %} +
{{ icon('triangle-alert') }}
Машина {{ name }} уже есть на сервереЧтобы восстановить копию под этим названием, существующую машину придётся удалить вместе с её дисками. Если хотите сохранить обе — укажите другое название.
+ + {% else %} + + {% endif %} + +
+
Дисков в копии{{ backup.disk_count }} · {{ backup.size }}
+
Понадобится местаоколо {{ need }}
+
Свободно{{ free }}
+
+
+ +
+
+ + +
+{% endblock %} diff --git a/web/templates/backups.html b/web/templates/backups.html new file mode 100644 index 0000000..1a9207d --- /dev/null +++ b/web/templates/backups.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, meter, empty, alert %} +{% set page_title = 'Резервные копии' %} + +{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% if backup_message %}{{ alert('success', backup_message) }}{% endif %} + {% if backup_error %}{{ alert('danger', backup_error) }}{% endif %} + {% for op in running %} +
{{ icon('activity') }}
{{ op.title }} — {{ op.progress|default(0) }}%{{ op.message }}
Следить
+ {% endfor %} +{% endblock %} + +{% block content %} +
+
+
Копий на сервере{{ icon('database', 'icon-sm') }}
+
{{ storage.count }}{{ plural(storage.count, 'копия', 'копии', 'копий') }}
+
{{ groups|length }} {{ plural(groups|length, 'машина', 'машины', 'машин') }}
+
+
+
Занято копиями{{ icon('hard-drive', 'icon-sm') }}
+
{{ storage.used }}
+ {{ meter(storage.used_pct, 'brand') }} +
{{ storage.used_pct }}% места на диске сервера
+
+
+
Свободно для новых копий{{ icon('layers', 'icon-sm') }}
+
{{ storage.free }}
+
Перед копированием место проверяется автоматически
+
+
+ +{% if groups %} +
+ {% for g in groups %} +
+
+
+ {{ icon('monitor') }} +
+

{% if g.exists %}{{ g.vm }}{% else %}{{ g.vm }}{% endif %}{% if not g.exists %} {{ badge('машина удалена', 'warning', cls='hide-sm') }}{% endif %}

+

{{ g.count }} {{ plural(g.count, 'копия', 'копии', 'копий') }} · {{ g.size }}{% if not g.exists %} · машины больше нет на сервере, но её можно восстановить{% endif %}

+
+
+ {% if g.exists %}{{ icon('plus', 'icon-sm') }}Создать копию{% endif %} +
+
+ {% with rows = g.backups, back = '/backups' %}{% include "_backup_rows.html" %}{% endwith %} +
+
+ {% endfor %} +
+{% else %} +
+ {{ empty('database', 'Копий пока нет', 'Откройте страницу машины, перейдите на вкладку «Резервные копии» и нажмите «Создать копию». Копирование идёт в фоне — страницу можно закрыть.', '/', 'К списку машин', 'monitor') }} +
+{% endif %} + +
+
+
+
{{ icon('shield-check') }}

Что входит в копию

+

Настройки машины (процессор, память, сеть, порядок загрузки) и все её диски целиком. Установочные образы (ISO) не копируются — они лежат отдельно в разделе «Образы ОС». Диски сжимаются, поэтому копия обычно меньше самой машины.

+
+
+
+
+
{{ icon('info') }}

Как это работает

+

Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной и получает новые сетевые адреса, чтобы не мешать оригиналу. Копии хранятся в папке /var/lib/virtuality/backups.

+
+
+
+{% endblock %} diff --git a/web/templates/base.html b/web/templates/base.html index 5364d58..383a455 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -39,6 +39,7 @@ {{ nav_link('/iso', 'disc-3', 'Образы ОС', path.startswith('/iso') or path.startswith('/disk-images')) }} {{ nav_link('/network', 'globe', 'Сеть и доступ', path.startswith('/network')) }} + {{ nav_link('/backups', 'database', 'Резервные копии', path.startswith('/backups')) }} {{ nav_link('/operations', 'list-checks', 'Задачи', path.startswith('/operations')) }} diff --git a/web/templates/vm_backups.html b/web/templates/vm_backups.html new file mode 100644 index 0000000..a3e4907 --- /dev/null +++ b/web/templates/vm_backups.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, vm_badge, empty, alert %} +{% set page_title = vm.name ~ ' — резервные копии' %} +{% set s = vm.state|vm_state %} + +{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% if backup_message %}{{ alert('success', backup_message) }}{% endif %} + {% if backup_error %}{{ alert('danger', backup_error) }}{% endif %} + {% if active %} +
{{ icon('activity') }}
{{ active.title }} — {{ active.progress|default(0) }}%{{ active.message }}
Следить
+ {% endif %} +{% endblock %} + +{% block content %} + + +
+
+

Копии этой машины

Новые сверху. Из любой можно восстановить машину — под тем же или новым названием.

+ {% if backups %} +
+ {% with rows = backups, back = '/vm/' ~ vm.name ~ '/backups' %}{% include "_backup_rows.html" %}{% endwith %} +
+ {% else %} + {{ empty('database', 'Копий пока нет', 'Создайте первую копию — это займёт несколько минут и защитит от неудачных обновлений и случайных ошибок.') }} + {% endif %} +
+ + +
+{% endblock %} diff --git a/web/templates/vm_detail.html b/web/templates/vm_detail.html index 7764bd1..ada4190 100644 --- a/web/templates/vm_detail.html +++ b/web/templates/vm_detail.html @@ -53,6 +53,7 @@ + {{ icon('database', 'icon-sm') }}Резервные копии