From 1e29134fb3101ed2e62ffdcf84fbbc073beabf5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:04:11 +0000 Subject: [PATCH 01/32] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B3=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BA=200.11:=20=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20core.py,?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BB=D0=B0=D1=87=D0=BD=D1=8B=D1=85=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2,=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D1=86?= =?UTF-8?q?=D1=8B=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=D0=B0=20virsh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/core.py: настройки, авторизация, run_cmd, хранилище фоновых операций и шаблоны вынесены в общий модуль, чтобы новые функции жили в отдельных модулях; app.py пока не переключён; - web/catalog.py: официальные облачные образы Ubuntu 26.04/24.04, Debian 13, Alpine 3.23 (amd64/arm64) и типы ОС для установки с ISO; - tests/fixtures: реалистичный вывод virsh для тестов будущих парсеров; - presenters.format_bytes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- tests/fixtures/domblkinfo.txt | 3 + tests/fixtures/domblklist-details.txt | 4 + tests/fixtures/domstats.txt | 16 ++ tests/fixtures/dumpxml-web01.xml | 37 +++ tests/fixtures/net-dumpxml.xml | 17 ++ tests/fixtures/qemu-img-info.json | 10 + tests/fixtures/snapshot-info.txt | 9 + tests/fixtures/snapshot-list.txt | 4 + web/catalog.py | 82 +++++++ web/core.py | 321 ++++++++++++++++++++++++++ web/presenters.py | 9 + 11 files changed, 512 insertions(+) create mode 100644 tests/fixtures/domblkinfo.txt create mode 100644 tests/fixtures/domblklist-details.txt create mode 100644 tests/fixtures/domstats.txt create mode 100644 tests/fixtures/dumpxml-web01.xml create mode 100644 tests/fixtures/net-dumpxml.xml create mode 100644 tests/fixtures/qemu-img-info.json create mode 100644 tests/fixtures/snapshot-info.txt create mode 100644 tests/fixtures/snapshot-list.txt create mode 100644 web/catalog.py create mode 100644 web/core.py diff --git a/tests/fixtures/domblkinfo.txt b/tests/fixtures/domblkinfo.txt new file mode 100644 index 0000000..075a7e8 --- /dev/null +++ b/tests/fixtures/domblkinfo.txt @@ -0,0 +1,3 @@ +Capacity: 42949672960 +Allocation: 6442450944 +Physical: 6443499520 diff --git a/tests/fixtures/domblklist-details.txt b/tests/fixtures/domblklist-details.txt new file mode 100644 index 0000000..8300047 --- /dev/null +++ b/tests/fixtures/domblklist-details.txt @@ -0,0 +1,4 @@ + Type Device Target Source +------------------------------------------------------------ + file disk vda /var/lib/virtuality/images/web01.qcow2 + file cdrom sda - diff --git a/tests/fixtures/domstats.txt b/tests/fixtures/domstats.txt new file mode 100644 index 0000000..5eb2d80 --- /dev/null +++ b/tests/fixtures/domstats.txt @@ -0,0 +1,16 @@ +Domain: 'web01' + cpu.time=48123456789 + cpu.user=30000000000 + cpu.system=18123456789 + balloon.current=2097152 + balloon.maximum=2097152 + balloon.rss=1534208 + vcpu.current=2 + vcpu.maximum=2 + vcpu.0.state=1 + vcpu.0.time=24000000000 + vcpu.0.wait=0 + vcpu.1.state=1 + vcpu.1.time=24123456789 + vcpu.1.wait=0 + diff --git a/tests/fixtures/dumpxml-web01.xml b/tests/fixtures/dumpxml-web01.xml new file mode 100644 index 0000000..23387f9 --- /dev/null +++ b/tests/fixtures/dumpxml-web01.xml @@ -0,0 +1,37 @@ + + web01 + 6c1b3c1e-0000-0000-0000-000000000001 + Сайт компании + Nginx + база. Бэкап по пятницам. + 2097152 + 2097152 + 2 + + hvm + + + + /usr/bin/qemu-system-x86_64 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/net-dumpxml.xml b/tests/fixtures/net-dumpxml.xml new file mode 100644 index 0000000..cf4594a --- /dev/null +++ b/tests/fixtures/net-dumpxml.xml @@ -0,0 +1,17 @@ + + virtuality-nat + 6c1b3c1e-1111-2222-3333-444444444444 + + + + + + + + + + + + + + diff --git a/tests/fixtures/qemu-img-info.json b/tests/fixtures/qemu-img-info.json new file mode 100644 index 0000000..426201a --- /dev/null +++ b/tests/fixtures/qemu-img-info.json @@ -0,0 +1,10 @@ +{ + "children": [], + "virtual-size": 42949672960, + "filename": "/var/lib/virtuality/images/web01.qcow2", + "cluster-size": 65536, + "format": "qcow2", + "actual-size": 6443499520, + "format-specific": {"type": "qcow2", "data": {"compat": "1.1", "lazy-refcounts": false, "refcount-bits": 16, "corrupt": false}}, + "dirty-flag": false +} diff --git a/tests/fixtures/snapshot-info.txt b/tests/fixtures/snapshot-info.txt new file mode 100644 index 0000000..e5d500f --- /dev/null +++ b/tests/fixtures/snapshot-info.txt @@ -0,0 +1,9 @@ +Name: clean +Domain: web01 +Current: yes +State: running +Location: internal +Parent: - +Children: 1 +Descendants: 1 +Metadata: yes diff --git a/tests/fixtures/snapshot-list.txt b/tests/fixtures/snapshot-list.txt new file mode 100644 index 0000000..2ee1326 --- /dev/null +++ b/tests/fixtures/snapshot-list.txt @@ -0,0 +1,4 @@ + Name Creation Time State +------------------------------------------------------ + before-upd 2026-09-25 10:12:03 +0000 shutoff + clean 2026-09-24 09:00:41 +0000 running diff --git a/web/catalog.py b/web/catalog.py new file mode 100644 index 0000000..014871b --- /dev/null +++ b/web/catalog.py @@ -0,0 +1,82 @@ +"""Catalog of ready-to-run cloud images for the quick VM flow. + +Each entry points to the vendor's official cloud image. Images boot in seconds +and are configured through cloud-init (user, password, SSH key) — no installer. +""" +from typing import Any + +# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13. +CLOUD_IMAGES: list[dict[str, Any]] = [ + { + "id": "ubuntu-26.04", + "title": "Ubuntu Server 26.04 LTS", + "vendor": "Ubuntu", + "hint": "Самая свежая LTS, поддержка до 2031 года. Рекомендуем.", + "osinfo": "ubuntu24.04", + "recommended": True, + "login": "ubuntu", + "arches": { + "x86_64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"}, + "aarch64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"}, + }, + }, + { + "id": "ubuntu-24.04", + "title": "Ubuntu Server 24.04 LTS", + "vendor": "Ubuntu", + "hint": "Проверенная LTS, поддержка до 2029 года.", + "osinfo": "ubuntu24.04", + "login": "ubuntu", + "arches": { + "x86_64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"}, + "aarch64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"}, + }, + }, + { + "id": "debian-13", + "title": "Debian 13", + "vendor": "Debian", + "hint": "Лёгкий и стабильный. Хорош для домашних сервисов.", + "osinfo": "debian13", + "login": "debian", + "arches": { + "x86_64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"}, + "aarch64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"}, + }, + }, + { + "id": "alpine-3.23", + "title": "Alpine Linux 3.23", + "vendor": "Alpine", + "hint": "Очень маленький: 200 МБ на диске, стартует мгновенно.", + "osinfo": "alpinelinux3.21", + "login": "alpine", + "arches": { + "x86_64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True}, + "aarch64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True}, + }, + }, +] + +# OS families for the "install from ISO" flow. virt-install picks the right +# virtual hardware from osinfo: Windows gets SATA/e1000e (works without extra +# drivers), win11 additionally gets UEFI + TPM. +OS_TYPES: list[dict[str, Any]] = [ + {"id": "linux", "title": "Linux", "hint": "Ubuntu, Debian, Fedora и другие", "osinfo": "detect=on,require=off"}, + {"id": "windows11", "title": "Windows 11 / Server 2025", "hint": "Нужны UEFI и TPM — включим сами", "osinfo": "win11"}, + {"id": "windows10", "title": "Windows 10 / Server 2019–2022", "hint": "Классическая загрузка", "osinfo": "win10"}, + {"id": "other", "title": "Другая система", "hint": "FreeBSD, старые ОС, экзотика", "osinfo": "generic"}, +] + + +def cloud_image(image_id: str) -> dict[str, Any] | None: + return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None) + + +def os_type(type_id: str) -> dict[str, Any]: + return next((item for item in OS_TYPES if item["id"] == type_id), OS_TYPES[0]) + + +def image_filename(entry: dict[str, Any], arch: str) -> str: + url = entry["arches"][arch]["url"] + return url.rsplit("/", 1)[-1] diff --git a/web/core.py b/web/core.py new file mode 100644 index 0000000..cf7b9a1 --- /dev/null +++ b/web/core.py @@ -0,0 +1,321 @@ +"""Shared primitives of the Virtuality panel: settings, auth, command runner, +background operations store, templates. Feature modules import from here; +app.py re-exports the same names for backwards compatibility.""" +import json +import os +import re +import subprocess +import threading +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Callable +from urllib.parse import quote, urlsplit + +from fastapi import Request +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from itsdangerous import BadSignature, URLSafeSerializer, URLSafeTimedSerializer + +import auth +import presenters +import update_core + +BASE_DIR = Path(__file__).resolve().parent +APP_NAME = "Virtuality" +ENV_FILE = BASE_DIR / ".env" +STORAGE_DIR = Path("/var/lib/virtuality") +ISO_DIR = Path("/var/lib/virtuality/iso") +IMAGES_DIR = Path("/var/lib/virtuality/images") +DISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images") +BACKUPS_DIR = Path("/var/lib/virtuality/backups") +CONFIG_DIR = Path("/var/lib/virtuality/config") +OPERATIONS_DIR = Path("/var/log/virtuality/operations") +DEFAULT_BRIDGE = "br0" +IMAGES_POOL = "virtuality-images" +ISO_POOL = "virtuality-iso" + +OP_LOCK = threading.Lock() + + +def load_env() -> dict[str, str]: + data: dict[str, str] = {} + if ENV_FILE.exists(): + for raw_line in ENV_FILE.read_text().splitlines(): + line = raw_line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + data[key.strip()] = value.strip().strip('"').strip("'") + return data + + +CONFIG = load_env() +AUTH_USER = CONFIG.get("VIRTUALITY_AUTH_USER", os.environ.get("VIRTUALITY_AUTH_USER", "root")) +SESSION_SECRET = CONFIG.get("VIRTUALITY_SESSION_SECRET", os.environ.get("VIRTUALITY_SESSION_SECRET", "dev-secret-change-me")) +COOKIE_SECURE = CONFIG.get("VIRTUALITY_COOKIE_SECURE", os.environ.get("VIRTUALITY_COOKIE_SECURE", "0")) == "1" +SESSION_MAX_AGE = 60 * 60 * 12 +login_throttle = auth.LoginThrottle() +serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="virtuality-session") +console_serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-console") + + +def is_configured() -> bool: + return bool(AUTH_USER and SESSION_SECRET != "dev-secret-change-me") + + +def read_version() -> str: + for path in (BASE_DIR / "VERSION", BASE_DIR.parent / "VERSION"): + try: + return path.read_text().strip() or "unknown" + except OSError: + continue + return "unknown" + + +APP_VERSION = read_version() + + +# ---------------------------------------------------------------- templates +def update_notice() -> dict[str, Any]: + try: + data = json.loads((update_core.STATE_DIR / "last_check.json").read_text()) + except Exception: + return {"has_update": False} + return {"has_update": bool(data.get("has_update")), "latest_version": data.get("latest_version", "")} + + +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) +templates.env.globals.update( + update_notice=update_notice, + vm_presets=presenters.VM_PRESETS, + port_presets=presenters.PORT_PRESETS, + plural=presenters.plural, + app_version=APP_VERSION, + app_name=APP_NAME, +) +templates.env.filters.update( + vm_state=presenters.vm_state, + operation_state=presenters.operation_state, + service_state=presenters.service_state, + format_mb=presenters.format_mb, + format_bytes=presenters.format_bytes, + level_tone=presenters.level_tone, + port_service=presenters.port_service, +) + + +def render(request: Request, template: str, context: dict[str, Any] | None = None, status_code: int = 200, **extra: Any): + data = {"request": request, "app_name": APP_NAME, "user": AUTH_USER} + data.update(context or {}) + data.update(extra) + return templates.TemplateResponse(template, data, status_code=status_code) + + +# ---------------------------------------------------------------- auth +def verify_linux_password(username: str, password: str) -> bool: + if username != AUTH_USER: + return False + return auth.verify_password(username, password) + + +def user_from_session_token(token: str | None) -> str | None: + if not token: + return None + try: + data = serializer.loads(token, max_age=SESSION_MAX_AGE) + except BadSignature: + return None + if not isinstance(data, dict): + return None + return AUTH_USER if data.get("user") == AUTH_USER else None + + +def get_current_user(request: Request) -> str | None: + return user_from_session_token(request.cookies.get("virtuality_session")) + + +def require_auth(request: Request): + """Return a redirect to /login for anonymous requests, otherwise None.""" + if not get_current_user(request): + return RedirectResponse(url="/login", status_code=303) + return None + + +def require_api_auth(request: Request): + """JSON variant of require_auth for /api and /live endpoints.""" + if not get_current_user(request): + return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) + return None + + +def client_key(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def same_origin(request: Request) -> bool: + origin = request.headers.get("origin") or request.headers.get("referer") + if not origin or origin == "null": + # Browsers always send Origin on cross-site form posts; tools like curl do not. + return origin != "null" + origin_host = urlsplit(origin).netloc.lower() + allowed = {request.headers.get("host", "").lower()} + forwarded = request.headers.get("x-forwarded-host") + if forwarded: + allowed.update(item.strip().lower() for item in forwarded.split(",")) + return origin_host in allowed + + +def redirect_with_message(path: str, key: str, message: str) -> RedirectResponse: + return RedirectResponse(url=f"{path}?{key}={quote(message)}", status_code=303) + + +# ---------------------------------------------------------------- commands +def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) + return {"ok": result.returncode == 0, "code": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "cmd": " ".join(cmd)} + except Exception as exc: + return {"ok": False, "code": -1, "stdout": "", "stderr": str(exc), "cmd": " ".join(cmd)} + + +def cmd_error(result: dict[str, Any], fallback: str) -> str: + """Best human-readable error text from a run_cmd result.""" + text = (result.get("stderr") or result.get("stdout") or "").strip() + text = re.sub(r"^error:\s*", "", text, flags=re.IGNORECASE) + return text.splitlines()[0] if text else fallback + + +def utc_now() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def tail_text(path: Path, max_lines: int = 220) -> str: + if not path.exists(): + return "" + try: + return "\n".join(path.read_text(errors="replace").splitlines()[-max_lines:]) + except OSError: + return "" + + +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_label(name: str) -> bool: + """Snapshot, backup and similar user-chosen names.""" + return bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,62}", name or "")) + + +def vm_exists(name: str) -> bool: + return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"] + + +def vm_state(name: str) -> str: + result = run_cmd(["virsh", "domstate", name], timeout=8) + return (result.get("stdout") or "unknown").strip().lower() + + +def vm_is_running(name: str) -> bool: + return vm_state(name) in ("running", "idle", "blocked", "paused", "pmsuspended", "in shutdown") + + +# ---------------------------------------------------------------- operations +def operation_meta_path(operation_id: str) -> Path: + return OPERATIONS_DIR / f"{operation_id}.json" + + +def operation_log_path(operation_id: str) -> Path: + return OPERATIONS_DIR / f"{operation_id}.log" + + +def ensure_operations_dir() -> None: + OPERATIONS_DIR.mkdir(parents=True, exist_ok=True) + + +def write_operation(operation: dict[str, Any]) -> None: + ensure_operations_dir() + path = operation_meta_path(operation["id"]) + tmp_path = path.with_suffix(".json.tmp") + with OP_LOCK: + tmp_path.write_text(json.dumps(operation, ensure_ascii=False, indent=2)) + tmp_path.replace(path) + + +def read_operation(operation_id: str) -> dict[str, Any] | None: + if not re.fullmatch(r"[a-f0-9-]{36}", operation_id or ""): + return None + path = operation_meta_path(operation_id) + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + except Exception: + return None + data["log_tail"] = tail_text(operation_log_path(operation_id)) + return data + + +def list_operations(limit: int = 25) -> list[dict[str, Any]]: + ensure_operations_dir() + operations: list[dict[str, Any]] = [] + for path in sorted(OPERATIONS_DIR.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True): + try: + data = json.loads(path.read_text()) + data["log_tail"] = tail_text(operation_log_path(data["id"]), max_lines=20) + operations.append(data) + except Exception: + continue + if len(operations) >= limit: + break + return operations + + +def running_operations(kind: str | None = None) -> list[dict[str, Any]]: + return [op for op in list_operations(50) if op.get("status") in ("queued", "running") and (kind is None or op.get("type") == kind)] + + +def append_operation_log(operation_id: str, message: str) -> None: + ensure_operations_dir() + with operation_log_path(operation_id).open("a", encoding="utf-8") as handle: + handle.write(f"[{utc_now()}] {message.rstrip()}\n") + + +def operation_css(status: str) -> str: + return "ok" if status == "success" else "err" if status == "error" else "warn" + + +def update_operation(operation: dict[str, Any], **changes: Any) -> None: + operation.update(changes) + operation["updated_at"] = utc_now() + write_operation(operation) + + +def new_operation(kind: str, title: str, message: str = "Операция поставлена в очередь", **meta: Any) -> dict[str, Any]: + operation = {"id": str(uuid.uuid4()), "type": kind, "title": title, "status": "queued", "progress": 0, "message": message, "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER} + operation.update(meta) + write_operation(operation) + append_operation_log(operation["id"], "Операция поставлена в очередь.") + return operation + + +def finish_operation(operation: dict[str, Any], ok: bool, message: str, **meta: Any) -> None: + fresh = read_operation(operation["id"]) or operation + fresh.pop("log_tail", None) + update_operation(fresh, status="success" if ok else "error", progress=100, message=message, finished_at=utc_now(), **meta) + append_operation_log(operation["id"], message) + + +def run_operation(operation: dict[str, Any], worker: Callable[[dict[str, Any]], None]) -> None: + """Run worker(operation) in a daemon thread; the worker reports progress via + update_operation/append_operation_log and must call finish_operation.""" + + def runner() -> None: + try: + update_operation(operation, status="running", started_at=utc_now()) + worker(operation) + except Exception as exc: # noqa: BLE001 — surface any failure to the user + append_operation_log(operation["id"], f"Ошибка: {exc}") + finish_operation(operation, False, str(exc)[:240]) + + threading.Thread(target=runner, daemon=True).start() diff --git a/web/presenters.py b/web/presenters.py index 89e525e..a8ec65e 100644 --- a/web/presenters.py +++ b/web/presenters.py @@ -242,3 +242,12 @@ def plural(count: int, one: str, few: str, many: str) -> str: def port_service(port: int) -> str: return WELL_KNOWN_PORTS.get(int(port or 0), "") + + +def format_bytes(value: int | float) -> str: + value = float(value or 0) + for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"): + if value < 1024 or unit == "ТБ": + return f"{value:.0f} {unit}" if value >= 10 or unit == "Б" else f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.0f} ТБ" From e52967fba11d67b312288d0153a292cfe5a69de2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:22:11 +0000 Subject: [PATCH 02/32] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=BE=D0=B5=20=D0=BC=D0=B5=D0=BD=D1=8E=20ISO?= =?UTF-8?q?=20=D0=B1=D1=8B=D0=BB=D0=BE=20=D0=BE=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=B2=20=D1=81=D1=82=D0=B8=D0=BB?= =?UTF-8?q?=D0=B5=20Virtuality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - графическая тема GRUB (фон, подсветка выбранного пункта, полоса автозапуска) в image/grub, подключается в build-iso.sh; при отсутствии модулей GRUB меню остаётся текстовым; - пункты меню переведены: «Установить Virtuality X.Y.Z», «Загрузить с другого диска», «Настройки UEFI», «Проверить память»; - tests/render_html.mjs рендерит HTML в PNG (для фона темы). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- image/build-iso.sh | 34 ++++++++++++++++++++++++------ image/grub/README.md | 12 +++++++++++ image/grub/background.html | 19 +++++++++++++++++ image/grub/background.png | Bin 0 -> 154810 bytes image/grub/select_c.png | Bin 0 -> 75 bytes image/grub/select_e.png | Bin 0 -> 75 bytes image/grub/select_n.png | Bin 0 -> 75 bytes image/grub/select_ne.png | Bin 0 -> 75 bytes image/grub/select_nw.png | Bin 0 -> 75 bytes image/grub/select_s.png | Bin 0 -> 75 bytes image/grub/select_se.png | Bin 0 -> 75 bytes image/grub/select_sw.png | Bin 0 -> 75 bytes image/grub/select_w.png | Bin 0 -> 75 bytes image/grub/theme.txt | 41 +++++++++++++++++++++++++++++++++++++ tests/render_html.mjs | 10 +++++++++ 15 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 image/grub/README.md create mode 100644 image/grub/background.html create mode 100644 image/grub/background.png create mode 100644 image/grub/select_c.png create mode 100644 image/grub/select_e.png create mode 100644 image/grub/select_n.png create mode 100644 image/grub/select_ne.png create mode 100644 image/grub/select_nw.png create mode 100644 image/grub/select_s.png create mode 100644 image/grub/select_se.png create mode 100644 image/grub/select_sw.png create mode 100644 image/grub/select_w.png create mode 100644 image/grub/theme.txt create mode 100644 tests/render_html.mjs diff --git a/image/build-iso.sh b/image/build-iso.sh index 62266ce..880c1b4 100755 --- a/image/build-iso.sh +++ b/image/build-iso.sh @@ -216,20 +216,41 @@ import sys path, unattended, version = sys.argv[1], sys.argv[2] == "1", sys.argv[3] text = open(path).read() # /autoinstall.yaml applies to every entry, so all Ubuntu entries become Virtuality entries. -suffix = " (UNATTENDED: erases disk)" if unattended else "" -text = text.replace('menuentry "Try or Install Ubuntu Server"', 'menuentry "Install Virtuality %s%s"' % (version, suffix), 1) -text = text.replace('menuentry "Ubuntu Server with the HWE kernel"', 'menuentry "Install Virtuality %s with the HWE kernel%s"' % (version, suffix), 1) -if "Install Virtuality" not in text: +suffix = " — автоматически, диск будет стёрт" if unattended else "" +text = text.replace('menuentry "Try or Install Ubuntu Server"', 'menuentry "Установить Virtuality %s%s"' % (version, suffix), 1) +text = text.replace('menuentry "Ubuntu Server with the HWE kernel"', 'menuentry "Установить Virtuality %s (новое ядро HWE)%s"' % (version, suffix), 1) +text = text.replace("menuentry 'Boot from next volume'", "menuentry 'Загрузить с другого диска'", 1) +text = text.replace("menuentry 'UEFI Firmware Settings'", "menuentry 'Настройки UEFI'", 1) +text = text.replace("menuentry 'Test memory'", "menuentry 'Проверить память'", 1) +if "Установить Virtuality" not in text: raise SystemExit("unexpected grub.cfg menu titles") if unattended: text = re.sub(r"^(\s*linux\s+\S+\s+)---", r"\1autoinstall ---", text, flags=re.M) -text = re.sub(r"^set timeout=\d+", "set default=0\nset timeout=10", text, count=1, flags=re.M) -open(path, "w").write(text) +text = re.sub(r"^set timeout=\d+\n", "", text, count=1, flags=re.M) +text = text.replace("loadfont unicode\n", "", 1) +# Branded graphical menu; if a module or the font is missing GRUB falls back to the plain text menu. +theme = """insmod all_video +insmod gfxterm +insmod png +insmod gfxmenu +set gfxmode=1024x768,auto +if loadfont /boot/grub/fonts/unicode.pf2; then + terminal_output gfxterm + set theme=/boot/grub/themes/virtuality/theme.txt + export theme +fi +set default=0 +set timeout=10 +""" +open(path, "w").write(theme + text) PYEOF new_md5="$(md5sum "${WORK_DIR}/grub.cfg" | cut -d' ' -f1)" sed -i "s|^[0-9a-f]\{32\} \./boot/grub/grub.cfg$|${new_md5} ./boot/grub/grub.cfg|" "${WORK_DIR}/md5sum.txt" # ---------------------------------------------------------- repack +THEME_DIR="${WORK_DIR}/theme" +mkdir -p "$THEME_DIR" +cp "${IMAGE_DIR}"/grub/theme.txt "${IMAGE_DIR}"/grub/*.png "$THEME_DIR"/ say "Writing ${OUTPUT_ISO}" rm -f "$OUTPUT_ISO" chmod -R a+rX,go-w "$PAYLOAD" @@ -238,6 +259,7 @@ if ! xorriso -indev "$UBUNTU_ISO" -outdev "$OUTPUT_ISO" \ -map "$PAYLOAD" /virtuality \ -map "${WORK_DIR}/autoinstall.yaml" /autoinstall.yaml \ -map "${WORK_DIR}/grub.cfg" /boot/grub/grub.cfg \ + -map "${THEME_DIR}" /boot/grub/themes/virtuality \ -map "${WORK_DIR}/md5sum.txt" /md5sum.txt \ -boot_image any replay > "${WORK_DIR}/xorriso.log" 2>&1; then tail -n 30 "${WORK_DIR}/xorriso.log" >&2 diff --git a/image/grub/README.md b/image/grub/README.md new file mode 100644 index 0000000..78fbdc5 --- /dev/null +++ b/image/grub/README.md @@ -0,0 +1,12 @@ +# Тема загрузочного меню + +`theme.txt` — тема GRUB (gfxmenu) для установочного ISO, `background.png` — фон 1024×768, +`select_*.png` — подсветка выбранного пункта. + +Фон рендерится из `background.html` (тот же стиль, что у страницы входа панели): + +```bash +NODE_PATH=$(npm root -g) node tests/render_html.mjs image/grub/background.html image/grub/background.png 1024 768 +``` + +Пиксмапы подсветки — однотонные RGBA 6×6 (110, 96, 255, 150). diff --git a/image/grub/background.html b/image/grub/background.html new file mode 100644 index 0000000..8f8e246 --- /dev/null +++ b/image/grub/background.html @@ -0,0 +1,19 @@ +
+
VirtualityПлатформа виртуализации
+
Ваши серверы.
Под полным контролем.
+
Установщик · KVM · QEMU · libvirt
+
↑ ↓ — выбор пункта, Enter — запуск
+ diff --git a/image/grub/background.png b/image/grub/background.png new file mode 100644 index 0000000000000000000000000000000000000000..87aa82ec4c37881885b2714fbf0e028bc87c9cef GIT binary patch literal 154810 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 Date: Fri, 25 Sep 2026 14:26:03 +0000 Subject: [PATCH 03/32] =?UTF-8?q?=D0=A3=D1=82=D0=B8=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=20virtuality-ctl=20=D0=B1=D1=8B=D0=BB=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единая точка управления нодой: пересборка systemd-юнитов из web.env (порт, HTTPS с самоподписанным сертификатом и redirector HTTP→HTTPS, автообновление, ежедневное обслуживание, libvirt-guests), смена пароля и часового пояса, перезагрузка/выключение, архив настроек ноды и его восстановление, откат панели после неудачного обновления, безопасное подключение моста br0 с автооткатом через 2 минуты, отчёт для поддержки, удаление. Установщик и панель будут использовать её вместо собственных копий этой логики. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- scripts/virtuality-ctl | 665 +++++++++++++++++++++++++++++++++++++++++ web/redirect_http.py | 39 +++ 2 files changed, 704 insertions(+) create mode 100755 scripts/virtuality-ctl create mode 100644 web/redirect_http.py diff --git a/scripts/virtuality-ctl b/scripts/virtuality-ctl new file mode 100755 index 0000000..a76c185 --- /dev/null +++ b/scripts/virtuality-ctl @@ -0,0 +1,665 @@ +#!/usr/bin/env bash +# virtuality-ctl — node management for Virtuality. +# Installed to /usr/local/bin by scripts/install_web_panel.sh. Run as root. +set -euo pipefail + +CONFIG_DIR="/var/lib/virtuality/config" +NODE_ENV="${CONFIG_DIR}/web.env" +TLS_DIR="${CONFIG_DIR}/tls" +STATE_FILE="${CONFIG_DIR}/ports.state" +DATA_DIR="/var/lib/virtuality" +LOG_DIR="/var/log/virtuality" +APP_DIR="/opt/virtuality/web" +VENV_DIR="/opt/virtuality/venv" +SOURCE_DIR="${VIRTUALITY_SOURCE_DIR:-/opt/virtuality/source}" +UNIT_DIR="/etc/systemd/system" +NETPLAN_DIR="/etc/netplan" +NETPLAN_BACKUP="${CONFIG_DIR}/netplan-backup" +BRIDGE_FILE="${NETPLAN_DIR}/60-virtuality-br0.yaml" +REVERT_UNIT="virtuality-netplan-revert" + +usage() { + cat < [options] + + status Что установлено, порты, адреса панели + reconfigure Пересоздать systemd-юниты из ${NODE_ENV} и перезапустить панель + set KEY=VALUE ... Изменить настройку в web.env (например set VIRTUALITY_TLS=1) и применить + passwd [USER] Сменить пароль пользователя панели (пароль читается со стандартного ввода) + timezone Установить часовой пояс сервера + power reboot|poweroff Перезагрузить или выключить сервер (машины выключаются корректно) + backup-config [FILE] Сохранить настройки ноды и описания машин в архив + restore-config FILE Восстановить настройки ноды из архива + rollback Вернуть предыдущую версию панели после неудачного обновления + maintenance Ежедневное обслуживание: старые задачи, временные файлы, свободное место + bridge [dhcp|static] Подключить машины к локальной сети через мост br0 с автооткатом + bridge-confirm Подтвердить новую сеть (отменяет автооткат) + bridge-revert Вернуть прежнюю сеть + support-bundle [FILE] Собрать отчёт для поддержки + uninstall [--purge] [--yes] Удалить Virtuality (по умолчанию машины и образы остаются) +EOF +} + +die() { echo "virtuality-ctl: $*" >&2; exit 1; } +need_root() { [[ "$EUID" -eq 0 ]] || die "нужны права root: sudo virtuality-ctl $*"; } +now() { date '+%Y-%m-%d %H:%M:%S'; } +log() { mkdir -p "$LOG_DIR"; echo "[$(now)] [virtuality-ctl] $*" >> "${LOG_DIR}/virtuality-ctl.log"; } + +# ---------------------------------------------------------------- settings +env_get() { + local key="$1" default="${2:-}" + local value="" + if [[ -f "$NODE_ENV" ]]; then + value="$(sed -n "s/^${key}=//p" "$NODE_ENV" | tail -n1 | tr -d '"' | tr -d "'")" + fi + printf '%s' "${value:-$default}" +} + +env_set() { + local key="$1" value="$2" + mkdir -p "$CONFIG_DIR" + touch "$NODE_ENV" + if grep -q "^${key}=" "$NODE_ENV"; then + sed -i "s|^${key}=.*|${key}=${value}|" "$NODE_ENV" + else + printf '%s=%s\n' "$key" "$value" >> "$NODE_ENV" + fi + chmod 644 "$NODE_ENV" +} + +primary_ip() { hostname -I 2>/dev/null | awk '{print $1}'; } + +panel_url() { + local tls port + tls="$(env_get VIRTUALITY_TLS 0)" + if [[ "$tls" == "1" ]]; then + port="$(env_get VIRTUALITY_TLS_PORT 8443)" + printf 'https://%s:%s' "$(primary_ip)" "$port" + else + port="$(env_get VIRTUALITY_WEB_PORT 8088)" + printf 'http://%s:%s' "$(primary_ip)" "$port" + fi +} + +# ---------------------------------------------------------------- tls +ensure_cert() { + local ip host + mkdir -p "$TLS_DIR" + chmod 700 "$TLS_DIR" + if [[ -s "${TLS_DIR}/panel.key" && -s "${TLS_DIR}/panel.crt" ]]; then + return 0 + fi + ip="$(primary_ip)" + host="$(hostname)" + local san="DNS:${host},DNS:localhost,IP:127.0.0.1" + [[ -n "$ip" ]] && san="${san},IP:${ip}" + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 3650 \ + -subj "/CN=Virtuality on ${host}" -addext "subjectAltName=${san}" \ + -keyout "${TLS_DIR}/panel.key" -out "${TLS_DIR}/panel.crt" >/dev/null 2>&1 \ + || die "не удалось создать TLS-сертификат (нужен openssl)" + chmod 600 "${TLS_DIR}/panel.key" + log "TLS certificate created for ${host} ${ip}" +} + +# ---------------------------------------------------------------- units +write_units() { + local port host tls tls_port auth_user auto_update + port="$(env_get VIRTUALITY_WEB_PORT 8088)" + host="$(env_get VIRTUALITY_WEB_HOST 0.0.0.0)" + tls="$(env_get VIRTUALITY_TLS 0)" + tls_port="$(env_get VIRTUALITY_TLS_PORT 8443)" + auth_user="$(env_get VIRTUALITY_AUTH_USER root)" + auto_update="$(env_get VIRTUALITY_AUTO_UPDATE 1)" + local listen_port="$port" ssl_args="" + if [[ "$tls" == "1" ]]; then + ensure_cert + listen_port="$tls_port" + ssl_args="--ssl-keyfile ${TLS_DIR}/panel.key --ssl-certfile ${TLS_DIR}/panel.crt" + env_set VIRTUALITY_COOKIE_SECURE 1 + else + env_set VIRTUALITY_COOKIE_SECURE 0 + fi + + cat > "${UNIT_DIR}/virtuality-web.service" < "${UNIT_DIR}/virtuality-redirect.service" < "${UNIT_DIR}/virtuality-network.service" < "${UNIT_DIR}/virtuality-auto-update.service" < "${UNIT_DIR}/virtuality-auto-update.timer" < "${UNIT_DIR}/virtuality-maintenance.service" < "${UNIT_DIR}/virtuality-maintenance.timer" < /etc/logrotate.d/virtuality < /etc/default/libvirt-guests </dev/null 2>&1 || true + systemctl enable libvirt-guests.service >/dev/null 2>&1 || true + if [[ "$tls" == "1" ]]; then + systemctl enable virtuality-redirect.service >/dev/null 2>&1 || true + else + systemctl disable --now virtuality-redirect.service >/dev/null 2>&1 || true + fi + if [[ "$auto_update" == "1" ]]; then + systemctl enable virtuality-auto-update.timer >/dev/null 2>&1 || true + else + systemctl disable --now virtuality-auto-update.timer >/dev/null 2>&1 || true + fi +} + +apply_firewall() { + command -v ufw >/dev/null 2>&1 || return 0 + local port tls tls_port old + port="$(env_get VIRTUALITY_WEB_PORT 8088)" + tls="$(env_get VIRTUALITY_TLS 0)" + tls_port="$(env_get VIRTUALITY_TLS_PORT 8443)" + if [[ -f "$STATE_FILE" ]]; then + while read -r old; do + [[ -n "$old" ]] && ufw delete allow "${old}/tcp" >/dev/null 2>&1 || true + done < "$STATE_FILE" + fi + # VM consoles are proxied by the panel over 127.0.0.1; VNC must never be reachable directly. + ufw delete allow 5900:5999/tcp >/dev/null 2>&1 || true + ufw allow "${port}/tcp" >/dev/null 2>&1 || true + printf '%s\n' "$port" > "$STATE_FILE" + if [[ "$tls" == "1" ]]; then + ufw allow "${tls_port}/tcp" >/dev/null 2>&1 || true + printf '%s\n' "$tls_port" >> "$STATE_FILE" + fi +} + +restart_panel() { + systemctl restart virtuality-web.service + if [[ "$(env_get VIRTUALITY_TLS 0)" == "1" ]]; then + systemctl restart virtuality-redirect.service || true + fi + systemctl enable --now virtuality-maintenance.timer >/dev/null 2>&1 || true + if [[ "$(env_get VIRTUALITY_AUTO_UPDATE 1)" == "1" ]]; then + systemctl enable --now virtuality-auto-update.timer >/dev/null 2>&1 || true + fi +} + +cmd_reconfigure() { + need_root reconfigure + [[ -d "$APP_DIR" ]] || die "панель не установлена: ${APP_DIR} не найден" + write_units + apply_firewall + restart_panel + log "reconfigured: $(panel_url)" + echo "Панель перезапущена: $(panel_url)" +} + +cmd_set() { + need_root set + [[ $# -gt 0 ]] || die "usage: virtuality-ctl set KEY=VALUE [KEY=VALUE ...]" + local pair key value + for pair in "$@"; do + key="${pair%%=*}" + value="${pair#*=}" + [[ "$key" =~ ^VIRTUALITY_[A-Z_]+$ ]] || die "недопустимый ключ: $key" + case "$key" in + VIRTUALITY_WEB_PORT|VIRTUALITY_TLS_PORT) [[ "$value" =~ ^[0-9]+$ ]] && (( value >= 1 && value <= 65535 )) || die "порт должен быть числом 1–65535" ;; + VIRTUALITY_TLS|VIRTUALITY_AUTO_UPDATE) [[ "$value" == "0" || "$value" == "1" ]] || die "$key принимает 0 или 1" ;; + VIRTUALITY_UPDATE_CHANNEL) [[ "$value" == "stable" || "$value" == "main" ]] || die "канал: stable или main" ;; + VIRTUALITY_WEB_HOST) [[ "$value" =~ ^[0-9a-fA-F.:]+$ ]] || die "адрес должен быть IP" ;; + esac + env_set "$key" "$value" + done + cmd_reconfigure +} + +# ---------------------------------------------------------------- account +cmd_passwd() { + need_root passwd + local user="${1:-$(env_get VIRTUALITY_AUTH_USER root)}" + id "$user" >/dev/null 2>&1 || die "пользователь $user не найден" + local password + if [[ -t 0 ]]; then + read -r -s -p "Новый пароль для ${user}: " password; echo + else + IFS= read -r password + fi + [[ ${#password} -ge 6 ]] || die "пароль должен быть не короче 6 символов" + printf '%s:%s\n' "$user" "$password" | chpasswd || die "не удалось сменить пароль" + passwd -u "$user" >/dev/null 2>&1 || true + log "password changed for ${user}" + echo "Пароль пользователя ${user} обновлён" +} + +cmd_timezone() { + need_root timezone + local tz="${1:-}" + [[ "$tz" =~ ^[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+){0,2}$ ]] || die "usage: virtuality-ctl timezone Europe/Moscow" + timedatectl set-timezone "$tz" || die "часовой пояс $tz не найден" + timedatectl set-ntp true >/dev/null 2>&1 || true + log "timezone set to ${tz}" + echo "Часовой пояс: $(timedatectl show -p Timezone --value)" +} + +cmd_power() { + need_root power + case "${1:-}" in + reboot) log "reboot requested"; systemctl reboot ;; + poweroff|shutdown) log "poweroff requested"; systemctl poweroff ;; + *) die "usage: virtuality-ctl power reboot|poweroff" ;; + esac +} + +# ---------------------------------------------------------------- config backup +cmd_backup_config() { + need_root backup-config + local out="${1:-${DATA_DIR}/backups/config-$(date +%Y%m%d-%H%M).tar.gz}" + local work + work="$(mktemp -d)" + mkdir -p "$work/node" "$work/vms" "$work/networks" "$(dirname "$out")" + cp -a "$CONFIG_DIR" "$work/node/config" 2>/dev/null || true + cp -a "${DATA_DIR}/network" "$work/node/network" 2>/dev/null || true + cp -a /etc/virtuality "$work/node/etc-virtuality" 2>/dev/null || true + cp -a "$BRIDGE_FILE" "$work/node/" 2>/dev/null || true + cp -a /etc/default/libvirt-guests "$work/node/" 2>/dev/null || true + cat "${SOURCE_DIR}/VERSION" > "$work/VERSION" 2>/dev/null || true + if command -v virsh >/dev/null 2>&1; then + local name + while read -r name; do + [[ -n "$name" ]] && virsh dumpxml --migratable "$name" > "$work/vms/${name}.xml" 2>/dev/null || true + done < <(virsh list --all --name 2>/dev/null) + while read -r name; do + [[ -n "$name" ]] && virsh net-dumpxml "$name" > "$work/networks/${name}.xml" 2>/dev/null || true + done < <(virsh net-list --all --name 2>/dev/null) + fi + tar -czf "$out" -C "$work" . + rm -rf "$work" + chmod 600 "$out" + log "config backup written to ${out}" + echo "$out" +} + +cmd_restore_config() { + need_root restore-config + local file="${1:-}" + [[ -f "$file" ]] || die "usage: virtuality-ctl restore-config FILE.tar.gz" + local work + work="$(mktemp -d)" + tar -xzf "$file" -C "$work" || die "архив повреждён" + [[ -d "$work/node/config" ]] || die "в архиве нет настроек Virtuality" + mkdir -p "$CONFIG_DIR" "${DATA_DIR}/network" /etc/virtuality + cp -a "$work/node/config/." "$CONFIG_DIR/" + [[ -d "$work/node/network" ]] && cp -a "$work/node/network/." "${DATA_DIR}/network/" + [[ -d "$work/node/etc-virtuality" ]] && cp -a "$work/node/etc-virtuality/." /etc/virtuality/ + [[ -f "$work/node/libvirt-guests" ]] && cp -a "$work/node/libvirt-guests" /etc/default/libvirt-guests + if command -v virsh >/dev/null 2>&1; then + local xml + for xml in "$work"/networks/*.xml; do + [[ -f "$xml" ]] || continue + virsh net-define "$xml" >/dev/null 2>&1 || true + virsh net-autostart "$(basename "$xml" .xml)" >/dev/null 2>&1 || true + virsh net-start "$(basename "$xml" .xml)" >/dev/null 2>&1 || true + done + for xml in "$work"/vms/*.xml; do + [[ -f "$xml" ]] || continue + virsh define "$xml" >/dev/null 2>&1 || echo "Машина $(basename "$xml" .xml): описание восстановлено с ошибкой (диски отсутствуют?)" + done + fi + rm -rf "$work" + log "config restored from ${file}" + if [[ -d "$APP_DIR" ]]; then + cmd_reconfigure + fi + echo "Настройки восстановлены" +} + +# ---------------------------------------------------------------- updates +cmd_rollback() { + need_root rollback + [[ -d "${APP_DIR}.prev" ]] || die "предыдущей версии панели нет" + rm -rf "${APP_DIR}.broken" + mv "$APP_DIR" "${APP_DIR}.broken" + mv "${APP_DIR}.prev" "$APP_DIR" + systemctl restart virtuality-web.service + log "rolled back to previous panel version" + echo "Возвращена предыдущая версия панели. Сломанная сохранена в ${APP_DIR}.broken" +} + +# ---------------------------------------------------------------- maintenance +cmd_maintenance() { + need_root maintenance + local ops="${LOG_DIR}/operations" + if [[ -d "$ops" ]]; then + find "$ops" -type f -mtime +30 -delete 2>/dev/null || true + # keep at most the 300 newest operations + ls -t "$ops"/*.json 2>/dev/null | tail -n +301 | while read -r f; do rm -f "$f" "${f%.json}.log"; done + fi + find "${DATA_DIR}/tmp" -mindepth 1 -mtime +1 -delete 2>/dev/null || true + find "${DATA_DIR}/iso" "${DATA_DIR}/disk-images" -maxdepth 1 -name '.*.uploading' -mtime +1 -delete 2>/dev/null || true + find "${DATA_DIR}/iso" "${DATA_DIR}/disk-images" -maxdepth 1 -name '.*.part' -mtime +1 -delete 2>/dev/null || true + find "$LOG_DIR" -maxdepth 1 -name 'install_*.log' -mtime +30 -delete 2>/dev/null || true + journalctl --vacuum-size=300M >/dev/null 2>&1 || true + local used + used="$(df -P "$DATA_DIR" | awk 'NR==2 {gsub("%","",$5); print $5}')" + mkdir -p "$CONFIG_DIR" + printf '{"disk_used_pct": %s, "checked_at": "%s"}\n' "${used:-0}" "$(now)" > "${CONFIG_DIR}/health.json" + log "maintenance done, disk used ${used}%" +} + +# ---------------------------------------------------------------- network bridge +cmd_bridge() { + need_root bridge + local iface="${1:-}" mode="${2:-dhcp}" + [[ -n "$iface" ]] || die "usage: virtuality-ctl bridge [dhcp|static]" + ip link show "$iface" >/dev/null 2>&1 || die "интерфейс $iface не найден" + [[ -d "/sys/class/net/${iface}/wireless" ]] && die "Wi-Fi нельзя объединить в мост; используйте режим NAT" + command -v netplan >/dev/null 2>&1 || die "netplan не найден" + local mac ip gw + mac="$(cat "/sys/class/net/${iface}/address")" + ip="$(ip -4 addr show "$iface" | awk '/inet / {print $2; exit}')" + gw="$(ip route show default | awk '/default/ {print $3; exit}')" + + rm -rf "$NETPLAN_BACKUP" + mkdir -p "$NETPLAN_BACKUP" + cp -a "$NETPLAN_DIR"/*.yaml "$NETPLAN_BACKUP"/ 2>/dev/null || true + # Neutralise the interface in existing files so it is not configured twice. + local file + for file in "$NETPLAN_DIR"/*.yaml; do + [[ -f "$file" && "$file" != "$BRIDGE_FILE" ]] || continue + if grep -q "^\s*${iface}:" "$file"; then + cat > "$file" < "$BRIDGE_FILE" + chmod 600 "$NETPLAN_DIR"/*.yaml 2>/dev/null || true + netplan generate || { cmd_bridge_revert; die "конфигурация сети не прошла проверку, изменения отменены"; } + # Auto-revert unless confirmed from the panel within two minutes. + systemctl stop "${REVERT_UNIT}.timer" >/dev/null 2>&1 || true + systemd-run --unit="$REVERT_UNIT" --on-active=120 --quiet /usr/local/bin/virtuality-ctl bridge-revert + netplan apply + log "bridge br0 on ${iface} applied (${mode}); auto-revert armed" + echo "Мост br0 включён. Подтвердите в течение 2 минут: virtuality-ctl bridge-confirm" +} + +cmd_bridge_confirm() { + need_root bridge-confirm + systemctl stop "${REVERT_UNIT}.timer" >/dev/null 2>&1 || true + systemctl stop "${REVERT_UNIT}.service" >/dev/null 2>&1 || true + rm -rf "$NETPLAN_BACKUP" + log "bridge confirmed" + echo "Сеть подтверждена" +} + +cmd_bridge_revert() { + need_root bridge-revert + systemctl stop "${REVERT_UNIT}.timer" >/dev/null 2>&1 || true + [[ -d "$NETPLAN_BACKUP" ]] || die "нечего откатывать" + rm -f "$NETPLAN_DIR"/*.yaml + cp -a "$NETPLAN_BACKUP"/*.yaml "$NETPLAN_DIR"/ 2>/dev/null || true + chmod 600 "$NETPLAN_DIR"/*.yaml 2>/dev/null || true + netplan generate && netplan apply + ip link delete br0 >/dev/null 2>&1 || true + rm -rf "$NETPLAN_BACKUP" + log "bridge reverted" + echo "Прежняя сеть восстановлена" +} + +# ---------------------------------------------------------------- support +cmd_support_bundle() { + need_root support-bundle + local out="${1:-${DATA_DIR}/tmp/virtuality-support-$(date +%Y%m%d-%H%M).tar.gz}" + local work + work="$(mktemp -d)" + mkdir -p "$(dirname "$out")" + { + echo "Virtuality $(cat "${SOURCE_DIR}/VERSION" 2>/dev/null || echo unknown)" + echo "Host: $(hostname) $(uname -r)"; date + echo; echo "== web.env"; sed 's/SECRET=.*/SECRET=***/' "$NODE_ENV" 2>/dev/null + echo; echo "== services"; systemctl --no-pager --plain list-units 'virtuality-*' 'libvirtd*' 2>/dev/null + echo; echo "== virsh"; virsh list --all 2>&1; virsh net-list --all 2>&1; virsh pool-list --all 2>&1 + echo; echo "== network"; ip -br a 2>&1; ip route 2>&1 + echo; echo "== firewall"; ufw status 2>&1; nft list ruleset 2>&1 | head -200 + echo; echo "== disk"; df -h / "$DATA_DIR" 2>&1 + } > "$work/summary.txt" + journalctl -u virtuality-web -n 500 --no-pager > "$work/virtuality-web.log" 2>&1 || true + journalctl -u libvirtd -n 200 --no-pager > "$work/libvirtd.log" 2>&1 || true + cp "${LOG_DIR}"/update.log "${LOG_DIR}"/firstboot.log "${LOG_DIR}"/virtuality-ctl.log "$work"/ 2>/dev/null || true + mkdir -p "$work/operations" + ls -t "${LOG_DIR}/operations"/*.json 2>/dev/null | head -20 | while read -r f; do cp "$f" "${f%.json}.log" "$work/operations/" 2>/dev/null || true; done + tar -czf "$out" -C "$work" . + rm -rf "$work" + echo "$out" +} + +cmd_status() { + echo "Virtuality $(cat "${SOURCE_DIR}/VERSION" 2>/dev/null || echo unknown)" + echo "Панель: $(panel_url) ($(systemctl is-active virtuality-web.service 2>/dev/null || echo unknown))" + echo "Пользователь: $(env_get VIRTUALITY_AUTH_USER root)" + echo "HTTPS: $([[ "$(env_get VIRTUALITY_TLS 0)" == "1" ]] && echo включён || echo выключен)" + echo "Автообновление: $([[ "$(env_get VIRTUALITY_AUTO_UPDATE 1)" == "1" ]] && echo "включено ($(env_get VIRTUALITY_UPDATE_CHANNEL stable))" || echo выключено)" + echo "Часовой пояс: $(timedatectl show -p Timezone --value 2>/dev/null || echo unknown)" + echo "Хранилище: $(df -h "$DATA_DIR" 2>/dev/null | awk 'NR==2 {print $4" свободно из "$2}')" +} + +# ---------------------------------------------------------------- uninstall +cmd_uninstall() { + need_root uninstall + local purge=0 yes=0 arg + for arg in "$@"; do + case "$arg" in + --purge) purge=1 ;; + --yes|-y) yes=1 ;; + *) die "unknown option: $arg" ;; + esac + done + if [[ "$yes" -ne 1 ]]; then + echo "Будут удалены панель, службы и настройки Virtuality." + [[ "$purge" -eq 1 ]] && echo "Режим --purge: также удаляются ВСЕ машины, диски, образы и резервные копии." + read -r -p "Продолжить? Введите YES: " answer + [[ "$answer" == "YES" ]] || { echo "Отменено."; exit 0; } + fi + local unit + for unit in virtuality-web.service virtuality-redirect.service virtuality-network.service virtuality-auto-update.timer virtuality-auto-update.service virtuality-maintenance.timer virtuality-maintenance.service virtuality-console-dashboard.service virtuality-firstboot.service; do + systemctl disable --now "$unit" >/dev/null 2>&1 || true + rm -f "${UNIT_DIR}/${unit}" + done + systemctl enable --now getty@tty1.service >/dev/null 2>&1 || true + systemctl daemon-reload + nft delete table ip virtuality >/dev/null 2>&1 || true + if command -v ufw >/dev/null 2>&1 && [[ -f "$STATE_FILE" ]]; then + while read -r old; do [[ -n "$old" ]] && ufw delete allow "${old}/tcp" >/dev/null 2>&1 || true; done < "$STATE_FILE" + fi + rm -rf /opt/virtuality /etc/virtuality /etc/logrotate.d/virtuality /etc/sysctl.d/98-virtuality-rpfilter.conf /etc/sysctl.d/99-virtuality-forward.conf + rm -f /usr/local/bin/vhealth /usr/local/bin/virtuality-console-dashboard /etc/issue.d/virtuality.issue + if [[ "$purge" -eq 1 ]]; then + if command -v virsh >/dev/null 2>&1; then + local name + while read -r name; do + [[ -n "$name" ]] || continue + virsh destroy "$name" >/dev/null 2>&1 || true + virsh undefine "$name" --remove-all-storage --nvram --managed-save --snapshots-metadata >/dev/null 2>&1 || true + done < <(virsh list --all --name 2>/dev/null) + virsh net-destroy virtuality-nat >/dev/null 2>&1 || true + virsh net-undefine virtuality-nat >/dev/null 2>&1 || true + fi + rm -rf "$DATA_DIR" "$LOG_DIR" + else + rm -rf "$CONFIG_DIR" "${DATA_DIR}/update" "${DATA_DIR}/tmp" + echo "Машины, образы и резервные копии сохранены в ${DATA_DIR}" + fi + rm -f /usr/local/bin/virtuality-ctl + echo "Virtuality удалена" +} + +case "${1:-}" in + status) cmd_status ;; + reconfigure) cmd_reconfigure ;; + set) shift; cmd_set "$@" ;; + passwd) shift; cmd_passwd "$@" ;; + timezone) shift; cmd_timezone "$@" ;; + power) shift; cmd_power "$@" ;; + backup-config) shift; cmd_backup_config "$@" ;; + restore-config) shift; cmd_restore_config "$@" ;; + rollback) cmd_rollback ;; + maintenance) cmd_maintenance ;; + bridge) shift; cmd_bridge "$@" ;; + bridge-confirm) cmd_bridge_confirm ;; + bridge-revert) cmd_bridge_revert ;; + support-bundle) shift; cmd_support_bundle "$@" ;; + uninstall) shift; cmd_uninstall "$@" ;; + -h|--help|help|"") usage ;; + *) usage >&2; die "неизвестная команда: $1" ;; +esac diff --git a/web/redirect_http.py b/web/redirect_http.py new file mode 100644 index 0000000..c6ef07f --- /dev/null +++ b/web/redirect_http.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Tiny HTTP → HTTPS redirector for the panel (used when TLS is enabled). + +Listens on the plain port so that a user typing http://server:8088 lands on +https://server:8443 instead of a TLS error. +""" +import argparse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8088) + parser.add_argument("--target-port", type=int, default=8443) + args = parser.parse_args() + + class Handler(BaseHTTPRequestHandler): + server_version = "Virtuality" + sys_version = "" + + def do_GET(self): # noqa: N802 + host = (self.headers.get("Host") or "").split(":")[0] or "localhost" + self.send_response(301) + self.send_header("Location", f"https://{host}:{args.target_port}{self.path}") + self.send_header("Content-Length", "0") + self.end_headers() + + do_HEAD = do_GET # noqa: N815 + do_POST = do_GET # noqa: N815 + + def log_message(self, *_args): + return + + ThreadingHTTPServer((args.host, args.port), Handler).serve_forever() + + +if __name__ == "__main__": + main() From 4198c18ff4aabeac0bb370fce136469f3f8fe6b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:30:25 +0000 Subject: [PATCH 04/32] =?UTF-8?q?=D0=A1=D0=BD=D0=B8=D0=BC=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=88=D0=B8=D0=BD=20(=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F)=20=D0=B1=D1=8B=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая страница /vm/{name}/snapshots и вкладка «Снимки» на странице машины: список внутренних снимков libvirt с описанием, датой, состоянием и меткой «Текущий», создание снимка с автоматическим именем snap-ГГГГММДД-ЧЧММ, возврат к снимку и удаление. Создание и возврат идут как фоновые задачи с журналом; страница сама обновляется, когда задача завершится. Ограничения и ошибки объясняются по-русски: не больше 8 снимков на машину, только диски qcow2, предупреждение для машин с UEFI; известные ошибки libvirt переводятся, а исходный текст показывается рядом. Возврат повторяется с --force, если libvirt этого требует. Модуль web/features/snapshots.py, парсеры с тестами на образцах вывода virsh, fake_run_cmd в тестах расширен командами snapshot-*, demo-сервер показывает снимки и идущую задачу. В спрайт добавлены иконки camera и history. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- tests/conftest.py | 34 +++- tests/dev_server.py | 35 +++- tests/test_snapshots.py | 285 ++++++++++++++++++++++++++ web/app.py | 4 + web/features/__init__.py | 0 web/features/snapshots.py | 344 ++++++++++++++++++++++++++++++++ web/static/icons.svg | 2 + web/templates/vm_detail.html | 1 + web/templates/vm_snapshots.html | 167 ++++++++++++++++ 9 files changed, 869 insertions(+), 3 deletions(-) create mode 100644 tests/test_snapshots.py create mode 100644 web/features/__init__.py create mode 100644 web/features/snapshots.py create mode 100644 web/templates/vm_snapshots.html diff --git a/tests/conftest.py b/tests/conftest.py index 0491550..99dde35 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,12 +35,37 @@ DUMPXML = """ """ +FIXTURES = ROOT / "tests" / "fixtures" + +# Снимки web01: имя → (состояние, описание); список берётся из fixtures/snapshot-list.txt. +SNAPSHOTS = {"before-upd": ("shutoff", "Перед обновлением ядра"), "clean": ("running", "Чистая система после установки")} + + 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": ""} +def option(cmd, name): + """Значение опции virsh вида --name VALUE (или '').""" + return cmd[cmd.index(name) + 1] if name in cmd and cmd.index(name) + 1 < len(cmd) else "" + + +def fake_snapshot_xml(snap: str) -> str: + state, description = SNAPSHOTS.get(snap, ("shutoff", "")) + memory = "" if state == "running" else "" + return f"{snap}{description}{state}{memory}1758794400" + + def fake_run_cmd(cmd, timeout=12, **_kwargs): joined = " ".join(cmd) + if cmd[:2] == ["virsh", "snapshot-list"]: + return fake_result((FIXTURES / "snapshot-list.txt").read_text()) + if cmd[:2] == ["virsh", "snapshot-current"]: + return fake_result("clean") + if cmd[:2] == ["virsh", "snapshot-dumpxml"]: + return fake_result(fake_snapshot_xml(option(cmd, "--snapshotname"))) + if cmd[:2] in (["virsh", "snapshot-create-as"], ["virsh", "snapshot-revert"], ["virsh", "snapshot-delete"]): + return fake_result("Domain snapshot %s created" % option(cmd, "--name")) if cmd[:2] == ["virsh", "list"]: return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off") if cmd[:2] == ["virsh", "dominfo"]: @@ -72,9 +97,15 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs): return fake_result() +def feature_modules(): + """Загруженные модули web/features/* — им тоже подменяем run_cmd.""" + return [module for name, module in sys.modules.items() if name.startswith("features.") and hasattr(module, "run_cmd")] + + @pytest.fixture() def data_dirs(tmp_path, monkeypatch): import app + import core import host_profile import network_core import update_core @@ -95,6 +126,7 @@ def data_dirs(tmp_path, monkeypatch): monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"]) monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"]) monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"]) + monkeypatch.setattr(core, "OPERATIONS_DIR", dirs["operations"]) monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"]) monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"]) monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"]) @@ -108,7 +140,7 @@ def data_dirs(tmp_path, monkeypatch): monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json") monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log") monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT) - for module in (app, network_core, host_profile): + for module in (app, network_core, host_profile, core, *feature_modules()): monkeypatch.setattr(module, "run_cmd", fake_run_cmd) return dirs diff --git a/tests/dev_server.py b/tests/dev_server.py index 9df2e62..83f788a 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -13,16 +13,44 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import conftest # noqa: E402 (sets sys.path and env) import app # noqa: E402 +import core # noqa: E402 import host_profile # noqa: E402 import network_core # noqa: E402 import update_core # noqa: E402 +# Снимки для демо: имя, дата, состояние, описание. +DEMO_SNAPSHOTS = [ + ("snap-20260925-1012", "2026-09-25 10:12:03 +0000", "shutoff", "Перед обновлением ядра"), + ("snap-20260924-0900", "2026-09-24 09:00:41 +0000", "running", "Настроен nginx и сертификаты"), + ("snap-20260918-1830", "2026-09-18 18:30:12 +0000", "running", "Чистая система после установки"), + ("clean", "2026-09-10 12:05:00 +0000", "shutoff", ""), +] + + +def demo_run_cmd(cmd, timeout=12, **kwargs): + """Как conftest.fake_run_cmd, но со списком снимков побогаче.""" + if cmd[:2] == ["virsh", "snapshot-list"]: + rows = "\n".join(f" {name:<20} {when} {state}" for name, when, state, _ in DEMO_SNAPSHOTS) + return conftest.fake_result(" Name Creation Time State\n" + "-" * 62 + "\n" + rows) + if cmd[:2] == ["virsh", "snapshot-current"]: + return conftest.fake_result("snap-20260924-0900") + if cmd[:2] == ["virsh", "snapshot-create-as"] and "uefi" in conftest.option(cmd, "--description").lower(): + # Описание со словом «uefi» показывает, как выглядит ошибка libvirt. + return conftest.fake_result(ok=False, stderr="error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported") + if cmd[:2] == ["virsh", "snapshot-dumpxml"]: + snap = conftest.option(cmd, "--snapshotname") + state, description = next(((s, d) for n, _, s, d in DEMO_SNAPSHOTS if n == snap), ("shutoff", "")) + memory = "internal" if state == "running" else "no" + return conftest.fake_result(f"{snap}{description}{state}") + return conftest.fake_run_cmd(cmd, timeout, **kwargs) + def seed(root: Path) -> None: dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")} for path in dirs.values(): 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"] + core.OPERATIONS_DIR = dirs["operations"] 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.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml" @@ -32,8 +60,8 @@ def seed(root: Path) -> None: 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.SOURCE_DIR = Path(__file__).resolve().parents[1] - for module in (app, network_core, host_profile): - module.run_cmd = conftest.fake_run_cmd + for module in (app, network_core, host_profile, core, *conftest.feature_modules()): + module.run_cmd = demo_run_cmd app.auth.verify_password = lambda user, password: True for name, size in (("ubuntu-26.04-live-server-amd64.iso", 3), ("debian-13.7.0-amd64-netinst.iso", 1)): @@ -49,6 +77,9 @@ def seed(root: Path) -> None: 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.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...") + # Идущий снимок машины db01 — видно на /vm/db01/snapshots. + snap_op = core.new_operation("snapshot_create", "Снимок машины db01", vm_name="db01", snapshot="snap-20260925-1500", vm_running=True) + core.update_operation(snap_op, status="running", progress=15, message="Сохраняем состояние машины и памяти — она на несколько секунд замрёт…") if __name__ == "__main__": diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py new file mode 100644 index 0000000..c60632c --- /dev/null +++ b/tests/test_snapshots.py @@ -0,0 +1,285 @@ +"""Снимки (точки восстановления) машины: парсеры и маршруты /vm/{name}/snapshots.""" +from datetime import datetime +from pathlib import Path +from urllib.parse import unquote + +import pytest + +import conftest +import core +from features import snapshots + +FIXTURES = Path(__file__).resolve().parent / "fixtures" + + +# ---------------------------------------------------------------- parsers +def test_parse_snapshot_list_fixture(): + rows = snapshots.parse_snapshot_list((FIXTURES / "snapshot-list.txt").read_text()) + assert [row["name"] for row in rows] == ["before-upd", "clean"] # новые сверху + assert rows[0] == {"name": "before-upd", "created_at": "2026-09-25 10:12:03", "tz": "+0000", "state": "shutoff"} + assert rows[1]["state"] == "running" + + +def test_parse_snapshot_list_without_timezone_and_garbage(): + text = " Name Creation Time State\n------\n snap1 2026-01-02 03:04:05 running\nerror: something\n\n" + rows = snapshots.parse_snapshot_list(text) + assert rows == [{"name": "snap1", "created_at": "2026-01-02 03:04:05", "tz": "", "state": "running"}] + assert snapshots.parse_snapshot_list("") == [] + + +def test_parse_snapshot_xml(): + meta = snapshots.parse_snapshot_xml(conftest.fake_snapshot_xml("clean")) + assert meta["description"] == "Чистая система после установки" + assert meta["state"] == "running" and meta["with_memory"] is True + assert snapshots.parse_snapshot_xml(conftest.fake_snapshot_xml("before-upd"))["with_memory"] is False + assert snapshots.parse_snapshot_xml("x + hvm/usr/share/OVMF/OVMF_CODE.fd/var/lib/libvirt/qemu/nvram/x.fd + + + + """ + info = snapshots.inspect_domain_xml(xml) + assert info["unsupported"] == [{"target": "vda", "format": "raw"}] + assert info["uefi"] is True + assert snapshots.inspect_domain_xml("") + return original(cmd, timeout, **kwargs) + + monkeypatch.setattr(snapshots, "run_cmd", run) + response = logged_in.post("/vm/web01/snapshots/create", data={"description": "x"}, follow_redirects=False) + assert "qcow2" in unquote(response.headers["location"]) + assert not find(recorder, "virsh", "snapshot-create-as") + + +def test_create_snapshot_surfaces_libvirt_error(logged_in, data_dirs, recorder, monkeypatch): + original = snapshots.run_cmd + + def run(cmd, timeout=12, **kwargs): + if cmd[:2] == ["virsh", "snapshot-create-as"]: + return conftest.fake_result(ok=False, stderr="error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported") + return original(cmd, timeout, **kwargs) + + monkeypatch.setattr(snapshots, "run_cmd", run) + response = logged_in.post("/vm/web01/snapshots/create", data={}, follow_redirects=False) + location = response.headers["location"] + operation = core.read_operation(location.split("op=", 1)[1]) + assert operation["status"] == "error" + assert operation["message"].startswith("Для машин с UEFI-прошивкой") and "pflash" in operation["message"] + assert "pflash" in operation["log_tail"] + html = logged_in.get(location).text + assert "Для машин с UEFI-прошивкой" in html + + +def test_create_snapshot_waits_for_active_operation(logged_in, data_dirs, recorder): + core.new_operation("snapshot_create", "Снимок машины web01", vm_name="web01", snapshot="snap-x") + response = logged_in.post("/vm/web01/snapshots/create", data={}, follow_redirects=False) + assert "предыдущей операции" in unquote(response.headers["location"]) + assert not find(recorder, "virsh", "snapshot-create-as") + html = logged_in.get("/vm/web01/snapshots").text + assert 'data-watch-operation=' in html and "/api/operations/" in html + + +def test_revert_snapshot(logged_in, data_dirs, recorder): + response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False) + assert response.status_code == 303 + assert find(recorder, "virsh", "snapshot-revert") == [["virsh", "snapshot-revert", "--domain", "web01", "--snapshotname", "clean"]] + operation = core.read_operation(response.headers["location"].split("op=", 1)[1]) + assert operation["status"] == "success" and "возвращена" in operation["message"] and "работает" in operation["message"] + + +def test_revert_retries_with_force(logged_in, data_dirs, recorder, monkeypatch): + original = snapshots.run_cmd + + def run(cmd, timeout=12, **kwargs): + if cmd[:2] == ["virsh", "snapshot-revert"] and "--force" not in cmd: + recorder.append(list(cmd)) + return conftest.fake_result(ok=False, stderr="error: revert requires force: snapshot was created with a different configuration") + return original(cmd, timeout, **kwargs) + + monkeypatch.setattr(snapshots, "run_cmd", run) + response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False) + reverts = find(recorder, "virsh", "snapshot-revert") + assert len(reverts) == 2 and reverts[1][-1] == "--force" + assert core.read_operation(response.headers["location"].split("op=", 1)[1])["status"] == "success" + + +def test_revert_reports_error(logged_in, data_dirs, recorder, monkeypatch): + original = snapshots.run_cmd + + def run(cmd, timeout=12, **kwargs): + if cmd[:2] == ["virsh", "snapshot-revert"]: + return conftest.fake_result(ok=False, stderr="error: Domain snapshot not found: no domain snapshot with matching name 'clean'") + return original(cmd, timeout, **kwargs) + + monkeypatch.setattr(snapshots, "run_cmd", run) + response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False) + operation = core.read_operation(response.headers["location"].split("op=", 1)[1]) + assert operation["status"] == "error" and operation["message"].startswith("Такого снимка уже нет") + + +def test_delete_snapshot(logged_in, data_dirs, recorder): + response = logged_in.post("/vm/web01/snapshots/before-upd/delete", follow_redirects=False) + assert response.status_code == 303 + assert "удалён" in unquote(response.headers["location"]) + assert find(recorder, "virsh", "snapshot-delete") == [["virsh", "snapshot-delete", "--domain", "web01", "--snapshotname", "before-upd"]] + + +def test_delete_snapshot_error(logged_in, data_dirs, recorder, monkeypatch): + original = snapshots.run_cmd + + def run(cmd, timeout=12, **kwargs): + if cmd[:2] == ["virsh", "snapshot-delete"]: + return conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock") + return original(cmd, timeout, **kwargs) + + monkeypatch.setattr(snapshots, "run_cmd", run) + response = logged_in.post("/vm/web01/snapshots/before-upd/delete", follow_redirects=False) + assert "snapshot_error=" in response.headers["location"] and "занята" in unquote(response.headers["location"]) + + +def test_snapshot_routes_validate_names(logged_in, data_dirs, recorder): + assert logged_in.post("/vm/web01/snapshots/-bad/revert", follow_redirects=False).status_code == 400 + assert logged_in.post("/vm/web01/snapshots/..%2Fx/delete", follow_redirects=False).status_code in (400, 404) + assert logged_in.post("/vm/-bad/snapshots/create", data={}, follow_redirects=False).status_code == 400 + assert not find(recorder, "virsh", "snapshot-delete") and not find(recorder, "virsh", "snapshot-revert") + + +def test_snapshot_routes_require_login(client): + for path in ("/vm/web01/snapshots/create", "/vm/web01/snapshots/clean/revert", "/vm/web01/snapshots/clean/delete"): + response = client.post(path, follow_redirects=False) + assert response.status_code == 303 and response.headers["location"] == "/login" diff --git a/web/app.py b/web/app.py index c911878..197fdac 100644 --- a/web/app.py +++ b/web/app.py @@ -2016,3 +2016,7 @@ def api_health(request: Request): if not get_current_user(request): 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()} + + +import features.snapshots as snapshots # noqa: E402 +app.include_router(snapshots.router) diff --git a/web/features/__init__.py b/web/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/features/snapshots.py b/web/features/snapshots.py new file mode 100644 index 0000000..a4ed371 --- /dev/null +++ b/web/features/snapshots.py @@ -0,0 +1,344 @@ +"""«Снимки» (точки восстановления) виртуальной машины — внутренние снимки +libvirt через `virsh snapshot-*`. + +Снимок хранится внутри qcow2-диска машины. У работающей машины в снимок +попадает и память (машина на несколько секунд замирает), у выключенной — +только диск. Создание и возврат выполняются как фоновые операции. +""" +import re +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta +from typing import Any + +from fastapi import APIRouter, Form, Request +from fastapi.responses import JSONResponse, RedirectResponse + +import presenters +from core import ( + append_operation_log, + cmd_error, + finish_operation, + list_operations, + new_operation, + read_operation, + redirect_with_message, + render, + require_auth, + run_cmd, + run_operation, + update_operation, + valid_label, + valid_vm_name, +) + +router = APIRouter() + +MAX_SNAPSHOTS = 8 +DESCRIPTION_MAX = 200 +SNAPSHOT_TIMEOUT = 1800 # секунд: снимок памяти большой машины идёт минуты +OPERATION_TYPES = ("snapshot_create", "snapshot_revert") + +SNAPSHOT_STATES = { + "running": ("Работала", "success"), + "paused": ("Была на паузе", "warning"), + "shutoff": ("Была выключена", "neutral"), + "disk-snapshot": ("Только диск", "info"), +} + +MONTHS = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"] + +# Известные ошибки libvirt → понятное объяснение (подстрока ищется без учёта регистра). +KNOWN_ERRORS = [ + ("pflash", "Для машин с UEFI-прошивкой эта версия libvirt не умеет делать внутренние снимки."), + ("nvram", "Для машин с UEFI-прошивкой эта версия libvirt не умеет делать внутренние снимки."), + ("unsupported for storage type", "Внутренние снимки работают только с дисками qcow2."), + ("does not support snapshot", "Внутренние снимки работают только с дисками qcow2."), + ("internal snapshot for disk", "Внутренние снимки работают только с дисками qcow2."), + ("cannot acquire state change lock", "Машина занята другой операцией. Подождите минуту и попробуйте снова."), + ("timed out during operation", "Машина занята другой операцией. Подождите минуту и попробуйте снова."), + ("snapshot not found", "Такого снимка уже нет — возможно, его удалили."), + ("already exists", "Снимок с таким именем уже есть."), + ("no space left", "На диске сервера закончилось место."), + ("is not running", "Машина выключена, поэтому это действие сейчас недоступно."), +] + +_ROW_RE = re.compile(r"^\s*(\S+)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})(?:\s+([+-]\d{4}))?\s+(\S+)\s*$") + + +# ---------------------------------------------------------------- parsers +def parse_snapshot_list(text: str) -> list[dict[str, Any]]: + """Таблица `virsh snapshot-list` → список снимков, новые сверху.""" + rows = [] + for line in (text or "").splitlines(): + match = _ROW_RE.match(line) + if not match: + continue + name, date, time, tz, state = match.groups() + rows.append({"name": name, "created_at": f"{date} {time}", "tz": tz or "", "state": state}) + return sorted(rows, key=lambda row: row["created_at"], reverse=True) + + +def parse_snapshot_xml(text: str) -> dict[str, Any]: + """`virsh snapshot-dumpxml` → описание, состояние, есть ли память.""" + try: + root = ET.fromstring(text or "") + except ET.ParseError: + return {} + memory = root.find("memory") + parent = root.find("parent/name") + return { + "description": (root.findtext("description") or "").strip(), + "state": (root.findtext("state") or "").strip(), + "with_memory": memory is not None and memory.get("snapshot", "no") not in ("no", ""), + "parent": (parent.text or "").strip() if parent is not None else "", + } + + +def inspect_domain_xml(text: str) -> dict[str, Any]: + """`virsh dumpxml` → форматы дисков и признак UEFI (для проверок перед снимком).""" + info: dict[str, Any] = {"disks": [], "unsupported": [], "uefi": False} + try: + root = ET.fromstring(text or "") + except ET.ParseError: + return info + for disk in root.iter("disk"): + if disk.get("device", "disk") != "disk": + continue + driver = disk.find("driver") + target = disk.find("target") + fmt = (driver.get("type") if driver is not None else "") or "" + dev = (target.get("dev") if target is not None else "") or "?" + info["disks"].append({"target": dev, "format": fmt or "неизвестно"}) + if fmt and fmt != "qcow2": + info["unsupported"].append({"target": dev, "format": fmt}) + os_node = root.find("os") + if os_node is not None: + loader = os_node.find("loader") + info["uefi"] = os_node.get("firmware") == "efi" or os_node.find("nvram") is not None or (loader is not None and loader.get("type") == "pflash") + return info + + +def snapshot_state(state: str) -> dict[str, str]: + label, tone = SNAPSHOT_STATES.get((state or "").strip().lower(), (state or "—", "neutral")) + return {"label": label, "tone": tone} + + +def format_when(created_at: str, now: datetime | None = None) -> str: + """'2026-09-25 10:12:03' → 'Сегодня, 10:12' / 'Вчера, 10:12' / '25 сентября 2026, 10:12'.""" + try: + moment = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError): + return created_at or "—" + today = (now or datetime.now()).date() + clock = moment.strftime("%H:%M") + if moment.date() == today: + return f"Сегодня, {clock}" + if moment.date() == today - timedelta(days=1): + return f"Вчера, {clock}" + return f"{moment.day} {MONTHS[moment.month - 1]} {moment.year}, {clock}" + + +def explain_error(raw: str) -> str: + """Текст ошибки libvirt → понятная фраза; технический текст остаётся в скобках.""" + text = re.sub(r"^error:\s*", "", (raw or "").strip(), flags=re.IGNORECASE) + first = text.splitlines()[0] if text else "" + low = first.lower() + for needle, explanation in KNOWN_ERRORS: + if needle in low: + return f"{explanation} (libvirt: {first})" + return first or "libvirt не выполнил команду и не объяснил причину." + + +def next_snapshot_name(existing: list[str], now: datetime | None = None) -> str: + stamp = (now or datetime.now()).strftime("snap-%Y%m%d-%H%M") + name, counter = stamp, 2 + while name in existing: + name, counter = f"{stamp}-{counter}", counter + 1 + return name + + +def clean_description(text: str) -> str: + text = re.sub(r"[\x00-\x1f\x7f]+", " ", text or "") + return re.sub(r"\s+", " ", text).strip().lstrip("-").strip()[:DESCRIPTION_MAX] + + +# ---------------------------------------------------------------- virsh +def current_snapshot(vm: str) -> str: + result = run_cmd(["virsh", "snapshot-current", "--domain", vm, "--name"], timeout=10) + return result["stdout"].strip() if result["ok"] else "" + + +def list_snapshots(vm: str) -> list[dict[str, Any]]: + result = run_cmd(["virsh", "snapshot-list", "--domain", vm], timeout=20) + rows = parse_snapshot_list(result["stdout"]) if result["ok"] else [] + current = current_snapshot(vm) if rows else "" + for row in rows: + xml = run_cmd(["virsh", "snapshot-dumpxml", "--domain", vm, "--snapshotname", row["name"]], timeout=10) + meta = parse_snapshot_xml(xml["stdout"]) if xml["ok"] else {} + row["description"] = meta.get("description", "") + row["with_memory"] = meta.get("with_memory", row["state"] == "running") + row["is_current"] = row["name"] == current + row["state_info"] = snapshot_state(row["state"]) + row["when"] = format_when(row["created_at"]) + row["title"] = row["description"] or row["name"] + return rows + + +def domain_checks(vm: str) -> dict[str, Any]: + result = run_cmd(["virsh", "dumpxml", vm], timeout=10) + return inspect_domain_xml(result["stdout"] if result["ok"] else "") + + +def vm_operations(vm: str) -> list[dict[str, Any]]: + return [op for op in list_operations(50) if op.get("type") in OPERATION_TYPES and op.get("vm_name") == vm] + + +def active_operations(vm: str) -> list[dict[str, Any]]: + return [op for op in vm_operations(vm) if op.get("status") in ("queued", "running")] + + +def create_blockers(vm: str, snapshots: list[dict[str, Any]], checks: dict[str, Any]) -> list[str]: + """Почему снимок сейчас нельзя создать (пустой список — можно).""" + reasons = [] + if len(snapshots) >= MAX_SNAPSHOTS: + reasons.append(f"Уже {MAX_SNAPSHOTS} снимков — это максимум для одной машины. Удалите ненужный снимок, чтобы создать новый.") + if checks.get("unsupported"): + disks = ", ".join(f"{d['target']} ({d['format']})" for d in checks["unsupported"]) + reasons.append(f"Внутренние снимки работают только с дисками qcow2, а у этой машины диск другого формата: {disks}.") + if active_operations(vm): + reasons.append("Дождитесь окончания предыдущей операции со снимками этой машины.") + return reasons + + +def _log_result(operation: dict[str, Any], result: dict[str, Any]) -> None: + for stream in ("stdout", "stderr"): + if result.get(stream): + append_operation_log(operation["id"], result[stream]) + + +def create_worker(operation: dict[str, Any]) -> None: + vm, snap, description = operation["vm_name"], operation["snapshot"], operation.get("description", "") + cmd = ["virsh", "snapshot-create-as", "--domain", vm, "--name", snap] + if description: + cmd += ["--description", description] + cmd.append("--atomic") + append_operation_log(operation["id"], "$ " + " ".join(cmd)) + update_operation(operation, progress=15, message="Сохраняем состояние машины и памяти — она на несколько секунд замрёт…" if operation.get("vm_running") else "Сохраняем состояние диска…", cmd=" ".join(cmd)) + result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT) + _log_result(operation, result) + if result["ok"]: + finish_operation(operation, True, f"Снимок «{snap}» создан") + else: + finish_operation(operation, False, explain_error(cmd_error(result, "virsh завершился с ошибкой"))) + + +def revert_worker(operation: dict[str, Any]) -> None: + vm, snap = operation["vm_name"], operation["snapshot"] + cmd = ["virsh", "snapshot-revert", "--domain", vm, "--snapshotname", snap] + append_operation_log(operation["id"], "$ " + " ".join(cmd)) + update_operation(operation, progress=15, message="Возвращаем машину к сохранённому состоянию…", cmd=" ".join(cmd)) + result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT) + _log_result(operation, result) + if not result["ok"] and "force" in (result.get("stderr") or "").lower(): + # libvirt просит --force, когда конфигурация машины изменилась после снимка: пользователь уже подтвердил возврат. + append_operation_log(operation["id"], "libvirt требует подтверждения (--force): повторяем принудительно.") + cmd.append("--force") + append_operation_log(operation["id"], "$ " + " ".join(cmd)) + result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT) + _log_result(operation, result) + if not result["ok"]: + finish_operation(operation, False, explain_error(cmd_error(result, "virsh завершился с ошибкой"))) + return + state = presenters.vm_state(run_cmd(["virsh", "domstate", vm], timeout=8)["stdout"]) + finish_operation(operation, True, f"Машина возвращена к снимку «{snap}». Сейчас она {'работает' if state['running'] else 'выключена'}.") + + +# ---------------------------------------------------------------- routes +def _guard(request: Request, name: str): + """Редирект на /login, 400 для плохого имени, редирект на / для несуществующей машины — или None + dominfo.""" + auth_redirect = require_auth(request) + if auth_redirect: + return auth_redirect, "" + if not valid_vm_name(name): + return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400), "" + dominfo = run_cmd(["virsh", "dominfo", name], timeout=8) + if not dominfo["ok"]: + return RedirectResponse(url="/", status_code=303), "" + return None, dominfo["stdout"] + + +@router.get("/vm/{name}/snapshots") +def snapshots_page(request: Request, name: str): + blocked, dominfo = _guard(request, name) + if blocked: + return blocked + info = presenters.parse_dominfo(dominfo) + snapshots = list_snapshots(name) + checks = domain_checks(name) + running = presenters.vm_state(info["state"])["running"] + op_id = request.query_params.get("op", "") + operation = read_operation(op_id) if op_id else None + if operation and (operation.get("vm_name") != name or operation.get("type") not in OPERATION_TYPES): + operation = None + return render(request, "vm_snapshots.html", { + "vm": {"name": name, "info": info, "dominfo": dominfo}, + "vm_running": running, + "snapshots": snapshots, + "max_snapshots": MAX_SNAPSHOTS, + "checks": checks, + "blockers": create_blockers(name, snapshots, checks), + "suggested_name": next_snapshot_name([s["name"] for s in snapshots]), + "description_max": DESCRIPTION_MAX, + "operation": operation, + "active_operations": active_operations(name), + "snapshot_message": request.query_params.get("snapshot_message", ""), + "snapshot_error": request.query_params.get("snapshot_error", ""), + "raw_list": run_cmd(["virsh", "snapshot-list", "--domain", name], timeout=20)["stdout"], + }) + + +@router.post("/vm/{name}/snapshots/create") +def snapshot_create(request: Request, name: str, description: str = Form("")): + blocked, dominfo = _guard(request, name) + if blocked: + return blocked + snapshots = list_snapshots(name) + blockers = create_blockers(name, snapshots, domain_checks(name)) + if blockers: + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", blockers[0]) + running = presenters.vm_state(presenters.parse_dominfo(dominfo)["state"])["running"] + snap = next_snapshot_name([s["name"] for s in snapshots]) + if not valid_label(snap): + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Не удалось подобрать имя снимка.") + operation = new_operation("snapshot_create", f"Снимок машины {name}", vm_name=name, snapshot=snap, description=clean_description(description), vm_running=running) + run_operation(operation, create_worker) + return RedirectResponse(url=f"/vm/{name}/snapshots?op={operation['id']}", status_code=303) + + +@router.post("/vm/{name}/snapshots/{snap}/revert") +def snapshot_revert(request: Request, name: str, snap: str): + blocked, _ = _guard(request, name) + if blocked: + return blocked + if not valid_label(snap): + return JSONResponse({"ok": False, "error": "Invalid snapshot name"}, status_code=400) + if active_operations(name): + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Дождитесь окончания предыдущей операции со снимками этой машины.") + operation = new_operation("snapshot_revert", f"Возврат машины {name} к снимку {snap}", vm_name=name, snapshot=snap) + run_operation(operation, revert_worker) + return RedirectResponse(url=f"/vm/{name}/snapshots?op={operation['id']}", status_code=303) + + +@router.post("/vm/{name}/snapshots/{snap}/delete") +def snapshot_delete(request: Request, name: str, snap: str): + blocked, _ = _guard(request, name) + if blocked: + return blocked + if not valid_label(snap): + return JSONResponse({"ok": False, "error": "Invalid snapshot name"}, status_code=400) + if active_operations(name): + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Дождитесь окончания предыдущей операции со снимками этой машины.") + result = run_cmd(["virsh", "snapshot-delete", "--domain", name, "--snapshotname", snap], timeout=180) + if not result["ok"]: + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Не удалось удалить снимок: " + explain_error(cmd_error(result, "virsh завершился с ошибкой"))) + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_message", f"Снимок «{snap}» удалён") diff --git a/web/static/icons.svg b/web/static/icons.svg index 12ffba4..ce2bf98 100644 --- a/web/static/icons.svg +++ b/web/static/icons.svg @@ -57,4 +57,6 @@ + + diff --git a/web/templates/vm_detail.html b/web/templates/vm_detail.html index 7764bd1..4c2d366 100644 --- a/web/templates/vm_detail.html +++ b/web/templates/vm_detail.html @@ -53,6 +53,7 @@ + {{ icon('camera', 'icon-sm') }}Снимки diff --git a/web/templates/vm_snapshots.html b/web/templates/vm_snapshots.html new file mode 100644 index 0000000..fd7724b --- /dev/null +++ b/web/templates/vm_snapshots.html @@ -0,0 +1,167 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, vm_badge, op_badge, empty, alert %} +{% set page_title = 'Снимки — ' ~ vm.name %} +{% set s = vm.info.state|vm_state %} +{% set count = snapshots|length %} + +{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% if snapshot_message %}{{ alert('success', snapshot_message) }}{% endif %} + {% if snapshot_error %}{{ alert('danger', snapshot_error) }}{% endif %} + {% if operation and operation.status == 'success' %}{{ alert('success', operation.message) }}{% endif %} + {% if operation and operation.status == 'error' %}{{ alert('danger', operation.message, title='Не получилось: ' ~ operation.title|lower) }}{% endif %} +{% endblock %} + +{% block content %} + + +
+
+ {% for op in active_operations %} +
+ +
+ {{ op.title }} + {{ op.message }} +
+
+ Подробнее{{ icon('chevron-right', 'icon-sm') }} +
+ {% endfor %} + +
+
+

Точки восстановления

Сохранённые состояния машины, к которым можно вернуться

+ {{ count }} из {{ max_snapshots }} +
+ {% if snapshots %} +
+ {% for snap in snapshots %} +
+ {{ icon('camera') }} +
+
+ {{ snap.title }} + {% if snap.is_current %}{{ badge('Текущий', 'brand', dot=False, attrs='title="Машина сейчас продолжает работу от этого снимка"') }}{% endif %} +
+
{{ snap.when }} · {{ snap.state_info.label }}{% if snap.with_memory %} — сохранена вместе с памятью{% endif %}{% if snap.description %} · {{ snap.name }}{% endif %}
+
+
+ +
+
+ +
+
+ {% endfor %} +
+ {% else %} + {{ empty('camera', 'Снимков пока нет', 'Сделайте снимок перед обновлением или рискованной настройкой. Если что-то пойдёт не так, машину можно будет вернуть назад за минуту.') }} + {% endif %} +
+ +
+ {{ icon('terminal', 'icon-sm') }}Для специалистов{{ icon('chevron-down', 'icon-sm') }} +
+

Это внутренние снимки libvirt (virsh snapshot-create-as … --atomic): они хранятся внутри qcow2-файла диска. Возврат — virsh snapshot-revert, удаление — virsh snapshot-delete.

+
+
virsh snapshot-list --domain {{ vm.name }}
+
{{ raw_list or '—' }}
+
+ {% if checks.disks %} +

Диски: {% for d in checks.disks %}{{ d.target }} — {{ d.format }}{{ ', ' if not loop.last }}{% endfor %}{% if checks.uefi %} · прошивка UEFI{% endif %}

+ {% endif %} +
+
+
+ + +
+{% endblock %} + +{% block scripts %} +{% if active_operations %} + +{% endif %} +{% endblock %} From 2450278d936bf3b6cd4a045d7eb69635773bf862 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:32:05 +0000 Subject: [PATCH 05/32] =?UTF-8?q?=D0=A0=D0=B5=D0=B7=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=88=D0=B8=D0=BD=20=D0=B1=D1=8B=D0=BB=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B:=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=B2=D0=BE?= =?UTF-8?q?=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5,=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Первый модуль-фича на общем 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 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- tests/conftest.py | 29 +- tests/dev_server.py | 32 +- tests/test_backups.py | 418 ++++++++++++++++++ web/app.py | 4 + web/features/__init__.py | 0 web/features/backups.py | 691 ++++++++++++++++++++++++++++++ web/static/app.css | 9 + web/templates/_backup_rows.html | 18 + web/templates/backup_restore.html | 75 ++++ web/templates/backups.html | 82 ++++ web/templates/base.html | 1 + web/templates/vm_backups.html | 81 ++++ web/templates/vm_detail.html | 1 + 13 files changed, 1433 insertions(+), 8 deletions(-) create mode 100644 tests/test_backups.py create mode 100644 web/features/__init__.py create mode 100644 web/features/backups.py create mode 100644 web/templates/_backup_rows.html create mode 100644 web/templates/backup_restore.html create mode 100644 web/templates/backups.html create mode 100644 web/templates/vm_backups.html diff --git a/tests/conftest.py b/tests/conftest.py index 0491550..94596e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,12 @@ DUMPXML = """ """ +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: 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): joined = " ".join(cmd) 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") 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") 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"]: return fake_result("running") if cmd[:2] == ["virsh", "domifaddr"]: @@ -75,6 +87,7 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs): @pytest.fixture() def data_dirs(tmp_path, monkeypatch): import app + import core import host_profile import network_core import update_core @@ -83,6 +96,7 @@ def data_dirs(tmp_path, monkeypatch): "iso": tmp_path / "iso", "images": tmp_path / "images", "disk_images": tmp_path / "disk-images", + "backups": tmp_path / "backups", "operations": tmp_path / "operations", "network": tmp_path / "network", "config": tmp_path / "config", @@ -91,10 +105,13 @@ def data_dirs(tmp_path, monkeypatch): } for path in dirs.values(): path.mkdir() - monkeypatch.setattr(app, "ISO_DIR", dirs["iso"]) - monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"]) - monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"]) - monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"]) + for module in (app, core): + monkeypatch.setattr(module, "ISO_DIR", dirs["iso"]) + monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"]) + 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, "NETWORK_DIR", dirs["network"]) 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, "LOG_FILE", dirs["update"] / "update.log") 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) return dirs diff --git a/tests/dev_server.py b/tests/dev_server.py index 9df2e62..dc87def 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -13,16 +13,40 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import conftest # noqa: E402 (sets sys.path and env) import app # noqa: E402 +import core # noqa: E402 import host_profile # noqa: E402 import network_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: - 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(): 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"] + 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.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json" 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" 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] - for module in (app, network_core, host_profile): + for module in (app, core, network_core, host_profile): module.run_cmd = conftest.fake_run_cmd app.auth.verify_password = lambda user, password: True @@ -49,6 +73,10 @@ def seed(root: Path) -> None: 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.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__": diff --git a/tests/test_backups.py b/tests/test_backups.py new file mode 100644 index 0000000..3148727 --- /dev/null +++ b/tests/test_backups.py @@ -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 "web02" in xml + assert "" not in xml and "id=" not in xml.split(">", 1)[0] + assert "" in xml or "" in xml # привод CD остаётся как был + + +def test_rewrite_xml_converts_block_disk_and_drops_nvram(): + source = """oldhvm/usr/share/OVMF/OVMF_CODE.fd/var/lib/libvirt/qemu/nvram/old_VARS.fd + """ + xml = backups.rewrite_xml(source, "new", {"vda": "/var/lib/virtuality/images/new.qcow2"}) + assert "" 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 "web02" in virsh.defined_xml and str(target) in virsh.defined_xml and "web01" 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() diff --git a/web/app.py b/web/app.py index c911878..4017882 100644 --- a/web/app.py +++ b/web/app.py @@ -2016,3 +2016,7 @@ def api_health(request: Request): if not get_current_user(request): 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()} + + +import features.backups as backups # noqa: E402 +app.include_router(backups.router) diff --git a/web/features/__init__.py b/web/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/features/backups.py b/web/features/backups.py new file mode 100644 index 0000000..ab2839c --- /dev/null +++ b/web/features/backups.py @@ -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. + + Меняет , убирает и id, переключает диски на новые файлы, + удаляет (libvirt выдаст новые адреса) и (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) diff --git a/web/static/app.css b/web/static/app.css index 33b98fa..0410358 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -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"]::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) */ .options { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); } .options-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } diff --git a/web/templates/_backup_rows.html b/web/templates/_backup_rows.html new file mode 100644 index 0000000..79cd92d --- /dev/null +++ b/web/templates/_backup_rows.html @@ -0,0 +1,18 @@ +{% from "_ui.html" import icon, badge %} +{#- Строки списка копий. Ожидает: rows (список копий), back (куда вернуться после удаления). -#} +{% for b in rows %} +
+ {{ icon('database' if b.complete else 'triangle-alert') }} +
+
{{ b.created_label }}{% if b.note %} {{ b.note }}{% endif %}
+
{{ b.size }} · {{ b.disk_count }} {{ plural(b.disk_count, 'диск', 'диска', 'дисков') }}{% if b.version %} · Virtuality {{ b.version }}{% endif %}{% if not b.complete %} · не хватает файлов{% endif %}
+
+ {% if b.complete %} + {{ icon('rotate-cw', 'icon-sm') }}Восстановить + {% endif %} +
+ + +
+
+{% endfor %} diff --git a/web/templates/backup_restore.html b/web/templates/backup_restore.html new file mode 100644 index 0000000..d9a365e --- /dev/null +++ b/web/templates/backup_restore.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, alert %} +{% set page_title = 'Восстановить ' ~ backup.vm %} + +{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% if error %}{{ alert('danger', error) }}{% endif %} +{% endblock %} + +{% block content %} +
+
+
+
+
+ + + Оставьте как есть, чтобы вернуть машину {{ backup.vm }}, или введите новое название — получится вторая независимая машина. +
+ + {% if exists %} +
{{ icon('triangle-alert') }}
Машина {{ name }} уже есть на сервереЧтобы восстановить копию под этим названием, существующую машину придётся удалить вместе с её дисками. Если хотите сохранить обе — укажите другое название.
+ + {% else %} + + {% endif %} + +
+
Дисков в копии{{ backup.disk_count }} · {{ backup.size }}
+
Понадобится местаоколо {{ need }}
+
Свободно{{ free }}
+
+
+ +
+
+ + +
+{% endblock %} diff --git a/web/templates/backups.html b/web/templates/backups.html new file mode 100644 index 0000000..1a9207d --- /dev/null +++ b/web/templates/backups.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, meter, empty, alert %} +{% set page_title = 'Резервные копии' %} + +{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% if backup_message %}{{ alert('success', backup_message) }}{% endif %} + {% if backup_error %}{{ alert('danger', backup_error) }}{% endif %} + {% for op in running %} +
{{ icon('activity') }}
{{ op.title }} — {{ op.progress|default(0) }}%{{ op.message }}
Следить
+ {% endfor %} +{% endblock %} + +{% block content %} +
+
+
Копий на сервере{{ icon('database', 'icon-sm') }}
+
{{ storage.count }}{{ plural(storage.count, 'копия', 'копии', 'копий') }}
+
{{ groups|length }} {{ plural(groups|length, 'машина', 'машины', 'машин') }}
+
+
+
Занято копиями{{ icon('hard-drive', 'icon-sm') }}
+
{{ storage.used }}
+ {{ meter(storage.used_pct, 'brand') }} +
{{ storage.used_pct }}% места на диске сервера
+
+
+
Свободно для новых копий{{ icon('layers', 'icon-sm') }}
+
{{ storage.free }}
+
Перед копированием место проверяется автоматически
+
+
+ +{% if groups %} +
+ {% for g in groups %} +
+
+
+ {{ icon('monitor') }} +
+

{% if g.exists %}{{ g.vm }}{% else %}{{ g.vm }}{% endif %}{% if not g.exists %} {{ badge('машина удалена', 'warning', cls='hide-sm') }}{% endif %}

+

{{ g.count }} {{ plural(g.count, 'копия', 'копии', 'копий') }} · {{ g.size }}{% if not g.exists %} · машины больше нет на сервере, но её можно восстановить{% endif %}

+
+
+ {% if g.exists %}{{ icon('plus', 'icon-sm') }}Создать копию{% endif %} +
+
+ {% with rows = g.backups, back = '/backups' %}{% include "_backup_rows.html" %}{% endwith %} +
+
+ {% endfor %} +
+{% else %} +
+ {{ empty('database', 'Копий пока нет', 'Откройте страницу машины, перейдите на вкладку «Резервные копии» и нажмите «Создать копию». Копирование идёт в фоне — страницу можно закрыть.', '/', 'К списку машин', 'monitor') }} +
+{% endif %} + +
+
+
+
{{ icon('shield-check') }}

Что входит в копию

+

Настройки машины (процессор, память, сеть, порядок загрузки) и все её диски целиком. Установочные образы (ISO) не копируются — они лежат отдельно в разделе «Образы ОС». Диски сжимаются, поэтому копия обычно меньше самой машины.

+
+
+
+
+
{{ icon('info') }}

Как это работает

+

Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной и получает новые сетевые адреса, чтобы не мешать оригиналу. Копии хранятся в папке /var/lib/virtuality/backups.

+
+
+
+{% endblock %} diff --git a/web/templates/base.html b/web/templates/base.html index 5364d58..383a455 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -39,6 +39,7 @@ {{ nav_link('/iso', 'disc-3', 'Образы ОС', path.startswith('/iso') or path.startswith('/disk-images')) }} {{ nav_link('/network', 'globe', 'Сеть и доступ', path.startswith('/network')) }} + {{ nav_link('/backups', 'database', 'Резервные копии', path.startswith('/backups')) }} {{ nav_link('/operations', 'list-checks', 'Задачи', path.startswith('/operations')) }} diff --git a/web/templates/vm_backups.html b/web/templates/vm_backups.html new file mode 100644 index 0000000..a3e4907 --- /dev/null +++ b/web/templates/vm_backups.html @@ -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 %} + +{% endblock %} + +{% block alerts %} + {% if backup_message %}{{ alert('success', backup_message) }}{% endif %} + {% if backup_error %}{{ alert('danger', backup_error) }}{% endif %} + {% if active %} +
{{ icon('activity') }}
{{ active.title }} — {{ active.progress|default(0) }}%{{ active.message }}
Следить
+ {% endif %} +{% endblock %} + +{% block content %} + + +
+
+

Копии этой машины

Новые сверху. Из любой можно восстановить машину — под тем же или новым названием.

+ {% if backups %} +
+ {% with rows = backups, back = '/vm/' ~ vm.name ~ '/backups' %}{% include "_backup_rows.html" %}{% endwith %} +
+ {% else %} + {{ empty('database', 'Копий пока нет', 'Создайте первую копию — это займёт несколько минут и защитит от неудачных обновлений и случайных ошибок.') }} + {% endif %} +
+ + +
+{% endblock %} diff --git a/web/templates/vm_detail.html b/web/templates/vm_detail.html index 7764bd1..ada4190 100644 --- a/web/templates/vm_detail.html +++ b/web/templates/vm_detail.html @@ -53,6 +53,7 @@ + {{ icon('database', 'icon-sm') }}Резервные копии From 8a4474a3057549390fe3f2ae11a219fda9b448a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:36:32 +0000 Subject: [PATCH 06/32] =?UTF-8?q?=D0=9C=D0=B0=D1=81=D1=82=D0=B5=D1=80=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B2=D0=B8=D1=87=D0=BD=D0=BE=D0=B9=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B8=20nod?= =?UTF-8?q?ectl:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=88=D0=B0=D0=B3=D0=BE=D0=B2=D1=8B=D0=B9=20/se?= =?UTF-8?q?tup,=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=83=D0=B7=D0=BB=D0=BE=D0=BC=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20virtuality-ctl=20=D0=B8=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/nodectl.py: факты об узле (сеть, диски, часовые пояса, KVM) и действия через virtuality-ctl - web/features/setup.py + шаблоны setup*: мастер из 8 шагов (установка, часовой пояс, пароль, HTTPS, сеть, хранилище, обновления, итог) - безопасное переключение на мост br0 с авто-откатом и подтверждением из браузера - virtuality-ctl storage-use: перенос данных на отдельный диск - панель перенаправляет на мастер до его завершения; демо-режим VIRTUALITY_DEV_SETUP - tests/test_setup.py: 8 тестов на маршруты мастера Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- .claude/worktrees/wf_18d9ca1d-93c-1 | 1 + .claude/worktrees/wf_18d9ca1d-93c-2 | 1 + .claude/worktrees/wf_18d9ca1d-93c-3 | 1 + .claude/worktrees/wf_18d9ca1d-93c-4 | 1 + scripts/virtuality-ctl | 37 ++++ tests/dev_server.py | 19 ++ tests/test_setup.py | 101 +++++++++ web/app.py | 6 + web/features/__init__.py | 0 web/features/setup.py | 287 ++++++++++++++++++++++++ web/nodectl.py | 313 +++++++++++++++++++++++++++ web/static/app.css | 28 +++ web/static/panel.js | 43 ++++ web/templates/setup.html | 241 +++++++++++++++++++++ web/templates/setup_applying.html | 30 +++ web/templates/setup_base.html | 60 +++++ web/templates/setup_bridge_wait.html | 48 ++++ 17 files changed, 1217 insertions(+) create mode 160000 .claude/worktrees/wf_18d9ca1d-93c-1 create mode 160000 .claude/worktrees/wf_18d9ca1d-93c-2 create mode 160000 .claude/worktrees/wf_18d9ca1d-93c-3 create mode 160000 .claude/worktrees/wf_18d9ca1d-93c-4 create mode 100644 tests/test_setup.py create mode 100644 web/features/__init__.py create mode 100644 web/features/setup.py create mode 100644 web/nodectl.py create mode 100644 web/templates/setup.html create mode 100644 web/templates/setup_applying.html create mode 100644 web/templates/setup_base.html create mode 100644 web/templates/setup_bridge_wait.html diff --git a/.claude/worktrees/wf_18d9ca1d-93c-1 b/.claude/worktrees/wf_18d9ca1d-93c-1 new file mode 160000 index 0000000..4198c18 --- /dev/null +++ b/.claude/worktrees/wf_18d9ca1d-93c-1 @@ -0,0 +1 @@ +Subproject commit 4198c18ff4aabeac0bb370fce136469f3f8fe6b8 diff --git a/.claude/worktrees/wf_18d9ca1d-93c-2 b/.claude/worktrees/wf_18d9ca1d-93c-2 new file mode 160000 index 0000000..2450278 --- /dev/null +++ b/.claude/worktrees/wf_18d9ca1d-93c-2 @@ -0,0 +1 @@ +Subproject commit 2450278d936bf3b6cd4a045d7eb69635773bf862 diff --git a/.claude/worktrees/wf_18d9ca1d-93c-3 b/.claude/worktrees/wf_18d9ca1d-93c-3 new file mode 160000 index 0000000..873b32c --- /dev/null +++ b/.claude/worktrees/wf_18d9ca1d-93c-3 @@ -0,0 +1 @@ +Subproject commit 873b32c517d5c3af6e1c1b94147ee5b1840fd55a diff --git a/.claude/worktrees/wf_18d9ca1d-93c-4 b/.claude/worktrees/wf_18d9ca1d-93c-4 new file mode 160000 index 0000000..7bf7a0f --- /dev/null +++ b/.claude/worktrees/wf_18d9ca1d-93c-4 @@ -0,0 +1 @@ +Subproject commit 7bf7a0f6b7bc72a3bcec589fc036cbffeae9ef57 diff --git a/scripts/virtuality-ctl b/scripts/virtuality-ctl index a76c185..cfeac79 100755 --- a/scripts/virtuality-ctl +++ b/scripts/virtuality-ctl @@ -35,6 +35,7 @@ Usage: virtuality-ctl [options] bridge [dhcp|static] Подключить машины к локальной сети через мост br0 с автооткатом bridge-confirm Подтвердить новую сеть (отменяет автооткат) bridge-revert Вернуть прежнюю сеть + storage-use /dev/sdX Отдать пустой диск под хранилище машин (${DATA_DIR}) support-bundle [FILE] Собрать отчёт для поддержки uninstall [--purge] [--yes] Удалить Virtuality (по умолчанию машины и образы остаются) EOF @@ -557,6 +558,41 @@ cmd_bridge_revert() { echo "Прежняя сеть восстановлена" } +# ---------------------------------------------------------------- storage +cmd_storage_use() { + need_root storage-use + local dev="${1:-}" + [[ "$dev" =~ ^/dev/[a-zA-Z0-9]+$ ]] || die "usage: virtuality-ctl storage-use /dev/sdX" + [[ -b "$dev" ]] || die "$dev не является блочным устройством" + local children fstype mounted + children="$(lsblk -n -o NAME "$dev" | tail -n +2 | wc -l)" + fstype="$(lsblk -n -d -o FSTYPE "$dev")" + mounted="$(lsblk -n -o MOUNTPOINT "$dev" | tr -d ' \n')" + [[ "$children" -eq 0 && -z "$fstype" && -z "$mounted" ]] || die "на $dev уже есть разделы или данные — используйте только пустой диск" + findmnt -n --target "$DATA_DIR" -o SOURCE | grep -q "^${dev}" && die "$dev уже используется" + log "storage-use ${dev}: formatting" + wipefs -a "$dev" >/dev/null + mkfs.ext4 -F -q -L virtuality "$dev" >/dev/null || die "не удалось создать файловую систему" + local uuid tmp + uuid="$(blkid -s UUID -o value "$dev")" + [[ -n "$uuid" ]] || die "не удалось прочитать UUID" + tmp="$(mktemp -d)" + mount "$dev" "$tmp" || die "не удалось смонтировать диск" + mkdir -p "$DATA_DIR" + rsync -a "$DATA_DIR"/ "$tmp"/ || { umount "$tmp"; die "не удалось перенести данные"; } + umount "$tmp"; rmdir "$tmp" + sed -i "\#[[:space:]]${DATA_DIR}[[:space:]]#d" /etc/fstab + printf 'UUID=%s %s ext4 defaults,nofail 0 2\n' "$uuid" "$DATA_DIR" >> /etc/fstab + systemctl daemon-reload + mount "$DATA_DIR" || die "не удалось смонтировать ${DATA_DIR}" + chmod 755 "$DATA_DIR" + mkdir -p "$DATA_DIR"/{iso,images,disk-images,backups,tmp,network,config} + chmod 1777 "$DATA_DIR/tmp" + command -v virsh >/dev/null 2>&1 && { virsh pool-refresh virtuality-images >/dev/null 2>&1 || true; virsh pool-refresh virtuality-iso >/dev/null 2>&1 || true; } + log "storage-use ${dev}: mounted at ${DATA_DIR}" + echo "Диск ${dev} подключён как ${DATA_DIR}" +} + # ---------------------------------------------------------------- support cmd_support_bundle() { need_root support-bundle @@ -658,6 +694,7 @@ case "${1:-}" in bridge) shift; cmd_bridge "$@" ;; bridge-confirm) cmd_bridge_confirm ;; bridge-revert) cmd_bridge_revert ;; + storage-use) shift; cmd_storage_use "$@" ;; support-bundle) shift; cmd_support_bundle "$@" ;; uninstall) shift; cmd_uninstall "$@" ;; -h|--help|help|"") usage ;; diff --git a/tests/dev_server.py b/tests/dev_server.py index 9df2e62..9dd38f2 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -4,6 +4,7 @@ python3 tests/dev_server.py [port] # login: tester / any password """ import json +import os import sys import tempfile import uuid @@ -35,6 +36,24 @@ def seed(root: Path) -> None: for module in (app, network_core, host_profile): module.run_cmd = conftest.fake_run_cmd app.auth.verify_password = lambda user, password: True + import nodectl + + nodectl.CONFIG_DIR = dirs["config"] + nodectl.SETUP_STATE = dirs["config"] / "setup.json" + nodectl.SETUP_DONE = dirs["config"] / "setup_done" + nodectl.WIZARD_FILE = dirs["config"] / "wizard.json" + stage = os.environ.get("VIRTUALITY_DEV_SETUP", "") + if stage: + # VIRTUALITY_DEV_SETUP=installing|done shows the setup wizard in the demo. + steps = [("panel", "Панель управления", "done"), ("network-wait", "Подключение к интернету", "done"), ("virtualization", "KVM, QEMU и libvirt", "running" if stage == "installing" else "done"), ("tools", "Инструменты диагностики", "pending" if stage == "installing" else "done"), ("finish", "Завершение", "pending" if stage == "installing" else "done")] + nodectl.SETUP_STATE.write_text(json.dumps({"stage": "installing" if stage == "installing" else "done", "message": "Устанавливаем qemu-system-x86 (14 из 61)…", "steps": [{"id": i, "title": t, "status": s} for i, t, s in steps]})) + nodectl.FIRSTBOOT_LOG = dirs["config"] / "firstboot.log" + nodectl.FIRSTBOOT_LOG.write_text("[2026-09-25 14:02:11] [virtuality-firstboot] step 1/4: web panel\n[2026-09-25 14:02:40] [virtuality-firstboot] step 2/4: virtualization node (KVM, libvirt)\nGet:14 http://archive.ubuntu.com/ubuntu resolute/main amd64 qemu-system-x86 amd64 1:10.2.1+ds-1ubuntu3 [9 812 kB]\n") + if not nodectl.ctl_available(): + nodectl.spare_disks = lambda: [{"path": "/dev/sdb", "name": "sdb", "size": 2 * 1024 ** 4, "model": "WD Red 2TB", "tran": "sata"}] + nodectl.network_facts = lambda: {"interface": "enp3s0", "gateway": "192.168.1.1", "address": "192.168.1.10", "prefix": 24, "mac": "aa:bb:cc:dd:ee:ff", "wireless": False, "private": True, "is_vps": False, "virt": "none", "bridge_present": False, "on_bridge": False, "recommended": "bridge", "revert_armed": False, "bridge_possible": True} + nodectl.hardware_summary = lambda: {"hostname": "home-server", "cpu_model": "Intel Core i5-12400", "cpu_count": 6, "mem_total": 32 * 1024 ** 3, "disk_total": 480 * 1024 ** 3, "disk_free": 401 * 1024 ** 3, "kvm": True} + nodectl.set_timezone = lambda tz: (True, tz) for name, size in (("ubuntu-26.04-live-server-amd64.iso", 3), ("debian-13.7.0-amd64-netinst.iso", 1)): with (dirs["iso"] / name).open("wb") as handle: diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..1ff7e03 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,101 @@ +import json + +import app +import nodectl +from features import setup + + +def prepare(data_dirs, monkeypatch, stage="done"): + config = data_dirs["config"] + monkeypatch.setattr(nodectl, "CONFIG_DIR", config) + monkeypatch.setattr(nodectl, "SETUP_STATE", config / "setup.json") + monkeypatch.setattr(nodectl, "SETUP_DONE", config / "setup_done") + monkeypatch.setattr(nodectl, "WIZARD_FILE", config / "wizard.json") + monkeypatch.setattr(nodectl, "FIRSTBOOT_LOG", config / "firstboot.log") + monkeypatch.setattr(nodectl, "ctl_available", lambda: False) + (config / "setup.json").write_text(json.dumps({"stage": stage, "message": "Устанавливаем…", "steps": [{"id": "panel", "title": "Панель", "status": "done"}, {"id": "virt", "title": "KVM", "status": "running" if stage != "done" else "done"}]})) + monkeypatch.setattr(nodectl, "network_facts", lambda: {"interface": "enp3s0", "gateway": "192.168.1.1", "address": "192.168.1.10", "prefix": 24, "mac": "aa:bb:cc:dd:ee:ff", "wireless": False, "private": True, "is_vps": False, "virt": "none", "bridge_present": False, "on_bridge": False, "recommended": "bridge", "revert_armed": False, "bridge_possible": True}) + monkeypatch.setattr(nodectl, "spare_disks", lambda: []) + monkeypatch.setattr(nodectl, "timezones", lambda: {"current": "UTC", "popular": ["Europe/Moscow", "UTC"], "all": ["Europe/Moscow", "UTC"]}) + + +def test_dashboard_redirects_to_wizard_until_done(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + response = logged_in.get("/", follow_redirects=False) + assert response.status_code == 303 and response.headers["location"] == "/setup" + nodectl.mark_wizard_done() + assert logged_in.get("/", follow_redirects=False).status_code == 200 + + +def test_dashboard_without_installer_state_has_no_wizard(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + nodectl.SETUP_STATE.unlink() + assert logged_in.get("/", follow_redirects=False).status_code == 200 + + +def test_install_step_shown_while_installing(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch, stage="installing") + assert logged_in.get("/setup", follow_redirects=False).headers["location"] == "/setup/install" + html = logged_in.get("/setup/install").text + assert "Настраиваем сервер" in html and "Устанавливаем…" in html + # other steps are locked until the installation finishes + assert logged_in.get("/setup/welcome", follow_redirects=False).headers["location"] == "/setup/install" + state = logged_in.get("/api/setup/state").json() + assert state["ok"] and state["state"]["installed"] is False + + +def test_wizard_steps_render_and_store_choices(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + monkeypatch.setattr(nodectl, "set_timezone", lambda tz: (True, tz)) + for step in setup.STEP_IDS[1:]: + assert logged_in.get(f"/setup/{step}").status_code == 200, step + assert logged_in.post("/setup/welcome", data={"timezone": "Europe/Moscow"}, follow_redirects=False).headers["location"] == "/setup/account" + assert logged_in.post("/setup/account", data={"password": "", "password2": ""}, follow_redirects=False).headers["location"] == "/setup/access" + assert logged_in.post("/setup/account", data={"password": "abcdef", "password2": "other"}).status_code == 400 + assert logged_in.post("/setup/access", data={"tls": "0", "port": "8090"}, follow_redirects=False).headers["location"] == "/setup/network" + assert logged_in.post("/setup/access", data={"tls": "1", "port": "22"}).status_code == 400 + monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {}) + assert logged_in.post("/setup/network", data={"mode": "nat"}, follow_redirects=False).headers["location"] == "/setup/storage" + assert logged_in.post("/setup/storage", data={"disk": "keep"}, follow_redirects=False).headers["location"] == "/setup/updates" + assert logged_in.post("/setup/updates", data={"auto_update": "0", "channel": "stable"}, follow_redirects=False).headers["location"] == "/setup/finish" + choices = nodectl.wizard_choices() + assert choices["timezone"] == "Europe/Moscow" and choices["port"] == "8090" and choices["tls"] == "0" and choices["network"] == "nat" and choices["auto_update"] == "0" + finish = logged_in.get("/setup/finish").text + assert "http://" in finish and "Europe/Moscow" in finish and "Вручную" in finish + + +def test_finish_marks_done_without_ctl(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + response = logged_in.post("/setup/finish", follow_redirects=False) + assert response.status_code == 303 and response.headers["location"].startswith("/?setup_message=") + assert nodectl.wizard_done() + + +def test_finish_applies_settings_through_ctl(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + calls = [] + monkeypatch.setattr(nodectl, "ctl_available", lambda: True) + monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""}) + nodectl.save_wizard_choices(tls="1", port="8088", auto_update="1", channel="stable") + response = logged_in.post("/setup/finish", follow_redirects=False) + assert response.status_code == 200 and "https://panel.test:8443" in response.text + assert calls and calls[0][0] == "set" and "VIRTUALITY_TLS=1" in calls[0] + + +def test_bridge_mode_starts_network_change(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + calls = [] + monkeypatch.setattr(nodectl, "enable_bridge", lambda iface, mode="dhcp": calls.append((iface, mode)) or (True, "ok")) + response = logged_in.post("/setup/network", data={"mode": "bridge"}) + assert response.status_code == 200 and "Перенастраиваем сеть" in response.text + assert calls == [("enp3s0", "dhcp")] + monkeypatch.setattr(nodectl, "confirm_bridge", lambda: (True, "Сеть подтверждена")) + assert logged_in.post("/setup/network/confirm").json()["ok"] is True + assert nodectl.wizard_choices()["bridge_pending"] is False + + +def test_restart_wizard_from_settings(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + nodectl.mark_wizard_done() + assert logged_in.post("/setup/restart", follow_redirects=False).headers["location"] == "/setup/welcome" + assert not nodectl.wizard_done() diff --git a/web/app.py b/web/app.py index c911878..28977a8 100644 --- a/web/app.py +++ b/web/app.py @@ -28,6 +28,7 @@ import auth import host_profile import network_core import presenters +from features import setup import update_core from network_core import NetworkError @@ -1341,6 +1342,8 @@ def dashboard(request: Request): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect + if setup.wizard_pending(): + return RedirectResponse(url="/setup", status_code=303) 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")} service_rows = [{"key": key, "name": presenters.SERVICE_LABELS.get(key, key), "state": state, **presenters.service_state(state)} for key, state in services.items()] return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "service_rows": service_rows, "services_ok": all(row["tone"] == "success" for row in service_rows if row["key"] in ("libvirtd", "web")), "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css, "host": presenters.host_stats(STORAGE_DIR), "greeting": presenters.greeting()}) @@ -2016,3 +2019,6 @@ def api_health(request: Request): if not get_current_user(request): 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()} + + +app.include_router(setup.router) diff --git a/web/features/__init__.py b/web/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/features/setup.py b/web/features/setup.py new file mode 100644 index 0000000..aa04be1 --- /dev/null +++ b/web/features/setup.py @@ -0,0 +1,287 @@ +"""First-run setup wizard (/setup) and the "applying settings" page.""" +import re +from typing import Any + +from fastapi import APIRouter, Form, Request +from fastapi.responses import JSONResponse, RedirectResponse + +import network_core +import nodectl +import presenters +from core import AUTH_USER, cmd_error, get_current_user, redirect_with_message, render, require_auth, run_cmd + +router = APIRouter() + +STEPS = [ + {"id": "install", "title": "Установка", "hint": "Компоненты сервера"}, + {"id": "welcome", "title": "Сервер", "hint": "Проверка и время"}, + {"id": "account", "title": "Администратор", "hint": "Вход в панель"}, + {"id": "access", "title": "Доступ", "hint": "Адрес и HTTPS"}, + {"id": "network", "title": "Сеть машин", "hint": "NAT или домашняя сеть"}, + {"id": "storage", "title": "Хранилище", "hint": "Где живут машины"}, + {"id": "updates", "title": "Обновления", "hint": "Как обновляться"}, + {"id": "finish", "title": "Готово", "hint": "Проверка и запуск"}, +] +STEP_IDS = [step["id"] for step in STEPS] + + +def wizard_pending() -> bool: + """The wizard is offered until finished on nodes set up by the installer or the ISO.""" + return not nodectl.wizard_done() and nodectl.SETUP_STATE.exists() + + +def step_index(step_id: str) -> int: + return STEP_IDS.index(step_id) if step_id in STEP_IDS else 0 + + +def next_step(step_id: str) -> str: + index = step_index(step_id) + return STEP_IDS[min(index + 1, len(STEP_IDS) - 1)] + + +def render_step(request: Request, step_id: str, error: str | None = None, message: str | None = None, status_code: int = 200): + choices = nodectl.wizard_choices() + settings = nodectl.node_settings() + state = nodectl.setup_state() + host = request.url.hostname or "127.0.0.1" + context: dict[str, Any] = { + "steps": STEPS, "step": step_id, "step_index": step_index(step_id), "choices": choices, "settings": settings, + "state": state, "error": error, "message": message, "host": host, "auth_user": AUTH_USER, + "page_title": "Мастер настройки", + } + if step_id == "welcome": + context["hardware"] = nodectl.hardware_summary() + context["timezones"] = nodectl.timezones() + context["profile_ready"] = context["hardware"]["kvm"] + elif step_id == "access": + context["tls"] = choices.get("tls", settings.get("VIRTUALITY_TLS", "0") == "1" or not settings.get("VIRTUALITY_TLS_SET")) + context["port"] = choices.get("port", settings.get("VIRTUALITY_WEB_PORT", "8088")) + elif step_id == "network": + context["net"] = nodectl.network_facts() + context["nat_ready"] = network_core.libvirt_network_info()["exists"] + elif step_id == "storage": + context["storage"] = nodectl.storage_facts() + elif step_id == "finish": + context["net"] = nodectl.network_facts() + context["hardware"] = nodectl.hardware_summary() + context["storage"] = nodectl.storage_facts() + context["timezone"] = choices.get("timezone") or nodectl.timezones()["current"] + tls = str(choices.get("tls", "1")) == "1" + port = str(choices.get("port", settings.get("VIRTUALITY_WEB_PORT", "8088"))) + context["final_url"] = f"https://{host}:{settings.get('VIRTUALITY_TLS_PORT', '8443')}" if tls else f"http://{host}:{port}" + context["restart_needed"] = (tls != (settings.get("VIRTUALITY_TLS") == "1")) or port != settings.get("VIRTUALITY_WEB_PORT", "8088") or str(choices.get("auto_update", settings.get("VIRTUALITY_AUTO_UPDATE", "1"))) != settings.get("VIRTUALITY_AUTO_UPDATE", "1") or str(choices.get("channel", settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable"))) != settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable") + return render(request, "setup.html", context, status_code=status_code) + + +@router.get("/setup") +def setup_index(request: Request): + auth = require_auth(request) + if auth: + return auth + choices = nodectl.wizard_choices() + state = nodectl.setup_state() + if not state["installed"]: + return RedirectResponse(url="/setup/install", status_code=303) + return RedirectResponse(url=f"/setup/{choices.get('step', 'welcome')}", status_code=303) + + +@router.get("/setup/{step_id}") +def setup_step(request: Request, step_id: str, message: str = "", error: str = ""): + auth = require_auth(request) + if auth: + return auth + if step_id not in STEP_IDS: + return RedirectResponse(url="/setup", status_code=303) + if step_id != "install" and not nodectl.setup_state()["installed"]: + return RedirectResponse(url="/setup/install", status_code=303) + if step_id != "install": + nodectl.save_wizard_choices(step=step_id) + return render_step(request, step_id, error=error or None, message=message or None) + + +@router.get("/api/setup/state") +def setup_state_api(request: Request): + if not get_current_user(request): + return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) + state = nodectl.setup_state() + return {"ok": True, "state": state, "net": {"revert_armed": nodectl.network_facts()["revert_armed"]} if request.query_params.get("net") else None} + + +# ---------------------------------------------------------------- step handlers +@router.post("/setup/welcome") +def setup_welcome(request: Request, timezone: str = Form("")): + auth = require_auth(request) + if auth: + return auth + if timezone: + ok, detail = nodectl.set_timezone(timezone) + if not ok: + return render_step(request, "welcome", error=detail, status_code=400) + nodectl.save_wizard_choices(timezone=timezone) + return RedirectResponse(url="/setup/account", status_code=303) + + +@router.post("/setup/account") +def setup_account(request: Request, password: str = Form(""), password2: str = Form("")): + auth = require_auth(request) + if auth: + return auth + if password or password2: + if password != password2: + return render_step(request, "account", error="Пароли не совпадают", status_code=400) + ok, detail = nodectl.change_password(AUTH_USER, password) + if not ok: + return render_step(request, "account", error=detail, status_code=400) + nodectl.save_wizard_choices(password_changed=True) + return RedirectResponse(url="/setup/access", status_code=303) + + +@router.post("/setup/access") +def setup_access(request: Request, tls: str = Form("1"), port: str = Form("8088")): + auth = require_auth(request) + if auth: + return auth + if not re.fullmatch(r"\d{2,5}", port) or not 1 <= int(port) <= 65535 or int(port) in (22, 9090): + return render_step(request, "access", error="Порт должен быть числом от 1 до 65535 (кроме 22 и 9090)", status_code=400) + nodectl.save_wizard_choices(tls="1" if tls == "1" else "0", port=port) + return RedirectResponse(url="/setup/network", status_code=303) + + +@router.post("/setup/network") +def setup_network(request: Request, mode: str = Form("nat")): + auth = require_auth(request) + if auth: + return auth + if mode not in ("nat", "bridge"): + return render_step(request, "network", error="Выберите режим сети", status_code=400) + if mode == "nat": + try: + network_core.create_nat_network() + except network_core.NetworkError as exc: + return render_step(request, "network", error=f"Не удалось создать сеть машин: {exc}", status_code=500) + nodectl.save_wizard_choices(network="nat") + return RedirectResponse(url="/setup/storage", status_code=303) + facts = nodectl.network_facts() + if facts["on_bridge"]: + nodectl.save_wizard_choices(network="bridge") + return RedirectResponse(url="/setup/storage", status_code=303) + if not facts["bridge_possible"]: + return render_step(request, "network", error="На этом сервере мост недоступен (Wi-Fi или не найден проводной интерфейс). Выберите NAT.", status_code=400) + ok, detail = nodectl.enable_bridge(facts["interface"], "dhcp") + if not ok: + return render_step(request, "network", error=detail, status_code=500) + nodectl.save_wizard_choices(network="bridge", bridge_pending=True) + return render(request, "setup_bridge_wait.html", {"page_title": "Перенастройка сети", "next_url": "/setup/storage", "confirm_url": "/setup/network/confirm", "revert_url": "/setup/network/revert", "back_url": "/setup/network"}) + + +@router.post("/setup/network/confirm") +def setup_network_confirm(request: Request): + if not get_current_user(request): + return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) + ok, detail = nodectl.confirm_bridge() + if ok: + nodectl.save_wizard_choices(bridge_pending=False, network="bridge") + profile = None + try: + import host_profile + + profile = host_profile.detect_host_profile() + host_profile.save_host_profile(profile) + except Exception: + pass + return JSONResponse({"ok": ok, "message": detail}) + + +@router.post("/setup/network/revert") +def setup_network_revert(request: Request): + auth = require_auth(request) + if auth: + return auth + ok, detail = nodectl.revert_bridge() + nodectl.save_wizard_choices(bridge_pending=False, network="nat") + return redirect_with_message("/setup/network", "message" if ok else "error", detail) + + +@router.post("/setup/storage") +def setup_storage(request: Request, disk: str = Form("")): + auth = require_auth(request) + if auth: + return auth + if disk and disk != "keep": + ok, detail = nodectl.use_disk_for_storage(disk) + if not ok: + return render_step(request, "storage", error=detail, status_code=400) + nodectl.save_wizard_choices(storage_disk=disk) + return RedirectResponse(url="/setup/updates", status_code=303) + + +@router.post("/setup/updates") +def setup_updates(request: Request, auto_update: str = Form("1"), channel: str = Form("stable")): + auth = require_auth(request) + if auth: + return auth + if channel not in ("stable", "main"): + channel = "stable" + nodectl.save_wizard_choices(auto_update="1" if auto_update == "1" else "0", channel=channel) + return RedirectResponse(url="/setup/finish", status_code=303) + + +@router.post("/setup/finish") +def setup_finish(request: Request): + auth = require_auth(request) + if auth: + return auth + choices = nodectl.wizard_choices() + settings = nodectl.node_settings() + host = request.url.hostname or "127.0.0.1" + changes = { + "VIRTUALITY_TLS": str(choices.get("tls", "1")), + "VIRTUALITY_WEB_PORT": str(choices.get("port", settings.get("VIRTUALITY_WEB_PORT", "8088"))), + "VIRTUALITY_AUTO_UPDATE": str(choices.get("auto_update", settings.get("VIRTUALITY_AUTO_UPDATE", "1"))), + "VIRTUALITY_UPDATE_CHANNEL": str(choices.get("channel", settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable"))), + } + current = {"VIRTUALITY_TLS": settings.get("VIRTUALITY_TLS", "0"), "VIRTUALITY_WEB_PORT": settings.get("VIRTUALITY_WEB_PORT", "8088"), "VIRTUALITY_AUTO_UPDATE": settings.get("VIRTUALITY_AUTO_UPDATE", "1"), "VIRTUALITY_UPDATE_CHANNEL": settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable")} + nodectl.mark_wizard_done() + nodectl.save_wizard_choices(step="finish", finished=True) + if changes != current and nodectl.ctl_available(): + ok, detail = nodectl.apply_settings(changes) + if not ok: + return render_step(request, "finish", error=detail, status_code=500) + final_url = f"https://{host}:{settings.get('VIRTUALITY_TLS_PORT', '8443')}" if changes["VIRTUALITY_TLS"] == "1" else f"http://{host}:{changes['VIRTUALITY_WEB_PORT']}" + return render(request, "setup_applying.html", {"page_title": "Применяем настройки", "final_url": final_url, "tls": changes["VIRTUALITY_TLS"] == "1"}) + return redirect_with_message("/", "setup_message", "Сервер настроен. Можно создавать машины.") + + +@router.get("/setup/skip") +def setup_skip(request: Request): + auth = require_auth(request) + if auth: + return auth + nodectl.mark_wizard_done() + return redirect_with_message("/", "setup_message", "Мастер пропущен. Его можно запустить позже в Настройках.") + + +@router.post("/setup/restart") +def setup_restart(request: Request): + """Re-run the wizard from Settings.""" + auth = require_auth(request) + if auth: + return auth + try: + nodectl.SETUP_DONE.unlink() + except OSError: + pass + nodectl.save_wizard_choices(step="welcome", finished=False) + if not nodectl.SETUP_STATE.exists(): + nodectl.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + nodectl.SETUP_STATE.write_text('{"stage": "done", "steps": []}\n') + return RedirectResponse(url="/setup/welcome", status_code=303) + + +def virtualization_hint() -> str: + result = run_cmd(["systemd-detect-virt"], timeout=5) + return cmd_error(result, "") if not result["ok"] else result["stdout"] + + +def format_bytes(value: int) -> str: + return presenters.format_bytes(value) diff --git a/web/nodectl.py b/web/nodectl.py new file mode 100644 index 0000000..5a64bd1 --- /dev/null +++ b/web/nodectl.py @@ -0,0 +1,313 @@ +"""Facts about the node (network, disks, time zone, first-boot progress) and +actions that change the node itself. Actions go through virtuality-ctl so the +panel and the command line behave identically.""" +import ipaddress +import json +import os +import re +import shutil +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any + +from core import CONFIG_DIR, STORAGE_DIR, cmd_error, run_cmd + +CTL = "/usr/local/bin/virtuality-ctl" +NODE_ENV = Path(os.environ.get("VIRTUALITY_NODE_ENV", str(CONFIG_DIR / "web.env"))) +SETUP_STATE = CONFIG_DIR / "setup.json" +SETUP_DONE = CONFIG_DIR / "setup_done" +WIZARD_FILE = CONFIG_DIR / "wizard.json" +FIRSTBOOT_LOG = Path("/var/log/virtuality/firstboot.log") +MIN_SPARE_DISK = 16 * 1024 ** 3 + +POPULAR_TIMEZONES = [ + "Europe/Moscow", "Europe/Kaliningrad", "Europe/Samara", "Asia/Yekaterinburg", "Asia/Omsk", "Asia/Novosibirsk", + "Asia/Krasnoyarsk", "Asia/Irkutsk", "Asia/Yakutsk", "Asia/Vladivostok", "Asia/Magadan", "Asia/Kamchatka", + "Europe/Minsk", "Europe/Kiev", "Asia/Almaty", "Asia/Tashkent", "Asia/Tbilisi", "Asia/Yerevan", "Asia/Baku", + "Europe/Berlin", "Europe/London", "Europe/Istanbul", "Asia/Dubai", "America/New_York", "UTC", +] + + +# ---------------------------------------------------------------- node settings +def node_settings() -> dict[str, str]: + data: dict[str, str] = {} + try: + for raw in NODE_ENV.read_text().splitlines(): + line = raw.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + data[key.strip()] = value.strip().strip('"').strip("'") + except OSError: + pass + data.setdefault("VIRTUALITY_WEB_PORT", "8088") + data.setdefault("VIRTUALITY_TLS_PORT", "8443") + data.setdefault("VIRTUALITY_TLS", "0") + data.setdefault("VIRTUALITY_AUTO_UPDATE", "1") + data.setdefault("VIRTUALITY_UPDATE_CHANNEL", "stable") + return data + + +def panel_url(host: str, settings: dict[str, str] | None = None) -> str: + settings = settings or node_settings() + if settings.get("VIRTUALITY_TLS") == "1": + return f"https://{host}:{settings['VIRTUALITY_TLS_PORT']}" + return f"http://{host}:{settings['VIRTUALITY_WEB_PORT']}" + + +def ctl_available() -> bool: + return Path(CTL).exists() + + +def ctl(*args: str, timeout: int = 60, stdin: str | None = None) -> dict[str, Any]: + if not ctl_available(): + return {"ok": False, "code": -1, "stdout": "", "stderr": "virtuality-ctl не установлен", "cmd": CTL} + try: + result = subprocess.run([CTL, *args], capture_output=True, text=True, timeout=timeout, check=False, input=stdin) + return {"ok": result.returncode == 0, "code": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "cmd": " ".join([CTL, *args])} + except Exception as exc: + return {"ok": False, "code": -1, "stdout": "", "stderr": str(exc), "cmd": " ".join([CTL, *args])} + + +def ctl_detached(*args: str, delay_seconds: int = 2) -> dict[str, Any]: + """Run a virtuality-ctl command after the HTTP response is sent (the command may restart the panel).""" + unit = f"virtuality-apply-{datetime.now().strftime('%Y%m%d%H%M%S%f')}" + return run_cmd(["systemd-run", "--unit", unit, "--collect", "--quiet", f"--on-active={delay_seconds}", CTL, *args], timeout=15) + + +# ---------------------------------------------------------------- first boot state +def setup_state() -> dict[str, Any]: + try: + data = json.loads(SETUP_STATE.read_text()) + except Exception: + data = {} + data.setdefault("stage", "done" if not SETUP_STATE.exists() else "installing") + data.setdefault("steps", []) + data.setdefault("message", "") + data["installed"] = data["stage"] == "done" + data["log_tail"] = "" + if FIRSTBOOT_LOG.exists(): + try: + data["log_tail"] = "\n".join(FIRSTBOOT_LOG.read_text(errors="replace").splitlines()[-40:]) + except OSError: + pass + return data + + +def wizard_done() -> bool: + return SETUP_DONE.exists() + + +def mark_wizard_done() -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + SETUP_DONE.write_text(datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "\n") + + +def wizard_choices() -> dict[str, Any]: + try: + return json.loads(WIZARD_FILE.read_text()) + except Exception: + return {} + + +def save_wizard_choices(**changes: Any) -> dict[str, Any]: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + data = wizard_choices() + data.update(changes) + WIZARD_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + return data + + +# ---------------------------------------------------------------- hardware & time +def hardware_summary() -> dict[str, Any]: + cpu_model = "—" + try: + for line in Path("/proc/cpuinfo").read_text().splitlines(): + key, _, value = line.partition(":") + if key.strip().lower() == "model name" and value.strip(): + cpu_model = value.strip() + break + except OSError: + pass + mem_total = 0 + try: + for line in Path("/proc/meminfo").read_text().splitlines(): + if line.startswith("MemTotal:"): + mem_total = int(line.split()[1]) * 1024 + except (OSError, ValueError): + pass + try: + usage = shutil.disk_usage(str(STORAGE_DIR) if STORAGE_DIR.exists() else "/") + disk_total, disk_free = usage.total, usage.free + except OSError: + disk_total = disk_free = 0 + return { + "hostname": run_cmd(["hostname"])["stdout"] or "—", + "cpu_model": cpu_model, + "cpu_count": os.cpu_count() or 1, + "mem_total": mem_total, + "disk_total": disk_total, + "disk_free": disk_free, + "kvm": Path("/dev/kvm").exists(), + } + + +def timezones() -> dict[str, Any]: + current = run_cmd(["timedatectl", "show", "-p", "Timezone", "--value"])["stdout"] or "UTC" + listed = run_cmd(["timedatectl", "list-timezones"], timeout=10) + names = [line.strip() for line in listed["stdout"].splitlines() if "/" in line or line.strip() == "UTC"] if listed["ok"] else [] + if not names: + names = list(POPULAR_TIMEZONES) + popular = [tz for tz in POPULAR_TIMEZONES if tz in names] + return {"current": current, "popular": popular, "all": names} + + +def set_timezone(tz: str) -> tuple[bool, str]: + if not re.fullmatch(r"[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+){0,2}", tz or ""): + return False, "Некорректный часовой пояс" + result = ctl("timezone", tz, timeout=30) + return result["ok"], cmd_error(result, "Не удалось сменить часовой пояс") if not result["ok"] else tz + + +# ---------------------------------------------------------------- network facts +def _ip_json(*args: str) -> list[dict[str, Any]]: + result = run_cmd(["ip", "-j", *args], timeout=8) + if not result["ok"]: + return [] + try: + data = json.loads(result["stdout"] or "[]") + return data if isinstance(data, list) else [] + except json.JSONDecodeError: + return [] + + +def network_facts() -> dict[str, Any]: + routes = _ip_json("route", "show", "default") + default = routes[0] if routes else {} + iface = default.get("dev", "") + gateway = default.get("gateway", "") + address = "" + prefix = 0 + mac = "" + for entry in _ip_json("addr", "show", iface) if iface else []: + mac = entry.get("address", "") or mac + for info in entry.get("addr_info", []): + if info.get("family") == "inet" and not address: + address = info.get("local", "") + prefix = int(info.get("prefixlen", 0) or 0) + wireless = bool(iface) and Path(f"/sys/class/net/{iface}/wireless").exists() + bridge_present = Path("/sys/class/net/br0").exists() + on_bridge = iface == "br0" + private = False + try: + private = ipaddress.ip_address(address).is_private if address else False + except ValueError: + private = False + is_vps = bool(address) and not private + virt = run_cmd(["systemd-detect-virt"], timeout=5)["stdout"] + if virt and virt != "none": + is_vps = True + if on_bridge: + recommended = "bridge" + elif wireless or is_vps or not iface: + recommended = "nat" + else: + recommended = "bridge" + revert_armed = run_cmd(["systemctl", "is-active", "virtuality-netplan-revert.timer"], timeout=5)["stdout"] == "active" + return { + "interface": iface, "gateway": gateway, "address": address, "prefix": prefix, "mac": mac, + "wireless": wireless, "private": private, "is_vps": is_vps, "virt": virt or "none", + "bridge_present": bridge_present, "on_bridge": on_bridge, "recommended": recommended, + "revert_armed": revert_armed, + "bridge_possible": bool(iface) and not wireless and not on_bridge, + } + + +def enable_bridge(iface: str, mode: str = "dhcp") -> tuple[bool, str]: + if not re.fullmatch(r"[a-zA-Z0-9_.:-]{1,15}", iface or "") or mode not in ("dhcp", "static"): + return False, "Некорректный интерфейс" + result = ctl_detached("bridge", iface, mode, delay_seconds=1) + return result["ok"], cmd_error(result, "Не удалось запустить настройку сети") if not result["ok"] else "Сеть перенастраивается" + + +def confirm_bridge() -> tuple[bool, str]: + result = ctl("bridge-confirm", timeout=30) + return result["ok"], cmd_error(result, "Не удалось подтвердить сеть") if not result["ok"] else "Сеть подтверждена" + + +def revert_bridge() -> tuple[bool, str]: + result = ctl_detached("bridge-revert", delay_seconds=1) + return result["ok"], cmd_error(result, "Не удалось вернуть прежнюю сеть") if not result["ok"] else "Возвращаем прежнюю сеть" + + +# ---------------------------------------------------------------- storage facts +def _lsblk() -> list[dict[str, Any]]: + result = run_cmd(["lsblk", "-J", "-b", "-o", "NAME,PATH,TYPE,SIZE,FSTYPE,MOUNTPOINT,MODEL,TRAN,RM,PKNAME"], timeout=10) + if not result["ok"]: + return [] + try: + return json.loads(result["stdout"] or "{}").get("blockdevices", []) + except json.JSONDecodeError: + return [] + + +def spare_disks() -> list[dict[str, Any]]: + """Whole disks with no partitions or file system: safe candidates for VM storage.""" + disks = [] + for dev in _lsblk(): + if dev.get("type") != "disk" or dev.get("children") or dev.get("fstype") or dev.get("mountpoint"): + continue + if str(dev.get("rm")) in ("1", "True", "true") or dev.get("tran") == "usb": + continue + size = int(dev.get("size") or 0) + if size < MIN_SPARE_DISK: + continue + disks.append({"path": dev.get("path") or f"/dev/{dev.get('name')}", "name": dev.get("name"), "size": size, "model": (dev.get("model") or "").strip() or "Диск", "tran": dev.get("tran") or ""}) + return disks + + +def storage_facts() -> dict[str, Any]: + try: + usage = shutil.disk_usage(str(STORAGE_DIR) if STORAGE_DIR.exists() else "/") + total, free = usage.total, usage.free + except OSError: + total = free = 0 + mount = run_cmd(["findmnt", "-n", "-o", "SOURCE,TARGET", "--target", str(STORAGE_DIR)], timeout=5)["stdout"] + source, _, target = mount.partition(" ") + return {"path": str(STORAGE_DIR), "total": total, "free": free, "source": source, "mount_target": target.strip() or "/", "dedicated": target.strip() == str(STORAGE_DIR), "spare": spare_disks()} + + +def use_disk_for_storage(path: str) -> tuple[bool, str]: + if not any(d["path"] == path for d in spare_disks()): + return False, "Этот диск нельзя использовать: он занят или на нём уже есть данные" + result = ctl("storage-use", path, timeout=600) + return result["ok"], cmd_error(result, "Не удалось подготовить диск") if not result["ok"] else "Диск подключён для хранения машин" + + +# ---------------------------------------------------------------- account & settings +def change_password(user: str, new_password: str) -> tuple[bool, str]: + if len(new_password) < 6: + return False, "Пароль должен быть не короче 6 символов" + result = ctl("passwd", user, stdin=new_password + "\n", timeout=30) + return result["ok"], cmd_error(result, "Не удалось сменить пароль") if not result["ok"] else "Пароль изменён" + + +def apply_settings(changes: dict[str, str]) -> tuple[bool, str]: + allowed = {"VIRTUALITY_WEB_PORT", "VIRTUALITY_TLS", "VIRTUALITY_TLS_PORT", "VIRTUALITY_AUTO_UPDATE", "VIRTUALITY_UPDATE_CHANNEL"} + pairs = [f"{key}={value}" for key, value in changes.items() if key in allowed and re.fullmatch(r"[A-Za-z0-9._-]+", str(value))] + if not pairs: + return False, "Нечего применять" + result = ctl_detached("set", *pairs) + return result["ok"], cmd_error(result, "Не удалось применить настройки") if not result["ok"] else "Настройки применяются" + + +def power(action: str) -> tuple[bool, str]: + if action not in ("reboot", "poweroff"): + return False, "Неизвестное действие" + result = ctl_detached("power", action, delay_seconds=3) + return result["ok"], cmd_error(result, "Не удалось выполнить") if not result["ok"] else ("Сервер перезагружается" if action == "reboot" else "Сервер выключается") + + +def backup_config() -> tuple[bool, str]: + result = ctl("backup-config", timeout=120) + return result["ok"], cmd_error(result, "Не удалось создать архив настроек") if not result["ok"] else result["stdout"] diff --git a/web/static/app.css b/web/static/app.css index 33b98fa..c004676 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -584,3 +584,31 @@ details.advanced > .advanced-body { padding: 4px 16px 16px; } .hide-sm { display: none !important; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } + +/* ---------------------------------------------------------- setup wizard */ +.setup-page { background: var(--bg); } +.setup-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 32px; border-bottom: 1px solid var(--border); background: var(--surface); } +.setup-top .logo { padding: 0; } +.setup-main { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 32px; width: 100%; max-width: 1180px; margin: 0 auto; padding: 36px 32px 60px; } +.setup-rail { display: grid; gap: 4px; align-content: start; position: sticky; top: 24px; } +.setup-step { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; color: var(--text-3); } +.setup-step:hover { text-decoration: none; } +.setup-step[href]:hover { background: var(--surface-3); color: var(--text); } +.setup-step strong { display: block; font-weight: 600; color: inherit; } +.setup-step small { display: block; font-size: 12px; } +.setup-step-num { display: grid; place-items: center; width: 28px; height: 28px; flex: none; border-radius: 50%; border: 1.5px solid var(--border-strong); font-size: 12.5px; font-weight: 700; } +.setup-step.active { color: var(--text); } +.setup-step.active .setup-step-num { border-color: var(--brand); background: var(--brand); color: #fff; box-shadow: 0 0 0 4px var(--brand-soft); } +.setup-step.done { color: var(--text-2); } +.setup-step.done .setup-step-num { border-color: var(--success); background: var(--success-soft); color: var(--success); } +.setup-content { min-width: 0; } +.setup-card { box-shadow: var(--shadow-md); } +.setup-card .card-header h2 { font-size: 20px; } +.setup-card .card-header p { font-size: 14px; color: var(--text-2); margin-top: 4px; } +@media (max-width: 900px) { + .setup-top { padding: 12px 16px; } + .setup-main { grid-template-columns: 1fr; padding: 20px 16px 48px; gap: 16px; } + .setup-rail { position: static; display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; } + .setup-step { flex: none; padding: 6px 10px; } + .setup-step small { display: none; } +} diff --git a/web/static/panel.js b/web/static/panel.js index 5581d03..e67ae91 100644 --- a/web/static/panel.js +++ b/web/static/panel.js @@ -475,3 +475,46 @@ flashToast(); }); })(); + +/* Setup wizard: live progress of the first-boot installation. */ +(() => { + 'use strict'; + document.addEventListener('DOMContentLoaded', () => { + const root = document.querySelector('[data-setup-install]'); + if (!root) return; + const list = root.querySelector('[data-setup-steps]'); + const log = root.querySelector('[data-setup-log]'); + const status = document.querySelector('[data-setup-status]'); + const next = document.querySelector('[data-setup-next]'); + const escapeHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); + const icon = (name) => ``; + let announced = root.dataset.installed === '1'; + async function poll() { + try { + const response = await fetch('/api/setup/state', { cache: 'no-store' }); + if (!response.ok) return; + const { state } = await response.json(); + if (state.steps && state.steps.length) { + list.innerHTML = state.steps.map((item) => { + const tone = item.status === 'done' ? 'success' : item.status === 'error' ? 'danger' : item.status === 'running' ? 'warning' : ''; + const mark = item.status === 'done' ? icon('circle-check') : item.status === 'error' ? icon('circle-x') : item.status === 'running' ? '' : icon('circle-dot'); + const detail = item.status === 'running' && state.message ? `${escapeHtml(state.message)}` : ''; + return `
${mark}
${escapeHtml(item.title)}${detail}
`; + }).join(''); + } + if (log && state.log_tail) { const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 40; log.textContent = state.log_tail; if (atBottom) log.scrollTop = log.scrollHeight; } + if (state.installed) { + next.classList.remove('disabled'); + status.textContent = 'Всё установлено'; + if (!announced) { announced = true; window.vToast && window.vToast('Компоненты установлены'); setTimeout(() => { location.href = root.dataset.next; }, 2000); } + } else if (state.stage === 'waiting-network') { + status.textContent = 'Ждём подключение к интернету…'; + } else if (state.stage === 'error') { + status.textContent = 'Ошибка установки — повторим автоматически'; + } + } catch (_) {} + } + poll(); + setInterval(poll, 3000); + }); +})(); diff --git a/web/templates/setup.html b/web/templates/setup.html new file mode 100644 index 0000000..ce2b8f1 --- /dev/null +++ b/web/templates/setup.html @@ -0,0 +1,241 @@ +{% extends "setup_base.html" %} +{% from "_ui.html" import icon, badge, alert, meter %} + +{% block step %} +{# ============================================================ install #} +{% if step == 'install' %} +

Настраиваем сервер

Устанавливаются компоненты виртуализации. Обычно это занимает 5–15 минут — можно оставить страницу открытой, дальше всё произойдёт само.

+
+ {% if state.stage == 'error' %} + {{ alert('danger', state.message or 'Установка остановилась с ошибкой. Она будет повторена автоматически через минуту.', 'Что-то пошло не так') }} + {% elif state.stage == 'waiting-network' %} + {{ alert('warning', 'Подключите сервер к интернету кабелем или настройте Wi-Fi — установка продолжится сама, ничего нажимать не нужно.', 'Нет доступа в интернет') }} + {% endif %} +
+ {% for item in state.steps %} +
+ {% if item.status == 'done' %}{{ icon('circle-check') }}{% elif item.status == 'error' %}{{ icon('circle-x') }}{% elif item.status == 'running' %}{% else %}{{ icon('circle-dot') }}{% endif %} +
{{ item.title }}{% if item.status == 'running' and state.message %}{{ state.message }}{% endif %}
+
+ {% else %} +
Подготовка…
+ {% endfor %} +
+
+ {{ icon('terminal', 'icon-sm') }}Что происходит{{ icon('chevron-down', 'icon-sm') }} +
{{ state.log_tail or 'Журнал пока пуст.' }}
+
+
+ + +{# ============================================================ welcome #} +{% elif step == 'welcome' %} +

Добро пожаловать в Virtuality

Проверим сервер и зададим время. Дальше — пять коротких шагов.

+
+
+
+
Сервер{{ hardware.hostname }}
+
Процессор{{ hardware.cpu_count }} {{ plural(hardware.cpu_count, 'ядро', 'ядра', 'ядер') }}
+
Память{{ hardware.mem_total|format_bytes }}
+
Свободно для машин{{ hardware.disk_free|format_bytes }}
+
+ {% if hardware.kvm %} + {{ alert('success', 'Аппаратное ускорение включено — машины будут работать быстро.', 'Виртуализация готова') }} + {% else %} + {{ alert('warning', 'Машины будут работать очень медленно. Перезагрузите сервер, войдите в BIOS/UEFI (обычно клавиша Del или F2) и включите Intel VT-x / AMD-V (может называться SVM Mode или Virtualization Technology). Продолжить можно и без этого.', 'Аппаратное ускорение выключено') }} + {% endif %} +
+ + + Сейчас на сервере: {{ timezones.current }}. От пояса зависят время в журналах и час ночного обновления. +
+
+ +
+ +{# ============================================================ account #} +{% elif step == 'account' %} +

Администратор

В панель входят по имени и паролю пользователя сервера. Пароль нигде не сохраняется.

+
+
+
{{ icon('user') }}
{{ auth_user }}Пользователь для входа в панель и по SSH
{{ badge('Администратор', 'brand', False) }}
+
+ {{ icon('lock', 'icon-sm') }}Сменить пароль{{ icon('chevron-down', 'icon-sm') }} +
+
+
+
+
+ Не короче 6 символов. Этот же пароль будет действовать для SSH и консоли сервера. +
+
+

{{ icon('info', 'icon-sm') }} Если пароль забудется, его можно сбросить с монитора и клавиатуры сервера: sudo virtuality-ctl passwd.

+
+ +
+ +{# ============================================================ access #} +{% elif step == 'access' %} +

Доступ к панели

Как вы будете открывать Virtuality в браузере.

+
+
+
+ + +
+
+ {{ icon('wrench', 'icon-sm') }}Порт панели{{ icon('chevron-down', 'icon-sm') }} +
+
Стандартный — 8088. Меняйте, только если он занят.
+

С HTTPS панель отвечает на порту 8443, а по обычному адресу автоматически перенаправляет на защищённый.

+
+
+

{{ icon('info', 'icon-sm') }} Настройки применятся на последнем шаге, после чего панель перезапустится и откроется по новому адресу.

+
+ +
+ +{# ============================================================ network #} +{% elif step == 'network' %} +

Сеть машин

Как виртуальные машины будут видны в сети. Это можно изменить позже в разделе «Сеть и доступ».

+
+
+ {% if net.on_bridge %} + {{ alert('success', 'Машины будут получать адреса от вашего роутера, как обычные устройства.', 'Сервер уже подключён через мост br0') }} + {% elif net.wireless %} + {{ alert('info', 'Сервер подключён по Wi-Fi, поэтому машины не могут появиться в домашней сети напрямую — они выйдут в интернет через сервер (NAT). Подключите кабель, если нужен режим домашней сети.') }} + {% elif net.is_vps %} + {{ alert('info', 'Похоже, это VPS или облачный сервер: у него публичный адрес. Машины будут скрыты за сервером, а нужные сервисы вы откроете наружу в разделе «Сеть и доступ».') }} + {% endif %} +
+ + +
+ {% if net.bridge_possible %} +
{{ icon('info') }}
Как включается домашняя сетьИнтерфейс {{ net.interface }} ({{ net.address or 'адрес по DHCP' }}) объединится в мост br0 с тем же MAC-адресом, поэтому адрес сервера не изменится. Если панель не ответит в течение 2 минут, сервер сам вернёт прежнюю сеть.
+ {% endif %} +
+ +
+ +{# ============================================================ storage #} +{% elif step == 'storage' %} +

Хранилище

Где будут лежать диски машин, образы и резервные копии.

+
+
+
+
Папка{{ storage.path }}
+
Свободно{{ storage.free|format_bytes }} из {{ storage.total|format_bytes }}
+
Диск{{ storage.source or 'системный' }}{% if storage.dedicated %} (отдельный){% endif %}
+
+ {% if storage.spare %} +
{{ icon('sparkles') }}
Найден свободный дискЕго можно отдать под машины целиком: системный диск останется для Ubuntu, а место для машин будет отдельным.
+
+ + {% for disk in storage.spare %} + + {% endfor %} +
+ {% else %} +

Свободных дисков не найдено — машины будут храниться на системном диске. Если позже добавите диск, его можно подключить в Настройках.

+ {% endif %} +
+ +
+ +{# ============================================================ updates #} +{% elif step == 'updates' %} +

Обновления

Virtuality умеет обновляться сама. Машины во время обновления продолжают работать.

+
+
+
+ + +
+
+ {{ icon('layers', 'icon-sm') }}Канал обновлений{{ icon('chevron-down', 'icon-sm') }} +
+ +
+
+
+ +
+ +{# ============================================================ finish #} +{% elif step == 'finish' %} +

Всё готово

Проверьте и нажмите «Завершить». {% if restart_needed %}Панель перезапустится и откроется по новому адресу.{% endif %}

+
+
+
+
Адрес панели{{ final_url }}
+
Администратор{{ auth_user }}{% if choices.get('password_changed') %} · пароль обновлён{% endif %}
+
Защищённое соединение{{ 'Включено (HTTPS)' if choices.get('tls', '1')|string == '1' else 'Выключено' }}
+
Сеть машин{{ 'Домашняя сеть (мост br0)' if choices.get('network') == 'bridge' or net.on_bridge else 'Отдельная сеть (NAT)' }}
+
Хранилище{{ storage.free|format_bytes }} свободно{% if storage.dedicated %} · отдельный диск{% endif %}
+
Обновления{{ 'Автоматически' if choices.get('auto_update', settings.VIRTUALITY_AUTO_UPDATE)|string == '1' else 'Вручную' }}{% if choices.get('channel', settings.VIRTUALITY_UPDATE_CHANNEL) == 'main' %} · ранний доступ{% endif %}
+
Часовой пояс{{ timezone }}
+
Ускорение{{ 'KVM включён' if hardware.kvm else 'Выключено — машины будут медленными' }}
+
+ {% if choices.get('tls', '1')|string == '1' %} +
{{ icon('shield-check') }}
При первом открытии по HTTPS браузер предупредит о сертификатеНажмите «Дополнительно» → «Перейти на сайт» (в Chrome) или «Принять риск» (в Firefox). Это происходит один раз.
+ {% endif %} +

Что дальше: загрузите установочный образ или выберите готовую систему из каталога и создайте первую машину. Все настройки можно изменить в разделе «Настройки».

+
+ +
+{% endif %} +{% endblock %} diff --git a/web/templates/setup_applying.html b/web/templates/setup_applying.html new file mode 100644 index 0000000..bd327d8 --- /dev/null +++ b/web/templates/setup_applying.html @@ -0,0 +1,30 @@ +{% extends "setup_base.html" %} +{% from "_ui.html" import icon, alert %} +{% block rail %}{% endblock %} +{% block step %} +

Применяем настройки

Панель перезапускается — это занимает несколько секунд.

+
+
Перезапускаем панель…
+
+
Новый адрес панели{{ final_url }}
+ {% if tls %} +
{{ icon('shield-check') }}
Браузер предупредит о сертификатеСертификат создан этим сервером, поэтому браузер его не знает. Нажмите «Дополнительно» → «Перейти на сайт» (Chrome) или «Принять риск и продолжить» (Firefox). Дальше предупреждение не появится.
+ {% endif %} +

Сохраните новый адрес в закладки. Если страница не открылась сама через 15 секунд, перейдите по ссылке выше.

+
+ +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/web/templates/setup_base.html b/web/templates/setup_base.html new file mode 100644 index 0000000..45b4c19 --- /dev/null +++ b/web/templates/setup_base.html @@ -0,0 +1,60 @@ +{% from "_ui.html" import icon, logo, alert %} + + + + + + + {{ page_title|default('Мастер настройки') }} — Virtuality + + + + + +
+ +
+ {{ user }} + +
+
+
+ {% block rail %} + {% if steps is defined %} + + {% endif %} + {% endblock %} +
+ {% if error %}
{{ alert('danger', error) }}
{% endif %} + {% if message %}
{{ alert('success', message) }}
{% endif %} +
+ {% block step %}{% endblock %} +
+
+
+ +
+
{{ icon('triangle-alert', 'icon-lg') }}

Подтвердите действие

+
+
+
+
+ + {% block scripts %}{% endblock %} + + diff --git a/web/templates/setup_bridge_wait.html b/web/templates/setup_bridge_wait.html new file mode 100644 index 0000000..4dc51ea --- /dev/null +++ b/web/templates/setup_bridge_wait.html @@ -0,0 +1,48 @@ +{% extends "setup_base.html" %} +{% from "_ui.html" import icon, alert %} +{% block rail %}{% endblock %} +{% block step %} +

Перенастраиваем сеть

Сервер объединяет сетевой порт в мост br0. Это займёт около 20 секунд, страница не перезагружается.

+
+
Ждём, пока сервер снова ответит…
+
+
{{ icon('info') }}
Если страница так и не оживётНичего страшного: через 2 минуты сервер сам вернёт прежнюю сеть. Обновите страницу и выберите «Отдельная сеть (NAT)» или попробуйте ещё раз.
+
+ +{% endblock %} +{% block scripts %} + +{% endblock %} From 9cf8c3e4b7a9c98ff077ff2fb8868b1811c9d98f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:37:12 +0000 Subject: [PATCH 07/32] =?UTF-8?q?=D0=A1=D0=BB=D1=83=D1=87=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8=20.claude/worktrees=20=D0=B1?= =?UTF-8?q?=D1=8B=D0=BB=D0=B8=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каталог .claude/ добавлен в .gitignore. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- .claude/worktrees/wf_18d9ca1d-93c-1 | 1 - .claude/worktrees/wf_18d9ca1d-93c-2 | 1 - .claude/worktrees/wf_18d9ca1d-93c-3 | 1 - .claude/worktrees/wf_18d9ca1d-93c-4 | 1 - .gitignore | 3 +++ 5 files changed, 3 insertions(+), 4 deletions(-) delete mode 160000 .claude/worktrees/wf_18d9ca1d-93c-1 delete mode 160000 .claude/worktrees/wf_18d9ca1d-93c-2 delete mode 160000 .claude/worktrees/wf_18d9ca1d-93c-3 delete mode 160000 .claude/worktrees/wf_18d9ca1d-93c-4 diff --git a/.claude/worktrees/wf_18d9ca1d-93c-1 b/.claude/worktrees/wf_18d9ca1d-93c-1 deleted file mode 160000 index 4198c18..0000000 --- a/.claude/worktrees/wf_18d9ca1d-93c-1 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4198c18ff4aabeac0bb370fce136469f3f8fe6b8 diff --git a/.claude/worktrees/wf_18d9ca1d-93c-2 b/.claude/worktrees/wf_18d9ca1d-93c-2 deleted file mode 160000 index 2450278..0000000 --- a/.claude/worktrees/wf_18d9ca1d-93c-2 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2450278d936bf3b6cd4a045d7eb69635773bf862 diff --git a/.claude/worktrees/wf_18d9ca1d-93c-3 b/.claude/worktrees/wf_18d9ca1d-93c-3 deleted file mode 160000 index 873b32c..0000000 --- a/.claude/worktrees/wf_18d9ca1d-93c-3 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 873b32c517d5c3af6e1c1b94147ee5b1840fd55a diff --git a/.claude/worktrees/wf_18d9ca1d-93c-4 b/.claude/worktrees/wf_18d9ca1d-93c-4 deleted file mode 160000 index 7bf7a0f..0000000 --- a/.claude/worktrees/wf_18d9ca1d-93c-4 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7bf7a0f6b7bc72a3bcec589fc036cbffeae9ef57 diff --git a/.gitignore b/.gitignore index e9b9b4b..e63cbc3 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ composer.lock # Offline Python wheels bundled into system images wheels/ + +# рабочие копии агентов и локальные настройки Claude Code +.claude/ From 4fd04be5888532bcd9211f4db3fa2cfd53850da8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:49:43 +0000 Subject: [PATCH 08/32] =?UTF-8?q?=D0=A1=D0=BA=D0=B0=D1=87=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=BE=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B5=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=B7=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D1=8B=D1=85=20?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=20=D0=B1=D1=8B=D0=BB=D0=BE?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web/features/downloads.py: фоновая операция типа «download» — urllib по 1 МиБ, таймаут 30 с, User-Agent Virtuality/<версия>, запись в .<имя>.part и переименование в конце; в сообщении скорость и остаток («245 МБ из 3,1 ГБ · 12 МБ/с · осталось 4 мин»); отмена между кусками (POST /api/operations/{id}/cancel и /downloads/{id}/cancel); проверка схемы, имени файла, занятости имени и свободного места (Content-Length + 512 МиБ); для каталога — проверка контрольной суммы (SHA256SUMS/SHA512SUMS/одиночный хеш), при несовпадении файл удаляется; после ISO — virsh pool-refresh virtuality-iso, после .img.xz — распаковка и запуск конвертации в qcow2 как при загрузке с компьютера. - /iso и /disk-images: карточка «Скачать по ссылке», на /disk-images — «Каталог готовых систем» для процессора сервера с пометками «Рекомендуем», «Загружен», «Скачивается»; идущие скачивания вверху страниц с живым прогрессом (опрос раз в 2 с) и «Отменить». - Тесты: парсер контрольных сумм, проверка ссылок и имён, загрузка с локального http-сервера, редирект, отмена, несовпадение суммы, нехватка места, .img.xz, маршруты. - В ветку скопированы web/core.py, web/catalog.py, presenters.format_bytes и tests/fixtures из общей рабочей копии (без изменений), conftest подменяет run_cmd и каталоги и в core. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- tests/conftest.py | 22 +- tests/dev_server.py | 15 +- tests/fixtures/SHA256SUMS | 3 + tests/fixtures/SHA512SUMS | 3 + tests/fixtures/domblkinfo.txt | 3 + tests/fixtures/domblklist-details.txt | 4 + tests/fixtures/domstats.txt | 16 + tests/fixtures/dumpxml-web01.xml | 37 ++ tests/fixtures/net-dumpxml.xml | 17 + ...23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 | 1 + tests/fixtures/qemu-img-info.json | 10 + tests/fixtures/snapshot-info.txt | 9 + tests/fixtures/snapshot-list.txt | 4 + tests/test_downloads.py | 381 ++++++++++++ web/app.py | 4 + web/catalog.py | 82 +++ web/core.py | 321 ++++++++++ web/features/__init__.py | 0 web/features/downloads.py | 578 ++++++++++++++++++ web/presenters.py | 9 + web/static/app.css | 9 + web/templates/_downloads_active.html | 71 +++ web/templates/_downloads_catalog.html | 36 ++ web/templates/_downloads_flash.html | 4 + web/templates/_downloads_url.html | 17 + web/templates/disk_images.html | 9 +- web/templates/iso.html | 4 + 27 files changed, 1662 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/SHA256SUMS create mode 100644 tests/fixtures/SHA512SUMS create mode 100644 tests/fixtures/domblkinfo.txt create mode 100644 tests/fixtures/domblklist-details.txt create mode 100644 tests/fixtures/domstats.txt create mode 100644 tests/fixtures/dumpxml-web01.xml create mode 100644 tests/fixtures/net-dumpxml.xml create mode 100644 tests/fixtures/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 create mode 100644 tests/fixtures/qemu-img-info.json create mode 100644 tests/fixtures/snapshot-info.txt create mode 100644 tests/fixtures/snapshot-list.txt create mode 100644 tests/test_downloads.py create mode 100644 web/catalog.py create mode 100644 web/core.py create mode 100644 web/features/__init__.py create mode 100644 web/features/downloads.py create mode 100644 web/templates/_downloads_active.html create mode 100644 web/templates/_downloads_catalog.html create mode 100644 web/templates/_downloads_flash.html create mode 100644 web/templates/_downloads_url.html diff --git a/tests/conftest.py b/tests/conftest.py index 0491550..0a6b806 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,6 +57,8 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs): return fake_result(":0") if cmd[:2] == ["virsh", "pool-list"]: return fake_result(" Name State Autostart\n---\n virtuality-images active yes") + if cmd[:2] == ["virsh", "pool-refresh"]: + return fake_result(f"Pool {cmd[-1]} refreshed") if cmd[:2] == ["virsh", "net-list"]: return fake_result(" Name State Autostart Persistent\n---\n virtuality-nat active yes yes") if cmd[:2] == ["systemctl", "is-active"]: @@ -72,9 +74,15 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs): return fake_result() +def feature_modules(): + """Загруженные модули web/features/* — им тоже подменяем run_cmd.""" + return [module for name, module in sys.modules.items() if name.startswith("features.") and hasattr(module, "run_cmd")] + + @pytest.fixture() def data_dirs(tmp_path, monkeypatch): import app + import core import host_profile import network_core import update_core @@ -84,6 +92,7 @@ def data_dirs(tmp_path, monkeypatch): "images": tmp_path / "images", "disk_images": tmp_path / "disk-images", "operations": tmp_path / "operations", + "backups": tmp_path / "backups", "network": tmp_path / "network", "config": tmp_path / "config", "nft": tmp_path / "nft", @@ -91,10 +100,13 @@ def data_dirs(tmp_path, monkeypatch): } for path in dirs.values(): path.mkdir() - monkeypatch.setattr(app, "ISO_DIR", dirs["iso"]) - monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"]) - monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"]) - monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"]) + for module in (app, core): + monkeypatch.setattr(module, "ISO_DIR", dirs["iso"]) + monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"]) + 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, "NETWORK_DIR", dirs["network"]) monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"]) @@ -108,7 +120,7 @@ def data_dirs(tmp_path, monkeypatch): monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json") monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log") monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT) - for module in (app, network_core, host_profile): + for module in (app, core, network_core, host_profile, *feature_modules()): monkeypatch.setattr(module, "run_cmd", fake_run_cmd) return dirs diff --git a/tests/dev_server.py b/tests/dev_server.py index 9df2e62..6de1733 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -13,6 +13,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import conftest # noqa: E402 (sets sys.path and env) import app # noqa: E402 +import core # noqa: E402 import host_profile # noqa: E402 import network_core # noqa: E402 import update_core # noqa: E402 @@ -23,6 +24,7 @@ def seed(root: Path) -> None: for path in dirs.values(): 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"] + core.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR, core.CONFIG_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"], dirs["config"] 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.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml" @@ -32,7 +34,7 @@ def seed(root: Path) -> None: 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.SOURCE_DIR = Path(__file__).resolve().parents[1] - for module in (app, network_core, host_profile): + for module in (app, core, network_core, host_profile, *conftest.feature_modules()): module.run_cmd = conftest.fake_run_cmd app.auth.verify_password = lambda user, password: True @@ -50,6 +52,17 @@ def seed(root: Path) -> None: 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 ...") + # Идущие скачивания — видны на /iso и /disk-images (в каталоге Ubuntu 26.04 помечена «Скачивается»). + for kind, url, catalog_id, title, progress, message, total, done in ( + ("iso", "https://software-download.microsoft.com/download/pr/windows-server-2025-eval-x64.iso", None, "Скачивание windows-server-2025-eval-x64.iso", 37, "1,9 ГБ из 5,2 ГБ · 24 МБ/с · осталось 2 мин", 5_583_457_280, 2_065_879_193), + ("disk", "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img", "ubuntu-26.04", "Скачивание Ubuntu Server 26.04 LTS", 62, "412 МБ из 663 МБ · 31 МБ/с · осталось 8 с", 695_205_888, 431_027_650), + ): + filename = url.rsplit("/", 1)[-1] + target = (dirs["iso"] if kind == "iso" else dirs["disk-images"]) / filename + op = core.new_operation("download", title, download_kind=kind, url=url, filename=filename, target_path=str(target), page="/iso" if kind == "iso" else "/disk-images", catalog_id=catalog_id, total_bytes=total, downloaded_bytes=done, cancel_requested=False) + core.update_operation(op, status="running", progress=progress, message=message, started_at=core.utc_now()) + core.append_operation_log(op["id"], f"Ссылка: {url}") + if __name__ == "__main__": import uvicorn diff --git a/tests/fixtures/SHA256SUMS b/tests/fixtures/SHA256SUMS new file mode 100644 index 0000000..ef21e26 --- /dev/null +++ b/tests/fixtures/SHA256SUMS @@ -0,0 +1,3 @@ +5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51 *resolute-server-cloudimg-amd64.img +f69162950f235e3cdbbad33f1f912d1a504be90d8a37d002c735d6f3e3882265 *resolute-server-cloudimg-arm64.img +e0361263a9f568c21a59e75c3b7810bc5867866e5bd48f499f8b2e6ab723f059 *resolute-server-cloudimg-amd64.vmdk diff --git a/tests/fixtures/SHA512SUMS b/tests/fixtures/SHA512SUMS new file mode 100644 index 0000000..0091726 --- /dev/null +++ b/tests/fixtures/SHA512SUMS @@ -0,0 +1,3 @@ +# generated by debian cloud team +1412d62c2d55ae2432df6d22c36b9edf4512a59a43bb4eadb1bd3a5ef201154a3052d83ffae704d7079b50388e61992013348e4bb4dee45b3c2a266e2b296135 debian-13-genericcloud-amd64.qcow2 +27c03aab0b5ee1f5918bfaa94821d2a1500fc171ffeb0ae159e7d18662f49171551f397683004434096e4006380fa607577b3c90fafa1eceb3a92702ebd49a15 debian-13-genericcloud-arm64.qcow2 diff --git a/tests/fixtures/domblkinfo.txt b/tests/fixtures/domblkinfo.txt new file mode 100644 index 0000000..075a7e8 --- /dev/null +++ b/tests/fixtures/domblkinfo.txt @@ -0,0 +1,3 @@ +Capacity: 42949672960 +Allocation: 6442450944 +Physical: 6443499520 diff --git a/tests/fixtures/domblklist-details.txt b/tests/fixtures/domblklist-details.txt new file mode 100644 index 0000000..8300047 --- /dev/null +++ b/tests/fixtures/domblklist-details.txt @@ -0,0 +1,4 @@ + Type Device Target Source +------------------------------------------------------------ + file disk vda /var/lib/virtuality/images/web01.qcow2 + file cdrom sda - diff --git a/tests/fixtures/domstats.txt b/tests/fixtures/domstats.txt new file mode 100644 index 0000000..5eb2d80 --- /dev/null +++ b/tests/fixtures/domstats.txt @@ -0,0 +1,16 @@ +Domain: 'web01' + cpu.time=48123456789 + cpu.user=30000000000 + cpu.system=18123456789 + balloon.current=2097152 + balloon.maximum=2097152 + balloon.rss=1534208 + vcpu.current=2 + vcpu.maximum=2 + vcpu.0.state=1 + vcpu.0.time=24000000000 + vcpu.0.wait=0 + vcpu.1.state=1 + vcpu.1.time=24123456789 + vcpu.1.wait=0 + diff --git a/tests/fixtures/dumpxml-web01.xml b/tests/fixtures/dumpxml-web01.xml new file mode 100644 index 0000000..23387f9 --- /dev/null +++ b/tests/fixtures/dumpxml-web01.xml @@ -0,0 +1,37 @@ + + web01 + 6c1b3c1e-0000-0000-0000-000000000001 + Сайт компании + Nginx + база. Бэкап по пятницам. + 2097152 + 2097152 + 2 + + hvm + + + + /usr/bin/qemu-system-x86_64 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/net-dumpxml.xml b/tests/fixtures/net-dumpxml.xml new file mode 100644 index 0000000..cf4594a --- /dev/null +++ b/tests/fixtures/net-dumpxml.xml @@ -0,0 +1,17 @@ + + virtuality-nat + 6c1b3c1e-1111-2222-3333-444444444444 + + + + + + + + + + + + + + diff --git a/tests/fixtures/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 b/tests/fixtures/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 new file mode 100644 index 0000000..6274704 --- /dev/null +++ b/tests/fixtures/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 @@ -0,0 +1 @@ +2ae30006299896d225c9d4de36e9a7c3d047c9b4a5793efe3a6422ec5040e8ee5f7d71326aa9922ffa446fbd11ed6cd1a8d0af4659b3d00233d3fea3550d2d78 diff --git a/tests/fixtures/qemu-img-info.json b/tests/fixtures/qemu-img-info.json new file mode 100644 index 0000000..426201a --- /dev/null +++ b/tests/fixtures/qemu-img-info.json @@ -0,0 +1,10 @@ +{ + "children": [], + "virtual-size": 42949672960, + "filename": "/var/lib/virtuality/images/web01.qcow2", + "cluster-size": 65536, + "format": "qcow2", + "actual-size": 6443499520, + "format-specific": {"type": "qcow2", "data": {"compat": "1.1", "lazy-refcounts": false, "refcount-bits": 16, "corrupt": false}}, + "dirty-flag": false +} diff --git a/tests/fixtures/snapshot-info.txt b/tests/fixtures/snapshot-info.txt new file mode 100644 index 0000000..e5d500f --- /dev/null +++ b/tests/fixtures/snapshot-info.txt @@ -0,0 +1,9 @@ +Name: clean +Domain: web01 +Current: yes +State: running +Location: internal +Parent: - +Children: 1 +Descendants: 1 +Metadata: yes diff --git a/tests/fixtures/snapshot-list.txt b/tests/fixtures/snapshot-list.txt new file mode 100644 index 0000000..2ee1326 --- /dev/null +++ b/tests/fixtures/snapshot-list.txt @@ -0,0 +1,4 @@ + Name Creation Time State +------------------------------------------------------ + before-upd 2026-09-25 10:12:03 +0000 shutoff + clean 2026-09-24 09:00:41 +0000 running diff --git a/tests/test_downloads.py b/tests/test_downloads.py new file mode 100644 index 0000000..d2bff1b --- /dev/null +++ b/tests/test_downloads.py @@ -0,0 +1,381 @@ +"""Скачивание образов по ссылке и из каталога: парсер контрольных сумм, +проверка ссылок, фоновая загрузка с локального http-сервера, отмена.""" +import hashlib +import lzma +import threading +import time +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace +from urllib.parse import unquote + +import pytest + +import app # noqa: F401 — подключает features.downloads к приложению +import catalog +import core +import features.downloads as downloads + +FIXTURES = Path(__file__).resolve().parent / "fixtures" +MB = 1024 * 1024 +SLOW_TOTAL = 4 * MB + + +class Handler(SimpleHTTPRequestHandler): + """Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет.""" + + root = "" + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=self.root, **kwargs) + + def log_message(self, *args): # noqa: D102 — тише в тестах + pass + + def do_GET(self): + if self.path == "/redirect.iso": + self.send_response(302) + self.send_header("Location", "/small.iso") + self.end_headers() + return + if self.path == "/slow.iso": + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(SLOW_TOTAL)) + self.end_headers() + try: + for _ in range(SLOW_TOTAL // (64 * 1024)): + self.wfile.write(b"\0" * 64 * 1024) + self.wfile.flush() + time.sleep(0.05) + except (BrokenPipeError, ConnectionResetError): + pass + return + super().do_GET() + + +@pytest.fixture() +def www(tmp_path): + root = tmp_path / "www" + root.mkdir() + (root / "small.iso").write_bytes(b"ISO" * 5000) + handler = type("TestHandler", (Handler,), {"root": str(root)}) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield SimpleNamespace(root=root, url=f"http://127.0.0.1:{server.server_port}") + server.shutdown() + server.server_close() + + +def wait_for(operation_id: str, timeout: float = 20.0) -> dict: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + operation = core.read_operation(operation_id) + if operation and operation.get("status") in ("success", "error"): + return operation + time.sleep(0.05) + raise AssertionError("операция не завершилась вовремя") + + +def wait_until(predicate, timeout: float = 10.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.05) + raise AssertionError("условие не наступило вовремя") + + +# ---------------------------------------------------------------- parsers & formatting +def test_parse_checksums_ubuntu_star_format(): + text = (FIXTURES / "SHA256SUMS").read_text() + assert downloads.parse_checksums(text, "resolute-server-cloudimg-amd64.img") == "5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51" + assert downloads.parse_checksums(text, "resolute-server-cloudimg-arm64.img").startswith("f6916295") + assert downloads.parse_checksums(text, "missing.img") is None + + +def test_parse_checksums_debian_two_spaces_and_comments(): + text = (FIXTURES / "SHA512SUMS").read_text() + assert downloads.parse_checksums(text, "debian-13-genericcloud-amd64.qcow2").startswith("1412d62c2d55") + assert downloads.parse_checksums(text, "debian-13-genericcloud-arm64.qcow2").startswith("27c03aab0b5e") + + +def test_parse_checksums_alpine_bare_hash_and_bsd_style(): + bare = (FIXTURES / "nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512").read_text() + assert downloads.parse_checksums(bare, "anything.qcow2") == bare.strip().lower() + bsd = "SHA256 (disk.qcow2) = ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789\n" + assert downloads.parse_checksums(bsd, "disk.qcow2") == "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + assert downloads.parse_checksums(bsd, "other.qcow2") is None + assert downloads.parse_checksums("not a checksum file\n", "disk.qcow2") is None + + +def test_human_size_and_eta(): + assert downloads.human_size(0) == "0 Б" + assert downloads.human_size(1023) == "1023 Б" + assert downloads.human_size(1536) == "1,5 КБ" + assert downloads.human_size(245 * MB) == "245 МБ" + assert downloads.human_size(int(3.1 * 1024 * MB)) == "3,1 ГБ" + assert downloads.human_size(10 * MB) == "10 МБ" + assert downloads.human_eta(45) == "45 с" + assert downloads.human_eta(244) == "4 мин" + assert downloads.human_eta(3700) == "1 ч 2 мин" + assert downloads.human_eta(7200) == "2 ч" + + +def test_progress_message(): + assert downloads.progress_message(245 * MB, int(3.1 * 1024 * MB), 12 * MB) == "245 МБ из 3,1 ГБ · 12 МБ/с · осталось 4 мин" + assert downloads.progress_message(245 * MB, None, 12 * MB) == "245 МБ · 12 МБ/с" + assert downloads.progress_message(0, 100 * MB, None) == "0 Б из 100 МБ" + + +# ---------------------------------------------------------------- validation +@pytest.mark.parametrize("url", ["", "ftp://example.org/x.iso", "file:///etc/passwd", "javascript:alert(1)", "http://", "http:///x.iso", "http://example.org/a b.iso"]) +def test_validate_url_rejects(url): + with pytest.raises(downloads.DownloadError): + downloads.validate_url(url) + + +def test_filename_from_url_sanitises_and_checks_suffix(): + assert downloads.filename_from_url("https://h.example/a%20b%20(1).ISO?x=1#frag", "iso") == "a-b-_1.iso" + assert downloads.filename_from_url("https://h.example/dir/debian-13.qcow2", "disk") == "debian-13.qcow2" + assert downloads.filename_from_url("https://h.example/rpi/2026-01-01-raspios.img.xz", "disk") == "2026-01-01-raspios.img.xz" + assert downloads.filename_from_url("https://h.example/../../etc.iso", "iso") == "etc.iso" + for url, kind in (("https://h.example/", "iso"), ("https://h.example/setup.exe", "iso"), ("https://h.example/x.iso", "disk"), ("https://h.example/x.qcow2", "iso")): + with pytest.raises(downloads.DownloadError): + downloads.filename_from_url(url, kind) + + +def test_start_download_refuses_existing_file(data_dirs): + (data_dirs["iso"] / "small.iso").write_bytes(b"x") + with pytest.raises(downloads.DownloadError, match="уже есть"): + downloads.start_download("http://127.0.0.1:9/small.iso", "iso") + + +def test_start_download_checks_free_space(data_dirs, monkeypatch): + monkeypatch.setattr(downloads.shutil, "disk_usage", lambda path: SimpleNamespace(free=1000, total=1000, used=0)) + with pytest.raises(downloads.DownloadError, match="мало места"): + downloads.start_download("http://127.0.0.1:9/small.iso", "iso") + + +def test_image_candidates(): + assert downloads.image_candidates("a.img.xz") == ["a.img.xz", "a.img", "a.qcow2"] + assert downloads.image_candidates("a.img") == ["a.img", "a.qcow2"] + assert downloads.image_candidates("a.qcow2") == ["a.qcow2"] + + +# ---------------------------------------------------------------- background download +def test_download_iso_success_refreshes_pool(data_dirs, www, monkeypatch): + calls = [] + monkeypatch.setattr(downloads, "run_cmd", lambda cmd, timeout=12: calls.append(cmd) or {"ok": True, "stdout": "", "stderr": ""}) + operation = downloads.start_download(f"{www.url}/small.iso", "iso") + assert operation["type"] == "download" and operation["download_kind"] == "iso" + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert done["progress"] == 100 + assert done["total_bytes"] == 15000 and done["downloaded_bytes"] == 15000 + assert (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000 + assert not (data_dirs["iso"] / ".small.iso.part").exists() + assert ["virsh", "pool-refresh", "virtuality-iso"] in calls + assert "Готово" in done["message"] + + +def test_download_follows_redirect(data_dirs, www): + operation = downloads.start_download(f"{www.url}/redirect.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert (data_dirs["iso"] / "redirect.iso").stat().st_size == 15000 + assert "Перенаправление" in done["log_tail"] + + +def test_download_missing_file_fails(data_dirs, www): + operation = downloads.start_download(f"{www.url}/nope.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "404" in done["message"] + assert not list(data_dirs["iso"].iterdir()) + + +def test_download_refuses_when_disk_fills_up(data_dirs, www, monkeypatch): + real = downloads.free_space_ok + seen = [] + + def fake(directory, needed): + seen.append(needed) + return real(directory, needed) if len(seen) == 1 else (False, 42) + + monkeypatch.setattr(downloads, "free_space_ok", fake) + operation = downloads.start_download(f"{www.url}/small.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "мало места" in done["message"] and "42 Б" in done["message"] + assert not list(data_dirs["iso"].iterdir()) + + +def test_download_cancel(data_dirs, www, logged_in): + operation = downloads.start_download(f"{www.url}/slow.iso", "iso") + wait_until(lambda: (core.read_operation(operation["id"]) or {}).get("status") == "running") + wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists()) + response = logged_in.post(f"/api/operations/{operation['id']}/cancel") + assert response.status_code == 200 and response.json()["ok"] is True + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert done["message"] == "Скачивание отменено" + assert done["cancelled"] is True and done["cancel_requested"] is True + assert not (data_dirs["iso"] / ".slow.iso.part").exists() + assert not (data_dirs["iso"] / "slow.iso").exists() + assert not downloads.cancel_requested(operation["id"]) + + +def test_catalog_download_verifies_checksum(data_dirs, www): + payload = b"QCOW2 image bytes" * 100 + (www.root / "disk.qcow2").write_bytes(payload) + (www.root / "SHA256SUMS").write_text(f"{hashlib.sha256(payload).hexdigest()} *disk.qcow2\n") + operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256", catalog_id="test") + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert "совпала" in done["log_tail"] + assert (data_dirs["disk_images"] / "disk.qcow2").read_bytes() == payload + + +def test_catalog_download_checksum_mismatch_deletes_file(data_dirs, www): + (www.root / "disk.qcow2").write_bytes(b"corrupted" * 100) + (www.root / "SHA512SUMS").write_text(f"{'0' * 128} disk.qcow2\n") + operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA512SUMS", algo="sha512") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "Контрольная сумма не совпала" in done["message"] + assert not list(data_dirs["disk_images"].iterdir()) + + +def test_catalog_download_fails_without_sums_entry(data_dirs, www): + (www.root / "disk.qcow2").write_bytes(b"data") + (www.root / "SHA256SUMS").write_text(f"{'a' * 64} *other.qcow2\n") + operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "нет записи" in done["message"] + assert not list(data_dirs["disk_images"].iterdir()) + + +def test_disk_download_extracts_xz_and_starts_conversion(data_dirs, www, monkeypatch): + raw = bytes(range(256)) * 256 + (www.root / "pi.img.xz").write_bytes(lzma.compress(raw)) + started = [] + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path) or {"id": "conv-1"}) + operation = downloads.start_download(f"{www.url}/pi.img.xz", "disk") + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert (data_dirs["disk_images"] / "pi.img").read_bytes() == raw + assert not (data_dirs["disk_images"] / "pi.img.xz").exists() + assert started == [data_dirs["disk_images"] / "pi.img"] + assert done["convert_operation_id"] == "conv-1" + assert "qcow2" in done["message"] + + +def test_qcow2_download_needs_no_conversion(data_dirs, www, monkeypatch): + (www.root / "ready.qcow2").write_bytes(b"q" * 10) + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None) + done = wait_for(downloads.start_download(f"{www.url}/ready.qcow2", "disk")["id"]) + assert done["status"] == "success" and "convert_operation_id" not in done + assert done["result_path"] == str(data_dirs["disk_images"] / "ready.qcow2") + + +# ---------------------------------------------------------------- routes +def test_url_route_starts_download(logged_in, data_dirs, www): + response = logged_in.post("/downloads/url", data={"url": f"{www.url}/small.iso", "kind": "iso"}, follow_redirects=False) + assert response.status_code == 303 + location = unquote(response.headers["location"]) + assert location.startswith("/iso?download_message=") and "small.iso" in location + running = downloads.active_downloads("iso") + assert len(running) == 1 and running[0]["filename"] == "small.iso" + assert wait_for(running[0]["id"])["status"] == "success" + assert (data_dirs["iso"] / "small.iso").exists() + + +def test_url_route_rejects_bad_links(logged_in, data_dirs): + response = logged_in.post("/downloads/url", data={"url": "ftp://example.org/x.iso", "kind": "iso"}, follow_redirects=False) + assert response.status_code == 303 + assert "download_error=" in response.headers["location"] and response.headers["location"].startswith("/iso?") + response = logged_in.post("/downloads/url", data={"url": "https://example.org/setup.exe", "kind": "disk"}, follow_redirects=False) + assert response.headers["location"].startswith("/disk-images?download_error=") + assert not core.running_operations("download") + + +def test_url_route_requires_login(client): + assert client.post("/downloads/url", data={"url": "https://example.org/x.iso"}, follow_redirects=False).headers["location"] == "/login" + assert client.post("/api/operations/x/cancel").status_code == 401 + + +def test_catalog_route(logged_in, data_dirs, www, monkeypatch): + payload = b"debian" * 1000 + (www.root / "debian-13-genericcloud-amd64.qcow2").write_bytes(payload) + (www.root / "SHA512SUMS").write_text(f"{hashlib.sha512(payload).hexdigest()} debian-13-genericcloud-amd64.qcow2\n") + entry = {"id": "debian-13", "title": "Debian 13", "hint": "x", "login": "debian", "arches": {"x86_64": {"url": f"{www.url}/debian-13-genericcloud-amd64.qcow2", "sums": f"{www.url}/SHA512SUMS", "algo": "sha512"}}} + monkeypatch.setattr(catalog, "CLOUD_IMAGES", [entry]) + monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64") + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None) + + assert unquote(logged_in.post("/downloads/catalog/nope", follow_redirects=False).headers["location"]).startswith("/disk-images?download_error=Такого образа нет") + response = logged_in.post("/downloads/catalog/debian-13", follow_redirects=False) + assert response.status_code == 303 and "download_message=" in response.headers["location"] + page = logged_in.get("/disk-images").text + assert "Скачивается" in page + running = downloads.active_downloads("disk") + assert running[0]["catalog_id"] == "debian-13" + assert wait_for(running[0]["id"])["status"] == "success" + assert (data_dirs["disk_images"] / "debian-13-genericcloud-amd64.qcow2").read_bytes() == payload + page = logged_in.get("/disk-images").text + assert "Загружен" in page and "/vm/create?image=debian-13-genericcloud-amd64.qcow2" in page + # второй раз — файл уже есть + response = logged_in.post("/downloads/catalog/debian-13", follow_redirects=False) + assert "download_error=" in response.headers["location"] + + +def test_catalog_route_skips_missing_arch(logged_in, data_dirs, monkeypatch): + monkeypatch.setattr(catalog, "CLOUD_IMAGES", [{"id": "only-arm", "title": "ARM only", "arches": {"aarch64": {"url": "https://example.org/a.qcow2"}}}]) + monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64") + location = unquote(logged_in.post("/downloads/catalog/only-arm", follow_redirects=False).headers["location"]) + assert "не выпускается для процессора x86_64" in location + assert downloads.catalog_entries() == [] + assert "готовых образов в каталоге нет" in logged_in.get("/disk-images").text + + +def test_cancel_api_rules(logged_in, data_dirs): + assert logged_in.post("/api/operations/00000000-0000-0000-0000-000000000000/cancel").status_code == 404 + other = core.new_operation("vm_create", "Создание VM x") + response = logged_in.post(f"/api/operations/{other['id']}/cancel") + assert response.status_code == 400 and "только скачивание" in response.json()["error"] + finished = core.new_operation("download", "Скачивание x", download_kind="iso", filename="x.iso") + core.finish_operation(finished, True, "Готово") + assert logged_in.post(f"/api/operations/{finished['id']}/cancel").status_code == 409 + running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2") + core.update_operation(running, status="running") + response = logged_in.post(f"/api/operations/{running['id']}/cancel") + assert response.status_code == 200 and response.json()["operation"]["cancel_requested"] is True + assert downloads.cancel_requested(running["id"]) + downloads.clear_cancel(running["id"]) + + +def test_cancel_form_redirects_to_page(logged_in, data_dirs): + running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2") + core.update_operation(running, status="running") + response = logged_in.post(f"/downloads/{running['id']}/cancel", follow_redirects=False) + assert response.status_code == 303 + assert unquote(response.headers["location"]).startswith("/disk-images?download_message=Останавливаем скачивание y.qcow2") + assert unquote(logged_in.post("/downloads/nope/cancel", follow_redirects=False).headers["location"]).startswith("/iso?download_error=") + downloads.clear_cancel(running["id"]) + + +def test_pages_show_download_cards(logged_in, data_dirs): + iso_page = logged_in.get("/iso").text + assert "Скачать по ссылке" in iso_page and 'name="kind" value="iso"' in iso_page + assert "Скачиваем на сервер" not in iso_page + disk_page = logged_in.get("/disk-images").text + assert "Каталог готовых систем" in disk_page and "Рекомендуем" in disk_page and 'name="kind" value="disk"' in disk_page + running = core.new_operation("download", "Скачивание big.iso", download_kind="iso", filename="big.iso", url="https://example.org/big.iso", total_bytes=None) + core.update_operation(running, status="running", progress=0, message="Подключаемся к серверу…") + iso_page = logged_in.get("/iso?download_message=Скачиваем").text + assert "Скачиваем на сервер" in iso_page and "big.iso" in iso_page and "indeterminate" in iso_page + assert f"/downloads/{running['id']}/cancel" in iso_page and "Скачиваем" in iso_page + assert "big.iso" not in logged_in.get("/disk-images").text diff --git a/web/app.py b/web/app.py index c911878..0d0653a 100644 --- a/web/app.py +++ b/web/app.py @@ -2016,3 +2016,7 @@ def api_health(request: Request): if not get_current_user(request): 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()} + + +import features.downloads as downloads # noqa: E402 +app.include_router(downloads.router) diff --git a/web/catalog.py b/web/catalog.py new file mode 100644 index 0000000..014871b --- /dev/null +++ b/web/catalog.py @@ -0,0 +1,82 @@ +"""Catalog of ready-to-run cloud images for the quick VM flow. + +Each entry points to the vendor's official cloud image. Images boot in seconds +and are configured through cloud-init (user, password, SSH key) — no installer. +""" +from typing import Any + +# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13. +CLOUD_IMAGES: list[dict[str, Any]] = [ + { + "id": "ubuntu-26.04", + "title": "Ubuntu Server 26.04 LTS", + "vendor": "Ubuntu", + "hint": "Самая свежая LTS, поддержка до 2031 года. Рекомендуем.", + "osinfo": "ubuntu24.04", + "recommended": True, + "login": "ubuntu", + "arches": { + "x86_64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"}, + "aarch64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"}, + }, + }, + { + "id": "ubuntu-24.04", + "title": "Ubuntu Server 24.04 LTS", + "vendor": "Ubuntu", + "hint": "Проверенная LTS, поддержка до 2029 года.", + "osinfo": "ubuntu24.04", + "login": "ubuntu", + "arches": { + "x86_64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"}, + "aarch64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"}, + }, + }, + { + "id": "debian-13", + "title": "Debian 13", + "vendor": "Debian", + "hint": "Лёгкий и стабильный. Хорош для домашних сервисов.", + "osinfo": "debian13", + "login": "debian", + "arches": { + "x86_64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"}, + "aarch64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"}, + }, + }, + { + "id": "alpine-3.23", + "title": "Alpine Linux 3.23", + "vendor": "Alpine", + "hint": "Очень маленький: 200 МБ на диске, стартует мгновенно.", + "osinfo": "alpinelinux3.21", + "login": "alpine", + "arches": { + "x86_64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True}, + "aarch64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True}, + }, + }, +] + +# OS families for the "install from ISO" flow. virt-install picks the right +# virtual hardware from osinfo: Windows gets SATA/e1000e (works without extra +# drivers), win11 additionally gets UEFI + TPM. +OS_TYPES: list[dict[str, Any]] = [ + {"id": "linux", "title": "Linux", "hint": "Ubuntu, Debian, Fedora и другие", "osinfo": "detect=on,require=off"}, + {"id": "windows11", "title": "Windows 11 / Server 2025", "hint": "Нужны UEFI и TPM — включим сами", "osinfo": "win11"}, + {"id": "windows10", "title": "Windows 10 / Server 2019–2022", "hint": "Классическая загрузка", "osinfo": "win10"}, + {"id": "other", "title": "Другая система", "hint": "FreeBSD, старые ОС, экзотика", "osinfo": "generic"}, +] + + +def cloud_image(image_id: str) -> dict[str, Any] | None: + return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None) + + +def os_type(type_id: str) -> dict[str, Any]: + return next((item for item in OS_TYPES if item["id"] == type_id), OS_TYPES[0]) + + +def image_filename(entry: dict[str, Any], arch: str) -> str: + url = entry["arches"][arch]["url"] + return url.rsplit("/", 1)[-1] diff --git a/web/core.py b/web/core.py new file mode 100644 index 0000000..cf7b9a1 --- /dev/null +++ b/web/core.py @@ -0,0 +1,321 @@ +"""Shared primitives of the Virtuality panel: settings, auth, command runner, +background operations store, templates. Feature modules import from here; +app.py re-exports the same names for backwards compatibility.""" +import json +import os +import re +import subprocess +import threading +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Callable +from urllib.parse import quote, urlsplit + +from fastapi import Request +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from itsdangerous import BadSignature, URLSafeSerializer, URLSafeTimedSerializer + +import auth +import presenters +import update_core + +BASE_DIR = Path(__file__).resolve().parent +APP_NAME = "Virtuality" +ENV_FILE = BASE_DIR / ".env" +STORAGE_DIR = Path("/var/lib/virtuality") +ISO_DIR = Path("/var/lib/virtuality/iso") +IMAGES_DIR = Path("/var/lib/virtuality/images") +DISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images") +BACKUPS_DIR = Path("/var/lib/virtuality/backups") +CONFIG_DIR = Path("/var/lib/virtuality/config") +OPERATIONS_DIR = Path("/var/log/virtuality/operations") +DEFAULT_BRIDGE = "br0" +IMAGES_POOL = "virtuality-images" +ISO_POOL = "virtuality-iso" + +OP_LOCK = threading.Lock() + + +def load_env() -> dict[str, str]: + data: dict[str, str] = {} + if ENV_FILE.exists(): + for raw_line in ENV_FILE.read_text().splitlines(): + line = raw_line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + data[key.strip()] = value.strip().strip('"').strip("'") + return data + + +CONFIG = load_env() +AUTH_USER = CONFIG.get("VIRTUALITY_AUTH_USER", os.environ.get("VIRTUALITY_AUTH_USER", "root")) +SESSION_SECRET = CONFIG.get("VIRTUALITY_SESSION_SECRET", os.environ.get("VIRTUALITY_SESSION_SECRET", "dev-secret-change-me")) +COOKIE_SECURE = CONFIG.get("VIRTUALITY_COOKIE_SECURE", os.environ.get("VIRTUALITY_COOKIE_SECURE", "0")) == "1" +SESSION_MAX_AGE = 60 * 60 * 12 +login_throttle = auth.LoginThrottle() +serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="virtuality-session") +console_serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-console") + + +def is_configured() -> bool: + return bool(AUTH_USER and SESSION_SECRET != "dev-secret-change-me") + + +def read_version() -> str: + for path in (BASE_DIR / "VERSION", BASE_DIR.parent / "VERSION"): + try: + return path.read_text().strip() or "unknown" + except OSError: + continue + return "unknown" + + +APP_VERSION = read_version() + + +# ---------------------------------------------------------------- templates +def update_notice() -> dict[str, Any]: + try: + data = json.loads((update_core.STATE_DIR / "last_check.json").read_text()) + except Exception: + return {"has_update": False} + return {"has_update": bool(data.get("has_update")), "latest_version": data.get("latest_version", "")} + + +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) +templates.env.globals.update( + update_notice=update_notice, + vm_presets=presenters.VM_PRESETS, + port_presets=presenters.PORT_PRESETS, + plural=presenters.plural, + app_version=APP_VERSION, + app_name=APP_NAME, +) +templates.env.filters.update( + vm_state=presenters.vm_state, + operation_state=presenters.operation_state, + service_state=presenters.service_state, + format_mb=presenters.format_mb, + format_bytes=presenters.format_bytes, + level_tone=presenters.level_tone, + port_service=presenters.port_service, +) + + +def render(request: Request, template: str, context: dict[str, Any] | None = None, status_code: int = 200, **extra: Any): + data = {"request": request, "app_name": APP_NAME, "user": AUTH_USER} + data.update(context or {}) + data.update(extra) + return templates.TemplateResponse(template, data, status_code=status_code) + + +# ---------------------------------------------------------------- auth +def verify_linux_password(username: str, password: str) -> bool: + if username != AUTH_USER: + return False + return auth.verify_password(username, password) + + +def user_from_session_token(token: str | None) -> str | None: + if not token: + return None + try: + data = serializer.loads(token, max_age=SESSION_MAX_AGE) + except BadSignature: + return None + if not isinstance(data, dict): + return None + return AUTH_USER if data.get("user") == AUTH_USER else None + + +def get_current_user(request: Request) -> str | None: + return user_from_session_token(request.cookies.get("virtuality_session")) + + +def require_auth(request: Request): + """Return a redirect to /login for anonymous requests, otherwise None.""" + if not get_current_user(request): + return RedirectResponse(url="/login", status_code=303) + return None + + +def require_api_auth(request: Request): + """JSON variant of require_auth for /api and /live endpoints.""" + if not get_current_user(request): + return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) + return None + + +def client_key(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def same_origin(request: Request) -> bool: + origin = request.headers.get("origin") or request.headers.get("referer") + if not origin or origin == "null": + # Browsers always send Origin on cross-site form posts; tools like curl do not. + return origin != "null" + origin_host = urlsplit(origin).netloc.lower() + allowed = {request.headers.get("host", "").lower()} + forwarded = request.headers.get("x-forwarded-host") + if forwarded: + allowed.update(item.strip().lower() for item in forwarded.split(",")) + return origin_host in allowed + + +def redirect_with_message(path: str, key: str, message: str) -> RedirectResponse: + return RedirectResponse(url=f"{path}?{key}={quote(message)}", status_code=303) + + +# ---------------------------------------------------------------- commands +def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) + return {"ok": result.returncode == 0, "code": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "cmd": " ".join(cmd)} + except Exception as exc: + return {"ok": False, "code": -1, "stdout": "", "stderr": str(exc), "cmd": " ".join(cmd)} + + +def cmd_error(result: dict[str, Any], fallback: str) -> str: + """Best human-readable error text from a run_cmd result.""" + text = (result.get("stderr") or result.get("stdout") or "").strip() + text = re.sub(r"^error:\s*", "", text, flags=re.IGNORECASE) + return text.splitlines()[0] if text else fallback + + +def utc_now() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def tail_text(path: Path, max_lines: int = 220) -> str: + if not path.exists(): + return "" + try: + return "\n".join(path.read_text(errors="replace").splitlines()[-max_lines:]) + except OSError: + return "" + + +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_label(name: str) -> bool: + """Snapshot, backup and similar user-chosen names.""" + return bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,62}", name or "")) + + +def vm_exists(name: str) -> bool: + return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"] + + +def vm_state(name: str) -> str: + result = run_cmd(["virsh", "domstate", name], timeout=8) + return (result.get("stdout") or "unknown").strip().lower() + + +def vm_is_running(name: str) -> bool: + return vm_state(name) in ("running", "idle", "blocked", "paused", "pmsuspended", "in shutdown") + + +# ---------------------------------------------------------------- operations +def operation_meta_path(operation_id: str) -> Path: + return OPERATIONS_DIR / f"{operation_id}.json" + + +def operation_log_path(operation_id: str) -> Path: + return OPERATIONS_DIR / f"{operation_id}.log" + + +def ensure_operations_dir() -> None: + OPERATIONS_DIR.mkdir(parents=True, exist_ok=True) + + +def write_operation(operation: dict[str, Any]) -> None: + ensure_operations_dir() + path = operation_meta_path(operation["id"]) + tmp_path = path.with_suffix(".json.tmp") + with OP_LOCK: + tmp_path.write_text(json.dumps(operation, ensure_ascii=False, indent=2)) + tmp_path.replace(path) + + +def read_operation(operation_id: str) -> dict[str, Any] | None: + if not re.fullmatch(r"[a-f0-9-]{36}", operation_id or ""): + return None + path = operation_meta_path(operation_id) + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + except Exception: + return None + data["log_tail"] = tail_text(operation_log_path(operation_id)) + return data + + +def list_operations(limit: int = 25) -> list[dict[str, Any]]: + ensure_operations_dir() + operations: list[dict[str, Any]] = [] + for path in sorted(OPERATIONS_DIR.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True): + try: + data = json.loads(path.read_text()) + data["log_tail"] = tail_text(operation_log_path(data["id"]), max_lines=20) + operations.append(data) + except Exception: + continue + if len(operations) >= limit: + break + return operations + + +def running_operations(kind: str | None = None) -> list[dict[str, Any]]: + return [op for op in list_operations(50) if op.get("status") in ("queued", "running") and (kind is None or op.get("type") == kind)] + + +def append_operation_log(operation_id: str, message: str) -> None: + ensure_operations_dir() + with operation_log_path(operation_id).open("a", encoding="utf-8") as handle: + handle.write(f"[{utc_now()}] {message.rstrip()}\n") + + +def operation_css(status: str) -> str: + return "ok" if status == "success" else "err" if status == "error" else "warn" + + +def update_operation(operation: dict[str, Any], **changes: Any) -> None: + operation.update(changes) + operation["updated_at"] = utc_now() + write_operation(operation) + + +def new_operation(kind: str, title: str, message: str = "Операция поставлена в очередь", **meta: Any) -> dict[str, Any]: + operation = {"id": str(uuid.uuid4()), "type": kind, "title": title, "status": "queued", "progress": 0, "message": message, "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER} + operation.update(meta) + write_operation(operation) + append_operation_log(operation["id"], "Операция поставлена в очередь.") + return operation + + +def finish_operation(operation: dict[str, Any], ok: bool, message: str, **meta: Any) -> None: + fresh = read_operation(operation["id"]) or operation + fresh.pop("log_tail", None) + update_operation(fresh, status="success" if ok else "error", progress=100, message=message, finished_at=utc_now(), **meta) + append_operation_log(operation["id"], message) + + +def run_operation(operation: dict[str, Any], worker: Callable[[dict[str, Any]], None]) -> None: + """Run worker(operation) in a daemon thread; the worker reports progress via + update_operation/append_operation_log and must call finish_operation.""" + + def runner() -> None: + try: + update_operation(operation, status="running", started_at=utc_now()) + worker(operation) + except Exception as exc: # noqa: BLE001 — surface any failure to the user + append_operation_log(operation["id"], f"Ошибка: {exc}") + finish_operation(operation, False, str(exc)[:240]) + + threading.Thread(target=runner, daemon=True).start() diff --git a/web/features/__init__.py b/web/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/features/downloads.py b/web/features/downloads.py new file mode 100644 index 0000000..4c13ab3 --- /dev/null +++ b/web/features/downloads.py @@ -0,0 +1,578 @@ +"""Скачивание образов ОС по ссылке и из встроенного каталога готовых систем. + +Файл качается на сервере в фоне как операция типа «download»: прогресс, +скорость и оставшееся время видны на страницах «Образы ОС» и в «Задачах». +Пока идёт загрузка, файл лежит рядом как `.<имя>.part` и переименовывается +только после успешного завершения (и проверки контрольной суммы для +образов из каталога). Скачивание можно отменить — недокачанный файл удаляется. +""" +import hashlib +import re +import shutil +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlsplit + +from fastapi import APIRouter, Form, Request +from fastapi.responses import JSONResponse + +import catalog +import core +import host_profile +from core import ( + APP_VERSION, + append_operation_log, + finish_operation, + new_operation, + read_operation, + redirect_with_message, + require_api_auth, + require_auth, + run_cmd, + run_operation, + running_operations, + update_operation, +) + +router = APIRouter() + +CHUNK_SIZE = 1024 * 1024 +TIMEOUT = 30 +RESERVE_BYTES = 512 * 1024 * 1024 +SUMS_MAX_BYTES = 4 * 1024 * 1024 +USER_AGENT = f"Virtuality/{APP_VERSION}" +ISO_POOL = "virtuality-iso" + +KINDS: dict[str, dict[str, Any]] = { + "iso": {"suffixes": (".iso",), "page": "/iso", "label": "установочный образ", "hint": "Ссылка должна вести на файл .iso"}, + "disk": {"suffixes": (".qcow2", ".img", ".raw", ".img.xz"), "page": "/disk-images", "label": "образ диска", "hint": "Подойдут файлы .qcow2, .img, .raw и сжатые .img.xz"}, +} + +# Отмена: идентификаторы операций, которые попросили остановить. Поток +# загрузки проверяет множество между кусками файла. +CANCEL_LOCK = threading.Lock() +CANCEL_REQUESTS: set[str] = set() + + +class DownloadError(ValueError): + """Понятная пользователю ошибка проверки ссылки или файла.""" + + +# ---------------------------------------------------------------- formatting +def human_size(value: float | int | None) -> str: + """1234567 → «1,2 МБ». Десятичная запятая, как принято в русском.""" + value = float(value or 0) + for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"): + if value < 1024 or unit == "ТБ": + if unit == "Б" or value >= 100: + return f"{value:.0f} {unit}" + text = f"{value:.1f}".rstrip("0").rstrip(".") + return f"{text.replace('.', ',')} {unit}" + value /= 1024 + return f"{value:.0f} ТБ" + + +def human_eta(seconds: float | None) -> str: + """Оставшееся время словами: «40 с», «4 мин», «1 ч 12 мин».""" + if seconds is None or seconds < 0: + return "" + seconds = int(round(seconds)) + if seconds < 60: + return f"{max(seconds, 1)} с" + minutes = (seconds + 30) // 60 + if minutes < 60: + return f"{minutes} мин" + hours, minutes = divmod(minutes, 60) + return f"{hours} ч {minutes} мин" if minutes else f"{hours} ч" + + +def progress_message(done: int, total: int | None, speed: float | None) -> str: + """«245 МБ из 3,1 ГБ · 12 МБ/с · осталось 4 мин».""" + parts = [f"{human_size(done)} из {human_size(total)}" if total else human_size(done)] + if speed and speed > 0: + parts.append(f"{human_size(speed)}/с") + if total and total > done: + parts.append(f"осталось {human_eta((total - done) / speed)}") + return " · ".join(parts) + + +# ---------------------------------------------------------------- validation +def validate_url(url: str) -> str: + url = (url or "").strip() + if not url: + raise DownloadError("Вставьте ссылку на файл") + if len(url) > 2048 or any(ch.isspace() for ch in url): + raise DownloadError("Ссылка выглядит некорректно") + parts = urlsplit(url) + if parts.scheme.lower() not in ("http", "https"): + raise DownloadError("Ссылка должна начинаться с http:// или https://") + if not parts.hostname: + raise DownloadError("В ссылке не указан адрес сервера") + return url + + +def filename_from_url(url: str, kind: str) -> str: + """Имя файла из ссылки, приведённое к безопасному виду (как при загрузке с компьютера).""" + if kind not in KINDS: + raise DownloadError("Неизвестный тип образа") + original = unquote(urlsplit(url).path).rstrip("/").rsplit("/", 1)[-1].strip() + if not original: + raise DownloadError("В ссылке нет имени файла — она должна вести на сам файл, а не на страницу") + lower = original.lower() + suffix = next((item for item in KINDS[kind]["suffixes"] if lower.endswith(item)), None) + if not suffix: + allowed = ", ".join(KINDS[kind]["suffixes"]) + raise DownloadError(f"Файл «{original}» не подходит: нужен {KINDS[kind]['label']} ({allowed})") + stem = original[: -len(suffix)] + stem = re.sub(r"\s+", "-", stem.strip()) + stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem) + stem = stem.strip("._-")[:120] + if not stem: + stem = "virtuality-iso" if kind == "iso" else "virtuality-disk" + name = f"{stem}{suffix}" + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,180}", name): + raise DownloadError("Не удалось составить безопасное имя файла из ссылки") + return name + + +def kind_dir(kind: str) -> Path: + return core.ISO_DIR if kind == "iso" else core.DISK_IMAGES_DIR + + +def target_path(kind: str, filename: str) -> Path: + directory = kind_dir(kind) + directory.mkdir(parents=True, exist_ok=True) + path = (directory / filename).resolve() + if directory.resolve() not in path.parents: + raise DownloadError("Недопустимое имя файла") + return path + + +def part_path(target: Path) -> Path: + return target.with_name(f".{target.name}.part") + + +def free_space_ok(directory: Path, needed: int | None) -> tuple[bool, int]: + free = shutil.disk_usage(str(directory)).free + return free >= (needed or 0) + RESERVE_BYTES, free + + +# ---------------------------------------------------------------- checksums +HASH_RE = re.compile(r"^[0-9a-fA-F]{32,128}$") + + +def parse_checksums(text: str, filename: str) -> str | None: + """Хеш файла из файла контрольных сумм. + + Понимает « *name» и « name» (Ubuntu, Debian), BSD-стиль + «SHA256 (name) = » и файл из одного хеша (Alpine *.sha512). + """ + bare: str | None = None + for raw in (text or "").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + bsd = re.match(r"^[A-Za-z0-9-]+\s*\((.+)\)\s*=\s*([0-9a-fA-F]{32,128})$", line) + if bsd: + if Path(bsd.group(1).strip()).name == filename: + return bsd.group(2).lower() + continue + pieces = line.split(None, 1) + if len(pieces) == 2 and HASH_RE.match(pieces[0]): + name = pieces[1].strip().lstrip("*").strip() + if Path(name).name == filename: + return pieces[0].lower() + continue + if len(pieces) == 1 and HASH_RE.match(pieces[0]) and bare is None: + bare = pieces[0].lower() + return bare + + +def file_digest(path: Path, algo: str, on_progress: Any = None) -> str: + digest = hashlib.new(algo) + with path.open("rb") as handle: + while True: + chunk = handle.read(CHUNK_SIZE) + if not chunk: + break + digest.update(chunk) + if on_progress: + on_progress(len(chunk)) + return digest.hexdigest() + + +# ---------------------------------------------------------------- catalog +def host_arch() -> str: + arch = str(host_profile.load_host_profile().get("arch") or "").lower() + return {"amd64": "x86_64", "arm64": "aarch64"}.get(arch, arch or "x86_64") + + +def image_candidates(filename: str) -> list[str]: + """Имена файлов, под которыми образ может лежать после скачивания: + сам файл, распакованный .img для .img.xz и qcow2 после конвертации.""" + names = [filename] + lower = filename.lower() + if lower.endswith(".img.xz"): + names.append(filename[:-3]) + names.append(filename[:-7] + ".qcow2") + elif lower.endswith((".img", ".raw")): + names.append(filename[: filename.rfind(".")] + ".qcow2") + return names + + +def catalog_entries(arch: str | None = None) -> list[dict[str, Any]]: + """Каталог готовых систем для процессора сервера с пометками «Загружен» / «Скачивается».""" + arch = arch or host_arch() + active = {op.get("catalog_id"): op for op in running_operations("download") if op.get("catalog_id")} + entries: list[dict[str, Any]] = [] + for item in catalog.CLOUD_IMAGES: + source = item.get("arches", {}).get(arch) + if not source: + continue + try: + filename = filename_from_url(source["url"], "disk") + except DownloadError: + continue + ready = next((name for name in reversed(image_candidates(filename)) if (core.DISK_IMAGES_DIR / name).exists()), None) + operation = active.get(item["id"]) + entries.append({ + "id": item["id"], "title": item["title"], "vendor": item.get("vendor", ""), "hint": item.get("hint", ""), + "recommended": bool(item.get("recommended")), "login": item.get("login", ""), "filename": filename, + "uefi": bool(source.get("uefi")), "status": "downloading" if operation else "ready" if ready else "none", + "ready_file": ready, "operation_id": operation["id"] if operation else None, + }) + return entries + + +def active_downloads(kind: str | None = None) -> list[dict[str, Any]]: + result = [] + for op in running_operations("download"): + if kind and op.get("download_kind") != kind: + continue + op["indeterminate"] = not op.get("total_bytes") and op.get("status") == "running" + result.append(op) + return result + + +def downloads_panel(kind: str) -> dict[str, Any]: + """Данные для карточек скачивания на страницах /iso и /disk-images (глобальная функция шаблонов).""" + kind = kind if kind in KINDS else "iso" + return {"kind": kind, "page": KINDS[kind]["page"], "hint": KINDS[kind]["hint"], "active": active_downloads(kind), "catalog": catalog_entries() if kind == "disk" else []} + + +# ---------------------------------------------------------------- cancel +def cancel_requested(operation_id: str) -> bool: + with CANCEL_LOCK: + return operation_id in CANCEL_REQUESTS + + +def clear_cancel(operation_id: str) -> None: + with CANCEL_LOCK: + CANCEL_REQUESTS.discard(operation_id) + + +def request_cancel(operation_id: str) -> tuple[dict[str, Any] | None, str | None]: + """Попросить остановить скачивание. Возвращает (операция, ошибка).""" + operation = read_operation(operation_id) + if not operation: + return None, "Задача не найдена" + if operation.get("type") != "download": + return operation, "Отменить можно только скачивание" + if operation.get("status") not in ("queued", "running"): + return operation, "Скачивание уже завершено" + with CANCEL_LOCK: + CANCEL_REQUESTS.add(operation_id) + operation.pop("log_tail", None) + update_operation(operation, cancel_requested=True, message="Останавливаем скачивание…") + append_operation_log(operation_id, "Пользователь попросил отменить скачивание.") + return operation, None + + +# ---------------------------------------------------------------- download +def start_download(url: str, kind: str, *, title: str | None = None, sums_url: str | None = None, algo: str | None = None, catalog_id: str | None = None) -> dict[str, Any]: + """Проверить ссылку и запустить фоновое скачивание. Бросает DownloadError.""" + if kind not in KINDS: + raise DownloadError("Неизвестный тип образа") + url = validate_url(url) + filename = filename_from_url(url, kind) + target = target_path(kind, filename) + if target.exists(): + raise DownloadError(f"Файл {filename} уже есть на сервере. Удалите его, если нужно скачать заново") + if any(op.get("target_path") == str(target) for op in running_operations("download")): + raise DownloadError(f"Файл {filename} уже скачивается") + if sums_url: + validate_url(sums_url) + if algo not in hashlib.algorithms_available: + raise DownloadError("Неизвестный алгоритм контрольной суммы") + ok, free = free_space_ok(target.parent, None) + if not ok: + raise DownloadError(f"На сервере мало места: свободно {human_size(free)}") + operation = new_operation( + "download", title or f"Скачивание {filename}", message="Подключаемся к серверу…", + download_kind=kind, url=url, filename=filename, target_path=str(target), source_path=str(target), page=KINDS[kind]["page"], + sums_url=sums_url, algo=algo, catalog_id=catalog_id, total_bytes=None, downloaded_bytes=0, cancel_requested=False, + ) + append_operation_log(operation["id"], f"Ссылка: {url}") + append_operation_log(operation["id"], f"Файл: {target}") + run_operation(operation, download_worker) + return operation + + +def open_url(url: str) -> Any: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "*/*"}) + return urllib.request.urlopen(request, timeout=TIMEOUT) # noqa: S310 — схема проверена в validate_url + + +def describe_network_error(exc: Exception) -> str: + if isinstance(exc, urllib.error.HTTPError): + return f"Сервер ответил ошибкой {exc.code} {exc.reason}" + if isinstance(exc, urllib.error.URLError): + reason = exc.reason + if isinstance(reason, TimeoutError) or "timed out" in str(reason).lower(): + return f"Сервер не отвечает (ждали {TIMEOUT} с)" + return f"Не удалось подключиться к серверу: {reason}" + if isinstance(exc, TimeoutError) or "timed out" in str(exc).lower(): + return f"Сервер перестал отвечать (ждали {TIMEOUT} с)" + return str(exc)[:240] + + +def fetch_expected_checksum(sums_url: str, filename: str, operation_id: str) -> str: + append_operation_log(operation_id, f"Скачиваем контрольные суммы: {sums_url}") + try: + with open_url(sums_url) as response: + text = response.read(SUMS_MAX_BYTES).decode("utf-8", errors="replace") + except Exception as exc: # noqa: BLE001 + raise DownloadError(f"Не удалось скачать файл контрольных сумм: {describe_network_error(exc)}") from exc + expected = parse_checksums(text, filename) + if not expected: + raise DownloadError(f"В файле контрольных сумм нет записи для {filename}") + return expected + + +def download_worker(operation: dict[str, Any]) -> None: + operation_id = operation["id"] + kind = operation["download_kind"] + target = Path(operation["target_path"]) + part = part_path(target) + verify = bool(operation.get("sums_url")) + download_share = 96 if verify else 100 + + def cancelled() -> bool: + return cancel_requested(operation_id) + + def fail(message: str, **meta: Any) -> None: + part.unlink(missing_ok=True) + clear_cancel(operation_id) + finish_operation(operation, False, message, **meta) + + def stop() -> None: + append_operation_log(operation_id, "Скачивание остановлено, недокачанный файл удалён.") + fail("Скачивание отменено", cancelled=True, cancel_requested=True) + + if cancelled(): + stop() + return + try: + response = open_url(operation["url"]) + except Exception as exc: # noqa: BLE001 + fail(describe_network_error(exc)) + return + + with response: + final_url = response.geturl() + if final_url != operation["url"]: + append_operation_log(operation_id, f"Перенаправление: {final_url}") + length = response.headers.get("Content-Length") + total = int(length) if length and length.isdigit() else None + ok, free = free_space_ok(target.parent, total) + if not ok: + need = f"нужно {human_size((total or 0) + RESERVE_BYTES)}, " if total else "" + fail(f"На сервере мало места: {need}свободно {human_size(free)}") + return + append_operation_log(operation_id, f"Размер файла: {human_size(total) if total else 'неизвестен'}") + update_operation(operation, total_bytes=total, message=progress_message(0, total, None)) + + done = 0 + started = last_tick = time.monotonic() + last_done = 0 + speed: float | None = None + try: + with part.open("wb") as handle: + while True: + if cancelled(): + stop() + return + chunk = response.read(CHUNK_SIZE) + if not chunk: + break + handle.write(chunk) + done += len(chunk) + now = time.monotonic() + if now - last_tick >= 1.0: + instant = (done - last_done) / (now - last_tick) + speed = instant if speed is None else speed * 0.7 + instant * 0.3 + last_tick, last_done = now, done + progress = int(done * download_share / total) if total else 0 + if not total: + ok, free = free_space_ok(target.parent, None) + if not ok: + fail(f"На сервере закончилось место: свободно {human_size(free)}") + return + update_operation(operation, progress=min(progress, download_share), downloaded_bytes=done, speed_bps=int(speed), message=progress_message(done, total, speed)) + except Exception as exc: # noqa: BLE001 + fail(f"Загрузка прервалась: {describe_network_error(exc)}") + return + + if total is not None and done != total: + fail(f"Файл скачался не полностью: получено {human_size(done)} из {human_size(total)}") + return + elapsed = max(time.monotonic() - started, 0.001) + append_operation_log(operation_id, f"Скачано {human_size(done)} за {human_eta(elapsed) or '1 с'} ({human_size(done / elapsed)}/с).") + update_operation(operation, progress=download_share, downloaded_bytes=done, total_bytes=done if total is None else total, message="Файл скачан, проверяем…") + + if verify: + try: + expected = fetch_expected_checksum(operation["sums_url"], operation["filename"], operation_id) + except DownloadError as exc: + fail(str(exc)) + return + algo = operation.get("algo") or "sha256" + update_operation(operation, progress=97, message=f"Проверяем контрольную сумму ({algo})…") + actual = file_digest(part, algo) + if actual != expected: + append_operation_log(operation_id, f"Ожидали {algo}: {expected}") + append_operation_log(operation_id, f"Получили {algo}: {actual}") + fail("Контрольная сумма не совпала — файл повреждён или подменён, поэтому удалён. Попробуйте скачать ещё раз") + return + append_operation_log(operation_id, f"Контрольная сумма {algo} совпала.") + + if target.exists(): + fail(f"Файл {target.name} появился на сервере, пока шло скачивание. Скачанная копия удалена") + return + part.replace(target) + clear_cancel(operation_id) + append_operation_log(operation_id, f"Файл сохранён: {target}") + try: + result = after_download(operation, target, kind) + except Exception as exc: # noqa: BLE001 + finish_operation(operation, False, f"Файл скачан, но не удалось его подготовить: {str(exc)[:200]}", result_path=str(target)) + return + finish_operation(operation, True, result["message"], **result["meta"]) + + +def after_download(operation: dict[str, Any], target: Path, kind: str) -> dict[str, Any]: + """Что делать со скачанным файлом: обновить пул ISO либо распаковать и конвертировать диск.""" + operation_id = operation["id"] + if kind == "iso": + refresh = run_cmd(["virsh", "pool-refresh", ISO_POOL], timeout=20) + append_operation_log(operation_id, "Список установочных образов обновлён." if refresh["ok"] else f"Не удалось обновить пул {ISO_POOL}: {refresh.get('stderr') or refresh.get('stdout')}") + return {"message": f"Готово: {target.name} — можно создавать машину", "meta": {"result_path": str(target)}} + + path = target + if target.name.lower().endswith(".img.xz"): + update_operation(operation, progress=98, message=f"Распаковываем {target.name}…") + append_operation_log(operation_id, "Распаковываем сжатый образ…") + path = extract_xz_image(target) + target.unlink(missing_ok=True) + append_operation_log(operation_id, f"Распакован: {path}") + convert = start_convert(path) + meta = {"result_path": str(path)} + if convert: + meta["convert_operation_id"] = convert["id"] + append_operation_log(operation_id, f"Запущена конвертация в qcow2, задача {convert['id']}.") + return {"message": f"Готово: {path.name} скачан, конвертируем в формат qcow2", "meta": meta} + return {"message": f"Готово: {path.name} — можно создавать машину", "meta": meta} + + +def extract_xz_image(compressed: Path) -> Path: + import app # локальный импорт: app.py подключает этот модуль в самом конце + + return app.extract_xz_disk_image(compressed) + + +def start_convert(path: Path) -> dict[str, Any] | None: + import app # локальный импорт: app.py подключает этот модуль в самом конце + + return app.start_disk_convert_operation(path) + + +# ---------------------------------------------------------------- routes +def page_for(kind: str) -> str: + return KINDS.get(kind, KINDS["iso"])["page"] + + +@router.post("/downloads/url") +def download_from_url(request: Request, url: str = Form(""), kind: str = Form("iso")): + auth = require_auth(request) + if auth: + return auth + kind = kind if kind in KINDS else "iso" + try: + operation = start_download(url, kind) + except DownloadError as exc: + return redirect_with_message(page_for(kind), "download_error", str(exc)) + return redirect_with_message(page_for(kind), "download_message", f"Скачиваем {operation['filename']} — ход загрузки виден выше") + + +@router.post("/downloads/catalog/{image_id}") +def download_from_catalog(request: Request, image_id: str): + auth = require_auth(request) + if auth: + return auth + entry = catalog.cloud_image(image_id) + if not entry: + return redirect_with_message("/disk-images", "download_error", "Такого образа нет в каталоге") + arch = host_arch() + source = entry.get("arches", {}).get(arch) + if not source: + return redirect_with_message("/disk-images", "download_error", f"{entry['title']} не выпускается для процессора {arch}") + try: + operation = start_download(source["url"], "disk", title=f"Скачивание {entry['title']}", sums_url=source.get("sums"), algo=source.get("algo"), catalog_id=image_id) + except DownloadError as exc: + return redirect_with_message("/disk-images", "download_error", str(exc)) + return redirect_with_message("/disk-images", "download_message", f"Скачиваем {entry['title']} ({operation['filename']}) — после загрузки проверим контрольную сумму") + + +@router.post("/downloads/{operation_id}/cancel") +def cancel_download(request: Request, operation_id: str): + auth = require_auth(request) + if auth: + return auth + operation, error = request_cancel(operation_id) + page = page_for(str(operation.get("download_kind"))) if operation else "/iso" + if error: + return redirect_with_message(page, "download_error", error) + return redirect_with_message(page, "download_message", f"Останавливаем скачивание {operation.get('filename', '')}".rstrip()) + + +@router.post("/api/operations/{operation_id}/cancel") +def api_cancel_operation(request: Request, operation_id: str): + auth = require_api_auth(request) + if auth: + return auth + operation, error = request_cancel(operation_id) + if operation is None: + return JSONResponse({"ok": False, "error": error}, status_code=404) + if error: + return JSONResponse({"ok": False, "error": error}, status_code=400 if operation.get("type") != "download" else 409) + return {"ok": True, "operation": read_operation(operation_id) or operation} + + +# ---------------------------------------------------------------- templates +def register_template_globals() -> None: + """Функция downloads_panel нужна шаблонам iso.html и disk_images.html, которые + рендерит app.py своим окружением Jinja; регистрируем её и в core, и в app. + + Импорт app здесь циклический, но безопасный: app.py подключает этот модуль + в самом конце, когда его `templates` уже создан, а наш `router` — уже определён.""" + import app + + for env in {id(item): item for item in (core.templates.env, app.templates.env)}.values(): + env.globals["downloads_panel"] = downloads_panel + + +register_template_globals() diff --git a/web/presenters.py b/web/presenters.py index 89e525e..a8ec65e 100644 --- a/web/presenters.py +++ b/web/presenters.py @@ -242,3 +242,12 @@ def plural(count: int, one: str, few: str, many: str) -> str: def port_service(port: int) -> str: return WELL_KNOWN_PORTS.get(int(port or 0), "") + + +def format_bytes(value: int | float) -> str: + value = float(value or 0) + for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"): + if value < 1024 or unit == "ТБ": + return f"{value:.0f} {unit}" if value >= 10 or unit == "Б" else f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.0f} ТБ" diff --git a/web/static/app.css b/web/static/app.css index 33b98fa..7cd3560 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -584,3 +584,12 @@ details.advanced > .advanced-body { padding: 4px 16px 16px; } .hide-sm { display: none !important; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } + +/* ---------------------------------------------------------- downloads */ +.downloads { margin-bottom: 20px; } +.download-item .download-pct { flex: none; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; } +.download-item[data-status="error"] .download-pct { color: var(--danger); } +.download-item[data-status="success"] .download-pct { color: var(--success); } +.download-error { color: var(--danger); } +.catalog-item .meta code { padding: 1px 5px; border-radius: 5px; background: var(--surface-3); font: 12px/1.4 var(--mono); color: var(--text-2); } +@media (max-width: 640px) { .download-item, .catalog-item { flex-wrap: wrap; } .download-item .grow, .catalog-item .grow { flex-basis: calc(100% - 50px); } } diff --git a/web/templates/_downloads_active.html b/web/templates/_downloads_active.html new file mode 100644 index 0000000..0e8ecff --- /dev/null +++ b/web/templates/_downloads_active.html @@ -0,0 +1,71 @@ +{#- Идущие скачивания (операции типа download) с живым прогрессом. Ожидает переменную kind ('iso' | 'disk'). -#} +{% from "_ui.html" import icon %} +{% set dl = downloads_panel(kind) %} +{% if dl.active %} +
+
+

Скачиваем на сервер

Страницу можно закрыть — загрузка продолжится. Когда файл будет готов, он появится в списке.

+ {{ icon('list-checks', 'icon-sm') }}Все задачи +
+
+ {% for op in dl.active %} +
+ {{ icon('download') }} +
+
+
{{ op.filename }}
+ {{ op.progress|default(0) }}% +
+
+
{{ op.message }}
+
+ Подробнее +
+ +
+
+ {% endfor %} +
+
+ +{% endif %} diff --git a/web/templates/_downloads_catalog.html b/web/templates/_downloads_catalog.html new file mode 100644 index 0000000..842e353 --- /dev/null +++ b/web/templates/_downloads_catalog.html @@ -0,0 +1,36 @@ +{#- Карточка «Каталог готовых систем» для страницы /disk-images. -#} +{% from "_ui.html" import icon, badge %} +{% set dl = downloads_panel('disk') %} +
+
+

Каталог готовых систем

Официальные образы для вашего процессора. Скачаем на сервер и проверим контрольную сумму — останется только создать машину.

+
+ {% if dl.catalog %} +
+ {% for item in dl.catalog %} +
+ {{ icon('circle-check' if item.status == 'ready' else 'download' if item.status == 'downloading' else 'box') }} +
+
+ {{ item.title }} + {% if item.recommended %}{{ badge('Рекомендуем', 'brand', dot=False) }}{% endif %} + {% if item.status == 'ready' %}{{ badge('Загружен', 'success') }}{% elif item.status == 'downloading' %}{{ badge('Скачивается', 'info') }}{% endif %} +
+
{{ item.hint }}{% if item.login %} · вход: {{ item.login }}{% endif %}
+
+ {% if item.status == 'ready' %} + {{ icon('plus', 'icon-sm') }}Создать машину + {% elif item.status == 'downloading' %} + Прогресс{{ icon('chevron-right', 'icon-sm') }} + {% else %} +
+ +
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

Для процессора этого сервера готовых образов в каталоге нет. Можно скачать образ по ссылке или загрузить с компьютера.

+ {% endif %} +
diff --git a/web/templates/_downloads_flash.html b/web/templates/_downloads_flash.html new file mode 100644 index 0000000..d8bb2a6 --- /dev/null +++ b/web/templates/_downloads_flash.html @@ -0,0 +1,4 @@ +{#- Сообщения после запуска/отмены скачивания (download_message / download_error в query string). -#} +{% from "_ui.html" import alert %} +{% if request.query_params.get('download_message') %}{{ alert('success', request.query_params.get('download_message')) }}{% endif %} +{% if request.query_params.get('download_error') %}{{ alert('danger', request.query_params.get('download_error')) }}{% endif %} diff --git a/web/templates/_downloads_url.html b/web/templates/_downloads_url.html new file mode 100644 index 0000000..47fde93 --- /dev/null +++ b/web/templates/_downloads_url.html @@ -0,0 +1,17 @@ +{#- Карточка «Скачать по ссылке». Ожидает переменную kind ('iso' | 'disk'). -#} +{% from "_ui.html" import icon %} +
+

Скачать по ссылке

Сервер скачает файл сам — не нужно гонять его через компьютер

+
+
+ +
+ + + {{ 'Ссылка должна вести на файл .iso — например, со страницы загрузки Ubuntu или Debian.' if kind == 'iso' else 'Подойдут файлы .qcow2, .img, .raw и сжатые .img.xz — распакуем и подготовим сами.' }} +
+
+
+
+
diff --git a/web/templates/disk_images.html b/web/templates/disk_images.html index 146a16b..b068b07 100644 --- a/web/templates/disk_images.html +++ b/web/templates/disk_images.html @@ -3,9 +3,14 @@ {% set page_title = 'Готовые системы' %} {% block page_header %}{% include "_images_header.html" %}{% endblock %} +{% block alerts %}{{ super() }}{% include "_downloads_flash.html" %}{% endblock %} {% block content %} +{% set kind = 'disk' %} +{% include "_downloads_active.html" %}
+
+ {% include "_downloads_catalog.html" %}

Готовые системы

Образы уже установленных систем — машина запускается сразу, без установки

{% if images %} @@ -22,9 +27,10 @@ {% endfor %}
{% else %} - {{ empty('hard-drive', 'Готовых образов пока нет', 'Подойдут облачные образы Ubuntu и Debian, образы для Raspberry Pi, диски других систем виртуализации.') }} + {{ empty('hard-drive', 'Готовых образов пока нет', 'Выберите систему в каталоге выше — или загрузите свой образ: облачные Ubuntu и Debian, образы для Raspberry Pi, диски других систем виртуализации.') }} {% endif %} +
From c2dc32947f3c608e60d63f7acb852b2f16d6bf37 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:31:12 +0000 Subject: [PATCH 25/32] =?UTF-8?q?=D0=A1=D0=BA=D0=B0=D1=87=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B1=D1=8B=D0=BB=D0=BE=20=D0=B4=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=D0=BE=20=D0=BF=D0=BE=20=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=D0=B3=D0=B0=D0=BC=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?:=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D1=88=D0=B8=D0=B5=20=D0=BE?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2,=20=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B5=20?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B0=D1=81=D0=BF=D0=B0=D0=BA=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Проверка «уже скачивается» идёт по всем операциям каталога (glob), а не по последним 50; скачивания без живого потока закрываются при старте (reap_stale_downloads), их .part удаляются, а отмена такой операции завершает её сразу, не дожидаясь потока. - Два запуска одного файла больше не пишут в один .part: проверки и создание операции под замком, файл создаётся монопольно (O_EXCL), вторая копия завершается ошибкой, не трогая чужой файл; осиротевший .part без владельца убирается перед новым запуском. - Отмена во время проверки контрольной суммы и распаковки .img.xz останавливает работу: операция завершается как отменённая, недокачанный файл, архив и недораспакованный образ удаляются. - Битый .img.xz после неудачной распаковки удаляется с понятным сообщением, повторное скачивание разрешено; проверка «файл уже есть» учитывает распакованный .img и qcow2 после конвертации. - Ссылки с кириллицей (домен IDNA, путь и параметры в процентах) открываются, некорректный порт и непонятные символы отклоняются сразу; ошибки сети описаны словами, исходный текст — в журнале. - Прогресс раз в секунду перечитывает JSON операции, чтобы не затирать cancel_requested и замечать отмену из другого процесса; операции читаются один раз на страницу; отмена в карточке — не ошибка. - Формулировки карточек и сообщения о недоступной архитектуре упрощены; добавлены тесты на каждое поведение. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- tests/test_downloads.py | 292 +++++++++++++++++++-- web/features/downloads.py | 359 +++++++++++++++++++++----- web/templates/_downloads_active.html | 18 +- web/templates/_downloads_catalog.html | 8 +- web/templates/_downloads_url.html | 2 +- web/templates/disk_images.html | 1 + web/templates/iso.html | 1 + 7 files changed, 585 insertions(+), 96 deletions(-) diff --git a/tests/test_downloads.py b/tests/test_downloads.py index fc1527d..e37941f 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -2,8 +2,11 @@ проверка ссылок, фоновая загрузка с локального http-сервера, отмена.""" import hashlib import lzma +import socket +import ssl import threading import time +import urllib.error from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from types import SimpleNamespace @@ -19,10 +22,12 @@ import features.downloads as downloads FIXTURES = Path(__file__).resolve().parent / "fixtures" MB = 1024 * 1024 SLOW_TOTAL = 4 * MB +CHUNKED_TOTAL = 40 * 64 * 1024 class Handler(SimpleHTTPRequestHandler): - """Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет.""" + """Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет, + /chunked.iso — 2,5 МБ с паузами без заголовка Content-Length (размер заранее неизвестен).""" root = "" @@ -38,13 +43,15 @@ class Handler(SimpleHTTPRequestHandler): self.send_header("Location", "/small.iso") self.end_headers() return - if self.path == "/slow.iso": + if self.path in ("/slow.iso", "/chunked.iso"): + total = SLOW_TOTAL if self.path == "/slow.iso" else CHUNKED_TOTAL self.send_response(200) self.send_header("Content-Type", "application/octet-stream") - self.send_header("Content-Length", str(SLOW_TOTAL)) + if self.path == "/slow.iso": + self.send_header("Content-Length", str(total)) self.end_headers() try: - for _ in range(SLOW_TOTAL // (64 * 1024)): + for _ in range(total // (64 * 1024)): self.wfile.write(b"\0" * 64 * 1024) self.wfile.flush() time.sleep(0.05) @@ -87,6 +94,18 @@ def wait_until(predicate, timeout: float = 10.0) -> None: raise AssertionError("условие не наступило вовремя") +def message_of(operation_id: str) -> str: + return str((core.read_operation(operation_id) or {}).get("message", "")) + + +def seed_running_download(kind: str, filename: str, **meta) -> dict: + """Операция «скачивается» без потока — как после перезапуска панели.""" + target = downloads.target_path(kind, filename) + operation = core.new_operation("download", f"Скачивание {filename}", download_kind=kind, url=f"http://127.0.0.1:9/{filename}", filename=filename, target_path=str(target), cancel_requested=False, **meta) + core.update_operation(operation, status="running") + return operation + + # ---------------------------------------------------------------- parsers & formatting def test_parse_checksums_ubuntu_star_format(): text = (FIXTURES / "SHA256SUMS").read_text() @@ -130,12 +149,35 @@ def test_progress_message(): # ---------------------------------------------------------------- validation -@pytest.mark.parametrize("url", ["", "ftp://example.org/x.iso", "file:///etc/passwd", "javascript:alert(1)", "http://", "http:///x.iso", "http://example.org/a b.iso"]) +@pytest.mark.parametrize("url", ["", "ftp://example.org/x.iso", "file:///etc/passwd", "javascript:alert(1)", "http://", "http:///x.iso", "http://example.org/a b.iso", "http://example.org:abc/x.iso", "http://[::1/x.iso"]) def test_validate_url_rejects(url): with pytest.raises(downloads.DownloadError): downloads.validate_url(url) +def test_encode_url_handles_cyrillic_and_keeps_encoded_links(): + encoded = downloads.encode_url("http://пример.рф:8080/папка/образ диска.iso?имя=ы#x") + assert encoded == "http://xn--e1afmkfd.xn--p1ai:8080/%D0%BF%D0%B0%D0%BF%D0%BA%D0%B0/%D0%BE%D0%B1%D1%80%D0%B0%D0%B7%20%D0%B4%D0%B8%D1%81%D0%BA%D0%B0.iso?%D0%B8%D0%BC%D1%8F=%D1%8B" + assert downloads.encode_url(encoded) == encoded + for same in ("https://user:pw@h.example/a%20b/x.iso?a=1&b=%2F", "http://[::1]:9/x.iso", "http://127.0.0.1/x.iso"): + assert downloads.encode_url(same) == same + assert downloads.validate_url("https://пример.рф/образ.iso") == "https://пример.рф/образ.iso" + + +def test_describe_network_error_is_friendly(): + describe = downloads.describe_network_error + assert describe(urllib.error.HTTPError("u", 404, "Not Found", {}, None)) == "Файл по ссылке не найден (ошибка 404)" + assert describe(urllib.error.HTTPError("u", 403, "Forbidden", {}, None)) == "Сервер запретил скачивание (ошибка 403)" + assert describe(urllib.error.HTTPError("u", 418, "Teapot", {}, None)) == "Сервер ответил ошибкой 418" + assert describe(urllib.error.URLError(socket.gaierror(-2, "Name or service not known"))) == "Не удалось найти сервер по этой ссылке — проверьте адрес" + assert describe(urllib.error.URLError(ConnectionRefusedError(111, "Connection refused"))) == "Сервер отказал в подключении" + assert describe(urllib.error.URLError(ssl.SSLCertVerificationError("CERTIFICATE_VERIFY_FAILED"))) == "У сервера проблема с сертификатом безопасности" + assert describe(urllib.error.URLError(TimeoutError())).startswith("Сервер не отвечает") + assert describe(UnicodeEncodeError("ascii", "образ", 0, 5, "ordinal not in range(128)")) == "Ссылка содержит символы, которые сервер не понимает" + assert describe(ConnectionResetError(104, "reset")) == "Сервер разорвал соединение" + assert describe(RuntimeError("boom")) == "boom" + + def test_filename_from_url_sanitises_and_checks_suffix(): assert downloads.filename_from_url("https://h.example/a%20b%20(1).ISO?x=1#frag", "iso") == "a-b-_1.iso" assert downloads.filename_from_url("https://h.example/dir/debian-13.qcow2", "disk") == "debian-13.qcow2" @@ -152,6 +194,28 @@ def test_start_download_refuses_existing_file(data_dirs): downloads.start_download("http://127.0.0.1:9/small.iso", "iso") +def test_start_download_refuses_when_extracted_image_exists(data_dirs): + # Из pi.img.xz после скачивания остаётся pi.img (или pi.qcow2 после конвертации) — это тот же образ. + (data_dirs["disk_images"] / "pi.img").write_bytes(b"x") + with pytest.raises(downloads.DownloadError, match="pi.img уже есть"): + downloads.start_download("http://127.0.0.1:9/pi.img.xz", "disk") + (data_dirs["disk_images"] / "pi.img").unlink() + (data_dirs["disk_images"] / "pi.qcow2").write_bytes(b"x") + with pytest.raises(downloads.DownloadError, match="pi.qcow2 уже есть"): + downloads.start_download("http://127.0.0.1:9/pi.img", "disk") + + +def test_start_download_refuses_when_already_downloading(data_dirs, monkeypatch): + seed_running_download("iso", "small.iso") + with pytest.raises(downloads.DownloadError, match="уже скачивается"): + downloads.start_download("http://127.0.0.1:9/small.iso", "iso") + # проверка идёт по всем операциям, а не по последним 50 + for index in range(60): + core.new_operation("vm_create", f"Создание VM {index}") + with pytest.raises(downloads.DownloadError, match="уже скачивается"): + downloads.start_download("http://127.0.0.1:9/small.iso", "iso") + + def test_start_download_checks_free_space(data_dirs, monkeypatch): monkeypatch.setattr(downloads.shutil, "disk_usage", lambda path: SimpleNamespace(free=1000, total=1000, used=0)) with pytest.raises(downloads.DownloadError, match="мало места"): @@ -178,6 +242,7 @@ def test_download_iso_success_refreshes_pool(data_dirs, www, monkeypatch): assert not (data_dirs["iso"] / ".small.iso.part").exists() assert ["virsh", "pool-refresh", "virtuality-iso"] in calls assert "Готово" in done["message"] + assert not downloads.is_live(operation["id"]) def test_download_follows_redirect(data_dirs, www): @@ -188,11 +253,22 @@ def test_download_follows_redirect(data_dirs, www): assert "Перенаправление" in done["log_tail"] +def test_download_with_cyrillic_file_name_in_url(data_dirs, www): + (www.root / "образ.iso").write_bytes(b"cyrillic" * 10) + operation = downloads.start_download(f"{www.url}/образ.iso", "iso") + assert operation["filename"] == "virtuality-iso.iso" + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert (data_dirs["iso"] / "virtuality-iso.iso").read_bytes() == b"cyrillic" * 10 + assert "Перенаправление" not in done["log_tail"] + + def test_download_missing_file_fails(data_dirs, www): operation = downloads.start_download(f"{www.url}/nope.iso", "iso") done = wait_for(operation["id"]) assert done["status"] == "error" - assert "404" in done["message"] + assert done["message"] == "Файл по ссылке не найден (ошибка 404)" + assert "HTTP Error 404" in done["log_tail"] # исходный текст остаётся в журнале assert not list(data_dirs["iso"].iterdir()) @@ -212,6 +288,24 @@ def test_download_refuses_when_disk_fills_up(data_dirs, www, monkeypatch): assert not list(data_dirs["iso"].iterdir()) +def test_download_without_content_length_stops_when_disk_fills_up(data_dirs, www, monkeypatch): + real = downloads.free_space_ok + seen = [] + + def fake(directory, needed): + seen.append(needed) + return real(directory, needed) if len(seen) < 3 else (False, 42) + + monkeypatch.setattr(downloads, "free_space_ok", fake) + operation = downloads.start_download(f"{www.url}/chunked.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "закончилось место" in done["message"] and "42 Б" in done["message"] + assert done["total_bytes"] is None and seen == [None, None, None] + assert not (data_dirs["iso"] / ".chunked.iso.part").exists() + assert not list(data_dirs["iso"].iterdir()) + + def test_download_cancel(data_dirs, www, logged_in): operation = downloads.start_download(f"{www.url}/slow.iso", "iso") wait_until(lambda: (core.read_operation(operation["id"]) or {}).get("status") == "running") @@ -224,7 +318,77 @@ def test_download_cancel(data_dirs, www, logged_in): assert done["cancelled"] is True and done["cancel_requested"] is True assert not (data_dirs["iso"] / ".slow.iso.part").exists() assert not (data_dirs["iso"] / "slow.iso").exists() - assert not downloads.cancel_requested(operation["id"]) + assert not downloads.cancel_requested(operation["id"]) and not downloads.is_live(operation["id"]) + + +def test_cancel_flag_on_disk_is_honoured_and_not_overwritten(data_dirs, www): + # Отмену могла принять другая копия панели: поток видит флаг в JSON операции раз в секунду. + operation = downloads.start_download(f"{www.url}/slow.iso", "iso") + wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists()) + fresh = core.read_operation(operation["id"]) + fresh.pop("log_tail", None) + core.update_operation(fresh, cancel_requested=True, message="Останавливаем скачивание…") + done = wait_for(operation["id"]) + assert done["status"] == "error" and done["cancelled"] is True and done["cancel_requested"] is True + assert not (data_dirs["iso"] / ".slow.iso.part").exists() + + +def test_second_download_of_same_file_does_not_touch_the_first(data_dirs, www): + first = downloads.start_download(f"{www.url}/slow.iso", "iso") + part = data_dirs["iso"] / ".slow.iso.part" + wait_until(part.exists) + # Второй запуск, проскочивший проверку «уже скачивается» (например, из другого процесса): операцию создаём руками. + second = core.new_operation("download", "Скачивание slow.iso", download_kind="iso", url=f"{www.url}/slow.iso", filename="slow.iso", target_path=first["target_path"], cancel_requested=False) + core.update_operation(second, status="running") + downloads.download_worker(second) + done = core.read_operation(second["id"]) + assert done["status"] == "error" and "уже скачивается" in done["message"] + assert part.exists() # чужой файл не тронут + assert wait_for(first["id"])["status"] == "success" + assert (data_dirs["iso"] / "slow.iso").stat().st_size == SLOW_TOTAL + + +def test_leftover_part_file_without_owner_does_not_block_download(data_dirs, www): + part = data_dirs["iso"] / ".small.iso.part" + part.write_bytes(b"half of a crashed download") + operation = downloads.start_download(f"{www.url}/small.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000 + assert not part.exists() + + +def test_stale_downloads_are_reaped_on_startup(data_dirs, www, monkeypatch): + stale = seed_running_download("iso", "small.iso") + part = downloads.part_path(Path(stale["target_path"])) + part.write_bytes(b"half") + (data_dirs["disk_images"] / ".orphan.img.part").write_bytes(b"x") + live = downloads.start_download(f"{www.url}/slow.iso", "iso") + wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists()) + + assert downloads.reap_stale_downloads() == 1 + closed = core.read_operation(stale["id"]) + assert closed["status"] == "error" and closed["interrupted"] is True and "перезапущена" in closed["message"] + assert not part.exists() and not (data_dirs["disk_images"] / ".orphan.img.part").exists() + assert (data_dirs["iso"] / ".slow.iso.part").exists() # живое скачивание не трогаем + assert wait_for(live["id"])["status"] == "success" + # после закрытия зависшей операции тот же файл можно скачать снова + done = wait_for(downloads.start_download(f"{www.url}/small.iso", "iso")["id"]) + assert done["status"] == "success" and (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000 + assert downloads.close_stale_downloads() is None + + +def test_cancel_of_stale_download_finishes_it_immediately(data_dirs, logged_in): + stale = seed_running_download("disk", "y.qcow2") + part = downloads.part_path(Path(stale["target_path"])) + part.write_bytes(b"x") + response = logged_in.post(f"/api/operations/{stale['id']}/cancel") + operation = response.json()["operation"] + assert response.status_code == 200 and operation["status"] == "error" and operation["cancelled"] is True + assert operation["message"] == "Скачивание отменено" and operation["cancel_requested"] is True + assert not part.exists() + assert not downloads.cancel_requested(stale["id"]) + assert logged_in.post(f"/api/operations/{stale['id']}/cancel").status_code == 409 def test_catalog_download_verifies_checksum(data_dirs, www): @@ -258,6 +422,28 @@ def test_catalog_download_fails_without_sums_entry(data_dirs, www): assert not list(data_dirs["disk_images"].iterdir()) +def test_cancel_during_checksum_verification_removes_file(data_dirs, www, logged_in, monkeypatch): + payload = b"QCOW2 image bytes" * 100 + (www.root / "disk.qcow2").write_bytes(payload) + (www.root / "SHA256SUMS").write_text(f"{hashlib.sha256(payload).hexdigest()} *disk.qcow2\n") + + def slow_digest(path, algo, on_progress=None): + for _ in range(200): + time.sleep(0.05) + if on_progress: + on_progress(1) + return hashlib.sha256(payload).hexdigest() + + monkeypatch.setattr(downloads, "file_digest", slow_digest) + operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256") + wait_until(lambda: "Проверяем контрольную сумму" in message_of(operation["id"])) + assert logged_in.post(f"/api/operations/{operation['id']}/cancel").status_code == 200 + done = wait_for(operation["id"]) + assert done["status"] == "error" and done["cancelled"] is True and done["message"] == "Скачивание отменено" + assert "во время проверки" in done["log_tail"] + assert not list(data_dirs["disk_images"].iterdir()) + + def test_disk_download_extracts_xz_and_starts_conversion(data_dirs, www, monkeypatch): raw = bytes(range(256)) * 256 (www.root / "pi.img.xz").write_bytes(lzma.compress(raw)) @@ -273,6 +459,55 @@ def test_disk_download_extracts_xz_and_starts_conversion(data_dirs, www, monkeyp assert "qcow2" in done["message"] +def test_cancel_during_unpack_removes_archive_and_partial_image(data_dirs, www, logged_in, monkeypatch): + raw = bytes(range(256)) * 4096 # 1 МБ, распаковка кусками по 16 КБ с паузой — успеваем отменить + (www.root / "pi.img.xz").write_bytes(lzma.compress(raw)) + real = downloads.extract_xz_image + + def slow_extract(compressed, should_stop=None): + def stop(): + time.sleep(0.05) + return bool(should_stop and should_stop()) + + return real(compressed, stop) + + started = [] + monkeypatch.setattr(downloads, "extract_xz_image", slow_extract) + monkeypatch.setattr(downloads, "CHUNK_SIZE", 16 * 1024) + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path)) + operation = downloads.start_download(f"{www.url}/pi.img.xz", "disk") + wait_until(lambda: "Распаковываем" in message_of(operation["id"])) + assert logged_in.post(f"/api/operations/{operation['id']}/cancel").status_code == 200 + done = wait_for(operation["id"]) + assert done["status"] == "error" and done["cancelled"] is True and done["message"] == "Скачивание отменено" + assert "Распаковка остановлена" in done["log_tail"] + assert not list(data_dirs["disk_images"].iterdir()) and started == [] + + +def test_corrupt_xz_archive_is_removed_and_can_be_downloaded_again(data_dirs, www, monkeypatch): + (www.root / "bad.img.xz").write_bytes(b"definitely not an xz archive" * 100) + started = [] + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path)) + done = wait_for(downloads.start_download(f"{www.url}/bad.img.xz", "disk")["id"]) + assert done["status"] == "error" + assert done["message"] == "Не удалось распаковать скачанный архив — файл повреждён и удалён. Попробуйте скачать ещё раз" + assert "Ошибка распаковки" in done["log_tail"] + assert not list(data_dirs["disk_images"].iterdir()) and started == [] + # архив убран, поэтому повторное скачивание не упирается в «уже есть на сервере» + assert wait_for(downloads.start_download(f"{www.url}/bad.img.xz", "disk")["id"])["status"] == "error" + + +def test_extract_xz_image_stops_and_cleans_up(data_dirs): + raw = bytes(range(256)) * 1024 + compressed = data_dirs["disk_images"] / "pi.img.xz" + compressed.write_bytes(lzma.compress(raw)) + assert downloads.extract_xz_image(compressed).read_bytes() == raw + (data_dirs["disk_images"] / "pi.img").unlink() + with pytest.raises(downloads.DownloadCancelled): + downloads.extract_xz_image(compressed, lambda: True) + assert sorted(item.name for item in data_dirs["disk_images"].iterdir()) == ["pi.img.xz"] + + def test_qcow2_download_needs_no_conversion(data_dirs, www, monkeypatch): (www.root / "ready.qcow2").write_bytes(b"q" * 10) monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None) @@ -282,13 +517,17 @@ def test_qcow2_download_needs_no_conversion(data_dirs, www, monkeypatch): # ---------------------------------------------------------------- routes -def test_url_route_starts_download(logged_in, data_dirs, www): +def test_url_route_starts_download(logged_in, data_dirs, www, monkeypatch): + # The worker is started by hand below: a 15 KB file would otherwise finish before the checks. + pending = {} + monkeypatch.setattr(downloads, "run_operation", lambda operation, worker: pending.update(operation=operation, worker=worker)) response = logged_in.post("/downloads/url", data={"url": f"{www.url}/small.iso", "kind": "iso"}, follow_redirects=False) assert response.status_code == 303 location = unquote(response.headers["location"]) assert location.startswith("/iso?download_message=") and "small.iso" in location running = downloads.active_downloads("iso") assert len(running) == 1 and running[0]["filename"] == "small.iso" + core.run_operation(pending["operation"], pending["worker"]) assert wait_for(running[0]["id"])["status"] == "success" assert (data_dirs["iso"] / "small.iso").exists() @@ -299,7 +538,7 @@ def test_url_route_rejects_bad_links(logged_in, data_dirs): assert "download_error=" in response.headers["location"] and response.headers["location"].startswith("/iso?") response = logged_in.post("/downloads/url", data={"url": "https://example.org/setup.exe", "kind": "disk"}, follow_redirects=False) assert response.headers["location"].startswith("/disk-images?download_error=") - assert not core.running_operations("download") + assert not core.running_operations("download") and not downloads.unfinished_downloads() def test_url_route_requires_login(client): @@ -340,7 +579,7 @@ def test_catalog_route_skips_missing_arch(logged_in, data_dirs, monkeypatch): monkeypatch.setattr(catalog, "CLOUD_IMAGES", [{"id": "only-arm", "title": "ARM only", "arches": {"aarch64": {"url": "https://example.org/a.qcow2"}}}]) monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64") location = unquote(logged_in.post("/downloads/catalog/only-arm", follow_redirects=False).headers["location"]) - assert "не выпускается для процессора x86_64" in location + assert "нет версии для этого сервера (x86_64)" in location assert downloads.catalog_entries() == [] assert "готовых образов в каталоге нет" in logged_in.get("/disk-images").text @@ -353,33 +592,46 @@ def test_cancel_api_rules(logged_in, data_dirs): finished = core.new_operation("download", "Скачивание x", download_kind="iso", filename="x.iso") core.finish_operation(finished, True, "Готово") assert logged_in.post(f"/api/operations/{finished['id']}/cancel").status_code == 409 + # операция без потока и без файла (панель перезапускалась) закрывается сразу running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2") core.update_operation(running, status="running") response = logged_in.post(f"/api/operations/{running['id']}/cancel") - assert response.status_code == 200 and response.json()["operation"]["cancel_requested"] is True - assert downloads.cancel_requested(running["id"]) - downloads.clear_cancel(running["id"]) + operation = response.json()["operation"] + assert response.status_code == 200 and operation["cancel_requested"] is True and operation["status"] == "error" and operation["cancelled"] is True + assert not downloads.cancel_requested(running["id"]) -def test_cancel_form_redirects_to_page(logged_in, data_dirs): - running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2") - core.update_operation(running, status="running") - response = logged_in.post(f"/downloads/{running['id']}/cancel", follow_redirects=False) +def test_cancel_form_redirects_to_page(logged_in, data_dirs, www): + stale = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2") + core.update_operation(stale, status="running") + response = logged_in.post(f"/downloads/{stale['id']}/cancel", follow_redirects=False) assert response.status_code == 303 - assert unquote(response.headers["location"]).startswith("/disk-images?download_message=Останавливаем скачивание y.qcow2") + assert unquote(response.headers["location"]).startswith("/disk-images?download_message=Скачивание y.qcow2 отменено") + assert core.read_operation(stale["id"])["status"] == "error" + live = downloads.start_download(f"{www.url}/slow.iso", "iso") + response = logged_in.post(f"/downloads/{live['id']}/cancel", follow_redirects=False) + assert unquote(response.headers["location"]).startswith("/iso?download_message=Останавливаем скачивание slow.iso") + assert wait_for(live["id"])["cancelled"] is True assert unquote(logged_in.post("/downloads/nope/cancel", follow_redirects=False).headers["location"]).startswith("/iso?download_error=") - downloads.clear_cancel(running["id"]) -def test_pages_show_download_cards(logged_in, data_dirs): +def test_pages_show_download_cards(logged_in, data_dirs, monkeypatch): + reads = [] + real = downloads.unfinished_downloads + monkeypatch.setattr(downloads, "unfinished_downloads", lambda: reads.append(1) or real()) iso_page = logged_in.get("/iso").text assert "Скачать по ссылке" in iso_page and 'name="kind" value="iso"' in iso_page assert "Скачиваем на сервер" not in iso_page + assert "не нужно сначала загружать его на компьютер" in iso_page disk_page = logged_in.get("/disk-images").text assert "Каталог готовых систем" in disk_page and "Рекомендуем" in disk_page and 'name="kind" value="disk"' in disk_page + assert "подходящие этому серверу" in disk_page + assert len(reads) == 2 # каталог операций читается один раз на страницу, а не в каждой вставке running = core.new_operation("download", "Скачивание big.iso", download_kind="iso", filename="big.iso", url="https://example.org/big.iso", total_bytes=None) core.update_operation(running, status="running", progress=0, message="Подключаемся к серверу…") iso_page = logged_in.get("/iso?download_message=Скачиваем").text assert "Скачиваем на сервер" in iso_page and "big.iso" in iso_page and "indeterminate" in iso_page assert f"/downloads/{running['id']}/cancel" in iso_page and "Скачиваем" in iso_page + # отмена — не ошибка: без тоста при отправке формы, опрос показывает её как предупреждение + assert 'data-toast="Скачивание отменено"' not in iso_page and "op.cancelled" in iso_page assert "big.iso" not in logged_in.get("/disk-images").text diff --git a/web/features/downloads.py b/web/features/downloads.py index 4c13ab3..1fc98c0 100644 --- a/web/features/downloads.py +++ b/web/features/downloads.py @@ -7,15 +7,19 @@ образов из каталога). Скачивание можно отменить — недокачанный файл удаляется. """ import hashlib +import json +import lzma import re import shutil +import socket +import ssl import threading import time import urllib.error import urllib.request from pathlib import Path -from typing import Any -from urllib.parse import unquote, urlsplit +from typing import Any, Callable +from urllib.parse import quote, unquote, urlsplit, urlunsplit from fastapi import APIRouter, Form, Request from fastapi.responses import JSONResponse @@ -34,7 +38,6 @@ from core import ( require_auth, run_cmd, run_operation, - running_operations, update_operation, ) @@ -52,16 +55,44 @@ KINDS: dict[str, dict[str, Any]] = { "disk": {"suffixes": (".qcow2", ".img", ".raw", ".img.xz"), "page": "/disk-images", "label": "образ диска", "hint": "Подойдут файлы .qcow2, .img, .raw и сжатые .img.xz"}, } -# Отмена: идентификаторы операций, которые попросили остановить. Поток -# загрузки проверяет множество между кусками файла. -CANCEL_LOCK = threading.Lock() +# Состояние скачиваний этого процесса (под одним замком): +# - CANCEL_REQUESTS — операции, которые попросили остановить; поток загрузки +# проверяет множество между кусками файла, при проверке суммы и распаковке; +# - LIVE_DOWNLOADS — операция → путь к файлу для скачиваний, которыми владеет +# живой поток. Операция без потока (после перезапуска панели) — сирота: её +# закрывают сразу, ничего не ожидая. +# Множества живут в памяти одного процесса: панель запускается с одним воркером +# uvicorn. На случай нескольких процессов поток раз в секунду читает флаг +# cancel_requested и из JSON операции. +STATE_LOCK = threading.Lock() CANCEL_REQUESTS: set[str] = set() +LIVE_DOWNLOADS: dict[str, str] = {} +# Проверки «файл уже есть» / «уже скачивается» и создание операции идут под одним +# замком, чтобы два запуска одного файла не прошли проверку одновременно. +START_LOCK = threading.Lock() + +# Понятные объяснения частых ошибок HTTP; остальные коды показываем числом. +HTTP_ERRORS = { + 401: "Сервер требует вход по паролю (ошибка 401)", + 403: "Сервер запретил скачивание (ошибка 403)", + 404: "Файл по ссылке не найден (ошибка 404)", + 410: "Файл по ссылке больше не выдаётся (ошибка 410)", + 429: "Сервер просит подождать: слишком много запросов (ошибка 429)", + 500: "На сервере произошла ошибка (500)", + 502: "Сервер временно недоступен (ошибка 502)", + 503: "Сервер временно недоступен (ошибка 503)", + 504: "Сервер временно недоступен (ошибка 504)", +} class DownloadError(ValueError): """Понятная пользователю ошибка проверки ссылки или файла.""" +class DownloadCancelled(Exception): + """Пользователь отменил скачивание во время проверки суммы или распаковки.""" + + # ---------------------------------------------------------------- formatting def human_size(value: float | int | None) -> str: """1234567 → «1,2 МБ». Десятичная запятая, как принято в русском.""" @@ -107,14 +138,40 @@ def validate_url(url: str) -> str: raise DownloadError("Вставьте ссылку на файл") if len(url) > 2048 or any(ch.isspace() for ch in url): raise DownloadError("Ссылка выглядит некорректно") - parts = urlsplit(url) + try: + parts = urlsplit(url) + except ValueError as exc: + raise DownloadError("Ссылка выглядит некорректно") from exc if parts.scheme.lower() not in ("http", "https"): raise DownloadError("Ссылка должна начинаться с http:// или https://") if not parts.hostname: raise DownloadError("В ссылке не указан адрес сервера") + encode_url(url) # кириллица в домене или пути, порт — проверяем сразу, а не в фоне return url +def encode_url(url: str) -> str: + """Ссылка в виде, который понимает urllib: домен с кириллицей — в IDNA, не-ASCII + символы в пути и параметрах — в проценты. Уже закодированные ссылки не меняются.""" + parts = urlsplit(url) + try: + port = parts.port + except ValueError as exc: + raise DownloadError("Ссылка выглядит некорректно") from exc + try: + host = (parts.hostname or "").encode("idna").decode("ascii") + except UnicodeError as exc: + raise DownloadError("Ссылка содержит символы, которые сервер не понимает") from exc + netloc = f"[{host}]" if ":" in host else host + if port: + netloc = f"{netloc}:{port}" + if "@" in parts.netloc: + netloc = f"{parts.netloc.rsplit('@', 1)[0]}@{netloc}" + path = quote(parts.path, safe="/%:@!$&'()*+,;=~") + query = quote(parts.query, safe="/%:@!$&'()*+,;=?~") + return urlunsplit((parts.scheme.lower(), netloc, path, query, "")) + + def filename_from_url(url: str, kind: str) -> str: """Имя файла из ссылки, приведённое к безопасному виду (как при загрузке с компьютера).""" if kind not in KINDS: @@ -205,6 +262,63 @@ def file_digest(path: Path, algo: str, on_progress: Any = None) -> str: return digest.hexdigest() +# ---------------------------------------------------------------- operations +def unfinished_downloads() -> list[dict[str, Any]]: + """Незавершённые скачивания по всему каталогу операций, а не по последним 50, + как running_operations: зависшая старая операция иначе не попадёт в проверку.""" + core.ensure_operations_dir() + found = [] + for path in core.OPERATIONS_DIR.glob("*.json"): + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + continue + if data.get("type") == "download" and data.get("status") in ("queued", "running"): + found.append(data) + found.sort(key=lambda op: str(op.get("created_at") or "")) + return found + + +def is_live(operation_id: str) -> bool: + with STATE_LOCK: + return operation_id in LIVE_DOWNLOADS + + +def release_download(operation_id: str) -> None: + with STATE_LOCK: + LIVE_DOWNLOADS.pop(operation_id, None) + CANCEL_REQUESTS.discard(operation_id) + + +def reap_stale_downloads() -> int: + """Скачивания без живого потока (панель перезапускалась) закрываются, а их + недокачанные .part удаляются — иначе они лежали бы в каталоге образов вечно. + + Операции всех типов закрывает core.interrupt_orphaned_operations() при старте; + здесь остаётся убрать файлы .part, которых та не касается. Возвращает число + закрытых операций.""" + count = 0 + for op in unfinished_downloads(): + if is_live(op["id"]): + continue + message = "Скачивание прервано: панель была перезапущена. Запустите его заново" + if op.get("target_path"): + part_path(Path(op["target_path"])).unlink(missing_ok=True) + release_download(op["id"]) + finish_operation(op, False, message, interrupted=True) + count += 1 + with STATE_LOCK: + live_targets = set(LIVE_DOWNLOADS.values()) + for directory in (core.ISO_DIR, core.DISK_IMAGES_DIR): + if not directory.is_dir(): + continue + for part in directory.glob(".*.part"): + if str(part.with_name(part.name[1:-5]).resolve()) in live_targets: + continue + part.unlink(missing_ok=True) + return count + + # ---------------------------------------------------------------- catalog def host_arch() -> str: arch = str(host_profile.load_host_profile().get("arch") or "").lower() @@ -224,10 +338,12 @@ def image_candidates(filename: str) -> list[str]: return names -def catalog_entries(arch: str | None = None) -> list[dict[str, Any]]: - """Каталог готовых систем для процессора сервера с пометками «Загружен» / «Скачивается».""" +def catalog_entries(arch: str | None = None, active: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: + """Каталог готовых систем для процессора сервера с пометками «Загружен» / «Скачивается». + Список идущих скачиваний можно передать готовым, чтобы не читать каталог операций дважды.""" arch = arch or host_arch() - active = {op.get("catalog_id"): op for op in running_operations("download") if op.get("catalog_id")} + ops = unfinished_downloads() if active is None else active + by_catalog_id = {op.get("catalog_id"): op for op in ops if op.get("catalog_id")} entries: list[dict[str, Any]] = [] for item in catalog.CLOUD_IMAGES: source = item.get("arches", {}).get(arch) @@ -238,7 +354,7 @@ def catalog_entries(arch: str | None = None) -> list[dict[str, Any]]: except DownloadError: continue ready = next((name for name in reversed(image_candidates(filename)) if (core.DISK_IMAGES_DIR / name).exists()), None) - operation = active.get(item["id"]) + operation = by_catalog_id.get(item["id"]) entries.append({ "id": item["id"], "title": item["title"], "vendor": item.get("vendor", ""), "hint": item.get("hint", ""), "recommended": bool(item.get("recommended")), "login": item.get("login", ""), "filename": filename, @@ -248,9 +364,9 @@ def catalog_entries(arch: str | None = None) -> list[dict[str, Any]]: return entries -def active_downloads(kind: str | None = None) -> list[dict[str, Any]]: +def active_downloads(kind: str | None = None, ops: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: result = [] - for op in running_operations("download"): + for op in unfinished_downloads() if ops is None else ops: if kind and op.get("download_kind") != kind: continue op["indeterminate"] = not op.get("total_bytes") and op.get("status") == "running" @@ -259,24 +375,29 @@ def active_downloads(kind: str | None = None) -> list[dict[str, Any]]: def downloads_panel(kind: str) -> dict[str, Any]: - """Данные для карточек скачивания на страницах /iso и /disk-images (глобальная функция шаблонов).""" + """Данные для карточек скачивания на страницах /iso и /disk-images (глобальная функция шаблонов). + Каталог операций читается один раз на вызов; страница вызывает функцию один раз и передаёт результат во вставки.""" kind = kind if kind in KINDS else "iso" - return {"kind": kind, "page": KINDS[kind]["page"], "hint": KINDS[kind]["hint"], "active": active_downloads(kind), "catalog": catalog_entries() if kind == "disk" else []} + ops = unfinished_downloads() + return {"kind": kind, "page": KINDS[kind]["page"], "hint": KINDS[kind]["hint"], "active": active_downloads(kind, ops), "catalog": catalog_entries(active=ops) if kind == "disk" else []} # ---------------------------------------------------------------- cancel def cancel_requested(operation_id: str) -> bool: - with CANCEL_LOCK: + with STATE_LOCK: return operation_id in CANCEL_REQUESTS def clear_cancel(operation_id: str) -> None: - with CANCEL_LOCK: + with STATE_LOCK: CANCEL_REQUESTS.discard(operation_id) def request_cancel(operation_id: str) -> tuple[dict[str, Any] | None, str | None]: - """Попросить остановить скачивание. Возвращает (операция, ошибка).""" + """Попросить остановить скачивание. Возвращает (операция, ошибка). + + Если операцией не владеет ни один поток (осталась от перезапуска панели), + ждать некого — она закрывается сразу, а недокачанный файл удаляется.""" operation = read_operation(operation_id) if not operation: return None, "Задача не найдена" @@ -284,9 +405,17 @@ def request_cancel(operation_id: str) -> tuple[dict[str, Any] | None, str | None return operation, "Отменить можно только скачивание" if operation.get("status") not in ("queued", "running"): return operation, "Скачивание уже завершено" - with CANCEL_LOCK: - CANCEL_REQUESTS.add(operation_id) operation.pop("log_tail", None) + with STATE_LOCK: + live = operation_id in LIVE_DOWNLOADS + if live: + CANCEL_REQUESTS.add(operation_id) + if not live: + if operation.get("target_path"): + part_path(Path(operation["target_path"])).unlink(missing_ok=True) + append_operation_log(operation_id, "Пользователь отменил скачивание. Поток загрузки не найден (панель перезапускалась), недокачанный файл удалён.") + finish_operation(operation, False, "Скачивание отменено", cancelled=True, cancel_requested=True) + return read_operation(operation_id) or operation, None update_operation(operation, cancel_requested=True, message="Останавливаем скачивание…") append_operation_log(operation_id, "Пользователь попросил отменить скачивание.") return operation, None @@ -300,22 +429,29 @@ def start_download(url: str, kind: str, *, title: str | None = None, sums_url: s url = validate_url(url) filename = filename_from_url(url, kind) target = target_path(kind, filename) - if target.exists(): - raise DownloadError(f"Файл {filename} уже есть на сервере. Удалите его, если нужно скачать заново") - if any(op.get("target_path") == str(target) for op in running_operations("download")): - raise DownloadError(f"Файл {filename} уже скачивается") if sums_url: validate_url(sums_url) if algo not in hashlib.algorithms_available: raise DownloadError("Неизвестный алгоритм контрольной суммы") - ok, free = free_space_ok(target.parent, None) - if not ok: - raise DownloadError(f"На сервере мало места: свободно {human_size(free)}") - operation = new_operation( - "download", title or f"Скачивание {filename}", message="Подключаемся к серверу…", - download_kind=kind, url=url, filename=filename, target_path=str(target), source_path=str(target), page=KINDS[kind]["page"], - sums_url=sums_url, algo=algo, catalog_id=catalog_id, total_bytes=None, downloaded_bytes=0, cancel_requested=False, - ) + with START_LOCK: + # Образ мог остаться под другим именем: распакованный .img или qcow2 после конвертации. + existing = next((name for name in image_candidates(filename) if (target.parent / name).exists()), None) + if existing: + raise DownloadError(f"Файл {existing} уже есть на сервере. Удалите его, если нужно скачать заново") + if any(op.get("target_path") == str(target) for op in unfinished_downloads()): + raise DownloadError(f"Файл {filename} уже скачивается") + # Владельца у .part нет (проверили выше) — это остаток прерванной загрузки. + part_path(target).unlink(missing_ok=True) + ok, free = free_space_ok(target.parent, None) + if not ok: + raise DownloadError(f"На сервере мало места: свободно {human_size(free)}") + operation = new_operation( + "download", title or f"Скачивание {filename}", message="Подключаемся к серверу…", + download_kind=kind, url=url, filename=filename, target_path=str(target), source_path=str(target), page=KINDS[kind]["page"], + sums_url=sums_url, algo=algo, catalog_id=catalog_id, total_bytes=None, downloaded_bytes=0, cancel_requested=False, + ) + with STATE_LOCK: + LIVE_DOWNLOADS[operation["id"]] = str(target) append_operation_log(operation["id"], f"Ссылка: {url}") append_operation_log(operation["id"], f"Файл: {target}") run_operation(operation, download_worker) @@ -323,20 +459,34 @@ def start_download(url: str, kind: str, *, title: str | None = None, sums_url: s def open_url(url: str) -> Any: - request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "*/*"}) + request = urllib.request.Request(encode_url(url), headers={"User-Agent": USER_AGENT, "Accept": "*/*"}) return urllib.request.urlopen(request, timeout=TIMEOUT) # noqa: S310 — схема проверена в validate_url def describe_network_error(exc: Exception) -> str: + """Ошибка сети словами для пользователя; исходный текст остаётся в журнале операции.""" if isinstance(exc, urllib.error.HTTPError): - return f"Сервер ответил ошибкой {exc.code} {exc.reason}" + return HTTP_ERRORS.get(exc.code, f"Сервер ответил ошибкой {exc.code}") if isinstance(exc, urllib.error.URLError): reason = exc.reason - if isinstance(reason, TimeoutError) or "timed out" in str(reason).lower(): + text = str(reason).lower() + if isinstance(reason, TimeoutError) or "timed out" in text: return f"Сервер не отвечает (ждали {TIMEOUT} с)" + if isinstance(reason, socket.gaierror) or "name or service not known" in text or "nodename nor servname" in text or "name resolution" in text: + return "Не удалось найти сервер по этой ссылке — проверьте адрес" + if isinstance(reason, ConnectionRefusedError) or "connection refused" in text: + return "Сервер отказал в подключении" + if isinstance(reason, ssl.SSLError) or "certificate" in text or "ssl" in text: + return "У сервера проблема с сертификатом безопасности" + if isinstance(reason, UnicodeError): + return "Ссылка содержит символы, которые сервер не понимает" return f"Не удалось подключиться к серверу: {reason}" + if isinstance(exc, UnicodeError): + return "Ссылка содержит символы, которые сервер не понимает" if isinstance(exc, TimeoutError) or "timed out" in str(exc).lower(): return f"Сервер перестал отвечать (ждали {TIMEOUT} с)" + if isinstance(exc, (ConnectionResetError, BrokenPipeError, ConnectionAbortedError)): + return "Сервер разорвал соединение" return str(exc)[:240] @@ -346,6 +496,7 @@ def fetch_expected_checksum(sums_url: str, filename: str, operation_id: str) -> with open_url(sums_url) as response: text = response.read(SUMS_MAX_BYTES).decode("utf-8", errors="replace") except Exception as exc: # noqa: BLE001 + append_operation_log(operation_id, f"Ошибка: {exc}") raise DownloadError(f"Не удалось скачать файл контрольных сумм: {describe_network_error(exc)}") from exc expected = parse_checksums(text, filename) if not expected: @@ -354,6 +505,13 @@ def fetch_expected_checksum(sums_url: str, filename: str, operation_id: str) -> def download_worker(operation: dict[str, Any]) -> None: + try: + run_download(operation) + finally: + release_download(operation["id"]) + + +def run_download(operation: dict[str, Any]) -> None: operation_id = operation["id"] kind = operation["download_kind"] target = Path(operation["target_path"]) @@ -364,27 +522,35 @@ def download_worker(operation: dict[str, Any]) -> None: def cancelled() -> bool: return cancel_requested(operation_id) - def fail(message: str, **meta: Any) -> None: - part.unlink(missing_ok=True) - clear_cancel(operation_id) + def check_cancel(*_args: Any) -> None: + if cancelled(): + raise DownloadCancelled + + def fail(message: str, *, keep_part: bool = False, **meta: Any) -> None: + if not keep_part: + part.unlink(missing_ok=True) finish_operation(operation, False, message, **meta) - def stop() -> None: - append_operation_log(operation_id, "Скачивание остановлено, недокачанный файл удалён.") + def stop(note: str = "Скачивание остановлено, недокачанный файл удалён.") -> None: + append_operation_log(operation_id, note) fail("Скачивание отменено", cancelled=True, cancel_requested=True) + def network_failure(prefix: str, exc: Exception) -> None: + append_operation_log(operation_id, f"Ошибка: {exc}") + fail(f"{prefix}{describe_network_error(exc)}") + if cancelled(): stop() return try: response = open_url(operation["url"]) except Exception as exc: # noqa: BLE001 - fail(describe_network_error(exc)) + network_failure("", exc) return with response: final_url = response.geturl() - if final_url != operation["url"]: + if final_url != encode_url(operation["url"]): append_operation_log(operation_id, f"Перенаправление: {final_url}") length = response.headers.get("Content-Length") total = int(length) if length and length.isdigit() else None @@ -401,7 +567,9 @@ def download_worker(operation: dict[str, Any]) -> None: last_done = 0 speed: float | None = None try: - with part.open("wb") as handle: + # Файл создаётся монопольно: если .part уже пишет другой поток, вторая загрузка + # не должна ни писать в него, ни удалять его при ошибке. + with part.open("xb") as handle: while True: if cancelled(): stop() @@ -413,6 +581,15 @@ def download_worker(operation: dict[str, Any]) -> None: done += len(chunk) now = time.monotonic() if now - last_tick >= 1.0: + # Раз в секунду перечитываем JSON операции: так запись прогресса не затирает + # флаг cancel_requested, который выставил request_cancel, а отмена из другого + # процесса панели (несколько воркеров uvicorn) тоже будет замечена. + fresh = read_operation(operation_id) or {} + fresh.pop("log_tail", None) + operation.update(fresh) + if cancelled() or operation.get("cancel_requested"): + stop() + return instant = (done - last_done) / (now - last_tick) speed = instant if speed is None else speed * 0.7 + instant * 0.3 last_tick, last_done = now, done @@ -423,8 +600,12 @@ def download_worker(operation: dict[str, Any]) -> None: fail(f"На сервере закончилось место: свободно {human_size(free)}") return update_operation(operation, progress=min(progress, download_share), downloaded_bytes=done, speed_bps=int(speed), message=progress_message(done, total, speed)) + except FileExistsError: + append_operation_log(operation_id, f"Файл {part.name} уже создан другой загрузкой — эта копия остановлена, чужой файл не тронут.") + fail(f"Файл {target.name} уже скачивается", keep_part=True) + return except Exception as exc: # noqa: BLE001 - fail(f"Загрузка прервалась: {describe_network_error(exc)}") + network_failure("Загрузка прервалась: ", exc) return if total is not None and done != total: @@ -434,37 +615,48 @@ def download_worker(operation: dict[str, Any]) -> None: append_operation_log(operation_id, f"Скачано {human_size(done)} за {human_eta(elapsed) or '1 с'} ({human_size(done / elapsed)}/с).") update_operation(operation, progress=download_share, downloaded_bytes=done, total_bytes=done if total is None else total, message="Файл скачан, проверяем…") - if verify: - try: + try: + if verify: expected = fetch_expected_checksum(operation["sums_url"], operation["filename"], operation_id) - except DownloadError as exc: - fail(str(exc)) - return - algo = operation.get("algo") or "sha256" - update_operation(operation, progress=97, message=f"Проверяем контрольную сумму ({algo})…") - actual = file_digest(part, algo) - if actual != expected: - append_operation_log(operation_id, f"Ожидали {algo}: {expected}") - append_operation_log(operation_id, f"Получили {algo}: {actual}") - fail("Контрольная сумма не совпала — файл повреждён или подменён, поэтому удалён. Попробуйте скачать ещё раз") - return - append_operation_log(operation_id, f"Контрольная сумма {algo} совпала.") + check_cancel() + algo = operation.get("algo") or "sha256" + update_operation(operation, progress=97, message=f"Проверяем контрольную сумму ({algo})…") + actual = file_digest(part, algo, on_progress=check_cancel) + if actual != expected: + append_operation_log(operation_id, f"Ожидали {algo}: {expected}") + append_operation_log(operation_id, f"Получили {algo}: {actual}") + fail("Контрольная сумма не совпала — файл повреждён или подменён, поэтому удалён. Попробуйте скачать ещё раз") + return + append_operation_log(operation_id, f"Контрольная сумма {algo} совпала.") + check_cancel() + except DownloadCancelled: + stop("Скачивание остановлено во время проверки, файл удалён.") + return + except DownloadError as exc: + fail(str(exc)) + return if target.exists(): fail(f"Файл {target.name} появился на сервере, пока шло скачивание. Скачанная копия удалена") return part.replace(target) - clear_cancel(operation_id) append_operation_log(operation_id, f"Файл сохранён: {target}") try: - result = after_download(operation, target, kind) + result = after_download(operation, target, kind, cancelled) + except DownloadCancelled: + append_operation_log(operation_id, "Распаковка остановлена, скачанный архив и недораспакованный файл удалены.") + finish_operation(operation, False, "Скачивание отменено", cancelled=True, cancel_requested=True) + return + except DownloadError as exc: + finish_operation(operation, False, str(exc)) + return except Exception as exc: # noqa: BLE001 finish_operation(operation, False, f"Файл скачан, но не удалось его подготовить: {str(exc)[:200]}", result_path=str(target)) return finish_operation(operation, True, result["message"], **result["meta"]) -def after_download(operation: dict[str, Any], target: Path, kind: str) -> dict[str, Any]: +def after_download(operation: dict[str, Any], target: Path, kind: str, should_stop: Callable[[], bool] | None = None) -> dict[str, Any]: """Что делать со скачанным файлом: обновить пул ISO либо распаковать и конвертировать диск.""" operation_id = operation["id"] if kind == "iso": @@ -476,7 +668,17 @@ def after_download(operation: dict[str, Any], target: Path, kind: str) -> dict[s if target.name.lower().endswith(".img.xz"): update_operation(operation, progress=98, message=f"Распаковываем {target.name}…") append_operation_log(operation_id, "Распаковываем сжатый образ…") - path = extract_xz_image(target) + try: + path = extract_xz_image(target, should_stop) + except DownloadCancelled: + target.unlink(missing_ok=True) + raise + except Exception as exc: # noqa: BLE001 + # Битый архив со страницы не видно и не удалить (там только .img/.raw/.qcow2), + # а повторное скачивание он бы блокировал — убираем сами. + target.unlink(missing_ok=True) + append_operation_log(operation_id, f"Ошибка распаковки: {exc}") + raise DownloadError("Не удалось распаковать скачанный архив — файл повреждён и удалён. Попробуйте скачать ещё раз") from exc target.unlink(missing_ok=True) append_operation_log(operation_id, f"Распакован: {path}") convert = start_convert(path) @@ -488,10 +690,25 @@ def after_download(operation: dict[str, Any], target: Path, kind: str) -> dict[s return {"message": f"Готово: {path.name} — можно создавать машину", "meta": meta} -def extract_xz_image(compressed: Path) -> Path: +def extract_xz_image(compressed: Path, should_stop: Callable[[], bool] | None = None) -> Path: + """Распаковать .img.xz рядом с архивом (имя подбирает app, как при загрузке с компьютера). + Между кусками проверяет should_stop: при отмене бросает DownloadCancelled, недораспакованный файл удаляется.""" import app # локальный импорт: app.py подключает этот модуль в самом конце - return app.extract_xz_disk_image(compressed) + extracted = app.unique_disk_image_path(compressed.name[:-3]) + try: + with lzma.open(compressed, "rb") as source, extracted.open("wb") as handle: + while True: + if should_stop and should_stop(): + raise DownloadCancelled + chunk = source.read(CHUNK_SIZE) + if not chunk: + break + handle.write(chunk) + except BaseException: + extracted.unlink(missing_ok=True) + raise + return extracted def start_convert(path: Path) -> dict[str, Any] | None: @@ -529,7 +746,7 @@ def download_from_catalog(request: Request, image_id: str): arch = host_arch() source = entry.get("arches", {}).get(arch) if not source: - return redirect_with_message("/disk-images", "download_error", f"{entry['title']} не выпускается для процессора {arch}") + return redirect_with_message("/disk-images", "download_error", f"У {entry['title']} нет версии для этого сервера ({arch})") try: operation = start_download(source["url"], "disk", title=f"Скачивание {entry['title']}", sums_url=source.get("sums"), algo=source.get("algo"), catalog_id=image_id) except DownloadError as exc: @@ -546,7 +763,11 @@ def cancel_download(request: Request, operation_id: str): page = page_for(str(operation.get("download_kind"))) if operation else "/iso" if error: return redirect_with_message(page, "download_error", error) - return redirect_with_message(page, "download_message", f"Останавливаем скачивание {operation.get('filename', '')}".rstrip()) + filename = operation.get("filename", "") + if operation.get("status") == "error": + # Потока не было — операция уже закрыта, ждать нечего. + return redirect_with_message(page, "download_message", " ".join(word for word in ("Скачивание", filename, "отменено") if word)) + return redirect_with_message(page, "download_message", f"Останавливаем скачивание {filename}".rstrip()) @router.post("/api/operations/{operation_id}/cancel") @@ -562,6 +783,12 @@ def api_cancel_operation(request: Request, operation_id: str): return {"ok": True, "operation": read_operation(operation_id) or operation} +@router.on_event("startup") +def close_stale_downloads() -> None: + """После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part.""" + reap_stale_downloads() + + # ---------------------------------------------------------------- templates def register_template_globals() -> None: """Функция downloads_panel нужна шаблонам iso.html и disk_images.html, которые diff --git a/web/templates/_downloads_active.html b/web/templates/_downloads_active.html index 0e8ecff..3fd1b62 100644 --- a/web/templates/_downloads_active.html +++ b/web/templates/_downloads_active.html @@ -1,6 +1,7 @@ -{#- Идущие скачивания (операции типа download) с живым прогрессом. Ожидает переменную kind ('iso' | 'disk'). -#} +{#- Идущие скачивания (операции типа download) с живым прогрессом. Ожидает переменную kind ('iso' | 'disk'); + страница может заранее положить в dl результат downloads_panel(kind), чтобы не читать операции дважды. -#} {% from "_ui.html" import icon %} -{% set dl = downloads_panel(kind) %} +{% set dl = dl if dl is defined else downloads_panel(kind) %} {% if dl.active %}
@@ -20,7 +21,8 @@
{{ op.message }}
Подробнее -
+ {#- Без data-toast: сервер лишь просит поток остановиться, а итог («Скачивание отменено») покажет опрос ниже. -#} +
@@ -54,13 +56,19 @@ if (op.status !== 'success' && op.status !== 'error') return; clearInterval(timer); progress.classList.remove('indeterminate'); - progress.classList.add(op.status === 'success' ? 'success' : 'danger'); cancel.hidden = true; - item.dataset.status = op.status; if (op.status === 'success') { + progress.classList.add('success'); + item.dataset.status = op.status; try { sessionStorage.setItem('virtualityToast', JSON.stringify({ message: op.message || 'Файл скачан', tone: 'success' })); } catch (_) {} setTimeout(function () { location.replace(location.pathname); }, 900); + } else if (op.cancelled) { + // Отмена по просьбе пользователя — не ошибка: предупреждение, без красной строки и красного процента. + item.dataset.status = 'cancelled'; + if (window.vToast) window.vToast(op.message || 'Скачивание отменено', 'warning'); } else { + progress.classList.add('danger'); + item.dataset.status = op.status; message.classList.add('download-error'); if (window.vToast) window.vToast(op.message || 'Скачивание не удалось', 'danger'); } diff --git a/web/templates/_downloads_catalog.html b/web/templates/_downloads_catalog.html index 842e353..61bab65 100644 --- a/web/templates/_downloads_catalog.html +++ b/web/templates/_downloads_catalog.html @@ -1,9 +1,9 @@ -{#- Карточка «Каталог готовых систем» для страницы /disk-images. -#} +{#- Карточка «Каталог готовых систем» для страницы /disk-images; страница может заранее положить в dl результат downloads_panel('disk'). -#} {% from "_ui.html" import icon, badge %} -{% set dl = downloads_panel('disk') %} +{% set dl = dl if dl is defined else downloads_panel('disk') %}
-

Каталог готовых систем

Официальные образы для вашего процессора. Скачаем на сервер и проверим контрольную сумму — останется только создать машину.

+

Каталог готовых систем

Официальные образы, подходящие этому серверу. Скачаем на сервер и проверим контрольную сумму — останется только создать машину.

{% if dl.catalog %}
@@ -31,6 +31,6 @@ {% endfor %}
{% else %} -

Для процессора этого сервера готовых образов в каталоге нет. Можно скачать образ по ссылке или загрузить с компьютера.

+

Подходящих этому серверу готовых образов в каталоге нет. Можно скачать образ по ссылке или загрузить с компьютера.

{% endif %}
diff --git a/web/templates/_downloads_url.html b/web/templates/_downloads_url.html index 47fde93..aed6e12 100644 --- a/web/templates/_downloads_url.html +++ b/web/templates/_downloads_url.html @@ -1,7 +1,7 @@ {#- Карточка «Скачать по ссылке». Ожидает переменную kind ('iso' | 'disk'). -#} {% from "_ui.html" import icon %}
-

Скачать по ссылке

Сервер скачает файл сам — не нужно гонять его через компьютер

+

Скачать по ссылке

Сервер скачает файл сам — не нужно сначала загружать его на компьютер

diff --git a/web/templates/disk_images.html b/web/templates/disk_images.html index b068b07..ae436cd 100644 --- a/web/templates/disk_images.html +++ b/web/templates/disk_images.html @@ -7,6 +7,7 @@ {% block content %} {% set kind = 'disk' %} +{% set dl = downloads_panel(kind) %} {% include "_downloads_active.html" %}
diff --git a/web/templates/iso.html b/web/templates/iso.html index 835c3b5..c9d400f 100644 --- a/web/templates/iso.html +++ b/web/templates/iso.html @@ -7,6 +7,7 @@ {% block content %} {% set kind = 'iso' %} +{% set dl = downloads_panel(kind) %} {% include "_downloads_active.html" %}
From ee6a615df7ce6ff064f049f0df917bad6d121833 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:39:41 +0000 Subject: [PATCH 26/32] =?UTF-8?q?=D0=94=D0=B5=D0=BC=D0=BE-=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=20=D0=BD=D0=B5=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- tests/dev_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/dev_server.py b/tests/dev_server.py index f769039..17c198a 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -217,5 +217,5 @@ if __name__ == "__main__": seed(Path(tempfile.mkdtemp(prefix="virtuality-dev-"))) # The demo operations are seeded as "running": keep them instead of closing them as orphaned on startup. - app.app.router.on_startup.remove(app.close_orphaned_operations) + app.app.router.on_startup.clear() uvicorn.run(app.app, host="127.0.0.1", port=int(sys.argv[1]) if len(sys.argv) > 1 else 8765, log_level="warning") From 5465000f4220b9e6bcf7063ba0c400ca910b788c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:41:03 +0000 Subject: [PATCH 27/32] =?UTF-8?q?=D0=94=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B1=D1=8B=D0=BB=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8E=20?= =?UTF-8?q?0.11:=20README,=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7,=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B0=D1=8F=20=D0=BC=D0=B0=D1=88=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0,=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D1=83=D1=80=D0=B0,=20=D1=82=D1=80=D0=B5=D0=B1=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F,=20=D0=BF=D0=BB=D0=B0=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README описывает продукт как простую замену Proxmox для дома: три способа установки (ISO из GitHub Actions или make iso, одна команда, вручную), первую загрузку и мастер настройки, возможности, страницу «Настройки», команды virtuality-ctl, обновления и откат, удаление, диагностику и разработку. Упоминания Cockpit, консольного dashboard, тестовых скриптов, эмуляции ARM64 на x86 и PXE убраны; таблица скриншотов сохранена и дополнена существующими снимками. docs/IMAGE.md — сборка ISO и все параметры image/build-iso.sh, вопросы установщика, этапы первой загрузки, тема GRUB, артефакты CI, проверка и запись на флешку. docs/FIRST_VM.md — первая машина только через панель: Ubuntu 26.04 из каталога с cloud-init, Windows 11 с ISO, экран, проброс портов, снимки, резервные копии. docs/ARCHITECTURE.md — текущее устройство: модули web/, virtuality-ctl, задачи, службы systemd, TLS, firewall, сеть NAT с резервациями, хранилище, обновления. docs/REQUIREMENTS.md — оборудование, ОС, ARM64, сеть, браузер. docs/ROADMAP.md — что сделано в 0.11 и что планируется. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- README.md | 673 +++++++++++++++++-------------------------- docs/ARCHITECTURE.md | 224 ++++++++------ docs/FIRST_VM.md | 220 ++++++++------ docs/IMAGE.md | 199 ++++++++----- docs/REQUIREMENTS.md | 110 +++---- docs/ROADMAP.md | 137 +++------ 6 files changed, 740 insertions(+), 823 deletions(-) diff --git a/README.md b/README.md index ed72360..1db196c 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,16 @@ # Virtuality -**Virtuality** — лёгкая серверная платформа виртуализации на базе **KVM**, **QEMU**, **libvirt**, **Cockpit** и собственной web-панели. +**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка. -Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией. +Текущая версия: **0.11.0** (файл `VERSION`, журнал изменений — `updates/versions.json`). -> Текущий статус: версия 0.10 — код панели покрыт тестами, есть установочный ISO-образ. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding. +Что умеет: ---- - -## Поддерживаемые профили хоста - -Virtuality автоматически определяет профиль сборки во время установки: - -```text -x86_64 # обычный сервер, домашний сервер, VPS -raspberry-arm64 # Raspberry Pi ARM64 edge node -orangepi5-arm64 # Orange Pi 5 / RK3588 ARM64 edge node -generic-arm64 # другая ARM64-плата -``` - -Профиль сохраняется здесь: - -```text -/var/lib/virtuality/config/host_profile.json -``` - -В web-панели профиль виден на странице: - -```text -/host -``` - -Для ARM64-плат правильный сценарий — **ARM64-гости**, NAT-сеть и позже cloud-image/cloud-init шаблоны. x86_64 ISO на Raspberry/Orange Pi не являются целевым режимом. +- ставится с готового ISO-образа, как Proxmox: три экрана установщика — и через несколько минут панель открыта в браузере; +- создаёт машины из установочных ISO (в том числе Windows 11) и за минуту — из готовых облачных образов Ubuntu, Debian и Alpine; +- снимки, резервные копии, клонирование, увеличение диска, спящий режим; +- сеть машин «из коробки»: отдельная сеть NAT с постоянными адресами и пробросом портов или ваша домашняя сеть через мост; +- экран машины прямо в браузере, HTTPS для панели, автообновления со стабильным каналом. ![Обзор](docs/screenshots/dashboard.png) @@ -43,41 +22,84 @@ generic-arm64 # другая ARM64-плата |---|---| | ![Сеть](docs/screenshots/network.png) | ![Тёмная тема](docs/screenshots/dashboard-dark.png) | ---- +| Мастер настройки: установка | Мастер настройки: сеть машин | +|---|---| +| ![Мастер, установка](docs/screenshots/wizard-install.png) | ![Мастер, сеть](docs/screenshots/wizard-network.png) | -## Установка с ISO-образа +| Каталог готовых систем | Настройки | +|---|---| +| ![Каталог](docs/screenshots/catalog.png) | ![Настройки](docs/screenshots/settings.png) | -Для установки на «голое» железо собирается загрузочный ISO на базе чистого Ubuntu Server 26.04 LTS: установщик спрашивает только сеть, диск и пользователя, а при первой загрузке нода настраивается сама. +| Снимки | Резервные копии | +|---|---| +| ![Снимки](docs/screenshots/snapshots.png) | ![Резервные копии](docs/screenshots/backups.png) | -```bash -make iso # dist/virtuality-<версия>-ubuntu-26.04-amd64.iso -make iso ARCH=arm64 -``` - -Подробно: [`docs/IMAGE.md`](docs/IMAGE.md). +| Вход | На телефоне | +|---|---| +| ![Вход](docs/screenshots/login.png) | ![Мобильная версия](docs/screenshots/dashboard-mobile.png) | --- -## Быстрая установка одной командой +## Содержание -Одна команда работает и под `root`, и под обычным пользователем с `sudo`: +- [Установка](#установка) + - [Способ 1: установочный ISO](#способ-1-установочный-iso) + - [Способ 2: одна команда на готовый сервер](#способ-2-одна-команда-на-готовый-сервер) + - [Способ 3: вручную](#способ-3-вручную) +- [Первая загрузка и мастер настройки](#первая-загрузка-и-мастер-настройки) +- [Возможности](#возможности) +- [Настройки](#настройки) +- [Команда virtuality-ctl](#команда-virtuality-ctl) +- [Обновления и откат](#обновления-и-откат) +- [Удаление](#удаление) +- [Если что-то не работает](#если-что-то-не-работает) +- [Где что лежит](#где-что-лежит) +- [Разработка](#разработка) + +Подробные документы: [требования](docs/REQUIREMENTS.md) · [установочный образ](docs/IMAGE.md) · [первая машина](docs/FIRST_VM.md) · [архитектура](docs/ARCHITECTURE.md) · [планы](docs/ROADMAP.md). + +--- + +## Установка + +Перед установкой проверьте [требования](docs/REQUIREMENTS.md): процессор x86_64 с включённой виртуализацией (Intel VT-x / AMD-V), от 4 ГБ памяти, проводная сеть. + +### Способ 1: установочный ISO + +Самый простой путь для «голого» сервера или старого компьютера. Образ собирается из чистого Ubuntu Server 26.04 LTS; установщик спрашивает только сеть, диск и пользователя. + +Где взять образ: + +- **GitHub Actions.** Откройте вкладку Actions репозитория → workflow **Build ISO image** → последний успешный запуск → раздел Artifacts → `virtuality-iso-amd64` (или `virtuality-iso-arm64`). Внутри архива — `.iso` и файл `.sha256`. Артефакты хранятся 30 дней; новый образ собирается при каждом выпуске (тег `vX.Y.Z`), запустить сборку вручную можно кнопкой Run workflow. +- **Собрать самому** на любой Linux-машине (root не нужен): + + ```bash + sudo apt install -y xorriso git curl python3-pip + git clone https://github.com/viktor138irk/virtuality.git + cd virtuality + make iso # dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso + ``` + +Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md). + +### Способ 2: одна команда на готовый сервер + +Подходит для уже установленного Ubuntu Server 24.04 / 26.04 или Debian 13. Работает и под `root`, и под обычным пользователем с `sudo`: ```bash curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | bash ``` -Как выбирается пользователь для входа в web-панель: +Скрипт проверит требования, поставит пакеты KVM/QEMU/libvirt, команду `vhealth` и панель, а в конце покажет адрес панели. Логин — тот пользователь, от которого запущена установка (через `sudo` — ваш пользователь, под `root` — `root`); пароль — его пароль Linux. Если пароль ещё не задан: `sudo passwd <пользователь>`. + +Переменные окружения, которые понимает `install.sh`: ```text -Если запуск под root → login: root -Если запуск через sudo → login: текущий sudo-пользователь -Если задан VIRTUALITY_USER → login: указанный Linux-пользователь -``` - -Принудительно выбрать пользователя: - -```bash -curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER= bash +VIRTUALITY_USER=admin # какой Linux-пользователь входит в панель (будет создан, если его нет) +VIRTUALITY_WEB_PORT=8089 # порт панели (по умолчанию 8088) +VIRTUALITY_AUTO_UPDATE=0 # не включать ночное автообновление +VIRTUALITY_BRANCH=main # ветка репозитория +VIRTUALITY_INSTALL_VERBOSE=1 # показывать вывод команд вместо спиннера ``` Пример: @@ -86,363 +108,227 @@ curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/instal curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=admin bash ``` -Установка с авторизацией под `root`: - -```bash -curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=root bash -``` - -Если пароль выбранного Linux-пользователя не задан: - -```bash -sudo passwd -``` - -Для root: - -```bash -passwd root -``` - ---- - -## Что уже есть - -- универсальная one-command установка через `install.sh`; -- автоопределение профиля хоста: x86_64, Raspberry Pi ARM64, Orange Pi 5 ARM64, generic ARM64; -- установка разных пакетных наборов под x86 и ARM64; -- красивый пошаговый installer с live-status и логами; -- preflight-проверка системных требований; -- установка KVM/QEMU/libvirt; -- установка Cockpit и Cockpit Machines; -- рабочая директория `/opt/virtuality/source`; -- libvirt storage pools `virtuality-images` и `virtuality-iso`; -- firewall-правила для SSH, Cockpit, VNC и web-панели; -- безопасная ручная настройка bridge `br0` через `netplan try`; -- NAT Router `virtuality-nat` для VPS/ARM edge nodes; -- port forwarding через nftables; -- автоматическое определение IP VM для проброса портов; -- healthcheck одной командой `vhealth`; -- консольный dashboard для физического монитора; -- web-панель Virtuality на FastAPI; -- авторизация web-панели через системного Linux-пользователя; -- `/host` — профиль хоста и проверки готовности; -- `/network` — NAT, DHCP leases, port forwarding, nftables preview; -- `/iso` — ISO-менеджер; -- `/operations` — журнал фоновых операций; -- список VM; -- управление VM: Start, Shutdown, Reboot, Power off, Autostart on/off, Delete VM with disks; -- создание VM из web-интерфейса; -- выбор сетевого режима при создании VM: `virtuality-nat` или `br0`; -- прогресс создания VM и live-лог `virt-install`; -- страница деталей VM: `dominfo`, VNC display, IP, диски, сетевые интерфейсы. - ---- - -## Системные требования - -Минимально для x86_64 тестового стенда: - -```text -CPU: 2 ядра с Intel VT-x / AMD-V -RAM: 4 GB -/: минимум 8 GB свободно -/var/lib: минимум 20 GB свободно -OS: Ubuntu Server 26.04 / 24.04 LTS или Debian 13 -Network: один проводной интерфейс -``` - -Рекомендуемо для x86_64: - -```text -CPU: 4+ ядра -RAM: 16+ GB -Storage: 100+ GB SSD/NVMe под /var/lib/virtuality -Network: 1 Gbit/s+ -OS: Ubuntu Server 26.04 LTS -``` - -ARM64 edge nodes: - -```text -Raspberry Pi 4/5 ARM64: желательно 8 GB RAM -Orange Pi 5 ARM64: желательно 8/16/32 GB RAM и NVMe -Гости: ARM64 Linux VM -Сеть: virtuality-nat по умолчанию -``` - -Подробнее: [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md) - ---- - -## Порты - -```text -Cockpit: https://SERVER_IP:9090 -Virtuality UI: http://SERVER_IP:8088 -VNC: 5900-5999/tcp -SSH: 22/tcp -``` - -Изменить порт web-панели: - -```bash -curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_WEB_PORT=8089 bash -``` - -Для VPS/ARM NAT-сценария входящие сервисы VM открываются через port forwarding в разделе `/network`. - ---- - -## Директории - -```text -/opt/virtuality/source # исходники проекта из GitHub -/opt/virtuality/web # установленная web-панель -/opt/virtuality/venv # Python virtualenv web-панели -/opt/virtuality/virtuality.env # базовый env ноды -/var/lib/virtuality/config # конфиги Virtuality -/var/lib/virtuality/config/host_profile.json -/var/lib/virtuality/iso # ISO-образы -/var/lib/virtuality/images # qcow2-диски VM -/var/lib/virtuality/network # port_forwards.json -/var/lib/virtuality/backups # backups -/var/log/virtuality # логи установки и диагностики -/var/log/virtuality/operations # JSON/log фоновых операций -/var/lib/virtuality/config/web.env # настройки ноды: порт, пользователь, автообновление -/var/lib/virtuality/disk-images # загруженные образы дисков -/etc/virtuality/nftables/virtuality.nft # nftables-правила Virtuality -``` - ---- - -## Диагностика - -```bash -sudo vhealth -``` - -Дополнительно: - -```bash -cat /var/lib/virtuality/config/host_profile.json -systemctl status virtuality-web --no-pager -journalctl -u virtuality-web -n 120 --no-pager -virsh list --all -virsh pool-list --all -virsh net-list --all -virsh net-dhcp-leases virtuality-nat -ip -br a -ip route -``` - ---- - -## Web-панель - -URL: - -```text -http://SERVER_IP:8088 -``` - -Основные разделы: - -```text -/ # дашборд -/host # профиль хоста и проверки -/iso # ISO-менеджер -/network # NAT Router и проброс портов -/operations # журнал операций -/vm/create # создание VM -``` - -Переустановка web-панели: +При первом входе в панель откроется мастер настройки — тот же, что после установки с ISO. + +### Способ 3: вручную + +Если хочется видеть каждый шаг. Всё делается от `root` или через `sudo`: ```bash +sudo mkdir -p /opt/virtuality +sudo git clone https://github.com/viktor138irk/virtuality.git /opt/virtuality/source cd /opt/virtuality/source -sudo bash scripts/install_web_panel.sh +sudo bash install_virtuality_node.sh # пакеты KVM/QEMU/libvirt, пулы, firewall +sudo bash scripts/install_healthcheck_command.sh # команда sudo vhealth +sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панель и virtuality-ctl ``` -Статус: +`install_web_panel.sh` копирует панель в `/opt/virtuality/web`, создаёт virtualenv, ставит `virtuality-ctl` и через него — systemd-юниты и правила firewall. Переменные: `VIRTUALITY_AUTH_USER` (пользователь для входа), `VIRTUALITY_WEB_PORT` (порт), `VIRTUALITY_AUTO_UPDATE` (0/1). Повторный запуск скрипта обновляет панель, сохраняя настройки. -```bash -systemctl status virtuality-web --no-pager -``` +--- -Логи: +## Первая загрузка и мастер настройки -```bash -journalctl -u virtuality-web -f -``` +### Что происходит после установки с ISO -Вход: +После перезагрузки запускается `virtuality-firstboot.service` (`scripts/virtuality_firstboot.sh`). Порядок шагов выбран так, чтобы вы как можно раньше увидели, что происходит: + +1. **Панель управления** — ставится офлайн из Python-колёс, упакованных в образ, и сразу запускается. Открывайте `http://адрес-сервера:8088` — мастер покажет ход остальных шагов прямо в браузере. +2. **Подключение к интернету** — ожидание сети. Если кабель не подключён, мастер напишет об этом и продолжит сам, как только интернет появится. +3. **KVM, QEMU и libvirt** — самый долгий шаг, обычно 5–15 минут: пакеты ставятся из репозиториев Ubuntu, мастер показывает, какой пакет скачивается или настраивается. +4. **Инструменты диагностики** — команда `vhealth`. +5. **Завершение** — `virtuality-ctl reconfigure`: юниты, firewall, перезапуск панели. + +Ход установки пишется в `/var/lib/virtuality/config/setup.json` и в `/var/log/virtuality/firstboot.log`. Если какой-то шаг упал (например, отвалилась сеть), systemd перезапускает скрипт через минуту, и он продолжает с первого незавершённого шага. На экране сервера (tty) всё это время показана подсказка, а после завершения — адрес панели и логин. + +Логин в панель — пользователь, созданный в установщике Ubuntu; пароль — его пароль. + +### Мастер настройки + +Мастер (`/setup`) открывается при первом входе в панель — и после ISO, и после `install.sh`. Каждый шаг можно пропустить, а весь мастер — запустить заново из раздела «Настройки». + +| Шаг | Что делает | +|---|---| +| **Установка** | Показывает ход первой загрузки (только после ISO). | +| **Сервер** | Проверяет процессор, память, диск и доступность KVM; задаёт часовой пояс. | +| **Администратор** | Смена пароля пользователя панели (он же пароль SSH). | +| **Доступ** | HTTPS с сертификатом сервера (панель на порту 8443, с 8088 — перенаправление) или обычный HTTP; порт. | +| **Сеть машин** | **Отдельная сеть (NAT)** — машины получают адреса 192.168.100.x, работает везде. **Домашняя сеть (мост)** — машины становятся устройствами вашей сети; сервер сам перестроит сеть в мост `br0`, а если панель не ответит за 2 минуты — вернёт всё как было. | +| **Хранилище** | Отдать пустой диск (от 16 ГБ) под машины: он форматируется и монтируется в `/var/lib/virtuality`. Или оставить системный диск. | +| **Обновления** | Автоматически ночью (только выпущенные версии) или вручную; канал: стабильный или ранний доступ. | +| **Готово** | Сводка; настройки применяются, при смене адреса панель перезапускается и открывается по новому. | + +--- + +## Возможности + +Разделы панели: **Обзор**, **Создать машину**, **Образы ОС**, **Сеть и доступ**, **Резервные копии**, **Задачи**, **Журналы**, **Обновления**, **Сервер**, **Настройки**, **Помощь**. + +**Машины** + +- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть. +- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны. +- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно. +- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками. +- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места). +- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ). +- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти. +- **Экран машины в браузере** (noVNC). VNC наружу не открыт: консоль доступна только через панель. + +**Снимки и копии** + +- **Снимки** — точки восстановления внутри диска машины, до 8 на машину. У работающей машины в снимок попадает и память. Вернуться или удалить — одной кнопкой. +- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием; копию можно делать и по расписанию из командной строки. + +**Образы ОС** + +- Загрузка ISO и образов дисков перетаскиванием (с прогрессом и скоростью), поддержка `.qcow2`, `.img`, `.raw`, сжатых `.img.xz` и архивов. +- **Скачивание по ссылке** — сервер качает файл сам, в фоне. +- **Каталог готовых систем** — официальные облачные образы Ubuntu Server 26.04 LTS и 24.04 LTS, Debian 13, Alpine Linux 3.23 с проверкой контрольных сумм. + +**Сеть** + +- Сеть **NAT** `virtuality-nat` (192.168.100.0/24): каждая машина получает **постоянный адрес** по имени, поэтому пробросы портов не ломаются после перезагрузок. +- **Проброс портов** из списка готовых сервисов (SSH, сайт, RDP, Minecraft, VPN и другие) или любой порт и диапазон; проверка правила одной кнопкой; правила переживают перезагрузку сервера. +- **Домашняя сеть через мост** `br0` с автооткатом, если сеть пропала. + +**Сервер** + +- Мастер настройки и страница «Настройки». +- **HTTPS** для панели с сертификатом сервера и перенаправлением с HTTP. +- **Обновления** из панели или автоматически ночью, **стабильный канал** (только выпущенные версии) и ранний доступ, откат на предыдущую версию. +- Задачи с прогрессом и журналом, журналы служб, диагностика `vhealth`, отчёт для поддержки, архив настроек. +- Светлая и тёмная темы, работает на телефоне, не требует доступа в интернет для самой панели. + +Как сделать первую машину — в [docs/FIRST_VM.md](docs/FIRST_VM.md). + +--- + +## Настройки + +Страница **Настройки** (`/settings`) — всё то же, что в мастере, плюс управление сервером: + +- **Доступ к панели** — HTTPS или HTTP, порт. После применения панель перезапускается и открывается по новому адресу. +- **Пароль** — смена пароля пользователя панели (он же пароль Linux и SSH). +- **Сеть машин** — переключение между NAT и домашней сетью (мост `br0`), возврат прежней сети, проверка сети NAT. +- **Хранилище** — подключить пустой диск под машины. +- **Время** — часовой пояс сервера (по нему показываются журналы и час ночного обновления). +- **Обновления** — автообновление и канал (стабильный / ранний доступ). +- **Сервер** — скачать архив настроек, собрать отчёт для поддержки, перезагрузить или выключить сервер (машины выключаются корректно). +- **Мастер настройки** — пройти все шаги заново. + +Настройки ноды хранятся в `/var/lib/virtuality/config/web.env` и не сбрасываются обновлениями: ```text -Login: выбранный Linux-пользователь -Pass: пароль этого Linux-пользователя +VIRTUALITY_WEB_PORT=8088 # порт HTTP +VIRTUALITY_TLS=1 # 1 — HTTPS включён +VIRTUALITY_TLS_PORT=8443 # порт HTTPS +VIRTUALITY_WEB_HOST=0.0.0.0 # 127.0.0.1, если панель стоит за reverse proxy +VIRTUALITY_AUTH_USER=admin # Linux-пользователь для входа +VIRTUALITY_AUTO_UPDATE=1 # 0 — без ночного автообновления +VIRTUALITY_UPDATE_CHANNEL=stable # stable — выпущенные версии, main — все изменения +VIRTUALITY_SOURCE_DIR=/opt/virtuality/source +``` + +Менять их руками не нужно: `sudo virtuality-ctl set КЛЮЧ=ЗНАЧЕНИЕ` сохраняет значение и сразу применяет его. + +--- + +## Команда virtuality-ctl + +`virtuality-ctl` ставится в `/usr/local/bin` вместе с панелью; панель сама вызывает его для всех действий с сервером, так что из терминала можно сделать то же самое. Запускается от `root`: + +```text +sudo virtuality-ctl status # что установлено, порты, адрес панели +sudo virtuality-ctl url # адрес панели +sudo virtuality-ctl reconfigure # пересоздать systemd-юниты из web.env и перезапустить панель +sudo virtuality-ctl set KEY=VALUE ... # изменить настройку в web.env и применить (например set VIRTUALITY_TLS=1) +sudo virtuality-ctl passwd [USER] # сменить пароль пользователя панели +sudo virtuality-ctl timezone Europe/Moscow # часовой пояс сервера +sudo virtuality-ctl power reboot|poweroff # перезагрузить или выключить сервер (машины выключаются корректно) +sudo virtuality-ctl backup-config [FILE] # архив настроек ноды и описаний машин +sudo virtuality-ctl restore-config FILE # восстановить настройки из архива +sudo virtuality-ctl rollback # вернуть предыдущую версию панели +sudo virtuality-ctl maintenance # ежедневное обслуживание (запускается таймером) +sudo virtuality-ctl bridge [dhcp|static] # мост br0 с автооткатом через 2 минуты +sudo virtuality-ctl bridge-confirm # подтвердить новую сеть +sudo virtuality-ctl bridge-revert # вернуть прежнюю сеть +sudo virtuality-ctl storage-use /dev/sdX # отдать пустой диск под /var/lib/virtuality +sudo virtuality-ctl support-bundle [FILE] # отчёт для поддержки +sudo virtuality-ctl uninstall [--purge] [--yes] # удалить Virtuality ``` --- -## Сеть: Bridge и NAT Router +## Обновления и откат -### Bridge br0 - -Подходит для домашнего/офисного сервера, где VM должны получать IP из локальной сети. - -По умолчанию one-command установщик **не включает `br0` автоматически**, чтобы не уронить SSH-сессию. - -Сначала нужно посмотреть сетевой интерфейс: - -```bash -ip -br a -ip route -``` - -Затем запустить bridge setup, например для `enp2s0`: - -```bash -cd /opt/virtuality/source -sudo bash scripts/setup_bridge_br0.sh enp2s0 static -``` - -Ожидаемый результат: - -```text -enp2s0 UP -br0 UP SERVER_IP/24 -default via GATEWAY dev br0 -``` - -### Virtuality NAT Router - -Подходит для VPS, Raspberry Pi, Orange Pi 5 и других ARM64 edge nodes. - -Схема: - -```text -Интернет / LAN - ↓ -Хост Virtuality - ↓ -virtuality-nat / virbr100 - ↓ -VM 192.168.100.x -``` - -Создать или починить NAT-сеть можно в web-панели: - -```text -/network → Создать / починить NAT-сеть -``` - -Параметры по умолчанию: - -```text -Network: virtuality-nat -Bridge: virbr100 -Subnet: 192.168.100.0/24 -Gateway: 192.168.100.1 -DHCP: 192.168.100.50–192.168.100.200 -``` - -Проброс порта добавляется в `/network`. IP VM определяется автоматически через `virsh domifaddr`, а если не получилось — через DHCP leases и MAC-адрес VM. - -Пример SSH-проброса: - -```text -VM: ubuntu-test -Внешний порт сервера: 2222 -Порт внутри VM: 22 -Протокол: tcp -``` - -Подключение: - -```bash -ssh user@SERVER_IP -p 2222 -``` +- **Из панели**: раздел **Обновления** → «Проверить сейчас» → «Обновить». Машины во время обновления продолжают работать. Что изменилось — видно на той же странице. +- **Автоматически**: таймер `virtuality-auto-update.timer` проверяет обновления каждую ночь около 4:00 по времени сервера. Включается и выключается в «Настройках» или командой `sudo virtuality-ctl set VIRTUALITY_AUTO_UPDATE=0`. +- **Каналы**: `stable` — только выпущенные версии (последний тег `vX.Y.Z`; пока тегов нет, стабильный канал следует ветке `main`), `main` — все изменения ветки `main`. Нода никогда не откатывается на более старую версию сама. +- **Вручную из терминала**: `sudo bash /opt/virtuality/source/scripts/apply_github_update.sh` — тот же сценарий, что и в панели (git fetch, при недоступности git — ZIP с GitHub, затем `scripts/install_web_panel.sh` и перезапуск панели). Журнал: `/var/log/virtuality/update.log`. +- **Откат**: перед каждым обновлением предыдущая версия панели сохраняется в `/opt/virtuality/web.prev`. Если новая версия не запустилась: `sudo virtuality-ctl rollback`. --- -## ISO и создание VM - -Загрузка ISO через web-панель: - -```text -/iso -``` - -Создание VM: - -```text -/vm/create -``` - -При создании VM можно выбрать сеть: - -```text -VPS NAT Router — virtuality-nat -Bridge — br0 / локальная сеть -``` - -Для x86_64 можно использовать обычные x86_64 ISO. Для Raspberry Pi / Orange Pi 5 нужны ARM64 ISO или, в будущем, ARM64 cloud images. - -Пример загрузки Alpine x86_64 ISO: +## Удаление ```bash -sudo wget -O /var/lib/virtuality/iso/alpine-standard-x86_64.iso https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.3-x86_64.iso -sudo virsh pool-refresh virtuality-iso +sudo virtuality-ctl uninstall # убирает панель, службы и настройки; машины, образы и копии остаются +sudo virtuality-ctl uninstall --purge # удаляет ВСЁ: машины, диски, образы, резервные копии, сеть NAT ``` +Команда спрашивает подтверждение (`YES`); `--yes` пропускает вопрос. Пакеты KVM/QEMU/libvirt остаются в системе. + --- -## Обновление +## Если что-то не работает -Из web-панели: раздел `/update`. Вручную: +1. Раздел **Сервер** в панели показывает, чего не хватает (KVM, пакеты, мост). Раздел **Задачи** — журнал каждой операции. Раздел **Журналы** — панель, обновления, установка, виртуализация. +2. Полная диагностика в терминале: -```bash -cd /opt/virtuality/source -sudo git pull -sudo bash scripts/install_web_panel.sh -sudo systemctl restart virtuality-web -``` + ```bash + sudo vhealth # проверка процессора, служб, сети, firewall, libvirt, дисков + sudo virtuality-ctl status # версия, адрес панели, HTTPS, автообновление, хранилище + ``` -Порт панели, пользователь и режим автообновления сохраняются в `/var/lib/virtuality/config/web.env` и не сбрасываются при обновлениях. +3. Отчёт для поддержки — кнопка в «Настройках» или `sudo virtuality-ctl support-bundle`: архив с настройками (без секретов), журналами служб и последними задачами. +4. Журналы: -Полная повторная установка компонентов ноды: + ```bash + journalctl -u virtuality-web -f # панель + sudo journalctl -fu virtuality-firstboot # первая загрузка после ISO + cat /var/log/virtuality/firstboot.log # то же, файлом + cat /var/log/virtuality/update.log # обновления + ls /var/log/virtuality/operations/ # задачи: JSON + журнал каждой + ``` -```bash -cd /opt/virtuality/source -sudo bash install_virtuality_node.sh -sudo bash scripts/install_web_panel.sh -``` +5. Для мониторинга: `GET /healthz` без авторизации отвечает `{"ok": true, "version": "..."}`. + +Полезные команды libvirt: `virsh list --all`, `virsh net-list --all`, `virsh net-dhcp-leases virtuality-nat`, `virsh pool-list --all`. --- -## Продакшен-настройки - -Настройки ноды — `/var/lib/virtuality/config/web.env`: +## Где что лежит ```text -VIRTUALITY_WEB_PORT=8088 # порт панели -VIRTUALITY_WEB_HOST=0.0.0.0 # 127.0.0.1, если панель стоит за reverse proxy -VIRTUALITY_AUTH_USER=admin # Linux-пользователь для входа -VIRTUALITY_AUTO_UPDATE=1 # 0 — отключить ночное автообновление с GitHub -VIRTUALITY_COOKIE_SECURE=0 # 1 — cookie только по HTTPS (за reverse proxy с TLS) +/opt/virtuality/source # исходники (git), отсюда обновления +/opt/virtuality/web # установленная панель (web.prev — предыдущая версия) +/opt/virtuality/venv # Python virtualenv панели +/var/lib/virtuality/iso # установочные ISO +/var/lib/virtuality/disk-images # готовые образы систем (каталог, ссылки, загрузки) +/var/lib/virtuality/images # диски машин (qcow2) +/var/lib/virtuality/backups # резервные копии машин и архивы настроек +/var/lib/virtuality/config # web.env, setup.json, wizard.json, host_profile.json, tls/ +/var/lib/virtuality/network # port_forwards.json, virtuality-nat.xml +/var/lib/virtuality/tmp # временные файлы загрузок и cloud-init +/var/log/virtuality # firstboot.log, update.log, virtuality-ctl.log, install_*.log +/var/log/virtuality/operations # задачи панели: JSON и журнал каждой +/etc/virtuality/nftables/virtuality.nft # правила проброса портов +/usr/local/bin/virtuality-ctl # управление нодой +/usr/local/bin/vhealth # диагностика ``` -После изменения: `cd /opt/virtuality/source && sudo bash scripts/install_web_panel.sh`. Любой параметр можно передать и переменной окружения при установке, например `VIRTUALITY_AUTO_UPDATE=0`. - -Рекомендации: - -- для продакшена отключите автообновление (`VIRTUALITY_AUTO_UPDATE=0`) и обновляйтесь вручную через `/update` после проверки версии; -- открывайте панель наружу только через HTTPS reverse proxy (nginx/Caddy) с `VIRTUALITY_WEB_HOST=127.0.0.1` и `VIRTUALITY_COOKIE_SECURE=1`; -- reverse proxy должен передавать исходный `Host` (nginx: `proxy_set_header Host $host;`, для noVNC-консоли ещё `Upgrade`/`Connection`), иначе панель отклонит POST-запросы как cross-origin; -- мониторинг: `GET /healthz` без авторизации возвращает `{"ok": true, "version": "..."}`; -- после 5 неверных паролей вход с этого адреса блокируется на 5 минут; сессия живёт 12 часов; -- правила проброса портов восстанавливаются после перезагрузки сервисом `virtuality-network.service`. +Порты: панель `8088` (HTTP) и `8443` (HTTPS), SSH `22`. VNC-порты машин наружу не открываются. --- @@ -450,50 +336,27 @@ VIRTUALITY_COOKIE_SECURE=0 # 1 — cookie только по HTTPS (за rev ```bash pip install -r web/requirements.txt -r requirements-dev.txt -make check # pyflakes + shellcheck + pytest -python3 tests/dev_server.py # панель на http://127.0.0.1:8765 с имитацией virsh, вход: любой пароль +make test # pytest (virsh и systemctl имитируются в tests/conftest.py) +make lint # pyflakes + shellcheck +make check # lint + test — запускать перед коммитом +make iso # установочный ISO в dist/ (см. docs/IMAGE.md) ``` -Интерфейс: шаблоны `web/templates` (общий каркас `base.html`, компоненты `_ui.html`), стили `web/static/app.css` (светлая и тёмная темы на CSS-переменных), поведение `web/static/panel.js`. Шрифт Inter и иконки Lucide лежат в `web/static` — панель работает без доступа в интернет. - -Код панели лежит в `web/` целиком и устанавливается как есть. Патчить `app.py` во время установки больше не нужно: изменения вносятся прямо в `web/` и покрываются тестами в `tests/`. GitHub Actions запускает проверки на Python 3.11–3.13 и собирает ISO по тегу `v*`. - ---- - -## Полезные команды +Демо-панель без сервера виртуализации — с тремя машинами, задачами и загрузками: ```bash -sudo vhealth -virsh list --all -virsh pool-list --all -virsh net-list --all -virsh net-dhcp-leases virtuality-nat -ip -br a -ip route -sudo nft list ruleset -sudo ufw status -systemctl status libvirtd --no-pager -systemctl status cockpit.socket --no-pager -systemctl status virtuality-web --no-pager -journalctl -u virtuality-web -f +python3 tests/dev_server.py 8765 # http://127.0.0.1:8765, вход: tester / любой пароль +VIRTUALITY_DEV_SETUP=installing python3 tests/dev_server.py # мастер на шаге «Установка» +VIRTUALITY_DEV_SETUP=done python3 tests/dev_server.py # мастер после установки +VIRTUALITY_DEV_SETUP=finished python3 tests/dev_server.py # настроенный сервер, страница «Настройки» ``` ---- +Устройство кода описано в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Интерфейс: шаблоны `web/templates` (каркас `base.html`, компоненты `_ui.html`), стили `web/static/app.css`, поведение `web/static/panel.js`; шрифт и иконки лежат в `web/static`. Скриншоты страниц: `node tests/screenshots.mjs` (нужен Playwright). -## Roadmap - -- ARM64 cloud-image templates; -- cloud-init для быстрых VM; -- управление storage pools; -- backup/snapshot manager; -- сетевой менеджер bridge/VLAN; -- роли и права пользователей; -- журнал событий; -- кластеризация; -- готовые образы для Raspberry Pi / Orange Pi 5. +GitHub Actions: `ci.yml` запускает pyflakes, shellcheck и тесты на Python 3.11–3.14; `image.yml` собирает ISO по тегу `v*` и вручную. Ветка `main` — рабочая ветка нод на канале `main`; выпуск для стабильного канала — тег `vX.Y.Z`. --- ## Лицензия -Рекомендуемая лицензия: **MIT License**. +[MIT License](LICENSE). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c1c4a9a..8b93a0f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,122 +1,172 @@ # Архитектура Virtuality -Virtuality — это платформа виртуализации в стиле Proxmox, но с более компактной и контролируемой архитектурой. - -## Цель - -Создать собственную систему управления виртуальными машинами на базе стабильных Linux-компонентов: - -- KVM; -- QEMU; -- libvirt; -- Cockpit на раннем этапе; -- собственная веб-панель на следующих этапах. - -## Базовая схема +Virtuality — одна нода: сервер с KVM/QEMU/libvirt, поверх которого работает панель на FastAPI. Панель не хранит собственную базу данных: источник правды о машинах — libvirt (`virsh`), о ноде — файлы в `/var/lib/virtuality/config`, о задачах — файлы в `/var/log/virtuality/operations`. Всё, что меняет сам сервер (службы, firewall, сеть, диски), делает один скрипт — `virtuality-ctl`, и панель вызывает его же. ```text -Веб-интерфейс - ↓ -Backend API - ↓ -Node services - ↓ -libvirt / QEMU / KVM - ↓ -Host OS / Storage / Network +Браузер + │ HTTP 8088 / HTTPS 8443 + ▼ +virtuality-web.service — uvicorn, web/app.py (FastAPI, Jinja2) + ├── web/features/* мастер, настройки, снимки, копии, загрузки, операции с машиной + ├── web/core.py общие примитивы, хранилище задач + ├── web/nodectl.py ───────► /usr/local/bin/virtuality-ctl (systemd, TLS, ufw, br0, диск, пароль) + ├── web/network_core.py ──► virsh net-*, nft, ufw, iptables (сеть NAT и пробросы) + └── virsh / virt-install / virt-clone / qemu-img ──► libvirtd ──► QEMU/KVM ``` -## Слои системы +--- -### 1. Web UI +## Код панели: `web/` -Будущая пользовательская панель Virtuality. +| Модуль | Назначение | +|---|---| +| `app.py` | Основное приложение: вход, обзор, машины (создание, действия, ресурсы, порядок загрузки, привод), ISO и образы дисков (загрузка, конвертация, распаковка), сеть и пробросы, обновления, задачи, журналы, экран машины (WebSocket-прокси к VNC), `/healthz`. В конце подключает роутеры из `features/`. | +| `core.py` | Общие примитивы: настройки из `.env`, сессии и авторизация (`require_auth`, `require_api_auth`, защита от cross-origin POST, ограничение попыток входа), `run_cmd`, `render` и окружение шаблонов, `valid_vm_name`, хранилище задач (`new_operation`, `run_operation`, `update_operation`, `finish_operation`, `active_operations_for`, `running_operations`, `interrupt_orphaned_operations`). Импорт `core` выставляет `LC_ALL=C.UTF-8`: вывод `virsh` разбирается панелью и не должен переводиться. | +| `auth.py` | Проверка пароля Linux-пользователя по `/etc/shadow`: через `crypt`/`spwd`, а на Python 3.13+ — через `libcrypt` (ctypes). Ограничение попыток входа. | +| `features/setup.py` | Мастер первичной настройки `/setup`: шаги install → welcome → account → access → network → storage → updates → finish, страница «Применяем настройки», ожидание перестройки сети в мост. | +| `features/settings.py` | Страница «Настройки»: доступ (HTTPS/порт), пароль, часовой пояс, обновления, сеть машин, хранилище, перезагрузка/выключение, архив настроек, отчёт для поддержки. | +| `features/snapshots.py` | Снимки: внутренние снимки libvirt (`virsh snapshot-*`), до 8 на машину, создание и возврат — фоновые задачи. | +| `features/backups.py` | Резервные копии: `vm.xml` + копии дисков + `meta.json` в `backups/<машина>/<ГГГГММДД-ЧЧММ>/`; восстановление под тем же или новым именем; запуск как скрипта для расписания. | +| `features/downloads.py` | Скачивание ISO и образов по ссылке и из каталога — фоновые задачи типа `download` с прогрессом, проверкой контрольной суммы и отменой. | +| `features/vmops.py` | Увеличение диска, клонирование (`virt-clone`), пауза, спящий режим (`virsh managedsave`), заметки (title/description машины), живая нагрузка (`virsh domstats`). | +| `nodectl.py` | Факты о ноде (сеть, диски, оборудование, часовые пояса, состояние первой загрузки, выбор мастера) и действия через `virtuality-ctl`. Действия, которые перезапускают панель, запускаются отложенно через `systemd-run`. | +| `network_core.py` | Сеть NAT `virtuality-nat`, резервации DHCP, пробросы портов и их применение (nftables, ufw, iptables). | +| `catalog.py` | Каталог облачных образов (Ubuntu 26.04/24.04, Debian 13, Alpine 3.23 для x86_64 и aarch64) и типы ОС для установки с ISO (`osinfo` для `virt-install`). | +| `cloudinit.py` | Seed cloud-init для машин из готовых образов: `user-data` (пользователь, хеш пароля SHA-512, SSH-ключи, рост диска, гостевой агент) и `meta-data`; подключается к `virt-install --cloud-init`. | +| `host_profile.py` | Профиль сервера (`x86_64`, `raspberry-arm64`, `orangepi5-arm64`, `generic-arm64`), проверки готовности; хранится в `config/host_profile.json`. | +| `update_core.py` | Проверка обновлений: канал, целевой ref, список пропущенных версий из `updates/versions.json`, запуск `scripts/apply_github_update.sh` через `systemd-run`. | +| `presenters.py` | Человеческие названия состояний, профили ресурсов машин, список типовых портов, форматирование размеров. | +| `restore_network.py` | Повторное применение пробросов после загрузки (`virtuality-network.service`). | +| `redirect_http.py` | Перенаправление HTTP → HTTPS на порту 8088, когда включён TLS (`virtuality-redirect.service`). | +| `templates/`, `static/` | Jinja2-шаблоны (`base.html` — каркас, `_ui.html` — компоненты), `app.css` (светлая и тёмная темы), `panel.js`, шрифт и иконки. Панель не ходит в интернет. | -Задачи: +Правило для роутов модулей: POST-маршруты вида `/vm/{name}/<модуль>/<действие>` — минимум четыре сегмента, иначе их перехватит общий `@app.post("/vm/{name}/{action}")`. -- список виртуальных машин; -- создание VM; -- управление питанием; -- консоль; -- хранилища; -- сеть; -- бэкапы; -- пользователи и роли. +### Фоновые задачи -### 2. Backend API +Долгие операции (создание машины, конвертация образа, скачивание, снимок, копия, клон, спящий режим) выполняются в потоках и записываются в `/var/log/virtuality/operations/.json` (статус, прогресс, сообщение, метаданные) и `.log` (журнал). Раздел «Задачи» и страницы машин читают эти файлы; `active_operations_for(vm, kinds)` защищает машину от двух одновременных операций. При старте панели `interrupt_orphaned_operations()` закрывает задачи, оставшиеся в состоянии «выполняется» после перезапуска. `virtuality-ctl maintenance` удаляет задачи старше 30 дней и оставляет не больше 300. -Единая точка управления нодой. +--- -Задачи: +## Управление нодой: `scripts/virtuality-ctl` -- принимать команды от Web UI; -- валидировать действия; -- работать с libvirt; -- вести журнал событий; -- отдавать состояние VM, storage и сети. +Один bash-скрипт, устанавливается в `/usr/local/bin`. Панель (через `nodectl.py`) и администратор в терминале делают одно и то же: -### 3. Node services +- **`reconfigure`** — читает `/var/lib/virtuality/config/web.env` и пересоздаёт systemd-юниты, logrotate, `/etc/default/libvirt-guests`, правила ufw; переводит VNC старых машин на `127.0.0.1`; перезапускает панель. +- **`set KEY=VALUE`** — проверяет и сохраняет настройку в `web.env`, затем `reconfigure` (для настроек обновления — только перезапись таймера). +- **TLS** — при `VIRTUALITY_TLS=1` создаёт самоподписанный сертификат EC P-256 на 10 лет в `config/tls/panel.{key,crt}` (SAN: имя сервера, localhost, адрес), uvicorn слушает `VIRTUALITY_TLS_PORT` (8443), а `virtuality-redirect.service` перенаправляет с HTTP-порта. +- **Мост `br0`** — `bridge [dhcp|static]` делает копию `/etc/netplan`, пишет `60-virtuality-br0.yaml`, проверяет `netplan generate`, взводит таймер `virtuality-netplan-revert` на 120 секунд и применяет; `bridge-confirm` снимает таймер, `bridge-revert` возвращает копию. Панель после перестройки сети сама вызывает подтверждение, когда снова получает ответ от сервера. +- **`storage-use /dev/sdX`** — только пустой диск без разделов: `mkfs.ext4`, перенос текущего содержимого `/var/lib/virtuality`, запись в `/etc/fstab` (`nofail`), монтирование. +- **`passwd`, `timezone`, `power`, `backup-config`, `restore-config`, `rollback`, `maintenance`, `support-bundle`, `uninstall`** — см. README. -Системные сервисы Virtuality. +Журнал действий: `/var/log/virtuality/virtuality-ctl.log`. -Задачи: +--- -- диагностика ноды; -- обновления; -- резервное копирование; -- мониторинг; -- фоновые задачи. +## Службы systemd -### 4. libvirt / QEMU / KVM +Создаются `virtuality-ctl reconfigure`: -Нижний слой виртуализации. +| Юнит | Что делает | +|---|---| +| `virtuality-web.service` | Панель: `uvicorn app:app` от root из `/opt/virtuality/web` с venv `/opt/virtuality/venv`; `Restart=always`. | +| `virtuality-redirect.service` | Только при TLS: HTTP → HTTPS. | +| `virtuality-network.service` | При загрузке заново применяет пробросы портов (`restore_network.py`). | +| `virtuality-auto-update.timer` / `.service` | Ночная проверка обновлений (`scripts/auto_update_check.sh`), 04:00 ± 30 минут; включён при `VIRTUALITY_AUTO_UPDATE=1`. | +| `virtuality-maintenance.timer` / `.service` | Ежедневное обслуживание: старые задачи, временные файлы, недокачанные загрузки, `journalctl --vacuum-size=300M`, запись занятости диска в `config/health.json`. | +| `libvirt-guests.service` | При выключении сервера машины выключаются корректно (`ON_SHUTDOWN=shutdown`, до 180 секунд), при загрузке не запускаются сами — только те, у кого включён автозапуск. | -Задачи: +Отдельно, из образа: `virtuality-firstboot.service` — первая загрузка (см. [IMAGE.md](IMAGE.md)); временные юниты `virtuality-netplan-revert` (автооткат моста), `virtuality-apply-*` (отложенное применение настроек) и `virtuality-update-*` (обновление вне cgroup панели, чтобы перезапуск панели не убил сам процесс обновления). -- запуск виртуальных машин; -- управление дисками; -- управление сетевыми интерфейсами; -- снапшоты; -- консольный доступ. +--- -### 5. Host OS +## Безопасность панели -Операционная система сервера. +- Вход — по имени и паролю системного пользователя (`VIRTUALITY_AUTH_USER`); проверяется по `/etc/shadow`, поэтому панель работает от root. После 5 неверных паролей адрес блокируется на 5 минут; сессия (подписанная cookie, ключ в `config/session_secret`) живёт 12 часов. +- POST-запросы принимаются только с того же origin; за reverse proxy нужно передавать исходный `Host` (и `Upgrade`/`Connection` для экрана машины). +- Экран машины: VNC у всех машин слушает `127.0.0.1`, панель проксирует его в WebSocket по подписанному токену и только при действующей сессии. Порты 5900–5999 в ufw закрыты. +- Пароль cloud-init хранится только как хеш SHA-512; секреты не попадают в журналы и отчёт для поддержки. +- Firewall: `install_virtuality_node.sh` включает ufw и разрешает SSH; `virtuality-ctl` открывает только порт панели (и HTTPS-порт), список открытых портов хранит в `config/ports.state`. -Целевые системы: +--- -- Debian 12; -- Ubuntu Server 22.04; -- Ubuntu Server 24.04. +## Сеть машин -## Пути +### NAT: `virtuality-nat` + +libvirt-сеть с мостом `virbr100`, подсеть `192.168.100.0/24`, шлюз `192.168.100.1`, DHCP `192.168.100.50–200`. Описание сохраняется в `/var/lib/virtuality/network/virtuality-nat.xml`. Создаётся мастером, страницей «Сеть и доступ» или автоматически при создании первой машины в режиме NAT. Включается `net.ipv4.ip_forward` (`/etc/sysctl.d/99-virtuality-forward.conf`) и ослабляется rp_filter (`98-virtuality-rpfilter.conf`). + +**Постоянные адреса.** Для каждой машины MAC-адрес вычисляется из её имени (`stable_mac`), и в сети создаётся резервация DHCP (`virsh net-update add ip-dhcp-host`) — машина всегда получает один и тот же адрес, панель знает его ещё до первого запуска. При удалении машины резервация освобождается. + +**Пробросы портов** хранятся в `/var/lib/virtuality/network/port_forwards.json` и применяются тремя способами сразу, чтобы работать на любом дистрибутиве: + +1. nftables — таблица `ip virtuality` в `/etc/virtuality/nftables/virtuality.nft` (DNAT, forward, masquerade); +2. ufw — правила `route allow`, список сохранённых правил в `network/ufw_rules.json`; +3. iptables — запасной вариант, все правила помечены комментарием `virtuality-forward`, чтобы их можно было найти и удалить. + +Адрес машины определяется через гостевой агент, `virsh domifaddr`, аренды DHCP по MAC машины, либо задаётся вручную. «Проверить» рядом с правилом делает TCP-подключение к машине и показывает команды для диагностики. + +### Домашняя сеть: мост `br0` + +Сервер объединяет свой проводной интерфейс в мост `br0` (netplan), машины подключаются к нему и получают адреса от роутера. Wi-Fi в мост объединить нельзя — тогда доступен только NAT. Как устроен автооткат — выше, в разделе про `virtuality-ctl`. + +--- + +## Хранилище ```text -/opt/virtuality # код и конфигурация проекта -/var/lib/virtuality # runtime-хранилище -/var/lib/virtuality/iso # ISO-образы -/var/lib/virtuality/images # диски VM -/var/lib/virtuality/backups # резервные копии -/var/log/virtuality # логи +/var/lib/virtuality/ +├── iso/ установочные образы; libvirt-пул virtuality-iso +├── images/ диски машин <имя>.qcow2; libvirt-пул virtuality-images +├── disk-images/ готовые образы систем (каталог, ссылки, загрузки с компьютера) +├── backups/ резервные копии машин (<машина>/<ГГГГММДД-ЧЧММ>/) и архивы настроек config-*.tar.gz +├── config/ web.env, setup.json, setup_done, wizard.json, host_profile.json, +│ session_secret, ports.state, health.json, tls/, netplan-backup/ +├── network/ port_forwards.json, ufw_rules.json, virtuality-nat.xml +├── update/ state.json, last_check.json, временные файлы обновления +└── tmp/ загрузки (.<имя>.part, .<имя>.uploading), seed cloud-init; TMPDIR панели ``` -## Принципы +Мастер и «Настройки» умеют отдать под всё это отдельный пустой диск — он монтируется в `/var/lib/virtuality` целиком. Память «спящих» машин libvirt хранит в `/var/lib/libvirt/qemu/save`. -- исходный код отдельно от runtime-данных; -- диски VM и ISO не хранятся в Git; -- конфиги должны быть прозрачными; -- установщик должен быть повторяемым; -- сетевые изменения делаются осторожно, чтобы не уронить SSH; -- архитектура документируется до активной разработки. +Код и служебные файлы: -## Этап v0.1 +```text +/opt/virtuality/source git-репозиторий, из него ставится панель и берутся обновления +/opt/virtuality/web установленная панель (+ .env с пользователем и ключом сессий) +/opt/virtuality/web.prev предыдущая версия для virtuality-ctl rollback +/opt/virtuality/venv virtualenv +/opt/virtuality/virtuality.env пути ноды (пишет install_virtuality_node.sh) +/etc/virtuality/image.env параметры образа (только после установки с ISO) +/etc/virtuality/nftables/ правила пробросов +/var/log/virtuality/ firstboot.log, update.log, virtuality-ctl.log, install_*.log, operations/ +``` -На этапе v0.1 создаётся фундамент: +--- -- установщик ноды; -- установка KVM/QEMU/libvirt; -- установка Cockpit; -- storage pools; -- структура проекта; -- GitHub-синхронизация; -- базовая диагностика. +## Обновления + +1. **Канал** (`VIRTUALITY_UPDATE_CHANNEL` в `web.env`): `stable` — последний тег `vX.Y.Z` (пока тегов нет — ветка `main`); `main` — ветка `main`. Логика одна и та же в `web/update_core.py` и `scripts/update_target.sh`. +2. **Проверка** (`/update/check`, ночной `scripts/auto_update_check.sh`): `git fetch --tags`, сравнение текущего коммита с целевым; обновление предлагается, только если целевой коммит — потомок текущего (нода никогда не откатывается сама). Список изменений берётся из `updates/versions.json` целевой версии. +3. **Применение** (`scripts/apply_github_update.sh`, запускается через `systemd-run`): `git checkout -B main <цель>` и `git clean`; если git недоступен — скачивание ZIP-архива с GitHub и `rsync`; затем `scripts/install_web_panel.sh` (панель копируется в `web.new`, старая переименовывается в `web.prev`, venv обновляется) и `systemctl restart virtuality-web`. Состояние — `/var/lib/virtuality/update/state.json`, журнал — `/var/log/virtuality/update.log`. +4. **Откат**: `virtuality-ctl rollback` меняет местами `web` и `web.prev`. + +Установщики и скрипты вне `web/`: + +```text +install.sh установка одной командой (проверки, пакеты, пользователь, клон, три установщика ниже) +install_virtuality_node.sh пакеты KVM/QEMU/libvirt (x86_64 или ARM64), пулы, ufw, группы пользователя +scripts/install_web_panel.sh панель, venv (офлайн из wheels/ при наличии), virtuality-ctl, юниты +scripts/install_healthcheck_command.sh команда vhealth (scripts/virtuality_healthcheck.sh) +scripts/detect_host_profile.sh профиль сервера для установщика +scripts/virtuality_firstboot.sh первая загрузка после ISO +scripts/auto_update_check.sh, apply_github_update.sh, update_target.sh обновления +scripts/bump_patch_version.py поднять patch-версию в VERSION +image/build-iso.sh, autoinstall.yaml, files/, grub/ сборка ISO +``` + +--- + +## Тесты и CI + +`tests/conftest.py` подменяет `run_cmd`, так что `virsh`, `systemctl`, `qemu-img` и остальные команды имитируются — тесты идут на любой машине без libvirt. `tests/dev_server.py` поднимает демо-панель с теми же подменами. `make check` = pyflakes + shellcheck + pytest; `.github/workflows/ci.yml` запускает это на Python 3.11–3.14, `.github/workflows/image.yml` собирает ISO. diff --git a/docs/FIRST_VM.md b/docs/FIRST_VM.md index 8649db4..99d9ef1 100644 --- a/docs/FIRST_VM.md +++ b/docs/FIRST_VM.md @@ -1,104 +1,138 @@ -# Первая тестовая виртуальная машина +# Первая виртуальная машина -Этот документ фиксирует проверку первого рабочего сценария Virtuality: сеть `br0`, libvirt storage pools и запуск тестовой VM. +Всё в этом документе делается через панель в браузере — терминал не понадобится. Считаем, что Virtuality установлена и мастер настройки пройден (см. README). -## Требования +Сначала — самый быстрый путь: готовая Ubuntu за минуту. Потом — установка Windows 11 с ISO, экран машины, доступ снаружи, снимки и резервные копии. -Перед созданием VM должны быть готовы: +--- -- KVM/QEMU; -- libvirt; -- Cockpit; -- storage pools `virtuality-images` и `virtuality-iso`; -- bridge `br0`; -- default route через `br0`. +## 1. Быстрая машина из каталога: Ubuntu Server 26.04 -Проверка: +Готовые (облачные) образы — это уже установленная система без пароля. Панель скачивает образ, делает из него диск нужного размера и через cloud-init настраивает первый вход: пользователя, пароль, SSH-ключ. + +### Скачать образ + +1. Откройте **Образы ОС** → вкладка **Готовые системы**. +2. В карточке **Каталог готовых систем** найдите **Ubuntu Server 26.04 LTS** (помечена «Рекомендуем») и нажмите **Скачать**. Сервер скачает образ с сайта Ubuntu сам и проверит контрольную сумму; ход виден в **Задачах** и на этой же странице. +3. Когда образ отмечен «Загружен», рядом появится кнопка **Создать машину** — она открывает мастер с уже выбранным образом. + +В каталоге также Ubuntu Server 24.04 LTS, Debian 13 и Alpine Linux 3.23. Свой образ (`.qcow2`, `.img`, `.raw`, `.img.xz`) можно загрузить с компьютера перетаскиванием или скачать по ссылке в карточке **Скачать по ссылке**. + +### Создать машину + +Шаг **1. Название и система**: + +- **Название машины** — латиница, цифры, точка, дефис, подчёркивание (например, `ubuntu-1`). Это же имя станет именем компьютера внутри системы. +- Источник **Готовая система** уже выбран, образ подставлен. +- Переключатель **Настроить первый вход** должен быть включён: у готовых образов нет пароля, без этого шага в машину не войти. + - **Пользователь** — панель подставляет стандартного пользователя образа (`ubuntu`, у Debian — `debian`, у Alpine — `alpine`); можно указать свой. Он получит права администратора (`sudo`). + - **Пароль** — не короче 6 символов. + - **SSH-ключ** — необязательно; вставьте содержимое `~/.ssh/id_ed25519.pub`. Если указать только ключ без пароля, вход по SSH будет только по ключу. + +Шаг **2. Мощность**: выберите профиль — **Мини** (1 ядро, 1 ГБ, 10 ГБ), **Стандарт** (2 ядра, 4 ГБ, 40 ГБ), **Мощная** (4 ядра, 8 ГБ, 80 ГБ) — или задайте ядра, память и диск сами. Диск образа увеличивается до выбранного размера, а система при первой загрузке сама расширит файловую систему. + +Шаг **3. Сеть**: **Автоматически** — машина получит постоянный адрес `192.168.100.x` в отдельной сети и выйдет в интернет через сервер; снаружи её не видно, пока вы не откроете доступ. **В локальной сети** (если в мастере настройки выбран мост) — машина станет устройством вашей домашней сети и получит адрес от роутера. + +Справа в **Итого** проверьте сводку и нажмите **Создать машину**. Панель откроет страницу задачи с живым журналом. Обычно в пределах минуты появится «Машина создана. Вход: ubuntu по паролю. Адрес в сети машин: 192.168.100.x». + +### Войти + +Откройте страницу машины (из **Обзора**) → **Открыть экран** и войдите с указанными пользователем и паролем. В машину сразу ставится гостевой агент, поэтому её адрес виден на странице машины и в **Сети и доступе** — по нему можно зайти по SSH из локальной сети (если выбран мост) или после проброса порта (см. ниже). + +--- + +## 2. Windows 11 с установочного ISO + +### Загрузить ISO + +1. Скачайте ISO Windows 11 с сайта Microsoft на свой компьютер. +2. **Образы ОС** → вкладка **Установочные образы** → перетащите файл в окно загрузки. Или вставьте прямую ссылку на `.iso` в карточку **Скачать по ссылке** — сервер скачает его сам. + +### Создать машину + +1. **Создать машину** → шаг 1: название (например, `win11`), источник **Установить с диска**, выберите ISO. +2. **Что будете устанавливать** → **Windows 11 / Server 2025**. Панель сама включит UEFI и виртуальный TPM 2.0 (без него Windows 11 отказывается ставиться), подберёт диск SATA и сетевую карту Intel — драйверы во время установки не нужны. +3. Шаг 2: профиль **Мощная** (4 ядра, 8 ГБ, 80 ГБ) — Windows 11 требует минимум 4 ГБ памяти и 64 ГБ диска. +4. Шаг 3: сеть — как и для Linux. +5. **Создать машину**. Задача завершится за несколько секунд — машина создана и запущена с установочного диска. + +### Установить систему + +1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова. +2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком. +3. После установки на странице машины откройте вкладку **Диски и загрузка** → **Привод CD/DVD** и извлеките установочный образ, чтобы машина больше не предлагала загрузку с него. + +Для других систем выбирайте тип **Linux** (Ubuntu, Debian, Fedora и другие), **Windows 10 / Server 2019–2022** (классическая загрузка) или **Другая система**. + +--- + +## 3. Экран машины + +- Кнопка **Открыть экран** на странице машины открывает экран в новой вкладке браузера — это как монитор, подключённый к машине. Работает и для установки системы, и когда сеть внутри машины ещё не настроена. +- Если курсор «застрял» внутри экрана, нажмите **Ctrl + Alt**. +- Экран доступен только через панель: VNC-порты машин наружу не открываются, соединение идёт через сервер по вашей сессии. +- Для повседневной работы удобнее подключаться по SSH (Linux) или по удалённому рабочему столу (Windows) — как открыть к ним доступ, ниже. + +--- + +## 4. Доступ к машине снаружи (проброс портов) + +В сети **Автоматически (NAT)** машины не видны ни из домашней сети, ни из интернета, пока вы не откроете нужный порт на сервере. + +1. Откройте **Сеть и доступ** → карточка **Открыть доступ**. +2. **Машина** — выберите машину (рядом показан её адрес). +3. **Что открыть** — сервис из списка: **SSH — удалённый доступ к Linux**, **Сайт (HTTP)**, **Сайт (HTTPS)**, **Удалённый рабочий стол Windows (RDP)**, **Minecraft**, MySQL, PostgreSQL, OpenVPN, WireGuard и другие — или **Другой порт**. +4. **Порт в машине** заполнится сам (для SSH — `22`), **Порт на сервере** — например, `2222`. Протокол TCP или UDP подставляется по сервису. Можно указать диапазон вида `10000-20000`. +5. Нажмите **Открыть доступ**. Теперь `адрес-сервера:2222` ведёт в машину: + + ```bash + ssh ubuntu@адрес-сервера -p 2222 + ``` + + Для Windows выберите **RDP** и подключайтесь приложением «Подключение к удалённому рабочему столу» по адресу `адрес-сервера:3389` (или другому порту сервера, который вы указали). + +Кнопка **Проверить** рядом с правилом покажет, доходит ли трафик до машины и слушает ли она порт. Правила восстанавливаются после перезагрузки сервера сами. Чтобы сервис был доступен из интернета, дополнительно пробросьте тот же порт на роутере на адрес сервера. + +Адреса в сети NAT закреплены за машинами по имени, поэтому пробросы не ломаются после перезагрузок. Если адрес машины не определился (например, система внутри ещё не запущена), в карточке **Адреса машин** его можно указать вручную. + +Машинам в режиме **В локальной сети** проброс не нужен: у них свой адрес от роутера, как у ноутбука или телефона. + +--- + +## 5. Снимок перед рискованными изменениями + +Снимок — точка восстановления машины: перед обновлением системы, экспериментом с настройками или установкой сомнительной программы. + +1. Страница машины → вкладка **Снимки** → карточка **Создать снимок**, при желании впишите описание («перед обновлением») → **Создать снимок**. +2. У работающей машины в снимок попадает и память — она на несколько секунд замрёт, зато вернётся ровно в то же состояние с открытыми программами. У выключенной сохраняется только диск. +3. Если что-то пошло не так — **Вернуться** рядом с нужным снимком. Машина откатится к тому моменту; всё, что сделано после снимка, пропадёт. +4. Ненужные снимки удаляйте: они занимают место внутри диска машины. На одну машину — до 8 снимков. + +Ограничения: снимки работают только с дисками qcow2 (все машины, созданные панелью, такие). Для машин с UEFI-прошивкой (Windows 11, образы Alpine, все машины на ARM64) внутренние снимки в текущей версии libvirt недоступны — панель предупредит; для них используйте резервные копии. + +--- + +## 6. Резервная копия + +Резервная копия — полная копия настроек машины и всех её дисков в отдельной папке на сервере. В отличие от снимка, она не зависит от диска машины и переживёт его повреждение. + +1. Страница машины → вкладка **Резервные копии** → **Создать копию** (заметка по желанию). Работающая машина будет корректно выключена, скопирована и запущена снова — в списке машин в это время видно, что происходит. +2. Копии всех машин собраны в разделе **Резервные копии**; они лежат в `/var/lib/virtuality/backups/<машина>/<дата-время>/`. Ход создания виден в **Задачах**, страницу можно закрыть. +3. **Восстановить**: выберите копию → укажите название — то же (тогда текущую машину придётся заменить, панель попросит подтверждение) или новое (получите вторую машину рядом с первой). Восстановленная машина создаётся выключенной. Файлы копии остаются на месте, восстанавливать из неё можно сколько угодно раз. +4. Перед восстановлением панель проверяет, хватит ли места в `/var/lib/virtuality`. + +Копию можно делать и по расписанию из терминала сервера — например, задачей cron: ```bash -sudo vhealth -ip -br a -ip route -virsh pool-list --all +sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия" ``` -Нормальная сеть: +--- -```text -enp2s0 UP -br0 UP 10.0.0.200/24 -default via 10.0.0.1 dev br0 -``` +## Что дальше -## Создание тестовой VM - -```bash -cd ~/virtuality -git pull -sudo bash scripts/create_test_vm.sh -``` - -По умолчанию создаётся VM: - -```text -Name: test-alpine -RAM: 1024 MB -CPU: 1 vCPU -Disk: 8 GB qcow2 -ISO: Alpine Linux -Network: br0 -Graphics: VNC -``` - -Можно задать имя: - -```bash -sudo bash scripts/create_test_vm.sh test-alpine-2 -``` - -## Проверка - -```bash -virsh list --all -sudo virsh vncdisplay test-alpine -sudo vhealth -``` - -Если `vncdisplay` показывает `:0`, значит порт VNC — `5900`. - -Если показывает `:1`, значит порт VNC — `5901`. - -## Доступ через Cockpit - -Открыть: - -```text -https://SERVER_IP:9090 -``` - -Дальше: - -```text -Virtual machines → test-alpine → Console -``` - -## Что считается успешным результатом - -Этап считается пройденным, если: - -- VM появилась в `virsh list --all`; -- VM запущена; -- консоль открывается в Cockpit или через VNC; -- VM подключена к bridge `br0`; -- `sudo vhealth` не показывает критических ошибок по Virtuality. - -## Следующий этап - -После первой тестовой VM можно переходить к разработке собственной панели управления Virtuality: - -- список VM; -- статусы VM; -- запуск/остановка; -- просмотр storage pools; -- создание VM через UI; -- базовый журнал событий. +- **Клонировать** (меню на странице машины) — точная копия с новым именем и своим адресом, удобно для тестов. +- **Спящий режим** — сохранить память на диск и выключить; после запуска машина продолжит с того же места. +- **Автозапуск** — переключатель на странице машины: машина включится сама после перезагрузки сервера. +- **Ресурсы** — процессор и память можно поменять, когда машина выключена; диск — увеличить в любой момент во вкладке **Диски и загрузка**. +- Раздел **Помощь** в панели повторяет главное из этого документа. diff --git a/docs/IMAGE.md b/docs/IMAGE.md index 7d14b8e..2302572 100644 --- a/docs/IMAGE.md +++ b/docs/IMAGE.md @@ -1,54 +1,38 @@ # Установочный образ Virtuality -Virtuality можно поставить двумя способами: +Загрузочный ISO ставит Virtuality на чистый сервер «с нуля», как Proxmox: записали на флешку, загрузились, ответили на три экрана — после перезагрузки панель уже открыта в браузере, а мастер настройки показывает, как ставятся остальные компоненты. -1. **Скрипт на готовый сервер** — `curl ... install.sh | bash` (см. README). -2. **Установочный ISO** — загрузочный образ «с нуля», как у Proxmox: записываете на флешку, загружаетесь, отвечаете на 3 экрана, после перезагрузки получаете готовую ноду. - -Этот документ про второй способ. - ---- - -## Что внутри образа - -Образ собирается из официального, ничем не изменённого **Ubuntu Server 26.04 LTS live-server ISO** (amd64 или arm64; поддержка до 2031 года) и дополняется: +Образ собирается из официального, ничем не изменённого **Ubuntu Server 26.04 LTS live-server ISO** (amd64 или arm64) и дополняется: ```text -/autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall) -/virtuality/source # исходники Virtuality (git-репозиторий нужной версии) -/virtuality/source/wheels # Python-колёса web-панели для офлайн-установки -/virtuality/image.env # параметры сборки: порт панели, автообновление, пользователь -/virtuality/virtuality-firstboot.service -/boot/grub/grub.cfg # меню «Install Virtuality X.Y.Z» +/autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall) +/virtuality/source # исходники Virtuality нужной версии (git-репозиторий) +/virtuality/source/wheels # Python-колёса панели и pip — для установки без интернета +/virtuality/image.env # параметры сборки: версия, порт панели, автообновление +/virtuality/virtuality-firstboot.service # служба первой загрузки +/boot/grub/grub.cfg # меню «Установить Virtuality X.Y.Z» с фирменной темой +/boot/grub/themes/virtuality/ # тема GRUB ``` Загрузочные записи BIOS и UEFI берутся из оригинального ISO без изменений. -## Как проходит установка +--- -1. Загрузка с ISO → пункт **Install Virtuality**. -2. Установщик Ubuntu спрашивает только **сеть**, **диск** и **пользователя** (имя, пароль, hostname). Язык, раскладка, SSH-сервер и остальное заданы автоматически. -3. Установщик копирует Virtuality в `/opt/virtuality/source` и включает `virtuality-firstboot.service`. -4. После перезагрузки `virtuality-firstboot` (нужен интернет) ставит KVM/QEMU/libvirt, Cockpit, web-панель, `vhealth` и консольный dashboard. Обычно это 5–15 минут. Если сети нет или apt упал, попытка повторяется каждую минуту до успеха. -5. Готово: +## Где взять готовый образ -```text -Web-панель: http://SERVER_IP:8088 (логин — пользователь, созданный в установщике) -Cockpit: https://SERVER_IP:9090 -``` +Workflow `.github/workflows/image.yml` собирает ISO в GitHub Actions: -Адрес панели также показывается на экране входа консоли. Ход первой настройки: +- автоматически при выпуске — пуш тега `v*` (amd64 и arm64); +- при pull request, который меняет `image/**`, `scripts/virtuality_firstboot.sh`, `web/requirements.txt` или сам workflow; +- вручную: Actions → **Build ISO image** → Run workflow → архитектура `amd64`, `arm64` или `both`. -```bash -sudo journalctl -fu virtuality-firstboot -cat /var/log/virtuality/firstboot.log -``` +После сборки workflow проверяет, что в образе есть загрузочная запись UEFI, `autoinstall.yaml` разбирается как YAML, а в `grub.cfg` есть пункт Virtuality. Образы Ubuntu Server больше лимита GitHub Releases (2 ГБ на файл), поэтому ISO публикуются как **artifacts** запуска (`virtuality-iso-amd64`, `virtuality-iso-arm64`) и хранятся 30 дней. В артефакте — `.iso` и `.sha256`. --- -## Сборка ISO +## Сборка своими руками -Нужна Linux-машина (root не нужен), ~10 GB свободного места и пакеты: +Нужна Linux-машина (root не нужен), около 10 ГБ свободного места и пакеты: ```bash sudo apt install -y xorriso git curl python3-pip @@ -63,81 +47,154 @@ make iso # amd64 make iso ARCH=arm64 # ARM64-серверы с UEFI ``` -Скрипт сам скачает последний Ubuntu 26.04.x live-server ISO, проверит SHA256 и положит результат в `dist/`: +`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`: ```text -dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso -dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso.sha256 +dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso +dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso.sha256 ``` -Скачанный ISO Ubuntu кешируется в `.cache/iso`. Уже скачанный образ можно передать явно: +Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно: ```bash ./image/build-iso.sh --ubuntu-iso ~/Downloads/ubuntu-26.04.1-live-server-amd64.iso ``` -### Параметры +### Параметры `image/build-iso.sh` ```text ---arch amd64|arm64 архитектура +--arch amd64|arm64 архитектура (по умолчанию amd64) --ubuntu 26.04|24.04 база образа (по умолчанию 26.04 LTS) +--ubuntu-iso PATH использовать локальный live-server ISO вместо скачивания +--cache-dir DIR где хранить скачанные ISO Ubuntu (по умолчанию .cache/iso) +--output DIR куда класть результат (по умолчанию dist) --ref GIT_REF какую версию Virtuality положить в образ (по умолчанию HEAD) ---web-port PORT порт web-панели (по умолчанию 8088) ---no-auto-update не включать ночное автообновление с GitHub ---locale / --keyboard / --timezone +--repo-url URL откуда установленная нода будет получать обновления +--web-port PORT порт панели на установленной ноде (по умолчанию 8088) +--no-auto-update не включать ночное автообновление +--locale LOCALE локаль установщика и системы (по умолчанию en_US.UTF-8) +--keyboard LAYOUT раскладка (по умолчанию us) +--timezone TZ часовой пояс (по умолчанию geoip — установщик определит по сети; мастер настройки может сменить) --no-wheels не класть Python-колёса (тогда первая загрузка скачает их из PyPI) ---repo-url URL откуда нода будет получать обновления ``` -Через make параметры передаются так: +Полностью автоматический режим (вопросов нет, **стирается самый большой диск**): + +```text +--unattended автоматическая установка +--username NAME администратор (логин в панель), обязателен с --unattended +--password-hash HASH хеш crypt(3), например от: openssl passwd -6 +--hostname NAME имя сервера (по умолчанию virtuality) +``` + +`./image/build-iso.sh --help` печатает тот же список. Через `make` параметры передаются так: ```bash make iso ISO_ARGS="--web-port 8089 --no-auto-update --timezone Europe/Moscow" ``` +### Что кладётся в образ + +- **Исходники**: `git clone` репозитория на указанный `--ref`; у клона `origin` переключается на `--repo-url`, чтобы нода обновлялась оттуда. +- **Колёса**: `pip download` для Python целевого выпуска (3.14 для Ubuntu 26.04, 3.12 для 24.04) и архитектуры, только бинарные `manylinux`-колёса из `web/requirements.txt`. Сам `pip` тоже кладётся: в Ubuntu Server нет `python3-venv`, и установщик панели поднимает pip из этого колеса в virtualenv, созданный `--without-pip`. Благодаря этому панель ставится до появления сети. +- **`image.env`**: `VIRTUALITY_IMAGE_VERSION`, `VIRTUALITY_IMAGE_BUILT`, `VIRTUALITY_WEB_PORT`, `VIRTUALITY_AUTO_UPDATE` и, в автоматическом режиме, `VIRTUALITY_USER`. Установщик копирует файл в `/etc/virtuality/image.env`, его читает первая загрузка. + ### Полностью автоматическая установка -Для массовой раскатки можно собрать образ, который не задаёт вопросов: +Для массовой раскатки: ```bash make iso ISO_ARGS="--unattended --username admin --password-hash '$(openssl passwd -6)' --hostname node01" ``` -> ⚠️ Такой образ загружается в установку сам (через 10 секунд в меню) и **без подтверждения стирает самый большой диск** машины. Файл получает суффикс `-unattended`. Не оставляйте такую флешку в серверах. +> ⚠️ Такой образ сам уходит в установку через 10 секунд в меню и **без подтверждения стирает самый большой диск** машины. Файл получает суффикс `-unattended`, пункты меню — пометку «автоматически, диск будет стёрт». Не оставляйте такую флешку в серверах. + +--- + +## Что спрашивает установщик + +Меню загрузки: **Установить Virtuality 0.11.0** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти. + +Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu: + +1. **Сеть** — обычно достаточно подтвердить адрес по DHCP. +2. **Диск** — какой диск использовать. Разметка: LVM на весь диск. +3. **Пользователь** — имя сервера, имя пользователя и пароль. Этот пользователь становится администратором панели (и входит по SSH). + +Язык, раскладка, часовой пояс (по умолчанию определяется по сети), SSH-сервер с входом по паролю и всё остальное заданы заранее. В конце установщик копирует исходники в `/opt/virtuality/source`, `image.env` в `/etc/virtuality/` и включает `virtuality-firstboot.service`. Затем — перезагрузка. + +--- + +## Первая загрузка + +`scripts/virtuality_firstboot.sh` запускается службой `virtuality-firstboot.service` один раз. Порядок шагов сделан так, чтобы панель появилась первой и показывала прогресс всего остального: + +| Шаг | Что происходит | Что видно | +|---|---|---| +| `panel` | `scripts/install_web_panel.sh` с `VIRTUALITY_PANEL_ONLY=1`: панель ставится офлайн из колёс и запускается. | Через минуту после загрузки панель отвечает на `http://адрес:8088`; на экране сервера — подсказка `Virtuality настраивается`. | +| `network` | Ожидание маршрута по умолчанию и DNS. | Если через 20 секунд сети нет, мастер пишет «Нет доступа в интернет. Подключите кабель или настройте сеть — установка продолжится сама». | +| `virtualization` | `install_virtuality_node.sh`: пакеты KVM/QEMU/libvirt, пулы, firewall. Самый долгий шаг, 5–15 минут. | Мастер показывает, какой пакет скачивается, распаковывается или настраивается. | +| `tools` | `scripts/install_healthcheck_command.sh`: команда `vhealth`. | | +| `finish` | `virtuality-ctl reconfigure`: юниты, firewall, перезапуск панели; на экран сервера выводится адрес панели и логин. | Мастер переходит к шагу «Сервер». | + +Прогресс пишется в `/var/lib/virtuality/config/setup.json` (`stage`, `message`, список шагов со статусами), мастер опрашивает его через `/api/setup/state`. Каждый шаг идемпотентен и отмечается флагом в `/var/lib/virtuality/.firstboot-steps/`; если что-то упало, systemd перезапускает скрипт через 60 секунд, и он продолжает с первого незавершённого шага — мастер в это время показывает ошибку и хвост журнала. По завершении создаётся `/var/lib/virtuality/.firstboot-done`, и служба больше не запускается. + +Если образ собран с `--no-wheels`, офлайн-установка панели не удастся; скрипт дождётся сети и поставит зависимости из PyPI. + +Смотреть ход установки с сервера: + +```bash +sudo journalctl -fu virtuality-firstboot +cat /var/log/virtuality/firstboot.log +``` + +Логин в панель — пользователь из установщика, пароль — его пароль. Дальше вас ведёт мастер настройки (см. README). + +--- + +## Тема загрузочного меню + +`image/grub/theme.txt` — тема GRUB (gfxmenu), `background.png` — фон 1024×768, `select_*.png` — подсветка выбранного пункта. `build-iso.sh` дописывает в `grub.cfg` подключение темы (`gfxterm`, шрифт `unicode.pf2`, `gfxmode=1024x768`); если модуль или шрифт недоступны, GRUB показывает обычное текстовое меню. Пункты меню переводятся на русский, таймаут — 10 секунд. Контрольная сумма изменённого `grub.cfg` обновляется в `md5sum.txt`, чтобы проверка целостности ISO проходила. + +Фон рендерится из `image/grub/background.html` (тот же стиль, что у страницы входа панели), как описано в `image/grub/README.md`. + +--- + +## Проверка образа + +Сверьте контрольную сумму скачанного или собранного образа: + +```bash +sha256sum -c virtuality-0.11.0-ubuntu-26.04-amd64.iso.sha256 +``` + +Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI): + +```bash +xorriso -indev virtuality-0.11.0-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI +xorriso -osirrox on -indev virtuality-0.11.0-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml +``` --- ## Запись на флешку -Linux / macOS: +Нужна флешка от 4 ГБ. Все данные на ней будут стёрты. -```bash -sudo dd if=dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync -``` +- **Windows**: [Rufus](https://rufus.ie) (режим записи DD) или [balenaEtcher](https://etcher.balena.io). +- **macOS**: balenaEtcher. +- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`): -Windows: Rufus (режим DD) или balenaEtcher. + ```bash + sudo dd if=dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync + ``` -Проверка целостности: - -```bash -cd dist && sha256sum -c virtuality-0.10.0-ubuntu-26.04-amd64.iso.sha256 -``` - ---- - -## Сборка в GitHub Actions - -Workflow `.github/workflows/image.yml` собирает ISO: - -- автоматически при пуше тега `v*` (amd64 и arm64); -- вручную: Actions → **Build ISO image** → Run workflow. - -Образы Ubuntu Server больше лимита GitHub Releases (2 GB на файл), поэтому ISO публикуются как **artifacts** workflow и хранятся 30 дней. +Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**. --- ## Ограничения -- База образа — Ubuntu Server 26.04 LTS (или 24.04 LTS через `--ubuntu 24.04`). -- Для первой загрузки нужен интернет: пакеты KVM/libvirt/Cockpit ставятся из репозиториев Ubuntu. Python-зависимости панели уже лежат в образе. -- ARM64-образ рассчитан на серверы с UEFI. Raspberry Pi и Orange Pi 5 грузятся иначе — для них используйте их штатный образ Ubuntu Server и `install.sh`. +- База — Ubuntu Server 26.04 LTS или 24.04 LTS (`--ubuntu 24.04`). +- Для первой загрузки нужен интернет: пакеты KVM/libvirt ставятся из репозиториев Ubuntu. Панель и её Python-зависимости уже в образе. +- ARM64-образ рассчитан на серверы с UEFI. Raspberry Pi и Orange Pi 5 загружаются иначе — для них используйте штатный Ubuntu Server для платы и `install.sh`. diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index e331363..ca500f7 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -1,95 +1,77 @@ -# Системные требования Virtuality +# Требования -Virtuality — это серверная система управления виртуализацией на базе KVM/QEMU/libvirt/Cockpit и собственной web-панели. +## Коротко -## Минимальные требования +Подойдёт почти любой компьютер или мини-ПК не старше 10 лет с проводной сетью и включённой в BIOS/UEFI виртуализацией. Для одной-двух небольших машин достаточно 4 ГБ памяти; для Windows и нескольких машин сразу — 16 ГБ и SSD. -Подходят только для тестового стенда и одной-двух лёгких VM. +## Операционная система -```text -CPU: 2 ядра с аппаратной виртуализацией Intel VT-x / AMD-V -RAM: 4 GB -Диск /: минимум 8 GB свободно -Диск под /var/lib/virtuality: минимум 20 GB свободно -OS: Ubuntu Server 24.04 LTS / Debian-like с apt -Network: один проводной интерфейс -``` +Virtuality работает на: -## Рекомендуемые требования +- **Ubuntu Server 26.04 LTS** — основа установочного ISO, рекомендуется; +- **Ubuntu Server 24.04 LTS**; +- **Debian 13**. -Для нормальной работы нескольких VM. +Другие системы на базе Debian/Ubuntu с `apt` скорее всего заработают, но не проверяются. Нужен systemd и netplan (для режима «домашняя сеть»). Установка с ISO делает всё сама; на готовую систему Virtuality ставится скриптом `install.sh` — ставьте её на чистый сервер, а не на рабочий компьютер. -```text -CPU: 4+ ядра с Intel VT-x / AMD-V -RAM: 16+ GB -Диск: 100+ GB SSD/NVMe под /var/lib/virtuality -Network: проводной интерфейс 1 Gbit/s+ -OS: Ubuntu Server 24.04 LTS -``` +## Процессор и память -## Проверки установщика +| | Минимум | Рекомендуется | +|---|---|---| +| Процессор | 2 ядра x86_64 с Intel VT-x / AMD-V | 4+ ядер | +| Память | 4 ГБ | 16 ГБ и больше | +| Системный диск | 8 ГБ свободно на `/` | SSD | +| Диск под машины | 20 ГБ свободно в `/var/lib` | 100+ ГБ SSD/NVMe, лучше отдельный диск | +| Сеть | один проводной интерфейс | 1 Гбит/с | -`bootstrap.sh` и `install_virtuality_node.sh` проверяют: +Как считать память: сервер и панель занимают около 1 ГБ, остальное делят машины. Ubuntu Server внутри машины — от 1 ГБ, Windows 11 — от 4 ГБ (лучше 8). Диск машины занимает место постепенно, по мере заполнения, но резервные копии и снимки требуют запаса: планируйте хотя бы вдвое больше, чем суммарный размер дисков машин. -- наличие root-прав; -- наличие `apt`; -- свободное место на `/`; -- свободное место для `/var/lib`; -- объём RAM; -- количество CPU cores; -- наличие флага аппаратной виртуализации `vmx` или `svm`. +**Виртуализация должна быть включена в BIOS/UEFI** (Intel VT-x / Virtualization Technology, AMD-V / SVM Mode). Без неё машины будут работать через медленную программную эмуляцию; панель предупредит об этом в разделе «Сервер», а `sudo vhealth` покажет ошибку `/dev/kvm missing`. -## Значения по умолчанию +Внутри другой виртуальной машины или на VPS Virtuality работает, только если включена вложенная виртуализация (nested virtualization). + +## ARM64 + +Платы и серверы на ARM64 (Raspberry Pi 4/5, Orange Pi 5, серверы с UEFI) поддерживаются: панель определяет профиль, ставит `qemu-system-arm` и прошивку UEFI для ARM. Гости на таких серверах — **только ARM64** (в каталоге готовых систем есть образы aarch64 для Ubuntu, Debian и Alpine); образы x86_64 на них не запускаются. Установка Windows на ARM64 не проверялась. + +Для Raspberry Pi и Orange Pi желательно 8 ГБ памяти и загрузка с SSD/NVMe. Установочный ISO для них не подходит (они грузятся иначе) — используйте штатный Ubuntu Server для платы и `install.sh`. ARM64-ISO предназначен для серверов с UEFI. + +## Сеть + +- Проводное подключение к роутеру. По Wi-Fi сервер работать может, но режим «домашняя сеть» (мост) недоступен — только NAT. +- Доступ в интернет при установке: первая загрузка после ISO и `install.sh` скачивают пакеты из репозиториев Ubuntu/Debian (сотни мегабайт). Панель после установки в интернет не ходит, кроме проверки обновлений и скачивания образов по вашей команде. +- Порты на сервере: `8088` (панель по HTTP), `8443` (панель по HTTPS), `22` (SSH). Открывать их на роутере наружу не обязательно и без HTTPS не рекомендуется. +- Для сети NAT машинам выделяется подсеть `192.168.100.0/24` — она не должна совпадать с вашей домашней сетью. Если роутер раздаёт именно `192.168.100.x`, выбирайте режим «домашняя сеть» или смените подсеть роутера. +- Если хотите, чтобы сервисы машин были доступны из интернета, потребуется проброс портов на роутере на адрес сервера (и «белый» адрес от провайдера). + +## Что проверяет установщик + +`install.sh` и `install_virtuality_node.sh` перед установкой проверяют права root, наличие `apt`, свободное место на `/` и в `/var/lib`, память, количество ядер и флаг виртуализации `vmx`/`svm`. Пороги можно изменить переменными окружения: ```text VIRTUALITY_MIN_ROOT_FREE_MB=8192 VIRTUALITY_MIN_VAR_FREE_MB=20480 VIRTUALITY_MIN_RAM_MB=4096 VIRTUALITY_MIN_CPU_CORES=2 +VIRTUALITY_SKIP_REQUIREMENTS=1 # отключить проверки совсем — только для тестов ``` -## Переопределение требований - -Для тестового стенда можно временно уменьшить требования: +Например, для тестового стенда с маленьким диском: ```bash -curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/bootstrap.sh | sudo VIRTUALITY_MIN_VAR_FREE_MB=8192 bash +curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_MIN_VAR_FREE_MB=8192 bash ``` -Полностью отключить preflight-проверки можно так: +Установка с ISO эти пороги не применяет: диск и память проверяются мастером настройки, который покажет предупреждение, но не остановит установку. -```bash -curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/bootstrap.sh | sudo VIRTUALITY_SKIP_REQUIREMENTS=1 bash -``` - -Использовать отключение проверок стоит только для тестов. Для реального сервера это плохая идея: VM быстро съедают диск. - -## Как быстро проверить место +Освободить место перед установкой на существующей системе: ```bash df -h -df -i -du -xh / --max-depth=1 2>/dev/null | sort -h -``` - -## Очистка перед установкой - -```bash -sudo apt clean -sudo apt autoclean -sudo apt autoremove -y -sudo rm -rf /var/lib/apt/lists/* -sudo rm -rf /tmp/* /var/tmp/* +sudo apt clean && sudo apt autoremove -y sudo journalctl --vacuum-time=3d ``` -## Где Virtuality хранит данные +## Браузер -```text -/opt/virtuality # системные конфиги и установленная web-панель -/home//virtuality # GitHub-репозиторий проекта -/var/lib/virtuality/iso # ISO-образы -/var/lib/virtuality/images # qcow2-диски VM -/var/lib/virtuality/backups # будущие backup VM -/var/log/virtuality # логи установки и диагностики -``` +Панель работает в актуальных Chrome, Firefox, Safari и Edge, в том числе на телефоне. Экран машины (noVNC) требует поддержки WebSocket — она есть во всех перечисленных. При включённом HTTPS сертификат выпускается самим сервером, поэтому браузер один раз спросит, доверять ли ему. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0fc462d..1c5171f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,115 +1,46 @@ -# Roadmap Virtuality +# Планы -Документ фиксирует направление разработки, чтобы проект не превратился в хаотичный комбайн. +Virtuality — панель для одного домашнего сервера. Этот документ честно фиксирует, что уже есть в версии 0.11 и что планируется дальше. Порядок в списке планов — примерный приоритет, сроков нет. Подробный журнал изменений по версиям — в `updates/versions.json` и в разделе «Обновления» панели. -## v0.1 — Фундамент ноды +## Сделано в 0.11 -Цель: получить рабочую базу для запуска виртуализации. +**Установка и настройка** -Задачи: +- Установочный ISO на базе Ubuntu Server 26.04 LTS (amd64, arm64), сборка `make iso` и в GitHub Actions, полностью автоматический режим. +- Первая загрузка: панель ставится первой и показывает ход установки в браузере, при ошибке продолжает сама. +- Мастер настройки: часовой пояс, пароль, HTTPS, сеть машин (NAT или домашняя сеть с автооткатом), отдельный диск под машины, обновления. +- Страница «Настройки», команда `virtuality-ctl`, установка одной командой на Ubuntu 24.04 / 26.04 и Debian 13. -- базовый установщик `install_virtuality_node.sh`; -- установка KVM/QEMU/libvirt; -- установка Cockpit и Cockpit Machines; -- создание директорий хранения; -- создание libvirt storage pools; -- базовая настройка firewall; -- подготовка GitHub-синхронизации; -- документация проекта. +**Машины** -## v0.2 — Сеть и диагностика +- Создание с ISO с выбором типа системы: Linux, Windows 11 / Server 2025 (UEFI + TPM), Windows 10, другая. +- Машина из готового образа за минуту: каталог (Ubuntu 26.04/24.04, Debian 13, Alpine), скачивание по ссылке, cloud-init с пользователем, паролем и SSH-ключом, гостевой агент. +- Снимки (до 8 на машину), резервные копии с восстановлением, клонирование, увеличение диска, пауза и спящий режим, заметки, живая нагрузка. +- Экран машины в браузере только через панель (VNC закрыт снаружи). -Цель: безопасно подготовить сетевую основу. +**Сеть и сервер** -Задачи: +- Сеть NAT с постоянными адресами, пробросы портов с проверкой и восстановлением после перезагрузки, домашняя сеть через мост `br0`. +- HTTPS для панели, стабильный канал обновлений и ранний доступ, откат, ежедневное обслуживание, отчёт для поддержки, архив настроек. +- Убрано: Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE, старые вспомогательные скрипты. -- диагностический скрипт ноды; -- определение активного сетевого интерфейса; -- безопасная настройка bridge `br0`; -- резервная копия сетевых конфигов перед изменением; -- откат сетевой конфигурации; -- улучшение логов установщика. +## Планируется -## v0.3 — Прототип панели +- **Резервные копии по расписанию из панели** — сейчас копия по расписанию делается только командой в терминале; нужен график и хранение N последних копий. +- **Копии на внешний диск или сетевую папку** — сейчас копии лежат только в `/var/lib/virtuality/backups`. +- **Контейнеры LXC** — лёгкие Linux-контейнеры рядом с машинами, как в Proxmox. +- **Проброс устройств (PCI/USB passthrough)** — видеокарты, USB-ключи, диски целиком в машину. +- **Второй сервер (кластер из двух нод)** — общий список машин, перенос машины между серверами, копии на соседний сервер. +- **Пользователи и роли** — сейчас в панель входит один администратор; нужны дополнительные пользователи с ограниченными правами. +- **Уведомления** — о завершении задач, ошибках, нехватке места и доступных обновлениях (почта, Telegram). +- **Шаблоны машин** — сохранить настроенную машину как шаблон и создавать из него новые. +- **Дополнительные диски и сети у машины** — второй диск, вторая сетевая карта, изменение сети существующей машины. +- **Сертификат Let's Encrypt** — вместо самоподписанного, для доступа к панели по доменному имени. +- **История нагрузки** — графики процессора, памяти и диска за день и неделю по серверу и машинам. +- **Проверка обновлений образов каталога** — предлагать обновить скачанный облачный образ, когда вышел новый. -Цель: начать собственную панель управления. +## Не планируется -Задачи: - -- лёгкая веб-панель; -- авторизация; -- список виртуальных машин; -- статус VM; -- старт/стоп/перезагрузка VM через libvirt; -- отображение storage pools; -- базовый журнал событий. - -## v0.4 — Создание VM - -Цель: добавить мастер создания виртуальных машин. - -Задачи: - -- менеджер ISO; -- форма создания VM; -- выбор CPU/RAM/disk; -- выбор ISO; -- выбор сети; -- создание qcow2-диска; -- запуск установки ОС; -- базовая консоль. - -## v0.5 — Обновления и бэкапы - -Цель: сделать платформу обслуживаемой. - -Задачи: - -- автообновлятор; -- резервная копия конфигов перед обновлением; -- история обновлений; -- ручной backup VM; -- список бэкапов; -- восстановление из backup. - -## v0.6 — Пользователи и роли - -Цель: подготовить систему к реальному использованию несколькими пользователями. - -Задачи: - -- роли: admin, operator, viewer; -- ограничения действий по ролям; -- журнал действий пользователей; -- базовые настройки безопасности. - -## v0.7 — Мониторинг - -Цель: видеть состояние ноды без терминального шаманства. - -Задачи: - -- CPU/RAM/disk usage; -- статус libvirt; -- статус Cockpit; -- количество VM; -- состояние storage pools; -- алерты; -- Telegram/email-уведомления в будущем. - -## v1.0 — Первый стабильный релиз - -Цель: рабочая одиночная нода Virtuality. - -Минимальный состав: - -- установка одной командой; -- управление VM; -- создание VM; -- сеть bridge; -- ISO manager; -- backup/restore; -- web dashboard; -- базовая безопасность; -- документация; -- обновлятор. +- Многосерверные кластеры с общим хранилищем, Ceph, живая миграция — это уровень Proxmox для дата-центров, не для дома. +- Поддержка других гипервизоров, кроме KVM/QEMU/libvirt. +- Гости x86_64 на ARM64-серверах через эмуляцию — слишком медленно, чтобы этим пользоваться. From 6b4d4f899cb3af3507176d52c2767e98973797bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 15:42:02 +0000 Subject: [PATCH 28/32] =?UTF-8?q?cloud-init:=20=D0=B2=D1=80=D0=B5=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B?= =?UTF-8?q?=20=D0=B1=D0=B5=D1=80=D1=83=D1=82=20=D0=BF=D1=83=D1=82=D1=8C=20?= =?UTF-8?q?=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D0=B0=20=D0=B8?= =?UTF-8?q?=D0=B7=20core=20=D0=BD=D0=B0=20=D0=BC=D0=BE=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тесты без прав root падали: путь /var/lib/virtuality фиксировался при импорте модуля. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- web/cloudinit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/cloudinit.py b/web/cloudinit.py index 1bb2561..19e964a 100644 --- a/web/cloudinit.py +++ b/web/cloudinit.py @@ -10,7 +10,7 @@ import tempfile from pathlib import Path import catalog -from core import STORAGE_DIR +import core USER_RE = re.compile(r"[a-z_][a-z0-9_-]{0,31}") SSH_KEY_RE = re.compile(r"^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com) [A-Za-z0-9+/=]+( .*)?$") @@ -92,7 +92,7 @@ def build_meta_data(name: str) -> str: def write_seed(name: str, user_data: str, meta_data: str) -> tuple[Path, Path]: """Files for virt-install --cloud-init; the folder is removed once the machine is created.""" - root = STORAGE_DIR / "tmp" + root = core.STORAGE_DIR / "tmp" root.mkdir(parents=True, exist_ok=True) folder = Path(tempfile.mkdtemp(prefix=f"cloudinit-{name}-", dir=str(root))) user_file, meta_file = folder / "user-data", folder / "meta-data" From 3ad50faa461e428935b9292125e73e0ea488a006 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:05:11 +0000 Subject: [PATCH 29/32] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B0=20=D0=B1=D1=8B=D0=BB=D0=B0=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0:=20libvi?= =?UTF-8?q?rt=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=B0=D0=B2=D0=BB=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BD=D0=B0=20=D0=B2=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=B5=20=D0=BE=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=84=D0=B8=D0=B3=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено полной установкой ISO в QEMU: панель ставится первой, и virtuality-ctl заранее создавал /etc/default/libvirt-guests; при установке libvirt dpkg спрашивал, какую версию оставить, и падал. - virtuality-ctl пишет этот файл только после установки libvirt - apt ставит пакеты с --force-confdef/--force-confold, перед установкой доделывает прерванную (dpkg --configure -a) - спиннер установщика не засоряет журнал первой загрузки - проверка ISO в CI ищет русский пункт меню Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- .github/workflows/image.yml | 2 +- install_virtuality_node.sh | 23 +++++++++++++++-------- scripts/install_web_panel.sh | 2 +- scripts/virtuality-ctl | 6 +++++- scripts/virtuality_firstboot.sh | 2 +- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 3a1d361..f96b0d3 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -51,7 +51,7 @@ jobs: xorriso -indev "$iso" -report_el_torito plain 2>/dev/null | tee /dev/stderr | grep -q "UEFI" xorriso -osirrox on -indev "$iso" -extract /autoinstall.yaml autoinstall.yaml -extract /boot/grub/grub.cfg grub.cfg python3 -c "import yaml; d = yaml.safe_load(open('autoinstall.yaml')); assert d['autoinstall']['version'] == 1" - grep -q "Install Virtuality" grub.cfg + grep -q "Установить Virtuality" grub.cfg # Ubuntu server ISOs are larger than the 2 GB GitHub release asset limit, # so images are published as workflow artifacts. - uses: actions/upload-artifact@v4 diff --git a/install_virtuality_node.sh b/install_virtuality_node.sh index 6eb7057..fb93c54 100644 --- a/install_virtuality_node.sh +++ b/install_virtuality_node.sh @@ -22,6 +22,8 @@ MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}" MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}" SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}" export DEBIAN_FRONTEND=noninteractive +# Keep existing configuration files without asking: there is nobody to answer on first boot. +APT_OPTS=(-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold) ESC="\033" RESET="${ESC}[0m" @@ -74,15 +76,18 @@ run_logged() { spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") i=0 while kill -0 "$pid" 2>/dev/null; do - elapsed=$(( $(date +%s) - start_ts )) - symbol="${spinner[$((i % ${#spinner[@]}))]}" - printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$description" "$elapsed" "$LOG_FILE" + # The spinner only makes sense on a terminal; first boot writes this output to a log. + if [[ -t 1 ]]; then + elapsed=$(( $(date +%s) - start_ts )) + symbol="${spinner[$((i % ${#spinner[@]}))]}" + printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$description" "$elapsed" "$LOG_FILE" + fi sleep 1 i=$((i + 1)) done - wait "$pid" - code=$? - printf "\r%*s\r" 120 "" + code=0 + wait "$pid" || code=$? + [[ -t 1 ]] && printf "\r%*s\r" 120 "" if [[ "$code" -eq 0 ]]; then ok "$description" else @@ -103,7 +108,7 @@ install_available() { warn "Пакет ${pkg} недоступен в этом дистрибутиве, пропускаю" fi done - run_logged "$description" apt install -y "${available[@]}" + run_logged "$description" apt-get install -y "${APT_OPTS[@]}" "${available[@]}" } service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; } @@ -164,7 +169,9 @@ else fi step "Обновляем apt cache" -run_logged "apt update выполнен" apt update +run_logged "apt update выполнен" apt-get update +# A previous attempt may have stopped half way (power loss, a question from dpkg): finish it first. +run_logged "Незавершённые установки пакетов доделаны" dpkg --configure -a --force-confdef --force-confold step "Устанавливаем базовые утилиты и зависимости диагностики" install_available "Базовые пакеты и диагностические утилиты установлены" \ diff --git a/scripts/install_web_panel.sh b/scripts/install_web_panel.sh index 1d8546e..45c14ed 100644 --- a/scripts/install_web_panel.sh +++ b/scripts/install_web_panel.sh @@ -136,7 +136,7 @@ if [[ "$PANEL_ONLY" == "1" ]]; then ok "Пропущено (VIRTUALITY_PANEL_ONLY=1): пакеты поставит установщик ноды" elif command -v apt-get >/dev/null 2>&1; then run_logged "apt update выполнен" apt-get update - run_logged "Пакеты панели установлены" apt-get install -y python3 python3-venv rsync openssl curl nftables novnc python3-websockify qemu-utils virtinst libvirt-clients cloud-image-utils + run_logged "Пакеты панели установлены" apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold python3 python3-venv rsync openssl curl nftables novnc python3-websockify qemu-utils virtinst libvirt-clients cloud-image-utils else warn "apt не найден — пакеты не устанавливались" fi diff --git a/scripts/virtuality-ctl b/scripts/virtuality-ctl index 404e3d7..62d01ec 100755 --- a/scripts/virtuality-ctl +++ b/scripts/virtuality-ctl @@ -253,12 +253,16 @@ ${LOG_DIR}/*.log { EOF # Guests shut down gracefully with the host instead of being killed (Proxmox behaviour). - cat > /etc/default/libvirt-guests < /etc/default/libvirt-guests </dev/null 2>&1 || true diff --git a/scripts/virtuality_firstboot.sh b/scripts/virtuality_firstboot.sh index 47988ae..9f9c599 100755 --- a/scripts/virtuality_firstboot.sh +++ b/scripts/virtuality_firstboot.sh @@ -156,7 +156,7 @@ say "source: ${SOURCE_DIR}, panel user: ${AUTH_USER}, port: ${VIRTUALITY_WEB_POR cd "$SOURCE_DIR" # unattended-upgrades often holds the dpkg lock right after the first boot. -printf 'DPkg::Lock::Timeout "900";\n' > "$APT_LOCK_CONF" +printf 'DPkg::Lock::Timeout "900";\nDPkg::Options { "--force-confdef"; "--force-confold"; };\n' > "$APT_LOCK_CONF" cat > "$ISSUE_FILE" <<'EOF' Virtuality настраивается. Ход установки: sudo journalctl -fu virtuality-firstboot From 03ca04073fb09d878f3921e406f6c45f23aaf80a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:07:20 +0000 Subject: [PATCH 30/32] =?UTF-8?q?=D0=A0=D0=B5=D0=B7=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8:=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BF=D0=B8=D1=8E=20=D0=BD=D0=B5=D0=BB=D1=8C=D0=B7=D1=8F?= =?UTF-8?q?=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B8=D1=82=D1=8C,=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=D0=B7=20=D0=BD=D0=B5=D1=91=20=D0=B8=D0=B4?= =?UTF-8?q?=D1=91=D1=82=20=D0=BB=D1=8E=D0=B1=D0=BE=D0=B5=20=D0=B2=D0=BE?= =?UTF-8?q?=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5;=20=D0=BD=D0=B0=D0=B1=D0=BB=D1=8E=D0=B4=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=20=D0=BF=D0=BE=D0=B7=D0=B4=D0=BD=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D1=8F=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - защита от удаления проверяет все незавершённые восстановления этой копии (под любым именем машины) - наблюдатель, который запускает машину после позднего выключения, снимается при «Выключить»/«Удалить» в панели и при новой копии, и не мешает другим задачам с этой машиной Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- tests/test_backups.py | 29 ++++++++++++++++-- web/app.py | 2 ++ web/features/backups.py | 65 +++++++++++++++++++++++++++-------------- 3 files changed, 71 insertions(+), 25 deletions(-) diff --git a/tests/test_backups.py b/tests/test_backups.py index 53d2c28..4b5e7ec 100644 --- a/tests/test_backups.py +++ b/tests/test_backups.py @@ -401,7 +401,6 @@ def test_restore_under_new_name(fast_worker, monkeypatch): assert "web02" in virsh.defined_xml and str(target) in virsh.defined_xml and " None: pass +def unfinished_restores_of(vm: str, backup_id: str) -> list[dict[str, Any]]: + """Все незавершённые восстановления из этой копии — под любым именем машины (их может быть несколько).""" + found = [] + for path in core.OPERATIONS_DIR.glob("*.json") if core.OPERATIONS_DIR.exists() else []: + try: + operation = json.loads(path.read_text()) + except (OSError, ValueError): + continue + if operation.get("type") == "restore" and operation.get("status") in ("queued", "running") and operation.get("source_vm") == vm and operation.get("backup_id") == backup_id: + found.append(operation) + return found + + def backup_in_use(vm: str, backup_id: str, path: Path) -> bool: """Пишет ли сейчас эту копию задача копирования или читает задача восстановления.""" if any(operation.get("backup_id") == backup_id for operation in core.active_operations_for(vm, OPERATION_TYPES)): return True - # Восстановление под другим именем записано на новую машину, поэтому его id лежит меткой в папке копии. - try: - marker = (path / RESTORE_MARKER).read_text().strip() - except OSError: - return False - operation = core.read_operation(marker) - return bool(operation and operation.get("status") in ("queued", "running")) + return bool(unfinished_restores_of(vm, backup_id)) def delete_backup(vm: str, backup_id: str) -> None: @@ -358,6 +364,7 @@ def create_backup(vm_name: str, note: str = "", wait: bool = False) -> dict[str, Проверки (машина есть, диски есть, места хватает) выполняются сразу и поднимают BackupError с понятным текстом. С wait=True копия делается в текущем потоке — удобно для расписания.""" + cancel_late_restart(vm_name) if not core.valid_vm_name(vm_name): raise BackupError("Некорректное имя машины") note = clean_note(note) @@ -445,16 +452,37 @@ def shutdown_and_wait(operation: dict[str, Any], vm: str) -> str: ) +LATE_WATCHES: dict[str, threading.Event] = {} + + +def cancel_late_restart(vm: str) -> None: + """Пользователь сам выключает машину или снова делает копию — наблюдатель не должен её запускать.""" + event = LATE_WATCHES.pop(vm, None) + if event: + event.set() + + def restart_after_late_shutdown(operation: dict[str, Any], vm: str) -> None: """Команда выключения уже ушла в машину: если она всё же выключится с опозданием, вернуть её в работу.""" + cancel_late_restart(vm) + cancelled = LATE_WATCHES.setdefault(vm, threading.Event()) deadline = time.monotonic() + LATE_SHUTDOWN_WATCH - while time.monotonic() < deadline: - time.sleep(POLL_INTERVAL) - if not core.vm_is_running(vm): - core.append_operation_log(operation["id"], "Машина всё-таки выключилась — запускаем её снова.") - start_vm(operation, vm) - return - core.append_operation_log(operation["id"], "Машина так и не выключилась — оставляем её работать.") + try: + while time.monotonic() < deadline: + if cancelled.wait(POLL_INTERVAL): + core.append_operation_log(operation["id"], "Наблюдение снято: машиной занялись вручную или новой задачей.") + return + if not core.vm_is_running(vm): + if any(other["id"] != operation["id"] for other in core.active_operations_for(vm, OPERATION_TYPES)): + core.append_operation_log(operation["id"], "Машина выключилась, но с ней уже работает другая задача — не запускаем.") + return + core.append_operation_log(operation["id"], "Машина всё-таки выключилась — запускаем её снова.") + start_vm(operation, vm) + return + core.append_operation_log(operation["id"], "Машина так и не выключилась — оставляем её работать.") + finally: + if LATE_WATCHES.get(vm) is cancelled: + LATE_WATCHES.pop(vm, None) def save_paused(operation: dict[str, Any], vm: str) -> str: @@ -645,10 +673,6 @@ def start_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict 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"]) - try: - (Path(backup["path"]) / RESTORE_MARKER).write_text(operation["id"], encoding="utf-8") # чтобы копию не удалили, пока её читают - except OSError: - pass core.run_operation(operation, restore_worker) return operation @@ -730,9 +754,6 @@ def restore_worker(operation: dict[str, Any]) -> None: log(f"Ошибка: {exc}") core.finish_operation(operation, False, str(exc)[:240]) return - finally: - if backup: - (Path(backup["path"]) / RESTORE_MARKER).unlink(missing_ok=True) created_label = backup["created_label"] if backup else "" core.finish_operation(operation, True, f"Машина {new_name} восстановлена из копии от {created_label}. Она выключена — запустите её, когда будете готовы.") From fb6fd2bc2d84a2015c37244b7cdc2c36f0b04258 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:10:22 +0000 Subject: [PATCH 31/32] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=8B=20=D1=81=D0=BA=D0=B0=D1=87=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B6=D0=B4=D1=91=D1=82,=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=20=D1=81?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=D0=B5=D1=82=20=D1=81=D0=B2=D0=BE=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20(=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=D0=BA=D0=B0=20=D0=B2=20CI=20=D0=BD=D0=B0=20Python=203.13?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- tests/test_downloads.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_downloads.py b/tests/test_downloads.py index e37941f..f0e400c 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -318,7 +318,8 @@ def test_download_cancel(data_dirs, www, logged_in): assert done["cancelled"] is True and done["cancel_requested"] is True assert not (data_dirs["iso"] / ".slow.iso.part").exists() assert not (data_dirs["iso"] / "slow.iso").exists() - assert not downloads.cancel_requested(operation["id"]) and not downloads.is_live(operation["id"]) + # The worker thread releases its markers right after it finishes the operation. + wait_until(lambda: not downloads.cancel_requested(operation["id"]) and not downloads.is_live(operation["id"])) def test_cancel_flag_on_disk_is_honoured_and_not_overwritten(data_dirs, www): From e13887ad76db128f6a18eacd00f37679c04c16b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:10:58 +0000 Subject: [PATCH 32/32] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=20=D1=83=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D1=88=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=82=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=20=D0=B6=D0=B4=D1=91=D1=82=20=D1=81=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg --- tests/test_downloads.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_downloads.py b/tests/test_downloads.py index f0e400c..0b0f060 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -242,7 +242,7 @@ def test_download_iso_success_refreshes_pool(data_dirs, www, monkeypatch): assert not (data_dirs["iso"] / ".small.iso.part").exists() assert ["virsh", "pool-refresh", "virtuality-iso"] in calls assert "Готово" in done["message"] - assert not downloads.is_live(operation["id"]) + wait_until(lambda: not downloads.is_live(operation["id"])) # поток снимает отметку сразу после завершения def test_download_follows_redirect(data_dirs, www):