- 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
288 строки
13 KiB
Python
288 строки
13 KiB
Python
"""First-run setup wizard (/setup) and the "applying settings" page."""
|
|
import re
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Form, Request
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
|
|
import network_core
|
|
import nodectl
|
|
import presenters
|
|
from core import AUTH_USER, cmd_error, get_current_user, redirect_with_message, render, require_auth, run_cmd
|
|
|
|
router = APIRouter()
|
|
|
|
STEPS = [
|
|
{"id": "install", "title": "Установка", "hint": "Компоненты сервера"},
|
|
{"id": "welcome", "title": "Сервер", "hint": "Проверка и время"},
|
|
{"id": "account", "title": "Администратор", "hint": "Вход в панель"},
|
|
{"id": "access", "title": "Доступ", "hint": "Адрес и HTTPS"},
|
|
{"id": "network", "title": "Сеть машин", "hint": "NAT или домашняя сеть"},
|
|
{"id": "storage", "title": "Хранилище", "hint": "Где живут машины"},
|
|
{"id": "updates", "title": "Обновления", "hint": "Как обновляться"},
|
|
{"id": "finish", "title": "Готово", "hint": "Проверка и запуск"},
|
|
]
|
|
STEP_IDS = [step["id"] for step in STEPS]
|
|
|
|
|
|
def wizard_pending() -> bool:
|
|
"""The wizard is offered until finished on nodes set up by the installer or the ISO."""
|
|
return not nodectl.wizard_done() and nodectl.SETUP_STATE.exists()
|
|
|
|
|
|
def step_index(step_id: str) -> int:
|
|
return STEP_IDS.index(step_id) if step_id in STEP_IDS else 0
|
|
|
|
|
|
def next_step(step_id: str) -> str:
|
|
index = step_index(step_id)
|
|
return STEP_IDS[min(index + 1, len(STEP_IDS) - 1)]
|
|
|
|
|
|
def render_step(request: Request, step_id: str, error: str | None = None, message: str | None = None, status_code: int = 200):
|
|
choices = nodectl.wizard_choices()
|
|
settings = nodectl.node_settings()
|
|
state = nodectl.setup_state()
|
|
host = request.url.hostname or "127.0.0.1"
|
|
context: dict[str, Any] = {
|
|
"steps": STEPS, "step": step_id, "step_index": step_index(step_id), "choices": choices, "settings": settings,
|
|
"state": state, "error": error, "message": message, "host": host, "auth_user": AUTH_USER,
|
|
"page_title": "Мастер настройки",
|
|
}
|
|
if step_id == "welcome":
|
|
context["hardware"] = nodectl.hardware_summary()
|
|
context["timezones"] = nodectl.timezones()
|
|
context["profile_ready"] = context["hardware"]["kvm"]
|
|
elif step_id == "access":
|
|
context["tls"] = choices.get("tls", settings.get("VIRTUALITY_TLS", "0") == "1" or not settings.get("VIRTUALITY_TLS_SET"))
|
|
context["port"] = choices.get("port", settings.get("VIRTUALITY_WEB_PORT", "8088"))
|
|
elif step_id == "network":
|
|
context["net"] = nodectl.network_facts()
|
|
context["nat_ready"] = network_core.libvirt_network_info()["exists"]
|
|
elif step_id == "storage":
|
|
context["storage"] = nodectl.storage_facts()
|
|
elif step_id == "finish":
|
|
context["net"] = nodectl.network_facts()
|
|
context["hardware"] = nodectl.hardware_summary()
|
|
context["storage"] = nodectl.storage_facts()
|
|
context["timezone"] = choices.get("timezone") or nodectl.timezones()["current"]
|
|
tls = str(choices.get("tls", "1")) == "1"
|
|
port = str(choices.get("port", settings.get("VIRTUALITY_WEB_PORT", "8088")))
|
|
context["final_url"] = f"https://{host}:{settings.get('VIRTUALITY_TLS_PORT', '8443')}" if tls else f"http://{host}:{port}"
|
|
context["restart_needed"] = (tls != (settings.get("VIRTUALITY_TLS") == "1")) or port != settings.get("VIRTUALITY_WEB_PORT", "8088") or str(choices.get("auto_update", settings.get("VIRTUALITY_AUTO_UPDATE", "1"))) != settings.get("VIRTUALITY_AUTO_UPDATE", "1") or str(choices.get("channel", settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable"))) != settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable")
|
|
return render(request, "setup.html", context, status_code=status_code)
|
|
|
|
|
|
@router.get("/setup")
|
|
def setup_index(request: Request):
|
|
auth = require_auth(request)
|
|
if auth:
|
|
return auth
|
|
choices = nodectl.wizard_choices()
|
|
state = nodectl.setup_state()
|
|
if not state["installed"]:
|
|
return RedirectResponse(url="/setup/install", status_code=303)
|
|
return RedirectResponse(url=f"/setup/{choices.get('step', 'welcome')}", status_code=303)
|
|
|
|
|
|
@router.get("/setup/{step_id}")
|
|
def setup_step(request: Request, step_id: str, message: str = "", error: str = ""):
|
|
auth = require_auth(request)
|
|
if auth:
|
|
return auth
|
|
if step_id not in STEP_IDS:
|
|
return RedirectResponse(url="/setup", status_code=303)
|
|
if step_id != "install" and not nodectl.setup_state()["installed"]:
|
|
return RedirectResponse(url="/setup/install", status_code=303)
|
|
if step_id != "install":
|
|
nodectl.save_wizard_choices(step=step_id)
|
|
return render_step(request, step_id, error=error or None, message=message or None)
|
|
|
|
|
|
@router.get("/api/setup/state")
|
|
def setup_state_api(request: Request):
|
|
if not get_current_user(request):
|
|
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
|
|
state = nodectl.setup_state()
|
|
return {"ok": True, "state": state, "net": {"revert_armed": nodectl.network_facts()["revert_armed"]} if request.query_params.get("net") else None}
|
|
|
|
|
|
# ---------------------------------------------------------------- step handlers
|
|
@router.post("/setup/welcome")
|
|
def setup_welcome(request: Request, timezone: str = Form("")):
|
|
auth = require_auth(request)
|
|
if auth:
|
|
return auth
|
|
if timezone:
|
|
ok, detail = nodectl.set_timezone(timezone)
|
|
if not ok:
|
|
return render_step(request, "welcome", error=detail, status_code=400)
|
|
nodectl.save_wizard_choices(timezone=timezone)
|
|
return RedirectResponse(url="/setup/account", status_code=303)
|
|
|
|
|
|
@router.post("/setup/account")
|
|
def setup_account(request: Request, password: str = Form(""), password2: str = Form("")):
|
|
auth = require_auth(request)
|
|
if auth:
|
|
return auth
|
|
if password or password2:
|
|
if password != password2:
|
|
return render_step(request, "account", error="Пароли не совпадают", status_code=400)
|
|
ok, detail = nodectl.change_password(AUTH_USER, password)
|
|
if not ok:
|
|
return render_step(request, "account", error=detail, status_code=400)
|
|
nodectl.save_wizard_choices(password_changed=True)
|
|
return RedirectResponse(url="/setup/access", status_code=303)
|
|
|
|
|
|
@router.post("/setup/access")
|
|
def setup_access(request: Request, tls: str = Form("1"), port: str = Form("8088")):
|
|
auth = require_auth(request)
|
|
if auth:
|
|
return auth
|
|
if not re.fullmatch(r"\d{2,5}", port) or not 1 <= int(port) <= 65535 or int(port) in (22, 9090):
|
|
return render_step(request, "access", error="Порт должен быть числом от 1 до 65535 (кроме 22 и 9090)", status_code=400)
|
|
nodectl.save_wizard_choices(tls="1" if tls == "1" else "0", port=port)
|
|
return RedirectResponse(url="/setup/network", status_code=303)
|
|
|
|
|
|
@router.post("/setup/network")
|
|
def setup_network(request: Request, mode: str = Form("nat")):
|
|
auth = require_auth(request)
|
|
if auth:
|
|
return auth
|
|
if mode not in ("nat", "bridge"):
|
|
return render_step(request, "network", error="Выберите режим сети", status_code=400)
|
|
if mode == "nat":
|
|
try:
|
|
network_core.create_nat_network()
|
|
except network_core.NetworkError as exc:
|
|
return render_step(request, "network", error=f"Не удалось создать сеть машин: {exc}", status_code=500)
|
|
nodectl.save_wizard_choices(network="nat")
|
|
return RedirectResponse(url="/setup/storage", status_code=303)
|
|
facts = nodectl.network_facts()
|
|
if facts["on_bridge"]:
|
|
nodectl.save_wizard_choices(network="bridge")
|
|
return RedirectResponse(url="/setup/storage", status_code=303)
|
|
if not facts["bridge_possible"]:
|
|
return render_step(request, "network", error="На этом сервере мост недоступен (Wi-Fi или не найден проводной интерфейс). Выберите NAT.", status_code=400)
|
|
ok, detail = nodectl.enable_bridge(facts["interface"], "dhcp")
|
|
if not ok:
|
|
return render_step(request, "network", error=detail, status_code=500)
|
|
nodectl.save_wizard_choices(network="bridge", bridge_pending=True)
|
|
return render(request, "setup_bridge_wait.html", {"page_title": "Перенастройка сети", "next_url": "/setup/storage", "confirm_url": "/setup/network/confirm", "revert_url": "/setup/network/revert", "back_url": "/setup/network"})
|
|
|
|
|
|
@router.post("/setup/network/confirm")
|
|
def setup_network_confirm(request: Request):
|
|
if not get_current_user(request):
|
|
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
|
|
ok, detail = nodectl.confirm_bridge()
|
|
if ok:
|
|
nodectl.save_wizard_choices(bridge_pending=False, network="bridge")
|
|
profile = None
|
|
try:
|
|
import host_profile
|
|
|
|
profile = host_profile.detect_host_profile()
|
|
host_profile.save_host_profile(profile)
|
|
except Exception:
|
|
pass
|
|
return JSONResponse({"ok": ok, "message": detail})
|
|
|
|
|
|
@router.post("/setup/network/revert")
|
|
def setup_network_revert(request: Request):
|
|
auth = require_auth(request)
|
|
if auth:
|
|
return auth
|
|
ok, detail = nodectl.revert_bridge()
|
|
nodectl.save_wizard_choices(bridge_pending=False, network="nat")
|
|
return redirect_with_message("/setup/network", "message" if ok else "error", detail)
|
|
|
|
|
|
@router.post("/setup/storage")
|
|
def setup_storage(request: Request, disk: str = Form("")):
|
|
auth = require_auth(request)
|
|
if auth:
|
|
return auth
|
|
if disk and disk != "keep":
|
|
ok, detail = nodectl.use_disk_for_storage(disk)
|
|
if not ok:
|
|
return render_step(request, "storage", error=detail, status_code=400)
|
|
nodectl.save_wizard_choices(storage_disk=disk)
|
|
return RedirectResponse(url="/setup/updates", status_code=303)
|
|
|
|
|
|
@router.post("/setup/updates")
|
|
def setup_updates(request: Request, auto_update: str = Form("1"), channel: str = Form("stable")):
|
|
auth = require_auth(request)
|
|
if auth:
|
|
return auth
|
|
if channel not in ("stable", "main"):
|
|
channel = "stable"
|
|
nodectl.save_wizard_choices(auto_update="1" if auto_update == "1" else "0", channel=channel)
|
|
return RedirectResponse(url="/setup/finish", status_code=303)
|
|
|
|
|
|
@router.post("/setup/finish")
|
|
def setup_finish(request: Request):
|
|
auth = require_auth(request)
|
|
if auth:
|
|
return auth
|
|
choices = nodectl.wizard_choices()
|
|
settings = nodectl.node_settings()
|
|
host = request.url.hostname or "127.0.0.1"
|
|
changes = {
|
|
"VIRTUALITY_TLS": str(choices.get("tls", "1")),
|
|
"VIRTUALITY_WEB_PORT": str(choices.get("port", settings.get("VIRTUALITY_WEB_PORT", "8088"))),
|
|
"VIRTUALITY_AUTO_UPDATE": str(choices.get("auto_update", settings.get("VIRTUALITY_AUTO_UPDATE", "1"))),
|
|
"VIRTUALITY_UPDATE_CHANNEL": str(choices.get("channel", settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable"))),
|
|
}
|
|
current = {"VIRTUALITY_TLS": settings.get("VIRTUALITY_TLS", "0"), "VIRTUALITY_WEB_PORT": settings.get("VIRTUALITY_WEB_PORT", "8088"), "VIRTUALITY_AUTO_UPDATE": settings.get("VIRTUALITY_AUTO_UPDATE", "1"), "VIRTUALITY_UPDATE_CHANNEL": settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable")}
|
|
nodectl.mark_wizard_done()
|
|
nodectl.save_wizard_choices(step="finish", finished=True)
|
|
if changes != current and nodectl.ctl_available():
|
|
ok, detail = nodectl.apply_settings(changes)
|
|
if not ok:
|
|
return render_step(request, "finish", error=detail, status_code=500)
|
|
final_url = f"https://{host}:{settings.get('VIRTUALITY_TLS_PORT', '8443')}" if changes["VIRTUALITY_TLS"] == "1" else f"http://{host}:{changes['VIRTUALITY_WEB_PORT']}"
|
|
return render(request, "setup_applying.html", {"page_title": "Применяем настройки", "final_url": final_url, "tls": changes["VIRTUALITY_TLS"] == "1"})
|
|
return redirect_with_message("/", "setup_message", "Сервер настроен. Можно создавать машины.")
|
|
|
|
|
|
@router.get("/setup/skip")
|
|
def setup_skip(request: Request):
|
|
auth = require_auth(request)
|
|
if auth:
|
|
return auth
|
|
nodectl.mark_wizard_done()
|
|
return redirect_with_message("/", "setup_message", "Мастер пропущен. Его можно запустить позже в Настройках.")
|
|
|
|
|
|
@router.post("/setup/restart")
|
|
def setup_restart(request: Request):
|
|
"""Re-run the wizard from Settings."""
|
|
auth = require_auth(request)
|
|
if auth:
|
|
return auth
|
|
try:
|
|
nodectl.SETUP_DONE.unlink()
|
|
except OSError:
|
|
pass
|
|
nodectl.save_wizard_choices(step="welcome", finished=False)
|
|
if not nodectl.SETUP_STATE.exists():
|
|
nodectl.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
nodectl.SETUP_STATE.write_text('{"stage": "done", "steps": []}\n')
|
|
return RedirectResponse(url="/setup/welcome", status_code=303)
|
|
|
|
|
|
def virtualization_hint() -> str:
|
|
result = run_cmd(["systemd-detect-virt"], timeout=5)
|
|
return cmd_error(result, "") if not result["ok"] else result["stdout"]
|
|
|
|
|
|
def format_bytes(value: int) -> str:
|
|
return presenters.format_bytes(value)
|