Бэкапы 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
Этот коммит содержится в:
@@ -11,6 +11,8 @@ VENV_DIR="/opt/virtuality/venv"
|
|||||||
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
|
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
|
||||||
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
|
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
|
||||||
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
|
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}"
|
PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
||||||
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
|
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
|
||||||
LOG_DIR="/var/log/virtuality"
|
LOG_DIR="/var/log/virtuality"
|
||||||
@@ -286,6 +288,33 @@ EOF
|
|||||||
else
|
else
|
||||||
warn "auto_update_check.sh не найден, автообновление пропущено"
|
warn "auto_update_check.sh не найден, автообновление пропущено"
|
||||||
fi
|
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"
|
step "Запускаем Virtuality Web Panel"
|
||||||
run_logged "virtuality-web.service включён и запущен" systemctl enable --now virtuality-web.service
|
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 fastapi.templating import Jinja2Templates
|
||||||
from itsdangerous import BadSignature, URLSafeSerializer
|
from itsdangerous import BadSignature, URLSafeSerializer
|
||||||
|
|
||||||
|
import backup_core
|
||||||
import cloud_images
|
import cloud_images
|
||||||
import host_profile
|
import host_profile
|
||||||
import metrics
|
import metrics
|
||||||
@@ -309,7 +310,7 @@ def progress_from_line(current: int, line: str) -> int:
|
|||||||
return current
|
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)
|
operation = read_operation(operation_id)
|
||||||
if not operation:
|
if not operation:
|
||||||
return
|
return
|
||||||
@@ -333,6 +334,13 @@ def run_operation_worker(operation_id: str, cmd: list[str], progress_fn=None, st
|
|||||||
if refresh_pool:
|
if refresh_pool:
|
||||||
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
|
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
|
||||||
cache_invalidate()
|
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())
|
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=success_message, finished_at=utc_now())
|
||||||
else:
|
else:
|
||||||
append_operation_log(operation_id, f"Команда завершилась с ошибкой. Exit code: {exit_code}")
|
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()
|
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:
|
def wget_progress(current: int, line: str) -> int:
|
||||||
# wget --progress=dot:giga пишет строки вида " 512000K .......... 42% 10.5M 2m30s"
|
# wget --progress=dot:giga пишет строки вида " 512000K .......... 42% 10.5M 2m30s"
|
||||||
match = re.search(r"(\d{1,3})%", line)
|
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)
|
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):
|
def safe_network_template(request: Request, error: str | None = None, status_code: int = 200, diagnostics: dict[str, Any] | None = None):
|
||||||
try:
|
try:
|
||||||
ctx = network_core.network_context()
|
ctx = network_core.network_context()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<a data-icon="☁" class="{{ 'active' if path.startswith('/cloud-images') else '' }}" href="/cloud-images">Cloud</a>
|
<a data-icon="☁" class="{{ 'active' if path.startswith('/cloud-images') else '' }}" href="/cloud-images">Cloud</a>
|
||||||
</div>
|
</div>
|
||||||
<a data-icon="⇄" class="{{ 'active' if path.startswith('/network') else '' }}" href="/network">Сеть</a>
|
<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('/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('/logs') else '' }}" href="/logs">Журналы</a>
|
||||||
<a data-icon="↻" class="{{ 'active' if path.startswith('/update') else '' }}" href="/update">Обновления</a>
|
<a data-icon="↻" class="{{ 'active' if path.startswith('/update') else '' }}" href="/update">Обновления</a>
|
||||||
|
|||||||
@@ -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>
|
||||||
Ссылка в новой задаче
Block a user