diff --git a/scripts/install_web_panel.sh b/scripts/install_web_panel.sh index 3dd52aa..3bbf308 100644 --- a/scripts/install_web_panel.sh +++ b/scripts/install_web_panel.sh @@ -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" < "$BACKUP_TIMER_FILE" < 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() diff --git a/web/backup_core.py b/web/backup_core.py new file mode 100644 index 0000000..cc27100 --- /dev/null +++ b/web/backup_core.py @@ -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) diff --git a/web/templates/_sidebar.html b/web/templates/_sidebar.html index 0f1c8f1..fd750cb 100644 --- a/web/templates/_sidebar.html +++ b/web/templates/_sidebar.html @@ -15,6 +15,7 @@ Cloud Сеть + Бэкапы Операции Журналы Обновления diff --git a/web/templates/backups.html b/web/templates/backups.html new file mode 100644 index 0000000..b91e171 --- /dev/null +++ b/web/templates/backups.html @@ -0,0 +1,119 @@ + + + + + + Бэкапы — {{ app_name }} + + + +
+ {% include "_sidebar.html" %} + +
+
+
+
Бэкапы
+
Сжатые копии дисков VM в /var/lib/virtuality/backups с ротацией и ночным расписанием
+
+
+ user: {{ user }} + ← Дашборд +
+
+
+ + {% if error %}
{{ error }}
{% endif %} + {% if message %}
{{ message }}
{% endif %} + +
+
+
+

Сделать бэкап сейчас

+ qemu-img convert -c +
+
+ + + + {% if not vms %}
VM пока нет.
{% endif %} +
+

Работающая VM ставится на паузу на время копирования диска и продолжает работу автоматически. Бэкап сжимается — занимает заметно меньше исходного диска.

+
+ +
+
+

Ночное расписание

+ {{ 'включено' if schedule.enabled else 'выключено' }} +
+
+ + + +
Какие VM бэкапить
+ {% for vm in vms %} + + {% else %} +
VM пока нет — расписание можно настроить после их создания.
+ {% endfor %} + +
+

Старые копии сверх лимита удаляются автоматически после каждого успешного бэкапа. Журнал ночных бэкапов: /var/log/virtuality/backup.log.

+
+
+ +
+
+

Сохранённые бэкапы

+ {{ backups|length }} файлов +
+ + + + + + {% for backup in backups %} + + + + + + + + {% else %} + + {% endfor %} + +
VMФайлРазмерСозданДействия
{{ backup.vm }}{{ backup.name }}{{ backup.size }}{{ backup.created }} +
+ + +
+
+ + +
+
Бэкапов пока нет. Сделай первый бэкап кнопкой слева или включи ночное расписание.
+

Восстановление перезаписывает первый диск VM содержимым бэкапа — VM должна быть выключена.

+
+
+
+ + +