From 1e29134fb3101ed2e62ffdcf84fbbc073beabf5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:04:11 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D0=BA=200.11:=20=D0=BE=D0=B1=D1=89=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20core.py,=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=BE=D0=B1=D0=BB?= =?UTF-8?q?=D0=B0=D1=87=D0=BD=D1=8B=D1=85=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D1=86=D1=8B=20?= =?UTF-8?q?=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} ТБ"