Add VM creation page and handler
Этот коммит содержится в:
+125
-7
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import crypt
|
import crypt
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import spwd
|
import spwd
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -17,6 +18,9 @@ from itsdangerous import BadSignature, URLSafeSerializer
|
|||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
APP_NAME = "Virtuality"
|
APP_NAME = "Virtuality"
|
||||||
ENV_FILE = BASE_DIR / ".env"
|
ENV_FILE = BASE_DIR / ".env"
|
||||||
|
ISO_DIR = Path("/var/lib/virtuality/iso")
|
||||||
|
IMAGES_DIR = Path("/var/lib/virtuality/images")
|
||||||
|
DEFAULT_BRIDGE = "br0"
|
||||||
|
|
||||||
app = FastAPI(title="Virtuality Panel")
|
app = FastAPI(title="Virtuality Panel")
|
||||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||||
@@ -143,6 +147,26 @@ def parse_pool_list() -> list[dict[str, str]]:
|
|||||||
return rows
|
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:
|
||||||
|
size_mb = item.stat().st_size / 1024 / 1024
|
||||||
|
except OSError:
|
||||||
|
size_mb = 0
|
||||||
|
files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB"})
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
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 system_summary() -> dict[str, str]:
|
def system_summary() -> dict[str, str]:
|
||||||
hostname = run_cmd(["hostname"])["stdout"]
|
hostname = run_cmd(["hostname"])["stdout"]
|
||||||
uptime = run_cmd(["uptime", "-p"])["stdout"]
|
uptime = run_cmd(["uptime", "-p"])["stdout"]
|
||||||
@@ -216,13 +240,7 @@ def login_submit(request: Request, username: str = Form(...), password: str = Fo
|
|||||||
)
|
)
|
||||||
token = serializer.dumps({"user": AUTH_USER})
|
token = serializer.dumps({"user": AUTH_USER})
|
||||||
response = RedirectResponse(url="/", status_code=303)
|
response = RedirectResponse(url="/", status_code=303)
|
||||||
response.set_cookie(
|
response.set_cookie("virtuality_session", token, httponly=True, samesite="lax", max_age=60 * 60 * 12)
|
||||||
"virtuality_session",
|
|
||||||
token,
|
|
||||||
httponly=True,
|
|
||||||
samesite="lax",
|
|
||||||
max_age=60 * 60 * 12,
|
|
||||||
)
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -245,6 +263,7 @@ def dashboard(request: Request):
|
|||||||
"virtlogd": service_state("virtlogd.service"),
|
"virtlogd": service_state("virtlogd.service"),
|
||||||
"cockpit": service_state("cockpit.socket"),
|
"cockpit": service_state("cockpit.socket"),
|
||||||
"dashboard": service_state("virtuality-console-dashboard.service"),
|
"dashboard": service_state("virtuality-console-dashboard.service"),
|
||||||
|
"web": service_state("virtuality-web.service"),
|
||||||
}
|
}
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
@@ -261,6 +280,104 @@ def dashboard(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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 templates.TemplateResponse(
|
||||||
|
"vm_create.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"app_name": APP_NAME,
|
||||||
|
"user": AUTH_USER,
|
||||||
|
"isos": list_iso_files(),
|
||||||
|
"error": None,
|
||||||
|
"form": {"memory": 2048, "vcpus": 2, "disk_size": 20, "bridge": DEFAULT_BRIDGE},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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(...),
|
||||||
|
bridge: str = Form(DEFAULT_BRIDGE),
|
||||||
|
autostart_install: str | None = Form(None),
|
||||||
|
):
|
||||||
|
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, "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 not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge):
|
||||||
|
error = "Некорректное имя bridge."
|
||||||
|
else:
|
||||||
|
iso = Path(iso_path).resolve()
|
||||||
|
iso_root = ISO_DIR.resolve()
|
||||||
|
if iso_root not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
|
||||||
|
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"vm_create.html",
|
||||||
|
{"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error, "form": form},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
disk_path = IMAGES_DIR / f"{name}.qcow2"
|
||||||
|
if disk_path.exists():
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"vm_create.html",
|
||||||
|
{"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Диск уже существует: {disk_path}", "form": form},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
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", f"bridge={bridge},model=virtio",
|
||||||
|
"--graphics", "vnc,listen=0.0.0.0",
|
||||||
|
"--noautoconsole",
|
||||||
|
]
|
||||||
|
if not autostart_install:
|
||||||
|
# virt-install starts the installer by design; this flag is kept for future behaviour toggles.
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = run_cmd(cmd, timeout=180)
|
||||||
|
if not result["ok"]:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"vm_create.html",
|
||||||
|
{"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": result["stderr"] or result["stdout"] or "virt-install failed", "form": form},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
|
||||||
|
return RedirectResponse(url="/", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/{action}")
|
@app.post("/vm/{name}/{action}")
|
||||||
def vm_action(request: Request, name: str, action: str):
|
def vm_action(request: Request, name: str, action: str):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
@@ -289,6 +406,7 @@ def api_health(request: Request):
|
|||||||
"virtlogd": service_state("virtlogd.service"),
|
"virtlogd": service_state("virtlogd.service"),
|
||||||
"cockpit": service_state("cockpit.socket"),
|
"cockpit": service_state("cockpit.socket"),
|
||||||
"dashboard": service_state("virtuality-console-dashboard.service"),
|
"dashboard": service_state("virtuality-console-dashboard.service"),
|
||||||
|
"web": service_state("virtuality-web.service"),
|
||||||
},
|
},
|
||||||
"vms": parse_virsh_list(),
|
"vms": parse_virsh_list(),
|
||||||
"pools": parse_pool_list(),
|
"pools": parse_pool_list(),
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user