Бэкапы 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 удалений
+29
Просмотреть файл
@@ -11,6 +11,8 @@ VENV_DIR="/opt/virtuality/venv"
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
BACKUP_SERVICE_FILE="/etc/systemd/system/virtuality-backup.service"
BACKUP_TIMER_FILE="/etc/systemd/system/virtuality-backup.timer"
PORT="${VIRTUALITY_WEB_PORT:-8088}"
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
LOG_DIR="/var/log/virtuality"
@@ -286,6 +288,33 @@ EOF
else
warn "auto_update_check.sh не найден, автообновление пропущено"
fi
cat > "$BACKUP_SERVICE_FILE" <<EOF
[Unit]
Description=Virtuality scheduled VM backups
After=libvirtd.service
[Service]
Type=oneshot
User=root
Group=root
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/backup_core.py --scheduled
EOF
cat > "$BACKUP_TIMER_FILE" <<EOF
[Unit]
Description=Run Virtuality VM backups nightly at 04:30 Moscow time
[Timer]
OnCalendar=*-*-* 01:30:00 UTC
AccuracySec=5min
Persistent=true
Unit=virtuality-backup.service
[Install]
WantedBy=timers.target
EOF
run_logged "systemd daemon-reload выполнен для бэкапов" systemctl daemon-reload
run_logged "virtuality-backup.timer включён" systemctl enable --now virtuality-backup.timer
ok "Ночные бэкапы VM: расписание настраивается на странице /backups"
step "Запускаем Virtuality Web Panel"
run_logged "virtuality-web.service включён и запущен" systemctl enable --now virtuality-web.service
+103 -1
Просмотреть файл
@@ -37,6 +37,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from itsdangerous import BadSignature, URLSafeSerializer
import backup_core
import cloud_images
import host_profile
import metrics
@@ -309,7 +310,7 @@ def progress_from_line(current: int, line: str) -> int:
return current
def run_operation_worker(operation_id: str, cmd: list[str], progress_fn=None, start_message: str = "virt-install запущен", success_message: str = "VM создана успешно", refresh_pool: bool = True) -> None:
def run_operation_worker(operation_id: str, cmd: list[str], progress_fn=None, start_message: str = "virt-install запущен", success_message: str = "VM создана успешно", refresh_pool: bool = True, on_success=None) -> None:
operation = read_operation(operation_id)
if not operation:
return
@@ -333,6 +334,13 @@ def run_operation_worker(operation_id: str, cmd: list[str], progress_fn=None, st
if refresh_pool:
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
cache_invalidate()
if on_success is not None:
try:
result_note = on_success()
if result_note:
append_operation_log(operation_id, str(result_note))
except Exception as exc:
append_operation_log(operation_id, f"Пост-обработка завершилась с ошибкой: {exc}")
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=success_message, finished_at=utc_now())
else:
append_operation_log(operation_id, f"Команда завершилась с ошибкой. Exit code: {exit_code}")
@@ -971,6 +979,11 @@ def is_vm_template(name: str) -> bool:
return name in load_vm_templates()
def qemu_convert_progress(current: int, line: str) -> int:
"""Адаптер для run_operation_worker: qemu-img convert -p пишет '(42.00/100%)'."""
return disk_convert_progress(line, current)
def wget_progress(current: int, line: str) -> int:
# wget --progress=dot:giga пишет строки вида " 512000K .......... 42% 10.5M 2m30s"
match = re.search(r"(\d{1,3})%", line)
@@ -1760,6 +1773,95 @@ def vm_create_cloud_submit(request: Request, name: str = Form(...), image_name:
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@app.get("/backups", response_class=HTMLResponse)
def backups_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("backups.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "backups": backup_core.list_backups(), "schedule": backup_core.load_schedule(), "error": error or request.query_params.get("backup_error", ""), "message": request.query_params.get("backup_message", "")})
@app.post("/backups/create")
def backup_create(request: Request, vm_name: str = Form(...), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
if not valid_vm_name(vm_name) or not vm_exists(vm_name):
return RedirectResponse(url="/backups?backup_error=VM не найдена", status_code=303)
disk_path = backup_core.first_disk_of(vm_name)
if not disk_path:
return RedirectResponse(url="/backups?backup_error=У VM не найден qcow2-диск для бэкапа", status_code=303)
target = backup_core.new_backup_path(vm_name)
if target is None:
return RedirectResponse(url="/backups?backup_error=Некорректное имя VM", status_code=303)
keep = backup_core.load_schedule()["keep"]
cmd = ["bash", "-c", backup_core.build_backup_script(vm_name, disk_path, target)]
operation_id = str(uuid.uuid4())
operation = {"id": operation_id, "type": "vm_backup", "title": f"Бэкап VM {vm_name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": vm_name, "disk_path": disk_path, "target": str(target), "cmd": " ".join(cmd)}
def rotate_after_backup():
removed = backup_core.rotate_backups(vm_name, keep)
return f"Ротация удалила старые бэкапы: {', '.join(removed)}" if removed else ""
start_background_operation(operation, cmd, progress_fn=qemu_convert_progress, start_message=f"Бэкап диска VM {vm_name}", success_message=f"Бэкап VM {vm_name} создан", refresh_pool=False, on_success=rotate_after_backup)
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@app.post("/backups/{vm_name}/{filename}/restore")
def backup_restore(request: Request, vm_name: str, filename: str, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
backup_file = backup_core.backup_path(vm_name, filename)
if backup_file is None or not backup_file.exists():
return RedirectResponse(url="/backups?backup_error=Бэкап не найден", status_code=303)
if not vm_exists(vm_name):
return RedirectResponse(url="/backups?backup_error=VM не существует — восстановление возможно только в существующую VM", status_code=303)
if "running" in vm_runtime_state(vm_name):
return RedirectResponse(url="/backups?backup_error=Сначала выключи VM — восстановление перезаписывает её диск", status_code=303)
disk_path = backup_core.first_disk_of(vm_name)
if not disk_path:
return RedirectResponse(url="/backups?backup_error=У VM не найден qcow2-диск", status_code=303)
cmd = ["bash", "-c", backup_core.build_restore_script(disk_path, backup_file)]
operation_id = str(uuid.uuid4())
operation = {"id": operation_id, "type": "vm_restore", "title": f"Восстановление VM {vm_name} из {filename}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": vm_name, "backup": filename, "disk_path": disk_path, "cmd": " ".join(cmd)}
start_background_operation(operation, cmd, progress_fn=qemu_convert_progress, start_message=f"Восстановление диска VM {vm_name}", success_message=f"VM {vm_name} восстановлена из бэкапа", refresh_pool=False)
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@app.post("/backups/{vm_name}/{filename}/delete")
def backup_delete(request: Request, vm_name: str, filename: str, csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
backup_file = backup_core.backup_path(vm_name, filename)
if backup_file and backup_file.exists():
backup_file.unlink()
return RedirectResponse(url="/backups?backup_message=Бэкап удалён", status_code=303)
@app.post("/backups/schedule")
def backup_schedule_save(request: Request, enabled: str = Form("0"), keep: int = Form(5), vms: list[str] = Form([]), csrf_token: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
csrf_fail = require_csrf(request, csrf_token)
if csrf_fail:
return csrf_fail
config = backup_core.save_schedule(enabled == "1", vms, keep)
state = "включено" if config["enabled"] else "выключено"
return RedirectResponse(url=f"/backups?backup_message=Расписание сохранено: {state}, VM: {len(config['vms'])}, хранить копий: {config['keep']}", status_code=303)
def safe_network_template(request: Request, error: str | None = None, status_code: int = 200, diagnostics: dict[str, Any] | None = None):
try:
ctx = network_core.network_context()
+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)
+1
Просмотреть файл
@@ -15,6 +15,7 @@
<a data-icon="☁" class="{{ 'active' if path.startswith('/cloud-images') else '' }}" href="/cloud-images">Cloud</a>
</div>
<a data-icon="⇄" class="{{ 'active' if path.startswith('/network') else '' }}" href="/network">Сеть</a>
<a data-icon="⛁" class="{{ 'active' if path.startswith('/backups') else '' }}" href="/backups">Бэкапы</a>
<a data-icon="⚙" class="{{ 'active' if path.startswith('/operations') else '' }}" href="/operations">Операции</a>
<a data-icon="≡" class="{{ 'active' if path.startswith('/logs') else '' }}" href="/logs">Журналы</a>
<a data-icon="↻" class="{{ 'active' if path.startswith('/update') else '' }}" href="/update">Обновления</a>
+119
Просмотреть файл
@@ -0,0 +1,119 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Бэкапы — {{ app_name }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="v-layout">
{% include "_sidebar.html" %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">Бэкапы</div>
<div class="v-subtitle">Сжатые копии дисков VM в /var/lib/virtuality/backups с ротацией и ночным расписанием</div>
</div>
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
</div>
</header>
{% if error %}<div class="alert danger">{{ error }}</div>{% endif %}
{% if message %}<div class="alert success">{{ message }}</div>{% endif %}
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Сделать бэкап сейчас</h2>
<span class="pill">qemu-img convert -c</span>
</div>
<form method="post" action="/backups/create" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Виртуальная машина</span>
<select name="vm_name" {% if vms %}required{% endif %}>
{% for vm in vms %}
<option value="{{ vm.name }}">{{ vm.name }} — {{ vm.state }}</option>
{% endfor %}
</select>
</label>
<button class="primary wide" {% if not vms %}disabled{% endif %}>Создать бэкап</button>
{% if not vms %}<div class="alert danger">VM пока нет.</div>{% endif %}
</form>
<p class="muted small-note">Работающая VM ставится на паузу на время копирования диска и продолжает работу автоматически. Бэкап сжимается — занимает заметно меньше исходного диска.</p>
</article>
<article class="card">
<div class="card-head">
<h2>Ночное расписание</h2>
<span class="pill">{{ 'включено' if schedule.enabled else 'выключено' }}</span>
</div>
<form method="post" action="/backups/schedule" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="check-row">
<input type="checkbox" name="enabled" value="1" {% if schedule.enabled %}checked{% endif %}>
<span>Делать бэкапы каждую ночь (04:30 по Москве)</span>
</label>
<label>
<span>Хранить копий на VM</span>
<input type="number" name="keep" min="1" max="30" value="{{ schedule.keep }}" required>
</label>
<div class="label">Какие VM бэкапить</div>
{% for vm in vms %}
<label class="check-row">
<input type="checkbox" name="vms" value="{{ vm.name }}" {% if vm.name in schedule.vms %}checked{% endif %}>
<span>{{ vm.name }}</span>
</label>
{% else %}
<div class="muted small-note">VM пока нет — расписание можно настроить после их создания.</div>
{% endfor %}
<button class="primary wide">Сохранить расписание</button>
</form>
<p class="muted small-note">Старые копии сверх лимита удаляются автоматически после каждого успешного бэкапа. Журнал ночных бэкапов: /var/log/virtuality/backup.log.</p>
</article>
</section>
<section class="card">
<div class="card-head">
<h2>Сохранённые бэкапы</h2>
<span class="pill">{{ backups|length }} файлов</span>
</div>
<table>
<thead>
<tr><th>VM</th><th>Файл</th><th>Размер</th><th>Создан</th><th>Действия</th></tr>
</thead>
<tbody>
{% for backup in backups %}
<tr>
<td class="strong">{{ backup.vm }}</td>
<td>{{ backup.name }}</td>
<td>{{ backup.size }}</td>
<td>{{ backup.created }}</td>
<td class="actions">
<form method="post" action="/backups/{{ backup.vm }}/{{ backup.name }}/restore" onsubmit="return confirm('Восстановить VM {{ backup.vm }} из бэкапа {{ backup.name }}? Текущий диск VM будет перезаписан.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="ghost">Восстановить</button>
</form>
<form method="post" action="/backups/{{ backup.vm }}/{{ backup.name }}/delete" onsubmit="return confirm('Удалить бэкап {{ backup.name }}?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="danger">Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">Бэкапов пока нет. Сделай первый бэкап кнопкой слева или включи ночное расписание.</td></tr>
{% endfor %}
</tbody>
</table>
<p class="muted small-note">Восстановление перезаписывает первый диск VM содержимым бэкапа — VM должна быть выключена.</p>
</section>
</main>
</div>
<script src="/static/panel.js" defer></script>
</body>
</html>