Подготовка к 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
Этот коммит содержится в:
Vendored
@@ -0,0 +1,3 @@
|
|||||||
|
Capacity: 42949672960
|
||||||
|
Allocation: 6442450944
|
||||||
|
Physical: 6443499520
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Type Device Target Source
|
||||||
|
------------------------------------------------------------
|
||||||
|
file disk vda /var/lib/virtuality/images/web01.qcow2
|
||||||
|
file cdrom sda -
|
||||||
Vendored
+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
|
||||||
|
|
||||||
Vendored
+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>
|
||||||
Vendored
+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>
|
||||||
Vendored
+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
|
||||||
|
}
|
||||||
Vendored
@@ -0,0 +1,9 @@
|
|||||||
|
Name: clean
|
||||||
|
Domain: web01
|
||||||
|
Current: yes
|
||||||
|
State: running
|
||||||
|
Location: internal
|
||||||
|
Parent: -
|
||||||
|
Children: 1
|
||||||
|
Descendants: 1
|
||||||
|
Metadata: yes
|
||||||
Vendored
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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()
|
||||||
@@ -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} ТБ"
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user