diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c8fa03e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Tests (Python ${{ matrix.python }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + # 3.12: Ubuntu 24.04, 3.13: Debian 13, 3.14: Ubuntu 26.04 (no crypt/spwd modules). + python: ["3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: web/requirements.txt + - run: pip install -r web/requirements.txt -r requirements-dev.txt + - run: make lint-python test + + shell: + name: Shell scripts + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - run: make lint-shell diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..3a1d361 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,64 @@ +name: Build ISO image + +on: + push: + tags: ["v*"] + pull_request: + paths: + - "image/**" + - "scripts/virtuality_firstboot.sh" + - "web/requirements.txt" + - ".github/workflows/image.yml" + workflow_dispatch: + inputs: + arch: + description: "Architecture" + type: choice + options: [amd64, arm64, both] + default: amd64 + +permissions: + contents: read + +concurrency: + group: image-${{ github.ref }} + cancel-in-progress: true + +jobs: + iso: + name: ISO ${{ matrix.arch }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + arch: ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: sudo apt-get update && sudo apt-get install -y xorriso python3-yaml + - uses: actions/cache@v4 + with: + path: .cache/iso + key: ubuntu-iso-${{ matrix.arch }}-${{ hashFiles('image/build-iso.sh') }} + restore-keys: ubuntu-iso-${{ matrix.arch }}- + - name: Free disk space + run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL && df -h / + - run: make iso ARCH=${{ matrix.arch }} + - name: Check ISO boot records + run: | + iso=$(ls dist/*.iso) + xorriso -indev "$iso" -report_el_torito plain 2>/dev/null | tee /dev/stderr | grep -q "UEFI" + xorriso -osirrox on -indev "$iso" -extract /autoinstall.yaml autoinstall.yaml -extract /boot/grub/grub.cfg grub.cfg + python3 -c "import yaml; d = yaml.safe_load(open('autoinstall.yaml')); assert d['autoinstall']['version'] == 1" + grep -q "Install Virtuality" grub.cfg + # Ubuntu server ISOs are larger than the 2 GB GitHub release asset limit, + # so images are published as workflow artifacts. + - uses: actions/upload-artifact@v4 + with: + name: virtuality-iso-${{ matrix.arch }} + path: | + dist/*.iso + dist/*.sha256 + compression-level: 0 + retention-days: 30 diff --git a/.gitignore b/.gitignore index af771ab..e9b9b4b 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,6 @@ composer.lock # System files *.pid *.sock + +# Offline Python wheels bundled into system images +wheels/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3be3cb6 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +PYTHON ?= python3 +ARCH ?= amd64 +ISO_ARGS ?= +SHELL_SCRIPTS := $(wildcard *.sh scripts/*.sh image/*.sh) + +.PHONY: help test lint lint-python lint-shell check iso clean + +help: + @echo "make test - run the web panel test suite" + @echo "make lint - pyflakes + shellcheck" + @echo "make check - lint + test" + @echo "make iso - build the installer ISO (ARCH=amd64|arm64, ISO_ARGS='--unattended ...')" + @echo "make clean - remove build output" + +test: + $(PYTHON) -m pytest -q + +lint-python: + $(PYTHON) -m pyflakes web tests + +lint-shell: + @for f in $(SHELL_SCRIPTS); do bash -n "$$f" || exit 1; done + shellcheck -S error $(SHELL_SCRIPTS) + +lint: lint-python lint-shell + +check: lint test + +iso: + ./image/build-iso.sh --arch $(ARCH) $(ISO_ARGS) + +clean: + rm -rf dist .pytest_cache + find . -name __pycache__ -type d -prune -exec rm -rf {} + diff --git a/README.md b/README.md index 3fca50a..ed72360 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией. -> Текущий статус: ранняя стадия разработки. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding. +> Текущий статус: версия 0.10 — код панели покрыт тестами, есть установочный ISO-образ. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding. --- @@ -33,6 +33,29 @@ generic-arm64 # другая ARM64-плата Для ARM64-плат правильный сценарий — **ARM64-гости**, NAT-сеть и позже cloud-image/cloud-init шаблоны. x86_64 ISO на Raspberry/Orange Pi не являются целевым режимом. +![Обзор](docs/screenshots/dashboard.png) + +| Мастер создания машины | Страница машины | +|---|---| +| ![Создание машины](docs/screenshots/create.png) | ![Машина](docs/screenshots/vm.png) | + +| Сеть и доступ | Тёмная тема | +|---|---| +| ![Сеть](docs/screenshots/network.png) | ![Тёмная тема](docs/screenshots/dashboard-dark.png) | + +--- + +## Установка с ISO-образа + +Для установки на «голое» железо собирается загрузочный ISO на базе чистого Ubuntu Server 26.04 LTS: установщик спрашивает только сеть, диск и пользователя, а при первой загрузке нода настраивается сама. + +```bash +make iso # dist/virtuality-<версия>-ubuntu-26.04-amd64.iso +make iso ARCH=arm64 +``` + +Подробно: [`docs/IMAGE.md`](docs/IMAGE.md). + --- ## Быстрая установка одной командой @@ -125,7 +148,7 @@ CPU: 2 ядра с Intel VT-x / AMD-V RAM: 4 GB /: минимум 8 GB свободно /var/lib: минимум 20 GB свободно -OS: Ubuntu Server 24.04 LTS / Debian-like с apt +OS: Ubuntu Server 26.04 / 24.04 LTS или Debian 13 Network: один проводной интерфейс ``` @@ -136,7 +159,7 @@ CPU: 4+ ядра RAM: 16+ GB Storage: 100+ GB SSD/NVMe под /var/lib/virtuality Network: 1 Gbit/s+ -OS: Ubuntu Server 24.04 LTS +OS: Ubuntu Server 26.04 LTS ``` ARM64 edge nodes: @@ -186,6 +209,8 @@ curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/instal /var/lib/virtuality/backups # backups /var/log/virtuality # логи установки и диагностики /var/log/virtuality/operations # JSON/log фоновых операций +/var/lib/virtuality/config/web.env # настройки ноды: порт, пользователь, автообновление +/var/lib/virtuality/disk-images # загруженные образы дисков /etc/virtuality/nftables/virtuality.nft # nftables-правила Virtuality ``` @@ -375,6 +400,8 @@ sudo virsh pool-refresh virtuality-iso ## Обновление +Из web-панели: раздел `/update`. Вручную: + ```bash cd /opt/virtuality/source sudo git pull @@ -382,6 +409,8 @@ sudo bash scripts/install_web_panel.sh sudo systemctl restart virtuality-web ``` +Порт панели, пользователь и режим автообновления сохраняются в `/var/lib/virtuality/config/web.env` и не сбрасываются при обновлениях. + Полная повторная установка компонентов ноды: ```bash @@ -392,6 +421,45 @@ sudo bash scripts/install_web_panel.sh --- +## Продакшен-настройки + +Настройки ноды — `/var/lib/virtuality/config/web.env`: + +```text +VIRTUALITY_WEB_PORT=8088 # порт панели +VIRTUALITY_WEB_HOST=0.0.0.0 # 127.0.0.1, если панель стоит за reverse proxy +VIRTUALITY_AUTH_USER=admin # Linux-пользователь для входа +VIRTUALITY_AUTO_UPDATE=1 # 0 — отключить ночное автообновление с GitHub +VIRTUALITY_COOKIE_SECURE=0 # 1 — cookie только по HTTPS (за reverse proxy с TLS) +``` + +После изменения: `cd /opt/virtuality/source && sudo bash scripts/install_web_panel.sh`. Любой параметр можно передать и переменной окружения при установке, например `VIRTUALITY_AUTO_UPDATE=0`. + +Рекомендации: + +- для продакшена отключите автообновление (`VIRTUALITY_AUTO_UPDATE=0`) и обновляйтесь вручную через `/update` после проверки версии; +- открывайте панель наружу только через HTTPS reverse proxy (nginx/Caddy) с `VIRTUALITY_WEB_HOST=127.0.0.1` и `VIRTUALITY_COOKIE_SECURE=1`; +- reverse proxy должен передавать исходный `Host` (nginx: `proxy_set_header Host $host;`, для noVNC-консоли ещё `Upgrade`/`Connection`), иначе панель отклонит POST-запросы как cross-origin; +- мониторинг: `GET /healthz` без авторизации возвращает `{"ok": true, "version": "..."}`; +- после 5 неверных паролей вход с этого адреса блокируется на 5 минут; сессия живёт 12 часов; +- правила проброса портов восстанавливаются после перезагрузки сервисом `virtuality-network.service`. + +--- + +## Разработка + +```bash +pip install -r web/requirements.txt -r requirements-dev.txt +make check # pyflakes + shellcheck + pytest +python3 tests/dev_server.py # панель на http://127.0.0.1:8765 с имитацией virsh, вход: любой пароль +``` + +Интерфейс: шаблоны `web/templates` (общий каркас `base.html`, компоненты `_ui.html`), стили `web/static/app.css` (светлая и тёмная темы на CSS-переменных), поведение `web/static/panel.js`. Шрифт Inter и иконки Lucide лежат в `web/static` — панель работает без доступа в интернет. + +Код панели лежит в `web/` целиком и устанавливается как есть. Патчить `app.py` во время установки больше не нужно: изменения вносятся прямо в `web/` и покрываются тестами в `tests/`. GitHub Actions запускает проверки на Python 3.11–3.13 и собирает ISO по тегу `v*`. + +--- + ## Полезные команды ```bash @@ -416,14 +484,13 @@ journalctl -u virtuality-web -f - ARM64 cloud-image templates; - cloud-init для быстрых VM; -- web-console/noVNC; - управление storage pools; - backup/snapshot manager; - сетевой менеджер bridge/VLAN; - роли и права пользователей; - журнал событий; -- автообновление; -- кластеризация. +- кластеризация; +- готовые образы для Raspberry Pi / Orange Pi 5. --- diff --git a/VERSION b/VERSION index 85b7c69..78bc1ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.6 +0.10.0 diff --git a/bootstrap.sh b/bootstrap.sh index 15de57e..e162e4e 100644 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -1,296 +1,16 @@ #!/usr/bin/env bash set -euo pipefail -# ========================================================== -# Virtuality One-command Bootstrap Installer -# Public install entrypoint for clean Ubuntu/Debian servers. -# ========================================================== +# Legacy entrypoint kept for old documentation links. +# All installation logic lives in install.sh. -REPO_URL="${VIRTUALITY_REPO_URL:-https://github.com/viktor138irk/virtuality.git}" -DEFAULT_INSTALL_USER="${SUDO_USER:-root}" -INSTALL_USER="${VIRTUALITY_USER:-$DEFAULT_INSTALL_USER}" -PROJECT_BASE_DIR="${VIRTUALITY_PROJECT_BASE_DIR:-/opt/virtuality}" -PROJECT_DIR="${VIRTUALITY_PROJECT_DIR:-${PROJECT_BASE_DIR}/source}" -RUN_BRIDGE="${VIRTUALITY_SETUP_BRIDGE:-0}" -BRIDGE_IFACE="${VIRTUALITY_BRIDGE_IFACE:-}" -RUN_TEST_VM="${VIRTUALITY_CREATE_TEST_VM:-0}" -WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}" -LOG_DIR="/var/log/virtuality" -LOG_FILE="${LOG_DIR}/bootstrap_$(date +%Y%m%d_%H%M%S).log" -MIN_ROOT_FREE_MB="${VIRTUALITY_MIN_ROOT_FREE_MB:-8192}" -MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}" -MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}" -MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}" -SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}" +INSTALL_URL="${VIRTUALITY_INSTALL_URL:-https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || true)" -ESC="\033" -RESET="${ESC}[0m" -BOLD="${ESC}[1m" -GREEN="${ESC}[32m" -YELLOW="${ESC}[33m" -RED="${ESC}[31m" -CYAN="${ESC}[36m" -BLUE="${ESC}[34m" -GRAY="${ESC}[90m" - -mkdir -p "$LOG_DIR" - -timestamp() { date '+%Y-%m-%d %H:%M:%S'; } -log() { echo "[$(timestamp)] $*" >> "$LOG_FILE"; } -ok() { echo -e " ${GREEN}✓${RESET} $*"; log "OK: $*"; } -warn() { echo -e " ${YELLOW}!${RESET} $*"; log "WARN: $*"; } -fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Bootstrap остановлен.${RESET} Лог: ${LOG_FILE}"; exit 1; } -step() { echo; echo -e "${BLUE}${BOLD}▶${RESET} ${BOLD}$*${RESET}"; log "STEP: $*"; } - -run_logged() { - local desc="$1" - shift - local start_ts pid code spinner elapsed symbol i - log "RUN: $*" - start_ts="$(date +%s)" - "$@" >> "$LOG_FILE" 2>&1 & - pid=$! - spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") - i=0 - while kill -0 "$pid" 2>/dev/null; do - elapsed=$(( $(date +%s) - start_ts )) - symbol="${spinner[$((i % ${#spinner[@]}))]}" - printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$desc" "$elapsed" "$LOG_FILE" - sleep 1 - i=$((i + 1)) - done - wait "$pid" - code=$? - printf "\r%*s\r" 120 "" - if [[ "$code" -eq 0 ]]; then - ok "$desc" - else - fail "$desc" - fi -} - -header() { - clear 2>/dev/null || true - echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}" - echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Bootstrap Installer${RESET} ${CYAN}${BOLD}│${RESET}" - echo -e "${CYAN}${BOLD}│${RESET} One-command KVM / libvirt / Cockpit / Web Panel setup ${CYAN}${BOLD}│${RESET}" - echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}" - echo - echo -e "${GRAY}Repository:${RESET} ${REPO_URL}" - echo -e "${GRAY}Auth/install user:${RESET} ${INSTALL_USER}" - echo -e "${GRAY}Project dir:${RESET} ${PROJECT_DIR}" - echo -e "${GRAY}Log:${RESET} ${LOG_FILE}" - echo -} - -require_root() { - [[ "$EUID" -eq 0 ]] || fail "Запусти от root: curl ... | sudo bash" -} - -free_mb_for_path() { - local path="$1" - mkdir -p "$path" 2>/dev/null || true - df -Pm "$path" | awk 'NR==2 {print $4}' -} - -check_password_state() { - local user="$1" - local hash - hash="$(getent shadow "$user" | cut -d: -f2 || true)" - if [[ -z "$hash" || "$hash" == "!" || "$hash" == "*" || "$hash" == "!!" ]]; then - warn "У пользователя ${user} пароль не задан или заблокирован. Для входа в панель выполни: passwd ${user}" - else - ok "У пользователя ${user} есть пароль для входа в панель" - fi -} - -check_requirements() { - step "Проверяем минимальные системные требования" - if [[ "$SKIP_REQUIREMENTS" == "1" ]]; then - warn "Проверка требований отключена через VIRTUALITY_SKIP_REQUIREMENTS=1" - return 0 - fi - - local root_free var_free ram_mb cpu_cores - root_free="$(free_mb_for_path /)" - var_free="$(free_mb_for_path /var/lib 2>/dev/null || free_mb_for_path /)" - ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)" - cpu_cores="$(nproc 2>/dev/null || echo 1)" - - if (( root_free >= MIN_ROOT_FREE_MB )); then - ok "Свободно на /: ${root_free} MB минимум ${MIN_ROOT_FREE_MB} MB" - else - fail "Недостаточно места на /: ${root_free} MB. Нужно минимум ${MIN_ROOT_FREE_MB} MB. Очисти диск: apt clean; journalctl --vacuum-time=3d" - fi - - if (( var_free >= MIN_VAR_FREE_MB )); then - ok "Свободно для /var/lib: ${var_free} MB минимум ${MIN_VAR_FREE_MB} MB" - else - fail "Недостаточно места для VM-хранилища /var/lib: ${var_free} MB. Нужно минимум ${MIN_VAR_FREE_MB} MB" - fi - - if (( ram_mb >= MIN_RAM_MB )); then - ok "RAM: ${ram_mb} MB минимум ${MIN_RAM_MB} MB" - else - warn "RAM: ${ram_mb} MB меньше рекомендуемых ${MIN_RAM_MB} MB. Установка возможна, но VM будут ограничены" - fi - - if (( cpu_cores >= MIN_CPU_CORES )); then - ok "CPU cores: ${cpu_cores} минимум ${MIN_CPU_CORES}" - else - warn "CPU cores: ${cpu_cores} меньше рекомендуемых ${MIN_CPU_CORES}" - fi - - if grep -E -q '(vmx|svm)' /proc/cpuinfo; then - ok "CPU virtualization vmx/svm найдена" - else - warn "CPU virtualization vmx/svm не найдена. Проверь BIOS/UEFI или настройки VPS" - fi -} - -ensure_user() { - if [[ "$INSTALL_USER" == "root" ]]; then - ok "Пользователь авторизации: root" - check_password_state root - return 0 - fi - - if id "$INSTALL_USER" >/dev/null 2>&1; then - ok "Пользователь уже существует: ${INSTALL_USER}" - else - run_logged "Создан пользователь ${INSTALL_USER}" adduser --disabled-password --gecos "" "$INSTALL_USER" - warn "Пароль для ${INSTALL_USER} не задан. После установки выполни: sudo passwd ${INSTALL_USER}" - fi - usermod -aG sudo "$INSTALL_USER" >> "$LOG_FILE" 2>&1 || true - ok "Пользователь ${INSTALL_USER} добавлен в sudo" - check_password_state "$INSTALL_USER" -} - -run_as_user() { - if [[ "$INSTALL_USER" == "root" ]]; then - bash -lc "$*" - else - sudo -H -u "$INSTALL_USER" bash -lc "$*" - fi -} - -prepare_project_dir() { - mkdir -p "$PROJECT_BASE_DIR" - if [[ "$INSTALL_USER" == "root" ]]; then - chown root:root "$PROJECT_BASE_DIR" - else - chown "$INSTALL_USER:$INSTALL_USER" "$PROJECT_BASE_DIR" - fi - chmod 755 "$PROJECT_BASE_DIR" - ok "Рабочая директория подготовлена: ${PROJECT_BASE_DIR}" -} - -header - -step "Проверяем root и apt" -require_root -command -v apt >/dev/null 2>&1 || fail "apt не найден. Нужен Debian/Ubuntu" -ok "root подтверждён" -ok "apt найден" - -check_requirements - -step "Устанавливаем базовые пакеты" -run_logged "apt update выполнен" apt update -run_logged "Установлены sudo/git/curl/ca-certificates" apt install -y sudo git curl ca-certificates openssh-client nano htop - -step "Создаём или проверяем пользователя" -ensure_user - -step "Готовим рабочую директорию в /opt" -prepare_project_dir - -step "Проверяем место после подготовки пользователя" -root_free_after="$(free_mb_for_path /)" -if (( root_free_after < MIN_ROOT_FREE_MB )); then - fail "После подготовки пользователя свободного места стало мало: ${root_free_after} MB на /. Очисти диск и повтори установку" -fi -ok "Место после подготовки пользователя: ${root_free_after} MB на /" - -step "Клонируем или обновляем репозиторий" -if [[ -d "$PROJECT_DIR/.git" ]]; then - run_logged "Репозиторий обновлён" run_as_user "cd '$PROJECT_DIR' && git reset --hard origin/main && git pull" -else - run_logged "Репозиторий склонирован" run_as_user "cd '$PROJECT_BASE_DIR' && git clone '$REPO_URL' source" +if [[ -n "$SCRIPT_DIR" && -f "${SCRIPT_DIR}/install.sh" ]]; then + exec bash "${SCRIPT_DIR}/install.sh" "$@" fi -step "Запускаем основной установщик ноды" -run_logged "Virtuality Node установлен" bash "$PROJECT_DIR/install_virtuality_node.sh" - -step "Устанавливаем healthcheck-команду" -run_logged "Healthcheck установлен" bash "$PROJECT_DIR/scripts/install_healthcheck_command.sh" - -step "Устанавливаем консольный dashboard" -run_logged "Console dashboard установлен" bash "$PROJECT_DIR/scripts/install_console_dashboard.sh" - -step "Устанавливаем web-панель" -VIRTUALITY_AUTH_USER="$INSTALL_USER" VIRTUALITY_WEB_PORT="$WEB_PORT" bash "$PROJECT_DIR/scripts/install_web_panel.sh" >> "$LOG_FILE" 2>&1 & -web_pid=$! -web_start="$(date +%s)" -web_i=0 -web_spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") -while kill -0 "$web_pid" 2>/dev/null; do - web_elapsed=$(( $(date +%s) - web_start )) - printf "\r ${CYAN}%s${RESET} Web-панель устанавливается... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "${web_spinner[$((web_i % ${#web_spinner[@]}))]}" "$web_elapsed" "$LOG_FILE" - sleep 1 - web_i=$((web_i + 1)) -done -wait "$web_pid" -web_code=$? -printf "\r%*s\r" 120 "" -[[ "$web_code" -eq 0 ]] && ok "Web-панель установлена" || fail "Web-панель не установлена" - -if [[ "$RUN_BRIDGE" == "1" ]]; then - step "Настраиваем bridge br0" - if [[ -z "$BRIDGE_IFACE" ]]; then - BRIDGE_IFACE="$(ip route | awk '/default/ {print $5; exit}')" - fi - if [[ -z "$BRIDGE_IFACE" ]]; then - warn "Не удалось определить интерфейс для br0. Пропускаю bridge setup" - else - warn "Bridge меняет сеть. Если SSH зависнет, нужен физический доступ или консоль провайдера." - bash "$PROJECT_DIR/scripts/setup_bridge_br0.sh" "$BRIDGE_IFACE" static >> "$LOG_FILE" 2>&1 && ok "Bridge br0 настроен на ${BRIDGE_IFACE}" || warn "Bridge setup завершился с предупреждением. Проверь лог" - fi -else - warn "Bridge br0 не настраивался автоматически. Запуск вручную: sudo bash scripts/setup_bridge_br0.sh INTERFACE static" -fi - -if [[ "$RUN_TEST_VM" == "1" ]]; then - step "Создаём тестовую VM" - run_logged "Тестовая VM создана" bash "$PROJECT_DIR/scripts/create_test_vm.sh" || warn "Тестовая VM не создана. Проверь ISO/bridge/log" -fi - -step "Финальная диагностика" -if command -v vhealth >/dev/null 2>&1; then - vhealth >> "$LOG_FILE" 2>&1 && ok "vhealth: OK" || warn "vhealth вернул warnings/errors. Проверь: sudo vhealth" -else - warn "Команда vhealth не найдена" -fi - -SERVER_IP="$(hostname -I | awk '{print $1}')" - -echo -echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}" -echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Virtuality Bootstrap завершён${RESET} ${GREEN}${BOLD}│${RESET}" -echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}" -echo -echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:9090" -echo -e "${BOLD}Web Panel:${RESET} http://${SERVER_IP}:${WEB_PORT}" -echo -e "${BOLD}Login:${RESET} ${INSTALL_USER} / пароль Linux-пользователя" -echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}" -echo -e "${BOLD}Log:${RESET} ${LOG_FILE}" -echo -echo -e "${BOLD}После установки:${RESET}" -echo " passwd ${INSTALL_USER} # если пароль ещё не задан или заблокирован" -echo " sudo vhealth" -echo " cd ${PROJECT_DIR}" -echo " ip -br a" -echo -echo -e "${YELLOW}Важно:${RESET} bridge br0 по умолчанию не включается в one-command режиме, чтобы не уронить SSH." -echo "Для bridge: cd ${PROJECT_DIR} && sudo bash scripts/setup_bridge_br0.sh INTERFACE static" -echo +tmp_installer="$(mktemp /tmp/virtuality-install.XXXXXX.sh)" +curl --connect-timeout 20 --max-time 180 -fsSL "$INSTALL_URL" -o "$tmp_installer" +exec bash "$tmp_installer" "$@" diff --git a/docs/IMAGE.md b/docs/IMAGE.md new file mode 100644 index 0000000..7d14b8e --- /dev/null +++ b/docs/IMAGE.md @@ -0,0 +1,143 @@ +# Установочный образ Virtuality + +Virtuality можно поставить двумя способами: + +1. **Скрипт на готовый сервер** — `curl ... install.sh | bash` (см. README). +2. **Установочный ISO** — загрузочный образ «с нуля», как у Proxmox: записываете на флешку, загружаетесь, отвечаете на 3 экрана, после перезагрузки получаете готовую ноду. + +Этот документ про второй способ. + +--- + +## Что внутри образа + +Образ собирается из официального, ничем не изменённого **Ubuntu Server 26.04 LTS live-server ISO** (amd64 или arm64; поддержка до 2031 года) и дополняется: + +```text +/autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall) +/virtuality/source # исходники Virtuality (git-репозиторий нужной версии) +/virtuality/source/wheels # Python-колёса web-панели для офлайн-установки +/virtuality/image.env # параметры сборки: порт панели, автообновление, пользователь +/virtuality/virtuality-firstboot.service +/boot/grub/grub.cfg # меню «Install Virtuality X.Y.Z» +``` + +Загрузочные записи BIOS и UEFI берутся из оригинального ISO без изменений. + +## Как проходит установка + +1. Загрузка с ISO → пункт **Install Virtuality**. +2. Установщик Ubuntu спрашивает только **сеть**, **диск** и **пользователя** (имя, пароль, hostname). Язык, раскладка, SSH-сервер и остальное заданы автоматически. +3. Установщик копирует Virtuality в `/opt/virtuality/source` и включает `virtuality-firstboot.service`. +4. После перезагрузки `virtuality-firstboot` (нужен интернет) ставит KVM/QEMU/libvirt, Cockpit, web-панель, `vhealth` и консольный dashboard. Обычно это 5–15 минут. Если сети нет или apt упал, попытка повторяется каждую минуту до успеха. +5. Готово: + +```text +Web-панель: http://SERVER_IP:8088 (логин — пользователь, созданный в установщике) +Cockpit: https://SERVER_IP:9090 +``` + +Адрес панели также показывается на экране входа консоли. Ход первой настройки: + +```bash +sudo journalctl -fu virtuality-firstboot +cat /var/log/virtuality/firstboot.log +``` + +--- + +## Сборка ISO + +Нужна Linux-машина (root не нужен), ~10 GB свободного места и пакеты: + +```bash +sudo apt install -y xorriso git curl python3-pip +``` + +Сборка: + +```bash +git clone https://github.com/viktor138irk/virtuality.git +cd virtuality +make iso # amd64 +make iso ARCH=arm64 # ARM64-серверы с UEFI +``` + +Скрипт сам скачает последний Ubuntu 26.04.x live-server ISO, проверит SHA256 и положит результат в `dist/`: + +```text +dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso +dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso.sha256 +``` + +Скачанный ISO Ubuntu кешируется в `.cache/iso`. Уже скачанный образ можно передать явно: + +```bash +./image/build-iso.sh --ubuntu-iso ~/Downloads/ubuntu-26.04.1-live-server-amd64.iso +``` + +### Параметры + +```text +--arch amd64|arm64 архитектура +--ubuntu 26.04|24.04 база образа (по умолчанию 26.04 LTS) +--ref GIT_REF какую версию Virtuality положить в образ (по умолчанию HEAD) +--web-port PORT порт web-панели (по умолчанию 8088) +--no-auto-update не включать ночное автообновление с GitHub +--locale / --keyboard / --timezone +--no-wheels не класть Python-колёса (тогда первая загрузка скачает их из PyPI) +--repo-url URL откуда нода будет получать обновления +``` + +Через make параметры передаются так: + +```bash +make iso ISO_ARGS="--web-port 8089 --no-auto-update --timezone Europe/Moscow" +``` + +### Полностью автоматическая установка + +Для массовой раскатки можно собрать образ, который не задаёт вопросов: + +```bash +make iso ISO_ARGS="--unattended --username admin --password-hash '$(openssl passwd -6)' --hostname node01" +``` + +> ⚠️ Такой образ загружается в установку сам (через 10 секунд в меню) и **без подтверждения стирает самый большой диск** машины. Файл получает суффикс `-unattended`. Не оставляйте такую флешку в серверах. + +--- + +## Запись на флешку + +Linux / macOS: + +```bash +sudo dd if=dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync +``` + +Windows: Rufus (режим DD) или balenaEtcher. + +Проверка целостности: + +```bash +cd dist && sha256sum -c virtuality-0.10.0-ubuntu-26.04-amd64.iso.sha256 +``` + +--- + +## Сборка в GitHub Actions + +Workflow `.github/workflows/image.yml` собирает ISO: + +- автоматически при пуше тега `v*` (amd64 и arm64); +- вручную: Actions → **Build ISO image** → Run workflow. + +Образы Ubuntu Server больше лимита GitHub Releases (2 GB на файл), поэтому ISO публикуются как **artifacts** workflow и хранятся 30 дней. + +--- + +## Ограничения + +- База образа — Ubuntu Server 26.04 LTS (или 24.04 LTS через `--ubuntu 24.04`). +- Для первой загрузки нужен интернет: пакеты KVM/libvirt/Cockpit ставятся из репозиториев Ubuntu. Python-зависимости панели уже лежат в образе. +- ARM64-образ рассчитан на серверы с UEFI. Raspberry Pi и Orange Pi 5 грузятся иначе — для них используйте их штатный образ Ubuntu Server и `install.sh`. diff --git a/docs/screenshots/create.png b/docs/screenshots/create.png new file mode 100644 index 0000000..cbb36bc Binary files /dev/null and b/docs/screenshots/create.png differ diff --git a/docs/screenshots/dashboard-dark.png b/docs/screenshots/dashboard-dark.png new file mode 100644 index 0000000..5950c53 Binary files /dev/null and b/docs/screenshots/dashboard-dark.png differ diff --git a/docs/screenshots/dashboard-mobile.png b/docs/screenshots/dashboard-mobile.png new file mode 100644 index 0000000..d9a706c Binary files /dev/null and b/docs/screenshots/dashboard-mobile.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..9178074 Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/login.png b/docs/screenshots/login.png new file mode 100644 index 0000000..d79b59e Binary files /dev/null and b/docs/screenshots/login.png differ diff --git a/docs/screenshots/network.png b/docs/screenshots/network.png new file mode 100644 index 0000000..cea7fb7 Binary files /dev/null and b/docs/screenshots/network.png differ diff --git a/docs/screenshots/vm.png b/docs/screenshots/vm.png new file mode 100644 index 0000000..ed26561 Binary files /dev/null and b/docs/screenshots/vm.png differ diff --git a/image/autoinstall.yaml b/image/autoinstall.yaml new file mode 100644 index 0000000..4026739 --- /dev/null +++ b/image/autoinstall.yaml @@ -0,0 +1,31 @@ +#cloud-config +# Virtuality installer configuration for Ubuntu Server 24.04 (subiquity autoinstall). +# build-iso.sh puts this file at /autoinstall.yaml on the ISO. +# @@VARIABLES@@ are substituted at build time. +autoinstall: + version: 1 + # The installer asks only for these screens; everything else is automated. + interactive-sections: +@@INTERACTIVE_SECTIONS@@ + locale: @@LOCALE@@ + keyboard: + layout: @@KEYBOARD@@ + timezone: @@TIMEZONE@@ + source: + id: ubuntu-server + ssh: + install-server: true + allow-pw: true +@@IDENTITY@@ + storage: + layout: + name: lvm + sizing-policy: all + late-commands: + - mkdir -p /target/opt/virtuality /target/etc/virtuality + - cp -a /cdrom/virtuality/source /target/opt/virtuality/source + - chown -R root:root /target/opt/virtuality + - chmod -R u+w /target/opt/virtuality/source + - cp /cdrom/virtuality/image.env /target/etc/virtuality/image.env + - cp /cdrom/virtuality/virtuality-firstboot.service /target/etc/systemd/system/virtuality-firstboot.service + - curtin in-target --target=/target -- systemctl enable virtuality-firstboot.service diff --git a/image/build-iso.sh b/image/build-iso.sh new file mode 100755 index 0000000..62266ce --- /dev/null +++ b/image/build-iso.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ========================================================== +# Virtuality ISO builder +# Remasters the official Ubuntu Server LTS live ISO (26.04 by default) into a +# Virtuality installer: Ubuntu autoinstall + bundled Virtuality +# source and Python wheels + first boot setup service. +# +# Usage: image/build-iso.sh [options] (see --help) +# Needs: xorriso, git, curl, python3 with pip, sha256sum. +# Does not need root. +# ========================================================== + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE_DIR="${REPO_ROOT}/image" + +ARCH="amd64" +UBUNTU_SERIES="26.04" +UBUNTU_ISO="" +CACHE_DIR="${VIRTUALITY_ISO_CACHE:-${REPO_ROOT}/.cache/iso}" +OUTPUT_DIR="${REPO_ROOT}/dist" +GIT_REF="HEAD" +REPO_URL="https://github.com/viktor138irk/virtuality.git" +WEB_PORT="8088" +AUTO_UPDATE="1" +UNATTENDED="0" +USERNAME="" +PASSWORD_HASH="" +HOSTNAME_VALUE="virtuality" +LOCALE="en_US.UTF-8" +KEYBOARD="us" +TIMEZONE="Etc/UTC" +WITH_WHEELS="1" +TARGET_PYTHON="" + +usage() { + cat <&2; exit 1; } +say() { echo "==> $*"; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --arch) ARCH="$2"; shift 2 ;; + --ubuntu) UBUNTU_SERIES="$2"; shift 2 ;; + --ubuntu-iso) UBUNTU_ISO="$2"; shift 2 ;; + --cache-dir) CACHE_DIR="$2"; shift 2 ;; + --output) OUTPUT_DIR="$2"; shift 2 ;; + --ref) GIT_REF="$2"; shift 2 ;; + --repo-url) REPO_URL="$2"; shift 2 ;; + --web-port) WEB_PORT="$2"; shift 2 ;; + --no-auto-update) AUTO_UPDATE="0"; shift ;; + --locale) LOCALE="$2"; shift 2 ;; + --keyboard) KEYBOARD="$2"; shift 2 ;; + --timezone) TIMEZONE="$2"; shift 2 ;; + --no-wheels) WITH_WHEELS="0"; shift ;; + --unattended) UNATTENDED="1"; shift ;; + --username) USERNAME="$2"; shift 2 ;; + --password-hash) PASSWORD_HASH="$2"; shift 2 ;; + --hostname) HOSTNAME_VALUE="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; die "unknown option: $1" ;; + esac +done + +# Python of the target release decides which wheels are bundled. +case "$UBUNTU_SERIES" in + 26.04) TARGET_PYTHON="3.14" ;; + 24.04) TARGET_PYTHON="3.12" ;; + *) die "unsupported --ubuntu ${UBUNTU_SERIES} (26.04 or 24.04)" ;; +esac + +case "$ARCH" in + amd64) PY_PLATFORM_ARCH="x86_64"; MIRROR="https://releases.ubuntu.com/${UBUNTU_SERIES}" ;; + arm64) PY_PLATFORM_ARCH="aarch64"; MIRROR="https://cdimage.ubuntu.com/releases/${UBUNTU_SERIES}/release" ;; + *) die "unsupported arch: $ARCH (amd64 or arm64)" ;; +esac +[[ "$WEB_PORT" =~ ^[0-9]+$ ]] && (( WEB_PORT >= 1 && WEB_PORT <= 65535 )) || die "invalid --web-port: $WEB_PORT" +if [[ "$UNATTENDED" == "1" ]]; then + [[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--unattended needs a valid --username" + [[ "$PASSWORD_HASH" == \$* ]] || die "--unattended needs --password-hash (generate: openssl passwd -6)" + [[ "$HOSTNAME_VALUE" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}$ ]] || die "invalid --hostname" +fi +for cmd in xorriso git curl python3 sha256sum md5sum; do + command -v "$cmd" >/dev/null 2>&1 || die "$cmd not found (Ubuntu: sudo apt install xorriso git curl python3-pip)" +done + +VERSION="$(git -C "$REPO_ROOT" show "${GIT_REF}:VERSION" | tr -d '[:space:]')" +[[ -n "$VERSION" ]] || die "cannot read VERSION at ${GIT_REF}" +SUFFIX="" +[[ "$UNATTENDED" == "1" ]] && SUFFIX="-unattended" +OUTPUT_ISO="${OUTPUT_DIR}/virtuality-${VERSION}-ubuntu-${UBUNTU_SERIES}-${ARCH}${SUFFIX}.iso" + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/virtuality-iso.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT +mkdir -p "$OUTPUT_DIR" "$CACHE_DIR" + +# ---------------------------------------------------------- Ubuntu ISO +if [[ -z "$UBUNTU_ISO" ]]; then + say "Resolving the latest Ubuntu ${UBUNTU_SERIES} live-server ISO for ${ARCH}" + curl -fsSL --retry 3 "${MIRROR}/SHA256SUMS" -o "${WORK_DIR}/SHA256SUMS" + iso_name="$(grep -oE "ubuntu-${UBUNTU_SERIES}(\.[0-9]+)*-live-server-${ARCH}\.iso" "${WORK_DIR}/SHA256SUMS" | sort -uV | tail -n1)" + [[ -n "$iso_name" ]] || die "live-server ISO for ${ARCH} not found in ${MIRROR}/SHA256SUMS" + expected_sha="$(awk -v n="*${iso_name}" '$2 == n || $2 == substr(n, 2) {print $1}' "${WORK_DIR}/SHA256SUMS")" + UBUNTU_ISO="${CACHE_DIR}/${iso_name}" + if [[ -f "$UBUNTU_ISO" ]] && echo "${expected_sha} ${UBUNTU_ISO}" | sha256sum -c --status; then + say "Using cached ${iso_name}" + else + say "Downloading ${iso_name}" + curl -fL --retry 5 -C - -o "${UBUNTU_ISO}.part" "${MIRROR}/${iso_name}" + mv "${UBUNTU_ISO}.part" "$UBUNTU_ISO" + echo "${expected_sha} ${UBUNTU_ISO}" | sha256sum -c --status || die "checksum mismatch for ${iso_name}" + fi + say "Checksum OK: ${expected_sha}" +fi +[[ -f "$UBUNTU_ISO" ]] || die "Ubuntu ISO not found: $UBUNTU_ISO" + +# ---------------------------------------------------------- payload +PAYLOAD="${WORK_DIR}/virtuality" +mkdir -p "$PAYLOAD" +say "Bundling Virtuality ${VERSION} (${GIT_REF})" +git clone --quiet --no-hardlinks "$REPO_ROOT" "${PAYLOAD}/source" +git -C "${PAYLOAD}/source" checkout --quiet --detach "$(git -C "$REPO_ROOT" rev-parse "${GIT_REF}^{commit}")" +git -C "${PAYLOAD}/source" remote set-url origin "$REPO_URL" + +if [[ "$WITH_WHEELS" == "1" ]]; then + say "Downloading Python ${TARGET_PYTHON} wheels for ${PY_PLATFORM_ARCH}" + platform_args=(--platform "manylinux2014_${PY_PLATFORM_ARCH}") + for glibc_minor in 17 28 31 34 35 39; do + platform_args+=(--platform "manylinux_2_${glibc_minor}_${PY_PLATFORM_ARCH}") + done + python3 -m pip download --quiet --disable-pip-version-check \ + -r "${PAYLOAD}/source/web/requirements.txt" -d "${PAYLOAD}/source/wheels" \ + --only-binary=:all: --implementation cp --python-version "$TARGET_PYTHON" \ + --abi "cp${TARGET_PYTHON/./}" --abi abi3 --abi none "${platform_args[@]}" +fi + +cat > "${PAYLOAD}/image.env" <> "${PAYLOAD}/image.env" +cp "${IMAGE_DIR}/files/virtuality-firstboot.service" "${PAYLOAD}/virtuality-firstboot.service" + +# ---------------------------------------------------------- autoinstall +if [[ "$UNATTENDED" == "1" ]]; then + interactive_sections=" []" + identity=" identity: + hostname: ${HOSTNAME_VALUE} + username: ${USERNAME} + realname: Virtuality Admin + password: '${PASSWORD_HASH}'" +else + interactive_sections=" - network + - storage + - identity" + identity="" +fi +python3 - "${IMAGE_DIR}/autoinstall.yaml" "${WORK_DIR}/autoinstall.yaml" </dev/null \ + || python3 -c 'import sys, json; print("PyYAML not available, skipping YAML validation")' + +# ---------------------------------------------------------- boot menu +xorriso -osirrox on -indev "$UBUNTU_ISO" \ + -extract /boot/grub/grub.cfg "${WORK_DIR}/grub.cfg" \ + -extract /md5sum.txt "${WORK_DIR}/md5sum.txt" >/dev/null 2>&1 || die "cannot read grub.cfg from $UBUNTU_ISO" +chmod u+w "${WORK_DIR}/grub.cfg" "${WORK_DIR}/md5sum.txt" +python3 - "${WORK_DIR}/grub.cfg" "$UNATTENDED" "$VERSION" <<'PYEOF' +import re +import sys + +path, unattended, version = sys.argv[1], sys.argv[2] == "1", sys.argv[3] +text = open(path).read() +# /autoinstall.yaml applies to every entry, so all Ubuntu entries become Virtuality entries. +suffix = " (UNATTENDED: erases disk)" if unattended else "" +text = text.replace('menuentry "Try or Install Ubuntu Server"', 'menuentry "Install Virtuality %s%s"' % (version, suffix), 1) +text = text.replace('menuentry "Ubuntu Server with the HWE kernel"', 'menuentry "Install Virtuality %s with the HWE kernel%s"' % (version, suffix), 1) +if "Install Virtuality" not in text: + raise SystemExit("unexpected grub.cfg menu titles") +if unattended: + text = re.sub(r"^(\s*linux\s+\S+\s+)---", r"\1autoinstall ---", text, flags=re.M) +text = re.sub(r"^set timeout=\d+", "set default=0\nset timeout=10", text, count=1, flags=re.M) +open(path, "w").write(text) +PYEOF +new_md5="$(md5sum "${WORK_DIR}/grub.cfg" | cut -d' ' -f1)" +sed -i "s|^[0-9a-f]\{32\} \./boot/grub/grub.cfg$|${new_md5} ./boot/grub/grub.cfg|" "${WORK_DIR}/md5sum.txt" + +# ---------------------------------------------------------- repack +say "Writing ${OUTPUT_ISO}" +rm -f "$OUTPUT_ISO" +chmod -R a+rX,go-w "$PAYLOAD" +if ! xorriso -indev "$UBUNTU_ISO" -outdev "$OUTPUT_ISO" \ + -uid 0 -gid 0 \ + -map "$PAYLOAD" /virtuality \ + -map "${WORK_DIR}/autoinstall.yaml" /autoinstall.yaml \ + -map "${WORK_DIR}/grub.cfg" /boot/grub/grub.cfg \ + -map "${WORK_DIR}/md5sum.txt" /md5sum.txt \ + -boot_image any replay > "${WORK_DIR}/xorriso.log" 2>&1; then + tail -n 30 "${WORK_DIR}/xorriso.log" >&2 + die "xorriso failed" +fi +[[ -s "$OUTPUT_ISO" ]] || die "xorriso did not produce $OUTPUT_ISO" + +(cd "$OUTPUT_DIR" && sha256sum "$(basename "$OUTPUT_ISO")" > "$(basename "$OUTPUT_ISO").sha256") +say "Done: ${OUTPUT_ISO} ($(du -h "$OUTPUT_ISO" | cut -f1))" +say "SHA256: $(cut -d' ' -f1 "${OUTPUT_ISO}.sha256")" diff --git a/image/files/virtuality-firstboot.service b/image/files/virtuality-firstboot.service new file mode 100644 index 0000000..9129c5c --- /dev/null +++ b/image/files/virtuality-firstboot.service @@ -0,0 +1,19 @@ +[Unit] +Description=Virtuality first boot setup +Documentation=https://github.com/viktor138irk/virtuality/blob/main/docs/IMAGE.md +Wants=network-online.target +After=network-online.target +ConditionPathExists=!/var/lib/virtuality/.firstboot-done +StartLimitIntervalSec=0 + +[Service] +# simple, not oneshot: boot must not wait for a multi-minute package install. +Type=simple +ExecStart=/bin/bash /opt/virtuality/source/scripts/virtuality_firstboot.sh +Restart=on-failure +RestartSec=60 +StandardOutput=journal+console +StandardError=journal+console + +[Install] +WantedBy=multi-user.target diff --git a/install.sh b/install.sh index ee392e2..4547bca 100644 --- a/install.sh +++ b/install.sh @@ -21,7 +21,7 @@ if [[ "${EUID}" -ne 0 ]]; then exit 1 fi chmod +x "$tmp_installer" - exec sudo --preserve-env=VIRTUALITY_INSTALL_URL,VIRTUALITY_USER,VIRTUALITY_WEB_PORT,VIRTUALITY_MIN_ROOT_FREE_MB,VIRTUALITY_MIN_VAR_FREE_MB,VIRTUALITY_MIN_RAM_MB,VIRTUALITY_MIN_CPU_CORES,VIRTUALITY_SKIP_REQUIREMENTS,VIRTUALITY_SETUP_BRIDGE,VIRTUALITY_BRIDGE_IFACE,VIRTUALITY_CREATE_TEST_VM,VIRTUALITY_REPO_URL,VIRTUALITY_PROJECT_BASE_DIR,VIRTUALITY_PROJECT_DIR,VIRTUALITY_INSTALL_VERBOSE,VIRTUALITY_CLEAN_BEFORE_INSTALL bash "$tmp_installer" "$@" + exec sudo --preserve-env=VIRTUALITY_INSTALL_URL,VIRTUALITY_USER,VIRTUALITY_WEB_PORT,VIRTUALITY_MIN_ROOT_FREE_MB,VIRTUALITY_MIN_VAR_FREE_MB,VIRTUALITY_MIN_RAM_MB,VIRTUALITY_MIN_CPU_CORES,VIRTUALITY_SKIP_REQUIREMENTS,VIRTUALITY_SETUP_BRIDGE,VIRTUALITY_BRIDGE_IFACE,VIRTUALITY_CREATE_TEST_VM,VIRTUALITY_REPO_URL,VIRTUALITY_PROJECT_BASE_DIR,VIRTUALITY_PROJECT_DIR,VIRTUALITY_INSTALL_VERBOSE,VIRTUALITY_CLEAN_BEFORE_INSTALL,VIRTUALITY_AUTO_UPDATE bash "$tmp_installer" "$@" fi echo "Root or sudo is required. Run: curl -fsSL $SELF_INSTALL_URL | sudo bash" >&2 exit 1 @@ -45,6 +45,7 @@ MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}" SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}" INSTALL_VERBOSE="${VIRTUALITY_INSTALL_VERBOSE:-0}" CLEAN_BEFORE_INSTALL="${VIRTUALITY_CLEAN_BEFORE_INSTALL:-0}" +export DEBIAN_FRONTEND=noninteractive ESC="\033" RESET="${ESC}[0m" @@ -277,7 +278,7 @@ if [[ "$CLEAN_BEFORE_INSTALL" == "1" && -x "$PROJECT_DIR/scripts/clean_install.s fi step "Запускаем основной установщик ноды" -run_logged "Virtuality Node установлен" bash "$PROJECT_DIR/install_virtuality_node.sh" +run_logged "Virtuality Node установлен" env VIRTUALITY_USER="$INSTALL_USER" bash "$PROJECT_DIR/install_virtuality_node.sh" step "Устанавливаем healthcheck-команду" run_logged "Healthcheck установлен" bash "$PROJECT_DIR/scripts/install_healthcheck_command.sh" diff --git a/install_virtuality_node.sh b/install_virtuality_node.sh index 2fb237d..0da8ff7 100644 --- a/install_virtuality_node.sh +++ b/install_virtuality_node.sh @@ -22,6 +22,7 @@ MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}" MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}" MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}" SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}" +export DEBIAN_FRONTEND=noninteractive ESC="\033" RESET="${ESC}[0m" @@ -91,6 +92,22 @@ run_logged() { fi } +# Installs the packages this distribution has; package names differ between +# Ubuntu 24.04/26.04 and Debian 13 (e.g. qemu-kvm became a virtual package). +install_available() { + local description="$1" + shift + local pkg available=() + for pkg in "$@"; do + if apt-cache show "$pkg" >/dev/null 2>&1; then + available+=("$pkg") + else + warn "Пакет ${pkg} недоступен в этом дистрибутиве, пропускаю" + fi + done + run_logged "$description" apt install -y "${available[@]}" +} + service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; } service_enabled() { systemctl is-enabled "$1" 2>/dev/null || echo "disabled"; } free_mb_for_path() { local path="$1"; mkdir -p "$path" 2>/dev/null || true; df -Pm "$path" | awk 'NR==2 {print $4}'; } @@ -152,17 +169,21 @@ step "Обновляем apt cache" run_logged "apt update выполнен" apt update step "Устанавливаем базовые утилиты и зависимости диагностики" -run_logged "Базовые пакеты и диагностические утилиты установлены" apt install -y \ +install_available "Базовые пакеты и диагностические утилиты установлены" \ curl wget git nano htop btop tree ncdu jq unzip xz-utils tar gzip ca-certificates gnupg lsb-release \ - software-properties-common apt-transport-https ufw rsync iproute2 iptables nftables dnsutils net-tools openssh-client + software-properties-common apt-transport-https ufw rsync iproute2 iptables nftables bind9-dnsutils net-tools openssh-client step "Устанавливаем KVM/QEMU/libvirt" -run_logged "Пакеты виртуализации установлены" apt install -y \ - qemu-kvm qemu-utils libvirt-daemon-system libvirt-clients virtinst \ - bridge-utils dnsmasq-base ovmf swtpm cloud-image-utils +case "$(uname -m)" in + aarch64|arm64) QEMU_SYSTEM_PACKAGES=(qemu-system-arm qemu-efi-aarch64) ;; + *) QEMU_SYSTEM_PACKAGES=(qemu-system-x86 ovmf) ;; +esac +install_available "Пакеты виртуализации установлены" \ + "${QEMU_SYSTEM_PACKAGES[@]}" qemu-utils libvirt-daemon-system libvirt-clients virtinst \ + bridge-utils dnsmasq-base swtpm cloud-image-utils step "Устанавливаем Cockpit и модули" -run_logged "Cockpit установлен" apt install -y \ +install_available "Cockpit установлен" \ cockpit cockpit-machines cockpit-networkmanager cockpit-storaged cockpit-packagekit step "Создаём структуру Virtuality" @@ -174,14 +195,14 @@ ok "Images: ${IMAGES_DIR}" ok "Backups: ${BACKUP_DIR}" step "Добавляем пользователя в группы libvirt/kvm" -REAL_USER="${SUDO_USER:-root}" +REAL_USER="${VIRTUALITY_USER:-${SUDO_USER:-root}}" if [[ "$REAL_USER" != "root" ]]; then usermod -aG libvirt "$REAL_USER" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить ${REAL_USER} в libvirt" usermod -aG kvm "$REAL_USER" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить ${REAL_USER} в kvm" ok "Пользователь ${REAL_USER} добавлен в группы libvirt/kvm" warn "Чтобы группы применились, нужно выйти из SSH и зайти снова" else - warn "Запуск под root без SUDO_USER; пользователь не добавлен в группы" + warn "Запуск под root без SUDO_USER/VIRTUALITY_USER; пользователь не добавлен в группы" fi step "Включаем systemd-сервисы" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..0e02926 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -p no:cacheprovider diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..d0eb39e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +# Development and CI tools +pytest==8.3.4 +httpx==0.28.1 +pyflakes==3.2.0 diff --git a/scripts/auto_update_check.sh b/scripts/auto_update_check.sh index 03ec864..c01c282 100644 --- a/scripts/auto_update_check.sh +++ b/scripts/auto_update_check.sh @@ -96,6 +96,11 @@ diag_network() { write_state "idle" "Автообновление: новых обновлений нет" exit 0 fi + if [ -n "$latest" ] && [ "$current" != "unknown" ] && ! git merge-base --is-ancestor "$current" "$latest" 2>/dev/null; then + log "local build ${current:0:12} is not behind ${REMOTE}/${BRANCH} (${latest:0:12}); skip to avoid a downgrade" + write_state "idle" "Автообновление: установленная сборка новее или отличается от ${BRANCH}, обновление пропущено" + exit 0 + fi log "update may be available or current commit unknown: ${current:0:12} -> ${latest:0:12}" else log "git fetch failed; diagnostics and apply ZIP fallback will be used" diff --git a/scripts/install_console_dashboard.sh b/scripts/install_console_dashboard.sh index 53e99f4..8de090e 100644 --- a/scripts/install_console_dashboard.sh +++ b/scripts/install_console_dashboard.sh @@ -5,7 +5,8 @@ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DASH_SRC="${REPO_DIR}/scripts/virtuality_console_dashboard.sh" DASH_DST="/usr/local/bin/virtuality-console-dashboard" SERVICE_FILE="/etc/systemd/system/virtuality-console-dashboard.service" -TARGET_USER="${SUDO_USER:-${USER:-root}}" +export DEBIAN_FRONTEND=noninteractive +TARGET_USER="${VIRTUALITY_USER:-${SUDO_USER:-${USER:-root}}}" if [[ "$EUID" -ne 0 ]]; then echo "Ошибка: запусти через sudo: sudo bash scripts/install_console_dashboard.sh" @@ -49,7 +50,9 @@ EOF systemctl daemon-reload systemctl disable --now getty@tty1.service >/dev/null 2>&1 || true -systemctl enable --now virtuality-console-dashboard.service +systemctl enable virtuality-console-dashboard.service +# --no-block: the unit is ordered after multi-user.target, which may still be starting (first boot). +systemctl start --no-block virtuality-console-dashboard.service USER_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6 || true)" if [[ -n "$USER_HOME" && -d "$USER_HOME" ]]; then diff --git a/scripts/install_disk_images_support.sh b/scripts/install_disk_images_support.sh deleted file mode 100644 index 96e9f1a..0000000 --- a/scripts/install_disk_images_support.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -APP_DIR="${VIRTUALITY_APP_DIR:-/opt/virtuality/web}" -REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -DISK_IMAGES_DIR="/var/lib/virtuality/disk-images" - -mkdir -p "$DISK_IMAGES_DIR" -chmod 755 "$DISK_IMAGES_DIR" - -if [[ -f "${REPO_DIR}/scripts/patch_disk_images.py" ]]; then - python3 "${REPO_DIR}/scripts/patch_disk_images.py" "${APP_DIR}/app.py" -else - echo "patch_disk_images.py not found" >&2 - exit 1 -fi - -systemctl restart virtuality-web.service - -echo "Disk image support installed. Open: /disk-images" diff --git a/scripts/install_healthcheck_command.sh b/scripts/install_healthcheck_command.sh index f23bded..8aab78e 100644 --- a/scripts/install_healthcheck_command.sh +++ b/scripts/install_healthcheck_command.sh @@ -4,7 +4,7 @@ set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SRC="${REPO_DIR}/scripts/virtuality_healthcheck.sh" DST="/usr/local/bin/vhealth" -TARGET_USER="${SUDO_USER:-${USER:-root}}" +TARGET_USER="${VIRTUALITY_USER:-${SUDO_USER:-${USER:-root}}}" if [[ "$EUID" -ne 0 ]]; then echo "Ошибка: запусти через sudo: sudo bash scripts/install_healthcheck_command.sh" diff --git a/scripts/install_web_panel.sh b/scripts/install_web_panel.sh index 4675ab8..d4ea679 100644 --- a/scripts/install_web_panel.sh +++ b/scripts/install_web_panel.sh @@ -11,15 +11,52 @@ VENV_DIR="/opt/virtuality/venv" SERVICE_FILE="/etc/systemd/system/virtuality-web.service" AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service" AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer" -PORT="${VIRTUALITY_WEB_PORT:-8088}" -AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}" +NETWORK_SERVICE_FILE="/etc/systemd/system/virtuality-network.service" LOG_DIR="/var/log/virtuality" LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log" PROFILE_DIR="/var/lib/virtuality/config" PROFILE_FILE="${PROFILE_DIR}/host_profile.json" SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret" +NODE_CONFIG_FILE="${PROFILE_DIR}/web.env" UPLOAD_TMP_DIR="/var/lib/virtuality/tmp" +WHEELS_DIR="${VIRTUALITY_WHEELS_DIR:-${REPO_DIR}/wheels}" TOTAL_STEPS=13 +export DEBIAN_FRONTEND=noninteractive + +# Settings survive updates: explicit env > saved node config > previous install > defaults. +saved_setting() { + local key="$1" + [[ -f "$NODE_CONFIG_FILE" ]] || return 0 + sed -n "s/^${key}=//p" "$NODE_CONFIG_FILE" | tail -n1 | tr -d "\"'" +} +legacy_auth_user() { + if [[ -f "${APP_DIR}/.env" ]]; then + sed -n 's/^VIRTUALITY_AUTH_USER=//p' "${APP_DIR}/.env" | tail -n1 + fi +} +legacy_port() { + if [[ -f "$SERVICE_FILE" ]]; then + sed -n 's/.*--port \([0-9][0-9]*\).*/\1/p' "$SERVICE_FILE" | tail -n1 + fi +} +first_human_user() { + getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $7 !~ /(nologin|false)$/ {print $1; exit}' +} + +PORT="${VIRTUALITY_WEB_PORT:-$(saved_setting VIRTUALITY_WEB_PORT)}" +PORT="${PORT:-$(legacy_port)}" +PORT="${PORT:-8088}" +AUTH_USER="${VIRTUALITY_AUTH_USER:-$(saved_setting VIRTUALITY_AUTH_USER)}" +AUTH_USER="${AUTH_USER:-$(legacy_auth_user)}" +AUTH_USER="${AUTH_USER:-${SUDO_USER:-}}" +AUTH_USER="${AUTH_USER:-$(first_human_user)}" +AUTH_USER="${AUTH_USER:-root}" +AUTO_UPDATE="${VIRTUALITY_AUTO_UPDATE:-$(saved_setting VIRTUALITY_AUTO_UPDATE)}" +AUTO_UPDATE="${AUTO_UPDATE:-1}" +WEB_HOST="${VIRTUALITY_WEB_HOST:-$(saved_setting VIRTUALITY_WEB_HOST)}" +WEB_HOST="${WEB_HOST:-0.0.0.0}" +COOKIE_SECURE="${VIRTUALITY_COOKIE_SECURE:-$(saved_setting VIRTUALITY_COOKIE_SECURE)}" +COOKIE_SECURE="${COOKIE_SECURE:-0}" CURRENT_STEP=0 ESC="\033" @@ -75,16 +112,6 @@ run_logged() { fi } -restore_canonical_templates() { - local templates=("_sidebar.html" "dashboard.html" "vm_create.html" "vm_detail.html" "iso.html" "disk_images.html" "operations.html" "operation_detail.html" "host.html" "network.html" "logs.html" "update.html") - local name="" - for name in "${templates[@]}"; do - if [[ -f "${WEB_DIR}/templates/${name}" ]]; then - cp "${WEB_DIR}/templates/${name}" "${APP_DIR}/templates/${name}" - fi - done -} - service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; } require_root() { [[ "$EUID" -eq 0 ]] || fail "Запусти через sudo: sudo bash scripts/install_web_panel.sh"; } json_value() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$PROFILE_FILE" "$1" 2>/dev/null || true; } @@ -166,114 +193,8 @@ fi step "Копируем web-панель в /opt/virtuality" run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality -run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete "$WEB_DIR/" "$APP_DIR/" -if [[ -f "${REPO_DIR}/scripts/patch_web_console.py" ]]; then - run_logged "noVNC web-console patch применён" python3 "${REPO_DIR}/scripts/patch_web_console.py" "${APP_DIR}/app.py" -else - warn "patch_web_console.py не найден, noVNC console patch пропущен" -fi -if [[ -f "${REPO_DIR}/scripts/patch_upload_compat.py" ]]; then - run_logged "upload compatibility patch применён" python3 "${REPO_DIR}/scripts/patch_upload_compat.py" "${APP_DIR}/app.py" -else - warn "patch_upload_compat.py не найден, совместимость загрузки файлов пропущена" -fi -if [[ -f "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" ]]; then - run_logged "upload navigation guard patch применён" python3 "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" "${APP_DIR}/app.py" -else - warn "patch_upload_navigation_guard.py не найден, защита загрузок от переходов пропущена" -fi -if [[ -f "${REPO_DIR}/scripts/patch_disk_images.py" ]]; then - run_logged "disk images patch применён" python3 "${REPO_DIR}/scripts/patch_disk_images.py" "${APP_DIR}/app.py" -else - warn "patch_disk_images.py не найден, менеджер дисковых образов пропущен" -fi -if [[ -f "${REPO_DIR}/scripts/patch_vm_boot_order.py" ]]; then - run_logged "VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_vm_boot_order.py" "${APP_DIR}/app.py" -else - warn "patch_vm_boot_order.py не найден, порядок загрузки VM пропущен" -fi -if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" ]]; then - run_logged "existing VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" "${APP_DIR}/app.py" -else - warn "patch_existing_vm_boot_order.py не найден, порядок загрузки существующих VM пропущен" -fi -if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_resources.py" ]]; then - run_logged "existing VM resources patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_resources.py" "${APP_DIR}/app.py" -else - warn "patch_existing_vm_resources.py не найден, ресурсы существующих VM пропущены" -fi -if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" ]]; then - run_logged "existing VM ISO mount patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" "${APP_DIR}/app.py" -else - warn "patch_existing_vm_iso_mount.py не найден, монтирование ISO в VM пропущено" -fi -if [[ -f "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" ]]; then - run_logged "VM detail resource layout patch применён" python3 "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" "${APP_DIR}/app.py" -else - warn "patch_vm_detail_resource_layout.py не найден, раскладка ресурсов VM пропущена" -fi -if [[ -f "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" ]]; then - run_logged "legacy boot order cleanup patch применён" python3 "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" "${APP_DIR}/app.py" -else - warn "patch_remove_legacy_boot_order_card.py не найден, удаление старого блока порядка загрузки пропущено" -fi -run_logged "Канонические шаблоны панели восстановлены после VM-патчей" restore_canonical_templates -if [[ -f "${REPO_DIR}/scripts/patch_disk_archives.py" ]]; then - run_logged "disk archive import patch применён" python3 "${REPO_DIR}/scripts/patch_disk_archives.py" "${APP_DIR}/app.py" -else - warn "patch_disk_archives.py не найден, импорт архивов дисков пропущен" -fi -if [[ -f "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" ]]; then - run_logged "DHCP leases empty-state patch применён" python3 "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" "${APP_DIR}/app.py" -else - warn "patch_dhcp_leases_empty.py не найден, диагностика DHCP leases пропущена" -fi -if [[ -f "${REPO_DIR}/scripts/patch_network_diagnostics.py" ]]; then - run_logged "network diagnostics patch применён" python3 "${REPO_DIR}/scripts/patch_network_diagnostics.py" "${APP_DIR}/app.py" -else - warn "patch_network_diagnostics.py не найден, диагностика сети пропущена" -fi -if [[ -f "${REPO_DIR}/scripts/patch_network_ranges.py" ]]; then - run_logged "network port ranges patch применён" python3 "${REPO_DIR}/scripts/patch_network_ranges.py" "${APP_DIR}/app.py" -else - warn "patch_network_ranges.py не найден, поддержка диапазонов портов пропущена" -fi -if [[ -f "${REPO_DIR}/scripts/patch_network_bridge_forwards.py" ]]; then - run_logged "network bridge forward patch применён" python3 "${REPO_DIR}/scripts/patch_network_bridge_forwards.py" "${APP_DIR}/app.py" -else - warn "patch_network_bridge_forwards.py не найден, проброс bridge/static VM пропущен" -fi -if [[ -f "${REPO_DIR}/scripts/patch_network_nat_errors.py" ]]; then - run_logged "network NAT error patch применён" python3 "${REPO_DIR}/scripts/patch_network_nat_errors.py" "${APP_DIR}/app.py" -else - warn "patch_network_nat_errors.py не найден, безопасные ошибки NAT пропущены" -fi -if [[ -f "${REPO_DIR}/scripts/patch_logs_center.py" ]]; then - run_logged "logs center patch применён" python3 "${REPO_DIR}/scripts/patch_logs_center.py" "${APP_DIR}/app.py" -else - warn "patch_logs_center.py не найден, центр журналов пропущен" -fi -if [[ -f "${REPO_DIR}/scripts/patch_vm_network_guard.py" ]]; then - run_logged "VM network guard patch применён" python3 "${REPO_DIR}/scripts/patch_vm_network_guard.py" "${APP_DIR}/app.py" -else - warn "patch_vm_network_guard.py не найден, защита от отсутствующего bridge пропущена" -fi -if [[ -f "${REPO_DIR}/scripts/patch_update_center.py" ]]; then - run_logged "update center patch применён" python3 "${REPO_DIR}/scripts/patch_update_center.py" "${APP_DIR}/app.py" -else - warn "patch_update_center.py не найден, центр обновлений пропущен" -fi -if [[ -f "${REPO_DIR}/scripts/patch_live_status.py" ]]; then - run_logged "live status patch применён" python3 "${REPO_DIR}/scripts/patch_live_status.py" "${APP_DIR}/app.py" -else - warn "patch_live_status.py не найден, live-статусы пропущены" -fi -if [[ -f "${REPO_DIR}/scripts/patch_vm_autostart.py" ]]; then - run_logged "VM autostart patch применён" python3 "${REPO_DIR}/scripts/patch_vm_autostart.py" "${APP_DIR}/app.py" -else - warn "patch_vm_autostart.py не найден, нормализация автозапуска VM пропущена" -fi -run_logged "Канонические шаблоны панели финально восстановлены" restore_canonical_templates +run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete --exclude='__pycache__/' --exclude='.env' "$WEB_DIR/" "$APP_DIR/" +run_logged "Версия панели записана" install -m 0644 "${REPO_DIR}/VERSION" "${APP_DIR}/VERSION" run_logged "Конфиг профиля доступен web-панели" mkdir -p "$PROFILE_DIR" if [[ -f "$PROFILE_FILE" ]]; then ok "Профиль уже сохранён: $PROFILE_FILE" @@ -291,9 +212,20 @@ else chmod 600 "$SESSION_SECRET_FILE" ok "Создан постоянный session secret: $SESSION_SECRET_FILE" fi +cat > "$NODE_CONFIG_FILE" < "${APP_DIR}/.env" </dev/null 2>&1; then warn "Virtualenv уже существует, будет переиспользован: $VENV_DIR" else + if [[ -d "$VENV_DIR" ]]; then + warn "Virtualenv повреждён (например, после обновления Python), пересоздаём" + rm -rf -- "${VENV_DIR:?}" + fi run_logged "Virtualenv создан: $VENV_DIR" python3 -m venv "$VENV_DIR" fi step "Устанавливаем Python-зависимости" -run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip -run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt" +if compgen -G "${WHEELS_DIR}/*.whl" >/dev/null; then + run_logged "Python-зависимости установлены офлайн из ${WHEELS_DIR}" "$VENV_DIR/bin/pip" install --no-index --find-links "$WHEELS_DIR" -r "$APP_DIR/requirements.txt" +else + run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip + run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt" +fi +run_logged "Web-панель импортируется без ошибок" bash -c "cd '$APP_DIR' && '$VENV_DIR/bin/python' -c 'import app'" step "Создаём systemd service" cat > "$SERVICE_FILE" < "$NETWORK_SERVICE_FILE" <> "$LOG_FILE" 2>&1 || true + ok "Автообновление отключено (VIRTUALITY_AUTO_UPDATE=0). Обновления — вручную через /update" + fi else warn "auto_update_check.sh не найден, автообновление пропущено" fi @@ -415,7 +380,8 @@ echo -e "${BOLD}Login:${RESET} ${AUTH_USER} / пароль Linux-польз echo -e "${BOLD}Profile:${RESET} ${LABEL:-$PROFILE}" echo -e "${BOLD}Arch:${RESET} ${ARCH:-unknown}" echo -e "${BOLD}Service:${RESET} virtuality-web.service" -echo -e "${BOLD}Auto update:${RESET} virtuality-auto-update.timer / ежедневно в 00:00 по Москве" +echo -e "${BOLD}Auto update:${RESET} $([[ "$AUTO_UPDATE" == "1" ]] && echo "ежедневно в 00:00 по Москве" || echo "отключено")" +echo -e "${BOLD}Settings:${RESET} ${NODE_CONFIG_FILE}" echo -e "${BOLD}Upload tmp:${RESET} ${UPLOAD_TMP_DIR}" echo -e "${BOLD}Session key:${RESET} ${SESSION_SECRET_FILE}" echo -e "${BOLD}Status:${RESET} systemctl status virtuality-web --no-pager" diff --git a/scripts/patch_arm64_emulation_xml.py b/scripts/patch_arm64_emulation_xml.py deleted file mode 100644 index 5fbb381..0000000 --- a/scripts/patch_arm64_emulation_xml.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -if not app_path.exists(): - raise SystemExit(f'app.py not found: {app_path}') - -text = app_path.read_text() -changed = [] - -helpers = r''' - -def is_x86_host(profile: dict[str, Any]) -> bool: - return str(profile.get("arch", "")) in ("x86_64", "amd64") - - -def is_arm64_guest_on_x86(guest_arch: str, profile: dict[str, Any]) -> bool: - return guest_arch == "aarch64" and is_x86_host(profile) - - -def available_memory_mib() -> int: - try: - for line in Path('/proc/meminfo').read_text().splitlines(): - if line.startswith('MemAvailable:'): - return int(line.split()[1]) // 1024 - except Exception: - return 0 - return 0 - - -def arm64_emulation_memory_error(memory: int) -> str | None: - available = available_memory_mib() - requested = int(memory) - reserve = 768 - if available <= 0: - return None - if requested + reserve <= available: - return None - recommended = max(512, available - reserve) - return ( - f"Недостаточно RAM для ARM64-эмуляции: запрошено {requested} MB, " - f"доступно около {available} MB, нужен запас минимум {reserve} MB. " - f"Уменьши RAM VM примерно до {recommended} MB или освободи память на хосте." - ) - - -def xml_escape(value: str) -> str: - return str(value).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) - - -def arm64_emulation_xml(name: str, memory: int, vcpus: int, disk_path: Path, network_mode: str, bridge: str) -> str: - if network_mode == "nat": - interface_xml = f""" - - - - """ - else: - interface_xml = f""" - - - - """ - return f""" - {xml_escape(name)} - {int(memory)} - {int(memory)} - {int(vcpus)} - - hvm - - - cortex-a57 - - - - - - destroy - restart - restart - - /usr/bin/qemu-system-aarch64 - - - - - {interface_xml} - - - - - - - - - -""" - - -def make_arm64_emulation_script(name: str, memory: int, vcpus: int, disk_path: Path, network_mode: str, bridge: str) -> str: - xml_path = Path('/tmp') / f"virtuality-{name}-arm64.xml" - xml_path.write_text(arm64_emulation_xml(name, memory, vcpus, disk_path, network_mode, bridge)) - return f"virsh define {xml_path} && virsh start {name}" -''' - -if 'def arm64_emulation_xml(' not in text: - marker = '\n\ndef valid_vm_name(name: str) -> bool:' - if marker not in text: - raise SystemExit('valid_vm_name marker not found') - text = text.replace(marker, helpers + marker, 1) - changed.append('ARM64 emulation XML helpers added') -else: - if 'def arm64_emulation_memory_error(' not in text: - marker = '\n\ndef xml_escape(value: str) -> str:' - if marker not in text: - raise SystemExit('xml_escape marker not found') - memory_helpers = r''' - -def available_memory_mib() -> int: - try: - for line in Path('/proc/meminfo').read_text().splitlines(): - if line.startswith('MemAvailable:'): - return int(line.split()[1]) // 1024 - except Exception: - return 0 - return 0 - - -def arm64_emulation_memory_error(memory: int) -> str | None: - available = available_memory_mib() - requested = int(memory) - reserve = 768 - if available <= 0: - return None - if requested + reserve <= available: - return None - recommended = max(512, available - reserve) - return ( - f"Недостаточно RAM для ARM64-эмуляции: запрошено {requested} MB, " - f"доступно около {available} MB, нужен запас минимум {reserve} MB. " - f"Уменьши RAM VM примерно до {recommended} MB или освободи память на хосте." - ) -''' - text = text.replace(marker, memory_helpers + marker, 1) - changed.append('ARM64 memory guard helpers added') - else: - changed.append('ARM64 memory guard helpers already present') - changed.append('ARM64 emulation XML helpers already present') - -old_disk_cmd = ''' if source_type == "disk_image": - source_disk = Path(disk_image_path).resolve() - source_format = disk_image_format(source_disk) - convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}" - virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]) - cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"] - else: - cmd += ["--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"] -''' -new_disk_cmd = ''' if source_type == "disk_image": - source_disk = Path(disk_image_path).resolve() - source_format = disk_image_format(source_disk) - convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}" - if is_arm64_guest_on_x86(resolved_guest_arch, profile): - memory_error = arm64_emulation_memory_error(memory) - if memory_error: - return vm_form_context(request, error=memory_error, form=form, status_code=400) - create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge) - cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"] - else: - virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]) - cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"] - else: - if is_arm64_guest_on_x86(resolved_guest_arch, profile): - return vm_form_context(request, error="ARM64 ISO на x86-хосте пока не поддерживается. Загрузи готовый ARM64 .img/.qcow2 в разделе «Диски» и создай VM из готового диска.", form=form, status_code=400) - cmd += ["--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"] -''' -old_arm64_xml_cmd = ''' if is_arm64_guest_on_x86(resolved_guest_arch, profile): - create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge) - cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"] - else: -''' -new_arm64_xml_cmd = ''' if is_arm64_guest_on_x86(resolved_guest_arch, profile): - memory_error = arm64_emulation_memory_error(memory) - if memory_error: - return vm_form_context(request, error=memory_error, form=form, status_code=400) - create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge) - cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"] - else: -''' -if old_disk_cmd in text: - text = text.replace(old_disk_cmd, new_disk_cmd, 1) - changed.append('ARM64 disk image on x86 uses direct libvirt XML with memory guard') -elif old_arm64_xml_cmd in text: - text = text.replace(old_arm64_xml_cmd, new_arm64_xml_cmd, 1) - changed.append('ARM64 memory guard added to existing XML path') -elif 'memory_error = arm64_emulation_memory_error(memory)' in text: - changed.append('ARM64 memory guard already present') -elif 'make_arm64_emulation_script' in text: - changed.append('ARM64 disk image XML path already present') -else: - raise SystemExit('disk image create command marker not found') - -app_path.write_text(text) -print('arm64 emulation xml patch applied:') -for item in changed: - print(f'- {item}') diff --git a/scripts/patch_dhcp_leases_empty.py b/scripts/patch_dhcp_leases_empty.py deleted file mode 100644 index 6f97318..0000000 --- a/scripts/patch_dhcp_leases_empty.py +++ /dev/null @@ -1,271 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import re -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -changed: list[str] = [] -warnings: list[str] = [] - -if not app_path.exists(): - print(f'WARN: app.py not found: {app_path}') - raise SystemExit(0) - -templates_dir = app_path.parent / 'templates' -dashboard_template_path = templates_dir / 'dashboard.html' -network_template_path = templates_dir / 'network.html' -if not dashboard_template_path.exists(): - print(f'WARN: dashboard.html not found: {dashboard_template_path}') - raise SystemExit(0) - -text = app_path.read_text(encoding='utf-8') - -text, removed_helpers = re.subn( - r"\n\ndef parse_dhcp_leases_output\(output: str\).*?\n\ndef parse_virsh_list\(\) -> list\[dict\[str, str\]\]:", - "\n\ndef parse_virsh_list() -> list[dict[str, str]]:", - text, - count=1, - flags=re.S, -) -if removed_helpers: - changed.append('old VM IP helper block was removed') - -new_parse_virsh_list = '''def parse_virsh_list() -> list[dict[str, str]]: - def clean_ip(ip: str) -> str: - ip = (ip or "").split("/")[0].strip() - if not ip or ip.startswith("127.") or ip.startswith("169.254.") or ip == "0.0.0.0": - return "" - return ip - - def manual_ip_map() -> dict[str, str]: - path = Path("/var/lib/virtuality/network/vm_ips.json") - if not path.exists(): - return {} - try: - data = json.loads(path.read_text(encoding="utf-8")) - if isinstance(data, dict): - return {str(k): clean_ip(str(v)) for k, v in data.items() if clean_ip(str(v))} - except Exception: - pass - return {} - - def vm_macs(name: str) -> list[str]: - try: - result = run_cmd(["virsh", "domiflist", name], timeout=8) - if not result.get("ok"): - return [] - return [mac.lower() for mac in re.findall(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", result.get("stdout", ""))] - except Exception: - return [] - - def ip_from_domifaddr(name: str) -> str: - try: - result = run_cmd(["virsh", "domifaddr", name], timeout=8) - if result.get("ok"): - for ip in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})/\\d+", result.get("stdout", "")): - ip = clean_ip(ip) - if ip: - return ip - except Exception: - pass - return "" - - def ip_from_network_core(name: str) -> str: - try: - resolved = network_core.resolve_vm_ip(name) - if resolved: - return clean_ip(str(resolved)) - except Exception: - pass - return "" - - def ip_from_dnsmasq_leases(macs: list[str]) -> str: - if not macs: - return "" - try: - for lease_file in Path("/var/lib/libvirt/dnsmasq").glob("*.leases"): - for line in lease_file.read_text(errors="ignore").splitlines(): - low = line.lower() - if not any(mac in low for mac in macs): - continue - parts = line.split() - if len(parts) >= 3: - ip = clean_ip(parts[2]) - if ip: - return ip - except Exception: - pass - return "" - - def ip_from_neighbor_tables(macs: list[str]) -> str: - if not macs: - return "" - commands = [["ip", "neigh", "show"], ["ip", "neigh", "show", "dev", "virbr100"], ["ip", "neigh", "show", "dev", "br0"], ["arp", "-an"]] - for cmd in commands: - try: - result = run_cmd(cmd, timeout=8) - if not result.get("ok"): - continue - for line in result.get("stdout", "").splitlines(): - low = line.lower() - if not any(mac in low for mac in macs): - continue - for value in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})\\b", line): - ip = clean_ip(value) - if ip: - return ip - except Exception: - pass - return "" - - manual_ips = manual_ip_map() - - def resolve_ip(name: str) -> str: - if not name: - return "—" - if manual_ips.get(name): - return manual_ips[name] - macs = vm_macs(name) - for resolver in (lambda: ip_from_domifaddr(name), lambda: ip_from_network_core(name), lambda: ip_from_dnsmasq_leases(macs), lambda: ip_from_neighbor_tables(macs)): - try: - ip = resolver() - if ip: - return ip - except Exception: - pass - return "—" - - 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]}) - for row in rows: - row["ip"] = resolve_ip(row.get("name", "")) - row["manual_ip"] = manual_ips.get(row.get("name", ""), "") - return rows -''' - -pattern = r"def parse_virsh_list\(\) -> list\[dict\[str, str\]\]:.*?\n\ndef parse_pool_list\(\) -> list\[dict\[str, str\]\]:" -replacement = new_parse_virsh_list + "\n\ndef parse_pool_list() -> list[dict[str, str]]:" -text, replaced = re.subn(pattern, lambda _match: replacement, text, count=1, flags=re.S) -if replaced: - changed.append('parse_virsh_list was replaced with manual-first VM IP resolver') -else: - warnings.append('parse_virsh_list block not found, app.py IP injection skipped') - -manual_route = ''' - -@app.post("/network/vm-ip/save") -def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: str = Form("")): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - if not valid_vm_name(vm_name): - return RedirectResponse(url="/network", status_code=303) - manual_ip = (manual_ip or "").strip() - if manual_ip and not re.fullmatch(r"(25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}", manual_ip): - return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": "Некорректный ручной IP VM"}, status_code=400) - path = Path("/var/lib/virtuality/network/vm_ips.json") - path.parent.mkdir(parents=True, exist_ok=True) - try: - data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} - if not isinstance(data, dict): - data = {} - except Exception: - data = {} - if manual_ip: - data[vm_name] = manual_ip - else: - data.pop(vm_name, None) - tmp = path.with_suffix(".json.tmp") - tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - tmp.replace(path) - return RedirectResponse(url="/network", status_code=303) -''' - -if '/network/vm-ip/save' not in text: - marker = '\n\n@app.post("/network/nat/setup")' - if marker in text: - text = text.replace(marker, manual_route + marker, 1) - changed.append('manual VM IP route was added') - else: - warnings.append('network NAT route marker not found, manual IP route skipped') -else: - changed.append('manual VM IP route was already present') - -app_path.write_text(text, encoding='utf-8') - -dashboard_html = dashboard_template_path.read_text(encoding='utf-8') -header_old = 'IDNameStateActions' -header_new = 'IDNameIPStateActions' -name_cell = '''{{ vm.name }} - {{ vm.name }} - {{ vm.ip|default("—") }} - IP' not in dashboard_html: - dashboard_html = dashboard_html.replace(header_old, header_new).replace(name_cell, name_ip_cell).replace('colspan="4" class="muted">Виртуальных машин пока нет', 'colspan="5" class="muted">Виртуальных машин пока нет') - changed.append('Dashboard VM IP column was added') -elif 'vm.ip' not in dashboard_html: - dashboard_html = dashboard_html.replace(name_cell, name_ip_cell) - changed.append('Dashboard VM IP cell was added') -else: - changed.append('Dashboard VM IP column was already present') -dashboard_template_path.write_text(dashboard_html, encoding='utf-8') - -if network_template_path.exists(): - network_html = network_template_path.read_text(encoding='utf-8') - manual_card = ''' -
-
-

Ручные IP VM

- override -
-
Если VM в bridge/static-сети и IP не виден через DHCP/ARP, укажи адрес вручную. Этот IP будет первым источником для таблицы и проброса портов.
- - - - {% for vm in vms %} - - - - - - - {% else %} - - {% endfor %} - -
VMТекущий IPРучной IPДействие
{{ vm.name }}{{ vm.ip|default("—") }} -
- - -
VM пока нет
-
-''' - if 'Ручные IP VM' not in network_html: - marker = '
\n
\n

Диагностика публичного доступа

' - if marker in network_html: - network_html = network_html.replace(marker, manual_card + '\n' + marker, 1) - changed.append('manual VM IP card was added to network page') - else: - warnings.append('network diagnostics card marker not found, manual IP card skipped') - else: - changed.append('manual VM IP card was already present') - network_template_path.write_text(network_html, encoding='utf-8') -else: - warnings.append('network.html not found, manual IP card skipped') - -print('DHCP leases empty-state patch completed:') -for item in changed: - print(f'- {item}') -for item in warnings: - print(f'WARN: {item}') - -raise SystemExit(0) diff --git a/scripts/patch_disk_archives.py b/scripts/patch_disk_archives.py deleted file mode 100644 index 93938a7..0000000 --- a/scripts/patch_disk_archives.py +++ /dev/null @@ -1,316 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -if not app_path.exists(): - raise SystemExit(f'app.py not found: {app_path}') - -text = app_path.read_text() -changed = [] - -if 'import zipfile' not in text: - text = text.replace('import uuid\n', 'import uuid\nimport lzma\nimport tarfile\nimport zipfile\n', 1) - changed.append('archive imports added') -elif 'import lzma' not in text: - text = text.replace('import tarfile\n', 'import lzma\nimport tarfile\n', 1) if 'import tarfile\n' in text else text.replace('import zipfile\n', 'import lzma\nimport zipfile\n', 1) - changed.append('lzma import added') -else: - changed.append('archive imports already present') - -helpers = r''' - -def safe_disk_upload_filename(filename: str) -> str | None: - original = Path(filename or "").name.strip() - lower = original.lower() - if lower.endswith(".tar.gz"): - suffix = ".tar.gz" - stem = original[:-7] - elif lower.endswith(".img.xz"): - suffix = ".img.xz" - stem = original[:-7] - elif lower.endswith(".tgz"): - suffix = ".tgz" - stem = original[:-4] - else: - suffix = Path(original).suffix.lower() - stem = Path(original).stem - if suffix not in (".img", ".raw", ".qcow2", ".img.xz", ".zip", ".tar.gz", ".tgz"): - return None - stem = re.sub(r"\s+", "-", stem.strip()) - stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem) - stem = stem.strip("._-")[:120] - if not stem: - stem = "virtuality-disk" - return f"{stem}{suffix}" - - -def disk_upload_is_archive(name: str) -> bool: - lower = str(name or "").lower() - return lower.endswith((".zip", ".tar.gz", ".tgz")) - - -def disk_upload_is_xz_image(name: str) -> bool: - return str(name or "").lower().endswith(".img.xz") - - -def disk_upload_is_image(name: str) -> bool: - lower = str(name or "").lower() - return lower.endswith((".img", ".raw", ".qcow2", ".img.xz")) - - -def archive_member_is_safe(name: str) -> bool: - if not name: - return False - p = Path(name) - if p.is_absolute(): - return False - return ".." not in p.parts - - -def archive_member_basename(name: str) -> str | None: - return safe_disk_image_filename(Path(name).name) - - -def unique_disk_image_path(name: str) -> Path: - DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True) - safe = safe_disk_image_filename(name) - if not safe: - raise ValueError("Некорректное имя образа диска") - target = DISK_IMAGES_DIR / safe - if not target.exists(): - return target - stem = target.stem - suffix = target.suffix - for index in range(1, 1000): - candidate = DISK_IMAGES_DIR / f"{stem}-{index}{suffix}" - if not candidate.exists(): - return candidate - raise ValueError("Не удалось подобрать свободное имя файла") - - -def find_archive_disk_members(archive_path: Path) -> list[dict[str, Any]]: - members: list[dict[str, Any]] = [] - lower = archive_path.name.lower() - if lower.endswith(".zip"): - with zipfile.ZipFile(archive_path) as archive: - for info in archive.infolist(): - if info.is_dir() or not archive_member_is_safe(info.filename): - continue - safe_name = archive_member_basename(info.filename) - if safe_name: - members.append({"kind": "zip", "name": info.filename, "safe_name": safe_name, "size": int(info.file_size)}) - elif lower.endswith((".tar.gz", ".tgz")): - with tarfile.open(archive_path, "r:gz") as archive: - for info in archive.getmembers(): - if not info.isfile() or not archive_member_is_safe(info.name): - continue - safe_name = archive_member_basename(info.name) - if safe_name: - members.append({"kind": "tar", "name": info.name, "safe_name": safe_name, "size": int(info.size)}) - else: - raise ValueError("Поддерживаются только .zip, .tar.gz и .tgz") - return sorted(members, key=lambda item: item.get("size", 0), reverse=True) - - -def extract_disk_archive(archive_path: Path) -> list[Path]: - members = find_archive_disk_members(archive_path) - if not members: - raise ValueError("В архиве не найдено .img, .raw или .qcow2 файлов") - extracted: list[Path] = [] - lower = archive_path.name.lower() - if lower.endswith(".zip"): - with zipfile.ZipFile(archive_path) as archive: - for member in members: - target = unique_disk_image_path(member["safe_name"]) - with archive.open(member["name"], "r") as src, target.open("wb") as dst: - shutil.copyfileobj(src, dst, length=1024 * 1024) - extracted.append(target) - else: - with tarfile.open(archive_path, "r:gz") as archive: - for member in members: - file_obj = archive.extractfile(member["name"]) - if file_obj is None: - continue - target = unique_disk_image_path(member["safe_name"]) - with file_obj as src, target.open("wb") as dst: - shutil.copyfileobj(src, dst, length=1024 * 1024) - extracted.append(target) - return extracted - - -def extract_xz_disk_image(compressed_path: Path, safe_name: str | None = None) -> Path: - source_name = safe_name or compressed_path.name - if not source_name.lower().endswith(".img.xz"): - raise ValueError("Поддерживаются только сжатые образы .img.xz") - raw_name = source_name[:-3] - target = unique_disk_image_path(raw_name) - try: - with lzma.open(compressed_path, "rb") as src, target.open("wb") as dst: - shutil.copyfileobj(src, dst, length=1024 * 1024) - except Exception: - target.unlink(missing_ok=True) - raise - return target - - -def disk_convert_target_path(source_path: Path) -> Path: - if source_path.suffix.lower() == ".qcow2": - return source_path - target = source_path.with_suffix(".qcow2") - if not target.exists(): - return target - for index in range(1, 1000): - candidate = source_path.with_name(f"{source_path.stem}-{index}.qcow2") - if not candidate.exists(): - return candidate - raise ValueError("Не удалось подобрать имя qcow2 для конвертации") - - -def disk_convert_progress(line: str, current: int) -> int: - match = re.search(r"\((\d+(?:\.\d+)?)/100%\)", line or "") - if match: - return max(current, int(float(match.group(1)))) - match = re.search(r"(\d+(?:\.\d+)?)%", line or "") - if match: - return max(current, int(float(match.group(1)))) - return current - - -def run_disk_convert_worker(operation_id: str, source_path: str, target_path: str) -> None: - operation = read_operation(operation_id) - if not operation: - return - source = Path(source_path) - target = Path(target_path) - update_operation(operation, status="running", progress=1, message=f"Конвертация {source.name} в qcow2", started_at=utc_now()) - cmd = ["qemu-img", "convert", "-p", "-f", disk_image_format(source), "-O", "qcow2", str(source), str(target)] - 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) - progress = 1 - if process.stdout: - for line in process.stdout: - append_operation_log(operation_id, line) - progress = disk_convert_progress(line, progress) - fresh = read_operation(operation_id) or operation - update_operation(fresh, progress=progress, message=f"Конвертация {source.name}: {progress}%") - exit_code = process.wait() - fresh = read_operation(operation_id) or operation - if exit_code == 0: - append_operation_log(operation_id, f"Конвертация завершена: {target}") - update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=f"Готово: {target.name}", finished_at=utc_now(), target_path=str(target)) - else: - append_operation_log(operation_id, f"qemu-img завершился с ошибкой. Exit code: {exit_code}") - target.unlink(missing_ok=True) - update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"qemu-img завершился с ошибкой: {exit_code}", finished_at=utc_now()) - except Exception as exc: - fresh = read_operation(operation_id) or operation - append_operation_log(operation_id, f"Ошибка конвертации: {exc}") - target.unlink(missing_ok=True) - update_operation(fresh, status="error", progress=100, exit_code=-1, message=str(exc), finished_at=utc_now()) - - -def start_disk_convert_operation(source_path: Path) -> dict[str, Any] | None: - if source_path.suffix.lower() == ".qcow2": - return None - target_path = disk_convert_target_path(source_path) - operation_id = str(uuid.uuid4()) - operation = { - "id": operation_id, - "type": "disk_convert", - "title": f"Конвертация {source_path.name}", - "status": "queued", - "progress": 0, - "message": "Конвертация поставлена в очередь", - "created_at": utc_now(), - "updated_at": utc_now(), - "created_by": AUTH_USER, - "source_path": str(source_path), - "target_path": str(target_path), - } - write_operation(operation) - append_operation_log(operation_id, "Операция поставлена в очередь.") - threading.Thread(target=run_disk_convert_worker, args=(operation_id, str(source_path), str(target_path)), daemon=True).start() - return operation - - -def disk_upload_response(request: Request, payload: dict[str, Any]): - if request.headers.get("x-requested-with") == "XMLHttpRequest" or "application/json" in request.headers.get("accept", ""): - return JSONResponse(payload) - return RedirectResponse(url="/disk-images", status_code=303) -''' - -if 'def safe_disk_upload_filename(' not in text: - marker = '\n\ndef valid_vm_name(name: str) -> bool:' - if marker not in text: - marker = '\n\ndef bridge_exists(name: str) -> bool:' - if marker not in text: - raise SystemExit('helper insert marker not found') - text = text.replace(marker, helpers + marker, 1) - changed.append('disk archive helpers added') -else: - pattern = r"\n\ndef safe_disk_upload_filename\(filename: str\).*?\n\ndef disk_upload_response\(request: Request, payload: dict\[str, Any\]\):.*?\n return RedirectResponse\(url=\"/disk-images\", status_code=303\)\n" - text, count = re.subn(pattern, helpers, text, count=1, flags=re.S) - changed.append('disk archive helpers replaced with img.xz support' if count else 'disk archive helpers already present') - -old_route_start = text.find('@app.post("/disk-images/upload"') -if old_route_start == -1: - raise SystemExit('disk image upload route not found') -next_route = text.find('\n\n@app.post("/disk-images/{name}/delete")', old_route_start) -if next_route == -1: - raise SystemExit('disk image delete route marker not found') -new_route = r'''@app.post("/disk-images/upload", response_class=HTMLResponse) -def disk_image_upload(request: Request, image_file: UploadFile = File(...)): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - safe_name = safe_disk_upload_filename(image_file.filename or "") - if not safe_name: - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400) - DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True) - tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading" - saved_paths: list[Path] = [] - try: - with tmp_target.open("wb") as out: - shutil.copyfileobj(image_file.file, out, length=1024 * 1024) - if disk_upload_is_archive(safe_name): - saved_paths = extract_disk_archive(tmp_target) - tmp_target.unlink(missing_ok=True) - elif disk_upload_is_xz_image(safe_name): - saved_paths = [extract_xz_disk_image(tmp_target, safe_name)] - tmp_target.unlink(missing_ok=True) - else: - target = unique_disk_image_path(safe_name) - tmp_target.rename(target) - saved_paths = [target] - except Exception as exc: - tmp_target.unlink(missing_ok=True) - for path in saved_paths: - path.unlink(missing_ok=True) - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500) - - operations = [] - for path in saved_paths: - operation = start_disk_convert_operation(path) - if operation: - operations.append(operation) - - payload = { - "ok": True, - "mode": "converting" if operations else "ready", - "operation_id": operations[0]["id"] if operations else None, - "operation_ids": [op["id"] for op in operations], - "files": [path.name for path in saved_paths], - "message": f"Загружено файлов: {len(saved_paths)}. Конвертаций запущено: {len(operations)}.", - } - return disk_upload_response(request, payload) -''' -text = text[:old_route_start] + new_route + text[next_route:] -changed.append('disk image upload route supports img.xz and archives') - -app_path.write_text(text) -print('disk archive import patch applied:') -for item in changed: - print(f'- {item}') diff --git a/scripts/patch_disk_convert_progress.py b/scripts/patch_disk_convert_progress.py deleted file mode 100644 index 441810a..0000000 --- a/scripts/patch_disk_convert_progress.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -if not app_path.exists(): - print(f'WARN: app.py not found: {app_path}') - raise SystemExit(0) - -text = app_path.read_text() -changed = [] -warnings = [] - -old_progress = '''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 -''' -new_progress = '''def progress_from_line(current: int, line: str) -> int: - low = line.lower() - convert_match = re.search(r"\\((\\d+(?:\\.\\d+)?)\\s*/\\s*100%\\)", line) - if convert_match: - return max(1, min(99, int(float(convert_match.group(1))))) - percent_match = re.search(r"(\\d+(?:\\.\\d+)?)%", line) - if "qemu-img" in low and percent_match: - return max(1, min(99, int(float(percent_match.group(1))))) - 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 -''' -if old_progress in text: - text = text.replace(old_progress, new_progress, 1) - changed.append('qemu-img percent parser added') -elif 'convert_match = re.search' in text: - changed.append('qemu-img percent parser already present') -else: - warnings.append('progress_from_line marker not found, progress parser skipped') - -helpers = r''' - -def qemu_img_format_for_path(path: Path) -> str: - suffix = path.suffix.lower() - if suffix == ".qcow2": - return "qcow2" - return "raw" - - -def qcow2_name_for_upload(safe_name: str) -> str: - base = Path(safe_name).stem - return f"{base}.qcow2" - - -def start_disk_image_convert_operation(source_path: Path, target_path: Path, source_format: str) -> dict[str, Any]: - operation_id = str(uuid.uuid4()) - cmd = ["qemu-img", "convert", "-p", "-f", source_format, "-O", "qcow2", str(source_path), str(target_path)] - operation = { - "id": operation_id, - "type": "disk_image_convert", - "title": f"Конвертация образа {source_path.name}", - "status": "queued", - "progress": 0, - "message": "Ожидание конвертации образа", - "created_at": utc_now(), - "updated_at": utc_now(), - "created_by": AUTH_USER, - "source_path": str(source_path), - "target_path": str(target_path), - "source_format": source_format, - "cmd": " ".join(cmd), - } - start_background_operation(operation, cmd) - return operation -''' -if 'def start_disk_image_convert_operation(' not in text: - marker = '\n\ndef valid_vm_name(name: str) -> bool:' - if marker in text: - text = text.replace(marker, helpers + marker, 1) - changed.append('disk conversion operation helpers added') - else: - warnings.append('valid_vm_name marker not found, conversion helpers skipped') -else: - changed.append('disk conversion operation helpers already present') - -old_upload = r'''@app.post("/disk-images/upload", response_class=HTMLResponse) -def disk_image_upload(request: Request, image_file: UploadFile = File(...)): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - safe_name = safe_disk_image_filename(image_file.filename or "") - if not safe_name: - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем."}, status_code=400) - DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True) - target = DISK_IMAGES_DIR / safe_name - if target.exists(): - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Образ уже существует: {safe_name}"}, status_code=400) - tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading" - try: - with tmp_target.open("wb") as out: - shutil.copyfileobj(image_file.file, out) - tmp_target.rename(target) - except Exception as exc: - tmp_target.unlink(missing_ok=True) - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки образа: {exc}"}, status_code=500) - return RedirectResponse(url="/disk-images", status_code=303) -''' -new_upload = r'''@app.post("/disk-images/upload") -def disk_image_upload(request: Request, image_file: UploadFile = File(...)): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - safe_name = safe_disk_image_filename(image_file.filename or "") - wants_json = "application/json" in request.headers.get("accept", "") or request.headers.get("x-requested-with") == "XMLHttpRequest" - - def disk_error(message: str, status_code: int = 400): - if wants_json: - return JSONResponse({"ok": False, "error": message}, status_code=status_code) - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": message}, status_code=status_code) - - if not safe_name: - return disk_error("Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем.") - DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True) - target = DISK_IMAGES_DIR / safe_name - final_qcow2 = DISK_IMAGES_DIR / qcow2_name_for_upload(safe_name) - if target.exists() or final_qcow2.exists(): - return disk_error(f"Образ уже существует: {safe_name}") - tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading" - try: - with tmp_target.open("wb") as out: - shutil.copyfileobj(image_file.file, out) - tmp_target.rename(target) - if target.suffix.lower() == ".qcow2": - payload = {"ok": True, "mode": "ready", "name": target.name, "redirect": "/disk-images"} - else: - operation = start_disk_image_convert_operation(target, final_qcow2, qemu_img_format_for_path(target)) - payload = {"ok": True, "mode": "converting", "name": target.name, "target": final_qcow2.name, "operation_id": operation["id"], "redirect": "/disk-images"} - except Exception as exc: - tmp_target.unlink(missing_ok=True) - return disk_error(f"Ошибка загрузки образа: {exc}", status_code=500) - - if wants_json: - return JSONResponse(payload) - return RedirectResponse(url="/disk-images", status_code=303) -''' -if old_upload in text: - text = text.replace(old_upload, new_upload, 1) - changed.append('disk upload returns JSON and starts conversion operation') -elif 'mode": "converting"' in text and ('start_disk_image_convert_operation' in text or 'start_disk_convert_operation' in text): - changed.append('disk upload conversion already present') -else: - warnings.append('disk_image_upload marker not found, upload conversion patch skipped') - -app_path.write_text(text) -print('disk convert progress patch completed:') -for item in changed: - print(f'- {item}') -for item in warnings: - print(f'WARN: {item}') -raise SystemExit(0) diff --git a/scripts/patch_disk_images.py b/scripts/patch_disk_images.py deleted file mode 100644 index 03688f3..0000000 --- a/scripts/patch_disk_images.py +++ /dev/null @@ -1,298 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import re -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -if not app_path.exists(): - raise SystemExit(f'app.py not found: {app_path}') - -text = app_path.read_text() -changed = [] -warnings = [] - -if 'DISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")' not in text: - text = text.replace('IMAGES_DIR = Path("/var/lib/virtuality/images")\n', 'IMAGES_DIR = Path("/var/lib/virtuality/images")\nDISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")\n', 1) - changed.append('DISK_IMAGES_DIR added') -else: - changed.append('DISK_IMAGES_DIR already present') - -helpers = r''' - -def vm_arch_options() -> list[dict[str, str]]: - return [ - {"value": "auto", "label": "Auto — по профилю хоста"}, - {"value": "x86_64", "label": "x86_64 / amd64"}, - {"value": "aarch64", "label": "ARM64 / aarch64"}, - {"value": "generic", "label": "Generic / no arch override"}, - ] - - -def normalize_guest_arch(value: str, profile: dict[str, Any]) -> str: - value = (value or "auto").strip() - if value == "auto": - return str(profile.get("recommended_guest_arch") or "x86_64") - if value in ("x86_64", "amd64"): - return "x86_64" - if value in ("aarch64", "arm64"): - return "aarch64" - if value == "generic": - return "generic" - return str(profile.get("recommended_guest_arch") or "x86_64") - - -def list_disk_image_files() -> list[dict[str, str]]: - DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True) - files = [] - for item in sorted(list(DISK_IMAGES_DIR.glob("*.img")) + list(DISK_IMAGES_DIR.glob("*.raw")) + list(DISK_IMAGES_DIR.glob("*.qcow2"))): - try: - stat = item.stat() - size_gb = stat.st_size / 1024 / 1024 / 1024 - updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M") - except OSError: - size_gb = 0 - updated = "unknown" - files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated}) - return files - - -def safe_disk_image_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((".img", ".raw", ".qcow2")): - return None - if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(img|raw|qcow2)", name, re.IGNORECASE): - return None - return name - - -def disk_image_path_by_name(name: str) -> Path | None: - safe_name = safe_disk_image_filename(name) - if not safe_name: - return None - path = (DISK_IMAGES_DIR / safe_name).resolve() - if DISK_IMAGES_DIR.resolve() not in path.parents: - return None - return path - - -def disk_image_format(path: Path) -> str: - suffix = path.suffix.lower().lstrip('.') - if suffix == 'qcow2': - return 'qcow2' - return 'raw' - - -def bridge_exists(name: str) -> bool: - if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name): - return False - return run_cmd(["ip", "link", "show", name], timeout=5)["ok"] -''' - -if 'def list_disk_image_files() -> list[dict[str, str]]:' not in text: - marker = '\n\ndef valid_vm_name(name: str) -> bool:' - if marker not in text: - raise SystemExit('valid_vm_name marker not found') - text = text.replace(marker, helpers + marker, 1) - changed.append('disk image and architecture helpers added') -else: - if 'def vm_arch_options()' not in text: - insert = helpers.split('\n\ndef list_disk_image_files()', 1)[0] - marker = '\n\ndef list_disk_image_files() -> list[dict[str, str]]:' - text = text.replace(marker, insert + marker, 1) - changed.append('architecture helpers added') - else: - changed.append('architecture helpers already present') - changed.append('disk image helpers already present') - -text = text.replace('\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n', '\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n') - -old_context = '"isos": list_iso_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}' -new_context = '"isos": list_iso_files(), "disk_images": list_disk_image_files(), "arch_options": vm_arch_options(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}' -if old_context in text: - text = text.replace(old_context, new_context, 1) - changed.append('vm form context gets disk images and arch options') -elif '"disk_images": list_disk_image_files()' in text and '"arch_options": vm_arch_options()' not in text: - text = text.replace('"disk_images": list_disk_image_files(),', '"disk_images": list_disk_image_files(), "arch_options": vm_arch_options(),') - text = text.replace('"source_type": "iso", "network_mode"', '"source_type": "iso", "guest_arch": "auto", "network_mode"') - changed.append('vm form context upgraded with arch options') -elif '"arch_options": vm_arch_options()' in text: - changed.append('vm form context already has arch options') -else: - warnings.append('vm_form_context marker not found') - -routes = r''' - -@app.get("/disk-images", response_class=HTMLResponse) -def disk_images_page(request: Request, error: str | None = None): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": error}) - - -@app.post("/disk-images/upload", response_class=HTMLResponse) -def disk_image_upload(request: Request, image_file: UploadFile = File(...)): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - safe_name = safe_disk_image_filename(image_file.filename or "") - if not safe_name: - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем."}, status_code=400) - DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True) - target = DISK_IMAGES_DIR / safe_name - if target.exists(): - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Образ уже существует: {safe_name}"}, status_code=400) - tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading" - try: - with tmp_target.open("wb") as out: - shutil.copyfileobj(image_file.file, out) - tmp_target.rename(target) - except Exception as exc: - tmp_target.unlink(missing_ok=True) - return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки образа: {exc}"}, status_code=500) - return RedirectResponse(url="/disk-images", status_code=303) - - -@app.post("/disk-images/{name}/delete") -def disk_image_delete(request: Request, name: str): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - path = disk_image_path_by_name(name) - if path and path.exists() and path.is_file(): - path.unlink() - return RedirectResponse(url="/disk-images", status_code=303) -''' - -if '@app.get("/disk-images"' not in text: - marker = '\n\n@app.get("/network", response_class=HTMLResponse)' - if marker not in text: - raise SystemExit('network route marker not found') - text = text.replace(marker, routes + marker, 1) - changed.append('disk image routes added') -else: - changed.append('disk image routes already present') - -old_sig = '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)):' -new_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):' -if old_sig in text: - text = text.replace(old_sig, new_sig, 1) - changed.append('vm create signature supports disk images and guest arch') -elif 'source_type: str = Form("iso")' in text and 'guest_arch: str = Form("auto")' not in text: - text = text.replace('source_type: str = Form("iso"), network_mode:', 'source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode:', 1) - changed.append('vm create signature upgraded with guest arch') -elif 'guest_arch: str = Form("auto")' in text: - changed.append('vm create signature already supports guest arch') -else: - warnings.append('vm_create_submit signature marker not found') - -old_body = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "network_mode": network_mode, "bridge": bridge}' -new_body = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}' -if old_body in text: - text = text.replace(old_body, new_body, 1) - changed.append('vm create form state supports source type and guest arch') -elif '"source_type": source_type' in text and '"guest_arch": guest_arch' not in text: - text = text.replace('"source_type": source_type, "network_mode"', '"source_type": source_type, "guest_arch": guest_arch, "network_mode"') - changed.append('vm create form state upgraded with guest arch') - -old_iso_validation = ''' 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." -''' -new_iso_validation = ''' elif source_type not in ("iso", "disk_image"): - error = "Некорректный источник VM." - elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"): - error = "Некорректная архитектура VM." - elif network_mode == "bridge" and not bridge_exists(bridge): - error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}." - else: - if source_type == "iso": - 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." - else: - disk_image = Path(disk_image_path).resolve() - if DISK_IMAGES_DIR.resolve() not in disk_image.parents or disk_image.suffix.lower() not in (".img", ".raw", ".qcow2") or not disk_image.exists(): - error = "Образ диска должен быть существующим .img, .raw или .qcow2 файлом из /var/lib/virtuality/disk-images." -''' -if old_iso_validation in text: - text = text.replace(old_iso_validation, new_iso_validation, 1) - changed.append('vm create validation supports disk image source and guest arch') -elif 'guest_arch not in' not in text and 'source_type == "iso"' in text: - text = text.replace('elif network_mode not in ("nat", "bridge"):\n error = "Некорректный режим сети."', 'elif network_mode not in ("nat", "bridge"):\n error = "Некорректный режим сети."\n elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."', 1) - changed.append('vm create validation upgraded with guest arch') -elif 'guest_arch not in' in text: - changed.append('vm create validation already supports guest arch') - -old_cmd = ''' 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) - - profile = host_profile.load_host_profile() - is_arm = profile.get("recommended_guest_arch") == "aarch64" - 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)] - if is_arm: - cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"] - cmd += ["--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"] -''' -new_cmd = ''' 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) - - profile = host_profile.load_host_profile() - selected_arch = normalize_guest_arch(guest_arch, profile) - host_arch = str(profile.get("arch") or platform.machine() or "") - is_arm = selected_arch == "aarch64" - # ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation. - virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu") - 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), "--virt-type", virt_type] - if selected_arch == "x86_64": - cmd += ["--arch", "x86_64"] - elif is_arm: - cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"] - - if source_type == "disk_image": - source_disk = Path(disk_image_path).resolve() - source_format = disk_image_format(source_disk) - convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}" - virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]) - cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"] - else: - cmd += ["--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"] -''' -if old_cmd in text: - text = text.replace(old_cmd, new_cmd, 1) - changed.append('vm create command supports ARM64 qemu fallback, guest arch and disk image import') -elif 'selected_arch = normalize_guest_arch' in text: - # Upgrade existing selected_arch block if it still uses kvm for aarch64 on x86. - text = re.sub( - r'virt_type = "kvm" if profile\.get\("kvm_device"\) else "qemu"', - 'host_arch = str(profile.get("arch") or platform.machine() or "")\n # ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation.\n virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu")', - text, - count=1, - ) - changed.append('existing vm create command was upgraded with ARM64 qemu fallback') -elif 'virt_type = "kvm" if profile.get("kvm_device") else "qemu"' in text: - text = text.replace('is_arm = profile.get("recommended_guest_arch") == "aarch64"\n virt_type =', 'selected_arch = normalize_guest_arch(guest_arch, profile)\n host_arch = str(profile.get("arch") or platform.machine() or "")\n is_arm = selected_arch == "aarch64"\n virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else', 1) - text = text.replace('if is_arm:\n cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]', 'if selected_arch == "x86_64":\n cmd += ["--arch", "x86_64"]\n elif is_arm:\n cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]', 1) - changed.append('existing vm create command was upgraded with guest arch and ARM64 qemu fallback') -else: - warnings.append('vm command marker not found') - -if '"guest_arch": selected_arch' not in text and '"guest_arch": profile.get("recommended_guest_arch")' in text: - text = text.replace('"guest_arch": profile.get("recommended_guest_arch")', '"guest_arch": selected_arch') -if '"disk_image_path": disk_image_path' not in text: - text = text.replace('"iso_path": iso_path, "host_profile"', '"iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": selected_arch, "host_profile"') - -app_path.write_text(text) -print('disk images patch applied:') -for item in changed: - print(f'- {item}') -for item in warnings: - print(f'WARN: {item}') diff --git a/scripts/patch_existing_vm_boot_order.py b/scripts/patch_existing_vm_boot_order.py deleted file mode 100644 index e9aae63..0000000 --- a/scripts/patch_existing_vm_boot_order.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -if not app_path.exists(): - raise SystemExit(f'app.py not found: {app_path}') - -app_dir = app_path.resolve().parent -template_path = app_dir / 'templates' / 'vm_detail.html' -changed = [] -warnings = [] - -text = app_path.read_text() - -if 'import tempfile' not in text: - text = text.replace('import subprocess\n', 'import subprocess\nimport tempfile\n', 1) - changed.append('tempfile import added') -else: - changed.append('tempfile import already present') - -if 'import xml.etree.ElementTree as ET' not in text: - text = text.replace('from typing import Any\n', 'from typing import Any\nimport xml.etree.ElementTree as ET\n', 1) - changed.append('ElementTree import added') -else: - changed.append('ElementTree import already present') - -helpers = r''' - -def vm_boot_order_label(value: str) -> str: - labels = {item["value"]: item["label"] for item in vm_boot_order_options()} - return labels.get(value or "auto", "Auto — по источнику VM") - - -def boot_order_to_devs(value: str) -> list[str]: - value = normalize_boot_order(value, "disk_image") - mapping = { - "disk": ["hd"], - "cdrom_disk": ["cdrom", "hd"], - "disk_cdrom": ["hd", "cdrom"], - "network_disk": ["network", "hd"], - } - return mapping.get(value, ["hd"]) - - -def boot_devs_to_order(devs: list[str]) -> str: - clean = [item for item in devs if item in ("hd", "cdrom", "network")] - if clean[:2] == ["cdrom", "hd"]: - return "cdrom_disk" - if clean[:2] == ["hd", "cdrom"]: - return "disk_cdrom" - if clean[:2] == ["network", "hd"]: - return "network_disk" - if clean[:1] == ["hd"]: - return "disk" - return "auto" - - -def current_vm_boot_order(name: str) -> str: - result = run_cmd(["virsh", "dumpxml", name], timeout=12) - if not result.get("ok"): - return "auto" - try: - root = ET.fromstring(result.get("stdout") or "") - except Exception: - return "auto" - os_node = root.find("os") - if os_node is None: - return "auto" - devs = [] - for boot in os_node.findall("boot"): - dev = boot.attrib.get("dev", "").strip() - if dev: - devs.append(dev) - return boot_devs_to_order(devs) - - -def apply_vm_boot_order(name: str, boot_order: str) -> tuple[bool, str]: - if not valid_vm_name(name) or not vm_exists(name): - return False, "VM не найдена." - if boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"): - return False, "Некорректный порядок загрузки VM." - - selected = normalize_boot_order(boot_order, "disk_image") - result = run_cmd(["virsh", "dumpxml", name], timeout=15) - if not result.get("ok"): - return False, result.get("stderr") or "Не удалось получить XML VM." - - try: - root = ET.fromstring(result.get("stdout") or "") - except Exception as exc: - return False, f"Не удалось разобрать XML VM: {exc}" - - os_node = root.find("os") - if os_node is None: - os_node = ET.SubElement(root, "os") - - for boot in list(os_node.findall("boot")): - os_node.remove(boot) - - insert_at = 0 - for idx, child in enumerate(list(os_node)): - if child.tag in ("type", "loader", "nvram", "firmware", "smbios", "bootmenu"): - insert_at = idx + 1 - - for dev in reversed(boot_order_to_devs(selected)): - boot_node = ET.Element("boot", {"dev": dev}) - os_node.insert(insert_at, boot_node) - - xml_text = ET.tostring(root, encoding="unicode") - with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle: - handle.write(xml_text) - tmp_name = handle.name - try: - define = run_cmd(["virsh", "define", tmp_name], timeout=30) - finally: - Path(tmp_name).unlink(missing_ok=True) - if not define.get("ok"): - return False, define.get("stderr") or "virsh define завершился ошибкой." - return True, f"Порядок загрузки применён: {vm_boot_order_label(selected)}. Если VM запущена, изменение сработает после перезапуска." -''' - -if 'def apply_vm_boot_order(name: str, boot_order: str)' not in text: - marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:' - if marker in text: - text = text.replace(marker, helpers + marker, 1) - changed.append('existing VM boot order helpers added') - else: - warnings.append('vm_details marker not found, helpers skipped') -else: - changed.append('existing VM boot order helpers already present') - -old = '"host_ip": system_summary()["ip"]})' -new = '"host_ip": system_summary()["ip"], "boot_options": vm_boot_order_options(), "current_boot_order": current_vm_boot_order(name), "boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})' -if '"current_boot_order": current_vm_boot_order(name)' not in text: - if old in text: - text = text.replace(old, new, 1) - changed.append('vm detail context gets boot order') - else: - warnings.append('vm_detail context marker not found, skipped') -else: - changed.append('vm detail context already has boot order') - -route = r''' - -@app.post("/vm/{name}/boot-order") -def vm_boot_order_apply(request: Request, name: str, boot_order: str = Form("auto")): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - ok, message = apply_vm_boot_order(name, boot_order) - if ok: - return RedirectResponse(url=f"/vm/{name}?boot_message={message}", status_code=303) - return RedirectResponse(url=f"/vm/{name}?boot_error={message}", status_code=303) -''' - -if '@app.post("/vm/{name}/boot-order")' not in text: - marker = '\n\n@app.post("/vm/{name}/{action}")' - if marker in text: - text = text.replace(marker, route + marker, 1) - changed.append('existing VM boot order route added') - else: - warnings.append('generic vm action marker not found, route skipped') -else: - changed.append('existing VM boot order route already present') - -app_path.write_text(text) - -if template_path.exists(): - tpl = template_path.read_text() - original = tpl - if 'action="/vm/{{ vm.name }}/boot-order"' not in tpl: - block = r''' - - {% if boot_message %} -
{{ boot_message }}
- {% endif %} - {% if boot_error %} -
{{ boot_error }}
- {% endif %} - -
-
-

Порядок загрузки

- boot order -
-
- - -
-

Настройка меняет XML-конфигурацию VM через virsh define. Если машина сейчас запущена, новый порядок загрузки сработает после перезапуска.

-
-''' - markers = ['\n\n
', '\n\n
', '\n\n
'] - inserted = False - for marker in markers: - if marker in tpl: - tpl = tpl.replace(marker, block + marker, 1) - inserted = True - changed.append('boot order card added to vm_detail.html') - break - if not inserted: - warnings.append('vm detail insertion marker not found, boot order card skipped') - else: - changed.append('boot order card already present in vm_detail.html') - if tpl != original: - template_path.write_text(tpl) -else: - warnings.append(f'vm_detail.html not found: {template_path}') - -print('existing VM boot order patch applied:') -for item in changed: - print(f'- {item}') -if warnings: - print('Warnings:') - for item in warnings: - print(f'- {item}') diff --git a/scripts/patch_existing_vm_iso_mount.py b/scripts/patch_existing_vm_iso_mount.py deleted file mode 100644 index b8eef27..0000000 --- a/scripts/patch_existing_vm_iso_mount.py +++ /dev/null @@ -1,280 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -if not app_path.exists(): - raise SystemExit(f'app.py not found: {app_path}') - -app_dir = app_path.resolve().parent -template_path = app_dir / 'templates' / 'vm_detail.html' -changed = [] - -text = app_path.read_text() - -helpers = r''' - -def vm_state(name: str) -> str: - result = run_cmd(["virsh", "domstate", name], timeout=8) - return (result.get("stdout") or "unknown").strip().lower() - - -def current_vm_iso(name: str) -> str: - result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10) - if not result.get("ok"): - return "" - for line in (result.get("stdout") or "").splitlines(): - if ".iso" not in line.lower(): - continue - parts = line.split() - if parts: - return parts[-1] - return "" - - -def detach_vm_iso(name: str) -> tuple[bool, str]: - if not valid_vm_name(name) or not vm_exists(name): - return False, "VM не найдена." - xml = run_cmd(["virsh", "dumpxml", name], timeout=12) - cdrom_targets: list[str] = [] - if xml.get("ok"): - try: - root = ET.fromstring(xml.get("stdout") or "") - devices = root.find("devices") - if devices is not None: - for disk in devices.findall("disk"): - if disk.attrib.get("device") != "cdrom": - continue - target = disk.find("target") - dev = target.attrib.get("dev") if target is not None else "" - if dev: - cdrom_targets.append(dev) - except Exception: - pass - if not cdrom_targets: - cdrom_targets = ["sda", "hda", "sdb", "hdc"] - - running = vm_state(name) == "running" - errors = [] - changed_any = False - for target in cdrom_targets: - commands = [] - if running: - commands.append(["virsh", "detach-disk", name, target, "--live"]) - commands.append(["virsh", "detach-disk", name, target, "--config"]) - for cmd in commands: - result = run_cmd(cmd, timeout=30) - if result.get("ok"): - changed_any = True - elif result.get("stderr"): - errors.append(result.get("stderr")) - if changed_any: - return True, "ISO был отмонтирован." - return False, errors[-1] if errors else "Подключенный ISO не найден." - - -def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]: - if not valid_vm_name(name) or not vm_exists(name): - return False, "VM не найдена." - iso = Path(iso_path or "").resolve() - try: - iso_root = ISO_DIR.resolve() - except Exception: - iso_root = Path("/var/lib/virtuality/iso") - if iso_root not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists(): - return False, "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso." - - detach_vm_iso(name) - running = vm_state(name) == "running" - base = ["virsh", "attach-disk", name, str(iso), "sda", "--type", "cdrom", "--mode", "readonly"] - if running: - live = run_cmd(base + ["--live"], timeout=30) - if not live.get("ok"): - return False, live.get("stderr") or "Не удалось подключить ISO к запущенной VM." - config = run_cmd(base + ["--config"], timeout=30) - if not config.get("ok"): - return False, config.get("stderr") or "Не удалось сохранить ISO в конфигурации VM." - return True, "ISO был смонтирован в VM. Если гостевая ОС его не увидела сразу, перезагрузи VM или обнови устройства внутри гостевой ОС." -''' - -if 'def mount_vm_iso(name: str, iso_path: str)' not in text: - marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:' - if marker in text: - text = text.replace(marker, helpers + marker, 1) - changed.append('existing VM ISO mount helpers added') - else: - changed.append('ISO helpers skipped: vm_details marker not found') -else: - changed.append('existing VM ISO mount helpers already present') - -if '"current_iso": current_vm_iso(name)' not in text: - replacements = [ - ( - '"resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})', - '"resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})', - ), - ( - '"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})', - '"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})', - ), - ( - '"host_ip": system_summary()["ip"]})', - '"host_ip": system_summary()["ip"], "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})', - ), - ] - for old, new in replacements: - if old in text: - text = text.replace(old, new, 1) - changed.append('vm detail context gets ISO mount data') - break - else: - changed.append('ISO context skipped: vm detail context marker not found') -else: - changed.append('vm detail context already has ISO mount data') - -routes = r''' - -@app.post("/vm/{name}/iso/mount") -def vm_iso_mount_apply(request: Request, name: str, iso_path: str = Form(...)): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - ok, message = mount_vm_iso(name, iso_path) - if ok: - return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303) - return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303) - - -@app.post("/vm/{name}/iso/unmount") -def vm_iso_unmount_apply(request: Request, name: str): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - ok, message = detach_vm_iso(name) - if ok: - return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303) - return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303) -''' - -if '@app.post("/vm/{name}/iso/mount")' not in text: - markers = ['\n\n@app.post("/vm/{name}/{action}")', '\n\n@app.get("/vm/create"', '\n\n@app.get("/operations"'] - for marker in markers: - if marker in text: - text = text.replace(marker, routes + marker, 1) - changed.append('existing VM ISO mount routes added') - break - else: - changed.append('ISO routes skipped: safe route marker not found') -else: - changed.append('existing VM ISO mount routes already present') - -app_path.write_text(text) - -iso_card = r''' -
-
-

ISO-привод

- cdrom -
- {% if current_iso %} -
Сейчас подключен ISO: {{ current_iso }}
- {% else %} -
ISO сейчас не подключён.
- {% endif %} -
- - -
-
- -
- {% if not isos %} -
ISO-образов пока нет. Сначала загрузи .iso в разделе ISO.
- {% endif %} -

Для запущенной VM ISO подключается live и сохраняется в конфигурации. Для выключенной VM ISO будет доступен при следующем старте.

-
-''' - -boot_card = r''' -
-
-

Порядок загрузки

- drag boot -
-
- -
-
☰
ISO / CD-ROMУстановщик или rescue-образ
-
☰
ДискОсновной qcow2/raw диск VM
-
☰
Сеть / PXEЗагрузка по сети
-
- -
-

Перетащи нужный источник выше. Для загрузки с ISO поставь ISO / CD-ROM первым. Изменение сработает после перезапуска VM.

- -
-''' - -if template_path.exists(): - tpl = template_path.read_text() - original = tpl - settings_grid = '\n\n
\n' + iso_card + boot_card + '
\n' - - if 'action="/vm/{{ vm.name }}/iso/mount"' not in tpl and 'vm-boot-iso-grid' not in tpl: - markers = ['\n\n
', '\n\n
', '\n\n
'] - for marker in markers: - if marker in tpl: - if 'vm-resource-boot-iso-grid' in marker: - insert_pos = tpl.find(marker) + len(marker) - tpl = tpl[:insert_pos] + '\n' + iso_card + boot_card + tpl[insert_pos:] - changed.append('ISO and boot cards inserted into three-column grid') - else: - tpl = tpl.replace(marker, settings_grid + marker, 1) - changed.append('ISO and boot cards inserted by fallback') - break - else: - changed.append('ISO layout skipped: safe template marker not found') - elif 'vm-boot-iso-grid' in tpl and 'boot-order-list' not in tpl: - changed.append('ISO grid exists but draggable boot marker absent; left unchanged safely') - else: - changed.append('ISO mount template already present') - - if tpl != original: - template_path.write_text(tpl) -else: - changed.append('ISO template skipped: vm_detail.html not found') - -print('existing VM ISO mount patch applied:') -for item in changed: - print(f'- {item}') diff --git a/scripts/patch_existing_vm_resources.py b/scripts/patch_existing_vm_resources.py deleted file mode 100644 index 8f3ee26..0000000 --- a/scripts/patch_existing_vm_resources.py +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -if not app_path.exists(): - raise SystemExit(f'app.py not found: {app_path}') - -changed = [] -warnings = [] -text = app_path.read_text() - -if 'import tempfile' not in text: - text = text.replace('import subprocess\n', 'import subprocess\nimport tempfile\n', 1) - changed.append('tempfile import added') -else: - changed.append('tempfile import already present') - -if 'import xml.etree.ElementTree as ET' not in text: - text = text.replace('from typing import Any\n', 'from typing import Any\nimport xml.etree.ElementTree as ET\n', 1) - changed.append('ElementTree import added') -else: - changed.append('ElementTree import already present') - -helpers = r''' - -def parse_dominfo_value(dominfo: str, label: str) -> str: - for line in (dominfo or "").splitlines(): - if line.strip().lower().startswith(label.lower()): - return line.split(":", 1)[1].strip() - return "" - - -def kib_text_to_mb(value: str) -> int: - match = re.search(r"(\d+)", value or "") - if not match: - return 0 - return max(0, int(int(match.group(1)) / 1024)) - - -def current_vm_arch(name: str) -> str: - result = run_cmd(["virsh", "dumpxml", name], timeout=12) - if not result.get("ok"): - return "unknown" - try: - root = ET.fromstring(result.get("stdout") or "") - except Exception: - return "unknown" - os_type = root.find("os/type") - return (os_type.attrib.get("arch") if os_type is not None else "") or "unknown" - - -def vm_runtime_state(name: str) -> str: - result = run_cmd(["virsh", "domstate", name], timeout=8) - return (result.get("stdout") or "unknown").strip().lower() - - -def vm_resource_settings(name: str) -> dict[str, Any]: - dominfo = run_cmd(["virsh", "dominfo", name], timeout=10).get("stdout") or "" - state = (parse_dominfo_value(dominfo, "State") or vm_runtime_state(name)).lower() - vcpus_raw = parse_dominfo_value(dominfo, "CPU(s)") - used_memory_raw = parse_dominfo_value(dominfo, "Used memory") - max_memory_raw = parse_dominfo_value(dominfo, "Max memory") - memory_mb = kib_text_to_mb(used_memory_raw) or kib_text_to_mb(max_memory_raw) or 1024 - try: - vcpus = int(re.search(r"\d+", vcpus_raw or "1").group(0)) - except Exception: - vcpus = 1 - return { - "state": state, - "is_shutoff": state in ("shut off", "shutoff", "shut-off"), - "memory_mb": memory_mb, - "vcpus": vcpus, - "arch": current_vm_arch(name), - } - - -def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str) -> tuple[bool, str]: - if not valid_vm_name(name) or not vm_exists(name): - return False, "VM не найдена." - resources = vm_resource_settings(name) - if not resources.get("is_shutoff"): - return False, "CPU/RAM/архитектуру можно менять только когда VM выключена. Сначала выключи VM." - if memory_mb < 512 or memory_mb > 262144: - return False, "RAM должна быть от 512 MB до 262144 MB." - if vcpus < 1 or vcpus > 128: - return False, "CPU должен быть от 1 до 128 vCPU." - if guest_arch not in ("keep", "x86_64", "aarch64"): - return False, "Некорректная архитектура VM." - - result = run_cmd(["virsh", "dumpxml", name], timeout=15) - if not result.get("ok"): - return False, result.get("stderr") or "Не удалось получить XML VM." - try: - root = ET.fromstring(result.get("stdout") or "") - except Exception as exc: - return False, f"Не удалось разобрать XML VM: {exc}" - - memory_kib = str(int(memory_mb) * 1024) - for tag in ("memory", "currentMemory"): - node = root.find(tag) - if node is None: - node = ET.SubElement(root, tag) - node.text = memory_kib - node.set("unit", "KiB") - - vcpu_node = root.find("vcpu") - if vcpu_node is None: - vcpu_node = ET.SubElement(root, "vcpu") - vcpu_node.text = str(int(vcpus)) - vcpu_node.set("placement", "static") - - arch_changed = False - if guest_arch != "keep": - os_type = root.find("os/type") - if os_type is None: - os_node = root.find("os") - if os_node is None: - os_node = ET.SubElement(root, "os") - os_type = ET.SubElement(os_node, "type") - os_type.text = "hvm" - old_arch = os_type.attrib.get("arch", "") - if old_arch != guest_arch: - os_type.set("arch", guest_arch) - if guest_arch == "aarch64": - os_type.set("machine", "virt") - arch_changed = True - - xml_text = ET.tostring(root, encoding="unicode") - with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle: - handle.write(xml_text) - tmp_name = handle.name - try: - define = run_cmd(["virsh", "define", tmp_name], timeout=30) - finally: - Path(tmp_name).unlink(missing_ok=True) - if not define.get("ok"): - return False, define.get("stderr") or "virsh define завершился ошибкой." - - message = f"Ресурсы VM применены: CPU {vcpus}, RAM {memory_mb} MB" - if arch_changed: - message += f", архитектура {guest_arch}. Важно: смена архитектуры может потребовать совместимый диск/загрузчик." - return True, message -''' - -if 'def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str)' not in text: - marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:' - if marker in text: - text = text.replace(marker, helpers + marker, 1) - changed.append('existing VM resources helpers added') - else: - warnings.append('vm_details marker not found, helpers skipped') -else: - changed.append('existing VM resources helpers already present') - -if '"resource_settings": vm_resource_settings(name)' not in text: - replacements = [ - ('"iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})', '"iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'), - ('"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})', '"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'), - ('"host_ip": system_summary()["ip"]})', '"host_ip": system_summary()["ip"], "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'), - ] - for old, new in replacements: - if old in text: - text = text.replace(old, new, 1) - changed.append('vm detail context gets resource settings') - break - else: - warnings.append('vm detail context marker not found, resource context skipped') -else: - changed.append('vm detail context already has resource settings') - -route = r''' - -@app.post("/vm/{name}/resources") -def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...), vcpus: int = Form(...), guest_arch: str = Form("keep")): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - ok, message = apply_vm_resources(name, memory_mb, vcpus, guest_arch) - if ok: - return RedirectResponse(url=f"/vm/{name}?resource_message={message}", status_code=303) - return RedirectResponse(url=f"/vm/{name}?resource_error={message}", status_code=303) -''' - -if '@app.post("/vm/{name}/resources")' not in text: - markers = ['\n\n@app.post("/vm/{name}/boot-order")', '\n\n@app.post("/vm/{name}/iso/mount")', '\n\n@app.post("/vm/{name}/{action}")'] - for marker in markers: - if marker in text: - text = text.replace(marker, route + marker, 1) - changed.append('existing VM resources route added') - break - else: - warnings.append('vm action marker not found, resources route skipped') -else: - changed.append('existing VM resources route already present') - -app_path.write_text(text) - -print('existing VM resources patch applied:') -for item in changed: - print(f'- {item}') -if warnings: - print('Warnings:') - for item in warnings: - print(f'- {item}') diff --git a/scripts/patch_live_status.py b/scripts/patch_live_status.py deleted file mode 100644 index bc99ce9..0000000 --- a/scripts/patch_live_status.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -if not app_path.exists(): - raise SystemExit(f'app.py not found: {app_path}') - -app_dir = app_path.parent -static_dir = app_dir / 'static' -static_dir.mkdir(exist_ok=True) -panel_js_path = static_dir / 'panel.js' -app_css_path = static_dir / 'app.css' -changed = [] - -text = app_path.read_text() -live_routes = r''' - -@app.get("/live/status") -def live_status(request: Request): - if not get_current_user(request): - return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) - vms = [] - for vm in parse_virsh_list(): - name = vm.get("name", "") - state = vm.get("state", "unknown") - try: - ip = vm_ip(name) if name else "—" - except Exception: - ip = "—" - css = "ok" if "running" in state else "err" if "shut" in state else "warn" - vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else "—"}) - return JSONResponse({"ok": True, "generated_at": utc_now(), "vms": vms, "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "web": service_state("virtuality-web.service")}, "operations": list_operations(5)}) - - -@app.get("/live/operations") -def live_operations(request: Request): - if not get_current_user(request): - return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) - return JSONResponse({"ok": True, "generated_at": utc_now(), "operations": list_operations(25)}) -''' -if '@app.get("/live/status"' not in text: - marker = '\n\n@app.get("/api/health")' - text = text.replace(marker, live_routes + marker, 1) if marker in text else text.rstrip() + live_routes + '\n' - changed.append('live routes added') -else: - changed.append('live routes already present') -app_path.write_text(text) - -panel_js = panel_js_path.read_text() if panel_js_path.exists() else '' -live_js = r''' - -/* Virtuality live status + toast layer */ -(() => { - if (window.VirtualityLiveStatus) return; - window.VirtualityLiveStatus = true; - function qs(s, r = document) { return r.querySelector(s); } - function qsa(s, r = document) { return Array.from(r.querySelectorAll(s)); } - function toast(message, type = 'ok') { - if (!message) return; - let wrap = qs('#v-toast-wrap'); - if (!wrap) { wrap = document.createElement('div'); wrap.id = 'v-toast-wrap'; document.body.appendChild(wrap); } - const item = document.createElement('div'); - item.className = 'v-toast ' + type; - item.textContent = message; - wrap.appendChild(item); - setTimeout(() => item.classList.add('show'), 20); - setTimeout(() => { item.classList.remove('show'); setTimeout(() => item.remove(), 260); }, 3600); - } - function vmNameFromRow(row) { - const link = qs('a[href^="/vm/"]', row); - if (!link) return ''; - try { const p = new URL(link.href, location.href).pathname.split('/').filter(Boolean); return p[0] === 'vm' ? decodeURIComponent(p[1] || '') : ''; } catch (_) { return ''; } - } - function stateClass(state) { const v = String(state || '').toLowerCase(); return v.includes('running') ? 'ok' : v.includes('shut') ? 'err' : 'warn'; } - function updateDashboardRows(vms) { - if (!Array.isArray(vms)) return; - const map = new Map(vms.map((vm) => [vm.name, vm])); - qsa('table tbody tr').forEach((row) => { - const name = vmNameFromRow(row); - if (!name || !map.has(name)) return; - const vm = map.get(name); - const cells = qsa('td', row); - if (cells[0]) cells[0].textContent = vm.id || '-'; - if (cells[2]) cells[2].textContent = vm.ip || '—'; - if (cells[3]) { - let badge = qs('.status', cells[3]); - if (!badge) { badge = document.createElement('span'); cells[3].textContent = ''; cells[3].appendChild(badge); } - badge.className = 'status ' + (vm.state_css || stateClass(vm.state)); - badge.textContent = vm.state || 'unknown'; - } - }); - } - function updateDetailHeader(vms) { - const title = qs('.brand'); - if (!title || !Array.isArray(vms)) return; - const vm = vms.find((item) => item.name === title.textContent.trim()); - if (!vm) return; - let badge = qs('#vm-live-badge'); - if (!badge) { badge = document.createElement('span'); badge.id = 'vm-live-badge'; title.insertAdjacentElement('afterend', badge); } - badge.className = 'status live-badge ' + (vm.state_css || stateClass(vm.state)); - badge.textContent = vm.state || 'unknown'; - } - async function refreshLiveStatus() { - if (document.hidden || (!qs('.v-main') && !qs('.shell'))) return; - try { - const r = await fetch('/live/status', { cache: 'no-store', headers: { 'Accept': 'application/json' }}); - if (!r.ok) return; - const p = await r.json(); - if (!p.ok) return; - updateDashboardRows(p.vms); - updateDetailHeader(p.vms); - } catch (_) {} - } - document.addEventListener('submit', (event) => { - const form = event.target; - if (!form || !form.action) return; - const parts = new URL(form.action, location.href).pathname.split('/').filter(Boolean); - if (parts[0] !== 'vm' || parts.length < 3) return; - const labels = { start: 'Команда запуска VM отправлена', shutdown: 'Команда мягкого выключения VM отправлена', reboot: 'Команда перезагрузки VM отправлена', destroy: 'Команда принудительного выключения VM отправлена', autostart: 'Автозапуск VM включается', 'autostart-disable': 'Автозапуск VM отключается', delete: 'Удаление VM запущено' }; - const action = parts[2]; - if (labels[action]) { const type = action === 'destroy' || action === 'delete' ? 'warn' : 'ok'; sessionStorage.setItem('virtualityToast', labels[action]); toast(labels[action], type); setTimeout(refreshLiveStatus, 900); setTimeout(refreshLiveStatus, 2500); } - }, true); - function boot() { const m = sessionStorage.getItem('virtualityToast'); if (m) { sessionStorage.removeItem('virtualityToast'); toast(m, 'ok'); } refreshLiveStatus(); setInterval(refreshLiveStatus, 5000); } - if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot(); -})(); -''' -if 'Virtuality live status + toast layer' not in panel_js: - panel_js_path.write_text(panel_js.rstrip() + live_js + '\n') - changed.append('live javascript added') -else: - changed.append('live javascript already present') - -app_css = app_css_path.read_text() if app_css_path.exists() else '' -live_css = r''' - -/* Virtuality live status + toast layer */ -#v-toast-wrap { position: fixed; top: 14px; right: 14px; z-index: 10000; display: grid; gap: 8px; width: min(360px, calc(100vw - 28px)); pointer-events: none; } -.v-toast { transform: translateY(-8px); opacity: 0; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: #fff; color: var(--text); box-shadow: 0 12px 32px rgba(15,23,42,.18); font-size: 13px; font-weight: 800; transition: .22s ease; } -.v-toast.show { transform: translateY(0); opacity: 1; } -.v-toast.ok { border-color: rgba(22,163,74,.28); box-shadow: 0 12px 32px rgba(22,163,74,.16); } -.v-toast.warn { border-color: rgba(217,119,6,.32); box-shadow: 0 12px 32px rgba(217,119,6,.16); } -.v-toast.err { border-color: rgba(220,38,38,.32); box-shadow: 0 12px 32px rgba(220,38,38,.16); } -.live-badge { margin-left: 10px; vertical-align: middle; } -''' -if 'Virtuality live status + toast layer' not in app_css: - app_css_path.write_text(app_css.rstrip() + live_css + '\n') - changed.append('live css added') -else: - changed.append('live css already present') - -print('live status patch applied:') -for item in changed: - print(f'- {item}') diff --git a/scripts/patch_logs_center.py b/scripts/patch_logs_center.py deleted file mode 100644 index 2494186..0000000 --- a/scripts/patch_logs_center.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -if not app_path.exists(): - raise SystemExit(f'app.py not found: {app_path}') - -templates_dir = app_path.parent / 'templates' -static_dir = app_path.parent / 'static' -text = app_path.read_text() -changed = [] - -helpers = r''' - -LOG_SOURCES = { - "web": {"title": "Virtuality Web", "kind": "journal", "unit": "virtuality-web.service"}, - "update": {"title": "Update Center", "kind": "file", "path": "/var/log/virtuality/update.log"}, - "install": {"title": "Последняя установка", "kind": "glob", "pattern": "/var/log/virtuality/install_web_panel_*.log"}, - "operations": {"title": "Операции", "kind": "operations"}, - "auto-update": {"title": "Auto Update", "kind": "journal", "unit": "virtuality-auto-update.service"}, - "libvirtd": {"title": "libvirtd", "kind": "journal", "unit": "libvirtd.service"}, - "virtlogd": {"title": "virtlogd", "kind": "journal", "unit": "virtlogd.service"}, -} - - -def read_log_source(source: str, lines: int = 220) -> dict[str, Any]: - key = source if source in LOG_SOURCES else "web" - cfg = LOG_SOURCES[key] - lines = max(20, min(int(lines or 220), 2000)) - content = "" - path = "" - cmd = "" - if cfg["kind"] == "journal": - unit = cfg["unit"] - cmd = f"journalctl -u {unit} -n {lines} --no-pager" - result = run_cmd(["journalctl", "-u", unit, "-n", str(lines), "--no-pager"], timeout=15) - content = result["stdout"] or result["stderr"] or "Лог пуст или journalctl недоступен" - elif cfg["kind"] == "file": - path = cfg["path"] - cmd = f"tail -n {lines} {path}" - content = tail_text(Path(path), max_lines=lines) or "Файл лога пока пуст или не найден" - elif cfg["kind"] == "glob": - pattern = cfg["pattern"] - files = sorted(Path('/').glob(pattern.lstrip('/')), key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True) - if files: - path = str(files[0]) - cmd = f"tail -n {lines} {path}" - content = tail_text(files[0], max_lines=lines) or "Файл лога пуст" - else: - cmd = f"ls {pattern}" - content = "Логи установки ещё не найдены" - elif cfg["kind"] == "operations": - cmd = f"tail -n {lines} /var/log/virtuality/operations/*.log" - parts = [] - ensure_operations_dir() - for item in sorted(OPERATIONS_DIR.glob('*.log'), key=lambda p: p.stat().st_mtime, reverse=True)[:10]: - parts.append(f"===== {item.name} =====\n" + tail_text(item, max_lines=max(20, lines // 5))) - content = "\n\n".join(parts) or "Журналы операций пока пусты" - return {"key": key, "title": cfg["title"], "content": content, "path": path, "cmd": cmd, "lines": lines} -''' - -if 'LOG_SOURCES = {' not in text: - markers = [ - '\n\ndef parse_virsh_list() -> list[dict[str, str]]:', - '\n\ndef list_recent_operations(limit: int = 20) -> list[dict[str, Any]]:', - '\n\ndef safe_iso_filename(filename: str) -> str | None:', - ] - for marker in markers: - if marker in text: - text = text.replace(marker, helpers + marker, 1) - changed.append('log center helpers added') - break - else: - print('WARN: log helper marker not found, skip helper injection') - changed.append('log center helpers skipped') -else: - start = text.find('LOG_SOURCES = {') - end = text.find('\n}\n\n\ndef read_log_source', start) - if start != -1 and end != -1: - replacement = helpers.strip().split('\n\n\ndef read_log_source', 1)[0] - text = text[:start] + replacement + text[end + 3:] - changed.append('log sources refreshed without telegram') - else: - text = text.replace(' "telegram": {"title": "Telegram notifier", "kind": "file", "path": "/var/log/virtuality/telegram_version_bot.log"},\n', '') - changed.append('telegram log source removed') - -route = r''' - -@app.get("/logs", response_class=HTMLResponse) -def logs_page(request: Request, source: str = "web", lines: int = 220): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - selected = read_log_source(source, lines) - return templates.TemplateResponse("logs.html", { - "request": request, - "app_name": APP_NAME, - "user": AUTH_USER, - "sources": LOG_SOURCES, - "selected": selected, - }) - - -@app.get("/api/logs") -def api_logs(request: Request, source: str = "web", lines: int = 220): - if not get_current_user(request): - return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) - return {"ok": True, "log": read_log_source(source, lines)} -''' - -if '@app.get("/logs"' not in text: - markers = ['\n\n@app.get("/vm/create", response_class=HTMLResponse)', '\n\n@app.get("/api/operations")', '\n\n@app.get("/iso", response_class=HTMLResponse)'] - for marker in markers: - if marker in text: - text = text.replace(marker, route + marker, 1) - changed.append('logs routes added') - break - else: - print('WARN: logs route insert marker not found, skip route injection') - changed.append('logs routes skipped') -else: - changed.append('logs routes already present') - -app_path.write_text(text) - -sidebar_html = '''{% set path = request.url.path %} - -''' - -if templates_dir.exists(): - sidebar_path = templates_dir / '_sidebar.html' - sidebar_path.write_text(sidebar_html) - changed.append('_sidebar.html ensured') - - for path in sorted(templates_dir.glob('*.html')): - if path.name in {'login.html', 'console.html', '_sidebar.html'}: - continue - html = path.read_text() - if '' not in html: - html = html.replace('', ' \n', 1) - changed.append(f'{path.name} panel.js attached') - path.write_text(html) - -print('logs center and UI dynamics patch applied:') -for item in changed: - print(f'- {item}') diff --git a/scripts/patch_network_bridge_forwards.py b/scripts/patch_network_bridge_forwards.py deleted file mode 100644 index 93cf5bc..0000000 --- a/scripts/patch_network_bridge_forwards.py +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -core_path = app_path.with_name('network_core.py') -if not core_path.exists(): - raise SystemExit(f'network_core.py not found: {core_path}') - -text = core_path.read_text() -changed = [] - -if 'def route_interface_for_ip(' not in text: - marker = '''def external_interface() -> str: - result = run_cmd(['ip', 'route', 'show', 'default'], timeout=5) - if not result['ok']: - return 'eth0' - match = re.search(r'\\bdev\\s+([^\\s]+)', result['stdout']) - return match.group(1) if match else 'eth0' -''' - helper = marker + ''' - -def route_interface_for_ip(ip: str) -> str: - if not valid_ip(ip): - return NAT_BRIDGE - result = run_cmd(['ip', 'route', 'get', ip], timeout=5) - if not result['ok']: - return NAT_BRIDGE - match = re.search(r'\\bdev\\s+([^\\s]+)', result['stdout']) - return match.group(1) if match else NAT_BRIDGE - - -def forward_guest_interface(item: dict[str, Any]) -> str: - return route_interface_for_ip(str(item.get('guest_ip', ''))) -''' - if marker not in text: - raise SystemExit('external_interface marker not found') - text = text.replace(marker, helper, 1) - changed.append('route_interface_for_ip helper added') -else: - changed.append('route_interface_for_ip helper already present') - -old_render = '''def render_nft_rules() -> str: - ext = external_interface() - lines = [ - 'table ip virtuality {', - ' chain prerouting {', - ' type nat hook prerouting priority dstnat; policy accept;', - ] - for item in load_port_forwards(): - external_ports = nft_port_value(item['external_port_start'], item['external_port_end']) - guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end']) - lines.append(f" iifname \\"{ext}\\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}") - lines += [ - ' }', - ' chain postrouting {', - ' type nat hook postrouting priority srcnat; policy accept;', - f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade', - ' }', - ' chain forward {', - ' type filter hook forward priority filter; policy accept;', - f' ip saddr {NAT_SUBNET} accept', - f' ip daddr {NAT_SUBNET} accept', - ' }', - '}', - ] - return '\\n'.join(lines) + '\\n' -''' -new_render = '''def render_nft_rules() -> str: - ext = external_interface() - forwards = load_port_forwards() - lines = [ - 'table ip virtuality {', - ' chain prerouting {', - ' type nat hook prerouting priority dstnat; policy accept;', - ] - for item in forwards: - external_ports = nft_port_value(item['external_port_start'], item['external_port_end']) - guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end']) - lines.append(f" iifname \\"{ext}\\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}") - lines += [ - ' }', - ' chain postrouting {', - ' type nat hook postrouting priority srcnat; policy accept;', - f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade', - ] - for item in forwards: - guest_iface = forward_guest_interface(item) - lines.append(f' ip daddr {item["guest_ip"]} oifname "{guest_iface}" masquerade') - lines += [ - ' }', - ' chain forward {', - ' type filter hook forward priority filter; policy accept;', - f' ip saddr {NAT_SUBNET} accept', - f' ip daddr {NAT_SUBNET} accept', - ] - for item in forwards: - guest_iface = forward_guest_interface(item) - lines.append(f' iifname "{ext}" oifname "{guest_iface}" ip daddr {item["guest_ip"]} accept') - lines.append(f' iifname "{guest_iface}" oifname "{ext}" ip saddr {item["guest_ip"]} ct state established,related accept') - lines += [ - ' }', - '}', - ] - return '\\n'.join(lines) + '\\n' -''' -if old_render in text: - text = text.replace(old_render, new_render, 1) - changed.append('nft rules now support bridge/static VM interfaces') -elif 'forward_guest_interface(item)' in text and 'ip daddr {item["guest_ip"]}' in text: - changed.append('nft bridge/static rules already present') -else: - raise SystemExit('render_nft_rules marker not found') - -text = text.replace( - "'out', 'on', NAT_BRIDGE,\n 'to', item['guest_ip'],", - "'out', 'on', forward_guest_interface(item),\n 'to', item['guest_ip'],", -) -if "'out', 'on', forward_guest_interface(item)" in text: - changed.append('UFW route rules now use guest route interface') - -old_iptables_line = """ results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', NAT_BRIDGE, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT'])) - results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', NAT_BRIDGE, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT'])) - results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to])) -""" -new_iptables_line = """ guest_iface = forward_guest_interface(item) - results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', guest_iface, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT'])) - results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', guest_iface, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT'])) - results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to])) - results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-d', guest_ip, '-o', guest_iface, '-j', 'MASQUERADE'])) -""" -if old_iptables_line in text: - text = text.replace(old_iptables_line, new_iptables_line, 1) - changed.append('iptables fallback now supports bridge/static VM interfaces') -elif "guest_iface = forward_guest_interface(item)" in text and "'-o', guest_iface" in text: - changed.append('iptables bridge/static fallback already present') -else: - raise SystemExit('iptables fallback marker not found') - -text = text.replace( - "'watch_internal': f\"sudo tcpdump -ni {NAT_BRIDGE} 'host {vm_ip or ''} and {protocol} port {int(guest_port)}'\",", - "'watch_internal': f\"sudo tcpdump -ni {route_interface_for_ip(vm_ip) if vm_ip else NAT_BRIDGE} 'host {vm_ip or ''} and {protocol} port {int(guest_port)}'\",", -) - -core_path.write_text(text) -print('network bridge forward patch applied:') -for item in changed: - print(f'- {item}') diff --git a/scripts/patch_network_diagnostics.py b/scripts/patch_network_diagnostics.py deleted file mode 100644 index cc1fd8f..0000000 --- a/scripts/patch_network_diagnostics.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -if not app_path.exists(): - raise SystemExit(f'app.py not found: {app_path}') - -text = app_path.read_text() -if 'def network_diagnose(' in text: - print('network diagnostics handler already applied') - raise SystemExit(0) - -handler = ''' - -@app.post("/network/diagnose", response_class=HTMLResponse) -def network_diagnose(request: Request, vm_name: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp")): - auth_redirect = require_auth(request) - if auth_redirect: - return auth_redirect - diagnostics = None - error = None - try: - diagnostics = network_core.diagnose_public_access(vm_name, external_port, guest_port, protocol) - except NetworkError as exc: - error = str(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": error, - "diagnostics": diagnostics, - }, status_code=400 if error else 200) -''' - -marker = '\n\n@app.get("/operations", response_class=HTMLResponse)' -if marker not in text: - raise SystemExit('operations marker not found in app.py') -text = text.replace(marker, handler + marker, 1) -app_path.write_text(text) -print(f'network diagnostics handler applied: {app_path}') diff --git a/scripts/patch_network_manual_ip_field.py b/scripts/patch_network_manual_ip_field.py deleted file mode 100644 index 1ba459d..0000000 --- a/scripts/patch_network_manual_ip_field.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -template_path = app_path.with_name('templates') / 'network.html' -if not template_path.exists(): - raise SystemExit(f'network.html not found: {template_path}') - -text = template_path.read_text() -old = '' -new = '''''' - -if old in text: - text = text.replace(old, new, 1) - template_path.write_text(text) - print('network manual IP field added') -elif 'name="guest_ip" value="auto" placeholder="auto' in text: - print('network manual IP field already present') -else: - raise SystemExit('guest_ip marker not found in network.html') diff --git a/scripts/patch_network_nat_errors.py b/scripts/patch_network_nat_errors.py deleted file mode 100644 index e620703..0000000 --- a/scripts/patch_network_nat_errors.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -if not app_path.exists(): - raise SystemExit(f'app.py not found: {app_path}') - -text = app_path.read_text() -changed = [] - -helper = r''' - -def safe_network_template(request: Request, error: str | None = None, status_code: int = 200, diagnostics: dict[str, Any] | None = None): - try: - ctx = network_core.network_context() - except Exception as exc: - ctx = { - "nat": { - "name": network_core.NETWORK_NAME, - "bridge": network_core.NAT_BRIDGE, - "subnet": network_core.NAT_SUBNET, - "gateway": network_core.NAT_GATEWAY, - "dhcp": f"{network_core.DHCP_START} - {network_core.DHCP_END}", - "exists": False, - "info": str(exc), - "leases": "", - }, - "networks": [], - "forwards": [], - "external_interface": "unknown", - "ip_forward": "unknown", - "nft_rules": "", - } - error = error or f"Ошибка чтения сетевого состояния: {exc}" - return templates.TemplateResponse("network.html", { - "request": request, - "app_name": APP_NAME, - "user": AUTH_USER, - "vms": parse_virsh_list(), - "ctx": ctx, - "error": error, - "diagnostics": diagnostics, - }, status_code=status_code) -''' - -if 'def safe_network_template(' not in text: - marker = '\n\n@app.get("/network", response_class=HTMLResponse)' - if marker not in text: - raise SystemExit('network route marker not found') - text = text.replace(marker, helper + marker, 1) - changed.append('safe network template helper added') -else: - changed.append('safe network template helper already present') - -old_get = '''@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}) -''' -new_get = '''@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 safe_network_template(request, error=error) -''' -if old_get in text: - text = text.replace(old_get, new_get, 1) - changed.append('network page now uses safe template') -elif 'return safe_network_template(request, error=error)' in text: - changed.append('network page already safe') -else: - changed.append('network page replacement skipped') - -old_nat = '''@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) -''' -new_nat = '''@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 safe_network_template(request, error=str(exc), status_code=500) - except Exception as exc: - return safe_network_template(request, error=f"Внутренняя ошибка настройки NAT: {exc}", status_code=500) - return RedirectResponse(url="/network", status_code=303) -''' -if old_nat in text: - text = text.replace(old_nat, new_nat, 1) - changed.append('NAT setup catches all exceptions') -elif 'Внутренняя ошибка настройки NAT' in text: - changed.append('NAT setup already catches all exceptions') -else: - raise SystemExit('network nat setup route marker not found') - -replacements = { - '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 safe_network_template(request, error=str(exc), status_code=400)', - '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 safe_network_template(request, error=str(exc), status_code=500)', - '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), "diagnostics": None}, status_code=400)': 'return safe_network_template(request, error=str(exc), status_code=400)', - 'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": None, "diagnostics": diagnostics})': 'return safe_network_template(request, diagnostics=diagnostics)', -} -for old, new in replacements.items(): - if old in text: - text = text.replace(old, new) - changed.append('network error response hardened') - -app_path.write_text(text) -print('network NAT error patch applied:') -for item in changed: - print(f'- {item}') diff --git a/scripts/patch_network_ranges.py b/scripts/patch_network_ranges.py deleted file mode 100644 index 41d42fb..0000000 --- a/scripts/patch_network_ranges.py +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -if not app_path.exists(): - raise SystemExit(f'app.py not found: {app_path}') - -changed = [] - -text = app_path.read_text() -old_signature = '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("")):' -new_signature = 'def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: str = Form(...), guest_port: str = Form(...), protocol: str = Form("tcp"), note: str = Form("")):' -if old_signature in text: - text = text.replace(old_signature, new_signature, 1) - app_path.write_text(text) - changed.append('app.py forward/add accepts port ranges as text') -elif new_signature in text: - changed.append('app.py forward/add already accepts port ranges as text') -else: - raise SystemExit('network_forward_add signature marker not found') - -core_path = app_path.with_name('network_core.py') -if core_path.exists(): - core_text = core_path.read_text() - before = core_text - - if 'def iptables_dnat_port_value(' not in core_text: - marker = """def iptables_port_value(start: int, end: int) -> str: - return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}' - - -def port_label""" - replacement = """def iptables_port_value(start: int, end: int) -> str: - return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}' - - -def iptables_dnat_port_value(start: int, end: int) -> str: - return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}' - - -def successful_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]: - return [item for item in results if item.get('ok')] - - -def failed_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]: - return [item for item in results if not item.get('ok')] - - -def port_label""" - if marker not in core_text: - raise SystemExit('network_core.py port helper marker not found') - core_text = core_text.replace(marker, replacement, 1) - changed.append('network_core.py iptables DNAT range helper added') - else: - changed.append('network_core.py iptables DNAT range helper already present') - - core_text = core_text.replace( - 'guest_to = f"{guest_ip}:{iptables_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])}"', - 'guest_to_port = iptables_dnat_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"', - ) - core_text = core_text.replace( - 'guest_to_port = nft_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"', - 'guest_to_port = iptables_dnat_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"', - ) - - old_apply = """def apply_port_forwards() -> dict[str, Any]: - ensure_dirs() - enable_ip_forward() - disable_rp_filter() - items = load_port_forwards() - NFT_FILE.write_text(render_nft_rules()) - run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8) - result = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15) - if not result['ok']: - raise NetworkError(result['stderr'] or result['stdout'] or 'Не удалось применить nftables-правила') - ufw_results = apply_ufw_route_rules(items) - iptables_results = apply_iptables_fallback(items) - return { - 'ok': True, - 'file': str(NFT_FILE), - 'rules': render_nft_rules(), - 'ufw': ufw_results, - 'iptables': iptables_results, - } -""" - new_apply = """def apply_port_forwards() -> dict[str, Any]: - ensure_dirs() - enable_ip_forward() - disable_rp_filter() - items = load_port_forwards() - NFT_FILE.write_text(render_nft_rules()) - - nft_delete = run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8) - nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15) - ufw_results = apply_ufw_route_rules(items) - iptables_results = apply_iptables_fallback(items) - - iptables_ok = bool(successful_results(iptables_results)) or not items - if not nft_apply['ok'] and not iptables_ok: - details = [ - nft_apply.get('stderr') or nft_apply.get('stdout') or 'nftables не применился', - *[ - item.get('stderr') or item.get('stdout') or item.get('cmd', 'iptables rule failed') - for item in failed_results(iptables_results) - ], - ] - raise NetworkError('Не удалось применить правила проброса: ' + ' | '.join([d for d in details if d])) - - return { - 'ok': nft_apply['ok'] or iptables_ok, - 'file': str(NFT_FILE), - 'rules': render_nft_rules(), - 'nft_delete': nft_delete, - 'nft_apply': nft_apply, - 'ufw': ufw_results, - 'iptables': iptables_results, - } -""" - if old_apply in core_text: - core_text = core_text.replace(old_apply, new_apply, 1) - changed.append('network_core.py apply_port_forwards now falls back to iptables when nft fails') - elif 'nft_apply = run_cmd([\'nft\', \'-f\', str(NFT_FILE)]' in core_text or "nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)]" in core_text: - changed.append('network_core.py apply_port_forwards fallback already present') - else: - raise SystemExit('network_core.py apply_port_forwards marker not found') - - core_text = core_text.replace( - 'f"{vm_ip}:{matching_forward[\'guest_port_label\'].replace(\'-\', \':\')}" in ipt_nat_text', - '(f"{vm_ip}:{matching_forward[\'guest_port_label\']}" in ipt_nat_text or f"{vm_ip}:{matching_forward[\'guest_port_label\'].replace(\'-\', \':\')}" in ipt_nat_text)', - ) - - if core_text != before: - core_path.write_text(core_text) - changed.append('network_core.py port forwarding rules patched') - else: - changed.append('network_core.py port forwarding rules already ok') -else: - changed.append(f'network_core.py not found near {app_path}') - -print('network ranges patch applied:') -for item in changed: - print(f'- {item}') diff --git a/scripts/patch_remove_legacy_boot_order_card.py b/scripts/patch_remove_legacy_boot_order_card.py deleted file mode 100644 index e5c67a9..0000000 --- a/scripts/patch_remove_legacy_boot_order_card.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import re -import sys - -app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py') -app_dir = app_path.resolve().parent if app_path.exists() else Path('/opt/virtuality/web') -template_path = app_dir / 'templates' / 'vm_detail.html' -changed = [] - -if not template_path.exists(): - print(f'legacy boot order cleanup skipped: vm_detail.html not found: {template_path}') - raise SystemExit(0) - -tpl = template_path.read_text() -original = tpl - -legacy_pattern = re.compile( - r'\n\s*
\s*\n' - r'\s*
\s*\n' - r'\s*

Порядок загрузки

\s*\n' - r'\s*boot order\s*\n' - r'.*?' - r'\s*
\s*\n', - re.DOTALL, -) - -tpl, count = legacy_pattern.subn('\n', tpl) -if count: - changed.append(f'legacy select boot-order card removed: {count}') - -# Safety cleanup for any accidental duplicated plain select card without the old pill text. -plain_select_pattern = re.compile( - r'\n\s*
\s*\n' - r'\s*
\s*\n' - r'\s*

Порядок загрузки

.*?' - r' + {{ icon('cloud-upload', 'icon-lg') }} + {{ title }} + {{ hint }} + + + +
+ + +
+ +{%- endmacro %} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..5364d58 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,94 @@ +{% from "_ui.html" import icon, logo, alert %} +{%- set path = request.url.path -%} +{%- macro nav_link(href, icon_name, label, active, extra='') -%} +{{ icon(icon_name) }}{{ label }}{{ extra|safe }} +{%- endmacro -%} + + + + + + + {% block title %}{{ page_title|default('Панель') }}{% endblock %} — Virtuality + + + + + {% block head %}{% endblock %} + + + {%- set notice = update_notice() -%} +
+ + +
+
+ + + +
+ +
+ {% block page_header %}{% endblock %} +
{% block alerts %}{% if error %}{{ alert('danger', error) }}{% endif %}{% endblock %}
+ {% block content %}{% endblock %} +
+
+
+ + +
+
+
{{ icon('triangle-alert', 'icon-lg') }}
+

Подтвердите действие

+
+
+ + +
+
+
+
+ + {% block scripts %}{% endblock %} + + diff --git a/web/templates/console.html b/web/templates/console.html index 84ea592..dba2221 100644 --- a/web/templates/console.html +++ b/web/templates/console.html @@ -1,48 +1,37 @@ +{% from "_ui.html" import icon, logo, vm_badge %} - + - Консоль {{ vm.name }} — {{ app_name }} - + Экран {{ vm.name }} — Virtuality + - -
-
-
- {{ vm.name }} - VNC {{ console.display }}{% if console.port %} · port {{ console.port }}{% endif %} + +
+
+ {{ logo() }} + {{ vm.name }} + {{ vm_badge(vm.info.state) }} +
+
+ Чтобы вернуть курсор, нажмите Ctrl+Alt + {{ icon('settings', 'icon-sm') }}Настройки машины +
+
+ {% if console.url %} + + {% else %} +
+
+
+
{{ icon('monitor', 'icon-xl') }}
+

Экран недоступен

+

{% if not console.has_novnc %}На сервере не установлен компонент noVNC. Переустановите панель: sudo bash scripts/install_web_panel.sh{% else %}Машина выключена или ещё загружается. Запустите её и обновите страницу.{% endif %}

+
К машине
+
-
- {{ 'connected ready' if console.url else 'not ready' }} - VM - Дашборд -
-
- - {% if console.url %} -
- -
- {% else %} -
-
-
-

Консоль пока недоступна

- not ready -
-
-
noVNC path{{ console.novnc_dir }}
-
VNC display{{ console.display }}
-
VNC port{{ console.port or 'not available' }}
-
VM IP{{ vm.ip }}
-
-
- Проверь, что VM запущена, у неё есть VNC display, а пакет novnc установлен. После обновления web-панели установщик должен поставить novnc автоматически. -
-
-
- {% endif %} -
+ + {% endif %} diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 536cf42..45ab270 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -1,190 +1,163 @@ - - - - - - {{ app_name }} Panel - - - -
- {% include "_sidebar.html" %} +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, vm_badge, op_badge, meter, alert %} +{% set page_title = 'Обзор' %} +{% set running = vms|selectattr('state', 'in', ['running', 'idle', 'blocked'])|list|length %} -
-
-
-
Обзор
-
{{ system.hostname }} · {{ system.ip }} · {{ system.time }}
-
-
- user: {{ user }} -
-
-
- - {% if update_notice and update_notice.has_update %} -
-
-
Доступно обновление Virtuality
-
{{ update_notice.current_version }} → {{ update_notice.latest_version }}
-
Пропущено версий: {{ update_notice.missing_count }} · новых коммитов: {{ update_notice.commit_count }}
-
- -
- {% endif %} - -
-
-
Uptime
-
{{ system.uptime }}
-
-
-
Load
-
{{ system.load }}
-
-
-
Kernel
-
{{ system.kernel }}
-
-
-
VM
-
{{ vms|length }}
-
-
- -
-
-

Виртуальные машины

- -
- - - - - - {% for vm in vms %} - {% set is_running = 'running' in vm.state %} - - - - - - - - - {% else %} - - {% endfor %} - -
IDNameIPStateAutostartActions
{{ vm.id }}{{ vm.name }}{{ vm.ip|default('—') }}{{ vm.state }} - {% if vm.autostart_enabled %} -
- -
- {% else %} -
- -
- {% endif %} -
-
- ℹДетали - ▣Консоль - {% if is_running %} -
-
-
- {% else %} -
-
- {% endif %} -
-
Виртуальных машин пока нет
-
- -
-
-
-

Сервисы

- systemd -
-
- {% for name, state in services.items() %} -
- {{ name }} - {{ state }} -
- {% endfor %} -
-
- -
-
-

Storage pools

- libvirt -
- - - - - - {% for pool in pools %} - - - - - - {% else %} - - {% endfor %} - -
NameStateAutostart
{{ pool.name }}{{ pool.state }}{{ pool.autostart }}
Storage pools не найдены
-
- - -
- -
-
-
-

Интерфейсы

- ip -br a -
-
{{ network.interfaces }}
-
-
-
-

Маршруты

- ip route -
-
{{ network.routes }}
-
-
-
+{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% set notice = update_notice() %} + {% if notice.has_update %} +
{{ icon('sparkles') }}
Доступна новая версия {{ notice.latest_version }}Обновление займёт пару минут, машины продолжат работать.
Посмотреть
+ {% endif %} + {% if profile.virtualization_warning %} + {{ alert('warning', 'Машины будут работать медленно. Включите аппаратную виртуализацию (Intel VT-x / AMD-V) в настройках BIOS сервера.', 'Аппаратное ускорение недоступно') }} + {% endif %} +{% endblock %} + +{% block content %} +
+
+
Машины{{ icon('monitor', 'icon-sm') }}
+
{{ running }}/ {{ vms|length }} работают
+ {{ meter(((running / vms|length * 100) if vms else 0)|round|int, 'brand') }} +
+
+
Процессор{{ icon('cpu', 'icon-sm') }}
+
{{ host.cpu_pct }}%
+ {{ meter(host.cpu_pct) }} +
{{ host.cpu_count }} {{ plural(host.cpu_count, 'ядро', 'ядра', 'ядер') }}
+
+
+
Память{{ icon('memory-stick', 'icon-sm') }}
+
{{ host.mem_used }}из {{ host.mem_total }}
+ {{ meter(host.mem_pct) }} +
Занято {{ host.mem_pct }}%
+
+
+
Хранилище{{ icon('hard-drive', 'icon-sm') }}
+
{{ host.disk_free }}свободно
+ {{ meter(host.disk_pct) }} +
Всего {{ host.disk_total }}
+
+
+ +{% if vms %} +
+
+

Виртуальные машины

Нажмите на машину, чтобы открыть её настройки

+ {{ icon('plus', 'icon-sm') }}Новая +
+
+ {% for vm in vms %} + {% set s = vm.state|vm_state %} +
+ + {{ icon('monitor') }} + + {{ vm.name }} + {{ vm.ip if vm.ip and vm.ip != '—' else ('IP появится после запуска' if not s.running else 'Получает адрес…') }} + + +
+
Ресурсы
+
{{ vm.vcpus or '—' }} {{ plural(vm.vcpus or 0, 'ядро', 'ядра', 'ядер') }} · {{ vm.memory_mb|format_mb }}
+
+
{{ vm_badge(vm.state) }}
+
+ {% if s.running %} + {{ icon('monitor', 'icon-sm') }}Экран + {% else %} +
+ {% endif %} + +
+
+ {% endfor %} +
+
+{% else %} +
+
+
+ {{ icon('sparkles', 'icon-sm') }}Начало работы +

Создайте первую виртуальную машину

+

Три шага: загрузите установочный образ системы, создайте машину и установите систему прямо в браузере.

+
+
+ +
+{% endif %} + +
+
+

Последние задачи

Создание машин, загрузка и конвертация образов

Все{{ icon('chevron-right', 'icon-sm') }}
+
+ {% for op in operations %} + {% set os = op.status|operation_state %} + + {{ icon('circle-check' if op.status == 'success' else 'circle-x' if op.status == 'error' else 'clock') }} +
{{ op.title }}
{{ op.created_at }} · {{ op.message }}
+ {{ op_badge(op.status) }} +
+ {% else %} +

Задач пока не было. Здесь появится прогресс создания машин и загрузки образов.

+ {% endfor %} +
+
+ +
+
+

Состояние сервера

Службы, от которых зависит работа машин

+ {% if services_ok %}{{ badge('Всё в порядке', 'success') }}{% else %}{{ badge('Требует внимания', 'danger') }}{% endif %} +
+
+ {% for svc in service_rows %} +
+ {{ icon('circle-check' if svc.tone == 'success' else 'triangle-alert' if svc.tone == 'warning' else 'circle-dot' if svc.tone == 'neutral' else 'circle-x') }} +
{{ svc.name }}{{ svc.label }}
+
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/web/templates/disk_images.html b/web/templates/disk_images.html index 220c269..146a16b 100644 --- a/web/templates/disk_images.html +++ b/web/templates/disk_images.html @@ -1,329 +1,44 @@ - - - - - - Диски — {{ app_name }} - - - -
- {% include "_sidebar.html" %} +{% extends "base.html" %} +{% from "_ui.html" import icon, uploader, empty %} +{% set page_title = 'Готовые системы' %} -
-
-
-
Диски
-
Готовые образы установленных систем: .img / .raw / .qcow2 / .img.xz, а также архивы .zip / .tar.gz / .tgz
-
-
- user: {{ user }} - ← Дашборд -
-
-
+{% block page_header %}{% include "_images_header.html" %}{% endblock %} - {% if error %} -
{{ error }}
- {% endif %} - -
-
-
-

Загрузить образ установленной системы

- .img .raw .qcow2 .img.xz .zip .tar.gz +{% block content %} +
+
+

Готовые системы

Образы уже установленных систем — машина запускается сразу, без установки

+ {% if images %} +
+ {% for image in images %} +
+ {{ icon('hard-drive') }} +
{{ image.name }}
{{ image.size|replace('GB', 'ГБ') }} · формат {{ image.format }} · {{ image.updated }}
+ Создать машину +
+ +
-
- -
Файл ещё не выбран. Разрешены .img, .raw, .qcow2, .img.xz, .zip, .tar.gz и .tgz.
- - - -
-

Архив или .img.xz будет распакован автоматически. Virtuality найдёт внутри .img/.raw/.qcow2 или распакует .img.xz и положит результат в /var/lib/virtuality/disk-images. После загрузки .img/.raw/.img.xz запускается конвертация в qcow2. Установочные .iso загружаются отдельно в разделе ISO.

-
+ {% endfor %} +
+ {% else %} + {{ empty('hard-drive', 'Готовых образов пока нет', 'Подойдут облачные образы Ubuntu и Debian, образы для Raspberry Pi, диски других систем виртуализации.') }} + {% endif %} +
-
-
-

Как использовать

- import disk -
-
-
- Raspberry / Orange Pi images - Загрузи .img, .img.xz или архив с .img, дождись распаковки/конвертации, затем в “Создать VM” выбери режим “Готовый диск”. -
-
- Архивы безопасно распаковываются - Поддерживаются .zip, .tar.gz, .tgz и .img.xz. Из обычных архивов берём только .img/.raw/.qcow2. -
-
-
qemu-img convert -p -f raw -O qcow2 source.img source.qcow2
-
-
- -
-
-

Локальные образы дисков

- {{ images|length }} файлов -
- - - - - - {% for image in images %} - - - - - - - - {% else %} - - {% endfor %} - -
ФайлТипРазмерОбновлёнДействия
{{ image.name }}{{ image.format }}{{ image.size }}{{ image.updated }} - Создать VM -
- -
-
Образов пока нет. Загрузи .img, .raw, .qcow2, .img.xz или архив .zip/.tar.gz/.tgz.
-
-
-
- - - - - + +
+{% endblock %} diff --git a/web/templates/help.html b/web/templates/help.html new file mode 100644 index 0000000..ac012a7 --- /dev/null +++ b/web/templates/help.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon %} +{% set page_title = 'Помощь' %} + +{% block page_header %} + +{% endblock %} + +{% block content %} +
+
+
{{ icon('plus') }}

Как создать машину

+
    +
  1. Скачайте установочный образ системы (.iso) на сайте разработчика — например, Ubuntu Server.
  2. +
  3. Откройте Образы ОС и перетащите файл в окно загрузки.
  4. +
  5. Нажмите Создать машину, выберите образ и конфигурацию.
  6. +
  7. Когда задача завершится, нажмите «Открыть экран» и установите систему как на обычный компьютер.
  8. +
+
+ +
+
{{ icon('globe') }}

Как открыть сайт или SSH из интернета

+
    +
  1. Откройте Сеть и доступ.
  2. +
  3. В блоке «Открыть доступ» выберите машину и сервис, например «Сайт (HTTPS)».
  4. +
  5. Нажмите «Открыть доступ». Теперь адрес {{ host_ip }}:порт ведёт в машину.
  6. +
  7. Если не открывается — нажмите «Проверить» рядом с правилом: панель подскажет, в чём проблема.
  8. +
+
+ +
+
{{ icon('monitor') }}

Экран машины

+
    +
  • Экран открывается в новой вкладке браузера — это как монитор, подключённый к машине.
  • +
  • Если курсор «застрял» внутри экрана, нажмите Ctrl + Alt.
  • +
  • После установки системы извлеките установочный диск на странице машины → «Диски и загрузка».
  • +
+
+ +
+
{{ icon('power') }}

Выключение и автозапуск

+
    +
  • Выключить — аккуратно завершает работу системы, как кнопка питания.
  • +
  • Выключить принудительно — мгновенно, как выдернуть шнур. Используйте, только если машина зависла.
  • +
  • Автозапуск — машина сама включится после перезагрузки сервера.
  • +
+
+ +
+
{{ icon('cpu') }}

Сколько ресурсов выделять

+
    +
  • Мини (1 ядро, 1 ГБ) — VPN, роутер, небольшие сервисы.
  • +
  • Стандарт (2 ядра, 4 ГБ) — сайт, база данных, Linux-сервер.
  • +
  • Мощная (4 ядра, 8 ГБ) — Windows, 1С, игровые серверы.
  • +
  • Процессор и память можно поменять позже, когда машина выключена.
  • +
+
+ +
+
{{ icon('wrench') }}

Если что-то не работает

+
    +
  • Проверьте раздел Сервер — там видно, чего не хватает.
  • +
  • Посмотрите Задачи — у каждой есть подробный журнал.
  • +
  • Полная диагностика в терминале сервера: sudo vhealth.
  • +
  • Расширенное управление доступно в Cockpit: https://{{ host_ip }}:9090.
  • +
+
+
+{% endblock %} diff --git a/web/templates/host.html b/web/templates/host.html index 0406140..dd66593 100644 --- a/web/templates/host.html +++ b/web/templates/host.html @@ -1,96 +1,56 @@ - - - - - - Профиль хоста — {{ app_name }} - - - -
- {% include "_sidebar.html" %} +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, alert %} +{% set page_title = 'Сервер' %} +{% set problems = profile.checks|selectattr('level', 'ne', 'ok')|list %} -
-
-
-
Профиль хоста
-
Автоматически выбранная сборка Virtuality
-
-
- user: {{ user }} - ← Дашборд -
-
-
- - {% if profile.virtualization_warning %} -
{{ profile.virtualization_warning }}
- {% endif %} - -
-
-
Profile
-
{{ profile.label }}
-
-
-
Arch
-
{{ profile.arch }}
-
-
-
Virtualization
-
{{ profile.virtualization_mode|default('unknown') }}
-
-
-
Default network
-
{{ profile.recommended_network }}
-
-
- -
-
-
-

Модель устройства

- auto-detected -
-
-
Model{{ profile.model }}
-
VM mode{{ profile.recommended_vm_mode }}
-
Ready{{ 'yes' if profile.ready else 'needs check' }}
-
-
- На физическом сервере для нормальной скорости нужен KVM: включи Intel VT-x / AMD-V в BIOS/UEFI. Если KVM недоступен, Virtuality может использовать QEMU fallback, но VM будут работать медленно. -
-
- -
-
-

Проверки

-
-
-
- {% for check in profile.checks %} - {% set level = check.level|default('ok' if check.ok else 'err') %} -
- - {{ check.name }} - {{ check.hint }} - - {{ 'OK' if level == 'ok' else 'WARN' if level == 'warn' else 'FAIL' }} -
- {% endfor %} -
-
-
- -
-
-

Raw profile

- /var/lib/virtuality/config/host_profile.json -
-
{{ profile_json }}
-
-
+{% block page_header %} + +{% endblock %} + +{% block content %} +
+
+
+
+

Готовность

{{ 'Всё готово для запуска машин' if not problems else 'Есть замечания — ниже написано, что сделать' }}

+ {% if profile.ready and not problems %}{{ badge('Готов', 'success') }}{% elif profile.ready %}{{ badge('Работает с замечаниями', 'warning') }}{% else %}{{ badge('Не готов', 'danger') }}{% endif %} +
+
+ {% for check in profile.checks %} + {% set level = check.level|default('ok' if check.ok else 'err') %} + {% set tone = 'success' if level == 'ok' else 'warning' if level == 'warn' else 'danger' %} +
+ {{ icon('circle-check' if tone == 'success' else 'triangle-alert' if tone == 'warning' else 'circle-x') }} +
{{ check.name }}{% if tone != 'success' %}{{ check.hint }}{% endif %}
+
+ {% endfor %} +
+
+
+ + +
+{% endblock %} diff --git a/web/templates/iso.html b/web/templates/iso.html index a671a63..4e0edda 100644 --- a/web/templates/iso.html +++ b/web/templates/iso.html @@ -1,272 +1,51 @@ - - - - - - ISO — {{ app_name }} - - - -
- {% include "_sidebar.html" %} +{% extends "base.html" %} +{% from "_ui.html" import icon, uploader, empty %} +{% set page_title = 'Образы ОС' %} -
-
-
-
ISO
-
Установочные образы только .iso в /var/lib/virtuality/iso
-
-
- user: {{ user }} - ← Дашборд -
-
-
+{% block page_header %}{% include "_images_header.html" %}{% endblock %} - {% if error %} -
{{ error }}
- {% endif %} - -
-
-
-

Загрузить установочный ISO

- только .iso +{% block content %} +
+
+
+

Установочные образы

Файлы .iso для установки системы с нуля

+
+
+ {% if isos %} +
+ {% for iso in isos %} +
+ {{ icon('disc-3') }} +
{{ iso.name }}
{{ iso.size|replace('MB', 'МБ') }} · загружен {{ iso.updated }}
+ Создать машину +
+ +
-
- -
Файл ещё не выбран. В этом разделе разрешены только установочные образы .iso.
- - - -
-

Файл попадёт в /var/lib/virtuality/iso. Для готовых системных дисков используй раздел Диски: .img / .raw / .qcow2.

-
+ {% endfor %} +
+ {% else %} + {{ empty('disc-3', 'Образов пока нет', 'Скачайте установочный образ нужной системы на сайте разработчика и загрузите его сюда.') }} + {% endif %} +
-
-
-

Действия

- pool -
-
-
- Создать VM -
-
sudo virsh pool-refresh virtuality-iso
-ls -lh /var/lib/virtuality/iso
-
-
- -
-
-

ISO-образы

- {{ isos|length }} файлов -
- - - - - - {% for iso in isos %} - - - - - - - {% else %} - - {% endfor %} - -
ФайлРазмерОбновлёнДействия
{{ iso.name }}{{ iso.size }}{{ iso.updated }} -
- -
-
ISO пока нет. Загрузи первый установочный .iso через форму выше.
-
-
-
- - - - - + +
+{% endblock %} diff --git a/web/templates/login.html b/web/templates/login.html index 3b7841a..144c27e 100644 --- a/web/templates/login.html +++ b/web/templates/login.html @@ -1,43 +1,72 @@ +{% from "_ui.html" import icon, logo, alert %} - + - {{ app_name }} Login + + Вход — Virtuality - - + + - -
-
-
Virtuality
-

Вход в панель управления виртуализацией

- - {% if not configured %} -
Панель ещё не настроена. Повтори установку веб-панели.
- {% endif %} - - {% if error %} -
{{ error }}
- {% endif %} - -
- - - -
- -

Используется пароль системного пользователя Linux. Пароль не хранится в Virtuality.

+ +
+
+ +
+

Ваши серверы.
Под полным контролем.

+

Создавайте виртуальные машины, открывайте к ним доступ из интернета и следите за состоянием сервера — всё в одном окне, без командной строки.

+
+
{{ icon('zap') }}Новая машина за пару минут
+
{{ icon('monitor') }}Экран машины прямо в браузере
+
{{ icon('shield-check') }}Работает на вашем железе, данные у вас
+
+
+
Virtuality {{ app_version }} · KVM · QEMU · libvirt
-
+ +
+
+ +
+

Вход в панель

+

Используйте имя и пароль пользователя этого сервера.

+
+ + {% if not configured %} + {{ alert('warning', 'Панель ещё не настроена. Запустите установку web-панели повторно.') }} + {% endif %} + {% if error %} + {{ alert('danger', error) }} + {% endif %} + +
+ + +
+
+ + +
+ +

{{ icon('lock', 'icon-sm') }} Пароль проверяется системой сервера и нигде не сохраняется.

+
+
+
+ diff --git a/web/templates/logs.html b/web/templates/logs.html index 287bcfa..02f6a23 100644 --- a/web/templates/logs.html +++ b/web/templates/logs.html @@ -1,92 +1,34 @@ - - - - - - Журналы — {{ app_name }} - - - -
- +{% extends "base.html" %} +{% from "_ui.html" import icon %} +{% set page_title = 'Журналы' %} -
-
-
-
Журналы
-
systemd, libvirt, update, install и операции Virtuality
-
-
- user: {{ user }} - ← Дашборд -
-
-
- -
- {% for key, source in sources.items() %} - -
Источник
-
{{ source.title }}
-
- {% endfor %} -
- -
-
-
-

{{ selected.title }}

-
{{ selected.cmd }}
-
-
- 120 - 300 - 1000 - -
-
-
{{ selected.content }}
-
-
+{% block page_header %} + +{% endblock %} - - - - +{% block content %} + +
+
+

{{ selected.title }}

{{ selected.cmd }}

+
+ +
+ {% for n in (120, 300, 1000) %}{{ n }}{% endfor %} +
+
+
+
+
{{ selected.content }}
+
+
+{% endblock %} diff --git a/web/templates/network.html b/web/templates/network.html index 9ec1a42..0e3fb71 100644 --- a/web/templates/network.html +++ b/web/templates/network.html @@ -1,271 +1,185 @@ - - - - - - Сеть — {{ app_name }} - - - -
- {% include "_sidebar.html" %} +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, alert, empty %} +{% set page_title = 'Сеть и доступ' %} -
-
-
-
Сеть
-
NAT Router для VPS, проброс одиночных портов и диапазонов к VM
-
-
- user: {{ user }} - ← Дашборд -
-
-
- - {% if error %} -
{{ error }}
- {% endif %} - -
-
-
NAT network
-
{{ ctx.nat.name }}
- {{ 'ready' if ctx.nat.exists else 'not created' }} -
-
-
Subnet
-
{{ ctx.nat.subnet }}
-
-
-
External interface
-
{{ ctx.external_interface }}
-
-
-
IPv4 forward
-
{{ ctx.ip_forward }}
-
-
- -
-
-
-

Virtuality NAT Router

- {{ ctx.nat.bridge }} -
-
-
Gateway{{ ctx.nat.gateway }}
-
DHCP{{ ctx.nat.dhcp }}
-
Bridge{{ ctx.nat.bridge }}
-
-
- -
-
{{ ctx.nat.info }}
-
- -
-
-

DHCP leases

- virsh net-dhcp-leases -
-
{{ ctx.nat.leases }}
-
IP для проброса можно оставить auto или ввести вручную, если DHCP leases пустые, у VM статический IP, bridge-сеть или нет qemu-guest-agent.
-
-
- -
-
-
-

Добавить проброс порта

- DNAT auto/manual IP · ranges -
-
- - - - - - -
Можно указать одиночный порт или диапазон. Если авто-IP не определяется, впиши IP VM вручную. Для VoIP/RTP обычно: внешний 10000-20000 → внутренний 10000-20000, протокол udp.
- -
-
- -
-
-

Диагностика публичного доступа

- VM → Internet -
-
- -
- - -
- - -
-
-
- - {% if diagnostics %} -
-
-

Результат диагностики

- {{ diagnostics.vm_name }} · {{ diagnostics.protocol }} {{ diagnostics.external_port }} → {{ diagnostics.guest_port }} -
-
{{ diagnostics.verdict }}
-
-
VM IP{{ diagnostics.vm_ip or 'not found' }}
-
External interface{{ diagnostics.external_interface }}
-
Mapping{{ diagnostics.external_port }} → {{ diagnostics.guest_port }}
-
- - - - {% for check in diagnostics.checks %} - - - - - - {% endfor %} - -
ПроверкаСтатусДетали
{{ check.name }}{{ 'OK' if check.ok else 'FAIL' }}{{ check.detail }}
-
-
Проверить входящий трафик
{{ diagnostics.commands.watch_external }}
-
Проверить трафик к VM
{{ diagnostics.commands.watch_internal }}
-
-
- {% endif %} - -
-
-

Пробросы портов

-
-
- - - - - - {% for item in ctx.forwards %} - - - - - - - - - {% else %} - - {% endfor %} - -
ПробросVMВнутренний адресПротоколОписаниеДействия
- {{ item.external_port_label|default(item.external_port) }} → {{ item.guest_port_label|default(item.guest_port) }} - {% if item.is_range %}range{% endif %} - {{ item.vm_name }}{{ item.guest_ip }}{{ item.protocol }}{{ item.note }} -
-
- - - - - -
-
-
-
Пробросов пока нет
-
- -
-
-

nftables preview

- /etc/virtuality/nftables/virtuality.nft -
-
{{ ctx.nft_rules }}
-
-
+{% block page_header %} + +{% endblock %} + +{% block content %} +{% if diagnostics %} + {% set failed = diagnostics.checks|rejectattr('ok')|list %} +
+
+

Проверка доступа: {{ diagnostics.vm_name }}, порт {{ diagnostics.external_port }}

{{ diagnostics.protocol|upper }} {{ diagnostics.external_port }} → {{ diagnostics.vm_ip or 'IP не найден' }}:{{ diagnostics.guest_port }}

+ {% if failed %}{{ badge('Есть проблемы: ' ~ failed|length, 'warning') }}{% else %}{{ badge('Всё в порядке', 'success') }}{% endif %} +
+
+ {{ alert('warning' if failed else 'success', diagnostics.verdict) }} +
+
+ {% for check in diagnostics.checks %} +
{{ icon('circle-check' if check.ok else 'circle-x') }}
{{ check.name }}{{ check.detail|truncate(160) }}
+ {% endfor %} +
+ +
+{% endif %} + +
+
+
+

Доступ из интернета

Порты сервера, которые ведут внутрь машин

+ {% if ctx.forwards %}
{% endif %} +
+ {% if ctx.forwards %} +
+ {% for item in ctx.forwards %} + {% set service = item.note or (item.guest_port|port_service) or ('Порт ' ~ item.guest_port_label) %} +
+ {{ icon('globe') }} +
+
{{ service }}
+
Сервер : {{ item.external_port_label }} → {{ item.vm_name }} : {{ item.guest_port_label }} · {{ item.protocol|upper }}{% if item.is_range %} · диапазон{% endif %}
+
+
+ + +
+
+ +
+
+ {% endfor %} +
+ {% else %} + {{ empty('shield-check', 'Всё закрыто', 'Машины выходят в интернет, но снаружи к ним подключиться нельзя. Откройте нужный порт в форме справа.') }} + {% endif %} +
+ +
+

Открыть доступ

Сделайте сервис машины доступным снаружи

+ {% if vms %} +
+
+
+ + +
+
+ + +
+
+
+
+
+
+ +
+ + +
+
+
+ {{ icon('wrench', 'icon-sm') }}Дополнительно{{ icon('chevron-down', 'icon-sm') }} +
+
«auto» — определить автоматически
+
+
+
+
+ +
+ {% else %} + + {% endif %} +
+
+ +
+
+
+

Внутренняя сеть машин

Через неё машины выходят в интернет

+ {% if ctx.nat.exists %}{{ badge('Работает', 'success') }}{% else %}{{ badge('Не создана', 'warning') }}{% endif %} +
+
+
+
Адреса машин{{ ctx.nat.subnet }}
+
Шлюз{{ ctx.nat.gateway }}
+
Выход в интернет{{ ctx.external_interface }}
+
Маршрутизация{{ 'Включена' if ctx.ip_forward == 'enabled' else 'Выключена' }}
+
+
+
+
+ +
+

Адреса машин

Если адрес не определился сам, укажите его вручную

+
+ {% for vm in vms %} +
+ {{ icon('monitor') }} +
{{ vm.name }}
{{ vm.ip if vm.ip and vm.ip != '—' else 'адрес не определён' }}{% if vm.manual_ip %} · задан вручную{% endif %}
+ + + +
+ {% else %} +
Машин пока нет
+ {% endfor %} +
+
+
+ +
+ {{ icon('terminal', 'icon-sm') }}Для специалистов: DHCP и правила nftables{{ icon('chevron-down', 'icon-sm') }} +
+
virsh net-dhcp-leases {{ ctx.nat.name }}
{{ ctx.nat.leases or '—' }}
+
/etc/virtuality/nftables/virtuality.nft
{{ ctx.nft_rules }}
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/operation_detail.html b/web/templates/operation_detail.html index 71b7c13..784430b 100644 --- a/web/templates/operation_detail.html +++ b/web/templates/operation_detail.html @@ -1,170 +1,63 @@ - - - - - - {{ operation.title }} — {{ app_name }} - - - -
-
-
-
{{ operation.title }}
-
Фоновая операция Virtuality
-
-
- user: {{ user }} - Операции - ← Дашборд -
-
-
+{% extends "base.html" %} +{% from "_ui.html" import icon, op_badge %} +{% set page_title = operation.title %} +{% set done = operation.status in ('success', 'error') %} -
-
-
-

Состояние

- {{ operation.status }} -
- -
-
-
Прогресс
-
-
{{ operation.progress }}%
-
-
-
Сообщение
-
{{ operation.message }}
-
-
- -
-
VM{{ operation.vm_name }}
-
RAM{{ operation.memory }} MB
-
CPU{{ operation.vcpus }}
-
Disk{{ operation.disk_size }} GB
-
Bridge{{ operation.bridge }}
-
Created{{ operation.created_at }}
-
-
- -
-
-

Команда

- virt-install -
-
{{ operation.cmd }}
-
-
- -
-
-

Журнал virt-install

- live -
-
{{ operation.log_tail }}
-
- - +{% block page_header %} + +{% endblock %} + +{% block content %} +
+
+ + +
+
Журнал выполнения
+
{{ operation.log_tail or 'Ожидаем вывод…' }}
+
+
+ + +
+{% endblock %} diff --git a/web/templates/operations.html b/web/templates/operations.html index f7f77e0..cc33a8a 100644 --- a/web/templates/operations.html +++ b/web/templates/operations.html @@ -1,56 +1,38 @@ - - - - - - Операции — {{ app_name }} - - - -
- {% include "_sidebar.html" %} +{% extends "base.html" %} +{% from "_ui.html" import icon, op_badge, empty %} +{% set page_title = 'Задачи' %} -
-
-
-
Операции
-
Журнал фоновых действий Virtuality
-
-
- user: {{ user }} - ← Дашборд -
-
-
- -
-
-

Последние операции

- /var/log/virtuality/operations -
- - - - - - {% for operation in operations %} - - - - - - - {% else %} - - {% endfor %} - -
ОперацияСтатусСозданаДействие
-
{{ operation.title }}
-
{{ operation.message }}
-
{{ operation.status }}{{ operation.created_at }}Открыть
Журнал операций пуст
-
-
+{% block page_header %} + +{% endblock %} + +{% block content %} +
+ {% if operations %} +
+ + + + {% for op in operations %} + {% set os = op.status|operation_state %} + + + + + + + + {% endfor %} + +
ЗадачаСтатусПрогрессНачата
{{ op.title }}
{{ op.message }}
{{ op_badge(op.status) }}
{{ op.created_at }}
+
+ {% else %} + {{ empty('list-checks', 'Задач пока нет', 'Когда вы создадите машину или загрузите образ, здесь появится ход выполнения.', '/vm/create', 'Создать машину') }} + {% endif %} +
+{% endblock %} diff --git a/web/templates/update.html b/web/templates/update.html index d7f8e59..582e198 100644 --- a/web/templates/update.html +++ b/web/templates/update.html @@ -1,199 +1,79 @@ - - - - - - Обновления — {{ app_name }} - - - -
- +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, alert %} +{% set page_title = 'Обновления' %} -
-
-
-
Обновления
-
GitHub update center: changelog, проверка и установка
-
-
- user: {{ user }} - ← Дашборд -
-
-
+{% block page_header %} + +{% endblock %} - {% if error %} -
{{ error }}
- {% endif %} - -
-
-
Текущая версия
-
{{ info.current_version }}
-
-
-
Новая версия
-
{{ info.latest_version }}
-
-
-
Статус
-
{{ 'Есть обновление' if info.has_update else 'Актуально' }}
- {{ 'update' if info.has_update else 'ok' }} -
-
-
Последняя проверка
-
{{ info.checked_at }}
-
-
- -
-
-
-

Проверка GitHub

- {{ info.remote }}/{{ info.branch }} -
-
-
Source{{ info.source_dir }}
-
Local commit{{ info.current_commit[:12] if info.current_commit else 'unknown' }}
-
Remote commit{{ info.latest_commit[:12] if info.latest_commit else 'unknown' }}
-
- {% if not info.fetch_ok %} -
GitHub fetch вернул ошибку: {{ info.fetch_error }}
- {% endif %} -
+{% block content %} +
+
+
+
+
+ {{ icon('sparkles' if info.has_update else 'shield-check', 'icon-xl') }} +
{% if info.has_update %} -
- -
+

Доступна версия {{ info.latest_version }}

+

У вас установлена {{ info.current_version }}. {% if info.missing_versions %}Пропущено обновлений: {{ info.missing_versions|length }}.{% endif %}

{% else %} -
- -
+

Установлена последняя версия

+

Virtuality {{ info.current_version }} · проверено {{ info.checked_at }}

{% endif %}
-
+
+
+ {% if info.has_update %} +
+ +
+ {% else %} +
+ {% endif %} +
+
+
-
-
-

Пропущенные версии и функции

- {{ info.missing_versions|length }} версий -
-
- {% for release in info.missing_versions %} -
-
-

{{ release.version }} · {{ release.title }}

- {{ release.date }} -
-

{{ release.summary }}

-
    - {% for feature in release.features %} -
  • {{ feature }}
  • - {% endfor %} -
-
- {% else %} -
Нет пропущенных версий. Система на свежем коде.
- {% endfor %} -
-
-
+ + {% if not info.fetch_ok %}{{ alert('warning', 'Не удалось связаться с GitHub: ' ~ (info.fetch_error or 'нет ответа') ~ '. Проверьте доступ сервера в интернет.', 'Проверка не удалась') }}{% endif %} -
-
-
-

Новые коммиты

- {{ info.commits|length }} +
+

Что нового

{{ 'Изменения, которые вы получите' if info.missing_versions else 'Последние изменения' }}

+ {% set releases = info.missing_versions or ([current_release] if current_release else []) %} + {% if releases %} + {% for release in releases|reverse %} +
+
{{ release.version }}

{{ release.title }}

{{ release.date }}
+

{{ release.summary }}

+
    {% for feature in release.features %}
  • {{ feature }}
  • {% endfor %}
-
- {% for commit in info.commits %} -
- {{ commit.short }}{{ commit.subject }} · {{ commit.date }} -
- {% else %} -
Новых коммитов нет.
- {% endfor %} -
-
- -
-
-

Лог обновления

- live -
-
{{ info.log_tail or 'Лог пока пуст.' }}
-
-
-
+ {% endfor %} + {% else %} +
Новых изменений нет — всё уже установлено.
+ {% endif %} +
- - - - + + +{% endblock %} diff --git a/web/templates/vm_create.html b/web/templates/vm_create.html index 28b4da1..cb89a2c 100644 --- a/web/templates/vm_create.html +++ b/web/templates/vm_create.html @@ -1,203 +1,171 @@ - - - - - - Создать VM — {{ app_name }} - - - - {% set disk_images_available = disk_images is defined and disk_images %} - {% set disk_feature_available = disk_images is defined %} -
- {% include "_sidebar.html" %} +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, alert %} +{% set page_title = 'Новая машина' %} +{% set source = form.source_type|default('iso') %} +{% set preset_match = namespace(id='custom') %} +{% for p in vm_presets %}{% if p.vcpus|string == form.vcpus|string and p.memory|string == form.memory|string and p.disk_size|string == form.disk_size|string %}{% set preset_match.id = p.id %}{% endif %}{% endfor %} -
-
-
-
Создать VM
-
Новая виртуальная машина через libvirt / virt-install
+{% block page_header %} + +{% endblock %} + +{% block content %} +
+
+
+
1

Название и система

Как назовём машину и что на неё установить

+
+
+ + + Латинские буквы, цифры, точка, дефис. От 2 до 63 символов.
-
- user: {{ user }} - ← Дашборд - + +
+ +
-
- {% if error %} -
{{ error }}
- {% endif %} +
+ + {% if isos %} + + Нужен другой? Загрузите образ. + {% endif %} +
+
+ + {% if disk_images %} + + Образ будет скопирован — оригинал останется для других машин. + {% endif %} +
+ + +
+ -
-
-
-

Параметры

- VM wizard -
- -
-
- + + + - - - - + + +{% endblock %} diff --git a/web/templates/vm_detail.html b/web/templates/vm_detail.html index dbc6a02..7764bd1 100644 --- a/web/templates/vm_detail.html +++ b/web/templates/vm_detail.html @@ -1,389 +1,237 @@ - - - - - - {{ vm.name }} — {{ app_name }} - - - - -
- {% set is_running = 'running' in vm.dominfo|lower %} - {% set is_autostart = vm.autostart_enabled|default(false) %} - {% set autostart_label = 'enabled' if is_autostart else 'disabled' %} - {% set rs = resource_settings|default({}) %} - {% include "_sidebar.html" %} +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, vm_badge, alert %} +{% set page_title = vm.name %} +{% set s = (vm.info.state or ('running' if 'running' in vm.dominfo|lower else 'shut off'))|vm_state %} +{% set rs = resource_settings|default({}) %} +{% set has_ip = vm.ip and vm.ip not in ('—', 'not available') %} -
-
-
-
{{ vm.name }}
-
Детали виртуальной машины
+{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% for key in ('resource', 'iso', 'boot') %} + {% set msg = {'resource': resource_message, 'iso': iso_message, 'boot': boot_message}[key] %} + {% set err = {'resource': resource_error, 'iso': iso_error, 'boot': boot_error}[key] %} + {% if msg %}{{ alert('success', msg) }}{% endif %} + {% if err %}{{ alert('danger', err) }}{% endif %} + {% endfor %} +{% endblock %} + +{% block content %} + + +
+
+
+

Сводка

+
+
+
Состояние{{ vm_badge(vm.info.state or s.raw) }}
+
IP-адрес{% if has_ip %}{{ vm.ip }} {% else %}{{ 'Определяется…' if s.running else 'Нет — машина выключена' }}{% endif %}
+
Процессор{{ vm.info.vcpus }} {{ plural(vm.info.vcpus, 'ядро', 'ядра', 'ядер') }}
+
Память{{ vm.info.memory_mb|format_mb }}
+
Архитектура{{ rs.arch|default('—') }}
+
Сеть{{ (vm.interface_list[0].source if vm.interface_list else '—')|replace('virtuality-nat', 'Автоматически (NAT)') }}
-
- user: {{ user }} - {{ 'running' if is_running else 'shut off' }} - autostart: {{ autostart_label }} - Консоль ↗ - ← Дашборд -
+
+
+ +
+

Как подключиться

Выберите удобный способ

+
+
+ {{ icon('monitor') }} +
Экран в браузере
Как монитор, подключённый к машине. Подходит для установки системы.
+ {% if s.running %}Открыть{{ icon('external-link', 'icon-sm') }}{% else %}Запустите машину{% endif %}
-
- - {% if resource_message %}
{{ resource_message }}
{% endif %} - {% if resource_error %}
{{ resource_error }}
{% endif %} - {% if iso_message %}
{{ iso_message }}
{% endif %} - {% if iso_error %}
{{ iso_error }}
{% endif %} - {% if boot_message %}
{{ boot_message }}
{% endif %} - {% if boot_error %}
{{ boot_error }}
{% endif %} - -
-
-
-

Управление

- actions -
-
-
-
Основные действия
-
- ▣Консоль - {% if is_running %} -
-
- {% else %} -
- {% endif %} -
-
-
-
Автозапуск
-
- {% if is_autostart %} -
- -
- {% else %} -
- -
- {% endif %} -
-

Автозапуск поднимает VM вместе с libvirt после перезагрузки хоста.

-
-
-
Опасные действия
-
- {% if is_running %} -
- {% endif %} - {% if not is_running %} -
- {% else %} - Удаление доступно после выключения VM. - {% endif %} -
-
-
-
- -
-
-

CPU / RAM / Архитектура

- resources -
-
-
- - -
- - -
- {% if is_running %} -

CPU/RAM/архитектуру можно менять только при выключенной VM.

- {% else %} -

Изменения применяются через XML VM и virsh define.

- {% endif %} -
- -
-
-

Порядок загрузки

- boot -
-
- -
-
☰
ISO / CD-ROMУстановщик или rescue-образ
-
☰
ДискОсновной диск VM
-
☰
Сеть / PXEЗагрузка по сети
-
- -
-

Изменение работает после перезапуска VM.

-
- -
-
-

ISO-привод

- cdrom -
- {% if current_iso %} -
Сейчас подключен ISO: {{ current_iso }}
- {% else %} -
ISO сейчас не подключён.
- {% endif %} -
- - -
-
- -
- {% if not isos %} -
ISO-образов пока нет. Сначала загрузи .iso в разделе ISO.
- {% endif %} -
-
- -
-
-
-

Информация

- overview -
-
-
- Технический вывод dominfo -
{{ vm.dominfo }}
-
-
- -
-
-

Диски

- storage -
-
-
- Технический вывод domblklist -
{{ vm.disks }}
-
-
- -
-
-

Сеть

- network -
-
-
- Технический вывод domiflist -
{{ vm.interfaces }}
-
-
-
- -
-
-

Консоль

- noVNC ↗ +
+ {{ icon('terminal') }} +
SSH с этого сервера
{% if has_ip %}ssh пользователь@{{ vm.ip }}{% else %}Станет доступно, когда машина получит IP-адрес{% endif %}
-
-
VNC display{{ vm.vnc }}
-
Host{{ host_ip }}
-
Web console/vm/{{ vm.name }}/console
+
+ {{ icon('globe') }} +
Доступ из интернета
Откройте порт машины наружу: сайт, SSH, удалённый рабочий стол
+ Настроить
-

Консоль открывается в новой вкладке и занимает всё окно браузера. VNC наружу открывать не нужно.

-
-
+
+ - - - +
+
+
+
+
АвтозапускЗапускать машину вместе с сервером
+ +
+
+
- - - - +
+ +
МБ
+ 1024 МБ = 1 ГБ +
+
+
+ {{ icon('wrench', 'icon-sm') }}Архитектура процессора{{ icon('chevron-down', 'icon-sm') }} +
+ + Меняйте, только если точно знаете, зачем: система внутри может перестать загружаться. +
+
+ + + + + + + + + +{% endblock %} diff --git a/web/update_core.py b/web/update_core.py index 16f5827..270b083 100644 --- a/web/update_core.py +++ b/web/update_core.py @@ -1,5 +1,6 @@ import json import os +import shutil import subprocess from datetime import datetime from pathlib import Path @@ -141,6 +142,11 @@ def missing_versions(local_version: str, remote_version: str, manifest: dict[str return sorted(versions, key=lambda item: version_tuple(str(item.get('version', '0.0.0')))) +def is_ancestor(older: str, newer: str) -> bool: + # Only offer an update when the remote is ahead; a node running a newer or diverged build must not be downgraded. + return run_cmd(['git', 'merge-base', '--is-ancestor', older, newer], cwd=SOURCE_DIR, timeout=10)['ok'] + + def git_log_between(base: str, head: str, limit: int = 50) -> list[dict[str, str]]: if not base or not head or base == head: return [] @@ -172,7 +178,7 @@ def check_updates(fetch: bool = True) -> dict[str, Any]: manifest = load_manifest(f'{REMOTE}/{DEFAULT_BRANCH}') missed = missing_versions(local_version, remote_version, manifest) commits = git_log_between(local_commit, remote_commit) - has_update = bool(local_commit and remote_commit and local_commit != remote_commit) + has_update = bool(local_commit and remote_commit and local_commit != remote_commit and is_ancestor(local_commit, remote_commit)) data = { 'ok': True, @@ -219,5 +225,11 @@ def start_update() -> dict[str, Any]: 'finished_at': '', } write_json(STATE_FILE, data) - subprocess.Popen(['bash', str(script)], cwd=str(SOURCE_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + cmd = ['bash', str(script)] + if shutil.which('systemd-run'): + # Run outside virtuality-web's cgroup: the update restarts virtuality-web, + # which would otherwise kill the update script before it finishes. + unit = f"virtuality-update-{datetime.now().strftime('%Y%m%d%H%M%S')}" + cmd = ['systemd-run', '--unit', unit, '--collect', '--quiet', f'--setenv=VIRTUALITY_SOURCE_DIR={SOURCE_DIR}', f'--working-directory={SOURCE_DIR}'] + cmd + subprocess.Popen(cmd, cwd=str(SOURCE_DIR), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) return data