Files
virtuality/web/nodectl.py
T
Claude 8a4474a305 Мастер первичной настройки и nodectl: добавлены пошаговый /setup, управление узлом через virtuality-ctl и тесты
- web/nodectl.py: факты об узле (сеть, диски, часовые пояса, KVM) и действия через virtuality-ctl
- web/features/setup.py + шаблоны setup*: мастер из 8 шагов (установка, часовой пояс, пароль, HTTPS, сеть, хранилище, обновления, итог)
- безопасное переключение на мост br0 с авто-откатом и подтверждением из браузера
- virtuality-ctl storage-use: перенос данных на отдельный диск
- панель перенаправляет на мастер до его завершения; демо-режим VIRTUALITY_DEV_SETUP
- tests/test_setup.py: 8 тестов на маршруты мастера

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 14:36:32 +00:00

314 строки
14 KiB
Python

"""Facts about the node (network, disks, time zone, first-boot progress) and
actions that change the node itself. Actions go through virtuality-ctl so the
panel and the command line behave identically."""
import ipaddress
import json
import os
import re
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
from core import CONFIG_DIR, STORAGE_DIR, cmd_error, run_cmd
CTL = "/usr/local/bin/virtuality-ctl"
NODE_ENV = Path(os.environ.get("VIRTUALITY_NODE_ENV", str(CONFIG_DIR / "web.env")))
SETUP_STATE = CONFIG_DIR / "setup.json"
SETUP_DONE = CONFIG_DIR / "setup_done"
WIZARD_FILE = CONFIG_DIR / "wizard.json"
FIRSTBOOT_LOG = Path("/var/log/virtuality/firstboot.log")
MIN_SPARE_DISK = 16 * 1024 ** 3
POPULAR_TIMEZONES = [
"Europe/Moscow", "Europe/Kaliningrad", "Europe/Samara", "Asia/Yekaterinburg", "Asia/Omsk", "Asia/Novosibirsk",
"Asia/Krasnoyarsk", "Asia/Irkutsk", "Asia/Yakutsk", "Asia/Vladivostok", "Asia/Magadan", "Asia/Kamchatka",
"Europe/Minsk", "Europe/Kiev", "Asia/Almaty", "Asia/Tashkent", "Asia/Tbilisi", "Asia/Yerevan", "Asia/Baku",
"Europe/Berlin", "Europe/London", "Europe/Istanbul", "Asia/Dubai", "America/New_York", "UTC",
]
# ---------------------------------------------------------------- node settings
def node_settings() -> dict[str, str]:
data: dict[str, str] = {}
try:
for raw in NODE_ENV.read_text().splitlines():
line = raw.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
data[key.strip()] = value.strip().strip('"').strip("'")
except OSError:
pass
data.setdefault("VIRTUALITY_WEB_PORT", "8088")
data.setdefault("VIRTUALITY_TLS_PORT", "8443")
data.setdefault("VIRTUALITY_TLS", "0")
data.setdefault("VIRTUALITY_AUTO_UPDATE", "1")
data.setdefault("VIRTUALITY_UPDATE_CHANNEL", "stable")
return data
def panel_url(host: str, settings: dict[str, str] | None = None) -> str:
settings = settings or node_settings()
if settings.get("VIRTUALITY_TLS") == "1":
return f"https://{host}:{settings['VIRTUALITY_TLS_PORT']}"
return f"http://{host}:{settings['VIRTUALITY_WEB_PORT']}"
def ctl_available() -> bool:
return Path(CTL).exists()
def ctl(*args: str, timeout: int = 60, stdin: str | None = None) -> dict[str, Any]:
if not ctl_available():
return {"ok": False, "code": -1, "stdout": "", "stderr": "virtuality-ctl не установлен", "cmd": CTL}
try:
result = subprocess.run([CTL, *args], capture_output=True, text=True, timeout=timeout, check=False, input=stdin)
return {"ok": result.returncode == 0, "code": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "cmd": " ".join([CTL, *args])}
except Exception as exc:
return {"ok": False, "code": -1, "stdout": "", "stderr": str(exc), "cmd": " ".join([CTL, *args])}
def ctl_detached(*args: str, delay_seconds: int = 2) -> dict[str, Any]:
"""Run a virtuality-ctl command after the HTTP response is sent (the command may restart the panel)."""
unit = f"virtuality-apply-{datetime.now().strftime('%Y%m%d%H%M%S%f')}"
return run_cmd(["systemd-run", "--unit", unit, "--collect", "--quiet", f"--on-active={delay_seconds}", CTL, *args], timeout=15)
# ---------------------------------------------------------------- first boot state
def setup_state() -> dict[str, Any]:
try:
data = json.loads(SETUP_STATE.read_text())
except Exception:
data = {}
data.setdefault("stage", "done" if not SETUP_STATE.exists() else "installing")
data.setdefault("steps", [])
data.setdefault("message", "")
data["installed"] = data["stage"] == "done"
data["log_tail"] = ""
if FIRSTBOOT_LOG.exists():
try:
data["log_tail"] = "\n".join(FIRSTBOOT_LOG.read_text(errors="replace").splitlines()[-40:])
except OSError:
pass
return data
def wizard_done() -> bool:
return SETUP_DONE.exists()
def mark_wizard_done() -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
SETUP_DONE.write_text(datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "\n")
def wizard_choices() -> dict[str, Any]:
try:
return json.loads(WIZARD_FILE.read_text())
except Exception:
return {}
def save_wizard_choices(**changes: Any) -> dict[str, Any]:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
data = wizard_choices()
data.update(changes)
WIZARD_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2))
return data
# ---------------------------------------------------------------- hardware & time
def hardware_summary() -> dict[str, Any]:
cpu_model = "—"
try:
for line in Path("/proc/cpuinfo").read_text().splitlines():
key, _, value = line.partition(":")
if key.strip().lower() == "model name" and value.strip():
cpu_model = value.strip()
break
except OSError:
pass
mem_total = 0
try:
for line in Path("/proc/meminfo").read_text().splitlines():
if line.startswith("MemTotal:"):
mem_total = int(line.split()[1]) * 1024
except (OSError, ValueError):
pass
try:
usage = shutil.disk_usage(str(STORAGE_DIR) if STORAGE_DIR.exists() else "/")
disk_total, disk_free = usage.total, usage.free
except OSError:
disk_total = disk_free = 0
return {
"hostname": run_cmd(["hostname"])["stdout"] or "—",
"cpu_model": cpu_model,
"cpu_count": os.cpu_count() or 1,
"mem_total": mem_total,
"disk_total": disk_total,
"disk_free": disk_free,
"kvm": Path("/dev/kvm").exists(),
}
def timezones() -> dict[str, Any]:
current = run_cmd(["timedatectl", "show", "-p", "Timezone", "--value"])["stdout"] or "UTC"
listed = run_cmd(["timedatectl", "list-timezones"], timeout=10)
names = [line.strip() for line in listed["stdout"].splitlines() if "/" in line or line.strip() == "UTC"] if listed["ok"] else []
if not names:
names = list(POPULAR_TIMEZONES)
popular = [tz for tz in POPULAR_TIMEZONES if tz in names]
return {"current": current, "popular": popular, "all": names}
def set_timezone(tz: str) -> tuple[bool, str]:
if not re.fullmatch(r"[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+){0,2}", tz or ""):
return False, "Некорректный часовой пояс"
result = ctl("timezone", tz, timeout=30)
return result["ok"], cmd_error(result, "Не удалось сменить часовой пояс") if not result["ok"] else tz
# ---------------------------------------------------------------- network facts
def _ip_json(*args: str) -> list[dict[str, Any]]:
result = run_cmd(["ip", "-j", *args], timeout=8)
if not result["ok"]:
return []
try:
data = json.loads(result["stdout"] or "[]")
return data if isinstance(data, list) else []
except json.JSONDecodeError:
return []
def network_facts() -> dict[str, Any]:
routes = _ip_json("route", "show", "default")
default = routes[0] if routes else {}
iface = default.get("dev", "")
gateway = default.get("gateway", "")
address = ""
prefix = 0
mac = ""
for entry in _ip_json("addr", "show", iface) if iface else []:
mac = entry.get("address", "") or mac
for info in entry.get("addr_info", []):
if info.get("family") == "inet" and not address:
address = info.get("local", "")
prefix = int(info.get("prefixlen", 0) or 0)
wireless = bool(iface) and Path(f"/sys/class/net/{iface}/wireless").exists()
bridge_present = Path("/sys/class/net/br0").exists()
on_bridge = iface == "br0"
private = False
try:
private = ipaddress.ip_address(address).is_private if address else False
except ValueError:
private = False
is_vps = bool(address) and not private
virt = run_cmd(["systemd-detect-virt"], timeout=5)["stdout"]
if virt and virt != "none":
is_vps = True
if on_bridge:
recommended = "bridge"
elif wireless or is_vps or not iface:
recommended = "nat"
else:
recommended = "bridge"
revert_armed = run_cmd(["systemctl", "is-active", "virtuality-netplan-revert.timer"], timeout=5)["stdout"] == "active"
return {
"interface": iface, "gateway": gateway, "address": address, "prefix": prefix, "mac": mac,
"wireless": wireless, "private": private, "is_vps": is_vps, "virt": virt or "none",
"bridge_present": bridge_present, "on_bridge": on_bridge, "recommended": recommended,
"revert_armed": revert_armed,
"bridge_possible": bool(iface) and not wireless and not on_bridge,
}
def enable_bridge(iface: str, mode: str = "dhcp") -> tuple[bool, str]:
if not re.fullmatch(r"[a-zA-Z0-9_.:-]{1,15}", iface or "") or mode not in ("dhcp", "static"):
return False, "Некорректный интерфейс"
result = ctl_detached("bridge", iface, mode, delay_seconds=1)
return result["ok"], cmd_error(result, "Не удалось запустить настройку сети") if not result["ok"] else "Сеть перенастраивается"
def confirm_bridge() -> tuple[bool, str]:
result = ctl("bridge-confirm", timeout=30)
return result["ok"], cmd_error(result, "Не удалось подтвердить сеть") if not result["ok"] else "Сеть подтверждена"
def revert_bridge() -> tuple[bool, str]:
result = ctl_detached("bridge-revert", delay_seconds=1)
return result["ok"], cmd_error(result, "Не удалось вернуть прежнюю сеть") if not result["ok"] else "Возвращаем прежнюю сеть"
# ---------------------------------------------------------------- storage facts
def _lsblk() -> list[dict[str, Any]]:
result = run_cmd(["lsblk", "-J", "-b", "-o", "NAME,PATH,TYPE,SIZE,FSTYPE,MOUNTPOINT,MODEL,TRAN,RM,PKNAME"], timeout=10)
if not result["ok"]:
return []
try:
return json.loads(result["stdout"] or "{}").get("blockdevices", [])
except json.JSONDecodeError:
return []
def spare_disks() -> list[dict[str, Any]]:
"""Whole disks with no partitions or file system: safe candidates for VM storage."""
disks = []
for dev in _lsblk():
if dev.get("type") != "disk" or dev.get("children") or dev.get("fstype") or dev.get("mountpoint"):
continue
if str(dev.get("rm")) in ("1", "True", "true") or dev.get("tran") == "usb":
continue
size = int(dev.get("size") or 0)
if size < MIN_SPARE_DISK:
continue
disks.append({"path": dev.get("path") or f"/dev/{dev.get('name')}", "name": dev.get("name"), "size": size, "model": (dev.get("model") or "").strip() or "Диск", "tran": dev.get("tran") or ""})
return disks
def storage_facts() -> dict[str, Any]:
try:
usage = shutil.disk_usage(str(STORAGE_DIR) if STORAGE_DIR.exists() else "/")
total, free = usage.total, usage.free
except OSError:
total = free = 0
mount = run_cmd(["findmnt", "-n", "-o", "SOURCE,TARGET", "--target", str(STORAGE_DIR)], timeout=5)["stdout"]
source, _, target = mount.partition(" ")
return {"path": str(STORAGE_DIR), "total": total, "free": free, "source": source, "mount_target": target.strip() or "/", "dedicated": target.strip() == str(STORAGE_DIR), "spare": spare_disks()}
def use_disk_for_storage(path: str) -> tuple[bool, str]:
if not any(d["path"] == path for d in spare_disks()):
return False, "Этот диск нельзя использовать: он занят или на нём уже есть данные"
result = ctl("storage-use", path, timeout=600)
return result["ok"], cmd_error(result, "Не удалось подготовить диск") if not result["ok"] else "Диск подключён для хранения машин"
# ---------------------------------------------------------------- account & settings
def change_password(user: str, new_password: str) -> tuple[bool, str]:
if len(new_password) < 6:
return False, "Пароль должен быть не короче 6 символов"
result = ctl("passwd", user, stdin=new_password + "\n", timeout=30)
return result["ok"], cmd_error(result, "Не удалось сменить пароль") if not result["ok"] else "Пароль изменён"
def apply_settings(changes: dict[str, str]) -> tuple[bool, str]:
allowed = {"VIRTUALITY_WEB_PORT", "VIRTUALITY_TLS", "VIRTUALITY_TLS_PORT", "VIRTUALITY_AUTO_UPDATE", "VIRTUALITY_UPDATE_CHANNEL"}
pairs = [f"{key}={value}" for key, value in changes.items() if key in allowed and re.fullmatch(r"[A-Za-z0-9._-]+", str(value))]
if not pairs:
return False, "Нечего применять"
result = ctl_detached("set", *pairs)
return result["ok"], cmd_error(result, "Не удалось применить настройки") if not result["ok"] else "Настройки применяются"
def power(action: str) -> tuple[bool, str]:
if action not in ("reboot", "poweroff"):
return False, "Неизвестное действие"
result = ctl_detached("power", action, delay_seconds=3)
return result["ok"], cmd_error(result, "Не удалось выполнить") if not result["ok"] else ("Сервер перезагружается" if action == "reboot" else "Сервер выключается")
def backup_config() -> tuple[bool, str]:
result = ctl("backup-config", timeout=120)
return result["ok"], cmd_error(result, "Не удалось создать архив настроек") if not result["ok"] else result["stdout"]