Резервные копии машин были добавлены: создание, восстановление, удаление

Первый модуль-фича на общем core.py — web/features/backups.py,
подключён в app.py одной строкой include_router.

- копия = папка /var/lib/virtuality/backups/<машина>/<ГГГГММДД-ЧЧММ>/
  с vm.xml (virsh dumpxml --migratable), <диск>.qcow2 на каждый диск
  и meta.json (машина, дата, диски, размер, версия, заметка);
- создание — фоновая операция: свободное место проверяется заранее по
  qemu-img info, работающая машина мягко выключается (ждём до 3 минут,
  без принудительного выключения), диски копируются qemu-img convert -c
  с прогрессом из строк «(NN.NN/100%)», после чего машина запускается;
- восстановление — операция: диски распаковываются в images/<имя>[-<диск>]
  .qcow2, в XML меняется имя, убираются uuid, MAC и nvram, затем
  virsh define; восстановление под занятым именем требует галочку
  «заменить существующую» — старая машина удаляется с дисками, а ISO
  перед этим извлекается из привода, чтобы --remove-all-storage его не
  стёр;
- страницы: «Резервные копии» в меню (все копии по машинам, занятое и
  свободное место), вкладка «Резервные копии» на странице машины с
  формой «Создать копию» и заметкой, страница восстановления с
  понятным списком шагов;
- create_backup(vm, note, wait=True) можно вызывать без HTTP —
  задел под расписание (python3 web/features/backups.py web01 «заметка»);
- tests/conftest.py: каталог backups, патчи core.*, вывод virsh
  domblklist --details, dumpxml --migratable и qemu-img info из fixtures;
  tests/test_backups.py — 33 теста парсеров, воркеров и маршрутов;
  tests/dev_server.py показывает демо-копии.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
Claude
2026-09-25 14:32:05 +00:00
родитель 1e29134fb3
Коммит 2450278d93
13 изменённых файлов: 1433 добавлений и 8 удалений
+23 -6
Просмотреть файл
@@ -35,6 +35,12 @@ DUMPXML = """<domain type='kvm'>
""" """
FIXTURES = Path(__file__).resolve().parent / "fixtures"
DOMBLKLIST_DETAILS = (FIXTURES / "domblklist-details.txt").read_text()
DUMPXML_MIGRATABLE = (FIXTURES / "dumpxml-web01.xml").read_text()
QEMU_IMG_INFO = (FIXTURES / "qemu-img-info.json").read_text()
def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict: def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict:
return {"ok": ok, "code": 0 if ok else 1, "stdout": stdout, "stderr": stderr, "cmd": ""} return {"ok": ok, "code": 0 if ok else 1, "stdout": stdout, "stderr": stderr, "cmd": ""}
@@ -42,11 +48,17 @@ def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict:
def fake_run_cmd(cmd, timeout=12, **_kwargs): def fake_run_cmd(cmd, timeout=12, **_kwargs):
joined = " ".join(cmd) joined = " ".join(cmd)
if cmd[:2] == ["virsh", "list"]: 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") return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
if cmd[:2] == ["virsh", "dominfo"]: 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") 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"]: 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"]: if cmd[:2] == ["virsh", "domstate"]:
return fake_result("running") return fake_result("running")
if cmd[:2] == ["virsh", "domifaddr"]: if cmd[:2] == ["virsh", "domifaddr"]:
@@ -75,6 +87,7 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
@pytest.fixture() @pytest.fixture()
def data_dirs(tmp_path, monkeypatch): def data_dirs(tmp_path, monkeypatch):
import app import app
import core
import host_profile import host_profile
import network_core import network_core
import update_core import update_core
@@ -83,6 +96,7 @@ def data_dirs(tmp_path, monkeypatch):
"iso": tmp_path / "iso", "iso": tmp_path / "iso",
"images": tmp_path / "images", "images": tmp_path / "images",
"disk_images": tmp_path / "disk-images", "disk_images": tmp_path / "disk-images",
"backups": tmp_path / "backups",
"operations": tmp_path / "operations", "operations": tmp_path / "operations",
"network": tmp_path / "network", "network": tmp_path / "network",
"config": tmp_path / "config", "config": tmp_path / "config",
@@ -91,10 +105,13 @@ def data_dirs(tmp_path, monkeypatch):
} }
for path in dirs.values(): for path in dirs.values():
path.mkdir() path.mkdir()
monkeypatch.setattr(app, "ISO_DIR", dirs["iso"]) for module in (app, core):
monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"]) monkeypatch.setattr(module, "ISO_DIR", dirs["iso"])
monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"]) monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"])
monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"]) 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, "CONFIG_DIR", dirs["config"])
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"]) monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"]) monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
@@ -108,7 +125,7 @@ def data_dirs(tmp_path, monkeypatch):
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json") monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log") monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT) monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
for module in (app, network_core, host_profile): for module in (app, core, network_core, host_profile):
monkeypatch.setattr(module, "run_cmd", fake_run_cmd) monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
return dirs return dirs
+30 -2
Просмотреть файл
@@ -13,16 +13,40 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import conftest # noqa: E402 (sets sys.path and env) import conftest # noqa: E402 (sets sys.path and env)
import app # noqa: E402 import app # noqa: E402
import core # noqa: E402
import host_profile # noqa: E402 import host_profile # noqa: E402
import network_core # noqa: E402 import network_core # noqa: E402
import update_core # noqa: E402 import update_core # noqa: E402
def seed_backups(backups_dir: Path) -> None:
"""Несколько копий, как после пары недель работы: web01 — три, db01 — одна."""
vm_xml = conftest.DUMPXML_MIGRATABLE
demo = [
("web01", "20260924-0300", 2_684_354_560, "", [("vda", 6_443_499_520)]),
("web01", "20260917-0300", 2_598_000_000, "", [("vda", 6_300_000_000)]),
("web01", "20260912-1842", 2_412_000_000, "Перед обновлением сайта", [("vda", 6_100_000_000)]),
("db01", "20260921-2215", 9_126_805_504, "После переноса базы", [("vda", 12_884_901_888), ("vdb", 8_589_934_592)]),
]
for vm, backup_id, size, note, disks in demo:
path = backups_dir / vm / backup_id
path.mkdir(parents=True, exist_ok=True)
(path / "vm.xml").write_text(vm_xml.replace("web01", vm))
for target, _ in disks:
with (path / f"{target}.qcow2").open("wb") as handle:
handle.truncate(1024 * 1024)
meta = {"vm": vm, "created_at": f"{backup_id[:4]}-{backup_id[4:6]}-{backup_id[6:8]} {backup_id[9:11]}:{backup_id[11:]}:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": s} for t, s in disks], "size_bytes": size, "version": "0.11.0", "note": note}
(path / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2))
def seed(root: Path) -> None: 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(): for path in dirs.values():
path.mkdir(parents=True, exist_ok=True) 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"] app.ISO_DIR, app.IMAGES_DIR, app.DISK_IMAGES_DIR, app.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"]
core.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"]
core.BACKUPS_DIR, core.CONFIG_DIR = dirs["backups"], dirs["config"]
seed_backups(dirs["backups"])
network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"] network_core.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.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json"
network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml" network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml"
@@ -32,7 +56,7 @@ def seed(root: Path) -> None:
host_profile.PROFILE_FILE = dirs["config"] / "host_profile.json" 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.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] update_core.SOURCE_DIR = Path(__file__).resolve().parents[1]
for module in (app, network_core, host_profile): for module in (app, core, network_core, host_profile):
module.run_cmd = conftest.fake_run_cmd module.run_cmd = conftest.fake_run_cmd
app.auth.verify_password = lambda user, password: True app.auth.verify_password = lambda user, password: True
@@ -49,6 +73,10 @@ def seed(root: Path) -> None:
op_id = str(uuid.uuid4()) op_id = str(uuid.uuid4())
app.write_operation({"id": op_id, "type": "vm_create", "title": title, "status": status, "progress": progress, "message": "Готово" if status == "success" else "qemu-img: 64%" if status == "running" else "virt-install завершился с ошибкой: 1", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester"}) app.write_operation({"id": op_id, "type": "vm_create", "title": title, "status": status, "progress": progress, "message": "Готово" if status == "success" else "qemu-img: 64%" if status == "running" else "virt-install завершился с ошибкой: 1", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester"})
app.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...") app.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...")
op_id = str(uuid.uuid4())
app.write_operation({"id": op_id, "type": "backup", "title": "Резервная копия web01", "status": "success", "progress": 100, "message": "Резервная копия создана: 2.5 ГБ", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester", "vm_name": "web01", "backup_id": "20260924-0300"})
for line in ("Машина работает — отправляем команду выключения.", "Машина выключена.", "Настройки машины сохранены в vm.xml.", "Копируем диск vda (1 из 1): 100%", "Машина запущена снова."):
app.append_operation_log(op_id, line)
if __name__ == "__main__": if __name__ == "__main__":
+418
Просмотреть файл
@@ -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()
+4
Просмотреть файл
@@ -2016,3 +2016,7 @@ def api_health(request: Request):
if not get_current_user(request): if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return {"system": system_summary(), "host_profile": host_profile.load_host_profile(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()} return {"system": system_summary(), "host_profile": host_profile.load_host_profile(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()}
import features.backups as backups # noqa: E402
app.include_router(backups.router)
Просмотреть файл
+691
Просмотреть файл
@@ -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)
+9
Просмотреть файл
@@ -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"] { background: var(--success); }
.switch[aria-checked="true"]::after { transform: translateX(18px); } .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) */ /* option cards (radio) */
.options { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); } .options { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
.options-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } .options-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+18
Просмотреть файл
@@ -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 %}
+75
Просмотреть файл
@@ -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 %}
+82
Просмотреть файл
@@ -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 %}
+1
Просмотреть файл
@@ -39,6 +39,7 @@
<div class="nav-section">Ресурсы</div> <div class="nav-section">Ресурсы</div>
{{ nav_link('/iso', 'disc-3', 'Образы ОС', path.startswith('/iso') or path.startswith('/disk-images')) }} {{ nav_link('/iso', 'disc-3', 'Образы ОС', path.startswith('/iso') or path.startswith('/disk-images')) }}
{{ nav_link('/network', 'globe', 'Сеть и доступ', path.startswith('/network')) }} {{ nav_link('/network', 'globe', 'Сеть и доступ', path.startswith('/network')) }}
{{ nav_link('/backups', 'database', 'Резервные копии', path.startswith('/backups')) }}
<div class="nav-section">Система</div> <div class="nav-section">Система</div>
{{ nav_link('/operations', 'list-checks', 'Задачи', path.startswith('/operations')) }} {{ nav_link('/operations', 'list-checks', 'Задачи', path.startswith('/operations')) }}
+81
Просмотреть файл
@@ -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 %}
+1
Просмотреть файл
@@ -53,6 +53,7 @@
<button type="button" data-tab="overview" role="tab">{{ icon('layout-dashboard', 'icon-sm') }}Обзор</button> <button type="button" data-tab="overview" role="tab">{{ icon('layout-dashboard', 'icon-sm') }}Обзор</button>
<button type="button" data-tab="resources" role="tab">{{ icon('cpu', 'icon-sm') }}Ресурсы</button> <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> <button type="button" data-tab="storage" role="tab">{{ icon('hard-drive', 'icon-sm') }}Диски и загрузка</button>
<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> <button type="button" data-tab="expert" role="tab">{{ icon('terminal', 'icon-sm') }}Для специалистов</button>
</nav> </nav>