Резервные копии были доработаны по замечаниям ревью: своя папка у каждой задачи, замена машины только после распаковки дисков
- Папка копии занимается атомарно ещё в create_backup; вторая копия в ту же минуту получает суффикс -2, поэтому неудачная задача удаляет только свою папку. - qemu-img info вызывается с -U (диск работающей машины занят QEMU), а ошибка теперь поднимается пользователю вместо тихих нулей в расчёте места. - Проверки «с машиной уже идёт задача» переведены на core.active_operations_for; удаление копии, которую читает восстановление под другим именем, блокирует метка .restoring с id задачи (устаревшая метка после перезапуска игнорируется). - Зависший qemu-img убивается по общему таймауту stream_cmd. - Восстановление с заменой сначала распаковывает все диски во временные файлы и только потом удаляет старую машину; при ошибке она остаётся на месте. Освобождаемое место старой машины больше не учитывается в проверке. - Отклонённая команда выключения сообщается как есть, машина на паузе сохраняется через managedsave и продолжает с того же места, а если машина выключилась уже после отказа задачи — панель запускает её снова. - rewrite_xml при замене сохраняет uuid и mac, убирает <backingStore> и вынимает из привода образ, которого больше нет на сервере. - Имена дисков из meta.json проверяются, процент занятого места считается от всего диска, убран двойной отклик при удалении, сообщения об ошибках копирования без жаргона, путь запуска из расписания указан через venv панели. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
+222
-18
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
@@ -26,9 +27,11 @@ def make_backup(dirs, vm="web01", backup_id="20260924-0300", size=2_684_354_560,
|
|||||||
class FakeVirsh:
|
class FakeVirsh:
|
||||||
"""Имитация virsh с состоянием: машина выключается после `virsh shutdown`."""
|
"""Имитация virsh с состоянием: машина выключается после `virsh shutdown`."""
|
||||||
|
|
||||||
def __init__(self, running=True, shuts_down=True, exists=("web01", "db01")):
|
def __init__(self, running=True, shuts_down=True, exists=("web01", "db01"), paused=False, rejects_shutdown=False):
|
||||||
self.running = running
|
self.running = running
|
||||||
|
self.paused = paused
|
||||||
self.shuts_down = shuts_down
|
self.shuts_down = shuts_down
|
||||||
|
self.rejects_shutdown = rejects_shutdown
|
||||||
self.exists = set(exists)
|
self.exists = set(exists)
|
||||||
self.calls: list[list[str]] = []
|
self.calls: list[list[str]] = []
|
||||||
self.defined_xml = ""
|
self.defined_xml = ""
|
||||||
@@ -36,11 +39,16 @@ class FakeVirsh:
|
|||||||
def __call__(self, cmd, timeout=12, **_kwargs):
|
def __call__(self, cmd, timeout=12, **_kwargs):
|
||||||
self.calls.append(list(cmd))
|
self.calls.append(list(cmd))
|
||||||
if cmd[:2] == ["virsh", "domstate"]:
|
if cmd[:2] == ["virsh", "domstate"]:
|
||||||
return fake_result("running" if self.running else "shut off")
|
return fake_result("paused" if self.running and self.paused else "running" if self.running else "shut off")
|
||||||
if cmd[:2] == ["virsh", "shutdown"]:
|
if cmd[:2] == ["virsh", "shutdown"]:
|
||||||
|
if self.rejects_shutdown:
|
||||||
|
return fake_result(ok=False, stderr="error: Requested operation is not valid: domain is not running")
|
||||||
if self.shuts_down:
|
if self.shuts_down:
|
||||||
self.running = False
|
self.running = False
|
||||||
return fake_result()
|
return fake_result()
|
||||||
|
if cmd[:2] == ["virsh", "managedsave"]:
|
||||||
|
self.running = False
|
||||||
|
return fake_result(f"Domain '{cmd[2]}' state saved by libvirt")
|
||||||
if cmd[:2] == ["virsh", "destroy"]:
|
if cmd[:2] == ["virsh", "destroy"]:
|
||||||
self.running = False
|
self.running = False
|
||||||
return fake_result()
|
return fake_result()
|
||||||
@@ -58,10 +66,11 @@ class FakeVirsh:
|
|||||||
return fake_run_cmd(cmd, timeout)
|
return fake_run_cmd(cmd, timeout)
|
||||||
|
|
||||||
|
|
||||||
def fake_stream(progress_lines=("(0.00/100%)", "(48.50/100%)", "(100.00/100%)"), code=0):
|
def fake_stream(progress_lines=("(0.00/100%)", "(48.50/100%)", "(100.00/100%)"), code=0, calls=None):
|
||||||
calls: list[list[str]] = []
|
"""Имитация qemu-img convert. calls — общий список с FakeVirsh, если важен порядок команд."""
|
||||||
|
calls = [] if calls is None else calls
|
||||||
|
|
||||||
def stream(cmd, on_line):
|
def stream(cmd, on_line, timeout=None):
|
||||||
calls.append(list(cmd))
|
calls.append(list(cmd))
|
||||||
Path(cmd[-1]).write_bytes(b"QFI\xfb" + b"\0" * 1000)
|
Path(cmd[-1]).write_bytes(b"QFI\xfb" + b"\0" * 1000)
|
||||||
for line in progress_lines:
|
for line in progress_lines:
|
||||||
@@ -72,12 +81,22 @@ def fake_stream(progress_lines=("(0.00/100%)", "(48.50/100%)", "(100.00/100%)"),
|
|||||||
return stream
|
return stream
|
||||||
|
|
||||||
|
|
||||||
|
class FixedDatetime(datetime):
|
||||||
|
"""datetime.now() всегда 24 сентября 2026, 03:00 — чтобы id копий были предсказуемы."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return cls(2026, 9, 24, 3, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def fast_worker(monkeypatch, data_dirs):
|
def fast_worker(monkeypatch, data_dirs):
|
||||||
"""Операции выполняются синхронно, ожидание выключения — мгновенно."""
|
"""Операции и фоновые потоки выполняются синхронно, ожидание выключения — мгновенно."""
|
||||||
monkeypatch.setattr(core, "run_operation", lambda operation, worker: worker(operation))
|
monkeypatch.setattr(core, "run_operation", lambda operation, worker: worker(operation))
|
||||||
|
monkeypatch.setattr(backups, "start_thread", lambda target, *args: target(*args))
|
||||||
monkeypatch.setattr(backups, "POLL_INTERVAL", 0.001)
|
monkeypatch.setattr(backups, "POLL_INTERVAL", 0.001)
|
||||||
monkeypatch.setattr(backups, "SHUTDOWN_TIMEOUT", 0.05)
|
monkeypatch.setattr(backups, "SHUTDOWN_TIMEOUT", 0.05)
|
||||||
|
monkeypatch.setattr(backups, "LATE_SHUTDOWN_WATCH", 0.05)
|
||||||
monkeypatch.setattr(backups, "free_bytes", lambda path: 500 * 1024 ** 3)
|
monkeypatch.setattr(backups, "free_bytes", lambda path: 500 * 1024 ** 3)
|
||||||
return data_dirs
|
return data_dirs
|
||||||
|
|
||||||
@@ -109,6 +128,29 @@ def test_stream_cmd_splits_carriage_returns():
|
|||||||
assert lines == ["a", "(10.00/100%)", "(55.50/100%)", "end"]
|
assert lines == ["a", "(10.00/100%)", "(55.50/100%)", "end"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_cmd_stops_hung_command():
|
||||||
|
lines: list[str] = []
|
||||||
|
code = backups.stream_cmd(["python3", "-c", "import time; time.sleep(30)"], lines.append, timeout=0.2)
|
||||||
|
assert code != 0
|
||||||
|
assert any("остановлена" in line for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disk_info_uses_shared_lock_and_raises_on_failure(monkeypatch):
|
||||||
|
calls: list[list[str]] = []
|
||||||
|
|
||||||
|
def info_ok(cmd, timeout=12, **_kw):
|
||||||
|
calls.append(list(cmd))
|
||||||
|
return fake_result(QEMU_IMG_INFO)
|
||||||
|
|
||||||
|
monkeypatch.setattr(core, "run_cmd", info_ok)
|
||||||
|
assert backups.disk_info("/x/web01.qcow2")["actual_size"] == 6443499520
|
||||||
|
assert calls == [["qemu-img", "info", "-U", "--output=json", "/x/web01.qcow2"]] # -U: диск занят работающей машиной
|
||||||
|
monkeypatch.setattr(core, "run_cmd", lambda cmd, timeout=12, **_kw: fake_result(ok=False, stderr='qemu-img: Could not open: Failed to get shared "write" lock'))
|
||||||
|
with pytest.raises(backups.BackupError) as exc:
|
||||||
|
backups.disk_info("/x/web01.qcow2")
|
||||||
|
assert "размер диска" in str(exc.value) and "write" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
def test_rewrite_xml_renames_and_detaches_identity():
|
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"})
|
xml = backups.rewrite_xml((FIXTURES / "dumpxml-web01.xml").read_text(), "web02", {"vda": "/var/lib/virtuality/images/web02.qcow2"})
|
||||||
assert "<name>web02</name>" in xml
|
assert "<name>web02</name>" in xml
|
||||||
@@ -119,6 +161,27 @@ def test_rewrite_xml_renames_and_detaches_identity():
|
|||||||
assert "<readonly />" in xml or "<readonly/>" in xml # привод CD остаётся как был
|
assert "<readonly />" in xml or "<readonly/>" in xml # привод CD остаётся как был
|
||||||
|
|
||||||
|
|
||||||
|
def test_rewrite_xml_keeps_identity_on_replace_and_drops_stale_chain(tmp_path):
|
||||||
|
present = tmp_path / "debian.iso"
|
||||||
|
present.write_bytes(b"iso")
|
||||||
|
source = f"""<domain type='kvm' id='7'><name>web01</name><uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>
|
||||||
|
<devices>
|
||||||
|
<disk type='file' device='disk'><driver name='qemu' type='qcow2'/><source file='/var/lib/virtuality/images/web01.snap.qcow2'/>
|
||||||
|
<backingStore type='file'><format type='qcow2'/><source file='/var/lib/virtuality/images/web01.qcow2'/></backingStore>
|
||||||
|
<target dev='vda' bus='virtio'/></disk>
|
||||||
|
<disk type='file' device='cdrom'><driver name='qemu' type='raw'/><source file='/var/lib/virtuality/iso/gone.iso'/><target dev='sda' bus='sata'/><readonly/></disk>
|
||||||
|
<disk type='file' device='cdrom'><driver name='qemu' type='raw'/><source file='{present}'/><target dev='sdb' bus='sata'/><readonly/></disk>
|
||||||
|
<interface type='network'><mac address='52:54:00:aa:bb:cc'/><source network='virtuality-nat'/></interface>
|
||||||
|
</devices></domain>"""
|
||||||
|
replaced = backups.rewrite_xml(source, "web01", {"vda": "/var/lib/virtuality/images/web01.qcow2"}, keep_identity=True)
|
||||||
|
assert "<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>" in replaced and 'mac address="52:54:00:aa:bb:cc"' in replaced
|
||||||
|
assert "backingStore" not in replaced and "web01.snap.qcow2" not in replaced
|
||||||
|
assert "gone.iso" not in replaced and str(present) in replaced # пропавший образ вынут, существующий остаётся
|
||||||
|
assert 'id="7"' not in replaced
|
||||||
|
fresh = backups.rewrite_xml(source, "web02", {"vda": "/var/lib/virtuality/images/web02.qcow2"})
|
||||||
|
assert "<uuid>" not in fresh and "<mac" not in fresh and "backingStore" not in fresh
|
||||||
|
|
||||||
|
|
||||||
def test_rewrite_xml_converts_block_disk_and_drops_nvram():
|
def test_rewrite_xml_converts_block_disk_and_drops_nvram():
|
||||||
source = """<domain type='kvm'><name>old</name><os><type>hvm</type><loader type='pflash'>/usr/share/OVMF/OVMF_CODE.fd</loader><nvram>/var/lib/libvirt/qemu/nvram/old_VARS.fd</nvram></os>
|
source = """<domain type='kvm'><name>old</name><os><type>hvm</type><loader type='pflash'>/usr/share/OVMF/OVMF_CODE.fd</loader><nvram>/var/lib/libvirt/qemu/nvram/old_VARS.fd</nvram></os>
|
||||||
<devices><disk type='block' device='disk'><driver name='qemu' type='raw'/><source dev='/dev/vg/old'/><target dev='vda' bus='virtio'/></disk></devices></domain>"""
|
<devices><disk type='block' device='disk'><driver name='qemu' type='raw'/><source dev='/dev/vg/old'/><target dev='vda' bus='virtio'/></disk></devices></domain>"""
|
||||||
@@ -130,6 +193,7 @@ def test_rewrite_xml_converts_block_disk_and_drops_nvram():
|
|||||||
|
|
||||||
def test_format_backup_date():
|
def test_format_backup_date():
|
||||||
assert backups.format_backup_date("20260924-0300") == "24 сентября 2026, 03:00"
|
assert backups.format_backup_date("20260924-0300") == "24 сентября 2026, 03:00"
|
||||||
|
assert backups.format_backup_date("20260924-0300-2") == "24 сентября 2026, 03:00" # вторая копия в ту же минуту
|
||||||
assert backups.format_backup_date("garbage") == "garbage"
|
assert backups.format_backup_date("garbage") == "garbage"
|
||||||
|
|
||||||
|
|
||||||
@@ -165,6 +229,22 @@ def test_incomplete_backup_is_flagged(data_dirs):
|
|||||||
assert backups.read_backup("web01", "20260924-0300")["complete"] is False
|
assert backups.read_backup("web01", "20260924-0300")["complete"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_with_unsafe_disk_target_is_incomplete(data_dirs):
|
||||||
|
path = make_backup(data_dirs)
|
||||||
|
meta = json.loads((path / "meta.json").read_text())
|
||||||
|
meta["disks"].append({"target": "../../etc/shadow", "source": "/x", "size": 1})
|
||||||
|
(path / "meta.json").write_text(json.dumps(meta))
|
||||||
|
backup = backups.read_backup("web01", "20260924-0300")
|
||||||
|
assert [disk["target"] for disk in backup["disks"]] == ["vda"] and backup["complete"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_summary_percent_is_share_of_whole_disk(data_dirs, monkeypatch):
|
||||||
|
monkeypatch.setattr(backups, "total_bytes", lambda path: 1000)
|
||||||
|
monkeypatch.setattr(backups, "free_bytes", lambda path: 400)
|
||||||
|
summary = backups.storage_summary([{"size_bytes": 350}])
|
||||||
|
assert summary["used_pct"] == 35 and summary["free_bytes"] == 400
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- создание копии
|
# ---------------------------------------------------------------- создание копии
|
||||||
def test_create_backup_refuses_without_space(data_dirs, monkeypatch):
|
def test_create_backup_refuses_without_space(data_dirs, monkeypatch):
|
||||||
monkeypatch.setattr(backups, "free_bytes", lambda path: 1024)
|
monkeypatch.setattr(backups, "free_bytes", lambda path: 1024)
|
||||||
@@ -191,6 +271,7 @@ def test_create_backup_shuts_down_copies_and_restarts(fast_worker, monkeypatch):
|
|||||||
assert operation["progress"] == 100 and "Резервная копия создана" in operation["message"]
|
assert operation["progress"] == 100 and "Резервная копия создана" in operation["message"]
|
||||||
assert ["virsh", "shutdown", "web01"] in virsh.calls
|
assert ["virsh", "shutdown", "web01"] in virsh.calls
|
||||||
assert ["virsh", "dumpxml", "web01", "--migratable"] in virsh.calls
|
assert ["virsh", "dumpxml", "web01", "--migratable"] in virsh.calls
|
||||||
|
assert ["qemu-img", "info", "-U", "--output=json", str(core.IMAGES_DIR / "web01.qcow2")] in virsh.calls
|
||||||
assert virsh.calls[-1] == ["virsh", "start", "web01"]
|
assert virsh.calls[-1] == ["virsh", "start", "web01"]
|
||||||
assert ["virsh", "destroy", "web01"] not in virsh.calls
|
assert ["virsh", "destroy", "web01"] not in virsh.calls
|
||||||
assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", "-c", str(core.IMAGES_DIR / "web01.qcow2"), str(fast_worker["backups"] / "web01" / operation["backup_id"] / "vda.qcow2")]]
|
assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", "-c", str(core.IMAGES_DIR / "web01.qcow2"), str(fast_worker["backups"] / "web01" / operation["backup_id"] / "vda.qcow2")]]
|
||||||
@@ -216,11 +297,57 @@ def test_backup_fails_when_vm_does_not_shut_down(fast_worker, monkeypatch):
|
|||||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||||
operation = backups.create_backup("web01")
|
operation = backups.create_backup("web01")
|
||||||
fresh = core.read_operation(operation["id"])
|
fresh = core.read_operation(operation["id"])
|
||||||
assert fresh["status"] == "error" and "не выключилась" in fresh["message"]
|
assert fresh["status"] == "error" and "не выключилась" in fresh["message"] and "запустит её снова" in fresh["message"]
|
||||||
assert ["virsh", "destroy", "web01"] not in virsh.calls
|
assert ["virsh", "destroy", "web01"] not in virsh.calls
|
||||||
|
assert ["virsh", "start", "web01"] not in virsh.calls # так и не выключилась — запускать нечего
|
||||||
|
assert "так и не выключилась" in fresh["log_tail"]
|
||||||
assert not (fast_worker["backups"] / "web01").exists()
|
assert not (fast_worker["backups"] / "web01").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_restarts_vm_that_shuts_down_late(fast_worker, monkeypatch):
|
||||||
|
virsh = FakeVirsh(running=True, shuts_down=False)
|
||||||
|
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||||
|
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||||
|
|
||||||
|
def shut_down_after_giving_up(target, *args):
|
||||||
|
virsh.running = False # ACPI-выключение дошло уже после того, как задача сдалась
|
||||||
|
target(*args)
|
||||||
|
|
||||||
|
monkeypatch.setattr(backups, "start_thread", shut_down_after_giving_up)
|
||||||
|
operation = backups.create_backup("web01")
|
||||||
|
fresh = core.read_operation(operation["id"])
|
||||||
|
assert fresh["status"] == "error" and "не выключилась" in fresh["message"]
|
||||||
|
assert virsh.calls[-1] == ["virsh", "start", "web01"] and virsh.running
|
||||||
|
assert "запускаем её снова" in fresh["log_tail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_reports_rejected_shutdown_command(fast_worker, monkeypatch):
|
||||||
|
virsh = FakeVirsh(running=True, rejects_shutdown=True)
|
||||||
|
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||||
|
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||||
|
monkeypatch.setattr(backups, "start_thread", lambda target, *args: pytest.fail("нечего ждать — команда не принята"))
|
||||||
|
operation = backups.create_backup("web01")
|
||||||
|
fresh = core.read_operation(operation["id"])
|
||||||
|
assert fresh["status"] == "error"
|
||||||
|
assert "не приняла команду выключения" in fresh["message"] and "domain is not running" in fresh["message"]
|
||||||
|
assert "не выключилась" not in fresh["message"]
|
||||||
|
assert ["virsh", "start", "web01"] not in virsh.calls
|
||||||
|
assert not (fast_worker["backups"] / "web01").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_of_paused_vm_saves_state_instead_of_shutdown(fast_worker, monkeypatch):
|
||||||
|
virsh = FakeVirsh(running=True, paused=True)
|
||||||
|
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"] == "success", fresh["message"]
|
||||||
|
assert ["virsh", "managedsave", "web01"] in virsh.calls and ["virsh", "shutdown", "web01"] not in virsh.calls
|
||||||
|
assert virsh.calls.index(["virsh", "managedsave", "web01"]) < virsh.calls.index(["virsh", "dumpxml", "web01", "--migratable"])
|
||||||
|
assert virsh.calls[-1] == ["virsh", "start", "web01"]
|
||||||
|
assert "на паузе" in fresh["log_tail"]
|
||||||
|
|
||||||
|
|
||||||
def test_backup_cleans_up_and_restarts_after_copy_error(fast_worker, monkeypatch):
|
def test_backup_cleans_up_and_restarts_after_copy_error(fast_worker, monkeypatch):
|
||||||
virsh = FakeVirsh(running=True)
|
virsh = FakeVirsh(running=True)
|
||||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||||
@@ -240,6 +367,22 @@ def test_create_backup_refuses_while_another_runs(fast_worker, monkeypatch):
|
|||||||
assert "уже идёт" in str(exc.value)
|
assert "уже идёт" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_backup_in_same_minute_gets_own_folder_and_keeps_first(fast_worker, monkeypatch):
|
||||||
|
monkeypatch.setattr(backups, "datetime", FixedDatetime)
|
||||||
|
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
|
||||||
|
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||||
|
first = core.read_operation(backups.create_backup("web01", "первая")["id"])
|
||||||
|
assert first["status"] == "success" and first["backup_id"] == "20260924-0300"
|
||||||
|
monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1))
|
||||||
|
second = core.read_operation(backups.create_backup("web01", "вторая")["id"])
|
||||||
|
assert second["status"] == "error" and second["backup_id"] == "20260924-0300-2"
|
||||||
|
first_dir = fast_worker["backups"] / "web01" / "20260924-0300"
|
||||||
|
assert (first_dir / "meta.json").exists() and (first_dir / "vda.qcow2").exists() # чужую папку неудачная задача не трогает
|
||||||
|
assert not (fast_worker["backups"] / "web01" / "20260924-0300-2").exists()
|
||||||
|
assert [item["id"] for item in backups.list_backups("web01")] == ["20260924-0300"]
|
||||||
|
assert backups.backup_path("web01", "20260924-0300-2") == fast_worker["backups"] / "web01" / "20260924-0300-2"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- восстановление
|
# ---------------------------------------------------------------- восстановление
|
||||||
def test_restore_under_new_name(fast_worker, monkeypatch):
|
def test_restore_under_new_name(fast_worker, monkeypatch):
|
||||||
make_backup(fast_worker)
|
make_backup(fast_worker)
|
||||||
@@ -252,11 +395,13 @@ def test_restore_under_new_name(fast_worker, monkeypatch):
|
|||||||
assert fresh["status"] == "success", fresh["message"]
|
assert fresh["status"] == "success", fresh["message"]
|
||||||
assert fresh["vm_name"] == "web02"
|
assert fresh["vm_name"] == "web02"
|
||||||
target = fast_worker["images"] / "web02.qcow2"
|
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 stream.calls[0][:6] == ["qemu-img", "convert", "-p", "-O", "qcow2", str(fast_worker["backups"] / "web01" / "20260924-0300" / "vda.qcow2")]
|
||||||
assert target.exists()
|
assert stream.calls[0][6].startswith(str(fast_worker["images"] / "web02.restore-")) # сначала во временный файл
|
||||||
|
assert target.exists() and not list(fast_worker["images"].glob("*.restore-*"))
|
||||||
assert "<name>web02</name>" in virsh.defined_xml and str(target) in virsh.defined_xml and "<mac" not in virsh.defined_xml
|
assert "<name>web02</name>" in virsh.defined_xml and str(target) in virsh.defined_xml and "<mac" not in virsh.defined_xml
|
||||||
assert not any(cmd[:2] == ["virsh", "undefine"] for cmd in virsh.calls)
|
assert not any(cmd[:2] == ["virsh", "undefine"] for cmd in virsh.calls)
|
||||||
assert not list((fast_worker["backups"] / "web01" / "20260924-0300").glob("restore-*.xml"))
|
assert not list((fast_worker["backups"] / "web01" / "20260924-0300").glob("restore-*.xml"))
|
||||||
|
assert not (fast_worker["backups"] / "web01" / "20260924-0300" / backups.RESTORE_MARKER).exists()
|
||||||
|
|
||||||
|
|
||||||
def test_restore_requires_replace_confirmation(fast_worker):
|
def test_restore_requires_replace_confirmation(fast_worker):
|
||||||
@@ -266,18 +411,48 @@ def test_restore_requires_replace_confirmation(fast_worker):
|
|||||||
assert exc.value.code == "exists"
|
assert exc.value.code == "exists"
|
||||||
|
|
||||||
|
|
||||||
def test_restore_replaces_existing_vm(fast_worker, monkeypatch):
|
UNDEFINE = ["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_replaces_existing_vm_only_after_disks_are_ready(fast_worker, monkeypatch):
|
||||||
make_backup(fast_worker)
|
make_backup(fast_worker)
|
||||||
virsh = FakeVirsh(running=True)
|
virsh = FakeVirsh(running=True)
|
||||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
monkeypatch.setattr(backups, "stream_cmd", fake_stream(calls=virsh.calls))
|
||||||
operation = backups.start_restore("web01", "20260924-0300", "", True)
|
operation = backups.start_restore("web01", "20260924-0300", "", True)
|
||||||
fresh = core.read_operation(operation["id"])
|
fresh = core.read_operation(operation["id"])
|
||||||
assert fresh["status"] == "success", fresh["message"]
|
assert fresh["status"] == "success", fresh["message"]
|
||||||
assert ["virsh", "destroy", "web01"] in virsh.calls
|
convert = next(index for index, cmd in enumerate(virsh.calls) if cmd[:2] == ["qemu-img", "convert"])
|
||||||
assert ["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"] in virsh.calls
|
assert convert < virsh.calls.index(["virsh", "destroy", "web01"]) < virsh.calls.index(UNDEFINE)
|
||||||
assert virsh.calls.index(["virsh", "destroy", "web01"]) < virsh.calls.index(["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"])
|
assert virsh.calls.index(UNDEFINE) < next(index for index, cmd in enumerate(virsh.calls) if cmd[:2] == ["virsh", "define"])
|
||||||
|
assert (fast_worker["images"] / "web01.qcow2").exists() and not list(fast_worker["images"].glob("*.restore-*"))
|
||||||
assert "<name>web01</name>" in virsh.defined_xml
|
assert "<name>web01</name>" in virsh.defined_xml
|
||||||
|
# при замене машина остаётся «той же»: UUID и MAC из копии сохраняются
|
||||||
|
assert "<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>" in virsh.defined_xml and "52:54:00:aa:bb:cc" in virsh.defined_xml
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_restore_keeps_old_vm_when_copy_fails(fast_worker, monkeypatch):
|
||||||
|
make_backup(fast_worker)
|
||||||
|
virsh = FakeVirsh(running=True)
|
||||||
|
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||||
|
monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1))
|
||||||
|
operation = backups.start_restore("web01", "20260924-0300", "web01", True)
|
||||||
|
fresh = core.read_operation(operation["id"])
|
||||||
|
assert fresh["status"] == "error" and "Не удалось восстановить диск vda" in fresh["message"] and "qemu-img" not in fresh["message"]
|
||||||
|
assert "qemu-img завершился с кодом 1" in fresh["log_tail"]
|
||||||
|
assert ["virsh", "destroy", "web01"] not in virsh.calls and UNDEFINE not in virsh.calls and virsh.running
|
||||||
|
assert not list(fast_worker["images"].iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_refuses_when_space_is_tight_even_if_old_vm_would_free_it(fast_worker, monkeypatch):
|
||||||
|
make_backup(fast_worker)
|
||||||
|
monkeypatch.setattr(backups, "free_bytes", lambda path: 1024)
|
||||||
|
with pytest.raises(backups.BackupError) as exc:
|
||||||
|
backups.start_restore("web01", "20260924-0300", "web01", True)
|
||||||
|
assert exc.value.code == "space" and "рядом со старой машиной" in str(exc.value)
|
||||||
|
with pytest.raises(backups.BackupError) as exc:
|
||||||
|
backups.start_restore("web01", "20260924-0300", "web02", False)
|
||||||
|
assert exc.value.code == "space" and "рядом со старой машиной" not in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch):
|
def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch):
|
||||||
@@ -296,7 +471,7 @@ def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch):
|
|||||||
backups.start_restore("web01", "20260924-0300", "web01", True)
|
backups.start_restore("web01", "20260924-0300", "web01", True)
|
||||||
eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"]
|
eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"]
|
||||||
assert eject in virsh.calls
|
assert eject in virsh.calls
|
||||||
assert virsh.calls.index(eject) < virsh.calls.index(["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"])
|
assert virsh.calls.index(eject) < virsh.calls.index(UNDEFINE)
|
||||||
|
|
||||||
|
|
||||||
def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker):
|
def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker):
|
||||||
@@ -325,7 +500,7 @@ def test_restore_cleans_up_on_define_failure(fast_worker, monkeypatch):
|
|||||||
operation = backups.start_restore("web01", "20260924-0300", "web02", False)
|
operation = backups.start_restore("web01", "20260924-0300", "web02", False)
|
||||||
fresh = core.read_operation(operation["id"])
|
fresh = core.read_operation(operation["id"])
|
||||||
assert fresh["status"] == "error" and "bad thing" in fresh["message"]
|
assert fresh["status"] == "error" and "bad thing" in fresh["message"]
|
||||||
assert not (fast_worker["images"] / "web02.qcow2").exists()
|
assert not (fast_worker["images"] / "web02.qcow2").exists() and not list(fast_worker["images"].glob("*.restore-*"))
|
||||||
|
|
||||||
|
|
||||||
def test_restore_disk_path_avoids_collisions(data_dirs):
|
def test_restore_disk_path_avoids_collisions(data_dirs):
|
||||||
@@ -391,6 +566,7 @@ def test_restore_page_and_submit(logged_in, fast_worker, monkeypatch):
|
|||||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||||
html = logged_in.get("/backups/web01/20260924-0300/restore").text
|
html = logged_in.get("/backups/web01/20260924-0300/restore").text
|
||||||
assert 'name="replace"' in html and "Заменить существующую машину web01" in html
|
assert 'name="replace"' in html and "Заменить существующую машину web01" in html
|
||||||
|
assert "Старая машина удаляется только после распаковки" in html and "Сетевые адреса сохраняются" in html
|
||||||
response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web01", "replace": ""})
|
response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web01", "replace": ""})
|
||||||
assert response.status_code == 400 and "уже есть на сервере" in response.text
|
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)
|
response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web02", "replace": ""}, follow_redirects=False)
|
||||||
@@ -413,6 +589,34 @@ def test_delete_backup_path_traversal(logged_in, data_dirs, tmp_path):
|
|||||||
victim = tmp_path / "victim"
|
victim = tmp_path / "victim"
|
||||||
victim.mkdir()
|
victim.mkdir()
|
||||||
(victim / "20260924-0300").mkdir()
|
(victim / "20260924-0300").mkdir()
|
||||||
logged_in.post("/backups/..%2Fvictim/20260924-0300/delete", follow_redirects=False)
|
# %2F раскрывается до маршрутизации: лишние сегменты не подходят ни под один маршрут
|
||||||
logged_in.post("/backups/web01/..%2F..%2Fvictim/delete", follow_redirects=False)
|
assert logged_in.post("/backups/..%2Fvictim/20260924-0300/delete", follow_redirects=False).status_code == 404
|
||||||
|
assert logged_in.post("/backups/web01/..%2F..%2Fvictim/delete", follow_redirects=False).status_code == 404
|
||||||
|
# а сама проверка путей — в delete_backup, независимо от маршрутизации
|
||||||
|
with pytest.raises(backups.BackupError):
|
||||||
|
backups.delete_backup("../victim", "20260924-0300")
|
||||||
|
with pytest.raises(backups.BackupError):
|
||||||
|
backups.delete_backup("web01", "../x")
|
||||||
assert (victim / "20260924-0300").exists()
|
assert (victim / "20260924-0300").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_backup_refused_while_copy_is_in_use(fast_worker, monkeypatch):
|
||||||
|
monkeypatch.setattr(core, "run_operation", lambda operation, worker: None) # задачи остаются в очереди
|
||||||
|
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
|
||||||
|
queued = backups.create_backup("web01")
|
||||||
|
with pytest.raises(backups.BackupError) as exc:
|
||||||
|
backups.delete_backup("web01", queued["backup_id"])
|
||||||
|
assert "идёт работа" in str(exc.value)
|
||||||
|
core.finish_operation(queued, False, "прервано")
|
||||||
|
backups.delete_backup("web01", queued["backup_id"])
|
||||||
|
assert not (fast_worker["backups"] / "web01").exists()
|
||||||
|
|
||||||
|
path = make_backup(fast_worker)
|
||||||
|
restore = backups.start_restore("web01", "20260924-0300", "web02", False) # под другим именем: задача записана на web02
|
||||||
|
assert (path / backups.RESTORE_MARKER).read_text() == restore["id"]
|
||||||
|
with pytest.raises(backups.BackupError) as exc:
|
||||||
|
backups.delete_backup("web01", "20260924-0300")
|
||||||
|
assert "идёт работа" in str(exc.value)
|
||||||
|
core.finish_operation(restore, False, "прервано") # устаревшая метка после перезапуска панели не мешает
|
||||||
|
backups.delete_backup("web01", "20260924-0300")
|
||||||
|
assert not path.exists()
|
||||||
|
|||||||
+236
-81
@@ -5,7 +5,8 @@
|
|||||||
восстановление выполняются как фоновые операции с прогрессом и журналом.
|
восстановление выполняются как фоновые операции с прогрессом и журналом.
|
||||||
|
|
||||||
`create_backup(vm_name, note)` можно вызывать без HTTP — например, из
|
`create_backup(vm_name, note)` можно вызывать без HTTP — например, из
|
||||||
расписания: `python3 web/features/backups.py web01 "ночная копия"`.
|
расписания на сервере (зависимости панели лежат в её virtualenv):
|
||||||
|
`/opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия"`.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -13,6 +14,7 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -30,12 +32,19 @@ import presenters # noqa: E402
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
BACKUP_ID_RE = re.compile(r"\d{8}-\d{4}")
|
BACKUP_ID_RE = re.compile(r"\d{8}-\d{4}(?:-\d{1,2})?") # ГГГГММДД-ЧЧММ, при второй копии в ту же минуту — суффикс -2, -3…
|
||||||
|
TARGET_RE = re.compile(r"[a-z]{2,3}[a-z0-9]{0,8}") # имя диска в meta.json: vda, sdb, hda…
|
||||||
|
OPERATION_TYPES = ("backup", "restore")
|
||||||
SHUTDOWN_TIMEOUT = 180.0 # секунд ждём мягкого выключения
|
SHUTDOWN_TIMEOUT = 180.0 # секунд ждём мягкого выключения
|
||||||
|
LATE_SHUTDOWN_WATCH = 600.0 # секунд после отказа ещё следим: если машина выключится позже, запустим её снова
|
||||||
|
SAVE_TIMEOUT = 1800 # секунд на сохранение памяти машины на паузе
|
||||||
|
COPY_TIMEOUT = 12 * 3600.0 # секунд на копирование одного диска — дольше только зависший qemu-img
|
||||||
POLL_INTERVAL = 2.0
|
POLL_INTERVAL = 2.0
|
||||||
SPACE_MARGIN = 256 * 1024 * 1024 # запас, чтобы не забить диск под завязку
|
SPACE_MARGIN = 256 * 1024 * 1024 # запас, чтобы не забить диск под завязку
|
||||||
NOTE_MAX = 120
|
NOTE_MAX = 120
|
||||||
|
RESTORE_MARKER = ".restoring" # файл в папке копии с id задачи восстановления, которая её читает
|
||||||
MONTHS = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря")
|
MONTHS = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря")
|
||||||
|
BACKUP_ID_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
class BackupError(Exception):
|
class BackupError(Exception):
|
||||||
@@ -51,29 +60,48 @@ def run(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
|
|||||||
return core.run_cmd(cmd, timeout=timeout)
|
return core.run_cmd(cmd, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
def stream_cmd(cmd: list[str], on_line: Callable[[str], None]) -> int:
|
def stream_cmd(cmd: list[str], on_line: Callable[[str], None], timeout: float = COPY_TIMEOUT) -> int:
|
||||||
"""Запустить команду и отдавать каждую строку вывода (по \\r и \\n) в on_line.
|
"""Запустить команду и отдавать каждую строку вывода (по \\r и \\n) в on_line.
|
||||||
|
|
||||||
qemu-img с ключом -p печатает прогресс через \\r, поэтому обычное чтение
|
qemu-img с ключом -p печатает прогресс через \\r, поэтому обычное чтение
|
||||||
по строкам не подходит. Возвращает код завершения."""
|
по строкам не подходит. Команду, не уложившуюся в timeout, убиваем, чтобы
|
||||||
|
зависший qemu-img не держал задачу «в работе» вечно. Возвращает код завершения."""
|
||||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||||
|
expired = threading.Event()
|
||||||
|
|
||||||
|
def kill() -> None:
|
||||||
|
expired.set()
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
timer = threading.Timer(timeout, kill)
|
||||||
|
timer.start()
|
||||||
pending = b""
|
pending = b""
|
||||||
assert process.stdout is not None
|
assert process.stdout is not None
|
||||||
while True:
|
try:
|
||||||
chunk = process.stdout.read(512)
|
while True:
|
||||||
if not chunk:
|
chunk = process.stdout.read(512)
|
||||||
break
|
if not chunk:
|
||||||
pending += chunk
|
break
|
||||||
parts = re.split(rb"[\r\n]+", pending)
|
pending += chunk
|
||||||
pending = parts.pop()
|
parts = re.split(rb"[\r\n]+", pending)
|
||||||
for part in parts:
|
pending = parts.pop()
|
||||||
text = part.decode("utf-8", "replace").strip()
|
for part in parts:
|
||||||
if text:
|
text = part.decode("utf-8", "replace").strip()
|
||||||
on_line(text)
|
if text:
|
||||||
tail = pending.decode("utf-8", "replace").strip()
|
on_line(text)
|
||||||
if tail:
|
tail = pending.decode("utf-8", "replace").strip()
|
||||||
on_line(tail)
|
if tail:
|
||||||
return process.wait()
|
on_line(tail)
|
||||||
|
code = process.wait()
|
||||||
|
finally:
|
||||||
|
timer.cancel()
|
||||||
|
if expired.is_set():
|
||||||
|
on_line(f"Команда не завершилась за отведённое время ({int(timeout)} с) и была остановлена.")
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def start_thread(target: Callable[..., None], *args: Any) -> None:
|
||||||
|
threading.Thread(target=target, args=args, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def parse_progress(line: str) -> int | None:
|
def parse_progress(line: str) -> int | None:
|
||||||
@@ -98,8 +126,11 @@ def parse_qemu_img_info(text: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def disk_info(path: str) -> dict[str, Any]:
|
def disk_info(path: str) -> dict[str, Any]:
|
||||||
result = run(["qemu-img", "info", "--output=json", path], timeout=30)
|
# -U: диск работающей машины занят QEMU, без этого ключа qemu-img info отказывает («Failed to get shared write lock»).
|
||||||
return parse_qemu_img_info(result["stdout"]) if result["ok"] else parse_qemu_img_info("")
|
result = run(["qemu-img", "info", "-U", "--output=json", path], timeout=30)
|
||||||
|
if not result["ok"]:
|
||||||
|
raise BackupError(f"Не удалось определить размер диска {path}: " + core.cmd_error(result, "qemu-img info"))
|
||||||
|
return parse_qemu_img_info(result["stdout"])
|
||||||
|
|
||||||
|
|
||||||
def vm_disks(name: str) -> list[dict[str, str]]:
|
def vm_disks(name: str) -> list[dict[str, str]]:
|
||||||
@@ -117,12 +148,24 @@ def vm_cdroms_with_media(name: str) -> list[dict[str, str]]:
|
|||||||
return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "cdrom" and disk["source"]]
|
return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "cdrom" and disk["source"]]
|
||||||
|
|
||||||
|
|
||||||
def free_bytes(path: Path) -> int:
|
def existing_parent(path: Path) -> Path:
|
||||||
|
"""Сам путь или ближайший существующий родитель — чтобы спросить у ФС о разделе."""
|
||||||
target = path
|
target = path
|
||||||
while not target.exists() and target != target.parent:
|
while not target.exists() and target != target.parent:
|
||||||
target = target.parent
|
target = target.parent
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def free_bytes(path: Path) -> int:
|
||||||
try:
|
try:
|
||||||
return shutil.disk_usage(target).free
|
return shutil.disk_usage(existing_parent(path)).free
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def total_bytes(path: Path) -> int:
|
||||||
|
try:
|
||||||
|
return shutil.disk_usage(existing_parent(path)).total
|
||||||
except OSError:
|
except OSError:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -133,7 +176,7 @@ def format_bytes(value: int | float) -> str:
|
|||||||
|
|
||||||
def format_backup_date(backup_id: str) -> str:
|
def format_backup_date(backup_id: str) -> str:
|
||||||
try:
|
try:
|
||||||
moment = datetime.strptime(backup_id, "%Y%m%d-%H%M")
|
moment = datetime.strptime((backup_id or "")[:13], "%Y%m%d-%H%M")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return backup_id
|
return backup_id
|
||||||
return f"{moment.day} {MONTHS[moment.month - 1]} {moment.year}, {moment:%H:%M}"
|
return f"{moment.day} {MONTHS[moment.month - 1]} {moment.year}, {moment:%H:%M}"
|
||||||
@@ -167,9 +210,12 @@ def backup_path(vm: str, backup_id: str) -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def describe_backup(vm: str, backup_id: str, path: Path, meta: dict[str, Any]) -> dict[str, Any]:
|
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")]
|
listed = meta.get("disks")
|
||||||
|
listed = [disk for disk in listed if isinstance(disk, dict)] if isinstance(listed, list) else []
|
||||||
|
# Имя диска попадает в пути файлов, поэтому испорченный meta.json не должен увести за пределы папок.
|
||||||
|
disks = [disk for disk in listed if TARGET_RE.fullmatch(str(disk.get("target") or ""))]
|
||||||
size = int(meta.get("size_bytes") or 0)
|
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)
|
complete = (path / "vm.xml").exists() and bool(disks) and len(disks) == len(listed) and all((path / f"{disk['target']}.qcow2").exists() for disk in disks)
|
||||||
return {
|
return {
|
||||||
"vm": vm,
|
"vm": vm,
|
||||||
"id": backup_id,
|
"id": backup_id,
|
||||||
@@ -237,7 +283,7 @@ def storage_summary(items: list[dict[str, Any]] | None = None) -> dict[str, Any]
|
|||||||
items = list_backups() if items is None else items
|
items = list_backups() if items is None else items
|
||||||
used = sum(item["size_bytes"] for item in items)
|
used = sum(item["size_bytes"] for item in items)
|
||||||
free = free_bytes(backup_root())
|
free = free_bytes(backup_root())
|
||||||
total = used + free
|
total = total_bytes(backup_root()) # доля именно от всего диска сервера, как и написано на странице
|
||||||
return {
|
return {
|
||||||
"count": len(items),
|
"count": len(items),
|
||||||
"used_bytes": used,
|
"used_bytes": used,
|
||||||
@@ -259,22 +305,50 @@ def remove_backup_dir(path: Path) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def backup_in_use(vm: str, backup_id: str, path: Path) -> bool:
|
||||||
|
"""Пишет ли сейчас эту копию задача копирования или читает задача восстановления."""
|
||||||
|
if any(operation.get("backup_id") == backup_id for operation in core.active_operations_for(vm, OPERATION_TYPES)):
|
||||||
|
return True
|
||||||
|
# Восстановление под другим именем записано на новую машину, поэтому его id лежит меткой в папке копии.
|
||||||
|
try:
|
||||||
|
marker = (path / RESTORE_MARKER).read_text().strip()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
operation = core.read_operation(marker)
|
||||||
|
return bool(operation and operation.get("status") in ("queued", "running"))
|
||||||
|
|
||||||
|
|
||||||
def delete_backup(vm: str, backup_id: str) -> None:
|
def delete_backup(vm: str, backup_id: str) -> None:
|
||||||
path = backup_path(vm, backup_id)
|
path = backup_path(vm, backup_id)
|
||||||
if not path or not path.is_dir():
|
if not path or not path.is_dir():
|
||||||
raise BackupError("Копия не найдена — возможно, её уже удалили")
|
raise BackupError("Копия не найдена — возможно, её уже удалили")
|
||||||
for operation in core.running_operations():
|
if backup_in_use(vm, backup_id, path):
|
||||||
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("С этой копией сейчас идёт работа — дождитесь завершения задачи")
|
||||||
raise BackupError("С этой копией сейчас идёт работа — дождитесь завершения задачи")
|
|
||||||
remove_backup_dir(path)
|
remove_backup_dir(path)
|
||||||
|
|
||||||
|
|
||||||
def active_operation(vm: str) -> dict[str, Any] | None:
|
def active_operation(vm: str) -> dict[str, Any] | None:
|
||||||
"""Идущая сейчас задача копирования/восстановления, связанная с машиной."""
|
"""Идущая сейчас задача копирования/восстановления этой машины (по всей папке задач, а не последним N)."""
|
||||||
for operation in core.running_operations():
|
found = core.active_operations_for(vm, OPERATION_TYPES)
|
||||||
if operation.get("type") in ("backup", "restore") and vm in (operation.get("vm_name"), operation.get("source_vm")):
|
return found[0] if found else None
|
||||||
return operation
|
|
||||||
return None
|
|
||||||
|
def reserve_backup_dir(vm_name: str) -> str:
|
||||||
|
"""Занять папку под новую копию и вернуть её id.
|
||||||
|
|
||||||
|
Папка создаётся здесь, атомарно и под замком, чтобы две задачи в одну минуту
|
||||||
|
получили разные папки (вторая — с суффиксом -2) и при ошибке каждая удаляла
|
||||||
|
только свою."""
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M")
|
||||||
|
with BACKUP_ID_LOCK:
|
||||||
|
for counter in range(1, 100):
|
||||||
|
backup_id = stamp if counter == 1 else f"{stamp}-{counter}"
|
||||||
|
try:
|
||||||
|
(backup_root() / vm_name / backup_id).mkdir(parents=True, exist_ok=False)
|
||||||
|
except FileExistsError:
|
||||||
|
continue
|
||||||
|
return backup_id
|
||||||
|
raise BackupError("Слишком много копий этой машины за одну минуту. Подождите минуту и повторите.")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- создание копии
|
# ---------------------------------------------------------------- создание копии
|
||||||
@@ -304,10 +378,12 @@ def create_backup(vm_name: str, note: str = "", wait: bool = False) -> dict[str,
|
|||||||
free = free_bytes(backup_root())
|
free = free_bytes(backup_root())
|
||||||
if free < need + SPACE_MARGIN:
|
if free < need + SPACE_MARGIN:
|
||||||
raise BackupError(f"Недостаточно места для копии: нужно около {format_bytes(need)}, свободно {format_bytes(free)}. Удалите старые копии или освободите место на сервере.", "space")
|
raise BackupError(f"Недостаточно места для копии: нужно около {format_bytes(need)}, свободно {format_bytes(free)}. Удалите старые копии или освободите место на сервере.", "space")
|
||||||
backup_id = datetime.now().strftime("%Y%m%d-%H%M")
|
backup_id = reserve_backup_dir(vm_name)
|
||||||
if (backup_root() / vm_name / backup_id).exists():
|
try:
|
||||||
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)
|
||||||
operation = core.new_operation("backup", f"Резервная копия {vm_name}", "Ожидает начала", vm_name=vm_name, backup_id=backup_id, note=note, disks=plan, size_bytes=need)
|
except Exception:
|
||||||
|
remove_backup_dir(backup_root() / vm_name / backup_id)
|
||||||
|
raise
|
||||||
if wait:
|
if wait:
|
||||||
core.update_operation(operation, status="running", started_at=core.utc_now())
|
core.update_operation(operation, status="running", started_at=core.utc_now())
|
||||||
backup_worker(operation)
|
backup_worker(operation)
|
||||||
@@ -337,14 +413,16 @@ def disk_progress_reporter(operation: dict[str, Any], index: int, total: int, la
|
|||||||
return on_line
|
return on_line
|
||||||
|
|
||||||
|
|
||||||
def shutdown_and_wait(operation: dict[str, Any], vm: str) -> bool:
|
def shutdown_and_wait(operation: dict[str, Any], vm: str) -> str:
|
||||||
|
"""Мягко выключить машину. Возвращает пустую строку или текст проблемы для пользователя."""
|
||||||
log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731
|
log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731
|
||||||
log("Машина работает — отправляем команду выключения (как кнопка питания, система завершит работу сама).")
|
log("Машина работает — отправляем команду выключения (как кнопка питания, система завершит работу сама).")
|
||||||
core.update_operation(operation, progress=2, message="Выключаем машину…")
|
core.update_operation(operation, progress=2, message="Выключаем машину…")
|
||||||
result = run(["virsh", "shutdown", vm], timeout=20)
|
result = run(["virsh", "shutdown", vm], timeout=20)
|
||||||
if not result["ok"]:
|
if not result["ok"]:
|
||||||
log("Команда выключения не принята: " + core.cmd_error(result, "virsh shutdown"))
|
problem = core.cmd_error(result, "virsh shutdown")
|
||||||
return False
|
log("Команда выключения не принята: " + problem)
|
||||||
|
return f"Машина не приняла команду выключения: {problem}. Выключите её вручную и создайте копию ещё раз."
|
||||||
deadline = time.monotonic() + SHUTDOWN_TIMEOUT
|
deadline = time.monotonic() + SHUTDOWN_TIMEOUT
|
||||||
polls = 0
|
polls = 0
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
@@ -352,12 +430,54 @@ def shutdown_and_wait(operation: dict[str, Any], vm: str) -> bool:
|
|||||||
polls += 1
|
polls += 1
|
||||||
if not core.vm_is_running(vm):
|
if not core.vm_is_running(vm):
|
||||||
log("Машина выключена.")
|
log("Машина выключена.")
|
||||||
return True
|
return ""
|
||||||
if polls % 5 == 0:
|
if polls % 5 == 0:
|
||||||
waited = int(polls * POLL_INTERVAL)
|
waited = int(polls * POLL_INTERVAL)
|
||||||
core.update_operation(operation, message=f"Ждём выключения машины… {waited} с из {int(SHUTDOWN_TIMEOUT)}")
|
core.update_operation(operation, message=f"Ждём выключения машины… {waited} с из {int(SHUTDOWN_TIMEOUT)}")
|
||||||
log(f"Всё ещё работает, ждём ({waited} с).")
|
log(f"Всё ещё работает, ждём ({waited} с).")
|
||||||
return False
|
minutes = max(1, int(SHUTDOWN_TIMEOUT // 60))
|
||||||
|
watch = max(1, int(LATE_SHUTDOWN_WATCH // 60))
|
||||||
|
log(f"Машина не выключилась за {minutes} {presenters.plural(minutes, 'минуту', 'минуты', 'минут')} — прекращаем, но ещё {watch} {presenters.plural(watch, 'минуту', 'минуты', 'минут')} следим за ней.")
|
||||||
|
start_thread(restart_after_late_shutdown, operation, vm)
|
||||||
|
return (
|
||||||
|
f"Машина не выключилась за {minutes} {presenters.plural(minutes, 'минуту', 'минуты', 'минут')}. Сохраните работу внутри неё, выключите её вручную и создайте копию ещё раз. "
|
||||||
|
f"Команда выключения уже отправлена: если машина выключится сама в ближайшие {watch} {presenters.plural(watch, 'минуту', 'минуты', 'минут')}, панель запустит её снова."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def restart_after_late_shutdown(operation: dict[str, Any], vm: str) -> None:
|
||||||
|
"""Команда выключения уже ушла в машину: если она всё же выключится с опозданием, вернуть её в работу."""
|
||||||
|
deadline = time.monotonic() + LATE_SHUTDOWN_WATCH
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
if not core.vm_is_running(vm):
|
||||||
|
core.append_operation_log(operation["id"], "Машина всё-таки выключилась — запускаем её снова.")
|
||||||
|
start_vm(operation, vm)
|
||||||
|
return
|
||||||
|
core.append_operation_log(operation["id"], "Машина так и не выключилась — оставляем её работать.")
|
||||||
|
|
||||||
|
|
||||||
|
def save_paused(operation: dict[str, Any], vm: str) -> str:
|
||||||
|
"""Машина на паузе не обработает команду выключения — сохраняем её память на диск (как спящий режим).
|
||||||
|
|
||||||
|
После копирования `virsh start` восстановит её с того же места. Возвращает пустую строку или текст проблемы."""
|
||||||
|
log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731
|
||||||
|
log("Машина на паузе — сохраняем её состояние на диск сервера, после копирования она продолжит с того же места.")
|
||||||
|
core.update_operation(operation, progress=2, message="Сохраняем состояние машины…")
|
||||||
|
result = run(["virsh", "managedsave", vm], timeout=SAVE_TIMEOUT)
|
||||||
|
if not result["ok"]:
|
||||||
|
problem = core.cmd_error(result, "virsh managedsave")
|
||||||
|
log("Не удалось сохранить состояние: " + problem)
|
||||||
|
return f"Машина на паузе, и сохранить её состояние не удалось: {problem}. Снимите её с паузы или выключите и создайте копию ещё раз."
|
||||||
|
log("Состояние сохранено, машина остановлена.")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def stop_for_backup(operation: dict[str, Any], vm: str) -> str:
|
||||||
|
"""Остановить работающую машину перед копированием; вернуть текст проблемы или пустую строку."""
|
||||||
|
if core.vm_state(vm) == "paused":
|
||||||
|
return save_paused(operation, vm)
|
||||||
|
return shutdown_and_wait(operation, vm)
|
||||||
|
|
||||||
|
|
||||||
def start_vm(operation: dict[str, Any], vm: str) -> str:
|
def start_vm(operation: dict[str, Any], vm: str) -> str:
|
||||||
@@ -375,14 +495,17 @@ def backup_worker(operation: dict[str, Any]) -> None:
|
|||||||
vm = operation["vm_name"]
|
vm = operation["vm_name"]
|
||||||
plan = operation["disks"]
|
plan = operation["disks"]
|
||||||
log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731
|
log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731
|
||||||
|
dest = backup_root() / vm / operation["backup_id"] # папка занята ещё в create_backup, поэтому она точно наша
|
||||||
was_running = core.vm_is_running(vm)
|
was_running = core.vm_is_running(vm)
|
||||||
if was_running and not shutdown_and_wait(operation, vm):
|
if was_running:
|
||||||
core.finish_operation(operation, False, "Машина не выключилась за 3 минуты. Сохраните работу внутри неё, выключите её вручную и создайте копию ещё раз.")
|
problem = stop_for_backup(operation, vm)
|
||||||
return
|
if problem:
|
||||||
dest = backup_root() / vm / operation["backup_id"]
|
remove_backup_dir(dest)
|
||||||
|
core.finish_operation(operation, False, problem)
|
||||||
|
return
|
||||||
size = 0
|
size = 0
|
||||||
try:
|
try:
|
||||||
dest.mkdir(parents=True, exist_ok=False)
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
result = run(["virsh", "dumpxml", vm, "--migratable"], timeout=20)
|
result = run(["virsh", "dumpxml", vm, "--migratable"], timeout=20)
|
||||||
if not result["ok"]:
|
if not result["ok"]:
|
||||||
raise BackupError("Не удалось сохранить настройки машины: " + core.cmd_error(result, "virsh dumpxml"))
|
raise BackupError("Не удалось сохранить настройки машины: " + core.cmd_error(result, "virsh dumpxml"))
|
||||||
@@ -398,7 +521,8 @@ def backup_worker(operation: dict[str, Any]) -> None:
|
|||||||
log(" ".join(cmd))
|
log(" ".join(cmd))
|
||||||
code = stream_cmd(cmd, disk_progress_reporter(operation, index, total, label))
|
code = stream_cmd(cmd, disk_progress_reporter(operation, index, total, label))
|
||||||
if code != 0:
|
if code != 0:
|
||||||
raise BackupError(f"Не удалось скопировать диск {disk['target']} (qemu-img завершился с кодом {code})")
|
log(f"qemu-img завершился с кодом {code}.")
|
||||||
|
raise BackupError(f"Не удалось скопировать диск {disk['target']} — подробности в журнале задачи")
|
||||||
size = sum(item.stat().st_size for item in dest.iterdir() if item.is_file())
|
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", "")}
|
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")
|
(dest / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
@@ -420,12 +544,16 @@ def backup_worker(operation: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- восстановление
|
# ---------------------------------------------------------------- восстановление
|
||||||
def rewrite_xml(xml_text: str, new_name: str, disk_map: dict[str, str]) -> str:
|
def rewrite_xml(xml_text: str, new_name: str, disk_map: dict[str, str], keep_identity: bool = False) -> str:
|
||||||
"""Подготовить XML копии к `virsh define` под именем new_name.
|
"""Подготовить XML копии к `virsh define` под именем new_name.
|
||||||
|
|
||||||
Меняет <name>, убирает <uuid> и id, переключает диски на новые файлы,
|
Меняет <name>, убирает id, переключает диски на новые файлы (без
|
||||||
удаляет <mac> (libvirt выдаст новые адреса) и <nvram> (libvirt заведёт
|
<backingStore> — копии сплошные, а старая цепочка могла указывать на диски
|
||||||
свой файл под новое имя, чтобы не делить его со старой машиной)."""
|
удалённой машины), вынимает из привода образ, которого больше нет на
|
||||||
|
сервере, и удаляет <nvram> (libvirt заведёт свой файл под новое имя).
|
||||||
|
Без keep_identity убирает и <uuid> с <mac>, чтобы вторая машина не мешала
|
||||||
|
оригиналу; при замене оригинала (keep_identity) их лучше оставить — гость
|
||||||
|
не увидит «новую» сетевую карту и не потребует повторной активации."""
|
||||||
root = ET.fromstring(xml_text)
|
root = ET.fromstring(xml_text)
|
||||||
root.attrib.pop("id", None)
|
root.attrib.pop("id", None)
|
||||||
name = root.find("name")
|
name = root.find("name")
|
||||||
@@ -433,16 +561,21 @@ def rewrite_xml(xml_text: str, new_name: str, disk_map: dict[str, str]) -> str:
|
|||||||
name = ET.Element("name")
|
name = ET.Element("name")
|
||||||
root.insert(0, name)
|
root.insert(0, name)
|
||||||
name.text = new_name
|
name.text = new_name
|
||||||
for element in root.findall("uuid"):
|
if not keep_identity:
|
||||||
root.remove(element)
|
for element in root.findall("uuid"):
|
||||||
|
root.remove(element)
|
||||||
devices = root.find("devices")
|
devices = root.find("devices")
|
||||||
if devices is not None:
|
if devices is not None:
|
||||||
for disk in devices.findall("disk"):
|
for disk in devices.findall("disk"):
|
||||||
target = disk.find("target")
|
target = disk.find("target")
|
||||||
dev = target.get("dev") if target is not None else None
|
dev = target.get("dev") if target is not None else None
|
||||||
|
source = disk.find("source")
|
||||||
|
if disk.get("device") == "cdrom":
|
||||||
|
if source is not None and source.get("file") and not Path(source.get("file", "")).is_file():
|
||||||
|
disk.remove(source)
|
||||||
|
continue
|
||||||
if disk.get("device") != "disk" or dev not in disk_map:
|
if disk.get("device") != "disk" or dev not in disk_map:
|
||||||
continue
|
continue
|
||||||
source = disk.find("source")
|
|
||||||
if source is None:
|
if source is None:
|
||||||
source = ET.SubElement(disk, "source")
|
source = ET.SubElement(disk, "source")
|
||||||
source.attrib.clear()
|
source.attrib.clear()
|
||||||
@@ -453,9 +586,12 @@ def rewrite_xml(xml_text: str, new_name: str, disk_map: dict[str, str]) -> str:
|
|||||||
driver = ET.SubElement(disk, "driver")
|
driver = ET.SubElement(disk, "driver")
|
||||||
driver.set("name", "qemu")
|
driver.set("name", "qemu")
|
||||||
driver.set("type", "qcow2")
|
driver.set("type", "qcow2")
|
||||||
for interface in devices.findall("interface"):
|
for chain in disk.findall("backingStore"):
|
||||||
for mac in interface.findall("mac"):
|
disk.remove(chain)
|
||||||
interface.remove(mac)
|
if not keep_identity:
|
||||||
|
for interface in devices.findall("interface"):
|
||||||
|
for mac in interface.findall("mac"):
|
||||||
|
interface.remove(mac)
|
||||||
os_element = root.find("os")
|
os_element = root.find("os")
|
||||||
if os_element is not None:
|
if os_element is not None:
|
||||||
for nvram in os_element.findall("nvram"):
|
for nvram in os_element.findall("nvram"):
|
||||||
@@ -476,6 +612,11 @@ def restore_disk_path(new_name: str, target: str, index: int) -> Path:
|
|||||||
return images / f"{new_name}-{target}-{counter}.qcow2"
|
return images / f"{new_name}-{target}-{counter}.qcow2"
|
||||||
|
|
||||||
|
|
||||||
|
def restore_temp_path(new_name: str, operation_id: str, target: str) -> Path:
|
||||||
|
"""Куда распаковывать диск, пока он не готов: <машина>.restore-<id задачи>-<диск>.qcow2 рядом с остальными."""
|
||||||
|
return core.IMAGES_DIR / f"{new_name}.restore-{operation_id[:8]}-{target}.qcow2"
|
||||||
|
|
||||||
|
|
||||||
def plan_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict[str, Any]:
|
def plan_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict[str, Any]:
|
||||||
backup = read_backup(vm, backup_id)
|
backup = read_backup(vm, backup_id)
|
||||||
if not backup:
|
if not backup:
|
||||||
@@ -491,15 +632,11 @@ def plan_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict[
|
|||||||
if active_operation(new_name) or active_operation(vm):
|
if active_operation(new_name) or active_operation(vm):
|
||||||
raise BackupError("С этой машиной уже идёт копирование или восстановление — дождитесь завершения")
|
raise BackupError("С этой машиной уже идёт копирование или восстановление — дождитесь завершения")
|
||||||
need = sum(int(disk.get("size") or 0) for disk in backup["disks"])
|
need = sum(int(disk.get("size") or 0) for disk in backup["disks"])
|
||||||
reclaim = 0
|
# Диски старой машины не считаем освобождающимися: копия распаковывается рядом с ними, а старая удаляется только потом.
|
||||||
if exists:
|
free = free_bytes(core.IMAGES_DIR)
|
||||||
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:
|
if free < need + SPACE_MARGIN:
|
||||||
raise BackupError(f"Недостаточно места для дисков машины: нужно около {format_bytes(need)}, свободно {format_bytes(free)}.", "space")
|
hint = " Копия сначала распаковывается рядом со старой машиной и только потом заменяет её, поэтому место нужно на обе." if exists else ""
|
||||||
|
raise BackupError(f"Недостаточно места для дисков машины: нужно около {format_bytes(need)}, свободно {format_bytes(free)}.{hint}", "space")
|
||||||
return {"backup": backup, "new_name": new_name, "replace": exists, "need": need}
|
return {"backup": backup, "new_name": new_name, "replace": exists, "need": need}
|
||||||
|
|
||||||
|
|
||||||
@@ -508,6 +645,10 @@ def start_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict
|
|||||||
backup = plan["backup"]
|
backup = plan["backup"]
|
||||||
title = f"Восстановление {plan['new_name']} из копии"
|
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"])
|
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"])
|
||||||
|
try:
|
||||||
|
(Path(backup["path"]) / RESTORE_MARKER).write_text(operation["id"], encoding="utf-8") # чтобы копию не удалили, пока её читают
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
core.run_operation(operation, restore_worker)
|
core.run_operation(operation, restore_worker)
|
||||||
return operation
|
return operation
|
||||||
|
|
||||||
@@ -533,34 +674,45 @@ def remove_vm(operation: dict[str, Any], name: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def restore_worker(operation: dict[str, Any]) -> None:
|
def restore_worker(operation: dict[str, Any]) -> None:
|
||||||
|
"""Сначала распаковать все диски во временные файлы, и только когда они готовы —
|
||||||
|
удалить старую машину (при замене), переименовать диски и зарегистрировать новую.
|
||||||
|
Так ошибка на середине копирования не оставляет пользователя без машины."""
|
||||||
new_name = operation["vm_name"]
|
new_name = operation["vm_name"]
|
||||||
log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731
|
log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731
|
||||||
backup = read_backup(operation["source_vm"], operation["backup_id"])
|
backup = read_backup(operation["source_vm"], operation["backup_id"])
|
||||||
created: list[Path] = []
|
created: list[Path] = [] # файлы, которые появились по вине этой задачи — только их и убираем при ошибке
|
||||||
try:
|
try:
|
||||||
if not backup or not backup["complete"]:
|
if not backup or not backup["complete"]:
|
||||||
raise BackupError("Копия не найдена или неполная")
|
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)
|
core.IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
disk_map: dict[str, str] = {}
|
copies: list[tuple[str, Path]] = []
|
||||||
total = len(backup["disks"])
|
total = len(backup["disks"])
|
||||||
for index, disk in enumerate(backup["disks"]):
|
for index, disk in enumerate(backup["disks"]):
|
||||||
source = Path(backup["path"]) / f"{disk['target']}.qcow2"
|
source = Path(backup["path"]) / f"{disk['target']}.qcow2"
|
||||||
target = restore_disk_path(new_name, disk["target"], index)
|
temp = restore_temp_path(new_name, operation["id"], disk["target"])
|
||||||
created.append(target)
|
created.append(temp)
|
||||||
label = f"Восстанавливаем диск {disk['target']} ({index + 1} из {total})"
|
label = f"Восстанавливаем диск {disk['target']} ({index + 1} из {total})"
|
||||||
core.update_operation(operation, progress=int(5 + index / total * 90), message=label)
|
core.update_operation(operation, progress=int(5 + index / total * 90), message=label)
|
||||||
log(f"{label}: {source} → {target}")
|
log(f"{label}: {source} → {temp}")
|
||||||
cmd = ["qemu-img", "convert", "-p", "-O", "qcow2", str(source), str(target)]
|
cmd = ["qemu-img", "convert", "-p", "-O", "qcow2", str(source), str(temp)]
|
||||||
log(" ".join(cmd))
|
log(" ".join(cmd))
|
||||||
code = stream_cmd(cmd, disk_progress_reporter(operation, index, total, label))
|
code = stream_cmd(cmd, disk_progress_reporter(operation, index, total, label))
|
||||||
if code != 0:
|
if code != 0:
|
||||||
raise BackupError(f"Не удалось восстановить диск {disk['target']} (qemu-img завершился с кодом {code})")
|
log(f"qemu-img завершился с кодом {code}.")
|
||||||
disk_map[disk["target"]] = str(target)
|
raise BackupError(f"Не удалось восстановить диск {disk['target']} — подробности в журнале задачи")
|
||||||
|
copies.append((disk["target"], temp))
|
||||||
|
if operation.get("replace"):
|
||||||
|
core.update_operation(operation, progress=95, message=f"Диски готовы — удаляем существующую машину {new_name}…")
|
||||||
|
remove_vm(operation, new_name)
|
||||||
|
disk_map: dict[str, str] = {}
|
||||||
|
for index, (target, temp) in enumerate(copies):
|
||||||
|
final = restore_disk_path(new_name, target, index)
|
||||||
|
temp.replace(final)
|
||||||
|
created[created.index(temp)] = final
|
||||||
|
disk_map[target] = str(final)
|
||||||
|
log(f"Диск {target} готов: {final}")
|
||||||
core.update_operation(operation, progress=96, message="Регистрируем машину…")
|
core.update_operation(operation, progress=96, message="Регистрируем машину…")
|
||||||
xml = rewrite_xml((Path(backup["path"]) / "vm.xml").read_text(encoding="utf-8"), new_name, disk_map)
|
xml = rewrite_xml((Path(backup["path"]) / "vm.xml").read_text(encoding="utf-8"), new_name, disk_map, keep_identity=bool(operation.get("replace")))
|
||||||
with tempfile.NamedTemporaryFile("w", dir=backup["path"], prefix="restore-", suffix=".xml", delete=False, encoding="utf-8") as handle:
|
with tempfile.NamedTemporaryFile("w", dir=backup["path"], prefix="restore-", suffix=".xml", delete=False, encoding="utf-8") as handle:
|
||||||
handle.write(xml)
|
handle.write(xml)
|
||||||
tmp_xml = Path(handle.name)
|
tmp_xml = Path(handle.name)
|
||||||
@@ -578,6 +730,9 @@ def restore_worker(operation: dict[str, Any]) -> None:
|
|||||||
log(f"Ошибка: {exc}")
|
log(f"Ошибка: {exc}")
|
||||||
core.finish_operation(operation, False, str(exc)[:240])
|
core.finish_operation(operation, False, str(exc)[:240])
|
||||||
return
|
return
|
||||||
|
finally:
|
||||||
|
if backup:
|
||||||
|
(Path(backup["path"]) / RESTORE_MARKER).unlink(missing_ok=True)
|
||||||
created_label = backup["created_label"] if backup else ""
|
created_label = backup["created_label"] if backup else ""
|
||||||
core.finish_operation(operation, True, f"Машина {new_name} восстановлена из копии от {created_label}. Она выключена — запустите её, когда будете готовы.")
|
core.finish_operation(operation, True, f"Машина {new_name} восстановлена из копии от {created_label}. Она выключена — запустите её, когда будете готовы.")
|
||||||
|
|
||||||
@@ -681,7 +836,7 @@ def vm_backup_create(request: Request, name: str, note: str = Form("")):
|
|||||||
return RedirectResponse(url=f"/operations/{operation['id']}", status_code=303)
|
return RedirectResponse(url=f"/operations/{operation['id']}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # python3 web/features/backups.py <машина> [заметка]
|
if __name__ == "__main__": # /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py <машина> [заметка]
|
||||||
try:
|
try:
|
||||||
finished = create_backup(sys.argv[1], " ".join(sys.argv[2:]), wait=True)
|
finished = create_backup(sys.argv[1], " ".join(sys.argv[2:]), wait=True)
|
||||||
except (IndexError, BackupError) as error:
|
except (IndexError, BackupError) as error:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
{% if b.complete %}
|
{% if b.complete %}
|
||||||
<a class="btn btn-sm" href="/backups/{{ b.vm }}/{{ b.id }}/restore">{{ icon('rotate-cw', 'icon-sm') }}<span class="hide-sm">Восстановить</span></a>
|
<a class="btn btn-sm" href="/backups/{{ b.vm }}/{{ b.id }}/restore">{{ icon('rotate-cw', 'icon-sm') }}<span class="hide-sm">Восстановить</span></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="/backups/{{ b.vm }}/{{ b.id }}/delete" class="inline-form" data-confirm="Копия машины {{ b.vm }} от {{ b.created_label }} ({{ b.size }}) будет удалена. Сама машина не пострадает." data-confirm-title="Удалить копию?" data-confirm-button="Удалить" data-toast="Копия удалена" data-toast-tone="warning">
|
<form method="post" action="/backups/{{ b.vm }}/{{ b.id }}/delete" class="inline-form" data-confirm="Копия машины {{ b.vm }} от {{ b.created_label }} ({{ b.size }}) будет удалена. Сама машина не пострадает." data-confirm-title="Удалить копию?" data-confirm-button="Удалить">
|
||||||
<input type="hidden" name="next" value="{{ back }}">
|
<input type="hidden" name="next" value="{{ back }}">
|
||||||
<button class="btn btn-sm btn-icon btn-ghost" aria-label="Удалить копию" title="Удалить копию">{{ icon('trash-2', 'icon-sm') }}</button>
|
<button class="btn btn-sm btn-icon btn-ghost" aria-label="Удалить копию" title="Удалить копию">{{ icon('trash-2', 'icon-sm') }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<div class="alert warning">{{ icon('triangle-alert') }}<div><strong>Машина {{ name }} уже есть на сервере</strong>Чтобы восстановить копию под этим названием, существующую машину придётся удалить вместе с её дисками. Если хотите сохранить обе — укажите другое название.</div></div>
|
<div class="alert warning">{{ icon('triangle-alert') }}<div><strong>Машина {{ name }} уже есть на сервере</strong>Чтобы восстановить копию под этим названием, существующую машину придётся удалить вместе с её дисками. Если хотите сохранить обе — укажите другое название.</div></div>
|
||||||
<label class="check danger">
|
<label class="check danger">
|
||||||
<input type="checkbox" name="replace" value="1" required>
|
<input type="checkbox" name="replace" value="1" required>
|
||||||
<span><strong>Заменить существующую машину {{ name }}</strong><span>Она будет выключена и удалена вместе с дисками, а на её место встанет копия.</span></span>
|
<span><strong>Заменить существующую машину {{ name }}</strong><span>Когда диски копии будут распакованы, она будет выключена и удалена вместе с дисками, а на её место встанет копия.</span></span>
|
||||||
</label>
|
</label>
|
||||||
{% else %}
|
{% else %}
|
||||||
<input type="hidden" name="replace" value="">
|
<input type="hidden" name="replace" value="">
|
||||||
@@ -54,12 +54,23 @@
|
|||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-header"><h2>Что будет сделано</h2></div>
|
<div class="card-header"><h2>Что будет сделано</h2></div>
|
||||||
<div class="checklist">
|
<div class="checklist">
|
||||||
{% for title, text in [
|
{% if exists %}
|
||||||
('Диски копируются обратно', 'В папку ' ~ images_dir ~ ' — распаковка занимает несколько минут.'),
|
{% set steps = [
|
||||||
('Настройки применяются заново', 'Процессор, память, сеть и порядок загрузки — как на момент копии.'),
|
('Диски копируются обратно', 'В папку ' ~ images_dir ~ ', рядом со старой машиной — распаковка занимает несколько минут.'),
|
||||||
('Сетевые адреса выдаются новые', 'Чтобы восстановленная машина не конфликтовала с оригиналом. IP-адрес внутри может измениться.'),
|
('Старая машина удаляется только после распаковки', 'Если копирование сорвётся, машина ' ~ name ~ ' останется как была.'),
|
||||||
('Машина остаётся выключенной', 'Проверьте настройки и запустите её сами.'),
|
('Настройки применяются заново', 'Процессор, память, сеть и порядок загрузки — как на момент копии.'),
|
||||||
] %}
|
('Сетевые адреса сохраняются', 'Машина получит прежние MAC-адреса и идентификатор, поэтому система внутри не заметит подмены.'),
|
||||||
|
('Машина остаётся выключенной', 'Проверьте настройки и запустите её сами.'),
|
||||||
|
] %}
|
||||||
|
{% else %}
|
||||||
|
{% set steps = [
|
||||||
|
('Диски копируются обратно', 'В папку ' ~ images_dir ~ ' — распаковка занимает несколько минут.'),
|
||||||
|
('Настройки применяются заново', 'Процессор, память, сеть и порядок загрузки — как на момент копии.'),
|
||||||
|
('Сетевые адреса выдаются новые', 'Чтобы восстановленная машина не конфликтовала с оригиналом. IP-адрес внутри может измениться.'),
|
||||||
|
('Машина остаётся выключенной', 'Проверьте настройки и запустите её сами.'),
|
||||||
|
] %}
|
||||||
|
{% endif %}
|
||||||
|
{% for title, text in steps %}
|
||||||
<div class="check-item success">{{ icon('circle-check') }}<div><strong>{{ title }}</strong><span>{{ text }}</span></div></div>
|
<div class="check-item success">{{ icon('circle-check') }}<div><strong>{{ title }}</strong><span>{{ text }}</span></div></div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
<article class="card">
|
<article class="card">
|
||||||
<div class="card-body stack-sm">
|
<div class="card-body stack-sm">
|
||||||
<div class="row">{{ icon('info') }}<h3>Как это работает</h3></div>
|
<div class="row">{{ icon('info') }}<h3>Как это работает</h3></div>
|
||||||
<p class="muted small">Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной и получает новые сетевые адреса, чтобы не мешать оригиналу. Копии хранятся в папке <code>/var/lib/virtuality/backups</code>.</p>
|
<p class="muted small">Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной: под новым названием — с новыми сетевыми адресами, чтобы не мешать оригиналу; при замене — с прежними. Копии хранятся в папке <code>/var/lib/virtuality/backups</code>.</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user