Бэкапы VM с ротацией и ночным расписанием были добавлены

Страница /backups: ручной бэкап любой VM (сжатый qcow2, работающая VM
ставится на паузу на время копирования), восстановление в выключенную
VM, удаление копий и настройка ночного расписания. Ротация хранит
заданное число копий. Установщик создаёт virtuality-backup.timer,
ночной запуск ведёт журнал /var/log/virtuality/backup.log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKUoZwHMCHnytfswFVRUHw
Этот коммит содержится в:
Claude
2026-08-15 19:26:58 +00:00
родитель 0b0496f6bb
Коммит 52e2033031
5 изменённых файлов: 461 добавлений и 1 удалений
+209
Просмотреть файл
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Бэкапы дисков VM: сжатые qcow2-копии в /var/lib/virtuality/backups.
Без внешних зависимостей — модуль используется и веб-панелью, и ночным
systemd-таймером (запуск: python3 backup_core.py --scheduled).
"""
import json
import re
import subprocess
import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import Any
BACKUPS_DIR = Path("/var/lib/virtuality/backups")
SCHEDULE_FILE = Path("/var/lib/virtuality/config/backup_schedule.json")
SCHEDULE_LOCK = threading.Lock()
LOG_FILE = Path("/var/log/virtuality/backup.log")
DEFAULT_KEEP = 5
def valid_vm_name(name: str) -> bool:
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""))
def valid_backup_filename(name: str) -> bool:
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,120}\.qcow2", name or ""))
def backup_dir_for(vm_name: str) -> Path | None:
if not valid_vm_name(vm_name):
return None
path = (BACKUPS_DIR / vm_name).resolve()
if BACKUPS_DIR.resolve() not in path.parents:
return None
return path
def backup_path(vm_name: str, filename: str) -> Path | None:
vm_dir = backup_dir_for(vm_name)
if vm_dir is None or not valid_backup_filename(filename):
return None
path = (vm_dir / filename).resolve()
if vm_dir not in path.parents:
return None
return path
def new_backup_path(vm_name: str) -> Path | None:
vm_dir = backup_dir_for(vm_name)
if vm_dir is None:
return None
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return vm_dir / f"{vm_name}_{stamp}.qcow2"
def list_backups() -> list[dict[str, Any]]:
backups = []
if not BACKUPS_DIR.exists():
return backups
for vm_dir in sorted(BACKUPS_DIR.iterdir()):
if not vm_dir.is_dir() or not valid_vm_name(vm_dir.name):
continue
for item in sorted(vm_dir.glob("*.qcow2"), reverse=True):
try:
stat = item.stat()
except OSError:
continue
backups.append({
"vm": vm_dir.name,
"name": item.name,
"path": str(item),
"size": f"{stat.st_size / 1024 / 1024:.0f} MB",
"created": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
})
return backups
def rotate_backups(vm_name: str, keep: int) -> list[str]:
"""Удаляет старые бэкапы сверх keep. Возвращает имена удалённых файлов."""
vm_dir = backup_dir_for(vm_name)
if vm_dir is None or not vm_dir.exists() or keep < 1:
return []
files = sorted(vm_dir.glob("*.qcow2"), key=lambda p: p.stat().st_mtime, reverse=True)
removed = []
for stale in files[keep:]:
try:
stale.unlink()
removed.append(stale.name)
except OSError:
pass
return removed
def load_schedule() -> dict[str, Any]:
try:
data = json.loads(SCHEDULE_FILE.read_text())
if isinstance(data, dict):
return {
"enabled": bool(data.get("enabled", False)),
"vms": [name for name in data.get("vms", []) if isinstance(name, str) and valid_vm_name(name)],
"keep": max(1, min(30, int(data.get("keep", DEFAULT_KEEP)))),
}
except Exception:
pass
return {"enabled": False, "vms": [], "keep": DEFAULT_KEEP}
def save_schedule(enabled: bool, vms: list[str], keep: int) -> dict[str, Any]:
config = {
"enabled": bool(enabled),
"vms": sorted({name for name in vms if valid_vm_name(name)}),
"keep": max(1, min(30, int(keep))),
}
with SCHEDULE_LOCK:
SCHEDULE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp_path = SCHEDULE_FILE.with_suffix(".json.tmp")
tmp_path.write_text(json.dumps(config, ensure_ascii=False, indent=2))
tmp_path.replace(SCHEDULE_FILE)
return config
def build_backup_script(vm_name: str, disk_path: str, target: Path) -> str:
"""Скрипт бэкапа: пауза работающей VM на время копии, сжатый qcow2, resume.
Для выключенной VM virsh suspend/resume тихо провалятся — это нормально.
"""
part = f"{target}.part"
return (
"set -uo pipefail; "
f"mkdir -p {target.parent}; "
f"virsh suspend {vm_name} >/dev/null 2>&1 && RESUME=1 || RESUME=0; "
f"qemu-img convert -p -c -O qcow2 {disk_path} {part}; STATUS=$?; "
f"[ \"$RESUME\" = 1 ] && virsh resume {vm_name} >/dev/null 2>&1; "
f"[ $STATUS -ne 0 ] && rm -f {part} && exit $STATUS; "
f"mv {part} {target}"
)
def build_restore_script(disk_path: str, backup_file: Path) -> str:
part = f"{disk_path}.restore"
return (
"set -euo pipefail; "
f"qemu-img convert -p -O qcow2 {backup_file} {part}; "
f"mv {part} {disk_path}"
)
# ---- CLI-режим для ночного systemd-таймера ----
def _log(message: str) -> None:
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with LOG_FILE.open("a", encoding="utf-8") as handle:
handle.write(f"[{stamp}] {message}\n")
def _run(cmd: list[str], timeout: int = 60) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
def first_disk_of(vm_name: str) -> str | None:
result = _run(["virsh", "domblklist", vm_name, "--details"])
if result.returncode != 0:
return None
for line in result.stdout.splitlines()[2:]:
parts = line.split()
if len(parts) >= 4 and parts[1] == "disk" and parts[3].endswith(".qcow2"):
return parts[3]
return None
def run_scheduled_backups() -> int:
config = load_schedule()
if not config["enabled"]:
_log("Расписание выключено — выходим.")
return 0
if not config["vms"]:
_log("Расписание включено, но список VM пуст.")
return 0
failures = 0
for vm_name in config["vms"]:
disk = first_disk_of(vm_name)
if not disk:
_log(f"{vm_name}: qcow2-диск не найден, пропуск.")
failures += 1
continue
target = new_backup_path(vm_name)
if target is None:
_log(f"{vm_name}: некорректное имя VM, пропуск.")
failures += 1
continue
_log(f"{vm_name}: бэкап {disk}{target}")
result = subprocess.run(["bash", "-c", build_backup_script(vm_name, disk, target)], capture_output=True, text=True, check=False)
if result.returncode == 0:
removed = rotate_backups(vm_name, config["keep"])
_log(f"{vm_name}: готово" + (f", ротация удалила: {', '.join(removed)}" if removed else ""))
else:
failures += 1
_log(f"{vm_name}: ошибка бэкапа (код {result.returncode}): {result.stderr.strip()[:400]}")
return 1 if failures else 0
if __name__ == "__main__":
if "--scheduled" in sys.argv:
sys.exit(run_scheduled_backups())
print("Использование: backup_core.py --scheduled", file=sys.stderr)
sys.exit(2)