613 строки
27 KiB
Python
613 строки
27 KiB
Python
#!/usr/bin/env python3
|
|
import crypt
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import shutil
|
|
import spwd
|
|
import subprocess
|
|
import threading
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI, Request, Form, UploadFile, File
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from itsdangerous import BadSignature, URLSafeSerializer
|
|
|
|
import network_core
|
|
from network_core import NetworkError
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
APP_NAME = "Virtuality"
|
|
ENV_FILE = BASE_DIR / ".env"
|
|
ISO_DIR = Path("/var/lib/virtuality/iso")
|
|
IMAGES_DIR = Path("/var/lib/virtuality/images")
|
|
OPERATIONS_DIR = Path("/var/log/virtuality/operations")
|
|
DEFAULT_BRIDGE = "br0"
|
|
|
|
app = FastAPI(title="Virtuality Panel")
|
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
|
static_dir = BASE_DIR / "static"
|
|
static_dir.mkdir(exist_ok=True)
|
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
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", "viktor"))
|
|
SESSION_SECRET = CONFIG.get("VIRTUALITY_SESSION_SECRET", os.environ.get("VIRTUALITY_SESSION_SECRET", "dev-secret-change-me"))
|
|
serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session")
|
|
|
|
|
|
def is_configured() -> bool:
|
|
return bool(AUTH_USER and SESSION_SECRET != "dev-secret-change-me")
|
|
|
|
|
|
def verify_linux_password(username: str, password: str) -> bool:
|
|
if not username or not password or username != AUTH_USER:
|
|
return False
|
|
try:
|
|
shadow = spwd.getspnam(username)
|
|
except (PermissionError, KeyError):
|
|
return False
|
|
stored_hash = shadow.sp_pwdp
|
|
if stored_hash in ("!", "*", "!!", ""):
|
|
return False
|
|
return secrets.compare_digest(crypt.crypt(password, stored_hash), stored_hash)
|
|
|
|
|
|
def get_current_user(request: Request) -> str | None:
|
|
token = request.cookies.get("virtuality_session")
|
|
if not token:
|
|
return None
|
|
try:
|
|
data = serializer.loads(token)
|
|
except BadSignature:
|
|
return None
|
|
return AUTH_USER if data.get("user") == AUTH_USER else None
|
|
|
|
|
|
def require_auth(request: Request):
|
|
if not get_current_user(request):
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
return None
|
|
|
|
|
|
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 utc_now() -> str:
|
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
|
|
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 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 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 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 progress_from_line(current: int, line: str) -> int:
|
|
low = line.lower()
|
|
if "allocating" in low or "creating storage" in low:
|
|
return max(current, 30)
|
|
if "starting install" in low or "installing" in low:
|
|
return max(current, 50)
|
|
if "creating domain" in low:
|
|
return max(current, 75)
|
|
if "domain creation completed" in low or "installation continues" in low:
|
|
return max(current, 90)
|
|
return current
|
|
|
|
|
|
def run_operation_worker(operation_id: str, cmd: list[str]) -> None:
|
|
operation = read_operation(operation_id)
|
|
if not operation:
|
|
return
|
|
update_operation(operation, status="running", progress=10, message="virt-install запущен", started_at=utc_now())
|
|
append_operation_log(operation_id, "Запуск команды:")
|
|
append_operation_log(operation_id, " ".join(cmd))
|
|
try:
|
|
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
|
if process.stdout:
|
|
for line in process.stdout:
|
|
append_operation_log(operation_id, line)
|
|
fresh = read_operation(operation_id) or operation
|
|
new_progress = progress_from_line(int(fresh.get("progress", 10)), line)
|
|
if new_progress != fresh.get("progress"):
|
|
update_operation(fresh, progress=new_progress, message=line.strip()[:240] or fresh.get("message"))
|
|
exit_code = process.wait()
|
|
fresh = read_operation(operation_id) or operation
|
|
if exit_code == 0:
|
|
append_operation_log(operation_id, "virt-install завершился успешно.")
|
|
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
|
|
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message="VM создана успешно", finished_at=utc_now())
|
|
else:
|
|
append_operation_log(operation_id, f"virt-install завершился с ошибкой. Exit code: {exit_code}")
|
|
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"virt-install завершился с ошибкой: {exit_code}", finished_at=utc_now())
|
|
except Exception as exc:
|
|
fresh = read_operation(operation_id) or operation
|
|
append_operation_log(operation_id, f"Ошибка запуска операции: {exc}")
|
|
update_operation(fresh, status="error", progress=100, exit_code=-1, message=str(exc), finished_at=utc_now())
|
|
|
|
|
|
def start_background_operation(operation: dict[str, Any], cmd: list[str]) -> None:
|
|
write_operation(operation)
|
|
append_operation_log(operation["id"], "Операция поставлена в очередь.")
|
|
threading.Thread(target=run_operation_worker, args=(operation["id"], cmd), daemon=True).start()
|
|
|
|
|
|
def parse_virsh_list() -> list[dict[str, str]]:
|
|
result = run_cmd(["virsh", "list", "--all"])
|
|
rows = []
|
|
if not result["ok"]:
|
|
return rows
|
|
for line in result["stdout"].splitlines()[2:]:
|
|
parts = line.strip().split(None, 2)
|
|
if len(parts) == 3:
|
|
rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})
|
|
elif len(parts) == 2:
|
|
rows.append({"id": "-", "name": parts[0], "state": parts[1]})
|
|
return rows
|
|
|
|
|
|
def parse_pool_list() -> list[dict[str, str]]:
|
|
result = run_cmd(["virsh", "pool-list", "--all"])
|
|
rows = []
|
|
if not result["ok"]:
|
|
return rows
|
|
for line in result["stdout"].splitlines()[2:]:
|
|
parts = line.strip().split()
|
|
if len(parts) >= 3:
|
|
rows.append({"name": parts[0], "state": parts[1], "autostart": parts[2]})
|
|
return rows
|
|
|
|
|
|
def list_iso_files() -> list[dict[str, str]]:
|
|
ISO_DIR.mkdir(parents=True, exist_ok=True)
|
|
files = []
|
|
for item in sorted(ISO_DIR.glob("*.iso")):
|
|
try:
|
|
stat = item.stat()
|
|
size_mb = stat.st_size / 1024 / 1024
|
|
updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
except OSError:
|
|
size_mb = 0
|
|
updated = "unknown"
|
|
files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated})
|
|
return files
|
|
|
|
|
|
def safe_iso_filename(filename: str) -> str | None:
|
|
name = Path(filename or "").name.strip().replace(" ", "-")
|
|
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
|
|
if not name.lower().endswith(".iso"):
|
|
return None
|
|
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.iso", name):
|
|
return None
|
|
return name
|
|
|
|
|
|
def iso_path_by_name(name: str) -> Path | None:
|
|
safe_name = safe_iso_filename(name)
|
|
if not safe_name:
|
|
return None
|
|
path = (ISO_DIR / safe_name).resolve()
|
|
if ISO_DIR.resolve() not in path.parents:
|
|
return None
|
|
return path
|
|
|
|
|
|
def refresh_iso_pool() -> None:
|
|
run_cmd(["virsh", "pool-refresh", "virtuality-iso"], timeout=20)
|
|
|
|
|
|
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 vm_exists(name: str) -> bool:
|
|
return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"]
|
|
|
|
|
|
def vm_ip(name: str) -> str:
|
|
result = run_cmd(["virsh", "domifaddr", name], timeout=8)
|
|
if not result["ok"]:
|
|
return "not available"
|
|
match = re.search(r"(192\.168\.100\.\d+|\d+\.\d+\.\d+\.\d+)/", result["stdout"])
|
|
return match.group(1) if match else "not available"
|
|
|
|
|
|
def vm_details(name: str) -> dict[str, Any]:
|
|
return {
|
|
"name": name,
|
|
"dominfo": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],
|
|
"vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",
|
|
"ip": vm_ip(name),
|
|
"disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],
|
|
"interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],
|
|
"autostart": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],
|
|
}
|
|
|
|
|
|
def system_summary() -> dict[str, str]:
|
|
hostname = run_cmd(["hostname"])["stdout"]
|
|
uptime = run_cmd(["uptime", "-p"])["stdout"]
|
|
kernel = run_cmd(["uname", "-r"])["stdout"]
|
|
ip_addr = run_cmd(["hostname", "-I"])["stdout"].split()
|
|
ip_main = ip_addr[0] if ip_addr else "unknown"
|
|
load = Path("/proc/loadavg").read_text().split()[:3]
|
|
return {"hostname": hostname, "uptime": uptime, "kernel": kernel, "ip": ip_main, "load": " ".join(load), "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
|
|
|
|
|
def service_state(unit: str) -> str:
|
|
result = run_cmd(["systemctl", "is-active", unit])
|
|
return result["stdout"] or "inactive"
|
|
|
|
|
|
def network_summary() -> dict[str, str]:
|
|
return {"interfaces": run_cmd(["ip", "-br", "a"])["stdout"], "routes": run_cmd(["ip", "route"])["stdout"]}
|
|
|
|
|
|
def vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200):
|
|
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "network_mode": "nat", "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
|
|
|
|
|
|
@app.get("/login", response_class=HTMLResponse)
|
|
def login_page(request: Request):
|
|
if get_current_user(request):
|
|
return RedirectResponse(url="/", status_code=303)
|
|
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": None, "configured": is_configured(), "auth_user": AUTH_USER})
|
|
|
|
|
|
@app.post("/login", response_class=HTMLResponse)
|
|
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
|
|
if not is_configured():
|
|
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Панель ещё не настроена. Запусти установщик веб-панели повторно.", "configured": False, "auth_user": AUTH_USER}, status_code=500)
|
|
if not verify_linux_password(username, password):
|
|
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Неверный логин или пароль Linux-пользователя", "configured": True, "auth_user": AUTH_USER}, status_code=401)
|
|
token = serializer.dumps({"user": AUTH_USER})
|
|
response = RedirectResponse(url="/", status_code=303)
|
|
response.set_cookie("virtuality_session", token, httponly=True, samesite="lax", max_age=60 * 60 * 12)
|
|
return response
|
|
|
|
|
|
@app.post("/logout")
|
|
def logout():
|
|
response = RedirectResponse(url="/login", status_code=303)
|
|
response.delete_cookie("virtuality_session")
|
|
return response
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def dashboard(request: Request):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
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")}
|
|
return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "operations": list_operations(5), "operation_css": operation_css})
|
|
|
|
|
|
@app.get("/iso", response_class=HTMLResponse)
|
|
def iso_page(request: Request, error: str | None = None):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error})
|
|
|
|
|
|
@app.post("/iso/upload", response_class=HTMLResponse)
|
|
def iso_upload(request: Request, iso_file: UploadFile = File(...)):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
safe_name = safe_iso_filename(iso_file.filename or "")
|
|
if not safe_name:
|
|
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400)
|
|
ISO_DIR.mkdir(parents=True, exist_ok=True)
|
|
target = ISO_DIR / safe_name
|
|
if target.exists():
|
|
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"ISO уже существует: {safe_name}"}, status_code=400)
|
|
tmp_target = ISO_DIR / f".{safe_name}.uploading"
|
|
try:
|
|
with tmp_target.open("wb") as out:
|
|
shutil.copyfileobj(iso_file.file, out)
|
|
tmp_target.rename(target)
|
|
refresh_iso_pool()
|
|
except Exception as exc:
|
|
tmp_target.unlink(missing_ok=True)
|
|
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Ошибка загрузки ISO: {exc}"}, status_code=500)
|
|
return RedirectResponse(url="/iso", status_code=303)
|
|
|
|
|
|
@app.post("/iso/{name}/delete")
|
|
def iso_delete(request: Request, name: str):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
path = iso_path_by_name(name)
|
|
if path and path.exists() and path.is_file():
|
|
path.unlink()
|
|
refresh_iso_pool()
|
|
return RedirectResponse(url="/iso", status_code=303)
|
|
|
|
|
|
@app.post("/iso/refresh")
|
|
def iso_refresh(request: Request):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
refresh_iso_pool()
|
|
return RedirectResponse(url="/iso", status_code=303)
|
|
|
|
|
|
@app.get("/network", response_class=HTMLResponse)
|
|
def network_page(request: Request, error: str | None = None):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": error})
|
|
|
|
|
|
@app.post("/network/nat/setup")
|
|
def network_nat_setup(request: Request):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
try:
|
|
network_core.create_nat_network()
|
|
network_core.apply_port_forwards()
|
|
except NetworkError as exc:
|
|
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=500)
|
|
return RedirectResponse(url="/network", status_code=303)
|
|
|
|
|
|
@app.post("/network/forward/add")
|
|
def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp"), note: str = Form("")):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
try:
|
|
network_core.add_port_forward(vm_name, guest_ip, external_port, guest_port, protocol, note)
|
|
except NetworkError as exc:
|
|
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=400)
|
|
return RedirectResponse(url="/network", status_code=303)
|
|
|
|
|
|
@app.post("/network/forward/{forward_id}/delete")
|
|
def network_forward_delete(request: Request, forward_id: str):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
try:
|
|
network_core.delete_port_forward(forward_id)
|
|
except NetworkError:
|
|
pass
|
|
return RedirectResponse(url="/network", status_code=303)
|
|
|
|
|
|
@app.post("/network/apply")
|
|
def network_apply(request: Request):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
try:
|
|
network_core.apply_port_forwards()
|
|
except NetworkError as exc:
|
|
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=500)
|
|
return RedirectResponse(url="/network", status_code=303)
|
|
|
|
|
|
@app.get("/operations", response_class=HTMLResponse)
|
|
def operations_page(request: Request):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
return templates.TemplateResponse("operations.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "operations": list_operations(50), "operation_css": operation_css})
|
|
|
|
|
|
@app.get("/operations/{operation_id}", response_class=HTMLResponse)
|
|
def operation_detail_page(request: Request, operation_id: str):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
operation = read_operation(operation_id)
|
|
if not operation:
|
|
return RedirectResponse(url="/operations", status_code=303)
|
|
return templates.TemplateResponse("operation_detail.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css})
|
|
|
|
|
|
@app.get("/api/operations")
|
|
def api_operations(request: Request):
|
|
if not get_current_user(request):
|
|
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
|
|
return {"ok": True, "operations": list_operations(25)}
|
|
|
|
|
|
@app.get("/api/operations/{operation_id}")
|
|
def api_operation(request: Request, operation_id: str):
|
|
if not get_current_user(request):
|
|
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
|
|
operation = read_operation(operation_id)
|
|
if not operation:
|
|
return JSONResponse({"ok": False, "error": "Operation not found"}, status_code=404)
|
|
return {"ok": True, "operation": operation}
|
|
|
|
|
|
@app.get("/vm/create", response_class=HTMLResponse)
|
|
def vm_create_page(request: Request):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
return vm_form_context(request)
|
|
|
|
|
|
@app.post("/vm/create", response_class=HTMLResponse)
|
|
def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "network_mode": network_mode, "bridge": bridge}
|
|
error = None
|
|
if not valid_vm_name(name):
|
|
error = "Имя VM может содержать латиницу, цифры, точку, дефис и подчёркивание. Длина 2–63 символа."
|
|
elif vm_exists(name):
|
|
error = f"VM с именем {name} уже существует."
|
|
elif memory < 512 or memory > 262144:
|
|
error = "RAM должна быть от 512 MB до 262144 MB."
|
|
elif vcpus < 1 or vcpus > 128:
|
|
error = "CPU должен быть от 1 до 128 vCPU."
|
|
elif disk_size < 4 or disk_size > 4096:
|
|
error = "Диск должен быть от 4 GB до 4096 GB."
|
|
elif network_mode not in ("nat", "bridge"):
|
|
error = "Некорректный режим сети."
|
|
elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
|
|
error = "Некорректное имя bridge."
|
|
else:
|
|
iso = Path(iso_path).resolve()
|
|
if ISO_DIR.resolve() not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
|
|
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
|
|
if error:
|
|
return vm_form_context(request, error=error, form=form, status_code=400)
|
|
|
|
if network_mode == "nat":
|
|
try:
|
|
network_core.create_nat_network()
|
|
except NetworkError as exc:
|
|
return vm_form_context(request, error=f"NAT-сеть не готова: {exc}", form=form, status_code=500)
|
|
|
|
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
|
disk_path = IMAGES_DIR / f"{name}.qcow2"
|
|
if disk_path.exists():
|
|
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
|
|
|
|
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
|
|
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
|
|
|
|
operation_id = str(uuid.uuid4())
|
|
operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "cmd": " ".join(cmd)}
|
|
start_background_operation(operation, cmd)
|
|
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
|
|
|
|
|
|
@app.get("/vm/{name}", response_class=HTMLResponse)
|
|
def vm_detail_page(request: Request, name: str):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
if not valid_vm_name(name) or not vm_exists(name):
|
|
return RedirectResponse(url="/", status_code=303)
|
|
return templates.TemplateResponse("vm_detail.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "host_ip": system_summary()["ip"]})
|
|
|
|
|
|
@app.post("/vm/{name}/{action}")
|
|
def vm_action(request: Request, name: str, action: str):
|
|
auth_redirect = require_auth(request)
|
|
if auth_redirect:
|
|
return auth_redirect
|
|
allowed = {"start": ["virsh", "start", name], "shutdown": ["virsh", "shutdown", name], "reboot": ["virsh", "reboot", name], "destroy": ["virsh", "destroy", name], "autostart": ["virsh", "autostart", name], "autostart-disable": ["virsh", "autostart", "--disable", name]}
|
|
if action == "delete":
|
|
run_cmd(["virsh", "destroy", name], timeout=20)
|
|
run_cmd(["virsh", "undefine", name, "--remove-all-storage"], timeout=60)
|
|
return RedirectResponse(url="/", status_code=303)
|
|
if action not in allowed:
|
|
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
|
|
run_cmd(allowed[action], timeout=30)
|
|
return RedirectResponse(url=f"/vm/{name}", status_code=303)
|
|
|
|
|
|
@app.get("/api/health")
|
|
def api_health(request: Request):
|
|
if not get_current_user(request):
|
|
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
|
|
return {"system": system_summary(), "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()}
|