Merge branch 'feature/backups' into claude/production-system-image-smcxyr
# Conflicts: # tests/conftest.py # tests/dev_server.py # web/app.py # web/templates/vm_detail.html
Этот коммит содержится в:
@@ -36,6 +36,9 @@ DUMPXML = """<domain type='kvm'>
|
||||
|
||||
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
DOMBLKLIST_DETAILS = (FIXTURES / "domblklist-details.txt").read_text()
|
||||
DUMPXML_MIGRATABLE = (FIXTURES / "dumpxml-web01.xml").read_text()
|
||||
QEMU_IMG_INFO = (FIXTURES / "qemu-img-info.json").read_text()
|
||||
|
||||
# Снимки web01: имя → (состояние, описание); список берётся из fixtures/snapshot-list.txt.
|
||||
SNAPSHOTS = {"before-upd": ("shutoff", "Перед обновлением ядра"), "clean": ("running", "Чистая система после установки")}
|
||||
@@ -67,11 +70,17 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
||||
if cmd[:2] in (["virsh", "snapshot-create-as"], ["virsh", "snapshot-revert"], ["virsh", "snapshot-delete"]):
|
||||
return fake_result("Domain snapshot %s created" % option(cmd, "--name"))
|
||||
if cmd[:2] == ["virsh", "list"]:
|
||||
if "--name" in cmd:
|
||||
return fake_result("web01\ndb01\n")
|
||||
return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
|
||||
if cmd[:2] == ["virsh", "dominfo"]:
|
||||
return fake_result(DOMINFO) if cmd[-1] in ("web01", "db01") else fake_result(ok=False, stderr="failed to get domain")
|
||||
if cmd[:2] == ["virsh", "dumpxml"]:
|
||||
return fake_result(DUMPXML)
|
||||
return fake_result(DUMPXML_MIGRATABLE if "--migratable" in cmd else DUMPXML)
|
||||
if cmd[:2] == ["virsh", "domblklist"]:
|
||||
return fake_result(DOMBLKLIST_DETAILS)
|
||||
if cmd[:2] == ["qemu-img", "info"]:
|
||||
return fake_result(QEMU_IMG_INFO)
|
||||
if cmd[:2] == ["virsh", "domstate"]:
|
||||
return fake_result("running")
|
||||
if cmd[:2] == ["virsh", "domifaddr"]:
|
||||
@@ -114,6 +123,7 @@ def data_dirs(tmp_path, monkeypatch):
|
||||
"iso": tmp_path / "iso",
|
||||
"images": tmp_path / "images",
|
||||
"disk_images": tmp_path / "disk-images",
|
||||
"backups": tmp_path / "backups",
|
||||
"operations": tmp_path / "operations",
|
||||
"network": tmp_path / "network",
|
||||
"config": tmp_path / "config",
|
||||
@@ -122,11 +132,13 @@ def data_dirs(tmp_path, monkeypatch):
|
||||
}
|
||||
for path in dirs.values():
|
||||
path.mkdir()
|
||||
monkeypatch.setattr(app, "ISO_DIR", dirs["iso"])
|
||||
monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"])
|
||||
monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"])
|
||||
monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"])
|
||||
monkeypatch.setattr(core, "OPERATIONS_DIR", dirs["operations"])
|
||||
for module in (app, core):
|
||||
monkeypatch.setattr(module, "ISO_DIR", dirs["iso"])
|
||||
monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"])
|
||||
monkeypatch.setattr(module, "DISK_IMAGES_DIR", dirs["disk_images"])
|
||||
monkeypatch.setattr(module, "OPERATIONS_DIR", dirs["operations"])
|
||||
monkeypatch.setattr(core, "BACKUPS_DIR", dirs["backups"])
|
||||
monkeypatch.setattr(core, "CONFIG_DIR", dirs["config"])
|
||||
monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"])
|
||||
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
|
||||
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
|
||||
@@ -140,7 +152,7 @@ def data_dirs(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
|
||||
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
|
||||
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
|
||||
for module in (app, network_core, host_profile, core, *feature_modules()):
|
||||
for module in (app, core, network_core, host_profile, *feature_modules()):
|
||||
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
|
||||
return dirs
|
||||
|
||||
|
||||
@@ -46,12 +46,34 @@ def demo_run_cmd(cmd, timeout=12, **kwargs):
|
||||
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
|
||||
|
||||
|
||||
def seed_backups(backups_dir: Path) -> None:
|
||||
"""Несколько копий, как после пары недель работы: web01 — три, db01 — одна."""
|
||||
vm_xml = conftest.DUMPXML_MIGRATABLE
|
||||
demo = [
|
||||
("web01", "20260924-0300", 2_684_354_560, "", [("vda", 6_443_499_520)]),
|
||||
("web01", "20260917-0300", 2_598_000_000, "", [("vda", 6_300_000_000)]),
|
||||
("web01", "20260912-1842", 2_412_000_000, "Перед обновлением сайта", [("vda", 6_100_000_000)]),
|
||||
("db01", "20260921-2215", 9_126_805_504, "После переноса базы", [("vda", 12_884_901_888), ("vdb", 8_589_934_592)]),
|
||||
]
|
||||
for vm, backup_id, size, note, disks in demo:
|
||||
path = backups_dir / vm / backup_id
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / "vm.xml").write_text(vm_xml.replace("web01", vm))
|
||||
for target, _ in disks:
|
||||
with (path / f"{target}.qcow2").open("wb") as handle:
|
||||
handle.truncate(1024 * 1024)
|
||||
meta = {"vm": vm, "created_at": f"{backup_id[:4]}-{backup_id[4:6]}-{backup_id[6:8]} {backup_id[9:11]}:{backup_id[11:]}:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": s} for t, s in disks], "size_bytes": size, "version": "0.11.0", "note": note}
|
||||
(path / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def seed(root: Path) -> None:
|
||||
dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")}
|
||||
dirs = {name: root / name for name in ("iso", "images", "disk-images", "backups", "operations", "network", "config", "nft", "update")}
|
||||
for path in dirs.values():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
app.ISO_DIR, app.IMAGES_DIR, app.DISK_IMAGES_DIR, app.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"]
|
||||
core.OPERATIONS_DIR = dirs["operations"]
|
||||
core.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"]
|
||||
core.BACKUPS_DIR, core.CONFIG_DIR = dirs["backups"], dirs["config"]
|
||||
seed_backups(dirs["backups"])
|
||||
network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"]
|
||||
network_core.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json"
|
||||
network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml"
|
||||
@@ -61,7 +83,7 @@ def seed(root: Path) -> None:
|
||||
host_profile.PROFILE_FILE = dirs["config"] / "host_profile.json"
|
||||
update_core.STATE_DIR, update_core.STATE_FILE, update_core.LOG_FILE = dirs["update"], dirs["update"] / "state.json", dirs["update"] / "update.log"
|
||||
update_core.SOURCE_DIR = Path(__file__).resolve().parents[1]
|
||||
for module in (app, network_core, host_profile, core, *conftest.feature_modules()):
|
||||
for module in (app, core, network_core, host_profile, *conftest.feature_modules()):
|
||||
module.run_cmd = demo_run_cmd
|
||||
app.auth.verify_password = lambda user, password: True
|
||||
import nodectl
|
||||
@@ -104,6 +126,10 @@ def seed(root: Path) -> None:
|
||||
# Идущий снимок машины db01 — видно на /vm/db01/snapshots.
|
||||
snap_op = core.new_operation("snapshot_create", "Снимок машины db01", vm_name="db01", snapshot="snap-20260925-1500", vm_running=True)
|
||||
core.update_operation(snap_op, status="running", progress=15, message="Сохраняем состояние машины и памяти — она на несколько секунд замрёт…")
|
||||
op_id = str(uuid.uuid4())
|
||||
app.write_operation({"id": op_id, "type": "backup", "title": "Резервная копия web01", "status": "success", "progress": 100, "message": "Резервная копия создана: 2.5 ГБ", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester", "vm_name": "web01", "backup_id": "20260924-0300"})
|
||||
for line in ("Машина работает — отправляем команду выключения.", "Машина выключена.", "Настройки машины сохранены в vm.xml.", "Копируем диск vda (1 из 1): 100%", "Машина запущена снова."):
|
||||
app.append_operation_log(op_id, line)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
||||
import core
|
||||
import features.backups as backups
|
||||
from conftest import DOMBLKLIST_DETAILS, DUMPXML_MIGRATABLE, QEMU_IMG_INFO, fake_result, fake_run_cmd
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
|
||||
def make_backup(dirs, vm="web01", backup_id="20260924-0300", size=2_684_354_560, note="", disks=("vda",), complete=True) -> Path:
|
||||
path = dirs["backups"] / vm / backup_id
|
||||
path.mkdir(parents=True)
|
||||
(path / "vm.xml").write_text(DUMPXML_MIGRATABLE)
|
||||
for target in disks:
|
||||
if complete:
|
||||
(path / f"{target}.qcow2").write_bytes(b"QFI\xfb" + b"\0" * 60)
|
||||
meta = {"vm": vm, "created_at": "2026-09-24 03:00:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": 6_443_499_520} for t in disks], "size_bytes": size, "version": "0.11.0", "note": note}
|
||||
(path / "meta.json").write_text(json.dumps(meta))
|
||||
return path
|
||||
|
||||
|
||||
class FakeVirsh:
|
||||
"""Имитация virsh с состоянием: машина выключается после `virsh shutdown`."""
|
||||
|
||||
def __init__(self, running=True, shuts_down=True, exists=("web01", "db01")):
|
||||
self.running = running
|
||||
self.shuts_down = shuts_down
|
||||
self.exists = set(exists)
|
||||
self.calls: list[list[str]] = []
|
||||
self.defined_xml = ""
|
||||
|
||||
def __call__(self, cmd, timeout=12, **_kwargs):
|
||||
self.calls.append(list(cmd))
|
||||
if cmd[:2] == ["virsh", "domstate"]:
|
||||
return fake_result("running" if self.running else "shut off")
|
||||
if cmd[:2] == ["virsh", "shutdown"]:
|
||||
if self.shuts_down:
|
||||
self.running = False
|
||||
return fake_result()
|
||||
if cmd[:2] == ["virsh", "destroy"]:
|
||||
self.running = False
|
||||
return fake_result()
|
||||
if cmd[:2] == ["virsh", "start"]:
|
||||
self.running = True
|
||||
return fake_result()
|
||||
if cmd[:2] == ["virsh", "dominfo"]:
|
||||
return fake_result(f"Name: {cmd[-1]}\nState: running\n") if cmd[-1] in self.exists else fake_result(ok=False, stderr="error: failed to get domain")
|
||||
if cmd[:2] == ["virsh", "define"]:
|
||||
self.defined_xml = Path(cmd[2]).read_text()
|
||||
return fake_result(f"Domain defined from {cmd[2]}")
|
||||
if cmd[:2] == ["virsh", "undefine"]:
|
||||
self.exists.discard(cmd[2])
|
||||
return fake_result()
|
||||
return fake_run_cmd(cmd, timeout)
|
||||
|
||||
|
||||
def fake_stream(progress_lines=("(0.00/100%)", "(48.50/100%)", "(100.00/100%)"), code=0):
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def stream(cmd, on_line):
|
||||
calls.append(list(cmd))
|
||||
Path(cmd[-1]).write_bytes(b"QFI\xfb" + b"\0" * 1000)
|
||||
for line in progress_lines:
|
||||
on_line(line)
|
||||
return code
|
||||
|
||||
stream.calls = calls
|
||||
return stream
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fast_worker(monkeypatch, data_dirs):
|
||||
"""Операции выполняются синхронно, ожидание выключения — мгновенно."""
|
||||
monkeypatch.setattr(core, "run_operation", lambda operation, worker: worker(operation))
|
||||
monkeypatch.setattr(backups, "POLL_INTERVAL", 0.001)
|
||||
monkeypatch.setattr(backups, "SHUTDOWN_TIMEOUT", 0.05)
|
||||
monkeypatch.setattr(backups, "free_bytes", lambda path: 500 * 1024 ** 3)
|
||||
return data_dirs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- парсеры
|
||||
def test_parse_progress():
|
||||
assert backups.parse_progress(" (12.34/100%)") == 12
|
||||
assert backups.parse_progress("(100.00/100%)") == 100
|
||||
assert backups.parse_progress("qemu-img: Could not open") is None
|
||||
|
||||
|
||||
def test_parse_qemu_img_info_fixture():
|
||||
info = backups.parse_qemu_img_info(QEMU_IMG_INFO)
|
||||
assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"}
|
||||
assert backups.parse_qemu_img_info("not json")["actual_size"] == 0
|
||||
assert backups.parse_qemu_img_info("[1, 2]")["format"] == ""
|
||||
|
||||
|
||||
def test_vm_disks_skips_cdrom(data_dirs):
|
||||
disks = backups.vm_disks("web01")
|
||||
assert [(d["target"], d["source"]) for d in disks] == [("vda", "/var/lib/virtuality/images/web01.qcow2")]
|
||||
assert DOMBLKLIST_DETAILS.count("cdrom") == 1
|
||||
|
||||
|
||||
def test_stream_cmd_splits_carriage_returns():
|
||||
lines: list[str] = []
|
||||
code = backups.stream_cmd(["python3", "-c", "import sys; sys.stdout.write('a\\r (10.00/100%)\\r(55.50/100%)\\nend')"], lines.append)
|
||||
assert code == 0
|
||||
assert lines == ["a", "(10.00/100%)", "(55.50/100%)", "end"]
|
||||
|
||||
|
||||
def test_rewrite_xml_renames_and_detaches_identity():
|
||||
xml = backups.rewrite_xml((FIXTURES / "dumpxml-web01.xml").read_text(), "web02", {"vda": "/var/lib/virtuality/images/web02.qcow2"})
|
||||
assert "<name>web02</name>" in xml
|
||||
assert "<uuid>" not in xml and "id=" not in xml.split(">", 1)[0]
|
||||
assert "<mac" not in xml
|
||||
assert "file=\"/var/lib/virtuality/images/web02.qcow2\"" in xml
|
||||
assert "Сайт компании" in xml # заголовок и описание сохраняются
|
||||
assert "<readonly />" in xml or "<readonly/>" in xml # привод CD остаётся как был
|
||||
|
||||
|
||||
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>
|
||||
<devices><disk type='block' device='disk'><driver name='qemu' type='raw'/><source dev='/dev/vg/old'/><target dev='vda' bus='virtio'/></disk></devices></domain>"""
|
||||
xml = backups.rewrite_xml(source, "new", {"vda": "/var/lib/virtuality/images/new.qcow2"})
|
||||
assert "<nvram>" not in xml
|
||||
assert 'type="file"' in xml and 'file="/var/lib/virtuality/images/new.qcow2"' in xml
|
||||
assert 'type="qcow2"' in xml and "source dev=" not in xml and 'type="raw"' not in xml
|
||||
|
||||
|
||||
def test_format_backup_date():
|
||||
assert backups.format_backup_date("20260924-0300") == "24 сентября 2026, 03:00"
|
||||
assert backups.format_backup_date("garbage") == "garbage"
|
||||
|
||||
|
||||
def test_clean_note_limits_length_and_control_chars():
|
||||
assert backups.clean_note(" до\x00обновления\n ") == "до обновления"
|
||||
assert len(backups.clean_note("x" * 500)) == backups.NOTE_MAX
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- хранилище
|
||||
def test_list_and_group_backups(data_dirs):
|
||||
make_backup(data_dirs, "web01", "20260917-0300", size=100)
|
||||
make_backup(data_dirs, "web01", "20260924-0300", size=200, note="перед обновлением")
|
||||
make_backup(data_dirs, "old-vm", "20260901-1200", size=50)
|
||||
(data_dirs["backups"] / "web01" / "junk").mkdir()
|
||||
(data_dirs["backups"] / "..hidden").mkdir()
|
||||
items = backups.list_backups()
|
||||
assert [(i["vm"], i["id"]) for i in items] == [("web01", "20260924-0300"), ("web01", "20260917-0300"), ("old-vm", "20260901-1200")]
|
||||
assert items[0]["note"] == "перед обновлением" and items[0]["complete"] is True
|
||||
groups = backups.group_by_vm(items, {"web01"})
|
||||
assert [(g["vm"], g["count"], g["size_bytes"], g["exists"]) for g in groups] == [("old-vm", 1, 50, False), ("web01", 2, 300, True)]
|
||||
assert backups.list_backups("web01")[0]["id"] == "20260924-0300"
|
||||
assert backups.storage_summary(items)["used_bytes"] == 350
|
||||
|
||||
|
||||
def test_backup_path_rejects_traversal(data_dirs):
|
||||
assert backups.backup_path("../etc", "20260924-0300") is None
|
||||
assert backups.backup_path("web01", "../../x") is None
|
||||
assert backups.backup_path("web01", "20260924-0300") == data_dirs["backups"] / "web01" / "20260924-0300"
|
||||
|
||||
|
||||
def test_incomplete_backup_is_flagged(data_dirs):
|
||||
make_backup(data_dirs, complete=False)
|
||||
assert backups.read_backup("web01", "20260924-0300")["complete"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- создание копии
|
||||
def test_create_backup_refuses_without_space(data_dirs, monkeypatch):
|
||||
monkeypatch.setattr(backups, "free_bytes", lambda path: 1024)
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.create_backup("web01")
|
||||
assert "Недостаточно места" in str(exc.value) and "6.0 ГБ" in str(exc.value)
|
||||
|
||||
|
||||
def test_create_backup_rejects_unknown_vm(fast_worker):
|
||||
with pytest.raises(backups.BackupError):
|
||||
backups.create_backup("nope")
|
||||
with pytest.raises(backups.BackupError):
|
||||
backups.create_backup("bad name")
|
||||
|
||||
|
||||
def test_create_backup_shuts_down_copies_and_restarts(fast_worker, monkeypatch):
|
||||
virsh = FakeVirsh(running=True)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
stream = fake_stream()
|
||||
monkeypatch.setattr(backups, "stream_cmd", stream)
|
||||
queued = backups.create_backup("web01", " ночная\tкопия ")
|
||||
operation = core.read_operation(queued["id"])
|
||||
assert operation["status"] == "success", operation["message"]
|
||||
assert operation["progress"] == 100 and "Резервная копия создана" in operation["message"]
|
||||
assert ["virsh", "shutdown", "web01"] in virsh.calls
|
||||
assert ["virsh", "dumpxml", "web01", "--migratable"] in virsh.calls
|
||||
assert virsh.calls[-1] == ["virsh", "start", "web01"]
|
||||
assert ["virsh", "destroy", "web01"] not in virsh.calls
|
||||
assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", "-c", "/var/lib/virtuality/images/web01.qcow2", str(fast_worker["backups"] / "web01" / operation["backup_id"] / "vda.qcow2")]]
|
||||
path = fast_worker["backups"] / "web01" / operation["backup_id"]
|
||||
meta = json.loads((path / "meta.json").read_text())
|
||||
assert meta["vm"] == "web01" and meta["note"] == "ночная копия" and meta["disks"][0]["target"] == "vda"
|
||||
assert meta["size_bytes"] == sum(f.stat().st_size for f in path.iterdir() if f.name != "meta.json")
|
||||
assert "Сайт компании" in (path / "vm.xml").read_text()
|
||||
log = core.read_operation(operation["id"])["log_tail"]
|
||||
assert "48%" in log and "Машина запущена снова" in log
|
||||
|
||||
|
||||
def test_create_backup_wait_returns_finished_operation(fast_worker, monkeypatch):
|
||||
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
operation = backups.create_backup("db01", wait=True)
|
||||
assert operation["status"] == "success" and operation["type"] == "backup"
|
||||
|
||||
|
||||
def test_backup_fails_when_vm_does_not_shut_down(fast_worker, monkeypatch):
|
||||
virsh = FakeVirsh(running=True, shuts_down=False)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
operation = backups.create_backup("web01")
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "error" and "не выключилась" in fresh["message"]
|
||||
assert ["virsh", "destroy", "web01"] not in virsh.calls
|
||||
assert not (fast_worker["backups"] / "web01").exists()
|
||||
|
||||
|
||||
def test_backup_cleans_up_and_restarts_after_copy_error(fast_worker, monkeypatch):
|
||||
virsh = FakeVirsh(running=True)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1))
|
||||
operation = backups.create_backup("web01")
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "error" and "vda" in fresh["message"]
|
||||
assert not (fast_worker["backups"] / "web01").exists()
|
||||
assert virsh.calls[-1] == ["virsh", "start", "web01"]
|
||||
|
||||
|
||||
def test_create_backup_refuses_while_another_runs(fast_worker, monkeypatch):
|
||||
monkeypatch.setattr(core, "run_operation", lambda operation, worker: None) # остаётся в очереди
|
||||
backups.create_backup("web01")
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.create_backup("web01")
|
||||
assert "уже идёт" in str(exc.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- восстановление
|
||||
def test_restore_under_new_name(fast_worker, monkeypatch):
|
||||
make_backup(fast_worker)
|
||||
virsh = FakeVirsh(running=False)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
stream = fake_stream()
|
||||
monkeypatch.setattr(backups, "stream_cmd", stream)
|
||||
operation = backups.start_restore("web01", "20260924-0300", "web02", False)
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "success", fresh["message"]
|
||||
assert fresh["vm_name"] == "web02"
|
||||
target = fast_worker["images"] / "web02.qcow2"
|
||||
assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", str(fast_worker["backups"] / "web01" / "20260924-0300" / "vda.qcow2"), str(target)]]
|
||||
assert target.exists()
|
||||
assert "<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 list((fast_worker["backups"] / "web01" / "20260924-0300").glob("restore-*.xml"))
|
||||
|
||||
|
||||
def test_restore_requires_replace_confirmation(fast_worker):
|
||||
make_backup(fast_worker)
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.start_restore("web01", "20260924-0300", "web01", False)
|
||||
assert exc.value.code == "exists"
|
||||
|
||||
|
||||
def test_restore_replaces_existing_vm(fast_worker, monkeypatch):
|
||||
make_backup(fast_worker)
|
||||
virsh = FakeVirsh(running=True)
|
||||
monkeypatch.setattr(core, "run_cmd", virsh)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
operation = backups.start_restore("web01", "20260924-0300", "", True)
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "success", fresh["message"]
|
||||
assert ["virsh", "destroy", "web01"] in virsh.calls
|
||||
assert ["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"] in virsh.calls
|
||||
assert virsh.calls.index(["virsh", "destroy", "web01"]) < virsh.calls.index(["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"])
|
||||
assert "<name>web01</name>" in virsh.defined_xml
|
||||
|
||||
|
||||
def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch):
|
||||
make_backup(fast_worker)
|
||||
virsh = FakeVirsh(running=False)
|
||||
base = virsh.__call__
|
||||
|
||||
def with_iso(cmd, timeout=12, **kw):
|
||||
if cmd[:2] == ["virsh", "domblklist"]:
|
||||
virsh.calls.append(list(cmd))
|
||||
return fake_result(DOMBLKLIST_DETAILS.replace("sda -", "sda /var/lib/virtuality/iso/debian.iso"))
|
||||
return base(cmd, timeout, **kw)
|
||||
|
||||
monkeypatch.setattr(core, "run_cmd", with_iso)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
backups.start_restore("web01", "20260924-0300", "web01", True)
|
||||
eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"]
|
||||
assert eject in virsh.calls
|
||||
assert virsh.calls.index(eject) < virsh.calls.index(["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"])
|
||||
|
||||
|
||||
def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker):
|
||||
make_backup(fast_worker)
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.start_restore("web01", "20260924-0300", "-bad", False)
|
||||
assert exc.value.code == "name"
|
||||
make_backup(fast_worker, backup_id="20260901-0000", complete=False)
|
||||
with pytest.raises(backups.BackupError) as exc:
|
||||
backups.start_restore("web01", "20260901-0000", "web02", False)
|
||||
assert "неполная" in str(exc.value)
|
||||
|
||||
|
||||
def test_restore_cleans_up_on_define_failure(fast_worker, monkeypatch):
|
||||
make_backup(fast_worker)
|
||||
virsh = FakeVirsh(running=False)
|
||||
base = virsh.__call__
|
||||
|
||||
def failing_define(cmd, timeout=12, **kw):
|
||||
if cmd[:2] == ["virsh", "define"]:
|
||||
return fake_result(ok=False, stderr="error: XML error: bad thing")
|
||||
return base(cmd, timeout, **kw)
|
||||
|
||||
monkeypatch.setattr(core, "run_cmd", failing_define)
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
operation = backups.start_restore("web01", "20260924-0300", "web02", False)
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert fresh["status"] == "error" and "bad thing" in fresh["message"]
|
||||
assert not (fast_worker["images"] / "web02.qcow2").exists()
|
||||
|
||||
|
||||
def test_restore_disk_path_avoids_collisions(data_dirs):
|
||||
(data_dirs["images"] / "web02.qcow2").write_bytes(b"x")
|
||||
assert backups.restore_disk_path("web02", "vda", 0) == data_dirs["images"] / "web02-vda.qcow2"
|
||||
assert backups.restore_disk_path("web02", "vdb", 1) == data_dirs["images"] / "web02-vdb.qcow2"
|
||||
(data_dirs["images"] / "web02-vda.qcow2").write_bytes(b"x")
|
||||
assert backups.restore_disk_path("web02", "vda", 0) == data_dirs["images"] / "web02-vda-2.qcow2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- страницы
|
||||
def test_backup_pages_require_login(client):
|
||||
for path in ("/backups", "/vm/web01/backups", "/backups/web01/20260924-0300/restore"):
|
||||
response = client.get(path, follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_backups_page_lists_groups(logged_in, data_dirs):
|
||||
make_backup(data_dirs, "web01", "20260924-0300", size=2_684_354_560, note="перед обновлением")
|
||||
make_backup(data_dirs, "gone-vm", "20260901-1200", size=1024 ** 3)
|
||||
html = logged_in.get("/backups").text
|
||||
assert "24 сентября 2026, 03:00" in html and "2.5 ГБ" in html and "перед обновлением" in html
|
||||
assert 'href="/backups/web01/20260924-0300/restore"' in html
|
||||
assert "машина удалена" in html and 'href="/vm/web01/backups"' in html
|
||||
assert "Резервные копии" in html
|
||||
|
||||
|
||||
def test_backups_page_empty_state(logged_in):
|
||||
html = logged_in.get("/backups").text
|
||||
assert "Копий пока нет" in html
|
||||
|
||||
|
||||
def test_vm_backups_page_and_tab_link(logged_in, data_dirs):
|
||||
make_backup(data_dirs)
|
||||
html = logged_in.get("/vm/web01/backups").text
|
||||
assert 'action="/vm/web01/backups/create"' in html and "Создать копию" in html
|
||||
assert "24 сентября 2026, 03:00" in html
|
||||
assert 'href="/vm/web01/backups"' in logged_in.get("/vm/web01").text
|
||||
assert logged_in.get("/vm/nope/backups", follow_redirects=False).headers["location"] == "/"
|
||||
|
||||
|
||||
def test_vm_backup_create_route(logged_in, fast_worker, monkeypatch):
|
||||
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
response = logged_in.post("/vm/web01/backups/create", data={"note": "перед обновлением"}, follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"].startswith("/operations/")
|
||||
operation_id = response.headers["location"].rsplit("/", 1)[1]
|
||||
assert core.read_operation(operation_id)["status"] == "success"
|
||||
assert logged_in.get(f"/operations/{operation_id}").status_code == 200
|
||||
|
||||
|
||||
def test_vm_backup_create_route_shows_error(logged_in, data_dirs, monkeypatch):
|
||||
monkeypatch.setattr(backups, "free_bytes", lambda path: 0)
|
||||
response = logged_in.post("/vm/web01/backups/create", data={"note": ""}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert "backup_error=" in response.headers["location"] and "Недостаточно" in unquote(response.headers["location"])
|
||||
assert "Недостаточно места" in logged_in.get(response.headers["location"]).text
|
||||
|
||||
|
||||
def test_restore_page_and_submit(logged_in, fast_worker, monkeypatch):
|
||||
make_backup(fast_worker)
|
||||
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
|
||||
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
|
||||
html = logged_in.get("/backups/web01/20260924-0300/restore").text
|
||||
assert 'name="replace"' in html and "Заменить существующую машину web01" in html
|
||||
response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web01", "replace": ""})
|
||||
assert response.status_code == 400 and "уже есть на сервере" in response.text
|
||||
response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web02", "replace": ""}, follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"].startswith("/operations/")
|
||||
response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "-bad"})
|
||||
assert response.status_code == 400
|
||||
assert logged_in.get("/backups/web01/20990101-0000/restore", follow_redirects=False).headers["location"].startswith("/backups?backup_error=")
|
||||
|
||||
|
||||
def test_delete_backup_route(logged_in, data_dirs):
|
||||
path = make_backup(data_dirs)
|
||||
response = logged_in.post("/backups/web01/20260924-0300/delete", data={"next": "/vm/web01/backups"}, follow_redirects=False)
|
||||
assert response.status_code == 303 and response.headers["location"].startswith("/vm/web01/backups?backup_message=")
|
||||
assert not path.exists() and not path.parent.exists()
|
||||
response = logged_in.post("/backups/web01/20260924-0300/delete", data={"next": "http://evil"}, follow_redirects=False)
|
||||
assert response.headers["location"].startswith("/backups?backup_error=")
|
||||
|
||||
|
||||
def test_delete_backup_path_traversal(logged_in, data_dirs, tmp_path):
|
||||
victim = tmp_path / "victim"
|
||||
victim.mkdir()
|
||||
(victim / "20260924-0300").mkdir()
|
||||
logged_in.post("/backups/..%2Fvictim/20260924-0300/delete", follow_redirects=False)
|
||||
logged_in.post("/backups/web01/..%2F..%2Fvictim/delete", follow_redirects=False)
|
||||
assert (victim / "20260924-0300").exists()
|
||||
+2
-1
@@ -28,7 +28,7 @@ import auth
|
||||
import host_profile
|
||||
import network_core
|
||||
import presenters
|
||||
from features import settings, setup, snapshots
|
||||
from features import backups, settings, setup, snapshots
|
||||
import update_core
|
||||
from network_core import NetworkError
|
||||
|
||||
@@ -2024,3 +2024,4 @@ def api_health(request: Request):
|
||||
app.include_router(setup.router)
|
||||
app.include_router(settings.router)
|
||||
app.include_router(snapshots.router)
|
||||
app.include_router(backups.router)
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
"""Резервные копии виртуальных машин.
|
||||
|
||||
Копия лежит в BACKUPS_DIR/<машина>/<ГГГГММДД-ЧЧММ>/ и состоит из vm.xml
|
||||
(настройки), <диск>.qcow2 на каждый диск и meta.json. Создание и
|
||||
восстановление выполняются как фоновые операции с прогрессом и журналом.
|
||||
|
||||
`create_backup(vm_name, note)` можно вызывать без HTTP — например, из
|
||||
расписания: `python3 web/features/backups.py web01 "ночная копия"`.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
if __name__ == "__main__": # запуск как скрипта: нужен web/ в sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from fastapi import APIRouter, Form, Request # noqa: E402
|
||||
from fastapi.responses import RedirectResponse # noqa: E402
|
||||
|
||||
import core # noqa: E402
|
||||
import presenters # noqa: E402
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
BACKUP_ID_RE = re.compile(r"\d{8}-\d{4}")
|
||||
SHUTDOWN_TIMEOUT = 180.0 # секунд ждём мягкого выключения
|
||||
POLL_INTERVAL = 2.0
|
||||
SPACE_MARGIN = 256 * 1024 * 1024 # запас, чтобы не забить диск под завязку
|
||||
NOTE_MAX = 120
|
||||
MONTHS = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря")
|
||||
|
||||
|
||||
class BackupError(Exception):
|
||||
"""Ошибка, текст которой можно показать пользователю как есть."""
|
||||
|
||||
def __init__(self, message: str, code: str = "") -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
def run(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
|
||||
return core.run_cmd(cmd, timeout=timeout)
|
||||
|
||||
|
||||
def stream_cmd(cmd: list[str], on_line: Callable[[str], None]) -> int:
|
||||
"""Запустить команду и отдавать каждую строку вывода (по \\r и \\n) в on_line.
|
||||
|
||||
qemu-img с ключом -p печатает прогресс через \\r, поэтому обычное чтение
|
||||
по строкам не подходит. Возвращает код завершения."""
|
||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
pending = b""
|
||||
assert process.stdout is not None
|
||||
while True:
|
||||
chunk = process.stdout.read(512)
|
||||
if not chunk:
|
||||
break
|
||||
pending += chunk
|
||||
parts = re.split(rb"[\r\n]+", pending)
|
||||
pending = parts.pop()
|
||||
for part in parts:
|
||||
text = part.decode("utf-8", "replace").strip()
|
||||
if text:
|
||||
on_line(text)
|
||||
tail = pending.decode("utf-8", "replace").strip()
|
||||
if tail:
|
||||
on_line(tail)
|
||||
return process.wait()
|
||||
|
||||
|
||||
def parse_progress(line: str) -> int | None:
|
||||
"""' (12.34/100%)' → 12. None, если в строке нет прогресса qemu-img."""
|
||||
match = re.search(r"\((\d+(?:\.\d+)?)/100%\)", line or "")
|
||||
return min(100, int(float(match.group(1)))) if match else None
|
||||
|
||||
|
||||
def parse_qemu_img_info(text: str) -> dict[str, Any]:
|
||||
"""Вывод `qemu-img info --output=json` → {virtual_size, actual_size, format}."""
|
||||
empty = {"virtual_size": 0, "actual_size": 0, "format": ""}
|
||||
try:
|
||||
data = json.loads(text or "")
|
||||
except ValueError:
|
||||
return empty
|
||||
if not isinstance(data, dict):
|
||||
return empty
|
||||
try:
|
||||
return {"virtual_size": int(data.get("virtual-size") or 0), "actual_size": int(data.get("actual-size") or 0), "format": str(data.get("format") or "")}
|
||||
except (TypeError, ValueError):
|
||||
return empty
|
||||
|
||||
|
||||
def disk_info(path: str) -> dict[str, Any]:
|
||||
result = run(["qemu-img", "info", "--output=json", path], timeout=30)
|
||||
return parse_qemu_img_info(result["stdout"]) if result["ok"] else parse_qemu_img_info("")
|
||||
|
||||
|
||||
def vm_disks(name: str) -> list[dict[str, str]]:
|
||||
"""Диски машины (без приводов CD/DVD) из `virsh domblklist --details`."""
|
||||
result = run(["virsh", "domblklist", name, "--details"], timeout=10)
|
||||
if not result["ok"]:
|
||||
raise BackupError("Не удалось получить список дисков: " + core.cmd_error(result, "virsh domblklist"))
|
||||
return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "disk" and disk["source"]]
|
||||
|
||||
|
||||
def vm_cdroms_with_media(name: str) -> list[dict[str, str]]:
|
||||
result = run(["virsh", "domblklist", name, "--details"], timeout=10)
|
||||
if not result["ok"]:
|
||||
return []
|
||||
return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "cdrom" and disk["source"]]
|
||||
|
||||
|
||||
def free_bytes(path: Path) -> int:
|
||||
target = path
|
||||
while not target.exists() and target != target.parent:
|
||||
target = target.parent
|
||||
try:
|
||||
return shutil.disk_usage(target).free
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def format_bytes(value: int | float) -> str:
|
||||
return presenters.format_bytes(value)
|
||||
|
||||
|
||||
def format_backup_date(backup_id: str) -> str:
|
||||
try:
|
||||
moment = datetime.strptime(backup_id, "%Y%m%d-%H%M")
|
||||
except ValueError:
|
||||
return backup_id
|
||||
return f"{moment.day} {MONTHS[moment.month - 1]} {moment.year}, {moment:%H:%M}"
|
||||
|
||||
|
||||
def clean_note(note: str) -> str:
|
||||
return re.sub(r"[\x00-\x1f\x7f]+", " ", note or "").strip()[:NOTE_MAX]
|
||||
|
||||
|
||||
def existing_vm_names() -> set[str]:
|
||||
result = run(["virsh", "list", "--all", "--name"], timeout=10)
|
||||
return {line.strip() for line in (result.get("stdout") or "").splitlines() if line.strip()} if result["ok"] else set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- storage layout
|
||||
def backup_root() -> Path:
|
||||
return core.BACKUPS_DIR
|
||||
|
||||
|
||||
def backup_path(vm: str, backup_id: str) -> Path | None:
|
||||
"""Папка копии или None, если имя машины/копии некорректно или уводит из BACKUPS_DIR."""
|
||||
if not core.valid_vm_name(vm) or not BACKUP_ID_RE.fullmatch(backup_id or ""):
|
||||
return None
|
||||
root = backup_root()
|
||||
path = root / vm / backup_id
|
||||
try:
|
||||
path.resolve().relative_to(root.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def describe_backup(vm: str, backup_id: str, path: Path, meta: dict[str, Any]) -> dict[str, Any]:
|
||||
disks = [disk for disk in meta.get("disks", []) if isinstance(disk, dict) and disk.get("target")]
|
||||
size = int(meta.get("size_bytes") or 0)
|
||||
complete = (path / "vm.xml").exists() and bool(disks) and all((path / f"{disk['target']}.qcow2").exists() for disk in disks)
|
||||
return {
|
||||
"vm": vm,
|
||||
"id": backup_id,
|
||||
"path": str(path),
|
||||
"created_at": meta.get("created_at") or "",
|
||||
"created_label": format_backup_date(backup_id),
|
||||
"note": clean_note(str(meta.get("note") or "")),
|
||||
"disks": disks,
|
||||
"disk_count": len(disks),
|
||||
"size_bytes": size,
|
||||
"size": format_bytes(size),
|
||||
"version": str(meta.get("version") or ""),
|
||||
"complete": complete,
|
||||
}
|
||||
|
||||
|
||||
def read_backup(vm: str, backup_id: str) -> dict[str, Any] | None:
|
||||
path = backup_path(vm, backup_id)
|
||||
if not path or not (path / "meta.json").is_file():
|
||||
return None
|
||||
try:
|
||||
meta = json.loads((path / "meta.json").read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
return describe_backup(vm, backup_id, path, meta)
|
||||
|
||||
|
||||
def list_backups(vm: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Все копии (или копии одной машины), новые сверху."""
|
||||
root = backup_root()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
if vm is not None:
|
||||
vm_dirs = [root / vm] if core.valid_vm_name(vm) else []
|
||||
else:
|
||||
vm_dirs = sorted(item for item in root.iterdir() if item.is_dir() and core.valid_vm_name(item.name))
|
||||
items: list[dict[str, Any]] = []
|
||||
for vm_dir in vm_dirs:
|
||||
if not vm_dir.is_dir():
|
||||
continue
|
||||
for item in vm_dir.iterdir():
|
||||
if item.is_dir() and BACKUP_ID_RE.fullmatch(item.name):
|
||||
backup = read_backup(vm_dir.name, item.name)
|
||||
if backup:
|
||||
items.append(backup)
|
||||
items.sort(key=lambda item: (item["id"], item["vm"]), reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
def group_by_vm(items: list[dict[str, Any]], existing: set[str] | None = None) -> list[dict[str, Any]]:
|
||||
groups: dict[str, dict[str, Any]] = {}
|
||||
for item in items:
|
||||
group = groups.setdefault(item["vm"], {"vm": item["vm"], "backups": [], "size_bytes": 0, "exists": existing is None or item["vm"] in existing})
|
||||
group["backups"].append(item)
|
||||
group["size_bytes"] += item["size_bytes"]
|
||||
for group in groups.values():
|
||||
group["size"] = format_bytes(group["size_bytes"])
|
||||
group["count"] = len(group["backups"])
|
||||
return sorted(groups.values(), key=lambda group: group["vm"])
|
||||
|
||||
|
||||
def storage_summary(items: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
items = list_backups() if items is None else items
|
||||
used = sum(item["size_bytes"] for item in items)
|
||||
free = free_bytes(backup_root())
|
||||
total = used + free
|
||||
return {
|
||||
"count": len(items),
|
||||
"used_bytes": used,
|
||||
"used": format_bytes(used),
|
||||
"free_bytes": free,
|
||||
"free": format_bytes(free),
|
||||
"used_pct": int(round(used / total * 100)) if total else 0,
|
||||
}
|
||||
|
||||
|
||||
def remove_backup_dir(path: Path) -> None:
|
||||
"""Удалить папку копии; пустую папку машины над ней — тоже."""
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
parent = path.parent
|
||||
try:
|
||||
if parent != backup_root() and parent.is_dir() and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def delete_backup(vm: str, backup_id: str) -> None:
|
||||
path = backup_path(vm, backup_id)
|
||||
if not path or not path.is_dir():
|
||||
raise BackupError("Копия не найдена — возможно, её уже удалили")
|
||||
for operation in core.running_operations():
|
||||
if operation.get("type") in ("backup", "restore") and operation.get("backup_id") == backup_id and operation.get("source_vm", operation.get("vm_name")) == vm:
|
||||
raise BackupError("С этой копией сейчас идёт работа — дождитесь завершения задачи")
|
||||
remove_backup_dir(path)
|
||||
|
||||
|
||||
def active_operation(vm: str) -> dict[str, Any] | None:
|
||||
"""Идущая сейчас задача копирования/восстановления, связанная с машиной."""
|
||||
for operation in core.running_operations():
|
||||
if operation.get("type") in ("backup", "restore") and vm in (operation.get("vm_name"), operation.get("source_vm")):
|
||||
return operation
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- создание копии
|
||||
def create_backup(vm_name: str, note: str = "", wait: bool = False) -> dict[str, Any]:
|
||||
"""Поставить создание копии в очередь и вернуть операцию.
|
||||
|
||||
Проверки (машина есть, диски есть, места хватает) выполняются сразу и
|
||||
поднимают BackupError с понятным текстом. С wait=True копия делается
|
||||
в текущем потоке — удобно для расписания."""
|
||||
if not core.valid_vm_name(vm_name):
|
||||
raise BackupError("Некорректное имя машины")
|
||||
note = clean_note(note)
|
||||
if not core.vm_exists(vm_name):
|
||||
raise BackupError(f"Машина {vm_name} не найдена")
|
||||
if active_operation(vm_name):
|
||||
raise BackupError("С этой машиной уже идёт копирование или восстановление — дождитесь завершения")
|
||||
disks = vm_disks(vm_name)
|
||||
if not disks:
|
||||
raise BackupError("У машины нет дисков — копировать нечего")
|
||||
plan: list[dict[str, Any]] = []
|
||||
need = 0
|
||||
for disk in disks:
|
||||
info = disk_info(disk["source"])
|
||||
size = info["actual_size"] or info["virtual_size"]
|
||||
need += size
|
||||
plan.append({"target": disk["target"], "source": disk["source"], "size": size})
|
||||
free = free_bytes(backup_root())
|
||||
if free < need + SPACE_MARGIN:
|
||||
raise BackupError(f"Недостаточно места для копии: нужно около {format_bytes(need)}, свободно {format_bytes(free)}. Удалите старые копии или освободите место на сервере.", "space")
|
||||
backup_id = datetime.now().strftime("%Y%m%d-%H%M")
|
||||
if (backup_root() / vm_name / backup_id).exists():
|
||||
raise BackupError("Копия этой машины уже создана в эту минуту. Подождите минуту и повторите.")
|
||||
operation = core.new_operation("backup", f"Резервная копия {vm_name}", "Ожидает начала", vm_name=vm_name, backup_id=backup_id, note=note, disks=plan, size_bytes=need)
|
||||
if wait:
|
||||
core.update_operation(operation, status="running", started_at=core.utc_now())
|
||||
backup_worker(operation)
|
||||
return core.read_operation(operation["id"]) or operation
|
||||
core.run_operation(operation, backup_worker)
|
||||
return operation
|
||||
|
||||
|
||||
def disk_progress_reporter(operation: dict[str, Any], index: int, total: int, label: str) -> Callable[[str], None]:
|
||||
"""on_line для qemu-img: общий прогресс = 5…95 %, разложенный по дискам."""
|
||||
state = {"last": -1, "logged": -10}
|
||||
|
||||
def on_line(line: str) -> None:
|
||||
pct = parse_progress(line)
|
||||
if pct is None:
|
||||
core.append_operation_log(operation["id"], line)
|
||||
return
|
||||
overall = int(5 + (index + pct / 100) / total * 90)
|
||||
if overall == state["last"]:
|
||||
return
|
||||
state["last"] = overall
|
||||
core.update_operation(operation, progress=overall, message=f"{label}: {pct}%")
|
||||
if pct - state["logged"] >= 10 or pct == 100:
|
||||
state["logged"] = pct
|
||||
core.append_operation_log(operation["id"], f"{label}: {pct}%")
|
||||
|
||||
return on_line
|
||||
|
||||
|
||||
def shutdown_and_wait(operation: dict[str, Any], vm: str) -> bool:
|
||||
log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731
|
||||
log("Машина работает — отправляем команду выключения (как кнопка питания, система завершит работу сама).")
|
||||
core.update_operation(operation, progress=2, message="Выключаем машину…")
|
||||
result = run(["virsh", "shutdown", vm], timeout=20)
|
||||
if not result["ok"]:
|
||||
log("Команда выключения не принята: " + core.cmd_error(result, "virsh shutdown"))
|
||||
return False
|
||||
deadline = time.monotonic() + SHUTDOWN_TIMEOUT
|
||||
polls = 0
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(POLL_INTERVAL)
|
||||
polls += 1
|
||||
if not core.vm_is_running(vm):
|
||||
log("Машина выключена.")
|
||||
return True
|
||||
if polls % 5 == 0:
|
||||
waited = int(polls * POLL_INTERVAL)
|
||||
core.update_operation(operation, message=f"Ждём выключения машины… {waited} с из {int(SHUTDOWN_TIMEOUT)}")
|
||||
log(f"Всё ещё работает, ждём ({waited} с).")
|
||||
return False
|
||||
|
||||
|
||||
def start_vm(operation: dict[str, Any], vm: str) -> str:
|
||||
"""Запустить машину обратно; вернуть текст проблемы или пустую строку."""
|
||||
result = run(["virsh", "start", vm], timeout=40)
|
||||
if result["ok"]:
|
||||
core.append_operation_log(operation["id"], "Машина запущена снова.")
|
||||
return ""
|
||||
problem = core.cmd_error(result, "virsh start")
|
||||
core.append_operation_log(operation["id"], "Не удалось запустить машину: " + problem)
|
||||
return problem
|
||||
|
||||
|
||||
def backup_worker(operation: dict[str, Any]) -> None:
|
||||
vm = operation["vm_name"]
|
||||
plan = operation["disks"]
|
||||
log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731
|
||||
was_running = core.vm_is_running(vm)
|
||||
if was_running and not shutdown_and_wait(operation, vm):
|
||||
core.finish_operation(operation, False, "Машина не выключилась за 3 минуты. Сохраните работу внутри неё, выключите её вручную и создайте копию ещё раз.")
|
||||
return
|
||||
dest = backup_root() / vm / operation["backup_id"]
|
||||
size = 0
|
||||
try:
|
||||
dest.mkdir(parents=True, exist_ok=False)
|
||||
result = run(["virsh", "dumpxml", vm, "--migratable"], timeout=20)
|
||||
if not result["ok"]:
|
||||
raise BackupError("Не удалось сохранить настройки машины: " + core.cmd_error(result, "virsh dumpxml"))
|
||||
(dest / "vm.xml").write_text(result["stdout"].rstrip() + "\n", encoding="utf-8")
|
||||
log("Настройки машины сохранены в vm.xml.")
|
||||
total = len(plan)
|
||||
for index, disk in enumerate(plan):
|
||||
target_file = dest / f"{disk['target']}.qcow2"
|
||||
label = f"Копируем диск {disk['target']} ({index + 1} из {total})"
|
||||
core.update_operation(operation, progress=int(5 + index / total * 90), message=label)
|
||||
log(f"{label}: {disk['source']} → {target_file}")
|
||||
cmd = ["qemu-img", "convert", "-p", "-O", "qcow2", "-c", disk["source"], str(target_file)]
|
||||
log(" ".join(cmd))
|
||||
code = stream_cmd(cmd, disk_progress_reporter(operation, index, total, label))
|
||||
if code != 0:
|
||||
raise BackupError(f"Не удалось скопировать диск {disk['target']} (qemu-img завершился с кодом {code})")
|
||||
size = sum(item.stat().st_size for item in dest.iterdir() if item.is_file())
|
||||
meta = {"vm": vm, "created_at": core.utc_now(), "disks": plan, "size_bytes": size, "version": core.APP_VERSION, "note": operation.get("note", "")}
|
||||
(dest / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
log(f"Копия готова: {format_bytes(size)} (сжатие включено).")
|
||||
except Exception as exc: # noqa: BLE001 — любая ошибка = операция с понятным текстом
|
||||
remove_backup_dir(dest)
|
||||
log(f"Ошибка: {exc}")
|
||||
problem = start_vm(operation, vm) if was_running else ""
|
||||
message = str(exc)[:200]
|
||||
if problem:
|
||||
message += f". Машину тоже не удалось запустить: {problem}"
|
||||
core.finish_operation(operation, False, message)
|
||||
return
|
||||
problem = start_vm(operation, vm) if was_running else ""
|
||||
message = f"Резервная копия создана: {format_bytes(size)}"
|
||||
if problem:
|
||||
message += f". Но машину не удалось запустить снова: {problem}"
|
||||
core.finish_operation(operation, True, message, size_bytes=size)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- восстановление
|
||||
def rewrite_xml(xml_text: str, new_name: str, disk_map: dict[str, str]) -> str:
|
||||
"""Подготовить XML копии к `virsh define` под именем new_name.
|
||||
|
||||
Меняет <name>, убирает <uuid> и id, переключает диски на новые файлы,
|
||||
удаляет <mac> (libvirt выдаст новые адреса) и <nvram> (libvirt заведёт
|
||||
свой файл под новое имя, чтобы не делить его со старой машиной)."""
|
||||
root = ET.fromstring(xml_text)
|
||||
root.attrib.pop("id", None)
|
||||
name = root.find("name")
|
||||
if name is None:
|
||||
name = ET.Element("name")
|
||||
root.insert(0, name)
|
||||
name.text = new_name
|
||||
for element in root.findall("uuid"):
|
||||
root.remove(element)
|
||||
devices = root.find("devices")
|
||||
if devices is not None:
|
||||
for disk in devices.findall("disk"):
|
||||
target = disk.find("target")
|
||||
dev = target.get("dev") if target is not None else None
|
||||
if disk.get("device") != "disk" or dev not in disk_map:
|
||||
continue
|
||||
source = disk.find("source")
|
||||
if source is None:
|
||||
source = ET.SubElement(disk, "source")
|
||||
source.attrib.clear()
|
||||
source.set("file", disk_map[dev])
|
||||
disk.set("type", "file")
|
||||
driver = disk.find("driver")
|
||||
if driver is None:
|
||||
driver = ET.SubElement(disk, "driver")
|
||||
driver.set("name", "qemu")
|
||||
driver.set("type", "qcow2")
|
||||
for interface in devices.findall("interface"):
|
||||
for mac in interface.findall("mac"):
|
||||
interface.remove(mac)
|
||||
os_element = root.find("os")
|
||||
if os_element is not None:
|
||||
for nvram in os_element.findall("nvram"):
|
||||
os_element.remove(nvram)
|
||||
return ET.tostring(root, encoding="unicode")
|
||||
|
||||
|
||||
def restore_disk_path(new_name: str, target: str, index: int) -> Path:
|
||||
images = core.IMAGES_DIR
|
||||
candidates = [images / f"{new_name}.qcow2"] if index == 0 else []
|
||||
candidates.append(images / f"{new_name}-{target}.qcow2")
|
||||
for candidate in candidates:
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
counter = 2
|
||||
while (images / f"{new_name}-{target}-{counter}.qcow2").exists():
|
||||
counter += 1
|
||||
return images / f"{new_name}-{target}-{counter}.qcow2"
|
||||
|
||||
|
||||
def plan_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict[str, Any]:
|
||||
backup = read_backup(vm, backup_id)
|
||||
if not backup:
|
||||
raise BackupError("Копия не найдена")
|
||||
if not backup["complete"]:
|
||||
raise BackupError("Копия неполная — не хватает файлов. Восстановить из неё нельзя.")
|
||||
new_name = (new_name or "").strip() or vm
|
||||
if not core.valid_vm_name(new_name):
|
||||
raise BackupError("Название машины: латинские буквы, цифры, точка, дефис, от 2 до 63 символов", "name")
|
||||
exists = core.vm_exists(new_name)
|
||||
if exists and not replace:
|
||||
raise BackupError(f"Машина {new_name} уже есть на сервере. Укажите другое название или подтвердите замену.", "exists")
|
||||
if active_operation(new_name) or active_operation(vm):
|
||||
raise BackupError("С этой машиной уже идёт копирование или восстановление — дождитесь завершения")
|
||||
need = sum(int(disk.get("size") or 0) for disk in backup["disks"])
|
||||
reclaim = 0
|
||||
if exists:
|
||||
try:
|
||||
reclaim = sum(disk_info(disk["source"])["actual_size"] for disk in vm_disks(new_name))
|
||||
except BackupError:
|
||||
reclaim = 0
|
||||
free = free_bytes(core.IMAGES_DIR) + reclaim
|
||||
if free < need + SPACE_MARGIN:
|
||||
raise BackupError(f"Недостаточно места для дисков машины: нужно около {format_bytes(need)}, свободно {format_bytes(free)}.", "space")
|
||||
return {"backup": backup, "new_name": new_name, "replace": exists, "need": need}
|
||||
|
||||
|
||||
def start_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict[str, Any]:
|
||||
plan = plan_restore(vm, backup_id, new_name, replace)
|
||||
backup = plan["backup"]
|
||||
title = f"Восстановление {plan['new_name']} из копии"
|
||||
operation = core.new_operation("restore", title, "Ожидает начала", vm_name=plan["new_name"], source_vm=vm, backup_id=backup_id, replace=plan["replace"], disks=backup["disks"], size_bytes=plan["need"])
|
||||
core.run_operation(operation, restore_worker)
|
||||
return operation
|
||||
|
||||
|
||||
def remove_vm(operation: dict[str, Any], name: str) -> None:
|
||||
"""Удалить существующую машину вместе с дисками перед восстановлением."""
|
||||
log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731
|
||||
if core.vm_is_running(name):
|
||||
log(f"Машина {name} работает — выключаем принудительно.")
|
||||
result = run(["virsh", "destroy", name], timeout=30)
|
||||
if not result["ok"]:
|
||||
raise BackupError("Не удалось выключить старую машину: " + core.cmd_error(result, "virsh destroy"))
|
||||
for cdrom in vm_cdroms_with_media(name):
|
||||
# Иначе `undefine --remove-all-storage` удалит и вставленный ISO-образ.
|
||||
result = run(["virsh", "change-media", name, cdrom["target"], "--eject", "--config"], timeout=20)
|
||||
if not result["ok"]:
|
||||
raise BackupError(f"Не удалось извлечь образ из привода {cdrom['target']}: " + core.cmd_error(result, "virsh change-media"))
|
||||
log(f"Образ извлечён из привода {cdrom['target']}, чтобы он не был удалён вместе с машиной.")
|
||||
result = run(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"], timeout=180)
|
||||
if not result["ok"]:
|
||||
raise BackupError("Не удалось удалить старую машину: " + core.cmd_error(result, "virsh undefine"))
|
||||
log(f"Старая машина {name} удалена вместе с дисками.")
|
||||
|
||||
|
||||
def restore_worker(operation: dict[str, Any]) -> None:
|
||||
new_name = operation["vm_name"]
|
||||
log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731
|
||||
backup = read_backup(operation["source_vm"], operation["backup_id"])
|
||||
created: list[Path] = []
|
||||
try:
|
||||
if not backup or not backup["complete"]:
|
||||
raise BackupError("Копия не найдена или неполная")
|
||||
if operation.get("replace"):
|
||||
core.update_operation(operation, progress=3, message=f"Удаляем существующую машину {new_name}…")
|
||||
remove_vm(operation, new_name)
|
||||
core.IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
disk_map: dict[str, str] = {}
|
||||
total = len(backup["disks"])
|
||||
for index, disk in enumerate(backup["disks"]):
|
||||
source = Path(backup["path"]) / f"{disk['target']}.qcow2"
|
||||
target = restore_disk_path(new_name, disk["target"], index)
|
||||
created.append(target)
|
||||
label = f"Восстанавливаем диск {disk['target']} ({index + 1} из {total})"
|
||||
core.update_operation(operation, progress=int(5 + index / total * 90), message=label)
|
||||
log(f"{label}: {source} → {target}")
|
||||
cmd = ["qemu-img", "convert", "-p", "-O", "qcow2", str(source), str(target)]
|
||||
log(" ".join(cmd))
|
||||
code = stream_cmd(cmd, disk_progress_reporter(operation, index, total, label))
|
||||
if code != 0:
|
||||
raise BackupError(f"Не удалось восстановить диск {disk['target']} (qemu-img завершился с кодом {code})")
|
||||
disk_map[disk["target"]] = str(target)
|
||||
core.update_operation(operation, progress=96, message="Регистрируем машину…")
|
||||
xml = rewrite_xml((Path(backup["path"]) / "vm.xml").read_text(encoding="utf-8"), new_name, disk_map)
|
||||
with tempfile.NamedTemporaryFile("w", dir=backup["path"], prefix="restore-", suffix=".xml", delete=False, encoding="utf-8") as handle:
|
||||
handle.write(xml)
|
||||
tmp_xml = Path(handle.name)
|
||||
try:
|
||||
result = run(["virsh", "define", str(tmp_xml)], timeout=30)
|
||||
finally:
|
||||
tmp_xml.unlink(missing_ok=True)
|
||||
if not result["ok"]:
|
||||
raise BackupError("Не удалось зарегистрировать машину: " + core.cmd_error(result, "virsh define"))
|
||||
run(["virsh", "pool-refresh", core.IMAGES_POOL], timeout=20)
|
||||
log(f"Машина {new_name} зарегистрирована.")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
for path in created:
|
||||
path.unlink(missing_ok=True)
|
||||
log(f"Ошибка: {exc}")
|
||||
core.finish_operation(operation, False, str(exc)[:240])
|
||||
return
|
||||
created_label = backup["created_label"] if backup else ""
|
||||
core.finish_operation(operation, True, f"Машина {new_name} восстановлена из копии от {created_label}. Она выключена — запустите её, когда будете готовы.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- страницы
|
||||
def page_messages(request: Request) -> dict[str, str]:
|
||||
return {"backup_message": request.query_params.get("backup_message", ""), "backup_error": request.query_params.get("backup_error", "")}
|
||||
|
||||
|
||||
def vm_summary(name: str) -> dict[str, Any]:
|
||||
dominfo = run(["virsh", "dominfo", name], timeout=10)["stdout"]
|
||||
info = presenters.parse_dominfo(dominfo)
|
||||
return {"name": name, "info": info, "state": info["state"] or core.vm_state(name)}
|
||||
|
||||
|
||||
@router.get("/backups")
|
||||
def backups_page(request: Request):
|
||||
auth = core.require_auth(request)
|
||||
if auth:
|
||||
return auth
|
||||
items = list_backups()
|
||||
context = {
|
||||
"groups": group_by_vm(items, existing_vm_names()),
|
||||
"storage": storage_summary(items),
|
||||
"running": [op for op in core.running_operations() if op.get("type") in ("backup", "restore")],
|
||||
**page_messages(request),
|
||||
}
|
||||
return core.render(request, "backups.html", context)
|
||||
|
||||
|
||||
@router.post("/backups/{vm}/{backup_id}/delete")
|
||||
def backup_delete(request: Request, vm: str, backup_id: str, back_to: str = Form("", alias="next")):
|
||||
auth = core.require_auth(request)
|
||||
if auth:
|
||||
return auth
|
||||
back = back_to if back_to == f"/vm/{vm}/backups" and core.valid_vm_name(vm) else "/backups"
|
||||
try:
|
||||
delete_backup(vm, backup_id)
|
||||
except BackupError as exc:
|
||||
return core.redirect_with_message(back, "backup_error", str(exc))
|
||||
return core.redirect_with_message(back, "backup_message", f"Копия от {format_backup_date(backup_id)} удалена")
|
||||
|
||||
|
||||
def restore_form(request: Request, backup: dict[str, Any], name: str, exists: bool, error: str = "", status_code: int = 200):
|
||||
need = sum(int(disk.get("size") or 0) for disk in backup["disks"])
|
||||
context = {"backup": backup, "name": name, "exists": exists, "error": error, "need": format_bytes(need), "free": format_bytes(free_bytes(core.IMAGES_DIR)), "images_dir": str(core.IMAGES_DIR)}
|
||||
return core.render(request, "backup_restore.html", context, status_code=status_code)
|
||||
|
||||
|
||||
@router.get("/backups/{vm}/{backup_id}/restore")
|
||||
def backup_restore_page(request: Request, vm: str, backup_id: str):
|
||||
auth = core.require_auth(request)
|
||||
if auth:
|
||||
return auth
|
||||
backup = read_backup(vm, backup_id)
|
||||
if not backup:
|
||||
return core.redirect_with_message("/backups", "backup_error", "Копия не найдена")
|
||||
return restore_form(request, backup, vm, core.vm_exists(vm))
|
||||
|
||||
|
||||
@router.post("/backups/{vm}/{backup_id}/restore")
|
||||
def backup_restore_submit(request: Request, vm: str, backup_id: str, name: str = Form(""), replace: str = Form("")):
|
||||
auth = core.require_auth(request)
|
||||
if auth:
|
||||
return auth
|
||||
backup = read_backup(vm, backup_id)
|
||||
if not backup:
|
||||
return core.redirect_with_message("/backups", "backup_error", "Копия не найдена")
|
||||
name = (name or "").strip() or vm
|
||||
try:
|
||||
operation = start_restore(vm, backup_id, name, replace == "1")
|
||||
except BackupError as exc:
|
||||
exists = exc.code == "exists" or (core.valid_vm_name(name) and core.vm_exists(name))
|
||||
return restore_form(request, backup, name, exists, str(exc), status_code=400)
|
||||
return RedirectResponse(url=f"/operations/{operation['id']}", status_code=303)
|
||||
|
||||
|
||||
@router.get("/vm/{name}/backups")
|
||||
def vm_backups_page(request: Request, name: str):
|
||||
auth = core.require_auth(request)
|
||||
if auth:
|
||||
return auth
|
||||
if not core.valid_vm_name(name) or not core.vm_exists(name):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
items = list_backups(name)
|
||||
context = {"vm": vm_summary(name), "backups": items, "storage": storage_summary(), "active": active_operation(name), **page_messages(request)}
|
||||
return core.render(request, "vm_backups.html", context)
|
||||
|
||||
|
||||
@router.post("/vm/{name}/backups/create")
|
||||
def vm_backup_create(request: Request, name: str, note: str = Form("")):
|
||||
auth = core.require_auth(request)
|
||||
if auth:
|
||||
return auth
|
||||
if not core.valid_vm_name(name):
|
||||
return RedirectResponse(url="/", status_code=303)
|
||||
try:
|
||||
operation = create_backup(name, note)
|
||||
except BackupError as exc:
|
||||
return core.redirect_with_message(f"/vm/{name}/backups", "backup_error", str(exc))
|
||||
return RedirectResponse(url=f"/operations/{operation['id']}", status_code=303)
|
||||
|
||||
|
||||
if __name__ == "__main__": # python3 web/features/backups.py <машина> [заметка]
|
||||
try:
|
||||
finished = create_backup(sys.argv[1], " ".join(sys.argv[2:]), wait=True)
|
||||
except (IndexError, BackupError) as error:
|
||||
print(f"Ошибка: {error or 'укажите имя машины'}")
|
||||
sys.exit(2)
|
||||
print(f"{finished['status']}: {finished['message']}")
|
||||
sys.exit(0 if finished["status"] == "success" else 1)
|
||||
@@ -298,6 +298,15 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; }
|
||||
.switch[aria-checked="true"] { background: var(--success); }
|
||||
.switch[aria-checked="true"]::after { transform: translateX(18px); }
|
||||
|
||||
/* checkbox row (consent for destructive actions) */
|
||||
.check { display: flex; gap: 12px; align-items: flex-start; padding: 13px 16px; border: 1.5px solid var(--border); border-radius: var(--radius); background: var(--surface-2); cursor: pointer; }
|
||||
.check input { width: 18px; height: 18px; flex: none; margin: 2px 0 0; accent-color: var(--brand); cursor: pointer; }
|
||||
.check > span { display: grid; gap: 2px; }
|
||||
.check > span > span { color: var(--text-3); font-size: 12.5px; }
|
||||
.check.danger { border-color: color-mix(in srgb, var(--danger) 30%, transparent); background: var(--danger-soft); }
|
||||
.check.danger input { accent-color: var(--danger); }
|
||||
.check.danger strong { color: var(--danger); }
|
||||
|
||||
/* option cards (radio) */
|
||||
.options { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||
.options-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{% from "_ui.html" import icon, badge %}
|
||||
{#- Строки списка копий. Ожидает: rows (список копий), back (куда вернуться после удаления). -#}
|
||||
{% for b in rows %}
|
||||
<div class="list-item">
|
||||
<span class="list-icon {{ 'brand' if b.complete else 'danger' }}">{{ icon('database' if b.complete else 'triangle-alert') }}</span>
|
||||
<div class="grow">
|
||||
<div class="title">{{ b.created_label }}{% if b.note %} <span class="tag" style="margin-left:6px">{{ b.note }}</span>{% endif %}</div>
|
||||
<div class="meta">{{ b.size }} · {{ b.disk_count }} {{ plural(b.disk_count, 'диск', 'диска', 'дисков') }}{% if b.version %} · Virtuality {{ b.version }}{% endif %}{% if not b.complete %} · <span style="color:var(--danger)">не хватает файлов</span>{% endif %}</div>
|
||||
</div>
|
||||
{% 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>
|
||||
{% 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">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_ui.html" import icon, badge, alert %}
|
||||
{% set page_title = 'Восстановить ' ~ backup.vm %}
|
||||
|
||||
{% block page_header %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="eyebrow"><a href="/backups">{{ icon('arrow-left', 'icon-sm') }} Резервные копии</a></div>
|
||||
<h1>Восстановить машину из копии</h1>
|
||||
<p class="lead">Копия машины <strong>{{ backup.vm }}</strong> от {{ backup.created_label }}{% if backup.note %} — «{{ backup.note }}»{% endif %}.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block alerts %}
|
||||
{% if error %}{{ alert('danger', error) }}{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="grid grid-main">
|
||||
<article class="card">
|
||||
<form method="post" action="/backups/{{ backup.vm }}/{{ backup.id }}/restore" data-busy="Запускаем…" {% if exists %}data-confirm="Машина {{ name }} будет удалена вместе со всеми её дисками и заменена копией от {{ backup.created_label }}. Отменить это будет нельзя." data-confirm-title="Заменить машину {{ name }}?" data-confirm-button="Заменить и восстановить"{% endif %}>
|
||||
<div class="card-body form-grid">
|
||||
<div class="field">
|
||||
<label for="name">Название восстановленной машины</label>
|
||||
<input class="input" id="name" type="text" name="name" value="{{ name }}" pattern="[a-zA-Z0-9][a-zA-Z0-9_.\-]{1,62}" autocomplete="off" required>
|
||||
<span class="hint">Оставьте как есть, чтобы вернуть машину {{ backup.vm }}, или введите новое название — получится вторая независимая машина.</span>
|
||||
</div>
|
||||
|
||||
{% if exists %}
|
||||
<div class="alert warning">{{ icon('triangle-alert') }}<div><strong>Машина {{ name }} уже есть на сервере</strong>Чтобы восстановить копию под этим названием, существующую машину придётся удалить вместе с её дисками. Если хотите сохранить обе — укажите другое название.</div></div>
|
||||
<label class="check danger">
|
||||
<input type="checkbox" name="replace" value="1" required>
|
||||
<span><strong>Заменить существующую машину {{ name }}</strong><span>Она будет выключена и удалена вместе с дисками, а на её место встанет копия.</span></span>
|
||||
</label>
|
||||
{% else %}
|
||||
<input type="hidden" name="replace" value="">
|
||||
{% endif %}
|
||||
|
||||
<div class="kv" style="grid-template-columns:repeat(3,minmax(0,1fr))">
|
||||
<div><span>Дисков в копии</span><strong>{{ backup.disk_count }} · {{ backup.size }}</strong></div>
|
||||
<div><span>Понадобится места</span><strong>около {{ need }}</strong></div>
|
||||
<div><span>Свободно</span><strong>{{ free }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a class="btn" href="/backups">Отмена</a>
|
||||
<button type="submit" class="btn {{ 'btn-danger' if exists else 'btn-primary' }}">{{ icon('rotate-cw') }}{{ 'Заменить и восстановить' if exists else 'Восстановить' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<aside class="stack">
|
||||
<article class="card">
|
||||
<div class="card-header"><h2>Что будет сделано</h2></div>
|
||||
<div class="checklist">
|
||||
{% for title, text in [
|
||||
('Диски копируются обратно', 'В папку ' ~ images_dir ~ ' — распаковка занимает несколько минут.'),
|
||||
('Настройки применяются заново', 'Процессор, память, сеть и порядок загрузки — как на момент копии.'),
|
||||
('Сетевые адреса выдаются новые', 'Чтобы восстановленная машина не конфликтовала с оригиналом. IP-адрес внутри может измениться.'),
|
||||
('Машина остаётся выключенной', 'Проверьте настройки и запустите её сами.'),
|
||||
] %}
|
||||
<div class="check-item success">{{ icon('circle-check') }}<div><strong>{{ title }}</strong><span>{{ text }}</span></div></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<div class="card-body stack-sm">
|
||||
<div class="row">{{ icon('info') }}<h3>Копия не пострадает</h3></div>
|
||||
<p class="muted small">Восстанавливать из одной копии можно сколько угодно раз — файлы копии остаются на месте.</p>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_ui.html" import icon, badge, meter, empty, alert %}
|
||||
{% set page_title = 'Резервные копии' %}
|
||||
|
||||
{% block page_header %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Резервные копии</h1>
|
||||
<p class="lead">Полная копия машины: настройки и все её диски. Из копии можно восстановить машину на этом сервере — под тем же или новым названием.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block alerts %}
|
||||
{% if backup_message %}{{ alert('success', backup_message) }}{% endif %}
|
||||
{% if backup_error %}{{ alert('danger', backup_error) }}{% endif %}
|
||||
{% for op in running %}
|
||||
<div class="alert info">{{ icon('activity') }}<div><strong>{{ op.title }} — {{ op.progress|default(0) }}%</strong>{{ op.message }}</div><a class="btn btn-sm alert-action" href="/operations/{{ op.id }}">Следить</a></div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="grid grid-3">
|
||||
<article class="card stat">
|
||||
<div class="stat-head">Копий на сервере<span class="stat-icon">{{ icon('database', 'icon-sm') }}</span></div>
|
||||
<div class="stat-value">{{ storage.count }}<small>{{ plural(storage.count, 'копия', 'копии', 'копий') }}</small></div>
|
||||
<div class="stat-foot">{{ groups|length }} {{ plural(groups|length, 'машина', 'машины', 'машин') }}</div>
|
||||
</article>
|
||||
<article class="card stat">
|
||||
<div class="stat-head">Занято копиями<span class="stat-icon">{{ icon('hard-drive', 'icon-sm') }}</span></div>
|
||||
<div class="stat-value">{{ storage.used }}</div>
|
||||
{{ meter(storage.used_pct, 'brand') }}
|
||||
<div class="stat-foot">{{ storage.used_pct }}% места на диске сервера</div>
|
||||
</article>
|
||||
<article class="card stat">
|
||||
<div class="stat-head">Свободно для новых копий<span class="stat-icon">{{ icon('layers', 'icon-sm') }}</span></div>
|
||||
<div class="stat-value">{{ storage.free }}</div>
|
||||
<div class="stat-foot">Перед копированием место проверяется автоматически</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{% if groups %}
|
||||
<div class="stack mt-3">
|
||||
{% for g in groups %}
|
||||
<article class="card">
|
||||
<div class="card-header">
|
||||
<div class="row">
|
||||
<span class="vm-avatar">{{ icon('monitor') }}</span>
|
||||
<div>
|
||||
<h2>{% if g.exists %}<a href="/vm/{{ g.vm }}" style="color:inherit">{{ g.vm }}</a>{% else %}{{ g.vm }}{% endif %}{% if not g.exists %} {{ badge('машина удалена', 'warning', cls='hide-sm') }}{% endif %}</h2>
|
||||
<p>{{ g.count }} {{ plural(g.count, 'копия', 'копии', 'копий') }} · {{ g.size }}{% if not g.exists %} · машины больше нет на сервере, но её можно восстановить{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if g.exists %}<a class="btn btn-sm" href="/vm/{{ g.vm }}/backups">{{ icon('plus', 'icon-sm') }}Создать копию</a>{% endif %}
|
||||
</div>
|
||||
<div class="list">
|
||||
{% with rows = g.backups, back = '/backups' %}{% include "_backup_rows.html" %}{% endwith %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<article class="card mt-3">
|
||||
{{ empty('database', 'Копий пока нет', 'Откройте страницу машины, перейдите на вкладку «Резервные копии» и нажмите «Создать копию». Копирование идёт в фоне — страницу можно закрыть.', '/', 'К списку машин', 'monitor') }}
|
||||
</article>
|
||||
{% endif %}
|
||||
|
||||
<section class="grid grid-2 mt-3">
|
||||
<article class="card">
|
||||
<div class="card-body stack-sm">
|
||||
<div class="row">{{ icon('shield-check') }}<h3>Что входит в копию</h3></div>
|
||||
<p class="muted small">Настройки машины (процессор, память, сеть, порядок загрузки) и все её диски целиком. Установочные образы (ISO) не копируются — они лежат отдельно в разделе «Образы ОС». Диски сжимаются, поэтому копия обычно меньше самой машины.</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<div class="card-body stack-sm">
|
||||
<div class="row">{{ icon('info') }}<h3>Как это работает</h3></div>
|
||||
<p class="muted small">Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной и получает новые сетевые адреса, чтобы не мешать оригиналу. Копии хранятся в папке <code>/var/lib/virtuality/backups</code>.</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -39,6 +39,7 @@
|
||||
<div class="nav-section">Ресурсы</div>
|
||||
{{ nav_link('/iso', 'disc-3', 'Образы ОС', path.startswith('/iso') or path.startswith('/disk-images')) }}
|
||||
{{ nav_link('/network', 'globe', 'Сеть и доступ', path.startswith('/network')) }}
|
||||
{{ nav_link('/backups', 'database', 'Резервные копии', path.startswith('/backups')) }}
|
||||
|
||||
<div class="nav-section">Система</div>
|
||||
{{ nav_link('/operations', 'list-checks', 'Задачи', path.startswith('/operations')) }}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_ui.html" import icon, badge, vm_badge, empty, alert %}
|
||||
{% set page_title = vm.name ~ ' — резервные копии' %}
|
||||
{% set s = vm.state|vm_state %}
|
||||
|
||||
{% block page_header %}
|
||||
<div class="page-header" data-live-vm="{{ vm.name }}">
|
||||
<div>
|
||||
<div class="eyebrow"><a href="/">{{ icon('arrow-left', 'icon-sm') }} Все машины</a></div>
|
||||
<div class="title-row">
|
||||
<span class="vm-avatar {{ 'on' if s.running else '' }}" style="width:48px;height:48px">{{ icon('monitor', 'icon-lg') }}<span class="status-dot {{ s.tone }}" data-live="dot"></span></span>
|
||||
<div>
|
||||
<div class="row"><h1>{{ vm.name }}</h1>{{ vm_badge(vm.state, 'lg') }}</div>
|
||||
<p class="subtle mt-1">{{ vm.info.vcpus }} {{ plural(vm.info.vcpus, 'ядро', 'ядра', 'ядер') }} · {{ vm.info.memory_mb|format_mb }} памяти</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block alerts %}
|
||||
{% if backup_message %}{{ alert('success', backup_message) }}{% endif %}
|
||||
{% if backup_error %}{{ alert('danger', backup_error) }}{% endif %}
|
||||
{% if active %}
|
||||
<div class="alert info">{{ icon('activity') }}<div><strong>{{ active.title }} — {{ active.progress|default(0) }}%</strong>{{ active.message }}</div><a class="btn btn-sm alert-action" href="/operations/{{ active.id }}">Следить</a></div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="tabs" role="tablist">
|
||||
<a href="/vm/{{ vm.name }}#overview">{{ icon('layout-dashboard', 'icon-sm') }}Обзор</a>
|
||||
<a href="/vm/{{ vm.name }}#resources">{{ icon('cpu', 'icon-sm') }}Ресурсы</a>
|
||||
<a href="/vm/{{ vm.name }}#storage">{{ icon('hard-drive', 'icon-sm') }}Диски и загрузка</a>
|
||||
<a href="/vm/{{ vm.name }}/backups" class="active" aria-current="page">{{ icon('database', 'icon-sm') }}Резервные копии{% if backups %}<span class="tag">{{ backups|length }}</span>{% endif %}</a>
|
||||
<a href="/vm/{{ vm.name }}#expert">{{ icon('terminal', 'icon-sm') }}Для специалистов</a>
|
||||
</nav>
|
||||
|
||||
<section class="grid grid-main">
|
||||
<article class="card">
|
||||
<div class="card-header"><div><h2>Копии этой машины</h2><p>Новые сверху. Из любой можно восстановить машину — под тем же или новым названием.</p></div></div>
|
||||
{% if backups %}
|
||||
<div class="list">
|
||||
{% with rows = backups, back = '/vm/' ~ vm.name ~ '/backups' %}{% include "_backup_rows.html" %}{% endwith %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty('database', 'Копий пока нет', 'Создайте первую копию — это займёт несколько минут и защитит от неудачных обновлений и случайных ошибок.') }}
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<aside class="stack">
|
||||
<article class="card">
|
||||
<div class="card-header"><div><h2>Создать копию</h2><p>Настройки и все диски машины</p></div></div>
|
||||
<form method="post" action="/vm/{{ vm.name }}/backups/create" class="stack-sm" data-busy="Запускаем…">
|
||||
<div class="card-body stack-sm">
|
||||
{% if s.running %}
|
||||
{{ alert('info', 'Машина работает: на время копирования она будет аккуратно выключена, а потом запущена снова.') }}
|
||||
{% endif %}
|
||||
<div class="field">
|
||||
<label for="note">Заметка <span class="subtle" style="font-weight:400">— необязательно</span></label>
|
||||
<input class="input" id="note" type="text" name="note" maxlength="120" placeholder="например, перед обновлением сайта" autocomplete="off">
|
||||
<span class="hint">Поможет потом вспомнить, зачем делали копию.</span>
|
||||
</div>
|
||||
<div class="kv">
|
||||
<div><span>Свободно для копий</span><strong>{{ storage.free }}</strong></div>
|
||||
<div><span>Уже занято копиями</span><strong>{{ storage.used }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="submit" class="btn btn-primary btn-block" {% if active %}disabled{% endif %}>{{ icon('database') }}Создать копию</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
<article class="card">
|
||||
<div class="card-body stack-sm">
|
||||
<div class="row">{{ icon('clock') }}<h3>Сколько это займёт</h3></div>
|
||||
<p class="muted small">Обычно несколько минут: зависит от размера дисков. Ход выполнения виден в разделе «Задачи», страницу можно закрыть.</p>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -54,6 +54,7 @@
|
||||
<button type="button" data-tab="resources" role="tab">{{ icon('cpu', 'icon-sm') }}Ресурсы</button>
|
||||
<button type="button" data-tab="storage" role="tab">{{ icon('hard-drive', 'icon-sm') }}Диски и загрузка</button>
|
||||
<a href="/vm/{{ vm.name }}/snapshots">{{ icon('camera', 'icon-sm') }}Снимки</a>
|
||||
<a href="/vm/{{ vm.name }}/backups" role="tab">{{ icon('database', 'icon-sm') }}Резервные копии</a>
|
||||
<button type="button" data-tab="expert" role="tab">{{ icon('terminal', 'icon-sm') }}Для специалистов</button>
|
||||
</nav>
|
||||
|
||||
|
||||
Ссылка в новой задаче
Block a user