Merge branch 'claude/backups-review-fixes' into claude/production-system-image-smcxyr
Этот коммит содержится в:
+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