diff --git a/tests/test_backups.py b/tests/test_backups.py index cf78e62..53d2c28 100644 --- a/tests/test_backups.py +++ b/tests/test_backups.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from pathlib import Path 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: """Имитация 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.paused = paused self.shuts_down = shuts_down + self.rejects_shutdown = rejects_shutdown self.exists = set(exists) self.calls: list[list[str]] = [] self.defined_xml = "" @@ -36,11 +39,16 @@ class FakeVirsh: def __call__(self, cmd, timeout=12, **_kwargs): self.calls.append(list(cmd)) if cmd[:2] == ["virsh", "domstate"]: - return fake_result("running" if self.running else "shut off") + return fake_result("paused" if self.running and self.paused else "running" if self.running else "shut off") 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: self.running = False 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"]: self.running = False return fake_result() @@ -58,10 +66,11 @@ class FakeVirsh: return fake_run_cmd(cmd, timeout) -def fake_stream(progress_lines=("(0.00/100%)", "(48.50/100%)", "(100.00/100%)"), code=0): - calls: list[list[str]] = [] +def fake_stream(progress_lines=("(0.00/100%)", "(48.50/100%)", "(100.00/100%)"), code=0, calls=None): + """Имитация 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)) Path(cmd[-1]).write_bytes(b"QFI\xfb" + b"\0" * 1000) 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 +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() def fast_worker(monkeypatch, data_dirs): - """Операции выполняются синхронно, ожидание выключения — мгновенно.""" + """Операции и фоновые потоки выполняются синхронно, ожидание выключения — мгновенно.""" 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, "SHUTDOWN_TIMEOUT", 0.05) + monkeypatch.setattr(backups, "LATE_SHUTDOWN_WATCH", 0.05) monkeypatch.setattr(backups, "free_bytes", lambda path: 500 * 1024 ** 3) return data_dirs @@ -109,6 +128,29 @@ def test_stream_cmd_splits_carriage_returns(): 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(): xml = backups.rewrite_xml((FIXTURES / "dumpxml-web01.xml").read_text(), "web02", {"vda": "/var/lib/virtuality/images/web02.qcow2"}) assert "web02" in xml @@ -119,6 +161,27 @@ def test_rewrite_xml_renames_and_detaches_identity(): assert "" in xml or "" 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"""web016c1b3c1e-0000-0000-0000-000000000001 + + + + + + + + """ + replaced = backups.rewrite_xml(source, "web01", {"vda": "/var/lib/virtuality/images/web01.qcow2"}, keep_identity=True) + assert "6c1b3c1e-0000-0000-0000-000000000001" 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 "" not in fresh and "oldhvm/usr/share/OVMF/OVMF_CODE.fd/var/lib/libvirt/qemu/nvram/old_VARS.fd """ @@ -130,6 +193,7 @@ def test_rewrite_xml_converts_block_disk_and_drops_nvram(): def test_format_backup_date(): 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" @@ -165,6 +229,22 @@ def test_incomplete_backup_is_flagged(data_dirs): 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): 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 ["virsh", "shutdown", "web01"] 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", "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")]] @@ -216,11 +297,57 @@ def test_backup_fails_when_vm_does_not_shut_down(fast_worker, monkeypatch): monkeypatch.setattr(backups, "stream_cmd", fake_stream()) operation = backups.create_backup("web01") fresh = core.read_operation(operation["id"]) - assert fresh["status"] == "error" and "не выключилась" in fresh["message"] + assert fresh["status"] == "error" and "не выключилась" in fresh["message"] and "запустит её снова" in fresh["message"] 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() +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): virsh = FakeVirsh(running=True) 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) +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): 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["vm_name"] == "web02" target = fast_worker["images"] / "web02.qcow2" - assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", str(fast_worker["backups"] / "web01" / "20260924-0300" / "vda.qcow2"), str(target)]] - assert target.exists() + assert stream.calls[0][:6] == ["qemu-img", "convert", "-p", "-O", "qcow2", str(fast_worker["backups"] / "web01" / "20260924-0300" / "vda.qcow2")] + assert stream.calls[0][6].startswith(str(fast_worker["images"] / "web02.restore-")) # сначала во временный файл + assert target.exists() and not list(fast_worker["images"].glob("*.restore-*")) assert "web02" in virsh.defined_xml and str(target) in virsh.defined_xml and "web01" in virsh.defined_xml + # при замене машина остаётся «той же»: UUID и MAC из копии сохраняются + assert "6c1b3c1e-0000-0000-0000-000000000001" 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): @@ -296,7 +471,7 @@ def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch): backups.start_restore("web01", "20260924-0300", "web01", True) eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"] assert eject in virsh.calls - assert virsh.calls.index(eject) < virsh.calls.index(["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"]) + assert virsh.calls.index(eject) < virsh.calls.index(UNDEFINE) 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) fresh = core.read_operation(operation["id"]) 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): @@ -391,6 +566,7 @@ def test_restore_page_and_submit(logged_in, fast_worker, monkeypatch): monkeypatch.setattr(backups, "stream_cmd", fake_stream()) html = logged_in.get("/backups/web01/20260924-0300/restore").text assert 'name="replace"' in html and "Заменить существующую машину web01" in html + assert "Старая машина удаляется только после распаковки" in html and "Сетевые адреса сохраняются" in html response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web01", "replace": ""}) assert response.status_code == 400 and "уже есть на сервере" in response.text response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web02", "replace": ""}, follow_redirects=False) @@ -413,6 +589,34 @@ def test_delete_backup_path_traversal(logged_in, data_dirs, tmp_path): victim = tmp_path / "victim" victim.mkdir() (victim / "20260924-0300").mkdir() - logged_in.post("/backups/..%2Fvictim/20260924-0300/delete", follow_redirects=False) - logged_in.post("/backups/web01/..%2F..%2Fvictim/delete", follow_redirects=False) + # %2F раскрывается до маршрутизации: лишние сегменты не подходят ни под один маршрут + 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() + + +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() diff --git a/web/features/backups.py b/web/features/backups.py index ab2839c..317461c 100644 --- a/web/features/backups.py +++ b/web/features/backups.py @@ -5,7 +5,8 @@ восстановление выполняются как фоновые операции с прогрессом и журналом. `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 re @@ -13,6 +14,7 @@ import shutil import subprocess import sys import tempfile +import threading import time import xml.etree.ElementTree as ET from datetime import datetime @@ -30,12 +32,19 @@ import presenters # noqa: E402 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 # секунд ждём мягкого выключения +LATE_SHUTDOWN_WATCH = 600.0 # секунд после отказа ещё следим: если машина выключится позже, запустим её снова +SAVE_TIMEOUT = 1800 # секунд на сохранение памяти машины на паузе +COPY_TIMEOUT = 12 * 3600.0 # секунд на копирование одного диска — дольше только зависший qemu-img POLL_INTERVAL = 2.0 SPACE_MARGIN = 256 * 1024 * 1024 # запас, чтобы не забить диск под завязку NOTE_MAX = 120 +RESTORE_MARKER = ".restoring" # файл в папке копии с id задачи восстановления, которая её читает MONTHS = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря") +BACKUP_ID_LOCK = threading.Lock() 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) -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. qemu-img с ключом -p печатает прогресс через \\r, поэтому обычное чтение - по строкам не подходит. Возвращает код завершения.""" + по строкам не подходит. Команду, не уложившуюся в timeout, убиваем, чтобы + зависший qemu-img не держал задачу «в работе» вечно. Возвращает код завершения.""" 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"" assert process.stdout is not None - while True: - chunk = process.stdout.read(512) - if not chunk: - break - pending += chunk - parts = re.split(rb"[\r\n]+", pending) - pending = parts.pop() - for part in parts: - text = part.decode("utf-8", "replace").strip() - if text: - on_line(text) - tail = pending.decode("utf-8", "replace").strip() - if tail: - on_line(tail) - return process.wait() + try: + while True: + chunk = process.stdout.read(512) + if not chunk: + break + pending += chunk + parts = re.split(rb"[\r\n]+", pending) + pending = parts.pop() + for part in parts: + text = part.decode("utf-8", "replace").strip() + if text: + on_line(text) + tail = pending.decode("utf-8", "replace").strip() + if tail: + on_line(tail) + 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: @@ -98,8 +126,11 @@ def parse_qemu_img_info(text: str) -> dict[str, Any]: def disk_info(path: str) -> dict[str, Any]: - result = run(["qemu-img", "info", "--output=json", path], timeout=30) - return parse_qemu_img_info(result["stdout"]) if result["ok"] else parse_qemu_img_info("") + # -U: диск работающей машины занят QEMU, без этого ключа qemu-img info отказывает («Failed to get shared write lock»). + 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]]: @@ -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"]] -def free_bytes(path: Path) -> int: +def existing_parent(path: Path) -> Path: + """Сам путь или ближайший существующий родитель — чтобы спросить у ФС о разделе.""" target = path while not target.exists() and target != target.parent: target = target.parent + return target + + +def free_bytes(path: Path) -> int: 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: return 0 @@ -133,7 +176,7 @@ def format_bytes(value: int | float) -> str: def format_backup_date(backup_id: str) -> str: 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: return backup_id 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]: - 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) - 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 { "vm": vm, "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 used = sum(item["size_bytes"] for item in items) free = free_bytes(backup_root()) - total = used + free + total = total_bytes(backup_root()) # доля именно от всего диска сервера, как и написано на странице return { "count": len(items), "used_bytes": used, @@ -259,22 +305,50 @@ def remove_backup_dir(path: Path) -> None: 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: path = backup_path(vm, backup_id) if not path or not path.is_dir(): raise BackupError("Копия не найдена — возможно, её уже удалили") - for operation in core.running_operations(): - if operation.get("type") in ("backup", "restore") and operation.get("backup_id") == backup_id and operation.get("source_vm", operation.get("vm_name")) == vm: - raise BackupError("С этой копией сейчас идёт работа — дождитесь завершения задачи") + if backup_in_use(vm, backup_id, path): + raise BackupError("С этой копией сейчас идёт работа — дождитесь завершения задачи") remove_backup_dir(path) def active_operation(vm: str) -> dict[str, Any] | None: - """Идущая сейчас задача копирования/восстановления, связанная с машиной.""" - for operation in core.running_operations(): - if operation.get("type") in ("backup", "restore") and vm in (operation.get("vm_name"), operation.get("source_vm")): - return operation - return None + """Идущая сейчас задача копирования/восстановления этой машины (по всей папке задач, а не последним N).""" + found = core.active_operations_for(vm, OPERATION_TYPES) + return found[0] if found else 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()) if free < need + SPACE_MARGIN: raise BackupError(f"Недостаточно места для копии: нужно около {format_bytes(need)}, свободно {format_bytes(free)}. Удалите старые копии или освободите место на сервере.", "space") - backup_id = datetime.now().strftime("%Y%m%d-%H%M") - if (backup_root() / vm_name / backup_id).exists(): - raise BackupError("Копия этой машины уже создана в эту минуту. Подождите минуту и повторите.") - operation = core.new_operation("backup", f"Резервная копия {vm_name}", "Ожидает начала", vm_name=vm_name, backup_id=backup_id, note=note, disks=plan, size_bytes=need) + backup_id = reserve_backup_dir(vm_name) + try: + 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: core.update_operation(operation, status="running", started_at=core.utc_now()) backup_worker(operation) @@ -337,14 +413,16 @@ def disk_progress_reporter(operation: dict[str, Any], index: int, total: int, la 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("Машина работает — отправляем команду выключения (как кнопка питания, система завершит работу сама).") core.update_operation(operation, progress=2, message="Выключаем машину…") result = run(["virsh", "shutdown", vm], timeout=20) if not result["ok"]: - log("Команда выключения не принята: " + core.cmd_error(result, "virsh shutdown")) - return False + problem = core.cmd_error(result, "virsh shutdown") + log("Команда выключения не принята: " + problem) + return f"Машина не приняла команду выключения: {problem}. Выключите её вручную и создайте копию ещё раз." deadline = time.monotonic() + SHUTDOWN_TIMEOUT polls = 0 while time.monotonic() < deadline: @@ -352,12 +430,54 @@ def shutdown_and_wait(operation: dict[str, Any], vm: str) -> bool: polls += 1 if not core.vm_is_running(vm): log("Машина выключена.") - return True + return "" if polls % 5 == 0: waited = int(polls * POLL_INTERVAL) core.update_operation(operation, message=f"Ждём выключения машины… {waited} с из {int(SHUTDOWN_TIMEOUT)}") 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: @@ -375,14 +495,17 @@ def backup_worker(operation: dict[str, Any]) -> None: vm = operation["vm_name"] plan = operation["disks"] log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731 + dest = backup_root() / vm / operation["backup_id"] # папка занята ещё в create_backup, поэтому она точно наша was_running = core.vm_is_running(vm) - if was_running and not shutdown_and_wait(operation, vm): - core.finish_operation(operation, False, "Машина не выключилась за 3 минуты. Сохраните работу внутри неё, выключите её вручную и создайте копию ещё раз.") - return - dest = backup_root() / vm / operation["backup_id"] + if was_running: + problem = stop_for_backup(operation, vm) + if problem: + remove_backup_dir(dest) + core.finish_operation(operation, False, problem) + return size = 0 try: - dest.mkdir(parents=True, exist_ok=False) + dest.mkdir(parents=True, exist_ok=True) result = run(["virsh", "dumpxml", vm, "--migratable"], timeout=20) if not result["ok"]: raise BackupError("Не удалось сохранить настройки машины: " + core.cmd_error(result, "virsh dumpxml")) @@ -398,7 +521,8 @@ def backup_worker(operation: dict[str, Any]) -> None: log(" ".join(cmd)) code = stream_cmd(cmd, disk_progress_reporter(operation, index, total, label)) if code != 0: - raise BackupError(f"Не удалось скопировать диск {disk['target']} (qemu-img завершился с кодом {code})") + 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()) 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") @@ -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. - Меняет , убирает и id, переключает диски на новые файлы, - удаляет (libvirt выдаст новые адреса) и (libvirt заведёт - свой файл под новое имя, чтобы не делить его со старой машиной).""" + Меняет , убирает id, переключает диски на новые файлы (без + — копии сплошные, а старая цепочка могла указывать на диски + удалённой машины), вынимает из привода образ, которого больше нет на + сервере, и удаляет (libvirt заведёт свой файл под новое имя). + Без keep_identity убирает и с , чтобы вторая машина не мешала + оригиналу; при замене оригинала (keep_identity) их лучше оставить — гость + не увидит «новую» сетевую карту и не потребует повторной активации.""" root = ET.fromstring(xml_text) root.attrib.pop("id", None) 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") root.insert(0, name) name.text = new_name - for element in root.findall("uuid"): - root.remove(element) + if not keep_identity: + for element in root.findall("uuid"): + root.remove(element) devices = root.find("devices") if devices is not None: for disk in devices.findall("disk"): target = disk.find("target") dev = target.get("dev") if target is not None else None + 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: continue - source = disk.find("source") if source is None: source = ET.SubElement(disk, "source") 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.set("name", "qemu") driver.set("type", "qcow2") - for interface in devices.findall("interface"): - for mac in interface.findall("mac"): - interface.remove(mac) + for chain in disk.findall("backingStore"): + disk.remove(chain) + if not keep_identity: + for interface in devices.findall("interface"): + for mac in interface.findall("mac"): + interface.remove(mac) os_element = root.find("os") if os_element is not None: for nvram in os_element.findall("nvram"): @@ -476,6 +612,11 @@ def restore_disk_path(new_name: str, target: str, index: int) -> Path: return images / f"{new_name}-{target}-{counter}.qcow2" +def restore_temp_path(new_name: str, operation_id: str, target: str) -> Path: + """Куда распаковывать диск, пока он не готов: <машина>.restore--<диск>.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]: backup = read_backup(vm, backup_id) 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): raise BackupError("С этой машиной уже идёт копирование или восстановление — дождитесь завершения") need = sum(int(disk.get("size") or 0) for disk in backup["disks"]) - reclaim = 0 - if exists: - try: - reclaim = sum(disk_info(disk["source"])["actual_size"] for disk in vm_disks(new_name)) - except BackupError: - reclaim = 0 - free = free_bytes(core.IMAGES_DIR) + reclaim + # Диски старой машины не считаем освобождающимися: копия распаковывается рядом с ними, а старая удаляется только потом. + free = free_bytes(core.IMAGES_DIR) 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} @@ -508,6 +645,10 @@ def start_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict backup = plan["backup"] title = f"Восстановление {plan['new_name']} из копии" operation = core.new_operation("restore", title, "Ожидает начала", vm_name=plan["new_name"], source_vm=vm, backup_id=backup_id, replace=plan["replace"], disks=backup["disks"], size_bytes=plan["need"]) + try: + (Path(backup["path"]) / RESTORE_MARKER).write_text(operation["id"], encoding="utf-8") # чтобы копию не удалили, пока её читают + except OSError: + pass core.run_operation(operation, restore_worker) return operation @@ -533,34 +674,45 @@ def remove_vm(operation: dict[str, Any], name: str) -> None: def restore_worker(operation: dict[str, Any]) -> None: + """Сначала распаковать все диски во временные файлы, и только когда они готовы — + удалить старую машину (при замене), переименовать диски и зарегистрировать новую. + Так ошибка на середине копирования не оставляет пользователя без машины.""" new_name = operation["vm_name"] log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731 backup = read_backup(operation["source_vm"], operation["backup_id"]) - created: list[Path] = [] + created: list[Path] = [] # файлы, которые появились по вине этой задачи — только их и убираем при ошибке try: if not backup or not backup["complete"]: raise BackupError("Копия не найдена или неполная") - if operation.get("replace"): - core.update_operation(operation, progress=3, message=f"Удаляем существующую машину {new_name}…") - remove_vm(operation, new_name) core.IMAGES_DIR.mkdir(parents=True, exist_ok=True) - disk_map: dict[str, str] = {} + copies: list[tuple[str, Path]] = [] total = len(backup["disks"]) for index, disk in enumerate(backup["disks"]): source = Path(backup["path"]) / f"{disk['target']}.qcow2" - target = restore_disk_path(new_name, disk["target"], index) - created.append(target) + temp = restore_temp_path(new_name, operation["id"], disk["target"]) + created.append(temp) label = f"Восстанавливаем диск {disk['target']} ({index + 1} из {total})" core.update_operation(operation, progress=int(5 + index / total * 90), message=label) - log(f"{label}: {source} → {target}") - cmd = ["qemu-img", "convert", "-p", "-O", "qcow2", str(source), str(target)] + log(f"{label}: {source} → {temp}") + cmd = ["qemu-img", "convert", "-p", "-O", "qcow2", str(source), str(temp)] log(" ".join(cmd)) code = stream_cmd(cmd, disk_progress_reporter(operation, index, total, label)) if code != 0: - raise BackupError(f"Не удалось восстановить диск {disk['target']} (qemu-img завершился с кодом {code})") - disk_map[disk["target"]] = str(target) + log(f"qemu-img завершился с кодом {code}.") + 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="Регистрируем машину…") - 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: handle.write(xml) tmp_xml = Path(handle.name) @@ -578,6 +730,9 @@ def restore_worker(operation: dict[str, Any]) -> None: log(f"Ошибка: {exc}") core.finish_operation(operation, False, str(exc)[:240]) return + finally: + if backup: + (Path(backup["path"]) / RESTORE_MARKER).unlink(missing_ok=True) created_label = backup["created_label"] if backup else "" 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) -if __name__ == "__main__": # python3 web/features/backups.py <машина> [заметка] +if __name__ == "__main__": # /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py <машина> [заметка] try: finished = create_backup(sys.argv[1], " ".join(sys.argv[2:]), wait=True) except (IndexError, BackupError) as error: diff --git a/web/templates/_backup_rows.html b/web/templates/_backup_rows.html index 79cd92d..9484e95 100644 --- a/web/templates/_backup_rows.html +++ b/web/templates/_backup_rows.html @@ -10,7 +10,7 @@ {% if b.complete %} {{ icon('rotate-cw', 'icon-sm') }}Восстановить {% endif %} -
+
diff --git a/web/templates/backup_restore.html b/web/templates/backup_restore.html index d9a365e..594419e 100644 --- a/web/templates/backup_restore.html +++ b/web/templates/backup_restore.html @@ -31,7 +31,7 @@
{{ icon('triangle-alert') }}
Машина {{ name }} уже есть на сервереЧтобы восстановить копию под этим названием, существующую машину придётся удалить вместе с её дисками. Если хотите сохранить обе — укажите другое название.
{% else %} @@ -54,12 +54,23 @@

Что будет сделано

- {% for title, text in [ - ('Диски копируются обратно', 'В папку ' ~ images_dir ~ ' — распаковка занимает несколько минут.'), - ('Настройки применяются заново', 'Процессор, память, сеть и порядок загрузки — как на момент копии.'), - ('Сетевые адреса выдаются новые', 'Чтобы восстановленная машина не конфликтовала с оригиналом. IP-адрес внутри может измениться.'), - ('Машина остаётся выключенной', 'Проверьте настройки и запустите её сами.'), - ] %} + {% if exists %} + {% set steps = [ + ('Диски копируются обратно', 'В папку ' ~ images_dir ~ ', рядом со старой машиной — распаковка занимает несколько минут.'), + ('Старая машина удаляется только после распаковки', 'Если копирование сорвётся, машина ' ~ name ~ ' останется как была.'), + ('Настройки применяются заново', 'Процессор, память, сеть и порядок загрузки — как на момент копии.'), + ('Сетевые адреса сохраняются', 'Машина получит прежние MAC-адреса и идентификатор, поэтому система внутри не заметит подмены.'), + ('Машина остаётся выключенной', 'Проверьте настройки и запустите её сами.'), + ] %} + {% else %} + {% set steps = [ + ('Диски копируются обратно', 'В папку ' ~ images_dir ~ ' — распаковка занимает несколько минут.'), + ('Настройки применяются заново', 'Процессор, память, сеть и порядок загрузки — как на момент копии.'), + ('Сетевые адреса выдаются новые', 'Чтобы восстановленная машина не конфликтовала с оригиналом. IP-адрес внутри может измениться.'), + ('Машина остаётся выключенной', 'Проверьте настройки и запустите её сами.'), + ] %} + {% endif %} + {% for title, text in steps %}
{{ icon('circle-check') }}
{{ title }}{{ text }}
{% endfor %}
diff --git a/web/templates/backups.html b/web/templates/backups.html index 1a9207d..44f87ff 100644 --- a/web/templates/backups.html +++ b/web/templates/backups.html @@ -75,7 +75,7 @@
{{ icon('info') }}

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

-

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

+

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