Подготовка к 0.11: общий модуль core.py, каталог облачных образов, образцы вывода virsh

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
Этот коммит содержится в:
Claude
2026-09-25 14:04:11 +00:00
родитель 873b32c517
Коммит 1e29134fb3
11 изменённых файлов: 512 добавлений и 0 удалений
+3
Просмотреть файл
@@ -0,0 +1,3 @@
Capacity: 42949672960
Allocation: 6442450944
Physical: 6443499520
+4
Просмотреть файл
@@ -0,0 +1,4 @@
Type Device Target Source
------------------------------------------------------------
file disk vda /var/lib/virtuality/images/web01.qcow2
file cdrom sda -
+16
Просмотреть файл
@@ -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
+37
Просмотреть файл
@@ -0,0 +1,37 @@
<domain type='kvm' id='1'>
<name>web01</name>
<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>
<title>Сайт компании</title>
<description>Nginx + база. Бэкап по пятницам.</description>
<memory unit='KiB'>2097152</memory>
<currentMemory unit='KiB'>2097152</currentMemory>
<vcpu placement='static'>2</vcpu>
<os>
<type arch='x86_64' machine='pc-q35-10.2'>hvm</type>
<boot dev='hd'/>
</os>
<devices>
<emulator>/usr/bin/qemu-system-x86_64</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='/var/lib/virtuality/images/web01.qcow2'/>
<target dev='vda' bus='virtio'/>
</disk>
<disk type='file' device='cdrom'>
<driver name='qemu' type='raw'/>
<target dev='sda' bus='sata'/>
<readonly/>
</disk>
<interface type='network'>
<mac address='52:54:00:aa:bb:cc'/>
<source network='virtuality-nat'/>
<model type='virtio'/>
</interface>
<channel type='unix'>
<target type='virtio' name='org.qemu.guest_agent.0'/>
</channel>
<graphics type='vnc' port='5900' autoport='yes' listen='127.0.0.1'>
<listen type='address' address='127.0.0.1'/>
</graphics>
</devices>
</domain>
+17
Просмотреть файл
@@ -0,0 +1,17 @@
<network connections='2'>
<name>virtuality-nat</name>
<uuid>6c1b3c1e-1111-2222-3333-444444444444</uuid>
<forward mode='nat'>
<nat>
<port start='1024' end='65535'/>
</nat>
</forward>
<bridge name='virbr100' stp='on' delay='0'/>
<mac address='52:54:00:12:34:56'/>
<ip address='192.168.100.1' netmask='255.255.255.0'>
<dhcp>
<range start='192.168.100.50' end='192.168.100.200'/>
<host mac='52:54:00:aa:bb:cc' name='web01' ip='192.168.100.51'/>
</dhcp>
</ip>
</network>
+10
Просмотреть файл
@@ -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
}
+9
Просмотреть файл
@@ -0,0 +1,9 @@
Name: clean
Domain: web01
Current: yes
State: running
Location: internal
Parent: -
Children: 1
Descendants: 1
Metadata: yes
+4
Просмотреть файл
@@ -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
+82
Просмотреть файл
@@ -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]
+321
Просмотреть файл
@@ -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()
+9
Просмотреть файл
@@ -242,3 +242,12 @@ def plural(count: int, one: str, few: str, many: str) -> str:
def port_service(port: int) -> str: def port_service(port: int) -> str:
return WELL_KNOWN_PORTS.get(int(port or 0), "") 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} ТБ"