diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 3a1d361..f96b0d3 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -51,7 +51,7 @@ jobs: 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 + grep -q "Установить 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 diff --git a/.gitignore b/.gitignore index e9b9b4b..e63cbc3 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ composer.lock # Offline Python wheels bundled into system images wheels/ + +# рабочие копии агентов и локальные настройки Claude Code +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3aadda4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# Virtuality — заметки для Claude Code + +Virtuality — простая замена Proxmox для дома и малого офиса: KVM/QEMU/libvirt + собственная +веб-панель на FastAPI, установочный ISO на базе чистого Ubuntu Server 26.04 LTS и мастер +первичной настройки в браузере. Пользователь — обычный человек, не админ. + +**Общайся с владельцем проекта по-русски.** Весь текст интерфейса — на простом русском, без жаргона +(термины вроде qcow2, libvirt, nvram — только в раскрывающихся блоках «Для специалистов»). + +## Устройство репозитория + +- `web/app.py` — основное приложение FastAPI (машины, ISO, образы дисков, сеть, журналы, обновления, консоль). +- `web/core.py` — общие примитивы: `run_cmd`, хранилище операций (`new_operation`, `run_operation`, + `finish_operation`, `active_operations_for`, `interrupt_orphaned_operations`), `render`, `require_auth`, + `valid_vm_name`, шаблоны Jinja. Импорт `core` выставляет `LC_ALL=C.UTF-8` (virsh иначе переводится). +- `web/features/*.py` — модули с `router = APIRouter()`, подключаются в конце `app.py`: + `setup` (мастер `/setup`), `settings` (`/settings`), `snapshots`, `backups`, `downloads` (скачивание + по ссылке и каталог), `vmops` (клон, увеличение диска, пауза/спящий режим, заметки, нагрузка). + POST-маршруты модулей — минимум 4 сегмента (`/vm/{name}/<модуль>/<действие>`), иначе их перехватит + `@app.post("/vm/{name}/{action}")`. +- `web/nodectl.py` — факты об узле и действия через `scripts/virtuality-ctl` (единая точка для + systemd-юнитов, TLS, firewall, моста br0 с автооткатом, диска под хранилище, пароля, часового пояса). +- `web/network_core.py` — NAT-сеть `virtuality-nat` (192.168.100.0/24), резервации DHCP (фиксированный + MAC по имени машины), проброс портов (nftables + ufw + iptables-fallback с меткой `virtuality-forward`). +- `web/catalog.py` — каталог облачных образов и типы ОС; `web/cloudinit.py` — seed cloud-init. +- `web/update_core.py` + `scripts/auto_update_check.sh`, `scripts/apply_github_update.sh`, + `scripts/update_target.sh` — обновления; канал `stable` = последний тег `vX.Y.Z` (пока тегов нет — main), + канал `main` = ветка main. +- `scripts/virtuality_firstboot.sh` — первая загрузка установленной системы: panel → network → + virtualization → tools → finish, прогресс в `/var/lib/virtuality/config/setup.json`. +- `scripts/install_web_panel.sh` — установка/обновление панели (web.prev для отката, офлайн-wheels). +- `install.sh` (установка одной командой), `install_virtuality_node.sh` (пакеты KVM/libvirt). +- `image/build-iso.sh`, `image/autoinstall.yaml`, `image/grub/` — сборка установочного ISO. +- `updates/versions.json` — журнал изменений для центра обновлений; `VERSION` — текущая версия. + +## Команды + +```bash +make check # pyflakes + shellcheck + pytest +python3 -m pytest -q # тесты (fake virsh в tests/conftest.py) +python3 tests/dev_server.py 8765 # демо-панель, вход: tester / любой пароль +VIRTUALITY_DEV_SETUP=installing python3 tests/dev_server.py # мастер на шаге установки +VIRTUALITY_DEV_SETUP=done|finished ... # мастер после установки / настроенный сервер +make iso # ISO в dist/ (нужны xorriso, git, curl, python3-pip; ~3 ГБ загрузки) +./image/build-iso.sh --help # все параметры сборки (в т.ч. --unattended) +``` + +Скриншоты: Playwright и Chromium доступны в облачной среде, локально — `npx playwright`. + +## Правила + +- Код как вокруг: плотность комментариев, имена, идиомы модуля. Не переформатировать чужой код. +- Каждое изменение поведения — с тестом. Перед коммитом: `make check`. +- Коммиты по-русски в стиле «<что> было сделано». +- `main` — рабочая ветка узлов: ноды на канале `main` подтягивают её каждую ночь. **Не сливать в main + без явного согласия владельца.** Релиз для канала `stable` — тег `vX.Y.Z` на main (он же запускает + сборку ISO в GitHub Actions). +- Секреты и пароли не логировать; пароль cloud-init хранится только как SHA-512 crypt. +- Не возвращать удалённое: Cockpit, консольный dashboard, эмуляцию ARM64 на x86, PXE, смену + архитектуры машины, скрипты bootstrap/clean_install/setup_bridge_br0/create_test_vm. diff --git a/README.md b/README.md index ed72360..1db196c 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,16 @@ # Virtuality -**Virtuality** — лёгкая серверная платформа виртуализации на базе **KVM**, **QEMU**, **libvirt**, **Cockpit** и собственной web-панели. +**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка. -Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией. +Текущая версия: **0.11.0** (файл `VERSION`, журнал изменений — `updates/versions.json`). -> Текущий статус: версия 0.10 — код панели покрыт тестами, есть установочный ISO-образ. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding. +Что умеет: ---- - -## Поддерживаемые профили хоста - -Virtuality автоматически определяет профиль сборки во время установки: - -```text -x86_64 # обычный сервер, домашний сервер, VPS -raspberry-arm64 # Raspberry Pi ARM64 edge node -orangepi5-arm64 # Orange Pi 5 / RK3588 ARM64 edge node -generic-arm64 # другая ARM64-плата -``` - -Профиль сохраняется здесь: - -```text -/var/lib/virtuality/config/host_profile.json -``` - -В web-панели профиль виден на странице: - -```text -/host -``` - -Для ARM64-плат правильный сценарий — **ARM64-гости**, NAT-сеть и позже cloud-image/cloud-init шаблоны. x86_64 ISO на Raspberry/Orange Pi не являются целевым режимом. +- ставится с готового ISO-образа, как Proxmox: три экрана установщика — и через несколько минут панель открыта в браузере; +- создаёт машины из установочных ISO (в том числе Windows 11) и за минуту — из готовых облачных образов Ubuntu, Debian и Alpine; +- снимки, резервные копии, клонирование, увеличение диска, спящий режим; +- сеть машин «из коробки»: отдельная сеть NAT с постоянными адресами и пробросом портов или ваша домашняя сеть через мост; +- экран машины прямо в браузере, HTTPS для панели, автообновления со стабильным каналом. ![Обзор](docs/screenshots/dashboard.png) @@ -43,41 +22,84 @@ generic-arm64 # другая ARM64-плата |---|---| | ![Сеть](docs/screenshots/network.png) | ![Тёмная тема](docs/screenshots/dashboard-dark.png) | ---- +| Мастер настройки: установка | Мастер настройки: сеть машин | +|---|---| +| ![Мастер, установка](docs/screenshots/wizard-install.png) | ![Мастер, сеть](docs/screenshots/wizard-network.png) | -## Установка с ISO-образа +| Каталог готовых систем | Настройки | +|---|---| +| ![Каталог](docs/screenshots/catalog.png) | ![Настройки](docs/screenshots/settings.png) | -Для установки на «голое» железо собирается загрузочный ISO на базе чистого Ubuntu Server 26.04 LTS: установщик спрашивает только сеть, диск и пользователя, а при первой загрузке нода настраивается сама. +| Снимки | Резервные копии | +|---|---| +| ![Снимки](docs/screenshots/snapshots.png) | ![Резервные копии](docs/screenshots/backups.png) | -```bash -make iso # dist/virtuality-<версия>-ubuntu-26.04-amd64.iso -make iso ARCH=arm64 -``` - -Подробно: [`docs/IMAGE.md`](docs/IMAGE.md). +| Вход | На телефоне | +|---|---| +| ![Вход](docs/screenshots/login.png) | ![Мобильная версия](docs/screenshots/dashboard-mobile.png) | --- -## Быстрая установка одной командой +## Содержание -Одна команда работает и под `root`, и под обычным пользователем с `sudo`: +- [Установка](#установка) + - [Способ 1: установочный ISO](#способ-1-установочный-iso) + - [Способ 2: одна команда на готовый сервер](#способ-2-одна-команда-на-готовый-сервер) + - [Способ 3: вручную](#способ-3-вручную) +- [Первая загрузка и мастер настройки](#первая-загрузка-и-мастер-настройки) +- [Возможности](#возможности) +- [Настройки](#настройки) +- [Команда virtuality-ctl](#команда-virtuality-ctl) +- [Обновления и откат](#обновления-и-откат) +- [Удаление](#удаление) +- [Если что-то не работает](#если-что-то-не-работает) +- [Где что лежит](#где-что-лежит) +- [Разработка](#разработка) + +Подробные документы: [требования](docs/REQUIREMENTS.md) · [установочный образ](docs/IMAGE.md) · [первая машина](docs/FIRST_VM.md) · [архитектура](docs/ARCHITECTURE.md) · [планы](docs/ROADMAP.md). + +--- + +## Установка + +Перед установкой проверьте [требования](docs/REQUIREMENTS.md): процессор x86_64 с включённой виртуализацией (Intel VT-x / AMD-V), от 4 ГБ памяти, проводная сеть. + +### Способ 1: установочный ISO + +Самый простой путь для «голого» сервера или старого компьютера. Образ собирается из чистого Ubuntu Server 26.04 LTS; установщик спрашивает только сеть, диск и пользователя. + +Где взять образ: + +- **GitHub Actions.** Откройте вкладку Actions репозитория → workflow **Build ISO image** → последний успешный запуск → раздел Artifacts → `virtuality-iso-amd64` (или `virtuality-iso-arm64`). Внутри архива — `.iso` и файл `.sha256`. Артефакты хранятся 30 дней; новый образ собирается при каждом выпуске (тег `vX.Y.Z`), запустить сборку вручную можно кнопкой Run workflow. +- **Собрать самому** на любой Linux-машине (root не нужен): + + ```bash + sudo apt install -y xorriso git curl python3-pip + git clone https://github.com/viktor138irk/virtuality.git + cd virtuality + make iso # dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso + ``` + +Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md). + +### Способ 2: одна команда на готовый сервер + +Подходит для уже установленного Ubuntu Server 24.04 / 26.04 или Debian 13. Работает и под `root`, и под обычным пользователем с `sudo`: ```bash curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | bash ``` -Как выбирается пользователь для входа в web-панель: +Скрипт проверит требования, поставит пакеты KVM/QEMU/libvirt, команду `vhealth` и панель, а в конце покажет адрес панели. Логин — тот пользователь, от которого запущена установка (через `sudo` — ваш пользователь, под `root` — `root`); пароль — его пароль Linux. Если пароль ещё не задан: `sudo passwd <пользователь>`. + +Переменные окружения, которые понимает `install.sh`: ```text -Если запуск под root → login: root -Если запуск через sudo → login: текущий sudo-пользователь -Если задан VIRTUALITY_USER → login: указанный Linux-пользователь -``` - -Принудительно выбрать пользователя: - -```bash -curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER= bash +VIRTUALITY_USER=admin # какой Linux-пользователь входит в панель (будет создан, если его нет) +VIRTUALITY_WEB_PORT=8089 # порт панели (по умолчанию 8088) +VIRTUALITY_AUTO_UPDATE=0 # не включать ночное автообновление +VIRTUALITY_BRANCH=main # ветка репозитория +VIRTUALITY_INSTALL_VERBOSE=1 # показывать вывод команд вместо спиннера ``` Пример: @@ -86,363 +108,227 @@ curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/instal curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=admin bash ``` -Установка с авторизацией под `root`: - -```bash -curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=root bash -``` - -Если пароль выбранного Linux-пользователя не задан: - -```bash -sudo passwd -``` - -Для root: - -```bash -passwd root -``` - ---- - -## Что уже есть - -- универсальная one-command установка через `install.sh`; -- автоопределение профиля хоста: x86_64, Raspberry Pi ARM64, Orange Pi 5 ARM64, generic ARM64; -- установка разных пакетных наборов под x86 и ARM64; -- красивый пошаговый installer с live-status и логами; -- preflight-проверка системных требований; -- установка KVM/QEMU/libvirt; -- установка Cockpit и Cockpit Machines; -- рабочая директория `/opt/virtuality/source`; -- libvirt storage pools `virtuality-images` и `virtuality-iso`; -- firewall-правила для SSH, Cockpit, VNC и web-панели; -- безопасная ручная настройка bridge `br0` через `netplan try`; -- NAT Router `virtuality-nat` для VPS/ARM edge nodes; -- port forwarding через nftables; -- автоматическое определение IP VM для проброса портов; -- healthcheck одной командой `vhealth`; -- консольный dashboard для физического монитора; -- web-панель Virtuality на FastAPI; -- авторизация web-панели через системного Linux-пользователя; -- `/host` — профиль хоста и проверки готовности; -- `/network` — NAT, DHCP leases, port forwarding, nftables preview; -- `/iso` — ISO-менеджер; -- `/operations` — журнал фоновых операций; -- список VM; -- управление VM: Start, Shutdown, Reboot, Power off, Autostart on/off, Delete VM with disks; -- создание VM из web-интерфейса; -- выбор сетевого режима при создании VM: `virtuality-nat` или `br0`; -- прогресс создания VM и live-лог `virt-install`; -- страница деталей VM: `dominfo`, VNC display, IP, диски, сетевые интерфейсы. - ---- - -## Системные требования - -Минимально для x86_64 тестового стенда: - -```text -CPU: 2 ядра с Intel VT-x / AMD-V -RAM: 4 GB -/: минимум 8 GB свободно -/var/lib: минимум 20 GB свободно -OS: Ubuntu Server 26.04 / 24.04 LTS или Debian 13 -Network: один проводной интерфейс -``` - -Рекомендуемо для x86_64: - -```text -CPU: 4+ ядра -RAM: 16+ GB -Storage: 100+ GB SSD/NVMe под /var/lib/virtuality -Network: 1 Gbit/s+ -OS: Ubuntu Server 26.04 LTS -``` - -ARM64 edge nodes: - -```text -Raspberry Pi 4/5 ARM64: желательно 8 GB RAM -Orange Pi 5 ARM64: желательно 8/16/32 GB RAM и NVMe -Гости: ARM64 Linux VM -Сеть: virtuality-nat по умолчанию -``` - -Подробнее: [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md) - ---- - -## Порты - -```text -Cockpit: https://SERVER_IP:9090 -Virtuality UI: http://SERVER_IP:8088 -VNC: 5900-5999/tcp -SSH: 22/tcp -``` - -Изменить порт web-панели: - -```bash -curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_WEB_PORT=8089 bash -``` - -Для VPS/ARM NAT-сценария входящие сервисы VM открываются через port forwarding в разделе `/network`. - ---- - -## Директории - -```text -/opt/virtuality/source # исходники проекта из GitHub -/opt/virtuality/web # установленная web-панель -/opt/virtuality/venv # Python virtualenv web-панели -/opt/virtuality/virtuality.env # базовый env ноды -/var/lib/virtuality/config # конфиги Virtuality -/var/lib/virtuality/config/host_profile.json -/var/lib/virtuality/iso # ISO-образы -/var/lib/virtuality/images # qcow2-диски VM -/var/lib/virtuality/network # port_forwards.json -/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 -``` - ---- - -## Диагностика - -```bash -sudo vhealth -``` - -Дополнительно: - -```bash -cat /var/lib/virtuality/config/host_profile.json -systemctl status virtuality-web --no-pager -journalctl -u virtuality-web -n 120 --no-pager -virsh list --all -virsh pool-list --all -virsh net-list --all -virsh net-dhcp-leases virtuality-nat -ip -br a -ip route -``` - ---- - -## Web-панель - -URL: - -```text -http://SERVER_IP:8088 -``` - -Основные разделы: - -```text -/ # дашборд -/host # профиль хоста и проверки -/iso # ISO-менеджер -/network # NAT Router и проброс портов -/operations # журнал операций -/vm/create # создание VM -``` - -Переустановка web-панели: +При первом входе в панель откроется мастер настройки — тот же, что после установки с ISO. + +### Способ 3: вручную + +Если хочется видеть каждый шаг. Всё делается от `root` или через `sudo`: ```bash +sudo mkdir -p /opt/virtuality +sudo git clone https://github.com/viktor138irk/virtuality.git /opt/virtuality/source cd /opt/virtuality/source -sudo bash scripts/install_web_panel.sh +sudo bash install_virtuality_node.sh # пакеты KVM/QEMU/libvirt, пулы, firewall +sudo bash scripts/install_healthcheck_command.sh # команда sudo vhealth +sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панель и virtuality-ctl ``` -Статус: +`install_web_panel.sh` копирует панель в `/opt/virtuality/web`, создаёт virtualenv, ставит `virtuality-ctl` и через него — systemd-юниты и правила firewall. Переменные: `VIRTUALITY_AUTH_USER` (пользователь для входа), `VIRTUALITY_WEB_PORT` (порт), `VIRTUALITY_AUTO_UPDATE` (0/1). Повторный запуск скрипта обновляет панель, сохраняя настройки. -```bash -systemctl status virtuality-web --no-pager -``` +--- -Логи: +## Первая загрузка и мастер настройки -```bash -journalctl -u virtuality-web -f -``` +### Что происходит после установки с ISO -Вход: +После перезагрузки запускается `virtuality-firstboot.service` (`scripts/virtuality_firstboot.sh`). Порядок шагов выбран так, чтобы вы как можно раньше увидели, что происходит: + +1. **Панель управления** — ставится офлайн из Python-колёс, упакованных в образ, и сразу запускается. Открывайте `http://адрес-сервера:8088` — мастер покажет ход остальных шагов прямо в браузере. +2. **Подключение к интернету** — ожидание сети. Если кабель не подключён, мастер напишет об этом и продолжит сам, как только интернет появится. +3. **KVM, QEMU и libvirt** — самый долгий шаг, обычно 5–15 минут: пакеты ставятся из репозиториев Ubuntu, мастер показывает, какой пакет скачивается или настраивается. +4. **Инструменты диагностики** — команда `vhealth`. +5. **Завершение** — `virtuality-ctl reconfigure`: юниты, firewall, перезапуск панели. + +Ход установки пишется в `/var/lib/virtuality/config/setup.json` и в `/var/log/virtuality/firstboot.log`. Если какой-то шаг упал (например, отвалилась сеть), systemd перезапускает скрипт через минуту, и он продолжает с первого незавершённого шага. На экране сервера (tty) всё это время показана подсказка, а после завершения — адрес панели и логин. + +Логин в панель — пользователь, созданный в установщике Ubuntu; пароль — его пароль. + +### Мастер настройки + +Мастер (`/setup`) открывается при первом входе в панель — и после ISO, и после `install.sh`. Каждый шаг можно пропустить, а весь мастер — запустить заново из раздела «Настройки». + +| Шаг | Что делает | +|---|---| +| **Установка** | Показывает ход первой загрузки (только после ISO). | +| **Сервер** | Проверяет процессор, память, диск и доступность KVM; задаёт часовой пояс. | +| **Администратор** | Смена пароля пользователя панели (он же пароль SSH). | +| **Доступ** | HTTPS с сертификатом сервера (панель на порту 8443, с 8088 — перенаправление) или обычный HTTP; порт. | +| **Сеть машин** | **Отдельная сеть (NAT)** — машины получают адреса 192.168.100.x, работает везде. **Домашняя сеть (мост)** — машины становятся устройствами вашей сети; сервер сам перестроит сеть в мост `br0`, а если панель не ответит за 2 минуты — вернёт всё как было. | +| **Хранилище** | Отдать пустой диск (от 16 ГБ) под машины: он форматируется и монтируется в `/var/lib/virtuality`. Или оставить системный диск. | +| **Обновления** | Автоматически ночью (только выпущенные версии) или вручную; канал: стабильный или ранний доступ. | +| **Готово** | Сводка; настройки применяются, при смене адреса панель перезапускается и открывается по новому. | + +--- + +## Возможности + +Разделы панели: **Обзор**, **Создать машину**, **Образы ОС**, **Сеть и доступ**, **Резервные копии**, **Задачи**, **Журналы**, **Обновления**, **Сервер**, **Настройки**, **Помощь**. + +**Машины** + +- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть. +- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны. +- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно. +- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками. +- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места). +- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ). +- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти. +- **Экран машины в браузере** (noVNC). VNC наружу не открыт: консоль доступна только через панель. + +**Снимки и копии** + +- **Снимки** — точки восстановления внутри диска машины, до 8 на машину. У работающей машины в снимок попадает и память. Вернуться или удалить — одной кнопкой. +- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием; копию можно делать и по расписанию из командной строки. + +**Образы ОС** + +- Загрузка ISO и образов дисков перетаскиванием (с прогрессом и скоростью), поддержка `.qcow2`, `.img`, `.raw`, сжатых `.img.xz` и архивов. +- **Скачивание по ссылке** — сервер качает файл сам, в фоне. +- **Каталог готовых систем** — официальные облачные образы Ubuntu Server 26.04 LTS и 24.04 LTS, Debian 13, Alpine Linux 3.23 с проверкой контрольных сумм. + +**Сеть** + +- Сеть **NAT** `virtuality-nat` (192.168.100.0/24): каждая машина получает **постоянный адрес** по имени, поэтому пробросы портов не ломаются после перезагрузок. +- **Проброс портов** из списка готовых сервисов (SSH, сайт, RDP, Minecraft, VPN и другие) или любой порт и диапазон; проверка правила одной кнопкой; правила переживают перезагрузку сервера. +- **Домашняя сеть через мост** `br0` с автооткатом, если сеть пропала. + +**Сервер** + +- Мастер настройки и страница «Настройки». +- **HTTPS** для панели с сертификатом сервера и перенаправлением с HTTP. +- **Обновления** из панели или автоматически ночью, **стабильный канал** (только выпущенные версии) и ранний доступ, откат на предыдущую версию. +- Задачи с прогрессом и журналом, журналы служб, диагностика `vhealth`, отчёт для поддержки, архив настроек. +- Светлая и тёмная темы, работает на телефоне, не требует доступа в интернет для самой панели. + +Как сделать первую машину — в [docs/FIRST_VM.md](docs/FIRST_VM.md). + +--- + +## Настройки + +Страница **Настройки** (`/settings`) — всё то же, что в мастере, плюс управление сервером: + +- **Доступ к панели** — HTTPS или HTTP, порт. После применения панель перезапускается и открывается по новому адресу. +- **Пароль** — смена пароля пользователя панели (он же пароль Linux и SSH). +- **Сеть машин** — переключение между NAT и домашней сетью (мост `br0`), возврат прежней сети, проверка сети NAT. +- **Хранилище** — подключить пустой диск под машины. +- **Время** — часовой пояс сервера (по нему показываются журналы и час ночного обновления). +- **Обновления** — автообновление и канал (стабильный / ранний доступ). +- **Сервер** — скачать архив настроек, собрать отчёт для поддержки, перезагрузить или выключить сервер (машины выключаются корректно). +- **Мастер настройки** — пройти все шаги заново. + +Настройки ноды хранятся в `/var/lib/virtuality/config/web.env` и не сбрасываются обновлениями: ```text -Login: выбранный Linux-пользователь -Pass: пароль этого Linux-пользователя +VIRTUALITY_WEB_PORT=8088 # порт HTTP +VIRTUALITY_TLS=1 # 1 — HTTPS включён +VIRTUALITY_TLS_PORT=8443 # порт HTTPS +VIRTUALITY_WEB_HOST=0.0.0.0 # 127.0.0.1, если панель стоит за reverse proxy +VIRTUALITY_AUTH_USER=admin # Linux-пользователь для входа +VIRTUALITY_AUTO_UPDATE=1 # 0 — без ночного автообновления +VIRTUALITY_UPDATE_CHANNEL=stable # stable — выпущенные версии, main — все изменения +VIRTUALITY_SOURCE_DIR=/opt/virtuality/source +``` + +Менять их руками не нужно: `sudo virtuality-ctl set КЛЮЧ=ЗНАЧЕНИЕ` сохраняет значение и сразу применяет его. + +--- + +## Команда virtuality-ctl + +`virtuality-ctl` ставится в `/usr/local/bin` вместе с панелью; панель сама вызывает его для всех действий с сервером, так что из терминала можно сделать то же самое. Запускается от `root`: + +```text +sudo virtuality-ctl status # что установлено, порты, адрес панели +sudo virtuality-ctl url # адрес панели +sudo virtuality-ctl reconfigure # пересоздать systemd-юниты из web.env и перезапустить панель +sudo virtuality-ctl set KEY=VALUE ... # изменить настройку в web.env и применить (например set VIRTUALITY_TLS=1) +sudo virtuality-ctl passwd [USER] # сменить пароль пользователя панели +sudo virtuality-ctl timezone Europe/Moscow # часовой пояс сервера +sudo virtuality-ctl power reboot|poweroff # перезагрузить или выключить сервер (машины выключаются корректно) +sudo virtuality-ctl backup-config [FILE] # архив настроек ноды и описаний машин +sudo virtuality-ctl restore-config FILE # восстановить настройки из архива +sudo virtuality-ctl rollback # вернуть предыдущую версию панели +sudo virtuality-ctl maintenance # ежедневное обслуживание (запускается таймером) +sudo virtuality-ctl bridge [dhcp|static] # мост br0 с автооткатом через 2 минуты +sudo virtuality-ctl bridge-confirm # подтвердить новую сеть +sudo virtuality-ctl bridge-revert # вернуть прежнюю сеть +sudo virtuality-ctl storage-use /dev/sdX # отдать пустой диск под /var/lib/virtuality +sudo virtuality-ctl support-bundle [FILE] # отчёт для поддержки +sudo virtuality-ctl uninstall [--purge] [--yes] # удалить Virtuality ``` --- -## Сеть: Bridge и NAT Router +## Обновления и откат -### Bridge br0 - -Подходит для домашнего/офисного сервера, где VM должны получать IP из локальной сети. - -По умолчанию one-command установщик **не включает `br0` автоматически**, чтобы не уронить SSH-сессию. - -Сначала нужно посмотреть сетевой интерфейс: - -```bash -ip -br a -ip route -``` - -Затем запустить bridge setup, например для `enp2s0`: - -```bash -cd /opt/virtuality/source -sudo bash scripts/setup_bridge_br0.sh enp2s0 static -``` - -Ожидаемый результат: - -```text -enp2s0 UP -br0 UP SERVER_IP/24 -default via GATEWAY dev br0 -``` - -### Virtuality NAT Router - -Подходит для VPS, Raspberry Pi, Orange Pi 5 и других ARM64 edge nodes. - -Схема: - -```text -Интернет / LAN - ↓ -Хост Virtuality - ↓ -virtuality-nat / virbr100 - ↓ -VM 192.168.100.x -``` - -Создать или починить NAT-сеть можно в web-панели: - -```text -/network → Создать / починить NAT-сеть -``` - -Параметры по умолчанию: - -```text -Network: virtuality-nat -Bridge: virbr100 -Subnet: 192.168.100.0/24 -Gateway: 192.168.100.1 -DHCP: 192.168.100.50–192.168.100.200 -``` - -Проброс порта добавляется в `/network`. IP VM определяется автоматически через `virsh domifaddr`, а если не получилось — через DHCP leases и MAC-адрес VM. - -Пример SSH-проброса: - -```text -VM: ubuntu-test -Внешний порт сервера: 2222 -Порт внутри VM: 22 -Протокол: tcp -``` - -Подключение: - -```bash -ssh user@SERVER_IP -p 2222 -``` +- **Из панели**: раздел **Обновления** → «Проверить сейчас» → «Обновить». Машины во время обновления продолжают работать. Что изменилось — видно на той же странице. +- **Автоматически**: таймер `virtuality-auto-update.timer` проверяет обновления каждую ночь около 4:00 по времени сервера. Включается и выключается в «Настройках» или командой `sudo virtuality-ctl set VIRTUALITY_AUTO_UPDATE=0`. +- **Каналы**: `stable` — только выпущенные версии (последний тег `vX.Y.Z`; пока тегов нет, стабильный канал следует ветке `main`), `main` — все изменения ветки `main`. Нода никогда не откатывается на более старую версию сама. +- **Вручную из терминала**: `sudo bash /opt/virtuality/source/scripts/apply_github_update.sh` — тот же сценарий, что и в панели (git fetch, при недоступности git — ZIP с GitHub, затем `scripts/install_web_panel.sh` и перезапуск панели). Журнал: `/var/log/virtuality/update.log`. +- **Откат**: перед каждым обновлением предыдущая версия панели сохраняется в `/opt/virtuality/web.prev`. Если новая версия не запустилась: `sudo virtuality-ctl rollback`. --- -## ISO и создание VM - -Загрузка ISO через web-панель: - -```text -/iso -``` - -Создание VM: - -```text -/vm/create -``` - -При создании VM можно выбрать сеть: - -```text -VPS NAT Router — virtuality-nat -Bridge — br0 / локальная сеть -``` - -Для x86_64 можно использовать обычные x86_64 ISO. Для Raspberry Pi / Orange Pi 5 нужны ARM64 ISO или, в будущем, ARM64 cloud images. - -Пример загрузки Alpine x86_64 ISO: +## Удаление ```bash -sudo wget -O /var/lib/virtuality/iso/alpine-standard-x86_64.iso https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.3-x86_64.iso -sudo virsh pool-refresh virtuality-iso +sudo virtuality-ctl uninstall # убирает панель, службы и настройки; машины, образы и копии остаются +sudo virtuality-ctl uninstall --purge # удаляет ВСЁ: машины, диски, образы, резервные копии, сеть NAT ``` +Команда спрашивает подтверждение (`YES`); `--yes` пропускает вопрос. Пакеты KVM/QEMU/libvirt остаются в системе. + --- -## Обновление +## Если что-то не работает -Из web-панели: раздел `/update`. Вручную: +1. Раздел **Сервер** в панели показывает, чего не хватает (KVM, пакеты, мост). Раздел **Задачи** — журнал каждой операции. Раздел **Журналы** — панель, обновления, установка, виртуализация. +2. Полная диагностика в терминале: -```bash -cd /opt/virtuality/source -sudo git pull -sudo bash scripts/install_web_panel.sh -sudo systemctl restart virtuality-web -``` + ```bash + sudo vhealth # проверка процессора, служб, сети, firewall, libvirt, дисков + sudo virtuality-ctl status # версия, адрес панели, HTTPS, автообновление, хранилище + ``` -Порт панели, пользователь и режим автообновления сохраняются в `/var/lib/virtuality/config/web.env` и не сбрасываются при обновлениях. +3. Отчёт для поддержки — кнопка в «Настройках» или `sudo virtuality-ctl support-bundle`: архив с настройками (без секретов), журналами служб и последними задачами. +4. Журналы: -Полная повторная установка компонентов ноды: + ```bash + journalctl -u virtuality-web -f # панель + sudo journalctl -fu virtuality-firstboot # первая загрузка после ISO + cat /var/log/virtuality/firstboot.log # то же, файлом + cat /var/log/virtuality/update.log # обновления + ls /var/log/virtuality/operations/ # задачи: JSON + журнал каждой + ``` -```bash -cd /opt/virtuality/source -sudo bash install_virtuality_node.sh -sudo bash scripts/install_web_panel.sh -``` +5. Для мониторинга: `GET /healthz` без авторизации отвечает `{"ok": true, "version": "..."}`. + +Полезные команды libvirt: `virsh list --all`, `virsh net-list --all`, `virsh net-dhcp-leases virtuality-nat`, `virsh pool-list --all`. --- -## Продакшен-настройки - -Настройки ноды — `/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) +/opt/virtuality/source # исходники (git), отсюда обновления +/opt/virtuality/web # установленная панель (web.prev — предыдущая версия) +/opt/virtuality/venv # Python virtualenv панели +/var/lib/virtuality/iso # установочные ISO +/var/lib/virtuality/disk-images # готовые образы систем (каталог, ссылки, загрузки) +/var/lib/virtuality/images # диски машин (qcow2) +/var/lib/virtuality/backups # резервные копии машин и архивы настроек +/var/lib/virtuality/config # web.env, setup.json, wizard.json, host_profile.json, tls/ +/var/lib/virtuality/network # port_forwards.json, virtuality-nat.xml +/var/lib/virtuality/tmp # временные файлы загрузок и cloud-init +/var/log/virtuality # firstboot.log, update.log, virtuality-ctl.log, install_*.log +/var/log/virtuality/operations # задачи панели: JSON и журнал каждой +/etc/virtuality/nftables/virtuality.nft # правила проброса портов +/usr/local/bin/virtuality-ctl # управление нодой +/usr/local/bin/vhealth # диагностика ``` -После изменения: `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`. +Порты: панель `8088` (HTTP) и `8443` (HTTPS), SSH `22`. VNC-порты машин наружу не открываются. --- @@ -450,50 +336,27 @@ VIRTUALITY_COOKIE_SECURE=0 # 1 — cookie только по HTTPS (за rev ```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, вход: любой пароль +make test # pytest (virsh и systemctl имитируются в tests/conftest.py) +make lint # pyflakes + shellcheck +make check # lint + test — запускать перед коммитом +make iso # установочный ISO в dist/ (см. docs/IMAGE.md) ``` -Интерфейс: шаблоны `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 -sudo vhealth -virsh list --all -virsh pool-list --all -virsh net-list --all -virsh net-dhcp-leases virtuality-nat -ip -br a -ip route -sudo nft list ruleset -sudo ufw status -systemctl status libvirtd --no-pager -systemctl status cockpit.socket --no-pager -systemctl status virtuality-web --no-pager -journalctl -u virtuality-web -f +python3 tests/dev_server.py 8765 # http://127.0.0.1:8765, вход: tester / любой пароль +VIRTUALITY_DEV_SETUP=installing python3 tests/dev_server.py # мастер на шаге «Установка» +VIRTUALITY_DEV_SETUP=done python3 tests/dev_server.py # мастер после установки +VIRTUALITY_DEV_SETUP=finished python3 tests/dev_server.py # настроенный сервер, страница «Настройки» ``` ---- +Устройство кода описано в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Интерфейс: шаблоны `web/templates` (каркас `base.html`, компоненты `_ui.html`), стили `web/static/app.css`, поведение `web/static/panel.js`; шрифт и иконки лежат в `web/static`. Скриншоты страниц: `node tests/screenshots.mjs` (нужен Playwright). -## Roadmap - -- ARM64 cloud-image templates; -- cloud-init для быстрых VM; -- управление storage pools; -- backup/snapshot manager; -- сетевой менеджер bridge/VLAN; -- роли и права пользователей; -- журнал событий; -- кластеризация; -- готовые образы для Raspberry Pi / Orange Pi 5. +GitHub Actions: `ci.yml` запускает pyflakes, shellcheck и тесты на Python 3.11–3.14; `image.yml` собирает ISO по тегу `v*` и вручную. Ветка `main` — рабочая ветка нод на канале `main`; выпуск для стабильного канала — тег `vX.Y.Z`. --- ## Лицензия -Рекомендуемая лицензия: **MIT License**. +[MIT License](LICENSE). diff --git a/VERSION b/VERSION index 78bc1ab..d9df1bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.10.0 +0.11.0 diff --git a/bootstrap.sh b/bootstrap.sh deleted file mode 100644 index e162e4e..0000000 --- a/bootstrap.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Legacy entrypoint kept for old documentation links. -# All installation logic lives in install.sh. - -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)" - -if [[ -n "$SCRIPT_DIR" && -f "${SCRIPT_DIR}/install.sh" ]]; then - exec bash "${SCRIPT_DIR}/install.sh" "$@" -fi - -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/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c1c4a9a..8b93a0f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,122 +1,172 @@ # Архитектура Virtuality -Virtuality — это платформа виртуализации в стиле Proxmox, но с более компактной и контролируемой архитектурой. - -## Цель - -Создать собственную систему управления виртуальными машинами на базе стабильных Linux-компонентов: - -- KVM; -- QEMU; -- libvirt; -- Cockpit на раннем этапе; -- собственная веб-панель на следующих этапах. - -## Базовая схема +Virtuality — одна нода: сервер с KVM/QEMU/libvirt, поверх которого работает панель на FastAPI. Панель не хранит собственную базу данных: источник правды о машинах — libvirt (`virsh`), о ноде — файлы в `/var/lib/virtuality/config`, о задачах — файлы в `/var/log/virtuality/operations`. Всё, что меняет сам сервер (службы, firewall, сеть, диски), делает один скрипт — `virtuality-ctl`, и панель вызывает его же. ```text -Веб-интерфейс - ↓ -Backend API - ↓ -Node services - ↓ -libvirt / QEMU / KVM - ↓ -Host OS / Storage / Network +Браузер + │ HTTP 8088 / HTTPS 8443 + ▼ +virtuality-web.service — uvicorn, web/app.py (FastAPI, Jinja2) + ├── web/features/* мастер, настройки, снимки, копии, загрузки, операции с машиной + ├── web/core.py общие примитивы, хранилище задач + ├── web/nodectl.py ───────► /usr/local/bin/virtuality-ctl (systemd, TLS, ufw, br0, диск, пароль) + ├── web/network_core.py ──► virsh net-*, nft, ufw, iptables (сеть NAT и пробросы) + └── virsh / virt-install / virt-clone / qemu-img ──► libvirtd ──► QEMU/KVM ``` -## Слои системы +--- -### 1. Web UI +## Код панели: `web/` -Будущая пользовательская панель Virtuality. +| Модуль | Назначение | +|---|---| +| `app.py` | Основное приложение: вход, обзор, машины (создание, действия, ресурсы, порядок загрузки, привод), ISO и образы дисков (загрузка, конвертация, распаковка), сеть и пробросы, обновления, задачи, журналы, экран машины (WebSocket-прокси к VNC), `/healthz`. В конце подключает роутеры из `features/`. | +| `core.py` | Общие примитивы: настройки из `.env`, сессии и авторизация (`require_auth`, `require_api_auth`, защита от cross-origin POST, ограничение попыток входа), `run_cmd`, `render` и окружение шаблонов, `valid_vm_name`, хранилище задач (`new_operation`, `run_operation`, `update_operation`, `finish_operation`, `active_operations_for`, `running_operations`, `interrupt_orphaned_operations`). Импорт `core` выставляет `LC_ALL=C.UTF-8`: вывод `virsh` разбирается панелью и не должен переводиться. | +| `auth.py` | Проверка пароля Linux-пользователя по `/etc/shadow`: через `crypt`/`spwd`, а на Python 3.13+ — через `libcrypt` (ctypes). Ограничение попыток входа. | +| `features/setup.py` | Мастер первичной настройки `/setup`: шаги install → welcome → account → access → network → storage → updates → finish, страница «Применяем настройки», ожидание перестройки сети в мост. | +| `features/settings.py` | Страница «Настройки»: доступ (HTTPS/порт), пароль, часовой пояс, обновления, сеть машин, хранилище, перезагрузка/выключение, архив настроек, отчёт для поддержки. | +| `features/snapshots.py` | Снимки: внутренние снимки libvirt (`virsh snapshot-*`), до 8 на машину, создание и возврат — фоновые задачи. | +| `features/backups.py` | Резервные копии: `vm.xml` + копии дисков + `meta.json` в `backups/<машина>/<ГГГГММДД-ЧЧММ>/`; восстановление под тем же или новым именем; запуск как скрипта для расписания. | +| `features/downloads.py` | Скачивание ISO и образов по ссылке и из каталога — фоновые задачи типа `download` с прогрессом, проверкой контрольной суммы и отменой. | +| `features/vmops.py` | Увеличение диска, клонирование (`virt-clone`), пауза, спящий режим (`virsh managedsave`), заметки (title/description машины), живая нагрузка (`virsh domstats`). | +| `nodectl.py` | Факты о ноде (сеть, диски, оборудование, часовые пояса, состояние первой загрузки, выбор мастера) и действия через `virtuality-ctl`. Действия, которые перезапускают панель, запускаются отложенно через `systemd-run`. | +| `network_core.py` | Сеть NAT `virtuality-nat`, резервации DHCP, пробросы портов и их применение (nftables, ufw, iptables). | +| `catalog.py` | Каталог облачных образов (Ubuntu 26.04/24.04, Debian 13, Alpine 3.23 для x86_64 и aarch64) и типы ОС для установки с ISO (`osinfo` для `virt-install`). | +| `cloudinit.py` | Seed cloud-init для машин из готовых образов: `user-data` (пользователь, хеш пароля SHA-512, SSH-ключи, рост диска, гостевой агент) и `meta-data`; подключается к `virt-install --cloud-init`. | +| `host_profile.py` | Профиль сервера (`x86_64`, `raspberry-arm64`, `orangepi5-arm64`, `generic-arm64`), проверки готовности; хранится в `config/host_profile.json`. | +| `update_core.py` | Проверка обновлений: канал, целевой ref, список пропущенных версий из `updates/versions.json`, запуск `scripts/apply_github_update.sh` через `systemd-run`. | +| `presenters.py` | Человеческие названия состояний, профили ресурсов машин, список типовых портов, форматирование размеров. | +| `restore_network.py` | Повторное применение пробросов после загрузки (`virtuality-network.service`). | +| `redirect_http.py` | Перенаправление HTTP → HTTPS на порту 8088, когда включён TLS (`virtuality-redirect.service`). | +| `templates/`, `static/` | Jinja2-шаблоны (`base.html` — каркас, `_ui.html` — компоненты), `app.css` (светлая и тёмная темы), `panel.js`, шрифт и иконки. Панель не ходит в интернет. | -Задачи: +Правило для роутов модулей: POST-маршруты вида `/vm/{name}/<модуль>/<действие>` — минимум четыре сегмента, иначе их перехватит общий `@app.post("/vm/{name}/{action}")`. -- список виртуальных машин; -- создание VM; -- управление питанием; -- консоль; -- хранилища; -- сеть; -- бэкапы; -- пользователи и роли. +### Фоновые задачи -### 2. Backend API +Долгие операции (создание машины, конвертация образа, скачивание, снимок, копия, клон, спящий режим) выполняются в потоках и записываются в `/var/log/virtuality/operations/.json` (статус, прогресс, сообщение, метаданные) и `.log` (журнал). Раздел «Задачи» и страницы машин читают эти файлы; `active_operations_for(vm, kinds)` защищает машину от двух одновременных операций. При старте панели `interrupt_orphaned_operations()` закрывает задачи, оставшиеся в состоянии «выполняется» после перезапуска. `virtuality-ctl maintenance` удаляет задачи старше 30 дней и оставляет не больше 300. -Единая точка управления нодой. +--- -Задачи: +## Управление нодой: `scripts/virtuality-ctl` -- принимать команды от Web UI; -- валидировать действия; -- работать с libvirt; -- вести журнал событий; -- отдавать состояние VM, storage и сети. +Один bash-скрипт, устанавливается в `/usr/local/bin`. Панель (через `nodectl.py`) и администратор в терминале делают одно и то же: -### 3. Node services +- **`reconfigure`** — читает `/var/lib/virtuality/config/web.env` и пересоздаёт systemd-юниты, logrotate, `/etc/default/libvirt-guests`, правила ufw; переводит VNC старых машин на `127.0.0.1`; перезапускает панель. +- **`set KEY=VALUE`** — проверяет и сохраняет настройку в `web.env`, затем `reconfigure` (для настроек обновления — только перезапись таймера). +- **TLS** — при `VIRTUALITY_TLS=1` создаёт самоподписанный сертификат EC P-256 на 10 лет в `config/tls/panel.{key,crt}` (SAN: имя сервера, localhost, адрес), uvicorn слушает `VIRTUALITY_TLS_PORT` (8443), а `virtuality-redirect.service` перенаправляет с HTTP-порта. +- **Мост `br0`** — `bridge [dhcp|static]` делает копию `/etc/netplan`, пишет `60-virtuality-br0.yaml`, проверяет `netplan generate`, взводит таймер `virtuality-netplan-revert` на 120 секунд и применяет; `bridge-confirm` снимает таймер, `bridge-revert` возвращает копию. Панель после перестройки сети сама вызывает подтверждение, когда снова получает ответ от сервера. +- **`storage-use /dev/sdX`** — только пустой диск без разделов: `mkfs.ext4`, перенос текущего содержимого `/var/lib/virtuality`, запись в `/etc/fstab` (`nofail`), монтирование. +- **`passwd`, `timezone`, `power`, `backup-config`, `restore-config`, `rollback`, `maintenance`, `support-bundle`, `uninstall`** — см. README. -Системные сервисы Virtuality. +Журнал действий: `/var/log/virtuality/virtuality-ctl.log`. -Задачи: +--- -- диагностика ноды; -- обновления; -- резервное копирование; -- мониторинг; -- фоновые задачи. +## Службы systemd -### 4. libvirt / QEMU / KVM +Создаются `virtuality-ctl reconfigure`: -Нижний слой виртуализации. +| Юнит | Что делает | +|---|---| +| `virtuality-web.service` | Панель: `uvicorn app:app` от root из `/opt/virtuality/web` с venv `/opt/virtuality/venv`; `Restart=always`. | +| `virtuality-redirect.service` | Только при TLS: HTTP → HTTPS. | +| `virtuality-network.service` | При загрузке заново применяет пробросы портов (`restore_network.py`). | +| `virtuality-auto-update.timer` / `.service` | Ночная проверка обновлений (`scripts/auto_update_check.sh`), 04:00 ± 30 минут; включён при `VIRTUALITY_AUTO_UPDATE=1`. | +| `virtuality-maintenance.timer` / `.service` | Ежедневное обслуживание: старые задачи, временные файлы, недокачанные загрузки, `journalctl --vacuum-size=300M`, запись занятости диска в `config/health.json`. | +| `libvirt-guests.service` | При выключении сервера машины выключаются корректно (`ON_SHUTDOWN=shutdown`, до 180 секунд), при загрузке не запускаются сами — только те, у кого включён автозапуск. | -Задачи: +Отдельно, из образа: `virtuality-firstboot.service` — первая загрузка (см. [IMAGE.md](IMAGE.md)); временные юниты `virtuality-netplan-revert` (автооткат моста), `virtuality-apply-*` (отложенное применение настроек) и `virtuality-update-*` (обновление вне cgroup панели, чтобы перезапуск панели не убил сам процесс обновления). -- запуск виртуальных машин; -- управление дисками; -- управление сетевыми интерфейсами; -- снапшоты; -- консольный доступ. +--- -### 5. Host OS +## Безопасность панели -Операционная система сервера. +- Вход — по имени и паролю системного пользователя (`VIRTUALITY_AUTH_USER`); проверяется по `/etc/shadow`, поэтому панель работает от root. После 5 неверных паролей адрес блокируется на 5 минут; сессия (подписанная cookie, ключ в `config/session_secret`) живёт 12 часов. +- POST-запросы принимаются только с того же origin; за reverse proxy нужно передавать исходный `Host` (и `Upgrade`/`Connection` для экрана машины). +- Экран машины: VNC у всех машин слушает `127.0.0.1`, панель проксирует его в WebSocket по подписанному токену и только при действующей сессии. Порты 5900–5999 в ufw закрыты. +- Пароль cloud-init хранится только как хеш SHA-512; секреты не попадают в журналы и отчёт для поддержки. +- Firewall: `install_virtuality_node.sh` включает ufw и разрешает SSH; `virtuality-ctl` открывает только порт панели (и HTTPS-порт), список открытых портов хранит в `config/ports.state`. -Целевые системы: +--- -- Debian 12; -- Ubuntu Server 22.04; -- Ubuntu Server 24.04. +## Сеть машин -## Пути +### NAT: `virtuality-nat` + +libvirt-сеть с мостом `virbr100`, подсеть `192.168.100.0/24`, шлюз `192.168.100.1`, DHCP `192.168.100.50–200`. Описание сохраняется в `/var/lib/virtuality/network/virtuality-nat.xml`. Создаётся мастером, страницей «Сеть и доступ» или автоматически при создании первой машины в режиме NAT. Включается `net.ipv4.ip_forward` (`/etc/sysctl.d/99-virtuality-forward.conf`) и ослабляется rp_filter (`98-virtuality-rpfilter.conf`). + +**Постоянные адреса.** Для каждой машины MAC-адрес вычисляется из её имени (`stable_mac`), и в сети создаётся резервация DHCP (`virsh net-update add ip-dhcp-host`) — машина всегда получает один и тот же адрес, панель знает его ещё до первого запуска. При удалении машины резервация освобождается. + +**Пробросы портов** хранятся в `/var/lib/virtuality/network/port_forwards.json` и применяются тремя способами сразу, чтобы работать на любом дистрибутиве: + +1. nftables — таблица `ip virtuality` в `/etc/virtuality/nftables/virtuality.nft` (DNAT, forward, masquerade); +2. ufw — правила `route allow`, список сохранённых правил в `network/ufw_rules.json`; +3. iptables — запасной вариант, все правила помечены комментарием `virtuality-forward`, чтобы их можно было найти и удалить. + +Адрес машины определяется через гостевой агент, `virsh domifaddr`, аренды DHCP по MAC машины, либо задаётся вручную. «Проверить» рядом с правилом делает TCP-подключение к машине и показывает команды для диагностики. + +### Домашняя сеть: мост `br0` + +Сервер объединяет свой проводной интерфейс в мост `br0` (netplan), машины подключаются к нему и получают адреса от роутера. Wi-Fi в мост объединить нельзя — тогда доступен только NAT. Как устроен автооткат — выше, в разделе про `virtuality-ctl`. + +--- + +## Хранилище ```text -/opt/virtuality # код и конфигурация проекта -/var/lib/virtuality # runtime-хранилище -/var/lib/virtuality/iso # ISO-образы -/var/lib/virtuality/images # диски VM -/var/lib/virtuality/backups # резервные копии -/var/log/virtuality # логи +/var/lib/virtuality/ +├── iso/ установочные образы; libvirt-пул virtuality-iso +├── images/ диски машин <имя>.qcow2; libvirt-пул virtuality-images +├── disk-images/ готовые образы систем (каталог, ссылки, загрузки с компьютера) +├── backups/ резервные копии машин (<машина>/<ГГГГММДД-ЧЧММ>/) и архивы настроек config-*.tar.gz +├── config/ web.env, setup.json, setup_done, wizard.json, host_profile.json, +│ session_secret, ports.state, health.json, tls/, netplan-backup/ +├── network/ port_forwards.json, ufw_rules.json, virtuality-nat.xml +├── update/ state.json, last_check.json, временные файлы обновления +└── tmp/ загрузки (.<имя>.part, .<имя>.uploading), seed cloud-init; TMPDIR панели ``` -## Принципы +Мастер и «Настройки» умеют отдать под всё это отдельный пустой диск — он монтируется в `/var/lib/virtuality` целиком. Память «спящих» машин libvirt хранит в `/var/lib/libvirt/qemu/save`. -- исходный код отдельно от runtime-данных; -- диски VM и ISO не хранятся в Git; -- конфиги должны быть прозрачными; -- установщик должен быть повторяемым; -- сетевые изменения делаются осторожно, чтобы не уронить SSH; -- архитектура документируется до активной разработки. +Код и служебные файлы: -## Этап v0.1 +```text +/opt/virtuality/source git-репозиторий, из него ставится панель и берутся обновления +/opt/virtuality/web установленная панель (+ .env с пользователем и ключом сессий) +/opt/virtuality/web.prev предыдущая версия для virtuality-ctl rollback +/opt/virtuality/venv virtualenv +/opt/virtuality/virtuality.env пути ноды (пишет install_virtuality_node.sh) +/etc/virtuality/image.env параметры образа (только после установки с ISO) +/etc/virtuality/nftables/ правила пробросов +/var/log/virtuality/ firstboot.log, update.log, virtuality-ctl.log, install_*.log, operations/ +``` -На этапе v0.1 создаётся фундамент: +--- -- установщик ноды; -- установка KVM/QEMU/libvirt; -- установка Cockpit; -- storage pools; -- структура проекта; -- GitHub-синхронизация; -- базовая диагностика. +## Обновления + +1. **Канал** (`VIRTUALITY_UPDATE_CHANNEL` в `web.env`): `stable` — последний тег `vX.Y.Z` (пока тегов нет — ветка `main`); `main` — ветка `main`. Логика одна и та же в `web/update_core.py` и `scripts/update_target.sh`. +2. **Проверка** (`/update/check`, ночной `scripts/auto_update_check.sh`): `git fetch --tags`, сравнение текущего коммита с целевым; обновление предлагается, только если целевой коммит — потомок текущего (нода никогда не откатывается сама). Список изменений берётся из `updates/versions.json` целевой версии. +3. **Применение** (`scripts/apply_github_update.sh`, запускается через `systemd-run`): `git checkout -B main <цель>` и `git clean`; если git недоступен — скачивание ZIP-архива с GitHub и `rsync`; затем `scripts/install_web_panel.sh` (панель копируется в `web.new`, старая переименовывается в `web.prev`, venv обновляется) и `systemctl restart virtuality-web`. Состояние — `/var/lib/virtuality/update/state.json`, журнал — `/var/log/virtuality/update.log`. +4. **Откат**: `virtuality-ctl rollback` меняет местами `web` и `web.prev`. + +Установщики и скрипты вне `web/`: + +```text +install.sh установка одной командой (проверки, пакеты, пользователь, клон, три установщика ниже) +install_virtuality_node.sh пакеты KVM/QEMU/libvirt (x86_64 или ARM64), пулы, ufw, группы пользователя +scripts/install_web_panel.sh панель, venv (офлайн из wheels/ при наличии), virtuality-ctl, юниты +scripts/install_healthcheck_command.sh команда vhealth (scripts/virtuality_healthcheck.sh) +scripts/detect_host_profile.sh профиль сервера для установщика +scripts/virtuality_firstboot.sh первая загрузка после ISO +scripts/auto_update_check.sh, apply_github_update.sh, update_target.sh обновления +scripts/bump_patch_version.py поднять patch-версию в VERSION +image/build-iso.sh, autoinstall.yaml, files/, grub/ сборка ISO +``` + +--- + +## Тесты и CI + +`tests/conftest.py` подменяет `run_cmd`, так что `virsh`, `systemctl`, `qemu-img` и остальные команды имитируются — тесты идут на любой машине без libvirt. `tests/dev_server.py` поднимает демо-панель с теми же подменами. `make check` = pyflakes + shellcheck + pytest; `.github/workflows/ci.yml` запускает это на Python 3.11–3.14, `.github/workflows/image.yml` собирает ISO. diff --git a/docs/FIRST_VM.md b/docs/FIRST_VM.md index 8649db4..99d9ef1 100644 --- a/docs/FIRST_VM.md +++ b/docs/FIRST_VM.md @@ -1,104 +1,138 @@ -# Первая тестовая виртуальная машина +# Первая виртуальная машина -Этот документ фиксирует проверку первого рабочего сценария Virtuality: сеть `br0`, libvirt storage pools и запуск тестовой VM. +Всё в этом документе делается через панель в браузере — терминал не понадобится. Считаем, что Virtuality установлена и мастер настройки пройден (см. README). -## Требования +Сначала — самый быстрый путь: готовая Ubuntu за минуту. Потом — установка Windows 11 с ISO, экран машины, доступ снаружи, снимки и резервные копии. -Перед созданием VM должны быть готовы: +--- -- KVM/QEMU; -- libvirt; -- Cockpit; -- storage pools `virtuality-images` и `virtuality-iso`; -- bridge `br0`; -- default route через `br0`. +## 1. Быстрая машина из каталога: Ubuntu Server 26.04 -Проверка: +Готовые (облачные) образы — это уже установленная система без пароля. Панель скачивает образ, делает из него диск нужного размера и через cloud-init настраивает первый вход: пользователя, пароль, SSH-ключ. + +### Скачать образ + +1. Откройте **Образы ОС** → вкладка **Готовые системы**. +2. В карточке **Каталог готовых систем** найдите **Ubuntu Server 26.04 LTS** (помечена «Рекомендуем») и нажмите **Скачать**. Сервер скачает образ с сайта Ubuntu сам и проверит контрольную сумму; ход виден в **Задачах** и на этой же странице. +3. Когда образ отмечен «Загружен», рядом появится кнопка **Создать машину** — она открывает мастер с уже выбранным образом. + +В каталоге также Ubuntu Server 24.04 LTS, Debian 13 и Alpine Linux 3.23. Свой образ (`.qcow2`, `.img`, `.raw`, `.img.xz`) можно загрузить с компьютера перетаскиванием или скачать по ссылке в карточке **Скачать по ссылке**. + +### Создать машину + +Шаг **1. Название и система**: + +- **Название машины** — латиница, цифры, точка, дефис, подчёркивание (например, `ubuntu-1`). Это же имя станет именем компьютера внутри системы. +- Источник **Готовая система** уже выбран, образ подставлен. +- Переключатель **Настроить первый вход** должен быть включён: у готовых образов нет пароля, без этого шага в машину не войти. + - **Пользователь** — панель подставляет стандартного пользователя образа (`ubuntu`, у Debian — `debian`, у Alpine — `alpine`); можно указать свой. Он получит права администратора (`sudo`). + - **Пароль** — не короче 6 символов. + - **SSH-ключ** — необязательно; вставьте содержимое `~/.ssh/id_ed25519.pub`. Если указать только ключ без пароля, вход по SSH будет только по ключу. + +Шаг **2. Мощность**: выберите профиль — **Мини** (1 ядро, 1 ГБ, 10 ГБ), **Стандарт** (2 ядра, 4 ГБ, 40 ГБ), **Мощная** (4 ядра, 8 ГБ, 80 ГБ) — или задайте ядра, память и диск сами. Диск образа увеличивается до выбранного размера, а система при первой загрузке сама расширит файловую систему. + +Шаг **3. Сеть**: **Автоматически** — машина получит постоянный адрес `192.168.100.x` в отдельной сети и выйдет в интернет через сервер; снаружи её не видно, пока вы не откроете доступ. **В локальной сети** (если в мастере настройки выбран мост) — машина станет устройством вашей домашней сети и получит адрес от роутера. + +Справа в **Итого** проверьте сводку и нажмите **Создать машину**. Панель откроет страницу задачи с живым журналом. Обычно в пределах минуты появится «Машина создана. Вход: ubuntu по паролю. Адрес в сети машин: 192.168.100.x». + +### Войти + +Откройте страницу машины (из **Обзора**) → **Открыть экран** и войдите с указанными пользователем и паролем. В машину сразу ставится гостевой агент, поэтому её адрес виден на странице машины и в **Сети и доступе** — по нему можно зайти по SSH из локальной сети (если выбран мост) или после проброса порта (см. ниже). + +--- + +## 2. Windows 11 с установочного ISO + +### Загрузить ISO + +1. Скачайте ISO Windows 11 с сайта Microsoft на свой компьютер. +2. **Образы ОС** → вкладка **Установочные образы** → перетащите файл в окно загрузки. Или вставьте прямую ссылку на `.iso` в карточку **Скачать по ссылке** — сервер скачает его сам. + +### Создать машину + +1. **Создать машину** → шаг 1: название (например, `win11`), источник **Установить с диска**, выберите ISO. +2. **Что будете устанавливать** → **Windows 11 / Server 2025**. Панель сама включит UEFI и виртуальный TPM 2.0 (без него Windows 11 отказывается ставиться), подберёт диск SATA и сетевую карту Intel — драйверы во время установки не нужны. +3. Шаг 2: профиль **Мощная** (4 ядра, 8 ГБ, 80 ГБ) — Windows 11 требует минимум 4 ГБ памяти и 64 ГБ диска. +4. Шаг 3: сеть — как и для Linux. +5. **Создать машину**. Задача завершится за несколько секунд — машина создана и запущена с установочного диска. + +### Установить систему + +1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова. +2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком. +3. После установки на странице машины откройте вкладку **Диски и загрузка** → **Привод CD/DVD** и извлеките установочный образ, чтобы машина больше не предлагала загрузку с него. + +Для других систем выбирайте тип **Linux** (Ubuntu, Debian, Fedora и другие), **Windows 10 / Server 2019–2022** (классическая загрузка) или **Другая система**. + +--- + +## 3. Экран машины + +- Кнопка **Открыть экран** на странице машины открывает экран в новой вкладке браузера — это как монитор, подключённый к машине. Работает и для установки системы, и когда сеть внутри машины ещё не настроена. +- Если курсор «застрял» внутри экрана, нажмите **Ctrl + Alt**. +- Экран доступен только через панель: VNC-порты машин наружу не открываются, соединение идёт через сервер по вашей сессии. +- Для повседневной работы удобнее подключаться по SSH (Linux) или по удалённому рабочему столу (Windows) — как открыть к ним доступ, ниже. + +--- + +## 4. Доступ к машине снаружи (проброс портов) + +В сети **Автоматически (NAT)** машины не видны ни из домашней сети, ни из интернета, пока вы не откроете нужный порт на сервере. + +1. Откройте **Сеть и доступ** → карточка **Открыть доступ**. +2. **Машина** — выберите машину (рядом показан её адрес). +3. **Что открыть** — сервис из списка: **SSH — удалённый доступ к Linux**, **Сайт (HTTP)**, **Сайт (HTTPS)**, **Удалённый рабочий стол Windows (RDP)**, **Minecraft**, MySQL, PostgreSQL, OpenVPN, WireGuard и другие — или **Другой порт**. +4. **Порт в машине** заполнится сам (для SSH — `22`), **Порт на сервере** — например, `2222`. Протокол TCP или UDP подставляется по сервису. Можно указать диапазон вида `10000-20000`. +5. Нажмите **Открыть доступ**. Теперь `адрес-сервера:2222` ведёт в машину: + + ```bash + ssh ubuntu@адрес-сервера -p 2222 + ``` + + Для Windows выберите **RDP** и подключайтесь приложением «Подключение к удалённому рабочему столу» по адресу `адрес-сервера:3389` (или другому порту сервера, который вы указали). + +Кнопка **Проверить** рядом с правилом покажет, доходит ли трафик до машины и слушает ли она порт. Правила восстанавливаются после перезагрузки сервера сами. Чтобы сервис был доступен из интернета, дополнительно пробросьте тот же порт на роутере на адрес сервера. + +Адреса в сети NAT закреплены за машинами по имени, поэтому пробросы не ломаются после перезагрузок. Если адрес машины не определился (например, система внутри ещё не запущена), в карточке **Адреса машин** его можно указать вручную. + +Машинам в режиме **В локальной сети** проброс не нужен: у них свой адрес от роутера, как у ноутбука или телефона. + +--- + +## 5. Снимок перед рискованными изменениями + +Снимок — точка восстановления машины: перед обновлением системы, экспериментом с настройками или установкой сомнительной программы. + +1. Страница машины → вкладка **Снимки** → карточка **Создать снимок**, при желании впишите описание («перед обновлением») → **Создать снимок**. +2. У работающей машины в снимок попадает и память — она на несколько секунд замрёт, зато вернётся ровно в то же состояние с открытыми программами. У выключенной сохраняется только диск. +3. Если что-то пошло не так — **Вернуться** рядом с нужным снимком. Машина откатится к тому моменту; всё, что сделано после снимка, пропадёт. +4. Ненужные снимки удаляйте: они занимают место внутри диска машины. На одну машину — до 8 снимков. + +Ограничения: снимки работают только с дисками qcow2 (все машины, созданные панелью, такие). Для машин с UEFI-прошивкой (Windows 11, образы Alpine, все машины на ARM64) внутренние снимки в текущей версии libvirt недоступны — панель предупредит; для них используйте резервные копии. + +--- + +## 6. Резервная копия + +Резервная копия — полная копия настроек машины и всех её дисков в отдельной папке на сервере. В отличие от снимка, она не зависит от диска машины и переживёт его повреждение. + +1. Страница машины → вкладка **Резервные копии** → **Создать копию** (заметка по желанию). Работающая машина будет корректно выключена, скопирована и запущена снова — в списке машин в это время видно, что происходит. +2. Копии всех машин собраны в разделе **Резервные копии**; они лежат в `/var/lib/virtuality/backups/<машина>/<дата-время>/`. Ход создания виден в **Задачах**, страницу можно закрыть. +3. **Восстановить**: выберите копию → укажите название — то же (тогда текущую машину придётся заменить, панель попросит подтверждение) или новое (получите вторую машину рядом с первой). Восстановленная машина создаётся выключенной. Файлы копии остаются на месте, восстанавливать из неё можно сколько угодно раз. +4. Перед восстановлением панель проверяет, хватит ли места в `/var/lib/virtuality`. + +Копию можно делать и по расписанию из терминала сервера — например, задачей cron: ```bash -sudo vhealth -ip -br a -ip route -virsh pool-list --all +sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия" ``` -Нормальная сеть: +--- -```text -enp2s0 UP -br0 UP 10.0.0.200/24 -default via 10.0.0.1 dev br0 -``` +## Что дальше -## Создание тестовой VM - -```bash -cd ~/virtuality -git pull -sudo bash scripts/create_test_vm.sh -``` - -По умолчанию создаётся VM: - -```text -Name: test-alpine -RAM: 1024 MB -CPU: 1 vCPU -Disk: 8 GB qcow2 -ISO: Alpine Linux -Network: br0 -Graphics: VNC -``` - -Можно задать имя: - -```bash -sudo bash scripts/create_test_vm.sh test-alpine-2 -``` - -## Проверка - -```bash -virsh list --all -sudo virsh vncdisplay test-alpine -sudo vhealth -``` - -Если `vncdisplay` показывает `:0`, значит порт VNC — `5900`. - -Если показывает `:1`, значит порт VNC — `5901`. - -## Доступ через Cockpit - -Открыть: - -```text -https://SERVER_IP:9090 -``` - -Дальше: - -```text -Virtual machines → test-alpine → Console -``` - -## Что считается успешным результатом - -Этап считается пройденным, если: - -- VM появилась в `virsh list --all`; -- VM запущена; -- консоль открывается в Cockpit или через VNC; -- VM подключена к bridge `br0`; -- `sudo vhealth` не показывает критических ошибок по Virtuality. - -## Следующий этап - -После первой тестовой VM можно переходить к разработке собственной панели управления Virtuality: - -- список VM; -- статусы VM; -- запуск/остановка; -- просмотр storage pools; -- создание VM через UI; -- базовый журнал событий. +- **Клонировать** (меню на странице машины) — точная копия с новым именем и своим адресом, удобно для тестов. +- **Спящий режим** — сохранить память на диск и выключить; после запуска машина продолжит с того же места. +- **Автозапуск** — переключатель на странице машины: машина включится сама после перезагрузки сервера. +- **Ресурсы** — процессор и память можно поменять, когда машина выключена; диск — увеличить в любой момент во вкладке **Диски и загрузка**. +- Раздел **Помощь** в панели повторяет главное из этого документа. diff --git a/docs/IMAGE.md b/docs/IMAGE.md index 7d14b8e..2302572 100644 --- a/docs/IMAGE.md +++ b/docs/IMAGE.md @@ -1,54 +1,38 @@ # Установочный образ Virtuality -Virtuality можно поставить двумя способами: +Загрузочный ISO ставит Virtuality на чистый сервер «с нуля», как Proxmox: записали на флешку, загрузились, ответили на три экрана — после перезагрузки панель уже открыта в браузере, а мастер настройки показывает, как ставятся остальные компоненты. -1. **Скрипт на готовый сервер** — `curl ... install.sh | bash` (см. README). -2. **Установочный ISO** — загрузочный образ «с нуля», как у Proxmox: записываете на флешку, загружаетесь, отвечаете на 3 экрана, после перезагрузки получаете готовую ноду. - -Этот документ про второй способ. - ---- - -## Что внутри образа - -Образ собирается из официального, ничем не изменённого **Ubuntu Server 26.04 LTS live-server ISO** (amd64 или arm64; поддержка до 2031 года) и дополняется: +Образ собирается из официального, ничем не изменённого **Ubuntu Server 26.04 LTS live-server ISO** (amd64 или arm64) и дополняется: ```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» +/autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall) +/virtuality/source # исходники Virtuality нужной версии (git-репозиторий) +/virtuality/source/wheels # Python-колёса панели и pip — для установки без интернета +/virtuality/image.env # параметры сборки: версия, порт панели, автообновление +/virtuality/virtuality-firstboot.service # служба первой загрузки +/boot/grub/grub.cfg # меню «Установить Virtuality X.Y.Z» с фирменной темой +/boot/grub/themes/virtuality/ # тема GRUB ``` Загрузочные записи 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 -``` +Workflow `.github/workflows/image.yml` собирает ISO в GitHub Actions: -Адрес панели также показывается на экране входа консоли. Ход первой настройки: +- автоматически при выпуске — пуш тега `v*` (amd64 и arm64); +- при pull request, который меняет `image/**`, `scripts/virtuality_firstboot.sh`, `web/requirements.txt` или сам workflow; +- вручную: Actions → **Build ISO image** → Run workflow → архитектура `amd64`, `arm64` или `both`. -```bash -sudo journalctl -fu virtuality-firstboot -cat /var/log/virtuality/firstboot.log -``` +После сборки workflow проверяет, что в образе есть загрузочная запись UEFI, `autoinstall.yaml` разбирается как YAML, а в `grub.cfg` есть пункт Virtuality. Образы Ubuntu Server больше лимита GitHub Releases (2 ГБ на файл), поэтому ISO публикуются как **artifacts** запуска (`virtuality-iso-amd64`, `virtuality-iso-arm64`) и хранятся 30 дней. В артефакте — `.iso` и `.sha256`. --- -## Сборка ISO +## Сборка своими руками -Нужна Linux-машина (root не нужен), ~10 GB свободного места и пакеты: +Нужна Linux-машина (root не нужен), около 10 ГБ свободного места и пакеты: ```bash sudo apt install -y xorriso git curl python3-pip @@ -63,81 +47,154 @@ make iso # amd64 make iso ARCH=arm64 # ARM64-серверы с UEFI ``` -Скрипт сам скачает последний Ubuntu 26.04.x live-server ISO, проверит SHA256 и положит результат в `dist/`: +`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`: ```text -dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso -dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso.sha256 +dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso +dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso.sha256 ``` -Скачанный ISO Ubuntu кешируется в `.cache/iso`. Уже скачанный образ можно передать явно: +Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно: ```bash ./image/build-iso.sh --ubuntu-iso ~/Downloads/ubuntu-26.04.1-live-server-amd64.iso ``` -### Параметры +### Параметры `image/build-iso.sh` ```text ---arch amd64|arm64 архитектура +--arch amd64|arm64 архитектура (по умолчанию amd64) --ubuntu 26.04|24.04 база образа (по умолчанию 26.04 LTS) +--ubuntu-iso PATH использовать локальный live-server ISO вместо скачивания +--cache-dir DIR где хранить скачанные ISO Ubuntu (по умолчанию .cache/iso) +--output DIR куда класть результат (по умолчанию dist) --ref GIT_REF какую версию Virtuality положить в образ (по умолчанию HEAD) ---web-port PORT порт web-панели (по умолчанию 8088) ---no-auto-update не включать ночное автообновление с GitHub ---locale / --keyboard / --timezone +--repo-url URL откуда установленная нода будет получать обновления +--web-port PORT порт панели на установленной ноде (по умолчанию 8088) +--no-auto-update не включать ночное автообновление +--locale LOCALE локаль установщика и системы (по умолчанию en_US.UTF-8) +--keyboard LAYOUT раскладка (по умолчанию us) +--timezone TZ часовой пояс (по умолчанию geoip — установщик определит по сети; мастер настройки может сменить) --no-wheels не класть Python-колёса (тогда первая загрузка скачает их из PyPI) ---repo-url URL откуда нода будет получать обновления ``` -Через make параметры передаются так: +Полностью автоматический режим (вопросов нет, **стирается самый большой диск**): + +```text +--unattended автоматическая установка +--username NAME администратор (логин в панель), обязателен с --unattended +--password-hash HASH хеш crypt(3), например от: openssl passwd -6 +--hostname NAME имя сервера (по умолчанию virtuality) +``` + +`./image/build-iso.sh --help` печатает тот же список. Через `make` параметры передаются так: ```bash make iso ISO_ARGS="--web-port 8089 --no-auto-update --timezone Europe/Moscow" ``` +### Что кладётся в образ + +- **Исходники**: `git clone` репозитория на указанный `--ref`; у клона `origin` переключается на `--repo-url`, чтобы нода обновлялась оттуда. +- **Колёса**: `pip download` для Python целевого выпуска (3.14 для Ubuntu 26.04, 3.12 для 24.04) и архитектуры, только бинарные `manylinux`-колёса из `web/requirements.txt`. Сам `pip` тоже кладётся: в Ubuntu Server нет `python3-venv`, и установщик панели поднимает pip из этого колеса в virtualenv, созданный `--without-pip`. Благодаря этому панель ставится до появления сети. +- **`image.env`**: `VIRTUALITY_IMAGE_VERSION`, `VIRTUALITY_IMAGE_BUILT`, `VIRTUALITY_WEB_PORT`, `VIRTUALITY_AUTO_UPDATE` и, в автоматическом режиме, `VIRTUALITY_USER`. Установщик копирует файл в `/etc/virtuality/image.env`, его читает первая загрузка. + ### Полностью автоматическая установка -Для массовой раскатки можно собрать образ, который не задаёт вопросов: +Для массовой раскатки: ```bash make iso ISO_ARGS="--unattended --username admin --password-hash '$(openssl passwd -6)' --hostname node01" ``` -> ⚠️ Такой образ загружается в установку сам (через 10 секунд в меню) и **без подтверждения стирает самый большой диск** машины. Файл получает суффикс `-unattended`. Не оставляйте такую флешку в серверах. +> ⚠️ Такой образ сам уходит в установку через 10 секунд в меню и **без подтверждения стирает самый большой диск** машины. Файл получает суффикс `-unattended`, пункты меню — пометку «автоматически, диск будет стёрт». Не оставляйте такую флешку в серверах. + +--- + +## Что спрашивает установщик + +Меню загрузки: **Установить Virtuality 0.11.0** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти. + +Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu: + +1. **Сеть** — обычно достаточно подтвердить адрес по DHCP. +2. **Диск** — какой диск использовать. Разметка: LVM на весь диск. +3. **Пользователь** — имя сервера, имя пользователя и пароль. Этот пользователь становится администратором панели (и входит по SSH). + +Язык, раскладка, часовой пояс (по умолчанию определяется по сети), SSH-сервер с входом по паролю и всё остальное заданы заранее. В конце установщик копирует исходники в `/opt/virtuality/source`, `image.env` в `/etc/virtuality/` и включает `virtuality-firstboot.service`. Затем — перезагрузка. + +--- + +## Первая загрузка + +`scripts/virtuality_firstboot.sh` запускается службой `virtuality-firstboot.service` один раз. Порядок шагов сделан так, чтобы панель появилась первой и показывала прогресс всего остального: + +| Шаг | Что происходит | Что видно | +|---|---|---| +| `panel` | `scripts/install_web_panel.sh` с `VIRTUALITY_PANEL_ONLY=1`: панель ставится офлайн из колёс и запускается. | Через минуту после загрузки панель отвечает на `http://адрес:8088`; на экране сервера — подсказка `Virtuality настраивается`. | +| `network` | Ожидание маршрута по умолчанию и DNS. | Если через 20 секунд сети нет, мастер пишет «Нет доступа в интернет. Подключите кабель или настройте сеть — установка продолжится сама». | +| `virtualization` | `install_virtuality_node.sh`: пакеты KVM/QEMU/libvirt, пулы, firewall. Самый долгий шаг, 5–15 минут. | Мастер показывает, какой пакет скачивается, распаковывается или настраивается. | +| `tools` | `scripts/install_healthcheck_command.sh`: команда `vhealth`. | | +| `finish` | `virtuality-ctl reconfigure`: юниты, firewall, перезапуск панели; на экран сервера выводится адрес панели и логин. | Мастер переходит к шагу «Сервер». | + +Прогресс пишется в `/var/lib/virtuality/config/setup.json` (`stage`, `message`, список шагов со статусами), мастер опрашивает его через `/api/setup/state`. Каждый шаг идемпотентен и отмечается флагом в `/var/lib/virtuality/.firstboot-steps/`; если что-то упало, systemd перезапускает скрипт через 60 секунд, и он продолжает с первого незавершённого шага — мастер в это время показывает ошибку и хвост журнала. По завершении создаётся `/var/lib/virtuality/.firstboot-done`, и служба больше не запускается. + +Если образ собран с `--no-wheels`, офлайн-установка панели не удастся; скрипт дождётся сети и поставит зависимости из PyPI. + +Смотреть ход установки с сервера: + +```bash +sudo journalctl -fu virtuality-firstboot +cat /var/log/virtuality/firstboot.log +``` + +Логин в панель — пользователь из установщика, пароль — его пароль. Дальше вас ведёт мастер настройки (см. README). + +--- + +## Тема загрузочного меню + +`image/grub/theme.txt` — тема GRUB (gfxmenu), `background.png` — фон 1024×768, `select_*.png` — подсветка выбранного пункта. `build-iso.sh` дописывает в `grub.cfg` подключение темы (`gfxterm`, шрифт `unicode.pf2`, `gfxmode=1024x768`); если модуль или шрифт недоступны, GRUB показывает обычное текстовое меню. Пункты меню переводятся на русский, таймаут — 10 секунд. Контрольная сумма изменённого `grub.cfg` обновляется в `md5sum.txt`, чтобы проверка целостности ISO проходила. + +Фон рендерится из `image/grub/background.html` (тот же стиль, что у страницы входа панели), как описано в `image/grub/README.md`. + +--- + +## Проверка образа + +Сверьте контрольную сумму скачанного или собранного образа: + +```bash +sha256sum -c virtuality-0.11.0-ubuntu-26.04-amd64.iso.sha256 +``` + +Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI): + +```bash +xorriso -indev virtuality-0.11.0-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI +xorriso -osirrox on -indev virtuality-0.11.0-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml +``` --- ## Запись на флешку -Linux / macOS: +Нужна флешка от 4 ГБ. Все данные на ней будут стёрты. -```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](https://rufus.ie) (режим записи DD) или [balenaEtcher](https://etcher.balena.io). +- **macOS**: balenaEtcher. +- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`): -Windows: Rufus (режим DD) или balenaEtcher. + ```bash + sudo dd if=dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync + ``` -Проверка целостности: - -```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 дней. +Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**. --- ## Ограничения -- База образа — 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`. +- База — Ubuntu Server 26.04 LTS или 24.04 LTS (`--ubuntu 24.04`). +- Для первой загрузки нужен интернет: пакеты KVM/libvirt ставятся из репозиториев Ubuntu. Панель и её Python-зависимости уже в образе. +- ARM64-образ рассчитан на серверы с UEFI. Raspberry Pi и Orange Pi 5 загружаются иначе — для них используйте штатный Ubuntu Server для платы и `install.sh`. diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index e331363..ca500f7 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -1,95 +1,77 @@ -# Системные требования Virtuality +# Требования -Virtuality — это серверная система управления виртуализацией на базе KVM/QEMU/libvirt/Cockpit и собственной web-панели. +## Коротко -## Минимальные требования +Подойдёт почти любой компьютер или мини-ПК не старше 10 лет с проводной сетью и включённой в BIOS/UEFI виртуализацией. Для одной-двух небольших машин достаточно 4 ГБ памяти; для Windows и нескольких машин сразу — 16 ГБ и SSD. -Подходят только для тестового стенда и одной-двух лёгких VM. +## Операционная система -```text -CPU: 2 ядра с аппаратной виртуализацией Intel VT-x / AMD-V -RAM: 4 GB -Диск /: минимум 8 GB свободно -Диск под /var/lib/virtuality: минимум 20 GB свободно -OS: Ubuntu Server 24.04 LTS / Debian-like с apt -Network: один проводной интерфейс -``` +Virtuality работает на: -## Рекомендуемые требования +- **Ubuntu Server 26.04 LTS** — основа установочного ISO, рекомендуется; +- **Ubuntu Server 24.04 LTS**; +- **Debian 13**. -Для нормальной работы нескольких VM. +Другие системы на базе Debian/Ubuntu с `apt` скорее всего заработают, но не проверяются. Нужен systemd и netplan (для режима «домашняя сеть»). Установка с ISO делает всё сама; на готовую систему Virtuality ставится скриптом `install.sh` — ставьте её на чистый сервер, а не на рабочий компьютер. -```text -CPU: 4+ ядра с Intel VT-x / AMD-V -RAM: 16+ GB -Диск: 100+ GB SSD/NVMe под /var/lib/virtuality -Network: проводной интерфейс 1 Gbit/s+ -OS: Ubuntu Server 24.04 LTS -``` +## Процессор и память -## Проверки установщика +| | Минимум | Рекомендуется | +|---|---|---| +| Процессор | 2 ядра x86_64 с Intel VT-x / AMD-V | 4+ ядер | +| Память | 4 ГБ | 16 ГБ и больше | +| Системный диск | 8 ГБ свободно на `/` | SSD | +| Диск под машины | 20 ГБ свободно в `/var/lib` | 100+ ГБ SSD/NVMe, лучше отдельный диск | +| Сеть | один проводной интерфейс | 1 Гбит/с | -`bootstrap.sh` и `install_virtuality_node.sh` проверяют: +Как считать память: сервер и панель занимают около 1 ГБ, остальное делят машины. Ubuntu Server внутри машины — от 1 ГБ, Windows 11 — от 4 ГБ (лучше 8). Диск машины занимает место постепенно, по мере заполнения, но резервные копии и снимки требуют запаса: планируйте хотя бы вдвое больше, чем суммарный размер дисков машин. -- наличие root-прав; -- наличие `apt`; -- свободное место на `/`; -- свободное место для `/var/lib`; -- объём RAM; -- количество CPU cores; -- наличие флага аппаратной виртуализации `vmx` или `svm`. +**Виртуализация должна быть включена в BIOS/UEFI** (Intel VT-x / Virtualization Technology, AMD-V / SVM Mode). Без неё машины будут работать через медленную программную эмуляцию; панель предупредит об этом в разделе «Сервер», а `sudo vhealth` покажет ошибку `/dev/kvm missing`. -## Значения по умолчанию +Внутри другой виртуальной машины или на VPS Virtuality работает, только если включена вложенная виртуализация (nested virtualization). + +## ARM64 + +Платы и серверы на ARM64 (Raspberry Pi 4/5, Orange Pi 5, серверы с UEFI) поддерживаются: панель определяет профиль, ставит `qemu-system-arm` и прошивку UEFI для ARM. Гости на таких серверах — **только ARM64** (в каталоге готовых систем есть образы aarch64 для Ubuntu, Debian и Alpine); образы x86_64 на них не запускаются. Установка Windows на ARM64 не проверялась. + +Для Raspberry Pi и Orange Pi желательно 8 ГБ памяти и загрузка с SSD/NVMe. Установочный ISO для них не подходит (они грузятся иначе) — используйте штатный Ubuntu Server для платы и `install.sh`. ARM64-ISO предназначен для серверов с UEFI. + +## Сеть + +- Проводное подключение к роутеру. По Wi-Fi сервер работать может, но режим «домашняя сеть» (мост) недоступен — только NAT. +- Доступ в интернет при установке: первая загрузка после ISO и `install.sh` скачивают пакеты из репозиториев Ubuntu/Debian (сотни мегабайт). Панель после установки в интернет не ходит, кроме проверки обновлений и скачивания образов по вашей команде. +- Порты на сервере: `8088` (панель по HTTP), `8443` (панель по HTTPS), `22` (SSH). Открывать их на роутере наружу не обязательно и без HTTPS не рекомендуется. +- Для сети NAT машинам выделяется подсеть `192.168.100.0/24` — она не должна совпадать с вашей домашней сетью. Если роутер раздаёт именно `192.168.100.x`, выбирайте режим «домашняя сеть» или смените подсеть роутера. +- Если хотите, чтобы сервисы машин были доступны из интернета, потребуется проброс портов на роутере на адрес сервера (и «белый» адрес от провайдера). + +## Что проверяет установщик + +`install.sh` и `install_virtuality_node.sh` перед установкой проверяют права root, наличие `apt`, свободное место на `/` и в `/var/lib`, память, количество ядер и флаг виртуализации `vmx`/`svm`. Пороги можно изменить переменными окружения: ```text VIRTUALITY_MIN_ROOT_FREE_MB=8192 VIRTUALITY_MIN_VAR_FREE_MB=20480 VIRTUALITY_MIN_RAM_MB=4096 VIRTUALITY_MIN_CPU_CORES=2 +VIRTUALITY_SKIP_REQUIREMENTS=1 # отключить проверки совсем — только для тестов ``` -## Переопределение требований - -Для тестового стенда можно временно уменьшить требования: +Например, для тестового стенда с маленьким диском: ```bash -curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/bootstrap.sh | sudo VIRTUALITY_MIN_VAR_FREE_MB=8192 bash +curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_MIN_VAR_FREE_MB=8192 bash ``` -Полностью отключить preflight-проверки можно так: +Установка с ISO эти пороги не применяет: диск и память проверяются мастером настройки, который покажет предупреждение, но не остановит установку. -```bash -curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/bootstrap.sh | sudo VIRTUALITY_SKIP_REQUIREMENTS=1 bash -``` - -Использовать отключение проверок стоит только для тестов. Для реального сервера это плохая идея: VM быстро съедают диск. - -## Как быстро проверить место +Освободить место перед установкой на существующей системе: ```bash df -h -df -i -du -xh / --max-depth=1 2>/dev/null | sort -h -``` - -## Очистка перед установкой - -```bash -sudo apt clean -sudo apt autoclean -sudo apt autoremove -y -sudo rm -rf /var/lib/apt/lists/* -sudo rm -rf /tmp/* /var/tmp/* +sudo apt clean && sudo apt autoremove -y sudo journalctl --vacuum-time=3d ``` -## Где Virtuality хранит данные +## Браузер -```text -/opt/virtuality # системные конфиги и установленная web-панель -/home//virtuality # GitHub-репозиторий проекта -/var/lib/virtuality/iso # ISO-образы -/var/lib/virtuality/images # qcow2-диски VM -/var/lib/virtuality/backups # будущие backup VM -/var/log/virtuality # логи установки и диагностики -``` +Панель работает в актуальных Chrome, Firefox, Safari и Edge, в том числе на телефоне. Экран машины (noVNC) требует поддержки WebSocket — она есть во всех перечисленных. При включённом HTTPS сертификат выпускается самим сервером, поэтому браузер один раз спросит, доверять ли ему. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0fc462d..1c5171f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,115 +1,46 @@ -# Roadmap Virtuality +# Планы -Документ фиксирует направление разработки, чтобы проект не превратился в хаотичный комбайн. +Virtuality — панель для одного домашнего сервера. Этот документ честно фиксирует, что уже есть в версии 0.11 и что планируется дальше. Порядок в списке планов — примерный приоритет, сроков нет. Подробный журнал изменений по версиям — в `updates/versions.json` и в разделе «Обновления» панели. -## v0.1 — Фундамент ноды +## Сделано в 0.11 -Цель: получить рабочую базу для запуска виртуализации. +**Установка и настройка** -Задачи: +- Установочный ISO на базе Ubuntu Server 26.04 LTS (amd64, arm64), сборка `make iso` и в GitHub Actions, полностью автоматический режим. +- Первая загрузка: панель ставится первой и показывает ход установки в браузере, при ошибке продолжает сама. +- Мастер настройки: часовой пояс, пароль, HTTPS, сеть машин (NAT или домашняя сеть с автооткатом), отдельный диск под машины, обновления. +- Страница «Настройки», команда `virtuality-ctl`, установка одной командой на Ubuntu 24.04 / 26.04 и Debian 13. -- базовый установщик `install_virtuality_node.sh`; -- установка KVM/QEMU/libvirt; -- установка Cockpit и Cockpit Machines; -- создание директорий хранения; -- создание libvirt storage pools; -- базовая настройка firewall; -- подготовка GitHub-синхронизации; -- документация проекта. +**Машины** -## v0.2 — Сеть и диагностика +- Создание с ISO с выбором типа системы: Linux, Windows 11 / Server 2025 (UEFI + TPM), Windows 10, другая. +- Машина из готового образа за минуту: каталог (Ubuntu 26.04/24.04, Debian 13, Alpine), скачивание по ссылке, cloud-init с пользователем, паролем и SSH-ключом, гостевой агент. +- Снимки (до 8 на машину), резервные копии с восстановлением, клонирование, увеличение диска, пауза и спящий режим, заметки, живая нагрузка. +- Экран машины в браузере только через панель (VNC закрыт снаружи). -Цель: безопасно подготовить сетевую основу. +**Сеть и сервер** -Задачи: +- Сеть NAT с постоянными адресами, пробросы портов с проверкой и восстановлением после перезагрузки, домашняя сеть через мост `br0`. +- HTTPS для панели, стабильный канал обновлений и ранний доступ, откат, ежедневное обслуживание, отчёт для поддержки, архив настроек. +- Убрано: Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE, старые вспомогательные скрипты. -- диагностический скрипт ноды; -- определение активного сетевого интерфейса; -- безопасная настройка bridge `br0`; -- резервная копия сетевых конфигов перед изменением; -- откат сетевой конфигурации; -- улучшение логов установщика. +## Планируется -## v0.3 — Прототип панели +- **Резервные копии по расписанию из панели** — сейчас копия по расписанию делается только командой в терминале; нужен график и хранение N последних копий. +- **Копии на внешний диск или сетевую папку** — сейчас копии лежат только в `/var/lib/virtuality/backups`. +- **Контейнеры LXC** — лёгкие Linux-контейнеры рядом с машинами, как в Proxmox. +- **Проброс устройств (PCI/USB passthrough)** — видеокарты, USB-ключи, диски целиком в машину. +- **Второй сервер (кластер из двух нод)** — общий список машин, перенос машины между серверами, копии на соседний сервер. +- **Пользователи и роли** — сейчас в панель входит один администратор; нужны дополнительные пользователи с ограниченными правами. +- **Уведомления** — о завершении задач, ошибках, нехватке места и доступных обновлениях (почта, Telegram). +- **Шаблоны машин** — сохранить настроенную машину как шаблон и создавать из него новые. +- **Дополнительные диски и сети у машины** — второй диск, вторая сетевая карта, изменение сети существующей машины. +- **Сертификат Let's Encrypt** — вместо самоподписанного, для доступа к панели по доменному имени. +- **История нагрузки** — графики процессора, памяти и диска за день и неделю по серверу и машинам. +- **Проверка обновлений образов каталога** — предлагать обновить скачанный облачный образ, когда вышел новый. -Цель: начать собственную панель управления. +## Не планируется -Задачи: - -- лёгкая веб-панель; -- авторизация; -- список виртуальных машин; -- статус VM; -- старт/стоп/перезагрузка VM через libvirt; -- отображение storage pools; -- базовый журнал событий. - -## v0.4 — Создание VM - -Цель: добавить мастер создания виртуальных машин. - -Задачи: - -- менеджер ISO; -- форма создания VM; -- выбор CPU/RAM/disk; -- выбор ISO; -- выбор сети; -- создание qcow2-диска; -- запуск установки ОС; -- базовая консоль. - -## v0.5 — Обновления и бэкапы - -Цель: сделать платформу обслуживаемой. - -Задачи: - -- автообновлятор; -- резервная копия конфигов перед обновлением; -- история обновлений; -- ручной backup VM; -- список бэкапов; -- восстановление из backup. - -## v0.6 — Пользователи и роли - -Цель: подготовить систему к реальному использованию несколькими пользователями. - -Задачи: - -- роли: admin, operator, viewer; -- ограничения действий по ролям; -- журнал действий пользователей; -- базовые настройки безопасности. - -## v0.7 — Мониторинг - -Цель: видеть состояние ноды без терминального шаманства. - -Задачи: - -- CPU/RAM/disk usage; -- статус libvirt; -- статус Cockpit; -- количество VM; -- состояние storage pools; -- алерты; -- Telegram/email-уведомления в будущем. - -## v1.0 — Первый стабильный релиз - -Цель: рабочая одиночная нода Virtuality. - -Минимальный состав: - -- установка одной командой; -- управление VM; -- создание VM; -- сеть bridge; -- ISO manager; -- backup/restore; -- web dashboard; -- базовая безопасность; -- документация; -- обновлятор. +- Многосерверные кластеры с общим хранилищем, Ceph, живая миграция — это уровень Proxmox для дата-центров, не для дома. +- Поддержка других гипервизоров, кроме KVM/QEMU/libvirt. +- Гости x86_64 на ARM64-серверах через эмуляцию — слишком медленно, чтобы этим пользоваться. diff --git a/docs/screenshots/backups.png b/docs/screenshots/backups.png new file mode 100644 index 0000000..3b88d40 Binary files /dev/null and b/docs/screenshots/backups.png differ diff --git a/docs/screenshots/catalog.png b/docs/screenshots/catalog.png new file mode 100644 index 0000000..6906f23 Binary files /dev/null and b/docs/screenshots/catalog.png differ diff --git a/docs/screenshots/create.png b/docs/screenshots/create.png index cbb36bc..982dee4 100644 Binary files a/docs/screenshots/create.png and b/docs/screenshots/create.png differ diff --git a/docs/screenshots/dashboard-dark.png b/docs/screenshots/dashboard-dark.png index 5950c53..a64e6ac 100644 Binary files a/docs/screenshots/dashboard-dark.png and b/docs/screenshots/dashboard-dark.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png index 9178074..670d9f9 100644 Binary files a/docs/screenshots/dashboard.png and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png new file mode 100644 index 0000000..be29be6 Binary files /dev/null and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/snapshots.png b/docs/screenshots/snapshots.png new file mode 100644 index 0000000..fb3ff62 Binary files /dev/null and b/docs/screenshots/snapshots.png differ diff --git a/docs/screenshots/vm.png b/docs/screenshots/vm.png index ed26561..db82991 100644 Binary files a/docs/screenshots/vm.png and b/docs/screenshots/vm.png differ diff --git a/docs/screenshots/wizard-install.png b/docs/screenshots/wizard-install.png new file mode 100644 index 0000000..948d852 Binary files /dev/null and b/docs/screenshots/wizard-install.png differ diff --git a/docs/screenshots/wizard-network.png b/docs/screenshots/wizard-network.png new file mode 100644 index 0000000..4d15526 Binary files /dev/null and b/docs/screenshots/wizard-network.png differ diff --git a/image/build-iso.sh b/image/build-iso.sh index 62266ce..22a396e 100755 --- a/image/build-iso.sh +++ b/image/build-iso.sh @@ -30,7 +30,7 @@ PASSWORD_HASH="" HOSTNAME_VALUE="virtuality" LOCALE="en_US.UTF-8" KEYBOARD="us" -TIMEZONE="Etc/UTC" +TIMEZONE="geoip" WITH_WHEELS="1" TARGET_PYTHON="" @@ -50,7 +50,7 @@ Options: --no-auto-update Disable the nightly GitHub auto-update on installed nodes --locale LOCALE Installer/system locale (default: ${LOCALE}) --keyboard LAYOUT Keyboard layout (default: ${KEYBOARD}) - --timezone TZ System timezone (default: ${TIMEZONE}) + --timezone TZ System timezone (default: ${TIMEZONE} — detected by the installer; the wizard can change it) --no-wheels Do not bundle Python wheels (first boot downloads them from PyPI) Unattended mode (no questions at all, ERASES THE LARGEST DISK): @@ -157,8 +157,10 @@ if [[ "$WITH_WHEELS" == "1" ]]; then for glibc_minor in 17 28 31 34 35 39; do platform_args+=(--platform "manylinux_2_${glibc_minor}_${PY_PLATFORM_ARCH}") done + # pip itself is bundled as well: Ubuntu Server has no python3-venv, the panel + # installer bootstraps pip from this wheel into a --without-pip virtualenv. python3 -m pip download --quiet --disable-pip-version-check \ - -r "${PAYLOAD}/source/web/requirements.txt" -d "${PAYLOAD}/source/wheels" \ + -r "${PAYLOAD}/source/web/requirements.txt" pip -d "${PAYLOAD}/source/wheels" \ --only-binary=:all: --implementation cp --python-version "$TARGET_PYTHON" \ --abi "cp${TARGET_PYTHON/./}" --abi abi3 --abi none "${platform_args[@]}" fi @@ -216,20 +218,41 @@ 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: +suffix = " — автоматически, диск будет стёрт" if unattended else "" +text = text.replace('menuentry "Try or Install Ubuntu Server"', 'menuentry "Установить Virtuality %s%s"' % (version, suffix), 1) +text = text.replace('menuentry "Ubuntu Server with the HWE kernel"', 'menuentry "Установить Virtuality %s (новое ядро HWE)%s"' % (version, suffix), 1) +text = text.replace("menuentry 'Boot from next volume'", "menuentry 'Загрузить с другого диска'", 1) +text = text.replace("menuentry 'UEFI Firmware Settings'", "menuentry 'Настройки UEFI'", 1) +text = text.replace("menuentry 'Test memory'", "menuentry 'Проверить память'", 1) +if "Установить 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) +text = re.sub(r"^set timeout=\d+\n", "", text, count=1, flags=re.M) +text = text.replace("loadfont unicode\n", "", 1) +# Branded graphical menu; if a module or the font is missing GRUB falls back to the plain text menu. +theme = """insmod all_video +insmod gfxterm +insmod png +insmod gfxmenu +set gfxmode=1024x768,auto +if loadfont /boot/grub/fonts/unicode.pf2; then + terminal_output gfxterm + set theme=/boot/grub/themes/virtuality/theme.txt + export theme +fi +set default=0 +set timeout=10 +""" +open(path, "w").write(theme + 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 +THEME_DIR="${WORK_DIR}/theme" +mkdir -p "$THEME_DIR" +cp "${IMAGE_DIR}"/grub/theme.txt "${IMAGE_DIR}"/grub/*.png "$THEME_DIR"/ say "Writing ${OUTPUT_ISO}" rm -f "$OUTPUT_ISO" chmod -R a+rX,go-w "$PAYLOAD" @@ -238,6 +261,7 @@ if ! xorriso -indev "$UBUNTU_ISO" -outdev "$OUTPUT_ISO" \ -map "$PAYLOAD" /virtuality \ -map "${WORK_DIR}/autoinstall.yaml" /autoinstall.yaml \ -map "${WORK_DIR}/grub.cfg" /boot/grub/grub.cfg \ + -map "${THEME_DIR}" /boot/grub/themes/virtuality \ -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 diff --git a/image/grub/README.md b/image/grub/README.md new file mode 100644 index 0000000..78fbdc5 --- /dev/null +++ b/image/grub/README.md @@ -0,0 +1,12 @@ +# Тема загрузочного меню + +`theme.txt` — тема GRUB (gfxmenu) для установочного ISO, `background.png` — фон 1024×768, +`select_*.png` — подсветка выбранного пункта. + +Фон рендерится из `background.html` (тот же стиль, что у страницы входа панели): + +```bash +NODE_PATH=$(npm root -g) node tests/render_html.mjs image/grub/background.html image/grub/background.png 1024 768 +``` + +Пиксмапы подсветки — однотонные RGBA 6×6 (110, 96, 255, 150). diff --git a/image/grub/background.html b/image/grub/background.html new file mode 100644 index 0000000..8f8e246 --- /dev/null +++ b/image/grub/background.html @@ -0,0 +1,19 @@ +
+
VirtualityПлатформа виртуализации
+
Ваши серверы.
Под полным контролем.
+
Установщик · KVM · QEMU · libvirt
+
↑ ↓ — выбор пункта, Enter — запуск
+ diff --git a/image/grub/background.png b/image/grub/background.png new file mode 100644 index 0000000..87aa82e Binary files /dev/null and b/image/grub/background.png differ diff --git a/image/grub/select_c.png b/image/grub/select_c.png new file mode 100644 index 0000000..5408fbd Binary files /dev/null and b/image/grub/select_c.png differ diff --git a/image/grub/select_e.png b/image/grub/select_e.png new file mode 100644 index 0000000..5408fbd Binary files /dev/null and b/image/grub/select_e.png differ diff --git a/image/grub/select_n.png b/image/grub/select_n.png new file mode 100644 index 0000000..5408fbd Binary files /dev/null and b/image/grub/select_n.png differ diff --git a/image/grub/select_ne.png b/image/grub/select_ne.png new file mode 100644 index 0000000..5408fbd Binary files /dev/null and b/image/grub/select_ne.png differ diff --git a/image/grub/select_nw.png b/image/grub/select_nw.png new file mode 100644 index 0000000..5408fbd Binary files /dev/null and b/image/grub/select_nw.png differ diff --git a/image/grub/select_s.png b/image/grub/select_s.png new file mode 100644 index 0000000..5408fbd Binary files /dev/null and b/image/grub/select_s.png differ diff --git a/image/grub/select_se.png b/image/grub/select_se.png new file mode 100644 index 0000000..5408fbd Binary files /dev/null and b/image/grub/select_se.png differ diff --git a/image/grub/select_sw.png b/image/grub/select_sw.png new file mode 100644 index 0000000..5408fbd Binary files /dev/null and b/image/grub/select_sw.png differ diff --git a/image/grub/select_w.png b/image/grub/select_w.png new file mode 100644 index 0000000..5408fbd Binary files /dev/null and b/image/grub/select_w.png differ diff --git a/image/grub/theme.txt b/image/grub/theme.txt new file mode 100644 index 0000000..ca62627 --- /dev/null +++ b/image/grub/theme.txt @@ -0,0 +1,41 @@ +desktop-image: "background.png" +desktop-image-scale-method: "stretch" +desktop-color: "#0b0a1f" +title-text: "" +terminal-font: "Unicode Regular" +terminal-left: "7%" +terminal-top: "36%" +terminal-width: "86%" +terminal-height: "50%" +terminal-border: "0" + ++ boot_menu { + left = 7% + top = 36% + width = 60% + height = 42% + item_font = "Unicode Regular" + item_color = "#b9b8cf" + selected_item_color = "#ffffff" + item_height = 36 + item_padding = 10 + item_spacing = 6 + icon_width = 0 + icon_height = 0 + scrollbar = false + selected_item_pixmap_style = "select_*.png" +} + ++ progress_bar { + id = "__timeout__" + left = 7% + top = 86% + width = 60% + height = 24 + font = "Unicode Regular" + text_color = "#ffffff" + fg_color = "#7b6bff" + bg_color = "#1c1a36" + border_color = "#2a2748" + text = "Автоматический запуск через %d с" +} diff --git a/install.sh b/install.sh index 4547bca..9e0fd6a 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,VIRTUALITY_AUTO_UPDATE 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_REPO_URL,VIRTUALITY_PROJECT_BASE_DIR,VIRTUALITY_PROJECT_DIR,VIRTUALITY_INSTALL_VERBOSE,VIRTUALITY_AUTO_UPDATE,VIRTUALITY_BRANCH bash "$tmp_installer" "$@" fi echo "Root or sudo is required. Run: curl -fsSL $SELF_INSTALL_URL | sudo bash" >&2 exit 1 @@ -32,9 +32,7 @@ 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}" +BRANCH="${VIRTUALITY_BRANCH:-main}" WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}" LOG_DIR="/var/log/virtuality" LOG_FILE="${LOG_DIR}/install_$(date +%Y%m%d_%H%M%S).log" @@ -44,7 +42,6 @@ MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}" 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" @@ -118,7 +115,7 @@ header() { clear 2>/dev/null || true echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}" echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality 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} One-command KVM / libvirt / web panel setup ${CYAN}${BOLD}│${RESET}" echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}" echo echo -e "${GRAY}Repository:${RESET} ${REPO_URL}" @@ -251,13 +248,6 @@ ensure_user step "Готовим рабочую директорию в /opt" prepare_project_dir -if [[ "$CLEAN_BEFORE_INSTALL" == "1" && -x "$PROJECT_DIR/scripts/clean_install.sh" ]]; then - step "Очищаем старую кривую установку перед установкой" - run_logged "Старая установка очищена" bash "$PROJECT_DIR/scripts/clean_install.sh" --yes -elif [[ "$CLEAN_BEFORE_INSTALL" == "1" ]]; then - warn "Очистка запрошена, но clean_install.sh пока недоступен. Продолжаю установку." -fi - step "Проверяем место после подготовки пользователя" root_free_after="$(free_mb_for_path /)" if (( root_free_after < MIN_ROOT_FREE_MB )); then @@ -267,14 +257,9 @@ ok "Место после подготовки пользователя: ${root_ step "Клонируем или обновляем репозиторий" if [[ -d "$PROJECT_DIR/.git" ]]; then - run_logged "Репозиторий обновлён" run_as_user "cd '$PROJECT_DIR' && git fetch origin main && git reset --hard origin/main && git pull --ff-only" + run_logged "Репозиторий обновлён (${BRANCH})" run_as_user "cd '$PROJECT_DIR' && git fetch origin '$BRANCH' && git checkout -q -B '$BRANCH' 'origin/$BRANCH'" else - run_logged "Репозиторий склонирован" run_as_user "cd '$PROJECT_BASE_DIR' && git clone '$REPO_URL' source" -fi - -if [[ "$CLEAN_BEFORE_INSTALL" == "1" && -x "$PROJECT_DIR/scripts/clean_install.sh" ]]; then - step "Очищаем старую кривую установку после обновления исходников" - run_logged "Старая установка очищена" bash "$PROJECT_DIR/scripts/clean_install.sh" --yes + run_logged "Репозиторий склонирован (${BRANCH})" run_as_user "cd '$PROJECT_BASE_DIR' && git clone --branch '$BRANCH' '$REPO_URL' source" fi step "Запускаем основной установщик ноды" @@ -283,9 +268,6 @@ run_logged "Virtuality Node установлен" env VIRTUALITY_USER="$INSTALL_ 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-панель" if [[ "$INSTALL_VERBOSE" == "1" ]]; then VIRTUALITY_AUTH_USER="$INSTALL_USER" VIRTUALITY_WEB_PORT="$WEB_PORT" bash "$PROJECT_DIR/scripts/install_web_panel.sh" 2>&1 | tee -a "$LOG_FILE" @@ -313,25 +295,6 @@ else [[ "$web_code" -eq 0 ]] && ok "Web-панель установлена" || fail "Web-панель не установлена" fi -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 @@ -347,18 +310,12 @@ echo -e "${GREEN}${BOLD}╭───────────────── echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Virtuality установка завершена${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 -e "${BOLD}Панель:${RESET} http://${SERVER_IP}:${WEB_PORT}" +echo -e "${BOLD}Логин:${RESET} ${INSTALL_USER} / пароль пользователя Linux" +echo -e "${BOLD}Исходники:${RESET} ${PROJECT_DIR}" +echo -e "${BOLD}Лог:${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 -e "${BOLD}Дальше:${RESET} откройте панель в браузере — мастер настройки поможет включить HTTPS," +echo "выбрать сеть для машин (NAT или ваша локальная сеть) и хранилище." +echo "Если пароль ещё не задан: passwd ${INSTALL_USER}" echo diff --git a/install_virtuality_node.sh b/install_virtuality_node.sh index 0da8ff7..fb93c54 100644 --- a/install_virtuality_node.sh +++ b/install_virtuality_node.sh @@ -3,7 +3,7 @@ set -euo pipefail # ========================================================== # Virtuality Node Installer -# Clean step-by-step installer for KVM/QEMU/libvirt/Cockpit +# Clean step-by-step installer for KVM/QEMU/libvirt # ========================================================== PROJECT_NAME="Virtuality" @@ -13,9 +13,8 @@ ISO_DIR="${STORAGE_DIR}/iso" IMAGES_DIR="${STORAGE_DIR}/images" BACKUP_DIR="${STORAGE_DIR}/backups" LOG_DIR="/var/log/virtuality" -COCKPIT_PORT="9090" LOG_FILE="${LOG_DIR}/install_node_$(date +%Y%m%d_%H%M%S).log" -TOTAL_STEPS=13 +TOTAL_STEPS=12 CURRENT_STEP=0 MIN_ROOT_FREE_MB="${VIRTUALITY_MIN_ROOT_FREE_MB:-8192}" MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}" @@ -23,6 +22,8 @@ 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 +# Keep existing configuration files without asking: there is nobody to answer on first boot. +APT_OPTS=(-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold) ESC="\033" RESET="${ESC}[0m" @@ -45,12 +46,11 @@ print_header() { clear 2>/dev/null || true echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}" echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Node Installer${RESET} ${CYAN}${BOLD}│${RESET}" - echo -e "${CYAN}${BOLD}│${RESET} KVM / QEMU / libvirt / Cockpit virtualization node ${CYAN}${BOLD}│${RESET}" + echo -e "${CYAN}${BOLD}│${RESET} KVM / QEMU / libvirt virtualization node ${CYAN}${BOLD}│${RESET}" echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}" echo echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}" echo -e "${GRAY}Storage:${RESET} ${STORAGE_DIR}" - echo -e "${GRAY}Cockpit:${RESET} ${COCKPIT_PORT}/tcp" echo } @@ -76,15 +76,18 @@ run_logged() { 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" "$description" "$elapsed" "$LOG_FILE" + # The spinner only makes sense on a terminal; first boot writes this output to a log. + if [[ -t 1 ]]; then + elapsed=$(( $(date +%s) - start_ts )) + symbol="${spinner[$((i % ${#spinner[@]}))]}" + printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$description" "$elapsed" "$LOG_FILE" + fi sleep 1 i=$((i + 1)) done - wait "$pid" - code=$? - printf "\r%*s\r" 120 "" + code=0 + wait "$pid" || code=$? + [[ -t 1 ]] && printf "\r%*s\r" 120 "" if [[ "$code" -eq 0 ]]; then ok "$description" else @@ -105,7 +108,7 @@ install_available() { warn "Пакет ${pkg} недоступен в этом дистрибутиве, пропускаю" fi done - run_logged "$description" apt install -y "${available[@]}" + run_logged "$description" apt-get install -y "${APT_OPTS[@]}" "${available[@]}" } service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; } @@ -166,7 +169,9 @@ else fi step "Обновляем apt cache" -run_logged "apt update выполнен" apt update +run_logged "apt update выполнен" apt-get update +# A previous attempt may have stopped half way (power loss, a question from dpkg): finish it first. +run_logged "Незавершённые установки пакетов доделаны" dpkg --configure -a --force-confdef --force-confold step "Устанавливаем базовые утилиты и зависимости диагностики" install_available "Базовые пакеты и диагностические утилиты установлены" \ @@ -180,11 +185,11 @@ case "$(uname -m)" in esac install_available "Пакеты виртуализации установлены" \ "${QEMU_SYSTEM_PACKAGES[@]}" qemu-utils libvirt-daemon-system libvirt-clients virtinst \ - bridge-utils dnsmasq-base swtpm cloud-image-utils + bridge-utils dnsmasq-base swtpm cloud-image-utils xorriso -step "Устанавливаем Cockpit и модули" -install_available "Cockpit установлен" \ - cockpit cockpit-machines cockpit-networkmanager cockpit-storaged cockpit-packagekit +step "Устанавливаем зависимости панели управления" +install_available "Пакеты панели установлены" \ + python3 python3-venv rsync openssl nftables novnc python3-websockify step "Создаём структуру Virtuality" run_logged "Директории Virtuality созданы" mkdir -p "$PROJECT_DIR" "$STORAGE_DIR" "$ISO_DIR" "$IMAGES_DIR" "$BACKUP_DIR" "$LOG_DIR" @@ -208,15 +213,13 @@ fi step "Включаем systemd-сервисы" run_logged "libvirtd включён и запущен" systemctl enable --now libvirtd run_logged "virtlogd включён и запущен" systemctl enable --now virtlogd -run_logged "cockpit.socket включён и запущен" systemctl enable --now cockpit.socket ok "libvirtd: $(service_state libvirtd) / $(service_enabled libvirtd)" ok "virtlogd: $(service_state virtlogd) / $(service_enabled virtlogd)" -ok "cockpit.socket: $(service_state cockpit.socket) / $(service_enabled cockpit.socket)" step "Настраиваем firewall" run_logged "Разрешён OpenSSH" ufw allow OpenSSH -run_logged "Разрешён Cockpit ${COCKPIT_PORT}/tcp" ufw allow "${COCKPIT_PORT}/tcp" -run_logged "Разрешены VNC-порты 5900:5999/tcp" ufw allow 5900:5999/tcp +# Consoles are proxied by the panel; VNC ports stay closed from outside. +ufw delete allow 5900:5999/tcp >> "$LOG_FILE" 2>&1 || true if ufw status | grep -qi inactive; then echo "y" | ufw enable >> "$LOG_FILE" 2>&1 && ok "UFW включён" || warn "Не удалось включить UFW" else @@ -243,7 +246,6 @@ ISO_DIR="${ISO_DIR}" IMAGES_DIR="${IMAGES_DIR}" BACKUP_DIR="${BACKUP_DIR}" LOG_DIR="${LOG_DIR}" -COCKPIT_PORT="${COCKPIT_PORT}" EOF ok "Создан ${PROJECT_DIR}/virtuality.env" @@ -254,11 +256,6 @@ if command -v virsh >/dev/null 2>&1; then else warn "virsh не найден после установки" fi -if ss -tulpn 2>/dev/null | grep -q ':9090'; then - ok "Cockpit слушает порт 9090" -else - warn "Порт 9090 не виден в ss; Cockpit может быть socket-activated" -fi if [[ -e /dev/kvm ]]; then ok "/dev/kvm доступен" else @@ -272,7 +269,6 @@ echo -e "${GREEN}${BOLD}╭───────────────── echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Node завершена${RESET} ${GREEN}${BOLD}│${RESET}" echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}" echo -echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:${COCKPIT_PORT}" echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}" echo -e "${BOLD}ISO:${RESET} ${ISO_DIR}" echo -e "${BOLD}Images:${RESET} ${IMAGES_DIR}" @@ -283,9 +279,7 @@ echo -e "${BOLD}Проверка:${RESET}" echo " virsh list --all" echo " virsh pool-list --all" echo " systemctl status libvirtd --no-pager" -echo " systemctl status cockpit.socket --no-pager" echo line -echo -e "${DIM}Следующий шаг: sudo bash scripts/setup_bridge_br0.sh enp2s0 static${RESET}" -echo -e "${DIM}После bridge: sudo bash scripts/create_test_vm.sh${RESET}" +echo -e "${DIM}Дальше: sudo bash scripts/install_web_panel.sh — панель управления и мастер настройки${RESET}" line diff --git a/scripts/apply_github_update.sh b/scripts/apply_github_update.sh index 2cb435f..711c438 100644 --- a/scripts/apply_github_update.sh +++ b/scripts/apply_github_update.sh @@ -7,8 +7,11 @@ STATE_FILE="${STATE_DIR}/state.json" LOG_FILE="/var/log/virtuality/update.log" REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}" BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}" -REPO_ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip}" TMP_DIR="${STATE_DIR}/tmp" +# shellcheck source=scripts/update_target.sh +. "$(dirname "${BASH_SOURCE[0]}")/update_target.sh" +resolve_update_target +REPO_ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-$TARGET_ZIP_URL}" mkdir -p "$STATE_DIR" "$TMP_DIR" "$(dirname "$LOG_FILE")" @@ -48,7 +51,7 @@ try_run() { fetch_zip_update() { local zip_file extract_dir extracted_root - zip_file="${TMP_DIR}/virtuality-${BRANCH}.zip" + zip_file="${TMP_DIR}/virtuality-${TARGET_LABEL}.zip" extract_dir="${TMP_DIR}/zip-update" rm -rf "$zip_file" "$extract_dir" mkdir -p "$extract_dir" @@ -127,9 +130,10 @@ GIT_UPDATED=0 if [ -d "$SOURCE_DIR/.git" ]; then cd "$SOURCE_DIR" || exit 1 write_state "running" "Получаем изменения из GitHub через git" - if try_run git fetch "$REMOTE" "$BRANCH"; then - TARGET_REF="${REMOTE}/${BRANCH}" - TARGET_COMMIT="$(git rev-parse "$TARGET_REF" 2>/dev/null || true)" + if try_run git fetch --tags --force "$REMOTE" "$BRANCH"; then + resolve_update_target + log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}" + TARGET_COMMIT="$(git rev-parse "${TARGET_REF}^{commit}" 2>/dev/null || true)" if [ -n "$TARGET_COMMIT" ]; then LOCAL_STATUS="$(git status --porcelain 2>/dev/null || true)" if [ -n "$LOCAL_STATUS" ]; then @@ -137,8 +141,8 @@ if [ -d "$SOURCE_DIR/.git" ]; then printf '%s\n' "$LOCAL_STATUS" >> "$LOG_FILE" fi write_state "running" "Синхронизируем исходники с GitHub через git reset" - log "Deploy mode: git reset --hard $TARGET_REF" - if try_run git reset --hard "$TARGET_REF" && try_run git clean -fd; then + log "Deploy mode: git checkout -B ${BRANCH} ${TARGET_LABEL}" + if try_run git checkout -q -f -B "$BRANCH" "$TARGET_COMMIT" && try_run git clean -fd; then GIT_UPDATED=1 else log "git reset/clean failed, ZIP fallback will be used" diff --git a/scripts/auto_update_check.sh b/scripts/auto_update_check.sh index c01c282..0d9ba61 100644 --- a/scripts/auto_update_check.sh +++ b/scripts/auto_update_check.sh @@ -9,7 +9,8 @@ LOG_FILE="/var/log/virtuality/update.log" REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}" BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}" APPLY_SCRIPT="${SOURCE_DIR}/scripts/apply_github_update.sh" -ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip}" +# shellcheck source=scripts/update_target.sh +. "${SOURCE_DIR}/scripts/update_target.sh" mkdir -p "$STATE_DIR" "$(dirname "$LOG_FILE")" @@ -89,16 +90,18 @@ diag_network() { fi if [ -d "$SOURCE_DIR/.git" ]; then - if git fetch --quiet "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then - latest="$(git rev-parse "${REMOTE}/${BRANCH}" 2>/dev/null || true)" + if git fetch --quiet --tags --force "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then + resolve_update_target + log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}" + latest="$(git rev-parse "${TARGET_REF}^{commit}" 2>/dev/null || true)" if [ -n "$latest" ] && [ "$current" = "$latest" ]; then log "no updates: ${current:0:12} / version $current_version" 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}, обновление пропущено" + log "local build ${current:0:12} is not behind ${TARGET_LABEL} (${latest:0:12}); skip to avoid a downgrade" + write_state "idle" "Автообновление: установленная версия новее ${TARGET_LABEL}, обновление пропущено" exit 0 fi log "update may be available or current commit unknown: ${current:0:12} -> ${latest:0:12}" @@ -117,6 +120,6 @@ diag_network() { exit 0 fi - write_state "running" "Автообновление: проверяем и устанавливаем обновление через устойчивый механизм" - VIRTUALITY_SOURCE_DIR="$SOURCE_DIR" VIRTUALITY_UPDATE_REMOTE="$REMOTE" VIRTUALITY_UPDATE_BRANCH="$BRANCH" VIRTUALITY_UPDATE_ZIP_URL="$ZIP_URL" bash "$APPLY_SCRIPT" + write_state "running" "Автообновление: устанавливаем обновление" + VIRTUALITY_SOURCE_DIR="$SOURCE_DIR" VIRTUALITY_UPDATE_REMOTE="$REMOTE" VIRTUALITY_UPDATE_BRANCH="$BRANCH" bash "$APPLY_SCRIPT" ) 9>"$LOCK_FILE" diff --git a/scripts/check_node.sh b/scripts/check_node.sh deleted file mode 100644 index aff3e3c..0000000 --- a/scripts/check_node.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -echo "==================================================" -echo " Virtuality Node Diagnostics" -echo "==================================================" - -echo - echo "[System]" -if [[ -f /etc/os-release ]]; then - . /etc/os-release - echo "OS: ${PRETTY_NAME}" -fi -uname -a - -echo - echo "[CPU virtualization]" -if grep -E -q '(vmx|svm)' /proc/cpuinfo; then - echo "OK: hardware virtualization flag found" -else - echo "WARNING: vmx/svm flag not found" -fi - -echo - echo "[Services]" -for service in libvirtd virtlogd cockpit.socket; do - if systemctl list-unit-files | grep -q "^${service}"; then - echo -n "${service}: " - systemctl is-active "$service" || true - else - echo "${service}: not installed" - fi -done - -echo - echo "[Packages]" -for bin in virsh qemu-system-x86_64 virt-install cockpit-bridge; do - if command -v "$bin" >/dev/null 2>&1; then - echo "OK: $bin -> $(command -v "$bin")" - else - echo "MISSING: $bin" - fi -done - -echo - echo "[libvirt VMs]" -if command -v virsh >/dev/null 2>&1; then - virsh list --all || true -else - echo "virsh not found" -fi - -echo - echo "[libvirt storage pools]" -if command -v virsh >/dev/null 2>&1; then - virsh pool-list --all || true -else - echo "virsh not found" -fi - -echo - echo "[Network interfaces]" -ip -br a || true - -echo - echo "[Routes]" -ip route || true - -echo - echo "[Virtuality paths]" -for dir in /opt/virtuality /var/lib/virtuality /var/lib/virtuality/iso /var/lib/virtuality/images /var/lib/virtuality/backups /var/log/virtuality; do - if [[ -d "$dir" ]]; then - echo "OK: $dir" - else - echo "MISSING: $dir" - fi -done - -echo - echo "[Firewall]" -if command -v ufw >/dev/null 2>&1; then - ufw status || true -else - echo "ufw not installed" -fi - -echo - echo "==================================================" -echo " Diagnostics completed" -echo "==================================================" diff --git a/scripts/clean_install.sh b/scripts/clean_install.sh deleted file mode 100644 index 8cadafe..0000000 --- a/scripts/clean_install.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Virtuality clean install helper. -# Default mode preserves user data: VM disks, ISO files, uploaded disk images and backups. -# Use --purge-data only when you intentionally want to remove Virtuality data directories too. - -PURGE_DATA=0 -YES=0 - -for arg in "$@"; do - case "$arg" in - --purge-data) PURGE_DATA=1 ;; - --yes|-y) YES=1 ;; - *) echo "Unknown argument: $arg" >&2; exit 2 ;; - esac -done - -if [[ "$EUID" -ne 0 ]]; then - exec sudo bash "$0" "$@" -fi - -RED="\033[31m" -GREEN="\033[32m" -YELLOW="\033[33m" -BOLD="\033[1m" -RESET="\033[0m" - -say() { echo -e "${GREEN}✓${RESET} $*"; } -warn() { echo -e "${YELLOW}!${RESET} $*"; } -remove_path() { - local path="$1" - if [[ -e "$path" || -L "$path" ]]; then - rm -rf "$path" - say "removed: $path" - else - warn "not found: $path" - fi -} - -cat </dev/null || true -systemctl disable virtuality-web.service 2>/dev/null || true - -remove_path /etc/systemd/system/virtuality-web.service -systemctl daemon-reload || true - -remove_path /opt/virtuality/web -remove_path /opt/virtuality/venv -remove_path /var/lib/virtuality/config -remove_path /var/lib/virtuality/update -remove_path /var/log/virtuality/update.log -find /var/log/virtuality -maxdepth 1 -type f -name 'install_web_panel_*.log' -delete 2>/dev/null || true -say "old installer logs removed" - -if [[ "$PURGE_DATA" -eq 1 ]]; then - remove_path /var/lib/virtuality/iso - remove_path /var/lib/virtuality/images - remove_path /var/lib/virtuality/disk-images - remove_path /var/lib/virtuality/backups - remove_path /var/lib/virtuality/network -fi - -mkdir -p /opt/virtuality /var/log/virtuality /var/lib/virtuality -say "base directories recreated" - -echo -say "Clean install cleanup finished. Run: sudo bash scripts/install_web_panel.sh" diff --git a/scripts/create_test_vm.sh b/scripts/create_test_vm.sh deleted file mode 100644 index 08c864b..0000000 --- a/scripts/create_test_vm.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -VM_NAME="${1:-test-alpine}" -ISO_URL="${VIRTUALITY_TEST_ISO_URL:-https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.3-x86_64.iso}" -ISO_PATH="/var/lib/virtuality/iso/alpine-standard.iso" -DISK_PATH="/var/lib/virtuality/images/${VM_NAME}.qcow2" -BRIDGE="${VIRTUALITY_BRIDGE:-br0}" -MEMORY="${VIRTUALITY_TEST_VM_MEMORY:-1024}" -VCPUS="${VIRTUALITY_TEST_VM_VCPUS:-1}" -DISK_SIZE="${VIRTUALITY_TEST_VM_DISK_SIZE:-8}" - -if [[ "$EUID" -ne 0 ]]; then - echo "Ошибка: запусти через sudo: sudo bash scripts/create_test_vm.sh" - exit 1 -fi - -if ! command -v virt-install >/dev/null 2>&1; then - echo "Ошибка: virt-install не найден. Запусти install_virtuality_node.sh" - exit 1 -fi - -if ! command -v virsh >/dev/null 2>&1; then - echo "Ошибка: virsh не найден. Запусти install_virtuality_node.sh" - exit 1 -fi - -if ! ip link show "$BRIDGE" >/dev/null 2>&1; then - echo "Ошибка: bridge $BRIDGE не найден. Сначала настрой br0." - ip -br a - exit 1 -fi - -mkdir -p /var/lib/virtuality/iso /var/lib/virtuality/images - -if virsh dominfo "$VM_NAME" >/dev/null 2>&1; then - echo "Виртуальная машина $VM_NAME уже существует." - virsh list --all - exit 0 -fi - -if [[ ! -f "$ISO_PATH" ]]; then - echo "Скачиваю тестовый ISO: $ISO_URL" - wget -O "$ISO_PATH" "$ISO_URL" -fi - -virsh pool-refresh virtuality-iso >/dev/null 2>&1 || true -virsh pool-refresh virtuality-images >/dev/null 2>&1 || true - -echo "Создаю тестовую VM: $VM_NAME" -virt-install \ - --name "$VM_NAME" \ - --memory "$MEMORY" \ - --vcpus "$VCPUS" \ - --disk "path=${DISK_PATH},size=${DISK_SIZE},format=qcow2,bus=virtio" \ - --cdrom "$ISO_PATH" \ - --os-variant generic \ - --network "bridge=${BRIDGE},model=virtio" \ - --graphics vnc,listen=0.0.0.0 \ - --noautoconsole - -echo - echo "VM создана." -virsh list --all - -echo - echo "VNC display:" -virsh vncdisplay "$VM_NAME" || true - -echo - echo "Открыть в Cockpit: https://SERVER_IP:9090 → Virtual machines → ${VM_NAME}" diff --git a/scripts/fix_cockpit_auth.sh b/scripts/fix_cockpit_auth.sh deleted file mode 100644 index 1a83378..0000000 --- a/scripts/fix_cockpit_auth.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Virtuality Cockpit auth helper -# Checks Linux user password state, Cockpit disallowed-users and required groups. - -USER_NAME="${1:-${VIRTUALITY_USER:-${SUDO_USER:-root}}}" -DISALLOWED_FILE="/etc/cockpit/disallowed-users" - -ESC="\033" -RESET="${ESC}[0m" -BOLD="${ESC}[1m" -GREEN="${ESC}[32m" -YELLOW="${ESC}[33m" -RED="${ESC}[31m" -CYAN="${ESC}[36m" -GRAY="${ESC}[90m" - -ok() { echo -e "${GREEN}✓${RESET} $*"; } -warn() { echo -e "${YELLOW}!${RESET} $*"; } -fail() { echo -e "${RED}✗${RESET} $*"; exit 1; } - -if [[ "$EUID" -ne 0 ]]; then - fail "Запусти от root: sudo bash scripts/fix_cockpit_auth.sh [user]" -fi - -clear 2>/dev/null || true -cat </dev/null 2>&1; then - fail "Пользователь ${USER_NAME} не найден" -fi -ok "Пользователь найден: ${USER_NAME}" - -HASH="$(getent shadow "$USER_NAME" | cut -d: -f2 || true)" -if [[ -z "$HASH" || "$HASH" == "!" || "$HASH" == "*" || "$HASH" == "!!" ]]; then - warn "У пользователя ${USER_NAME} пароль не задан или заблокирован" - echo - echo -e "${BOLD}Для входа в Cockpit задай пароль:${RESET}" - if [[ "$USER_NAME" == "root" ]]; then - echo " passwd root" - else - echo " sudo passwd ${USER_NAME}" - fi -else - ok "У пользователя ${USER_NAME} есть пароль" -fi - -if [[ -f "$DISALLOWED_FILE" ]]; then - if grep -qx "$USER_NAME" "$DISALLOWED_FILE"; then - cp "$DISALLOWED_FILE" "${DISALLOWED_FILE}.bak.$(date +%Y%m%d_%H%M%S)" - sed -i "/^${USER_NAME}$/d" "$DISALLOWED_FILE" - ok "Пользователь ${USER_NAME} удалён из ${DISALLOWED_FILE}" - else - ok "Пользователь ${USER_NAME} не запрещён в ${DISALLOWED_FILE}" - fi -else - warn "Файл ${DISALLOWED_FILE} не найден, пропускаю проверку запрета пользователей" -fi - -if [[ "$USER_NAME" != "root" ]]; then - usermod -aG sudo "$USER_NAME" || true - usermod -aG libvirt "$USER_NAME" || true - usermod -aG kvm "$USER_NAME" || true - ok "Пользователь ${USER_NAME} добавлен в группы sudo/libvirt/kvm" - warn "Для применения групп нужно выйти из SSH и войти снова" -else - ok "root не требует добавления в группы" -fi - -if command -v apt >/dev/null 2>&1; then - apt install -y cockpit cockpit-machines sscg >/dev/null 2>&1 || true - ok "Пакеты cockpit/cockpit-machines/sscg проверены" -fi - -systemctl enable --now cockpit.socket >/dev/null 2>&1 || fail "Не удалось запустить cockpit.socket" -ok "cockpit.socket включён и запущен" - -if command -v ufw >/dev/null 2>&1; then - ufw allow 9090/tcp >/dev/null 2>&1 || true - ok "UFW правило 9090/tcp добавлено/проверено" -fi - -systemctl restart cockpit.socket >/dev/null 2>&1 || true -systemctl restart cockpit.service >/dev/null 2>&1 || true -ok "Cockpit перезапущен" - -SERVER_IP="$(hostname -I | awk '{print $1}')" -PUBLIC_IP="$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") {print $(i+1); exit}}')" - -echo -echo -e "${GREEN}${BOLD}Готово.${RESET}" -echo -e "${BOLD}Cockpit local/private:${RESET} https://${SERVER_IP}:9090" -if [[ -n "$PUBLIC_IP" && "$PUBLIC_IP" != "$SERVER_IP" ]]; then - echo -e "${BOLD}Cockpit route IP:${RESET} https://${PUBLIC_IP}:9090" -fi -echo -e "${BOLD}Login:${RESET} ${USER_NAME}" -echo -e "${BOLD}Password:${RESET} пароль Linux-пользователя ${USER_NAME}" -echo diff --git a/scripts/install_console_dashboard.sh b/scripts/install_console_dashboard.sh deleted file mode 100644 index 8de090e..0000000 --- a/scripts/install_console_dashboard.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -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" -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" - exit 1 -fi - -if [[ ! -f "$DASH_SRC" ]]; then - echo "Ошибка: не найден файл $DASH_SRC" - exit 1 -fi - -apt update -apt install -y btop procps iproute2 util-linux coreutils - -install -m 0755 "$DASH_SRC" "$DASH_DST" - -cat > "$SERVICE_FILE" </dev/null 2>&1 || true -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 - BASHRC="$USER_HOME/.bashrc" - touch "$BASHRC" - grep -q "alias bt=" "$BASHRC" || cat >> "$BASHRC" <<'EOF' - -# Virtuality quick aliases -alias bt='btop' -alias dash='sudo systemctl restart virtuality-console-dashboard && sudo chvt 1' -alias dash-stop='sudo systemctl stop virtuality-console-dashboard' -alias dash-start='sudo systemctl start virtuality-console-dashboard' -alias dash-status='systemctl status virtuality-console-dashboard --no-pager' -alias vcheck='cd ~/virtuality && sudo bash scripts/check_node.sh' -EOF - chown "$TARGET_USER:$TARGET_USER" "$BASHRC" || true -fi - -echo "============================================================" -echo "Virtuality console dashboard installed" -echo "============================================================" -echo "Физический монитор: tty1" -echo "Перейти на дашборд: sudo chvt 1" -echo "Команда btop: bt" -echo "Перезапустить дашборд: dash" -echo "Остановить дашборд: dash-stop" -echo "Запустить дашборд: dash-start" -echo "Статус: dash-status" -echo "============================================================" diff --git a/scripts/install_web_panel.sh b/scripts/install_web_panel.sh index d4ea679..45c14ed 100644 --- a/scripts/install_web_panel.sh +++ b/scripts/install_web_panel.sh @@ -1,63 +1,65 @@ #!/usr/bin/env bash set -euo pipefail -# Virtuality Web Panel Installer -# Auto-detects x86_64 / Raspberry Pi ARM64 / Orange Pi 5 ARM64 / generic ARM64 profile. +# ========================================================== +# Virtuality web panel installer +# Copies web/ to /opt/virtuality/web (keeping the previous +# version for rollback), builds the virtualenv (offline from +# bundled wheels when available), installs virtuality-ctl and +# lets it create the systemd units, TLS and firewall rules. +# +# VIRTUALITY_PANEL_ONLY=1 skip apt entirely (first boot from the image, +# before the network is up) +# VIRTUALITY_AUTH_USER Linux user that logs in to the panel +# VIRTUALITY_WEB_PORT HTTP port (default 8088) +# ========================================================== REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WEB_DIR="${REPO_DIR}/web" APP_DIR="/opt/virtuality/web" 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" -NETWORK_SERVICE_FILE="/etc/systemd/system/virtuality-network.service" +CTL_SRC="${REPO_DIR}/scripts/virtuality-ctl" +CTL_DST="/usr/local/bin/virtuality-ctl" 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" +CONFIG_DIR="/var/lib/virtuality/config" +PROFILE_FILE="${CONFIG_DIR}/host_profile.json" +SESSION_SECRET_FILE="${CONFIG_DIR}/session_secret" +NODE_CONFIG_FILE="${CONFIG_DIR}/web.env" +SETUP_STATE_FILE="${CONFIG_DIR}/setup.json" UPLOAD_TMP_DIR="/var/lib/virtuality/tmp" WHEELS_DIR="${VIRTUALITY_WHEELS_DIR:-${REPO_DIR}/wheels}" -TOTAL_STEPS=13 +PANEL_ONLY="${VIRTUALITY_PANEL_ONLY:-0}" export DEBIAN_FRONTEND=noninteractive -# Settings survive updates: explicit env > saved node config > previous install > defaults. +# Settings survive updates: explicit env > saved node config > 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}' } +env_set() { + local key="$1" value="$2" + if grep -q "^${key}=" "$NODE_CONFIG_FILE" 2>/dev/null; then + sed -i "s|^${key}=.*|${key}=${value}|" "$NODE_CONFIG_FILE" + else + printf '%s=%s\n' "$key" "$value" >> "$NODE_CONFIG_FILE" + fi +} 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 +TOTAL_STEPS=9 ESC="\033" RESET="${ESC}[0m" @@ -70,36 +72,22 @@ CYAN="${ESC}[36m" BLUE="${ESC}[34m" GRAY="${ESC}[90m" -mkdir -p "$LOG_DIR" "$PROFILE_DIR" "$UPLOAD_TMP_DIR" +mkdir -p "$LOG_DIR" "$CONFIG_DIR" "$UPLOAD_TMP_DIR" chmod 1777 "$UPLOAD_TMP_DIR" timestamp() { date '+%Y-%m-%d %H:%M:%S'; } line() { printf '%*s\n' 72 '' | tr ' ' '─'; } log() { echo "[$(timestamp)] $*" >> "$LOG_FILE"; } -print_header() { - clear 2>/dev/null || true - echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}" - echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Web Panel Installer${RESET} ${CYAN}${BOLD}│${RESET}" - echo -e "${CYAN}${BOLD}│${RESET} Auto profile: x86 / VPS / Raspberry / Orange Pi 5 ${CYAN}${BOLD}│${RESET}" - echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}" - echo - echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}" - echo -e "${GRAY}Порт панели:${RESET} ${PORT}" - echo -e "${GRAY}Linux auth user:${RESET} ${AUTH_USER}" - echo -} - step() { CURRENT_STEP=$((CURRENT_STEP + 1)) echo echo -e "${BLUE}${BOLD}[${CURRENT_STEP}/${TOTAL_STEPS}]${RESET} ${BOLD}$*${RESET}" log "STEP ${CURRENT_STEP}/${TOTAL_STEPS}: $*" } - 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}Установка остановлена.${RESET} Подробности в логе: ${LOG_FILE}"; exit 1; } +fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Установка остановлена.${RESET} Подробности в логе: ${LOG_FILE}"; tail -n 20 "$LOG_FILE" 2>/dev/null || true; exit 1; } run_logged() { local description="$1" @@ -112,282 +100,149 @@ run_logged() { fi } -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; } -print_header +echo -e "${CYAN}${BOLD}Virtuality — установка панели управления${RESET}" +echo -e "${GRAY}Лог: ${LOG_FILE}${RESET}" -step "Проверяем права и структуру проекта" -require_root -ok "Права root подтверждены" -[[ -d "$WEB_DIR" ]] || fail "Не найдена директория web: $WEB_DIR" -ok "Исходники web найдены: $WEB_DIR" -[[ -f "$WEB_DIR/app.py" ]] || fail "Не найден web/app.py" -ok "FastAPI entrypoint найден: web/app.py" -[[ -f "$WEB_DIR/requirements.txt" ]] || fail "Не найден web/requirements.txt" -ok "requirements.txt найден" +step "Проверяем права и исходники" +[[ "$EUID" -eq 0 ]] || fail "Запустите через sudo: sudo bash scripts/install_web_panel.sh" +[[ -f "$WEB_DIR/app.py" && -f "$WEB_DIR/requirements.txt" ]] || fail "Не найдены исходники панели в ${WEB_DIR}" +[[ -f "$CTL_SRC" ]] || fail "Не найден ${CTL_SRC}" +for cmd in systemctl rsync python3 hostname openssl; do + command -v "$cmd" >/dev/null 2>&1 || fail "Не найдена команда ${cmd}" +done +ok "Исходники: ${WEB_DIR}" +if id "$AUTH_USER" >/dev/null 2>&1; then + ok "Пользователь для входа: ${AUTH_USER}" +else + fail "Пользователь ${AUTH_USER} не найден. Создайте его или передайте VIRTUALITY_AUTH_USER=имя" +fi +if getent shadow "$AUTH_USER" | cut -d: -f2 | grep -Eq '^(!|\*|!!)?$'; then + warn "У пользователя ${AUTH_USER} нет пароля — задайте его: sudo passwd ${AUTH_USER}" +fi -step "Автоматически определяем профиль сборки" +step "Определяем профиль сервера" if [[ -x "${REPO_DIR}/scripts/detect_host_profile.sh" ]]; then - PROFILE="$(${REPO_DIR}/scripts/detect_host_profile.sh 2>>"$LOG_FILE" | tail -n1)" + PROFILE="$("${REPO_DIR}/scripts/detect_host_profile.sh" 2>>"$LOG_FILE" | tail -n1 || true)" else bash "${REPO_DIR}/scripts/detect_host_profile.sh" >>"$LOG_FILE" 2>&1 || true PROFILE="$(json_value profile)" fi -[[ -n "$PROFILE" ]] || PROFILE="x86_64" -ARCH="$(json_value arch)" -MODEL="$(json_value model)" -LABEL="$(json_value label)" -PACKAGE_PROFILE="$(json_value package_profile)" -ok "Профиль: ${LABEL:-$PROFILE}" -ok "Архитектура: ${ARCH:-unknown}" -ok "Модель: ${MODEL:-unknown}" -ok "Конфиг профиля: $PROFILE_FILE" +ok "Профиль: $(json_value label)${PROFILE:+ (${PROFILE})}" -step "Проверяем Linux-пользователя для входа" -if id "$AUTH_USER" >/dev/null 2>&1; then - ok "Пользователь найден: $AUTH_USER" +step "Системные пакеты" +if [[ "$PANEL_ONLY" == "1" ]]; then + ok "Пропущено (VIRTUALITY_PANEL_ONLY=1): пакеты поставит установщик ноды" +elif command -v apt-get >/dev/null 2>&1; then + run_logged "apt update выполнен" apt-get update + run_logged "Пакеты панели установлены" apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold python3 python3-venv rsync openssl curl nftables novnc python3-websockify qemu-utils virtinst libvirt-clients cloud-image-utils else - fail "Пользователь $AUTH_USER не найден. Создай его или передай VIRTUALITY_AUTH_USER=username" -fi -if getent shadow "$AUTH_USER" | cut -d: -f2 | grep -Eq '^(!|\*|!!)?$'; then - warn "У пользователя $AUTH_USER может быть заблокирован пароль. Установи пароль: sudo passwd $AUTH_USER" -else - ok "У пользователя $AUTH_USER есть пароль для Linux-auth" + warn "apt не найден — пакеты не устанавливались" fi -step "Проверяем базовые команды" -for cmd in apt systemctl rsync python3 hostname openssl; do - if command -v "$cmd" >/dev/null 2>&1; then - ok "$cmd найден: $(command -v "$cmd")" - else - fail "$cmd не найден" - fi -done +step "Копируем панель в ${APP_DIR}" +run_logged "Новая версия скопирована" rsync -a --delete --exclude='__pycache__/' --exclude='.env' "$WEB_DIR/" "${APP_DIR}.new/" +install -m 0644 "${REPO_DIR}/VERSION" "${APP_DIR}.new/VERSION" +FRESH_INSTALL=1 +if [[ -d "$APP_DIR" ]]; then + FRESH_INSTALL=0 + rm -rf "${APP_DIR}.prev" + mv "$APP_DIR" "${APP_DIR}.prev" + ok "Предыдущая версия сохранена в ${APP_DIR}.prev (virtuality-ctl rollback)" +fi +mv "${APP_DIR}.new" "$APP_DIR" +ok "Версия панели: $(cat "${APP_DIR}/VERSION")" -step "Обновляем apt cache" -run_logged "apt update выполнен" apt update - -step "Устанавливаем системные зависимости под выбранную сборку" -COMMON_PACKAGES=(python3 python3-venv python3-pip rsync openssl curl wget unzip nftables novnc python3-websockify) -X86_PACKAGES=(qemu-system-x86 qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils) -ARM_PACKAGES=(qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils) -PACKAGES=("${COMMON_PACKAGES[@]}") -case "$PACKAGE_PROFILE" in - raspberry|orangepi5|arm64) - PACKAGES+=("${ARM_PACKAGES[@]}") - ;; - *) - PACKAGES+=("${X86_PACKAGES[@]}") - ;; -esac -run_logged "Системные пакеты установлены для профиля ${PACKAGE_PROFILE:-x86}" apt install -y "${PACKAGES[@]}" -if [[ "${PACKAGE_PROFILE:-x86}" != "raspberry" && "${PACKAGE_PROFILE:-x86}" != "orangepi5" && "${PACKAGE_PROFILE:-x86}" != "arm64" ]]; then - if command -v qemu-system-aarch64 >/dev/null 2>&1; then - ok "ARM64 QEMU эмулятор найден: $(command -v qemu-system-aarch64)" +step "Python-окружение" +if [[ -x "$VENV_DIR/bin/python" ]] && "$VENV_DIR/bin/python" -c 'import sys' >/dev/null 2>&1; then + ok "Virtualenv уже есть: ${VENV_DIR}" +else + [[ -d "$VENV_DIR" ]] && { warn "Virtualenv повреждён (например, после обновления Python) — пересоздаём"; rm -rf -- "${VENV_DIR:?}"; } + if python3 -c 'import ensurepip' >/dev/null 2>&1; then + run_logged "Virtualenv создан" python3 -m venv "$VENV_DIR" else - fail "Не найден qemu-system-aarch64. Установи пакет: sudo apt install -y qemu-system-arm qemu-efi-aarch64" + # Ubuntu Server without python3-venv: pip is bootstrapped from the bundled wheel. + run_logged "Virtualenv создан (без pip)" python3 -m venv --without-pip "$VENV_DIR" fi fi - -step "Копируем web-панель в /opt/virtuality" -run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality -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" -else - warn "Профиль не найден после детекта, будет создан web-панелью при старте" +if ! "$VENV_DIR/bin/python" -m pip --version >/dev/null 2>&1; then + PIP_WHEEL="$(ls "${WHEELS_DIR}"/pip-*.whl 2>/dev/null | head -n1 || true)" + [[ -n "$PIP_WHEEL" ]] || fail "В virtualenv нет pip и нет wheel pip в ${WHEELS_DIR}. Установите python3-venv: sudo apt install python3-venv" + run_logged "pip установлен из ${PIP_WHEEL##*/}" "$VENV_DIR/bin/python" "${PIP_WHEEL}/pip" install --no-index --find-links "$WHEELS_DIR" pip fi +if compgen -G "${WHEELS_DIR}/*.whl" >/dev/null; then + run_logged "Зависимости установлены офлайн из ${WHEELS_DIR}" "$VENV_DIR/bin/python" -m pip install --no-index --find-links "$WHEELS_DIR" -r "$APP_DIR/requirements.txt" +else + run_logged "Зависимости установлены из PyPI" "$VENV_DIR/bin/python" -m pip install --disable-pip-version-check -r "$APP_DIR/requirements.txt" +fi +run_logged "Панель импортируется без ошибок" bash -c "cd '$APP_DIR' && '$VENV_DIR/bin/python' -c 'import app'" -step "Создаём конфиг авторизации" +step "Настройки ноды" +touch "$NODE_CONFIG_FILE" +grep -q '^#' "$NODE_CONFIG_FILE" || sed -i '1i # Настройки ноды Virtuality. Менять: sudo virtuality-ctl set KEY=VALUE' "$NODE_CONFIG_FILE" +env_set VIRTUALITY_WEB_PORT "$PORT" +env_set VIRTUALITY_AUTH_USER "$AUTH_USER" +env_set VIRTUALITY_AUTO_UPDATE "$AUTO_UPDATE" +env_set VIRTUALITY_SOURCE_DIR "$REPO_DIR" +[[ -n "$(saved_setting VIRTUALITY_WEB_HOST)" ]] || env_set VIRTUALITY_WEB_HOST 0.0.0.0 +[[ -n "$(saved_setting VIRTUALITY_TLS)" ]] || env_set VIRTUALITY_TLS 0 +[[ -n "$(saved_setting VIRTUALITY_UPDATE_CHANNEL)" ]] || env_set VIRTUALITY_UPDATE_CHANNEL stable +chmod 644 "$NODE_CONFIG_FILE" +ok "Сохранено в ${NODE_CONFIG_FILE}" if [[ -s "$SESSION_SECRET_FILE" ]]; then SESSION_SECRET="$(cat "$SESSION_SECRET_FILE")" - ok "Используем существующий session secret: $SESSION_SECRET_FILE" else SESSION_SECRET="$(openssl rand -hex 32)" printf '%s\n' "$SESSION_SECRET" > "$SESSION_SECRET_FILE" chmod 600 "$SESSION_SECRET_FILE" - ok "Создан постоянный session secret: $SESSION_SECRET_FILE" + ok "Создан ключ сессий: ${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 "Устанавливаем virtuality-ctl" +install -m 0755 "$CTL_SRC" "$CTL_DST" +ok "${CTL_DST}" -step "Устанавливаем Python-зависимости" -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" < "$AUTO_UPDATE_SERVICE_FILE" < "$AUTO_UPDATE_TIMER_FILE" <> "$LOG_FILE" 2>&1 || true - ok "Автообновление отключено (VIRTUALITY_AUTO_UPDATE=0). Обновления — вручную через /update" - fi -else - warn "auto_update_check.sh не найден, автообновление пропущено" -fi - -step "Запускаем Virtuality Web Panel" -run_logged "virtuality-web.service включён и запущен" systemctl enable --now virtuality-web.service +step "Службы, firewall и запуск" +run_logged "Юниты созданы, панель запущена" env VIRTUALITY_SOURCE_DIR="$REPO_DIR" "$CTL_DST" reconfigure sleep 2 -STATE="$(service_state virtuality-web.service)" -if [[ "$STATE" == "active" ]]; then - ok "virtuality-web.service active" +if systemctl is-active --quiet virtuality-web.service; then + ok "virtuality-web.service работает" else - warn "virtuality-web.service состояние: $STATE" journalctl -u virtuality-web.service -n 40 --no-pager >> "$LOG_FILE" 2>&1 || true + fail "virtuality-web.service не запустился" fi -step "Настраиваем firewall и проверяем порт" -if command -v ufw >/dev/null 2>&1; then - ufw allow "${PORT}/tcp" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить UFW правило для ${PORT}/tcp" - ok "UFW правило добавлено/проверено: ${PORT}/tcp" +step "Мастер настройки" +if [[ -f "$SETUP_STATE_FILE" ]]; then + ok "Состояние установки уже ведётся" +elif [[ "${VIRTUALITY_FIRSTBOOT:-0}" == "1" ]]; then + ok "Прогресс пишет первая загрузка" +elif [[ "$FRESH_INSTALL" != "1" ]]; then + ok "Обновление уже настроенного сервера — мастер не нужен (его можно запустить в Настройках)" else - warn "ufw не установлен, firewall-правило пропущено" -fi -if command -v ss >/dev/null 2>&1 && ss -tulpn | grep -q ":${PORT}"; then - ok "Порт ${PORT} слушается" -else - warn "Порт ${PORT} пока не найден в ss; проверь service logs" + # A node installed by hand gets the same first-run wizard as one installed from the image. + printf '{"stage": "done", "message": "", "steps": []}\n' > "$SETUP_STATE_FILE" + ok "Мастер настройки откроется при первом входе в панель" fi -SERVER_IP="$(hostname -I | awk '{print $1}')" - -echo -echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}" -echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Web Panel завершена${RESET} ${GREEN}${BOLD}│${RESET}" -echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}" -echo -echo -e "${BOLD}URL:${RESET} http://${SERVER_IP}:${PORT}" -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} $([[ "$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" -echo -e "${BOLD}Logs:${RESET} journalctl -u virtuality-web -f" -echo -e "${BOLD}Install log:${RESET} ${LOG_FILE}" +PANEL_URL="$("$CTL_DST" url 2>/dev/null || echo "http://$(hostname -I | awk '{print $1}'):${PORT}")" echo +echo -e "${GREEN}${BOLD}Панель управления установлена${RESET}" line -echo -e "${DIM}Следующий шаг: /host, /network, /logs, /update, /vm/create, /vm/NAME/console.${RESET}" +echo -e "${BOLD}Адрес:${RESET} ${PANEL_URL}" +echo -e "${BOLD}Логин:${RESET} ${AUTH_USER} (пароль пользователя Linux)" +echo -e "${BOLD}Служба:${RESET} systemctl status virtuality-web --no-pager" +echo -e "${BOLD}Журнал:${RESET} journalctl -u virtuality-web -f" +echo -e "${BOLD}Настройки:${RESET} sudo virtuality-ctl status" line +echo -e "${DIM}Автообновление: $([[ "$AUTO_UPDATE" == "1" ]] && echo "включено (ночью)" || echo "отключено")${RESET}" diff --git a/scripts/setup_bridge_br0.sh b/scripts/setup_bridge_br0.sh deleted file mode 100644 index df4fa05..0000000 --- a/scripts/setup_bridge_br0.sh +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -IFACE="${1:-enp2s0}" -BRIDGE="br0" -NETPLAN_DIR="/etc/netplan" -BACKUP_DIR="/root/virtuality-netplan-backups/$(date +%Y%m%d_%H%M%S)" -BRIDGE_FILE="${NETPLAN_DIR}/60-virtuality-br0.yaml" -ROLLBACK_SCRIPT="/root/virtuality-rollback-netplan.sh" -MODE="${2:-dhcp}" - -if [[ "$EUID" -ne 0 ]]; then - echo "Ошибка: запусти через sudo: sudo bash scripts/setup_bridge_br0.sh enp2s0" - exit 1 -fi - -if ! command -v netplan >/dev/null 2>&1; then - echo "Ошибка: netplan не найден." - exit 1 -fi - -if ! ip link show "$IFACE" >/dev/null 2>&1; then - echo "Ошибка: интерфейс $IFACE не найден." - ip -br a - exit 1 -fi - -CURRENT_IP="$(ip -4 addr show "$IFACE" | awk '/inet / {print $2; exit}')" -CURRENT_GW="$(ip route | awk '/default/ {print $3; exit}')" - -# Use stable IPv4 DNS by default. Link-local IPv6 DNS can break YAML/routes without scope id. -DNS_1="${VIRTUALITY_DNS_1:-10.0.0.1}" -DNS_2="${VIRTUALITY_DNS_2:-1.1.1.1}" - -echo "============================================================" -echo "Virtuality safe bridge setup" -echo "============================================================" -echo "Interface: $IFACE" -echo "Bridge: $BRIDGE" -echo "Mode: $MODE" -echo "Current IP: ${CURRENT_IP:-unknown}" -echo "Current gateway: ${CURRENT_GW:-unknown}" -echo "DNS: ${DNS_1}, ${DNS_2}" -echo "Backup: $BACKUP_DIR" -echo - -echo "ВАЖНО: скрипт использует netplan try." -echo "Если сеть пропадёт, netplan должен автоматически откатить изменения." -echo "Если видишь подтверждение — нажми Enter, только если SSH/сеть не отвалилась." -echo - -mkdir -p "$BACKUP_DIR" -cp -a "$NETPLAN_DIR"/*.yaml "$BACKUP_DIR"/ 2>/dev/null || true - -cat > "$ROLLBACK_SCRIPT" </dev/null || true -netplan generate -netplan apply -echo "Netplan rollback applied from $BACKUP_DIR" -EOF -chmod +x "$ROLLBACK_SCRIPT" - -# Keep existing netplan files neutral for this interface to avoid duplicate DHCP/IP. -for file in "$NETPLAN_DIR"/*.yaml; do - [[ "$file" == "$BRIDGE_FILE" ]] && continue - if grep -q "${IFACE}:" "$file"; then - cp -a "$file" "${file}.virtuality-before-br0" - cat > "$file" < "$BRIDGE_FILE" < "$BRIDGE_FILE" </dev/null || true - -echo "[1/5] Generated config:" -cat "$BRIDGE_FILE" - -echo - echo "[2/5] Validating netplan..." -netplan generate - -echo - echo "[3/5] Testing config with netplan try..." -echo "Если после применения сеть работает — подтверди netplan try клавишей Enter." -echo "Если SSH зависнет — жди автооткат или используй физический монитор: sudo $ROLLBACK_SCRIPT" -netplan try --timeout 30 - -echo - echo "[4/5] Applying final netplan..." -netplan apply -sleep 3 - -echo - echo "[5/5] Result:" -ip -br a -ip route - -echo - echo "============================================================" -echo "Bridge setup completed" -echo "============================================================" -echo "Rollback command: sudo $ROLLBACK_SCRIPT" -echo "If DHCP mode failed, try static mode:" -echo "sudo bash scripts/setup_bridge_br0.sh ${IFACE} static" -echo "============================================================" diff --git a/scripts/update_target.sh b/scripts/update_target.sh new file mode 100644 index 0000000..9bfa11b --- /dev/null +++ b/scripts/update_target.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Sourced by auto_update_check.sh and apply_github_update.sh: which git ref a node updates to. +# stable (default) — the latest release tag vX.Y.Z; main until the first release is tagged +# main — every change on the main branch +# Needs SOURCE_DIR, REMOTE and BRANCH; sets UPDATE_CHANNEL, TARGET_REF, TARGET_LABEL, TARGET_ZIP_URL. + +update_channel() { + local value="${VIRTUALITY_UPDATE_CHANNEL:-}" + if [[ -z "$value" && -f /var/lib/virtuality/config/web.env ]]; then + value="$(sed -n 's/^VIRTUALITY_UPDATE_CHANNEL=//p' /var/lib/virtuality/config/web.env | tail -n1 | tr -d "\"'")" + fi + [[ "$value" == "main" ]] && echo main || echo stable +} + +resolve_update_target() { + local tag="" + UPDATE_CHANNEL="$(update_channel)" + TARGET_REF="${REMOTE}/${BRANCH}" + TARGET_LABEL="${BRANCH}" + TARGET_ZIP_URL="https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip" + if [[ "$UPDATE_CHANNEL" == "stable" && -d "${SOURCE_DIR}/.git" ]]; then + tag="$(git -C "$SOURCE_DIR" tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+(\.[0-9]+)*$' | head -n1 || true)" + if [[ -n "$tag" ]]; then + TARGET_REF="refs/tags/${tag}" + TARGET_LABEL="$tag" + TARGET_ZIP_URL="https://github.com/viktor138irk/virtuality/archive/refs/tags/${tag}.zip" + fi + fi +} diff --git a/scripts/virtuality-ctl b/scripts/virtuality-ctl new file mode 100755 index 0000000..62d01ec --- /dev/null +++ b/scripts/virtuality-ctl @@ -0,0 +1,744 @@ +#!/usr/bin/env bash +# virtuality-ctl — node management for Virtuality. +# Installed to /usr/local/bin by scripts/install_web_panel.sh. Run as root. +set -euo pipefail + +CONFIG_DIR="/var/lib/virtuality/config" +NODE_ENV="${CONFIG_DIR}/web.env" +TLS_DIR="${CONFIG_DIR}/tls" +STATE_FILE="${CONFIG_DIR}/ports.state" +DATA_DIR="/var/lib/virtuality" +LOG_DIR="/var/log/virtuality" +APP_DIR="/opt/virtuality/web" +VENV_DIR="/opt/virtuality/venv" +UNIT_DIR="/etc/systemd/system" +NETPLAN_DIR="/etc/netplan" +NETPLAN_BACKUP="${CONFIG_DIR}/netplan-backup" +BRIDGE_FILE="${NETPLAN_DIR}/60-virtuality-br0.yaml" +REVERT_UNIT="virtuality-netplan-revert" + +usage() { + cat < [options] + + status Что установлено, порты, адреса панели + url Адрес панели управления + reconfigure Пересоздать systemd-юниты из ${NODE_ENV} и перезапустить панель + set KEY=VALUE ... Изменить настройку в web.env (например set VIRTUALITY_TLS=1) и применить + passwd [USER] Сменить пароль пользователя панели (пароль читается со стандартного ввода) + timezone Установить часовой пояс сервера + power reboot|poweroff Перезагрузить или выключить сервер (машины выключаются корректно) + backup-config [FILE] Сохранить настройки ноды и описания машин в архив + restore-config FILE Восстановить настройки ноды из архива + rollback Вернуть предыдущую версию панели после неудачного обновления + maintenance Ежедневное обслуживание: старые задачи, временные файлы, свободное место + bridge [dhcp|static] Подключить машины к локальной сети через мост br0 с автооткатом + bridge-confirm Подтвердить новую сеть (отменяет автооткат) + bridge-revert Вернуть прежнюю сеть + storage-use /dev/sdX Отдать пустой диск под хранилище машин (${DATA_DIR}) + support-bundle [FILE] Собрать отчёт для поддержки + uninstall [--purge] [--yes] Удалить Virtuality (по умолчанию машины и образы остаются) +EOF +} + +die() { echo "virtuality-ctl: $*" >&2; exit 1; } +need_root() { [[ "$EUID" -eq 0 ]] || die "нужны права root: sudo virtuality-ctl $*"; } +now() { date '+%Y-%m-%d %H:%M:%S'; } +log() { mkdir -p "$LOG_DIR"; echo "[$(now)] [virtuality-ctl] $*" >> "${LOG_DIR}/virtuality-ctl.log"; } + +# ---------------------------------------------------------------- settings +env_get() { + local key="$1" default="${2:-}" + local value="" + if [[ -f "$NODE_ENV" ]]; then + value="$(sed -n "s/^${key}=//p" "$NODE_ENV" | tail -n1 | tr -d '"' | tr -d "'")" + fi + printf '%s' "${value:-$default}" +} + +env_set() { + local key="$1" value="$2" + mkdir -p "$CONFIG_DIR" + touch "$NODE_ENV" + if grep -q "^${key}=" "$NODE_ENV"; then + sed -i "s|^${key}=.*|${key}=${value}|" "$NODE_ENV" + else + printf '%s=%s\n' "$key" "$value" >> "$NODE_ENV" + fi + chmod 644 "$NODE_ENV" +} + +primary_ip() { hostname -I 2>/dev/null | awk '{print $1}'; } +# Source checkout used by updates; the installer records it in web.env. +SOURCE_DIR="${VIRTUALITY_SOURCE_DIR:-$(env_get VIRTUALITY_SOURCE_DIR /opt/virtuality/source)}" + +panel_url() { + local tls port + tls="$(env_get VIRTUALITY_TLS 0)" + if [[ "$tls" == "1" ]]; then + port="$(env_get VIRTUALITY_TLS_PORT 8443)" + printf 'https://%s:%s' "$(primary_ip)" "$port" + else + port="$(env_get VIRTUALITY_WEB_PORT 8088)" + printf 'http://%s:%s' "$(primary_ip)" "$port" + fi +} + +# ---------------------------------------------------------------- tls +ensure_cert() { + local ip host + mkdir -p "$TLS_DIR" + chmod 700 "$TLS_DIR" + if [[ -s "${TLS_DIR}/panel.key" && -s "${TLS_DIR}/panel.crt" ]]; then + return 0 + fi + ip="$(primary_ip)" + host="$(hostname)" + local san="DNS:${host},DNS:localhost,IP:127.0.0.1" + [[ -n "$ip" ]] && san="${san},IP:${ip}" + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 3650 \ + -subj "/CN=Virtuality on ${host}" -addext "subjectAltName=${san}" \ + -keyout "${TLS_DIR}/panel.key" -out "${TLS_DIR}/panel.crt" >/dev/null 2>&1 \ + || die "не удалось создать TLS-сертификат (нужен openssl)" + chmod 600 "${TLS_DIR}/panel.key" + log "TLS certificate created for ${host} ${ip}" +} + +# ---------------------------------------------------------------- units +write_units() { + local port host tls tls_port auth_user auto_update + port="$(env_get VIRTUALITY_WEB_PORT 8088)" + host="$(env_get VIRTUALITY_WEB_HOST 0.0.0.0)" + tls="$(env_get VIRTUALITY_TLS 0)" + tls_port="$(env_get VIRTUALITY_TLS_PORT 8443)" + auth_user="$(env_get VIRTUALITY_AUTH_USER root)" + auto_update="$(env_get VIRTUALITY_AUTO_UPDATE 1)" + local listen_port="$port" ssl_args="" + if [[ "$tls" == "1" ]]; then + ensure_cert + listen_port="$tls_port" + ssl_args="--ssl-keyfile ${TLS_DIR}/panel.key --ssl-certfile ${TLS_DIR}/panel.crt" + env_set VIRTUALITY_COOKIE_SECURE 1 + else + env_set VIRTUALITY_COOKIE_SECURE 0 + fi + + cat > "${UNIT_DIR}/virtuality-web.service" < "${UNIT_DIR}/virtuality-redirect.service" < "${UNIT_DIR}/virtuality-network.service" < "${UNIT_DIR}/virtuality-auto-update.service" < "${UNIT_DIR}/virtuality-auto-update.timer" < "${UNIT_DIR}/virtuality-maintenance.service" < "${UNIT_DIR}/virtuality-maintenance.timer" < /etc/logrotate.d/virtuality < /etc/default/libvirt-guests </dev/null 2>&1 || true + systemctl enable libvirt-guests.service >/dev/null 2>&1 || true + if [[ "$tls" == "1" ]]; then + systemctl enable virtuality-redirect.service >/dev/null 2>&1 || true + else + systemctl disable --now virtuality-redirect.service >/dev/null 2>&1 || true + fi + if [[ "$auto_update" == "1" ]]; then + systemctl enable virtuality-auto-update.timer >/dev/null 2>&1 || true + else + systemctl disable --now virtuality-auto-update.timer >/dev/null 2>&1 || true + fi +} + +apply_firewall() { + command -v ufw >/dev/null 2>&1 || return 0 + local port tls tls_port old + port="$(env_get VIRTUALITY_WEB_PORT 8088)" + tls="$(env_get VIRTUALITY_TLS 0)" + tls_port="$(env_get VIRTUALITY_TLS_PORT 8443)" + if [[ -f "$STATE_FILE" ]]; then + while read -r old; do + [[ -n "$old" ]] && ufw delete allow "${old}/tcp" >/dev/null 2>&1 || true + done < "$STATE_FILE" + fi + # VM consoles are proxied by the panel over 127.0.0.1; VNC must never be reachable directly. + ufw delete allow 5900:5999/tcp >/dev/null 2>&1 || true + ufw allow "${port}/tcp" >/dev/null 2>&1 || true + printf '%s\n' "$port" > "$STATE_FILE" + if [[ "$tls" == "1" ]]; then + ufw allow "${tls_port}/tcp" >/dev/null 2>&1 || true + printf '%s\n' "$tls_port" >> "$STATE_FILE" + fi +} + +restart_panel() { + systemctl restart virtuality-web.service + if [[ "$(env_get VIRTUALITY_TLS 0)" == "1" ]]; then + systemctl restart virtuality-redirect.service || true + fi + systemctl enable --now virtuality-maintenance.timer >/dev/null 2>&1 || true + if [[ "$(env_get VIRTUALITY_AUTO_UPDATE 1)" == "1" ]]; then + systemctl enable --now virtuality-auto-update.timer >/dev/null 2>&1 || true + fi +} + +# VMs created by older versions listened for VNC on every address; the panel proxies consoles over localhost. +harden_vnc() { + command -v virsh >/dev/null 2>&1 && command -v virt-xml >/dev/null 2>&1 || return 0 + local name + while read -r name; do + [[ -n "$name" ]] || continue + if virsh dumpxml "$name" 2>/dev/null | grep -q "listen='0.0.0.0'"; then + virt-xml "$name" --edit --graphics listen=127.0.0.1 >/dev/null 2>&1 && log "vnc of ${name} now listens on 127.0.0.1 (applies after restart)" || true + fi + done < <(virsh list --all --name 2>/dev/null) +} + +# Nodes installed before 0.11 had a text dashboard on tty1 instead of the login prompt. +cleanup_legacy() { + if [[ -f "${UNIT_DIR}/virtuality-console-dashboard.service" ]]; then + systemctl disable --now virtuality-console-dashboard.service >/dev/null 2>&1 || true + rm -f "${UNIT_DIR}/virtuality-console-dashboard.service" /usr/local/bin/virtuality-console-dashboard + systemctl daemon-reload + systemctl enable --now getty@tty1.service >/dev/null 2>&1 || true + log "legacy tty1 console dashboard removed" + fi +} + +cmd_reconfigure() { + need_root reconfigure + [[ -d "$APP_DIR" ]] || die "панель не установлена: ${APP_DIR} не найден" + cleanup_legacy + write_units + apply_firewall + harden_vnc + restart_panel + log "reconfigured: $(panel_url)" + echo "Панель перезапущена: $(panel_url)" +} + +cmd_set() { + need_root set + [[ $# -gt 0 ]] || die "usage: virtuality-ctl set KEY=VALUE [KEY=VALUE ...]" + local pair key value restart_needed=0 + for pair in "$@"; do + key="${pair%%=*}" + value="${pair#*=}" + [[ "$key" =~ ^VIRTUALITY_[A-Z_]+$ ]] || die "недопустимый ключ: $key" + case "$key" in + VIRTUALITY_WEB_PORT|VIRTUALITY_TLS_PORT) [[ "$value" =~ ^[0-9]+$ ]] && (( value >= 1 && value <= 65535 )) || die "порт должен быть числом 1–65535" ;; + VIRTUALITY_TLS|VIRTUALITY_AUTO_UPDATE) [[ "$value" == "0" || "$value" == "1" ]] || die "$key принимает 0 или 1" ;; + VIRTUALITY_UPDATE_CHANNEL) [[ "$value" == "stable" || "$value" == "main" ]] || die "канал: stable или main" ;; + VIRTUALITY_WEB_HOST) [[ "$value" =~ ^[0-9a-fA-F.:]+$ ]] || die "адрес должен быть IP" ;; + esac + env_set "$key" "$value" + [[ "$key" == VIRTUALITY_AUTO_UPDATE || "$key" == VIRTUALITY_UPDATE_CHANNEL ]] || restart_needed=1 + done + if [[ "$restart_needed" == "1" ]]; then + cmd_reconfigure + else + # Update settings only touch the timer: no need to restart the panel. + need_root set + write_units + log "settings updated without restart: $*" + echo "Настройки сохранены" + fi +} + +# ---------------------------------------------------------------- account +cmd_passwd() { + need_root passwd + local user="${1:-$(env_get VIRTUALITY_AUTH_USER root)}" + id "$user" >/dev/null 2>&1 || die "пользователь $user не найден" + local password + if [[ -t 0 ]]; then + read -r -s -p "Новый пароль для ${user}: " password; echo + else + IFS= read -r password + fi + [[ ${#password} -ge 6 ]] || die "пароль должен быть не короче 6 символов" + printf '%s:%s\n' "$user" "$password" | chpasswd || die "не удалось сменить пароль" + passwd -u "$user" >/dev/null 2>&1 || true + log "password changed for ${user}" + echo "Пароль пользователя ${user} обновлён" +} + +cmd_timezone() { + need_root timezone + local tz="${1:-}" + [[ "$tz" =~ ^[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+){0,2}$ ]] || die "usage: virtuality-ctl timezone Europe/Moscow" + timedatectl set-timezone "$tz" || die "часовой пояс $tz не найден" + timedatectl set-ntp true >/dev/null 2>&1 || true + log "timezone set to ${tz}" + echo "Часовой пояс: $(timedatectl show -p Timezone --value)" +} + +cmd_power() { + need_root power + case "${1:-}" in + reboot) log "reboot requested"; systemctl reboot ;; + poweroff|shutdown) log "poweroff requested"; systemctl poweroff ;; + *) die "usage: virtuality-ctl power reboot|poweroff" ;; + esac +} + +# ---------------------------------------------------------------- config backup +cmd_backup_config() { + need_root backup-config + local out="${1:-${DATA_DIR}/backups/config-$(date +%Y%m%d-%H%M).tar.gz}" + local work + work="$(mktemp -d)" + mkdir -p "$work/node" "$work/vms" "$work/networks" "$(dirname "$out")" + cp -a "$CONFIG_DIR" "$work/node/config" 2>/dev/null || true + cp -a "${DATA_DIR}/network" "$work/node/network" 2>/dev/null || true + cp -a /etc/virtuality "$work/node/etc-virtuality" 2>/dev/null || true + cp -a "$BRIDGE_FILE" "$work/node/" 2>/dev/null || true + cp -a /etc/default/libvirt-guests "$work/node/" 2>/dev/null || true + cat "${SOURCE_DIR}/VERSION" > "$work/VERSION" 2>/dev/null || true + if command -v virsh >/dev/null 2>&1; then + local name + while read -r name; do + [[ -n "$name" ]] && virsh dumpxml --migratable "$name" > "$work/vms/${name}.xml" 2>/dev/null || true + done < <(virsh list --all --name 2>/dev/null) + while read -r name; do + [[ -n "$name" ]] && virsh net-dumpxml "$name" > "$work/networks/${name}.xml" 2>/dev/null || true + done < <(virsh net-list --all --name 2>/dev/null) + fi + tar -czf "$out" -C "$work" . + rm -rf "$work" + chmod 600 "$out" + log "config backup written to ${out}" + echo "$out" +} + +cmd_restore_config() { + need_root restore-config + local file="${1:-}" + [[ -f "$file" ]] || die "usage: virtuality-ctl restore-config FILE.tar.gz" + local work + work="$(mktemp -d)" + tar -xzf "$file" -C "$work" || die "архив повреждён" + [[ -d "$work/node/config" ]] || die "в архиве нет настроек Virtuality" + mkdir -p "$CONFIG_DIR" "${DATA_DIR}/network" /etc/virtuality + cp -a "$work/node/config/." "$CONFIG_DIR/" + [[ -d "$work/node/network" ]] && cp -a "$work/node/network/." "${DATA_DIR}/network/" + [[ -d "$work/node/etc-virtuality" ]] && cp -a "$work/node/etc-virtuality/." /etc/virtuality/ + [[ -f "$work/node/libvirt-guests" ]] && cp -a "$work/node/libvirt-guests" /etc/default/libvirt-guests + if command -v virsh >/dev/null 2>&1; then + local xml + for xml in "$work"/networks/*.xml; do + [[ -f "$xml" ]] || continue + virsh net-define "$xml" >/dev/null 2>&1 || true + virsh net-autostart "$(basename "$xml" .xml)" >/dev/null 2>&1 || true + virsh net-start "$(basename "$xml" .xml)" >/dev/null 2>&1 || true + done + for xml in "$work"/vms/*.xml; do + [[ -f "$xml" ]] || continue + virsh define "$xml" >/dev/null 2>&1 || echo "Машина $(basename "$xml" .xml): описание восстановлено с ошибкой (диски отсутствуют?)" + done + fi + rm -rf "$work" + log "config restored from ${file}" + if [[ -d "$APP_DIR" ]]; then + cmd_reconfigure + fi + echo "Настройки восстановлены" +} + +# ---------------------------------------------------------------- updates +cmd_rollback() { + need_root rollback + [[ -d "${APP_DIR}.prev" ]] || die "предыдущей версии панели нет" + rm -rf "${APP_DIR}.broken" + mv "$APP_DIR" "${APP_DIR}.broken" + mv "${APP_DIR}.prev" "$APP_DIR" + systemctl restart virtuality-web.service + log "rolled back to previous panel version" + echo "Возвращена предыдущая версия панели. Сломанная сохранена в ${APP_DIR}.broken" +} + +# ---------------------------------------------------------------- maintenance +cmd_maintenance() { + need_root maintenance + local ops="${LOG_DIR}/operations" + if [[ -d "$ops" ]]; then + find "$ops" -type f -mtime +30 -delete 2>/dev/null || true + # keep at most the 300 newest operations + ls -t "$ops"/*.json 2>/dev/null | tail -n +301 | while read -r f; do rm -f "$f" "${f%.json}.log"; done + fi + find "${DATA_DIR}/tmp" -mindepth 1 -mtime +1 -delete 2>/dev/null || true + find "${DATA_DIR}/iso" "${DATA_DIR}/disk-images" -maxdepth 1 -name '.*.uploading' -mtime +1 -delete 2>/dev/null || true + find "${DATA_DIR}/iso" "${DATA_DIR}/disk-images" -maxdepth 1 -name '.*.part' -mtime +1 -delete 2>/dev/null || true + find "$LOG_DIR" -maxdepth 1 -name 'install_*.log' -mtime +30 -delete 2>/dev/null || true + journalctl --vacuum-size=300M >/dev/null 2>&1 || true + local used + used="$(df -P "$DATA_DIR" | awk 'NR==2 {gsub("%","",$5); print $5}')" + mkdir -p "$CONFIG_DIR" + printf '{"disk_used_pct": %s, "checked_at": "%s"}\n' "${used:-0}" "$(now)" > "${CONFIG_DIR}/health.json" + log "maintenance done, disk used ${used}%" +} + +# ---------------------------------------------------------------- network bridge +cmd_bridge() { + need_root bridge + local iface="${1:-}" mode="${2:-dhcp}" + [[ -n "$iface" ]] || die "usage: virtuality-ctl bridge [dhcp|static]" + ip link show "$iface" >/dev/null 2>&1 || die "интерфейс $iface не найден" + [[ -d "/sys/class/net/${iface}/wireless" ]] && die "Wi-Fi нельзя объединить в мост; используйте режим NAT" + command -v netplan >/dev/null 2>&1 || die "netplan не найден" + local mac ip gw + mac="$(cat "/sys/class/net/${iface}/address")" + ip="$(ip -4 addr show "$iface" | awk '/inet / {print $2; exit}')" + gw="$(ip route show default | awk '/default/ {print $3; exit}')" + + rm -rf "$NETPLAN_BACKUP" + mkdir -p "$NETPLAN_BACKUP" + cp -a "$NETPLAN_DIR"/*.yaml "$NETPLAN_BACKUP"/ 2>/dev/null || true + # Neutralise the interface in existing files so it is not configured twice. + local file + for file in "$NETPLAN_DIR"/*.yaml; do + [[ -f "$file" && "$file" != "$BRIDGE_FILE" ]] || continue + if grep -q "^\s*${iface}:" "$file"; then + cat > "$file" < "$BRIDGE_FILE" + chmod 600 "$NETPLAN_DIR"/*.yaml 2>/dev/null || true + netplan generate || { cmd_bridge_revert; die "конфигурация сети не прошла проверку, изменения отменены"; } + # Auto-revert unless confirmed from the panel within two minutes. + systemctl stop "${REVERT_UNIT}.timer" >/dev/null 2>&1 || true + systemd-run --unit="$REVERT_UNIT" --on-active=120 --quiet /usr/local/bin/virtuality-ctl bridge-revert + netplan apply + log "bridge br0 on ${iface} applied (${mode}); auto-revert armed" + echo "Мост br0 включён. Подтвердите в течение 2 минут: virtuality-ctl bridge-confirm" +} + +cmd_bridge_confirm() { + need_root bridge-confirm + systemctl stop "${REVERT_UNIT}.timer" >/dev/null 2>&1 || true + systemctl stop "${REVERT_UNIT}.service" >/dev/null 2>&1 || true + rm -rf "$NETPLAN_BACKUP" + log "bridge confirmed" + echo "Сеть подтверждена" +} + +cmd_bridge_revert() { + need_root bridge-revert + systemctl stop "${REVERT_UNIT}.timer" >/dev/null 2>&1 || true + [[ -d "$NETPLAN_BACKUP" ]] || die "нечего откатывать" + rm -f "$NETPLAN_DIR"/*.yaml + cp -a "$NETPLAN_BACKUP"/*.yaml "$NETPLAN_DIR"/ 2>/dev/null || true + chmod 600 "$NETPLAN_DIR"/*.yaml 2>/dev/null || true + netplan generate && netplan apply + ip link delete br0 >/dev/null 2>&1 || true + rm -rf "$NETPLAN_BACKUP" + log "bridge reverted" + echo "Прежняя сеть восстановлена" +} + +# ---------------------------------------------------------------- storage +cmd_storage_use() { + need_root storage-use + local dev="${1:-}" + [[ "$dev" =~ ^/dev/[a-zA-Z0-9]+$ ]] || die "usage: virtuality-ctl storage-use /dev/sdX" + [[ -b "$dev" ]] || die "$dev не является блочным устройством" + local children fstype mounted + children="$(lsblk -n -o NAME "$dev" | tail -n +2 | wc -l)" + fstype="$(lsblk -n -d -o FSTYPE "$dev")" + mounted="$(lsblk -n -o MOUNTPOINT "$dev" | tr -d ' \n')" + [[ "$children" -eq 0 && -z "$fstype" && -z "$mounted" ]] || die "на $dev уже есть разделы или данные — используйте только пустой диск" + findmnt -n --target "$DATA_DIR" -o SOURCE | grep -q "^${dev}" && die "$dev уже используется" + log "storage-use ${dev}: formatting" + wipefs -a "$dev" >/dev/null + mkfs.ext4 -F -q -L virtuality "$dev" >/dev/null || die "не удалось создать файловую систему" + local uuid tmp + uuid="$(blkid -s UUID -o value "$dev")" + [[ -n "$uuid" ]] || die "не удалось прочитать UUID" + tmp="$(mktemp -d)" + mount "$dev" "$tmp" || die "не удалось смонтировать диск" + mkdir -p "$DATA_DIR" + rsync -a "$DATA_DIR"/ "$tmp"/ || { umount "$tmp"; die "не удалось перенести данные"; } + umount "$tmp"; rmdir "$tmp" + sed -i "\#[[:space:]]${DATA_DIR}[[:space:]]#d" /etc/fstab + printf 'UUID=%s %s ext4 defaults,nofail 0 2\n' "$uuid" "$DATA_DIR" >> /etc/fstab + systemctl daemon-reload + mount "$DATA_DIR" || die "не удалось смонтировать ${DATA_DIR}" + chmod 755 "$DATA_DIR" + mkdir -p "$DATA_DIR"/{iso,images,disk-images,backups,tmp,network,config} + chmod 1777 "$DATA_DIR/tmp" + command -v virsh >/dev/null 2>&1 && { virsh pool-refresh virtuality-images >/dev/null 2>&1 || true; virsh pool-refresh virtuality-iso >/dev/null 2>&1 || true; } + log "storage-use ${dev}: mounted at ${DATA_DIR}" + echo "Диск ${dev} подключён как ${DATA_DIR}" +} + +# ---------------------------------------------------------------- support +cmd_support_bundle() { + need_root support-bundle + local out="${1:-${DATA_DIR}/tmp/virtuality-support-$(date +%Y%m%d-%H%M).tar.gz}" + local work + work="$(mktemp -d)" + mkdir -p "$(dirname "$out")" + { + echo "Virtuality $(cat "${SOURCE_DIR}/VERSION" 2>/dev/null || echo unknown)" + echo "Host: $(hostname) $(uname -r)"; date + echo; echo "== web.env"; sed 's/SECRET=.*/SECRET=***/' "$NODE_ENV" 2>/dev/null + echo; echo "== services"; systemctl --no-pager --plain list-units 'virtuality-*' 'libvirtd*' 2>/dev/null + echo; echo "== virsh"; virsh list --all 2>&1; virsh net-list --all 2>&1; virsh pool-list --all 2>&1 + echo; echo "== network"; ip -br a 2>&1; ip route 2>&1 + echo; echo "== firewall"; ufw status 2>&1; nft list ruleset 2>&1 | head -200 + echo; echo "== disk"; df -h / "$DATA_DIR" 2>&1 + } > "$work/summary.txt" + journalctl -u virtuality-web -n 500 --no-pager > "$work/virtuality-web.log" 2>&1 || true + journalctl -u libvirtd -n 200 --no-pager > "$work/libvirtd.log" 2>&1 || true + cp "${LOG_DIR}"/update.log "${LOG_DIR}"/firstboot.log "${LOG_DIR}"/virtuality-ctl.log "$work"/ 2>/dev/null || true + mkdir -p "$work/operations" + ls -t "${LOG_DIR}/operations"/*.json 2>/dev/null | head -20 | while read -r f; do cp "$f" "${f%.json}.log" "$work/operations/" 2>/dev/null || true; done + tar -czf "$out" -C "$work" . + rm -rf "$work" + echo "$out" +} + +cmd_status() { + echo "Virtuality $(cat "${SOURCE_DIR}/VERSION" 2>/dev/null || echo unknown)" + echo "Панель: $(panel_url) ($(systemctl is-active virtuality-web.service 2>/dev/null || echo unknown))" + echo "Пользователь: $(env_get VIRTUALITY_AUTH_USER root)" + echo "HTTPS: $([[ "$(env_get VIRTUALITY_TLS 0)" == "1" ]] && echo включён || echo выключен)" + echo "Автообновление: $([[ "$(env_get VIRTUALITY_AUTO_UPDATE 1)" == "1" ]] && echo "включено ($(env_get VIRTUALITY_UPDATE_CHANNEL stable))" || echo выключено)" + echo "Часовой пояс: $(timedatectl show -p Timezone --value 2>/dev/null || echo unknown)" + echo "Хранилище: $(df -h "$DATA_DIR" 2>/dev/null | awk 'NR==2 {print $4" свободно из "$2}')" +} + +# ---------------------------------------------------------------- uninstall +cmd_uninstall() { + need_root uninstall + local purge=0 yes=0 arg + for arg in "$@"; do + case "$arg" in + --purge) purge=1 ;; + --yes|-y) yes=1 ;; + *) die "unknown option: $arg" ;; + esac + done + if [[ "$yes" -ne 1 ]]; then + echo "Будут удалены панель, службы и настройки Virtuality." + [[ "$purge" -eq 1 ]] && echo "Режим --purge: также удаляются ВСЕ машины, диски, образы и резервные копии." + read -r -p "Продолжить? Введите YES: " answer + [[ "$answer" == "YES" ]] || { echo "Отменено."; exit 0; } + fi + local unit + for unit in virtuality-web.service virtuality-redirect.service virtuality-network.service virtuality-auto-update.timer virtuality-auto-update.service virtuality-maintenance.timer virtuality-maintenance.service virtuality-console-dashboard.service virtuality-firstboot.service; do + systemctl disable --now "$unit" >/dev/null 2>&1 || true + rm -f "${UNIT_DIR}/${unit}" + done + systemctl enable --now getty@tty1.service >/dev/null 2>&1 || true + systemctl daemon-reload + nft delete table ip virtuality >/dev/null 2>&1 || true + if command -v ufw >/dev/null 2>&1 && [[ -f "$STATE_FILE" ]]; then + while read -r old; do [[ -n "$old" ]] && ufw delete allow "${old}/tcp" >/dev/null 2>&1 || true; done < "$STATE_FILE" + fi + rm -rf /opt/virtuality /etc/virtuality /etc/logrotate.d/virtuality /etc/sysctl.d/98-virtuality-rpfilter.conf /etc/sysctl.d/99-virtuality-forward.conf + rm -f /usr/local/bin/vhealth /usr/local/bin/virtuality-console-dashboard /etc/issue.d/virtuality.issue + if [[ "$purge" -eq 1 ]]; then + if command -v virsh >/dev/null 2>&1; then + local name + while read -r name; do + [[ -n "$name" ]] || continue + virsh destroy "$name" >/dev/null 2>&1 || true + virsh undefine "$name" --remove-all-storage --nvram --managed-save --snapshots-metadata >/dev/null 2>&1 || true + done < <(virsh list --all --name 2>/dev/null) + virsh net-destroy virtuality-nat >/dev/null 2>&1 || true + virsh net-undefine virtuality-nat >/dev/null 2>&1 || true + fi + rm -rf "$DATA_DIR" "$LOG_DIR" + else + rm -rf "$CONFIG_DIR" "${DATA_DIR}/update" "${DATA_DIR}/tmp" + echo "Машины, образы и резервные копии сохранены в ${DATA_DIR}" + fi + rm -f /usr/local/bin/virtuality-ctl + echo "Virtuality удалена" +} + +case "${1:-}" in + status) cmd_status ;; + url) panel_url; echo ;; + reconfigure) cmd_reconfigure ;; + set) shift; cmd_set "$@" ;; + passwd) shift; cmd_passwd "$@" ;; + timezone) shift; cmd_timezone "$@" ;; + power) shift; cmd_power "$@" ;; + backup-config) shift; cmd_backup_config "$@" ;; + restore-config) shift; cmd_restore_config "$@" ;; + rollback) cmd_rollback ;; + maintenance) cmd_maintenance ;; + bridge) shift; cmd_bridge "$@" ;; + bridge-confirm) cmd_bridge_confirm ;; + bridge-revert) cmd_bridge_revert ;; + storage-use) shift; cmd_storage_use "$@" ;; + support-bundle) shift; cmd_support_bundle "$@" ;; + uninstall) shift; cmd_uninstall "$@" ;; + -h|--help|help|"") usage ;; + *) usage >&2; die "неизвестная команда: $1" ;; +esac diff --git a/scripts/virtuality_console_dashboard.sh b/scripts/virtuality_console_dashboard.sh deleted file mode 100644 index 65cc6df..0000000 --- a/scripts/virtuality_console_dashboard.sh +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Virtuality Console Dashboard -# btop-like compact TUI for physical server console tty1 - -refresh_interval="${VIRTUALITY_DASHBOARD_INTERVAL:-2}" - -has_cmd() { command -v "$1" >/dev/null 2>&1; } - -ESC="\033" -RESET="${ESC}[0m" -BOLD="${ESC}[1m" -DIM="${ESC}[2m" -FG_CYAN="${ESC}[38;5;51m" -FG_BLUE="${ESC}[38;5;39m" -FG_GREEN="${ESC}[38;5;46m" -FG_YELLOW="${ESC}[38;5;226m" -FG_RED="${ESC}[38;5;196m" -FG_MAGENTA="${ESC}[38;5;201m" -FG_GRAY="${ESC}[38;5;245m" -FG_WHITE="${ESC}[38;5;255m" -BG_PANEL="${ESC}[48;5;235m" -BG_HEADER="${ESC}[48;5;24m" - -move() { printf "${ESC}[%s;%sH" "$1" "$2"; } -clear_screen() { printf "${ESC}[2J${ESC}[H"; } -hide_cursor() { printf "${ESC}[?25l"; } -show_cursor() { printf "${ESC}[?25h${RESET}"; } -trap show_cursor EXIT - -term_cols() { tput cols 2>/dev/null || echo 120; } -term_rows() { tput lines 2>/dev/null || echo 40; } - -repeat_char() { - local char="$1" count="$2" - printf '%*s' "$count" '' | tr ' ' "$char" -} - -strip_ansi() { - sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' -} - -print_fit() { - local text="$1" width="$2" - printf "%-${width}.${width}s" "$text" -} - -box() { - local row="$1" col="$2" height="$3" width="$4" title="$5" - local inner=$((width - 2)) - move "$row" "$col"; printf "${FG_BLUE}╭%s╮${RESET}" "$(repeat_char '─' "$inner")" - move "$row" $((col + 2)); printf "${BG_PANEL}${FG_CYAN}${BOLD} %s ${RESET}" "$title" - for ((i=1; i= 85 )); then color="$FG_RED"; elif (( percent >= 65 )); then color="$FG_YELLOW"; fi - printf "%b%s%b%s" "$color" "$(repeat_char '█' "$filled")" "$DIM" "$(repeat_char '░' "$empty")" - printf "%b" "$RESET" -} - -service_state() { - local unit="$1" - if systemctl list-unit-files "$unit" >/dev/null 2>&1; then - systemctl is-active "$unit" 2>/dev/null || echo "inactive" - else - echo "missing" - fi -} - -state_badge() { - local state="$1" - case "$state" in - active) printf "${FG_GREEN}● active${RESET}" ;; - inactive) printf "${FG_YELLOW}● inactive${RESET}" ;; - failed) printf "${FG_RED}● failed${RESET}" ;; - missing) printf "${FG_GRAY}● missing${RESET}" ;; - *) printf "${FG_YELLOW}● %s${RESET}" "$state" ;; - esac -} - -get_cpu_usage() { - local a b idle_a total_a idle_b total_b diff_idle diff_total usage - read -r _ a b c idle_a rest < /proc/stat - total_a=$((a+b+c+idle_a)) - sleep 0.15 - read -r _ a b c idle_b rest < /proc/stat - total_b=$((a+b+c+idle_b)) - diff_idle=$((idle_b-idle_a)) - diff_total=$((total_b-total_a)) - if (( diff_total <= 0 )); then echo 0; else echo $(( (100 * (diff_total - diff_idle)) / diff_total )); fi -} - -get_mem_percent() { - free | awk '/Mem:/ {printf "%d", ($3/$2)*100}' -} - -get_disk_percent() { - df / | awk 'NR==2 {gsub("%", "", $5); print $5}' -} - -while true; do - cols="$(term_cols)" - rows="$(term_rows)" - [[ "$cols" -lt 100 ]] && cols=100 - [[ "$rows" -lt 32 ]] && rows=32 - - cpu="$(get_cpu_usage)" - mem="$(get_mem_percent)" - disk="$(get_disk_percent)" - load="$(awk '{print $1" "$2" "$3}' /proc/loadavg)" - uptime_text="$(uptime -p 2>/dev/null | sed 's/up //' || true)" - host="$(hostname)" - now="$(date '+%Y-%m-%d %H:%M:%S')" - ip_main="$(hostname -I 2>/dev/null | awk '{print $1}')" - gateway="$(ip route | awk '/default/ {print $3" via "$5; exit}')" - - clear_screen - hide_cursor - - move 1 1 - printf "${BG_HEADER}${FG_WHITE}${BOLD} %-20s ${FG_CYAN}%-30s ${FG_GRAY}%s ${RESET}" "VIRTUALITY" "$host" "$now" - printf "%s" "$(repeat_char ' ' $((cols-70 > 0 ? cols-70 : 1)))" - - # Layout - left_w=48 - right_w=$((cols - left_w - 5)) - [[ "$right_w" -lt 48 ]] && right_w=48 - - box 3 2 10 "$left_w" "SYSTEM" - box 3 $((left_w + 4)) 10 "$right_w" "SERVICES" - box 14 2 8 "$left_w" "NETWORK" - box 14 $((left_w + 4)) 8 "$right_w" "VIRTUAL MACHINES" - box 23 2 8 "$left_w" "STORAGE" - box 23 $((left_w + 4)) 8 "$right_w" "LIBVIRT POOLS" - - # System panel - put 5 4 $((left_w-4)) "${FG_GRAY}CPU ${FG_WHITE}${cpu}% $(bar "$cpu" 26)" - put 6 4 $((left_w-4)) "${FG_GRAY}RAM ${FG_WHITE}${mem}% $(bar "$mem" 26)" - put 7 4 $((left_w-4)) "${FG_GRAY}DSK ${FG_WHITE}${disk}% $(bar "$disk" 26)" - put 8 4 $((left_w-4)) "${FG_GRAY}Load:${RESET} ${FG_WHITE}${load}${RESET}" - put 9 4 $((left_w-4)) "${FG_GRAY}Uptime:${RESET} ${FG_WHITE}${uptime_text}${RESET}" - put 10 4 $((left_w-4)) "${FG_GRAY}Kernel:${RESET} ${FG_WHITE}$(uname -r)${RESET}" - - # Services panel - svc_col=$((left_w + 6)) - svc_w=$((right_w - 4)) - put 5 "$svc_col" "$svc_w" "libvirtd.service $(state_badge "$(service_state libvirtd.service)")" - put 6 "$svc_col" "$svc_w" "virtlogd.service $(state_badge "$(service_state virtlogd.service)")" - put 7 "$svc_col" "$svc_w" "cockpit.socket $(state_badge "$(service_state cockpit.socket)")" - put 8 "$svc_col" "$svc_w" "libvirt-guests.service $(state_badge "$(service_state libvirt-guests.service)")" - put 10 "$svc_col" "$svc_w" "${FG_GRAY}Cockpit:${RESET} ${FG_WHITE}https://${ip_main}:9090${RESET}" - - # Network panel - put 16 4 $((left_w-4)) "${FG_GRAY}Main IP:${RESET} ${FG_GREEN}${ip_main}${RESET}" - put 17 4 $((left_w-4)) "${FG_GRAY}Gateway:${RESET} ${FG_WHITE}${gateway}${RESET}" - net_lines="$(ip -br a 2>/dev/null | head -n 3)" - n=0 - while IFS= read -r line; do - put $((18+n)) 4 $((left_w-4)) "${FG_WHITE}${line}${RESET}" - n=$((n+1)) - done <<< "$net_lines" - - # VM panel - vm_col=$((left_w + 6)) - vm_lines="$(virsh list --all 2>/dev/null | tail -n +3 | sed '/^$/d' | head -n 4 || true)" - if [[ -z "$vm_lines" ]]; then - put 16 "$vm_col" "$svc_w" "${FG_GRAY}VM пока нет. Следующий этап — создать тестовую VM.${RESET}" - else - n=0 - while IFS= read -r line; do - put $((16+n)) "$vm_col" "$svc_w" "${FG_WHITE}${line}${RESET}" - n=$((n+1)) - done <<< "$vm_lines" - fi - - # Storage panel - srow=25 - for dir in /var/lib/virtuality/iso /var/lib/virtuality/images /var/lib/virtuality/backups /var/log/virtuality; do - if [[ -d "$dir" ]]; then - used="$(du -sh "$dir" 2>/dev/null | awk '{print $1}')" - put "$srow" 4 $((left_w-4)) "${FG_GRAY}${dir}:${RESET} ${FG_WHITE}${used}${RESET}" - else - put "$srow" 4 $((left_w-4)) "${FG_GRAY}${dir}:${RESET} ${FG_RED}missing${RESET}" - fi - srow=$((srow+1)) - done - - # Pools panel - pool_lines="$(virsh pool-list --all 2>/dev/null | tail -n +3 | sed '/^$/d' | head -n 4 || true)" - if [[ -z "$pool_lines" ]]; then - put 25 "$vm_col" "$svc_w" "${FG_GRAY}Storage pools unavailable${RESET}" - else - n=0 - while IFS= read -r line; do - put $((25+n)) "$vm_col" "$svc_w" "${FG_WHITE}${line}${RESET}" - n=$((n+1)) - done <<< "$pool_lines" - fi - - move $((rows-2)) 2 - printf "${FG_GRAY}Refresh:${RESET} ${FG_WHITE}${refresh_interval}s${RESET} ${FG_GRAY}|${RESET} ${FG_CYAN}Ctrl+Alt+F2${RESET} login ${FG_GRAY}|${RESET} ${FG_GREEN}bt${RESET}=btop ${FG_GRAY}|${RESET} stop: ${FG_YELLOW}sudo systemctl stop virtuality-console-dashboard${RESET}" - move $((rows-1)) 1 - printf "${RESET}" - sleep "$refresh_interval" -done diff --git a/scripts/virtuality_firstboot.sh b/scripts/virtuality_firstboot.sh index 2986219..9f9c599 100755 --- a/scripts/virtuality_firstboot.sh +++ b/scripts/virtuality_firstboot.sh @@ -2,28 +2,123 @@ set -euo pipefail # ========================================================== -# Virtuality first boot setup -# Runs once on a system installed from a Virtuality image: -# installs KVM/libvirt/Cockpit, the web panel and helpers -# from the bundled source in /opt/virtuality/source. +# Virtuality first boot +# Runs once on a system installed from the Virtuality image. +# The web panel is installed first (offline, from bundled +# wheels) so the setup wizard in the browser can show the +# progress of everything that follows: network, KVM/libvirt, +# tools. Progress is written to config/setup.json, which the +# wizard polls. Every step is idempotent: on failure systemd +# restarts the script after a minute and it continues from +# the first unfinished step. # ========================================================== SOURCE_DIR="${VIRTUALITY_SOURCE_DIR:-/opt/virtuality/source}" -IMAGE_ENV="/etc/virtuality/image.env" -STATE_DIR="/var/lib/virtuality" +IMAGE_ENV="${VIRTUALITY_IMAGE_ENV:-/etc/virtuality/image.env}" +STATE_DIR="${VIRTUALITY_STATE_DIR:-/var/lib/virtuality}" +CONFIG_DIR="${STATE_DIR}/config" +STATE_FILE="${CONFIG_DIR}/setup.json" DONE_FLAG="${STATE_DIR}/.firstboot-done" -LOG_DIR="/var/log/virtuality" +STEP_FLAG_DIR="${STATE_DIR}/.firstboot-steps" +LOG_DIR="${VIRTUALITY_LOG_DIR:-/var/log/virtuality}" LOG_FILE="${LOG_DIR}/firstboot.log" -APT_LOCK_CONF="/etc/apt/apt.conf.d/90virtuality-lock-timeout" -ISSUE_FILE="/etc/issue.d/virtuality.issue" -NETWORK_WAIT_SECONDS="${VIRTUALITY_NETWORK_WAIT_SECONDS:-300}" +APT_LOCK_CONF="${VIRTUALITY_APT_LOCK_CONF:-/etc/apt/apt.conf.d/90virtuality-lock-timeout}" +ISSUE_FILE="${VIRTUALITY_ISSUE_FILE:-/etc/issue.d/virtuality.issue}" +CTL="${VIRTUALITY_CTL:-/usr/local/bin/virtuality-ctl}" +NETWORK_WAIT_SECONDS="${VIRTUALITY_NETWORK_WAIT_SECONDS:-0}" # 0 = wait forever -mkdir -p "$LOG_DIR" "$STATE_DIR" "$(dirname "$ISSUE_FILE")" +STEP_IDS=(panel network virtualization tools finish) +declare -A STEP_TITLE=( + [panel]="Панель управления" + [network]="Подключение к интернету" + [virtualization]="KVM, QEMU и libvirt" + [tools]="Инструменты диагностики" + [finish]="Завершение" +) +declare -A STEP_STATUS=() +CURRENT_STEP="" +WATCHER_PID="" + +mkdir -p "$LOG_DIR" "$STATE_DIR" "$CONFIG_DIR" "$STEP_FLAG_DIR" "$(dirname "$ISSUE_FILE")" exec > >(tee -a "$LOG_FILE") 2>&1 now() { date '+%Y-%m-%d %H:%M:%S'; } say() { echo "[$(now)] [virtuality-firstboot] $*"; } +# ---------------------------------------------------------------- progress for the wizard +json_escape() { + local text="$1" + text="${text//\\/\\\\}" + text="${text//\"/\\\"}" + text="${text//$'\n'/ }" + printf '%s' "$text" +} + +write_state() { + local stage="$1" message="$2" id first=1 + { + printf '{"stage": "%s", "message": "%s", "updated_at": "%s", "steps": [' "$stage" "$(json_escape "$message")" "$(now)" + for id in "${STEP_IDS[@]}"; do + (( first )) || printf ', ' + first=0 + printf '{"id": "%s", "title": "%s", "status": "%s"}' "$id" "${STEP_TITLE[$id]}" "${STEP_STATUS[$id]:-pending}" + done + printf ']}\n' + } > "${STATE_FILE}.tmp" + mv -f "${STATE_FILE}.tmp" "$STATE_FILE" + chmod 644 "$STATE_FILE" +} + +step_done_before() { [[ -f "${STEP_FLAG_DIR}/$1" ]]; } + +step_start() { + CURRENT_STEP="$1" + STEP_STATUS[$1]="running" + say "step ${1}: ${STEP_TITLE[$1]}" + write_state installing "$2" +} + +step_finish() { + STEP_STATUS[$1]="done" + touch "${STEP_FLAG_DIR}/$1" + CURRENT_STEP="" + write_state installing "${2:-}" +} + +# apt writes its progress to the installer logs; turn the last line into a friendly message. +watch_progress() { + local latest line pkg + while true; do + latest="$(ls -t "${LOG_DIR}"/install_*.log 2>/dev/null | head -n1 || true)" + if [[ -n "$latest" ]]; then + line="$(grep -aE '^(Get:[0-9]+ |Unpacking |Setting up |Preparing to unpack )' "$latest" 2>/dev/null | tail -n1 || true)" + pkg="" + case "$line" in + Get:*) pkg="$(printf '%s' "$line" | awk '{print $5}')" ; [[ -n "$pkg" ]] && write_state installing "Скачиваем ${pkg}…" ;; + "Unpacking "*|"Preparing to unpack "*) pkg="$(printf '%s' "$line" | sed -E 's/^(Preparing to unpack \.\.\.\/|Preparing to unpack |Unpacking )//; s/[ :].*//')"; [[ -n "$pkg" ]] && write_state installing "Распаковываем ${pkg}…" ;; + "Setting up "*) pkg="$(printf '%s' "$line" | sed -E 's/^Setting up //; s/[ :].*//')"; [[ -n "$pkg" ]] && write_state installing "Настраиваем ${pkg}…" ;; + esac + fi + sleep 3 + done +} + +watch_start() { watch_progress & WATCHER_PID=$!; } +watch_stop() { [[ -n "$WATCHER_PID" ]] && { kill "$WATCHER_PID" 2>/dev/null || true; wait "$WATCHER_PID" 2>/dev/null || true; }; WATCHER_PID=""; } + +on_error() { + local code=$? + watch_stop + if [[ -n "$CURRENT_STEP" ]]; then + STEP_STATUS[$CURRENT_STEP]="error" + fi + write_state error "Шаг «${STEP_TITLE[${CURRENT_STEP:-finish}]}» завершился с ошибкой (код ${code}). Повторим автоматически через минуту; подробности ниже в журнале." + say "ERROR: step ${CURRENT_STEP:-?} failed with code ${code}" + exit "$code" +} +trap on_error ERR + +# ---------------------------------------------------------------- environment if [[ -f "$DONE_FLAG" ]]; then say "already completed, nothing to do" exit 0 @@ -42,54 +137,103 @@ first_human_user() { AUTH_USER="${VIRTUALITY_USER:-$(first_human_user)}" AUTH_USER="${AUTH_USER:-root}" -WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}" export VIRTUALITY_USER="$AUTH_USER" export VIRTUALITY_AUTH_USER="$AUTH_USER" -export VIRTUALITY_WEB_PORT="$WEB_PORT" +export VIRTUALITY_WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}" export VIRTUALITY_AUTO_UPDATE="${VIRTUALITY_AUTO_UPDATE:-1}" -# Disk/RAM limits are reported by vhealth; they must not block an image install. +# Disk/RAM limits are reported in the panel; they must not block an image install. export VIRTUALITY_SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-1}" +export VIRTUALITY_FIRSTBOOT=1 export DEBIAN_FRONTEND=noninteractive export TERM="${TERM:-dumb}" -cat > "$ISSUE_FILE" <<'EOF' -Virtuality: first boot setup is running. Progress: sudo journalctl -fu virtuality-firstboot +for id in "${STEP_IDS[@]}"; do + step_done_before "$id" && STEP_STATUS[$id]="done" +done -EOF - -say "source: $SOURCE_DIR" -say "panel user: $AUTH_USER, port: $WEB_PORT" -[[ -f "$SOURCE_DIR/install_virtuality_node.sh" ]] || { say "ERROR: Virtuality source not found in $SOURCE_DIR"; exit 1; } +say "source: ${SOURCE_DIR}, panel user: ${AUTH_USER}, port: ${VIRTUALITY_WEB_PORT}" +[[ -f "${SOURCE_DIR}/install_virtuality_node.sh" ]] || { write_state error "Исходники Virtuality не найдены в ${SOURCE_DIR}"; say "ERROR: source not found"; exit 1; } +cd "$SOURCE_DIR" # unattended-upgrades often holds the dpkg lock right after the first boot. -printf 'DPkg::Lock::Timeout "900";\n' > "$APT_LOCK_CONF" +printf 'DPkg::Lock::Timeout "900";\nDPkg::Options { "--force-confdef"; "--force-confold"; };\n' > "$APT_LOCK_CONF" -say "waiting for network (up to ${NETWORK_WAIT_SECONDS}s)" -deadline=$(( $(date +%s) + NETWORK_WAIT_SECONDS )) -until ip route show default 2>/dev/null | grep -q . && getent hosts archive.ubuntu.com >/dev/null 2>&1; do - if (( $(date +%s) >= deadline )); then - say "ERROR: no network with DNS. Configure networking; the setup will be retried automatically" - exit 1 - fi - sleep 5 -done -say "network is ready" - -cd "$SOURCE_DIR" -say "step 1/4: virtualization node (KVM, libvirt, Cockpit)" -bash ./install_virtuality_node.sh -say "step 2/4: healthcheck command" -bash ./scripts/install_healthcheck_command.sh -say "step 3/4: web panel" -bash ./scripts/install_web_panel.sh -say "step 4/4: console dashboard" -bash ./scripts/install_console_dashboard.sh - -touch "$DONE_FLAG" -cat > "$ISSUE_FILE" < "$ISSUE_FILE" <<'EOF' +Virtuality настраивается. Ход установки: sudo journalctl -fu virtuality-firstboot EOF + +network_ready() { + [[ -n "${VIRTUALITY_NETWORK_CHECK:-}" ]] && { "$VIRTUALITY_NETWORK_CHECK"; return; } + ip route show default 2>/dev/null | grep -q . && getent hosts archive.ubuntu.com >/dev/null 2>&1 +} + +wait_for_network() { + local started elapsed + started="$(date +%s)" + until network_ready; do + elapsed=$(( $(date +%s) - started )) + if (( NETWORK_WAIT_SECONDS > 0 && elapsed >= NETWORK_WAIT_SECONDS )); then + say "ERROR: no network with DNS after ${elapsed}s" + return 1 + fi + if (( elapsed >= 20 )); then + write_state waiting-network "Нет доступа в интернет. Подключите кабель или настройте сеть — установка продолжится сама." + fi + sleep 5 + done + write_state installing "Интернет доступен" +} + +# ---------------------------------------------------------------- 1. panel (offline) +if ! step_done_before panel; then + step_start panel "Устанавливаем панель управления…" + if ! VIRTUALITY_PANEL_ONLY=1 bash ./scripts/install_web_panel.sh; then + # No bundled wheels (image built with --no-wheels): install online instead. + say "offline panel install failed, retrying with network" + wait_for_network + bash ./scripts/install_web_panel.sh + fi + step_finish panel "Панель управления запущена" +fi + +# ---------------------------------------------------------------- 2. network +if ! step_done_before network; then + step_start network "Проверяем подключение к интернету…" + wait_for_network + step_finish network "Интернет доступен" +fi + +# ---------------------------------------------------------------- 3. virtualization +if ! step_done_before virtualization; then + step_start virtualization "Устанавливаем пакеты виртуализации (это самый долгий шаг)…" + watch_start + bash ./install_virtuality_node.sh + watch_stop + step_finish virtualization "Виртуализация установлена" +fi + +# ---------------------------------------------------------------- 4. tools +if ! step_done_before tools; then + step_start tools "Устанавливаем инструменты диагностики…" + bash ./scripts/install_healthcheck_command.sh + step_finish tools "Инструменты установлены" +fi + +# ---------------------------------------------------------------- 5. finish +step_start finish "Завершаем настройку…" +# The panel was installed before libvirt: refresh units/firewall and restart it with everything in place. +"$CTL" reconfigure +PANEL_URL="$("$CTL" url 2>/dev/null || echo "http://$(hostname -I | awk '{print $1}'):${VIRTUALITY_WEB_PORT}")" +cat > "$ISSUE_FILE" </dev/null 2>&1 || true -say "completed. Web panel: http://$(hostname -I | awk '{print $1}'):${WEB_PORT} login: ${AUTH_USER}" +rm -f "$APT_LOCK_CONF" +say "completed. Panel: ${PANEL_URL} login: ${AUTH_USER}" diff --git a/setup_github_sync.sh b/setup_github_sync.sh deleted file mode 100644 index 83cd391..0000000 --- a/setup_github_sync.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -PROJECT_DIR="/opt/virtuality" -DEFAULT_BRANCH="main" - -if [[ "$EUID" -ne 0 ]]; then - echo "Ошибка: запусти от root: sudo bash setup_github_sync.sh" - exit 1 -fi - -apt update -apt install -y git openssh-client - -if [[ ! -d "$PROJECT_DIR" ]]; then - echo "Ошибка: папка проекта не найдена: $PROJECT_DIR" - echo "Сначала запусти install_virtuality_node.sh" - exit 1 -fi - -cd "$PROJECT_DIR" - -read -rp "GitHub repo URL [git@github.com:viktor138irk/virtuality.git]: " GITHUB_REPO -GITHUB_REPO="${GITHUB_REPO:-git@github.com:viktor138irk/virtuality.git}" - -read -rp "Git author name [Virtuality Node]: " GIT_NAME -GIT_NAME="${GIT_NAME:-Virtuality Node}" - -read -rp "Git author email [virtuality@local]: " GIT_EMAIL -GIT_EMAIL="${GIT_EMAIL:-virtuality@local}" - -git config --global user.name "$GIT_NAME" -git config --global user.email "$GIT_EMAIL" - -cat > "$PROJECT_DIR/git_sync.sh" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -PROJECT_DIR="/opt/virtuality" -cd "$PROJECT_DIR" - -MESSAGE="${1:-Auto sync $(date '+%Y-%m-%d %H:%M:%S')}" - -git add . -git commit -m "$MESSAGE" || { - echo "Нет изменений для commit." - exit 0 -} - -git push origin main -EOF - -chmod +x "$PROJECT_DIR/git_sync.sh" - -if [[ ! -d "$PROJECT_DIR/.git" ]]; then - git init -fi - -git branch -M "$DEFAULT_BRANCH" - -if git remote get-url origin >/dev/null 2>&1; then - git remote set-url origin "$GITHUB_REPO" -else - git remote add origin "$GITHUB_REPO" -fi - -git add . -git commit -m "Initial Virtuality node base" || true -git push -u origin "$DEFAULT_BRANCH" - -echo "GitHub sync готов." -echo "Ручная синхронизация: sudo /opt/virtuality/git_sync.sh \"Описание изменений\"" diff --git a/tests/conftest.py b/tests/conftest.py index 0491550..667dba2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,20 +35,69 @@ DUMPXML = """ """ +FIXTURES = ROOT / "tests" / "fixtures" +DOMBLKLIST_DETAILS = (FIXTURES / "domblklist-details.txt").read_text() +DUMPXML_MIGRATABLE = (FIXTURES / "dumpxml-web01.xml").read_text() +QEMU_IMG_INFO = (FIXTURES / "qemu-img-info.json").read_text() + +# Снимки web01: имя → (состояние, описание); список берётся из fixtures/snapshot-list.txt. +SNAPSHOTS = {"before-upd": ("shutoff", "Перед обновлением ядра"), "clean": ("running", "Чистая система после установки")} + +# Заметки машин для `virsh desc`: имя → (название, описание). +NOTES = {"web01": ("Сайт компании", "Nginx + база. Бэкап по пятницам.")} + + def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict: return {"ok": ok, "code": 0 if ok else 1, "stdout": stdout, "stderr": stderr, "cmd": ""} +def option(cmd, name): + """Значение опции virsh вида --name VALUE (или '').""" + return cmd[cmd.index(name) + 1] if name in cmd and cmd.index(name) + 1 < len(cmd) else "" + + +def fake_snapshot_xml(snap: str) -> str: + state, description = SNAPSHOTS.get(snap, ("shutoff", "")) + memory = "" if state == "running" else "" + return f"{snap}{description}{state}{memory}1758794400" + + def fake_run_cmd(cmd, timeout=12, **_kwargs): joined = " ".join(cmd) + if cmd[:2] == ["virsh", "snapshot-list"]: + return fake_result((FIXTURES / "snapshot-list.txt").read_text()) + if cmd[:2] == ["virsh", "snapshot-current"]: + return fake_result("clean") + if cmd[:2] == ["virsh", "snapshot-dumpxml"]: + return fake_result(fake_snapshot_xml(option(cmd, "--snapshotname"))) + if cmd[:2] in (["virsh", "snapshot-create-as"], ["virsh", "snapshot-revert"], ["virsh", "snapshot-delete"]): + return fake_result("Domain snapshot %s created" % option(cmd, "--name")) if cmd[:2] == ["virsh", "list"]: + if "--title" in cmd: + return fake_result((FIXTURES / "list-title.txt").read_text()) + if "--name" in cmd: + return fake_result("web01\ndb01\n" if "--all" in cmd else "web01\n") return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off") if cmd[:2] == ["virsh", "dominfo"]: return fake_result(DOMINFO) if cmd[-1] in ("web01", "db01") else fake_result(ok=False, stderr="failed to get domain") if cmd[:2] == ["virsh", "dumpxml"]: - return fake_result(DUMPXML) + return fake_result(DUMPXML_MIGRATABLE if "--migratable" in cmd else DUMPXML) if cmd[:2] == ["virsh", "domstate"]: return fake_result("running") + if cmd[:2] == ["virsh", "domblklist"]: + import core # диск лежит в подменённом data_dirs хранилище — иначе панель откажется его увеличивать + + return fake_result((FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images", str(core.IMAGES_DIR))) + if cmd[:2] == ["virsh", "domstats"]: + return fake_result((FIXTURES / "domstats.txt").read_text()) + if cmd[:1] == ["virsh"] and "desc" in cmd[:3]: + if any(arg.startswith("--new-desc") for arg in cmd): + return fake_result("Domain description updated successfully") + vm = cmd[cmd.index("desc") + 1] + title, description = NOTES.get(vm, ("", "")) + return fake_result(title if "--title" in cmd else description) + if cmd[:2] == ["qemu-img", "info"]: + return fake_result(QEMU_IMG_INFO) if cmd[:2] == ["virsh", "domifaddr"]: return fake_result(" Name MAC address Protocol Address\n vnet0 52:54:00:aa:bb:cc ipv4 192.168.100.51/24") if cmd[:2] == ["virsh", "domiflist"]: @@ -57,6 +106,8 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs): return fake_result(":0") if cmd[:2] == ["virsh", "pool-list"]: return fake_result(" Name State Autostart\n---\n virtuality-images active yes") + if cmd[:2] == ["virsh", "pool-refresh"]: + return fake_result(f"Pool {cmd[-1]} refreshed") if cmd[:2] == ["virsh", "net-list"]: return fake_result(" Name State Autostart Persistent\n---\n virtuality-nat active yes yes") if cmd[:2] == ["systemctl", "is-active"]: @@ -72,9 +123,15 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs): return fake_result() +def feature_modules(): + """Загруженные модули web/features/* — им тоже подменяем run_cmd.""" + return [module for name, module in sys.modules.items() if name.startswith("features.") and hasattr(module, "run_cmd")] + + @pytest.fixture() def data_dirs(tmp_path, monkeypatch): import app + import core import host_profile import network_core import update_core @@ -83,6 +140,7 @@ def data_dirs(tmp_path, monkeypatch): "iso": tmp_path / "iso", "images": tmp_path / "images", "disk_images": tmp_path / "disk-images", + "backups": tmp_path / "backups", "operations": tmp_path / "operations", "network": tmp_path / "network", "config": tmp_path / "config", @@ -91,14 +149,19 @@ def data_dirs(tmp_path, monkeypatch): } for path in dirs.values(): path.mkdir() - monkeypatch.setattr(app, "ISO_DIR", dirs["iso"]) - monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"]) - monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"]) - monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"]) + for module in (app, core): + monkeypatch.setattr(module, "ISO_DIR", dirs["iso"]) + monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"]) + monkeypatch.setattr(module, "DISK_IMAGES_DIR", dirs["disk_images"]) + monkeypatch.setattr(module, "OPERATIONS_DIR", dirs["operations"]) + monkeypatch.setattr(core, "BACKUPS_DIR", dirs["backups"]) + monkeypatch.setattr(core, "CONFIG_DIR", dirs["config"]) + monkeypatch.setattr(core, "STORAGE_DIR", tmp_path) monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"]) monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"]) monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"]) monkeypatch.setattr(network_core, "PORT_FORWARDS_FILE", dirs["network"] / "port_forwards.json") + monkeypatch.setattr(network_core, "UFW_STATE_FILE", dirs["network"] / "ufw_rules.json") monkeypatch.setattr(network_core, "NAT_XML_FILE", dirs["network"] / "virtuality-nat.xml") monkeypatch.setattr(network_core, "NFT_FILE", dirs["nft"] / "virtuality.nft") monkeypatch.setattr(network_core, "enable_ip_forward", lambda: None) @@ -108,7 +171,7 @@ def data_dirs(tmp_path, monkeypatch): monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json") monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log") monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT) - for module in (app, network_core, host_profile): + for module in (app, core, network_core, host_profile, *feature_modules()): monkeypatch.setattr(module, "run_cmd", fake_run_cmd) return dirs diff --git a/tests/dev_server.py b/tests/dev_server.py index 9df2e62..17c198a 100644 --- a/tests/dev_server.py +++ b/tests/dev_server.py @@ -4,8 +4,10 @@ python3 tests/dev_server.py [port] # login: tester / any password """ import json +import os import sys import tempfile +import time import uuid from pathlib import Path @@ -13,18 +15,130 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import conftest # noqa: E402 (sets sys.path and env) import app # noqa: E402 +import core # noqa: E402 import host_profile # noqa: E402 import network_core # noqa: E402 import update_core # noqa: E402 +# Машины демо-сервера: имя → состояние virsh (web01 работает, db01 выключена, win11 на паузе). +DEMO_VMS = {"web01": "running", "db01": "shut off", "win11": "paused"} +DEMO_NOTES = {"web01": ("Сайт компании", "Nginx + база. Бэкап по пятницам.\nЗа сервер отвечает Иван."), "win11": ("Бухгалтерия 1С", "")} +DEMO_DOMSTATS = """Domain: 'web01' + cpu.time={cpu_web} + balloon.current=2097152 + balloon.maximum=2097152 + balloon.rss=1534208 + vcpu.current=2 + vcpu.maximum=2 + +Domain: 'win11' + cpu.time={cpu_win} + balloon.current=4194304 + balloon.maximum=4194304 + balloon.rss=3102720 + vcpu.current=4 + vcpu.maximum=4 + +""" +_cpu_clock = {"web": 48_123_456_789, "win": 90_000_000_000, "at": time.monotonic()} + + +def demo_vm_run_cmd(cmd, timeout=12, **kwargs): + """Три машины в разных состояниях и живая нагрузка; остальное — conftest.fake_run_cmd.""" + if cmd[:2] == ["virsh", "list"]: + if "--title" in cmd: + rows = "\n".join(f" {'1' if state == 'running' else '2' if state == 'paused' else '-':<4} {name:<7} {state:<10} {DEMO_NOTES.get(name, ('', ''))[0]}" for name, state in DEMO_VMS.items()) + return conftest.fake_result(" Id Name State Title\n" + "-" * 44 + "\n" + rows + "\n") + if "--name" in cmd: + names = [name for name, state in DEMO_VMS.items() if "--all" in cmd or state != "shut off"] + return conftest.fake_result("\n".join(names) + "\n") + rows = "\n".join(f" {'1' if state == 'running' else '2' if state == 'paused' else '-':<4} {name:<7} {state}" for name, state in DEMO_VMS.items()) + return conftest.fake_result(" Id Name State\n" + "-" * 25 + "\n" + rows) + if cmd[:2] in (["virsh", "dominfo"], ["virsh", "domstate"]): + name = cmd[-1] + if name not in DEMO_VMS: + return conftest.fake_result(ok=False, stderr=f"error: failed to get domain '{name}'") + if cmd[1] == "domstate": + return conftest.fake_result(DEMO_VMS[name]) + info = conftest.DOMINFO.replace("web01", name).replace("State: running", f"State: {DEMO_VMS[name]}") + if name == "db01": + info += "Managed save: yes\n" + return conftest.fake_result(info) + if cmd[:2] == ["virsh", "domstats"]: + # Процессорное время растёт вместе с реальным: web01 нагружена на ~37 % (2 ядра), win11 на ~8 % (4 ядра). + now = time.monotonic() + elapsed_ns = (now - _cpu_clock["at"]) * 1e9 + _cpu_clock.update(web=_cpu_clock["web"] + int(elapsed_ns * 2 * 0.37), win=_cpu_clock["win"] + int(elapsed_ns * 4 * 0.08), at=now) + return conftest.fake_result(DEMO_DOMSTATS.format(cpu_web=_cpu_clock["web"], cpu_win=_cpu_clock["win"])) + if cmd[:2] == ["virsh", "domblklist"]: + return conftest.fake_result((conftest.FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images/web01", str(core.IMAGES_DIR / cmd[2]))) + if cmd[:1] == ["virsh"] and "desc" in cmd[:3] and not any(arg.startswith("--new-desc") for arg in cmd): + title, description = DEMO_NOTES.get(cmd[cmd.index("desc") + 1], ("", "")) + return conftest.fake_result(title if "--title" in cmd else description) + return conftest.fake_run_cmd(cmd, timeout, **kwargs) + + +# Снимки для демо: имя, дата, состояние, описание. +DEMO_SNAPSHOTS = [ + ("snap-20260925-1012", "2026-09-25 10:12:03 +0000", "shutoff", "Перед обновлением ядра"), + ("snap-20260924-0900", "2026-09-24 09:00:41 +0000", "running", "Настроен nginx и сертификаты"), + ("snap-20260918-1830", "2026-09-18 18:30:12 +0000", "running", "Чистая система после установки"), + ("clean", "2026-09-10 12:05:00 +0000", "shutoff", ""), +] + + +def demo_run_cmd(cmd, timeout=12, **kwargs): + """Как conftest.fake_run_cmd, но со списком снимков побогаче.""" + if cmd[:2] == ["virsh", "snapshot-list"]: + rows = "\n".join(f" {name:<20} {when} {state}" for name, when, state, _ in DEMO_SNAPSHOTS) + return conftest.fake_result(" Name Creation Time State\n" + "-" * 62 + "\n" + rows) + if cmd[:2] == ["virsh", "snapshot-current"]: + return conftest.fake_result("snap-20260924-0900") + if cmd[:2] == ["virsh", "snapshot-create-as"] and "uefi" in conftest.option(cmd, "--description").lower(): + # Описание со словом «uefi» показывает, как выглядит ошибка libvirt. + return conftest.fake_result(ok=False, stderr="error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported") + if cmd[:2] == ["virsh", "snapshot-dumpxml"]: + snap = conftest.option(cmd, "--snapshotname") + state, description = next(((s, d) for n, _, s, d in DEMO_SNAPSHOTS if n == snap), ("shutoff", "")) + memory = "internal" if state == "running" else "no" + return conftest.fake_result(f"{snap}{description}{state}") + return demo_vm_run_cmd(cmd, timeout, **kwargs) + + +def seed_backups(backups_dir: Path) -> None: + """Несколько копий, как после пары недель работы: web01 — три, db01 — одна.""" + vm_xml = conftest.DUMPXML_MIGRATABLE + demo = [ + ("web01", "20260924-0300", 2_684_354_560, "", [("vda", 6_443_499_520)]), + ("web01", "20260917-0300", 2_598_000_000, "", [("vda", 6_300_000_000)]), + ("web01", "20260912-1842", 2_412_000_000, "Перед обновлением сайта", [("vda", 6_100_000_000)]), + ("db01", "20260921-2215", 9_126_805_504, "После переноса базы", [("vda", 12_884_901_888), ("vdb", 8_589_934_592)]), + ] + for vm, backup_id, size, note, disks in demo: + path = backups_dir / vm / backup_id + path.mkdir(parents=True, exist_ok=True) + (path / "vm.xml").write_text(vm_xml.replace("web01", vm)) + for target, _ in disks: + with (path / f"{target}.qcow2").open("wb") as handle: + handle.truncate(1024 * 1024) + meta = {"vm": vm, "created_at": f"{backup_id[:4]}-{backup_id[4:6]}-{backup_id[6:8]} {backup_id[9:11]}:{backup_id[11:]}:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": s} for t, s in disks], "size_bytes": size, "version": "0.11.0", "note": note} + (path / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2)) + def seed(root: Path) -> None: - dirs = {name: root / name for name in ("iso", "images", "disk-images", "operations", "network", "config", "nft", "update")} + dirs = {name: root / name for name in ("iso", "images", "disk-images", "backups", "operations", "network", "config", "nft", "update")} for path in dirs.values(): path.mkdir(parents=True, exist_ok=True) app.ISO_DIR, app.IMAGES_DIR, app.DISK_IMAGES_DIR, app.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"] + core.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR, core.STORAGE_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"], root + core.BACKUPS_DIR, core.CONFIG_DIR = dirs["backups"], dirs["config"] + seed_backups(dirs["backups"]) + for name in DEMO_VMS: # диски машин лежат в хранилище, иначе форма увеличения не появится + with (dirs["images"] / f"{name}.qcow2").open("wb") as handle: + handle.truncate(4 * 1024 * 1024) network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"] network_core.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json" + network_core.UFW_STATE_FILE = dirs["network"] / "ufw_rules.json" network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml" network_core.NFT_FILE = dirs["nft"] / "virtuality.nft" network_core.enable_ip_forward = lambda: None @@ -32,9 +146,32 @@ def seed(root: Path) -> None: host_profile.PROFILE_FILE = dirs["config"] / "host_profile.json" update_core.STATE_DIR, update_core.STATE_FILE, update_core.LOG_FILE = dirs["update"], dirs["update"] / "state.json", dirs["update"] / "update.log" update_core.SOURCE_DIR = Path(__file__).resolve().parents[1] - for module in (app, network_core, host_profile): - module.run_cmd = conftest.fake_run_cmd + for module in (app, core, network_core, host_profile, *conftest.feature_modules()): + module.run_cmd = demo_run_cmd app.auth.verify_password = lambda user, password: True + import nodectl + + nodectl.CONFIG_DIR = dirs["config"] + nodectl.SETUP_STATE = dirs["config"] / "setup.json" + nodectl.SETUP_DONE = dirs["config"] / "setup_done" + nodectl.WIZARD_FILE = dirs["config"] / "wizard.json" + stage = os.environ.get("VIRTUALITY_DEV_SETUP", "") + if stage: + # VIRTUALITY_DEV_SETUP=installing|done shows the setup wizard in the demo; finished = wizard completed (Settings page). + steps = [("panel", "Панель управления", "done"), ("network-wait", "Подключение к интернету", "done"), ("virtualization", "KVM, QEMU и libvirt", "running" if stage == "installing" else "done"), ("tools", "Инструменты диагностики", "pending" if stage == "installing" else "done"), ("finish", "Завершение", "pending" if stage == "installing" else "done")] + nodectl.SETUP_STATE.write_text(json.dumps({"stage": "installing" if stage == "installing" else "done", "message": "Устанавливаем qemu-system-x86 (14 из 61)…", "steps": [{"id": i, "title": t, "status": s} for i, t, s in steps]})) + nodectl.FIRSTBOOT_LOG = dirs["config"] / "firstboot.log" + nodectl.FIRSTBOOT_LOG.write_text("[2026-09-25 14:02:11] [virtuality-firstboot] step 1/4: web panel\n[2026-09-25 14:02:40] [virtuality-firstboot] step 2/4: virtualization node (KVM, libvirt)\nGet:14 http://archive.ubuntu.com/ubuntu resolute/main amd64 qemu-system-x86 amd64 1:10.2.1+ds-1ubuntu3 [9 812 kB]\n") + if not nodectl.ctl_available(): + nodectl.spare_disks = lambda: [{"path": "/dev/sdb", "name": "sdb", "size": 2 * 1024 ** 4, "model": "WD Red 2TB", "tran": "sata"}] + nodectl.network_facts = lambda: {"interface": "enp3s0", "gateway": "192.168.1.1", "address": "192.168.1.10", "prefix": 24, "mac": "aa:bb:cc:dd:ee:ff", "wireless": False, "private": True, "is_vps": False, "virt": "none", "bridge_present": False, "on_bridge": False, "recommended": "bridge", "revert_armed": False, "bridge_possible": True} + nodectl.hardware_summary = lambda: {"hostname": "home-server", "cpu_model": "Intel Core i5-12400", "cpu_count": 6, "mem_total": 32 * 1024 ** 3, "disk_total": 480 * 1024 ** 3, "disk_free": 401 * 1024 ** 3, "kvm": True} + nodectl.set_timezone = lambda tz: (True, tz) + nodectl.timezones = lambda: {"current": "Europe/Moscow", "popular": nodectl.POPULAR_TIMEZONES, "all": nodectl.POPULAR_TIMEZONES} + if stage == "finished": + nodectl.mark_wizard_done() + nodectl.NODE_ENV = dirs["config"] / "web.env" + nodectl.NODE_ENV.write_text("VIRTUALITY_WEB_PORT=8088\nVIRTUALITY_TLS=1\nVIRTUALITY_AUTO_UPDATE=1\nVIRTUALITY_UPDATE_CHANNEL=stable\n") for name, size in (("ubuntu-26.04-live-server-amd64.iso", 3), ("debian-13.7.0-amd64-netinst.iso", 1)): with (dirs["iso"] / name).open("wb") as handle: @@ -49,10 +186,36 @@ def seed(root: Path) -> None: op_id = str(uuid.uuid4()) app.write_operation({"id": op_id, "type": "vm_create", "title": title, "status": status, "progress": progress, "message": "Готово" if status == "success" else "qemu-img: 64%" if status == "running" else "virt-install завершился с ошибкой: 1", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester"}) app.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...") + # Идущий снимок машины db01 — видно на /vm/db01/snapshots. + snap_op = core.new_operation("snapshot_create", "Снимок машины db01", vm_name="db01", snapshot="snap-20260925-1500", vm_running=True) + core.update_operation(snap_op, status="running", progress=15, message="Сохраняем состояние машины и памяти — она на несколько секунд замрёт…") + op_id = str(uuid.uuid4()) + app.write_operation({"id": op_id, "type": "backup", "title": "Резервная копия web01", "status": "success", "progress": 100, "message": "Резервная копия создана: 2.5 ГБ", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester", "vm_name": "web01", "backup_id": "20260924-0300"}) + for line in ("Машина работает — отправляем команду выключения.", "Машина выключена.", "Настройки машины сохранены в vm.xml.", "Копируем диск vda (1 из 1): 100%", "Машина запущена снова."): + app.append_operation_log(op_id, line) + + # Идущие скачивания — видны на /iso и /disk-images (в каталоге Ubuntu 26.04 помечена «Скачивается»). + for kind, url, catalog_id, title, progress, message, total, done in ( + ("iso", "https://software-download.microsoft.com/download/pr/windows-server-2025-eval-x64.iso", None, "Скачивание windows-server-2025-eval-x64.iso", 37, "1,9 ГБ из 5,2 ГБ · 24 МБ/с · осталось 2 мин", 5_583_457_280, 2_065_879_193), + ("disk", "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img", "ubuntu-26.04", "Скачивание Ubuntu Server 26.04 LTS", 62, "412 МБ из 663 МБ · 31 МБ/с · осталось 8 с", 695_205_888, 431_027_650), + ): + filename = url.rsplit("/", 1)[-1] + target = (dirs["iso"] if kind == "iso" else dirs["disk-images"]) / filename + op = core.new_operation("download", title, download_kind=kind, url=url, filename=filename, target_path=str(target), page="/iso" if kind == "iso" else "/disk-images", catalog_id=catalog_id, total_bytes=total, downloaded_bytes=done, cancel_requested=False) + core.update_operation(op, status="running", progress=progress, message=message, started_at=core.utc_now()) + core.append_operation_log(op["id"], f"Ссылка: {url}") + # Завершённое клонирование web01 → web01-copy: видно на обзоре и на странице /operations/{id}. + clone_op = core.new_operation("vm_clone", "Клонирование web01 → web01-copy", vm_name="web01-copy", source_vm="web01", targets=[str(dirs["images"] / "web01-copy.qcow2")], expected_bytes=6443499520) + core.append_operation_log(clone_op["id"], "virt-clone --original web01 --name web01-copy --file /var/lib/virtuality/images/web01-copy.qcow2") + core.append_operation_log(clone_op["id"], "Allocating 'web01-copy.qcow2' | 6.0 GB 00:01:12") + core.update_operation(clone_op, cmd="virt-clone --original web01 --name web01-copy --file " + str(dirs["images"] / "web01-copy.qcow2")) + core.finish_operation(clone_op, True, "Машина web01-copy создана — это копия web01. Запустите её и смените имя компьютера внутри системы.") if __name__ == "__main__": import uvicorn seed(Path(tempfile.mkdtemp(prefix="virtuality-dev-"))) + # The demo operations are seeded as "running": keep them instead of closing them as orphaned on startup. + app.app.router.on_startup.clear() uvicorn.run(app.app, host="127.0.0.1", port=int(sys.argv[1]) if len(sys.argv) > 1 else 8765, log_level="warning") diff --git a/tests/fixtures/SHA256SUMS b/tests/fixtures/SHA256SUMS new file mode 100644 index 0000000..ef21e26 --- /dev/null +++ b/tests/fixtures/SHA256SUMS @@ -0,0 +1,3 @@ +5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51 *resolute-server-cloudimg-amd64.img +f69162950f235e3cdbbad33f1f912d1a504be90d8a37d002c735d6f3e3882265 *resolute-server-cloudimg-arm64.img +e0361263a9f568c21a59e75c3b7810bc5867866e5bd48f499f8b2e6ab723f059 *resolute-server-cloudimg-amd64.vmdk diff --git a/tests/fixtures/SHA512SUMS b/tests/fixtures/SHA512SUMS new file mode 100644 index 0000000..0091726 --- /dev/null +++ b/tests/fixtures/SHA512SUMS @@ -0,0 +1,3 @@ +# generated by debian cloud team +1412d62c2d55ae2432df6d22c36b9edf4512a59a43bb4eadb1bd3a5ef201154a3052d83ffae704d7079b50388e61992013348e4bb4dee45b3c2a266e2b296135 debian-13-genericcloud-amd64.qcow2 +27c03aab0b5ee1f5918bfaa94821d2a1500fc171ffeb0ae159e7d18662f49171551f397683004434096e4006380fa607577b3c90fafa1eceb3a92702ebd49a15 debian-13-genericcloud-arm64.qcow2 diff --git a/tests/fixtures/domblkinfo.txt b/tests/fixtures/domblkinfo.txt new file mode 100644 index 0000000..075a7e8 --- /dev/null +++ b/tests/fixtures/domblkinfo.txt @@ -0,0 +1,3 @@ +Capacity: 42949672960 +Allocation: 6442450944 +Physical: 6443499520 diff --git a/tests/fixtures/domblklist-details.txt b/tests/fixtures/domblklist-details.txt new file mode 100644 index 0000000..8300047 --- /dev/null +++ b/tests/fixtures/domblklist-details.txt @@ -0,0 +1,4 @@ + Type Device Target Source +------------------------------------------------------------ + file disk vda /var/lib/virtuality/images/web01.qcow2 + file cdrom sda - diff --git a/tests/fixtures/domstats.txt b/tests/fixtures/domstats.txt new file mode 100644 index 0000000..5eb2d80 --- /dev/null +++ b/tests/fixtures/domstats.txt @@ -0,0 +1,16 @@ +Domain: 'web01' + cpu.time=48123456789 + cpu.user=30000000000 + cpu.system=18123456789 + balloon.current=2097152 + balloon.maximum=2097152 + balloon.rss=1534208 + vcpu.current=2 + vcpu.maximum=2 + vcpu.0.state=1 + vcpu.0.time=24000000000 + vcpu.0.wait=0 + vcpu.1.state=1 + vcpu.1.time=24123456789 + vcpu.1.wait=0 + diff --git a/tests/fixtures/dumpxml-web01.xml b/tests/fixtures/dumpxml-web01.xml new file mode 100644 index 0000000..23387f9 --- /dev/null +++ b/tests/fixtures/dumpxml-web01.xml @@ -0,0 +1,37 @@ + + web01 + 6c1b3c1e-0000-0000-0000-000000000001 + Сайт компании + Nginx + база. Бэкап по пятницам. + 2097152 + 2097152 + 2 + + hvm + + + + /usr/bin/qemu-system-x86_64 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/list-title.txt b/tests/fixtures/list-title.txt new file mode 100644 index 0000000..7593822 --- /dev/null +++ b/tests/fixtures/list-title.txt @@ -0,0 +1,6 @@ + Id Name State Title +-------------------------------------------- + 1 web01 running Сайт компании + - db01 shut off + - win11 shut off Бухгалтерия 1С + diff --git a/tests/fixtures/net-dumpxml.xml b/tests/fixtures/net-dumpxml.xml new file mode 100644 index 0000000..cf4594a --- /dev/null +++ b/tests/fixtures/net-dumpxml.xml @@ -0,0 +1,17 @@ + + virtuality-nat + 6c1b3c1e-1111-2222-3333-444444444444 + + + + + + + + + + + + + + diff --git a/tests/fixtures/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 b/tests/fixtures/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 new file mode 100644 index 0000000..6274704 --- /dev/null +++ b/tests/fixtures/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512 @@ -0,0 +1 @@ +2ae30006299896d225c9d4de36e9a7c3d047c9b4a5793efe3a6422ec5040e8ee5f7d71326aa9922ffa446fbd11ed6cd1a8d0af4659b3d00233d3fea3550d2d78 diff --git a/tests/fixtures/qemu-img-info.json b/tests/fixtures/qemu-img-info.json new file mode 100644 index 0000000..426201a --- /dev/null +++ b/tests/fixtures/qemu-img-info.json @@ -0,0 +1,10 @@ +{ + "children": [], + "virtual-size": 42949672960, + "filename": "/var/lib/virtuality/images/web01.qcow2", + "cluster-size": 65536, + "format": "qcow2", + "actual-size": 6443499520, + "format-specific": {"type": "qcow2", "data": {"compat": "1.1", "lazy-refcounts": false, "refcount-bits": 16, "corrupt": false}}, + "dirty-flag": false +} diff --git a/tests/fixtures/snapshot-info.txt b/tests/fixtures/snapshot-info.txt new file mode 100644 index 0000000..e5d500f --- /dev/null +++ b/tests/fixtures/snapshot-info.txt @@ -0,0 +1,9 @@ +Name: clean +Domain: web01 +Current: yes +State: running +Location: internal +Parent: - +Children: 1 +Descendants: 1 +Metadata: yes diff --git a/tests/fixtures/snapshot-list.txt b/tests/fixtures/snapshot-list.txt new file mode 100644 index 0000000..2ee1326 --- /dev/null +++ b/tests/fixtures/snapshot-list.txt @@ -0,0 +1,4 @@ + Name Creation Time State +------------------------------------------------------ + before-upd 2026-09-25 10:12:03 +0000 shutoff + clean 2026-09-24 09:00:41 +0000 running diff --git a/tests/render_html.mjs b/tests/render_html.mjs new file mode 100644 index 0000000..049dbac --- /dev/null +++ b/tests/render_html.mjs @@ -0,0 +1,10 @@ +import { createRequire } from 'node:module'; +const { chromium } = createRequire(import.meta.url)('playwright'); +// Render an HTML file to PNG. Usage: node tests/render_html.mjs in.html out.png [width] [height] +const [,, html, out, width = '1024', height = '768'] = process.argv; +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: +width, height: +height }, deviceScaleFactor: 1 }); +await page.goto('file://' + (html.startsWith('/') ? html : process.cwd() + '/' + html)); +await page.waitForTimeout(300); +await page.screenshot({ path: out, type: 'png' }); +await browser.close(); diff --git a/tests/test_app.py b/tests/test_app.py index ba4310e..95f4139 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -139,7 +139,7 @@ def test_vm_create_iso_builds_virt_install(logged_in, data_dirs, monkeypatch): cmd = captured["cmd"] assert cmd[0] == "virt-install" assert "--cdrom" in cmd and str(iso) in cmd - assert "network=virtuality-nat,model=virtio" in cmd + assert any(part.startswith("network=virtuality-nat,model=virtio,mac=52:54:00:") for part in cmd) assert captured["operation"]["boot_order"] == "cdrom_disk" diff --git a/tests/test_audit_fixes.py b/tests/test_audit_fixes.py new file mode 100644 index 0000000..d93d87e --- /dev/null +++ b/tests/test_audit_fixes.py @@ -0,0 +1,203 @@ +"""Regressions for the defects found by the 0.11 audit.""" +import json +from pathlib import Path +from urllib.parse import quote + +import app +import conftest +import network_core + +CDROM_XML = conftest.DUMPXML.replace( + "", + "", +) + + +def recording(monkeypatch, module, overrides=()): + calls: list[list[str]] = [] + + def run(cmd, timeout=12, **kwargs): + calls.append(list(cmd)) + for prefix, result in overrides: + if cmd[: len(prefix)] == list(prefix): + return result + return conftest.fake_run_cmd(cmd, timeout, **kwargs) + + monkeypatch.setattr(module, "run_cmd", run) + return calls + + +def find(calls, *prefix): + return [cmd for cmd in calls if cmd[: len(prefix)] == list(prefix)] + + +# ---------------------------------------------------------------- disk image format (app.py:620) +def test_disk_image_format_asks_qemu_img(data_dirs, monkeypatch): + # Ubuntu cloud images are qcow2 files named *.img: the suffix must not decide the format. + assert app.disk_image_format(Path("/tmp/noble-server-cloudimg-amd64.img")) == "qcow2" + recording(monkeypatch, app, [(["qemu-img", "info"], conftest.fake_result(ok=False, stderr="no such file"))]) + assert app.disk_image_format(Path("/tmp/disk.img")) == "raw" + assert app.disk_image_format(Path("/tmp/disk.qcow2")) == "qcow2" + + +# ---------------------------------------------------------------- VM actions (app.py:1977, 1981) +def test_vm_action_reports_virsh_error(logged_in, data_dirs, monkeypatch): + recording(monkeypatch, app, [(["virsh", "start"], conftest.fake_result(ok=False, stderr="error: Domain is already active"))]) + response = logged_in.post("/vm/web01/start", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/vm/web01?error=" + quote("Машина уже запущена") + assert "Машина уже запущена" in logged_in.get("/vm/web01?error=" + quote("Машина уже запущена")).text + + +def test_vm_action_success_redirects_without_error(logged_in, data_dirs, monkeypatch): + recording(monkeypatch, app) + assert logged_in.post("/vm/web01/shutdown", follow_redirects=False).headers["location"] == "/vm/web01" + + +def test_vm_delete_undefines_uefi_machines(logged_in, data_dirs, monkeypatch): + calls = recording(monkeypatch, app) + response = logged_in.post("/vm/web01/delete", follow_redirects=False) + assert response.headers["location"].startswith("/?message=") + assert find(calls, "virsh", "destroy", "web01") + undefine = find(calls, "virsh", "undefine", "web01")[0] + assert "--nvram" in undefine and "--remove-all-storage" in undefine and "--snapshots-metadata" in undefine + + +def test_vm_delete_failure_is_reported(logged_in, data_dirs, monkeypatch): + recording(monkeypatch, app, [(["virsh", "undefine"], conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: cannot undefine domain with nvram"))]) + response = logged_in.post("/vm/web01/delete", follow_redirects=False) + assert response.headers["location"].startswith("/vm/web01?error=") + + +# ---------------------------------------------------------------- ISO in the CD-ROM drive (app.py:1150, 1158) +def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch): + iso = data_dirs["iso"] / "debian.iso" + iso.write_bytes(b"iso") + calls = recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(CDROM_XML))]) + ok, message = app.mount_vm_iso("web01", str(iso)) + assert ok, message + assert find(calls, "virsh", "change-media", "web01", "sda", str(iso), "--update", "--config", "--live") + assert not find(calls, "virsh", "detach-disk") and not find(calls, "virsh", "attach-disk") + ok, message = app.detach_vm_iso("web01") + assert ok and find(calls, "virsh", "change-media", "web01", "sda", "--eject", "--config", "--live") + + +def test_iso_unmount_never_guesses_targets(data_dirs, monkeypatch): + # web01 has only a virtio data disk: nothing may be detached blindly. + calls = recording(monkeypatch, app) + ok, message = app.detach_vm_iso("web01") + assert not ok and message == "Подключенный ISO не найден." + assert not find(calls, "virsh", "detach-disk") and not find(calls, "virsh", "change-media") + + +def test_iso_mount_adds_drive_to_config_when_missing(data_dirs, monkeypatch): + iso = data_dirs["iso"] / "debian.iso" + iso.write_bytes(b"iso") + calls = recording(monkeypatch, app) + ok, message = app.mount_vm_iso("web01", str(iso)) + assert ok and "перезапуска" in message + attach = find(calls, "virsh", "attach-disk", "web01")[0] + assert "--config" in attach and "--live" not in attach + + +# ---------------------------------------------------------------- port forwards leave no stale firewall rules (network_core.py:459) +def test_iptables_fallback_removes_stale_rules(data_dirs, monkeypatch): + listing = "\n".join([ + "-A FORWARD -i eth0 -o virbr1 -p tcp -d 192.168.100.51 -m tcp --dport 22 -m comment --comment virtuality-forward -j ACCEPT", + "-A FORWARD -j SOMETHING_ELSE", + ]) + calls = recording(monkeypatch, network_core, [(["iptables", "-t", "filter", "-S", "FORWARD"], conftest.fake_result(listing))]) + network_core.apply_iptables_fallback([]) + deleted = find(calls, "iptables", "-t", "filter", "-D", "FORWARD") + assert deleted == [["iptables", "-t", "filter", "-D", "FORWARD", "-i", "eth0", "-o", "virbr1", "-p", "tcp", "-d", "192.168.100.51", "-m", "tcp", "--dport", "22", "-m", "comment", "--comment", "virtuality-forward", "-j", "ACCEPT"]] + checked = find(calls, "iptables", "-t", "nat", "-C", "POSTROUTING") + assert checked and "virtuality-forward" in checked[0] + + +def test_ufw_rules_follow_forwards(data_dirs, monkeypatch): + old_rule = ["allow", "2222/tcp"] + network_core.UFW_STATE_FILE.write_text(json.dumps([old_rule, ["route", "allow", "in", "on", "eth0", "out", "on", "virbr1", "to", "192.168.100.51", "port", "22", "proto", "tcp"]])) + calls = recording(monkeypatch, network_core, [(["ufw", "status"], conftest.fake_result("Status: active"))]) + network_core.apply_ufw_route_rules([]) + assert find(calls, "ufw", "delete", "allow", "2222/tcp") + assert find(calls, "ufw", "route", "delete", "allow", "in", "on", "eth0") + assert json.loads(network_core.UFW_STATE_FILE.read_text()) == [] + + +# ---------------------------------------------------------------- operations orphaned by a restart +def test_orphaned_operations_are_closed_on_startup(data_dirs): + import core + + running = core.new_operation("snapshot_create", "Снимок web01", vm_name="web01") + core.update_operation(running, status="running") + done = core.new_operation("backup", "Копия web01", vm_name="web01") + core.finish_operation(done, True, "Готово") + assert core.active_operations_for("web01") + assert core.interrupt_orphaned_operations() == 1 + assert core.read_operation(running["id"])["status"] == "error" and core.read_operation(running["id"])["interrupted"] + assert core.read_operation(done["id"])["status"] == "success" + assert core.active_operations_for("web01") == [] + + +def test_snapshot_names_with_spaces_are_listed(): + from features import snapshots + + rows = snapshots.parse_snapshot_list(" Name Creation Time State\n------\n before update 2026-09-25 10:12:03 +0000 shutoff\n clean 2026-09-10 12:05:00 +0000 running\n") + assert [row["name"] for row in rows] == ["before update", "clean"] + + +# ---------------------------------------------------------------- second audit round +def test_vm_names_that_virsh_reads_as_id_or_uuid_are_rejected(): + import core + + for module in (app, core, network_core): + assert module.valid_vm_name("web01") and module.valid_vm_name("1c-server") + assert not module.valid_vm_name("10") and not module.valid_vm_name("6c1b3c1e-0000-0000-0000-000000000001") + + +def test_subprocesses_use_c_locale(): + import os + + import core + + assert core.STORAGE_DIR and os.environ["LC_ALL"] == "C.UTF-8" # set when core is imported + + +def test_lease_is_not_guessed_without_mac(data_dirs, monkeypatch): + recording(monkeypatch, network_core, [ + (["virsh", "domifaddr"], conftest.fake_result(ok=False, stderr="domain is not running")), + (["virsh", "domiflist"], conftest.fake_result(ok=False, stderr="failed to get domain")), + (["virsh", "net-dhcp-leases"], conftest.fake_result(" 2026-09-26 10:00:00 52:54:00:11:22:33 ipv4 192.168.100.77/24 other -")), + ]) + assert network_core.resolve_vm_ip("gone") is None + + +def test_stopped_machines_have_no_stale_ip(data_dirs, monkeypatch): + calls = recording(monkeypatch, app) + rows = {row["name"]: row for row in app.parse_virsh_list()} + assert rows["db01"]["ip"] == "—" and rows["web01"]["ip"] == "192.168.100.51" + assert not [cmd for cmd in calls if cmd[:2] == ["virsh", "domifaddr"] and cmd[-1] == "db01"] + + +def test_system_disk_named_like_iso_is_not_shown_as_iso(data_dirs, monkeypatch): + xml = conftest.DUMPXML.replace("", "") + recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(xml))]) + assert app.current_vm_iso("web01") == "" + + +def test_iso_in_use_is_not_deleted(logged_in, data_dirs, monkeypatch): + iso = data_dirs["iso"] / "old.iso" + iso.write_bytes(b"iso") + xml = CDROM_XML.replace("/var/lib/virtuality/iso/old.iso", str(iso)) + recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(xml)), (["virsh", "list", "--all", "--name"], conftest.fake_result("web01\n"))]) + response = logged_in.post("/iso/old.iso/delete", follow_redirects=False) + assert "error=" in response.headers["location"] and iso.exists() + + +def test_arm_board_is_ready_without_vmx_flags(monkeypatch): + import host_profile + + monkeypatch.setattr(host_profile.platform, "machine", lambda: "aarch64") + monkeypatch.setattr(host_profile, "cpu_virtualization_flags_count", lambda: 0) + profile = host_profile.detect_host_profile() + assert not [check for check in profile["checks"] if check["name"] == "Поддержка виртуализации процессором"] diff --git a/tests/test_backups.py b/tests/test_backups.py new file mode 100644 index 0000000..4b5e7ec --- /dev/null +++ b/tests/test_backups.py @@ -0,0 +1,645 @@ +import json +from datetime import datetime +from pathlib import Path +from urllib.parse import unquote + +import pytest + +import core +import features.backups as backups +from conftest import DOMBLKLIST_DETAILS, DUMPXML_MIGRATABLE, QEMU_IMG_INFO, fake_result, fake_run_cmd + +FIXTURES = Path(__file__).resolve().parent / "fixtures" + + +def make_backup(dirs, vm="web01", backup_id="20260924-0300", size=2_684_354_560, note="", disks=("vda",), complete=True) -> Path: + path = dirs["backups"] / vm / backup_id + path.mkdir(parents=True) + (path / "vm.xml").write_text(DUMPXML_MIGRATABLE) + for target in disks: + if complete: + (path / f"{target}.qcow2").write_bytes(b"QFI\xfb" + b"\0" * 60) + meta = {"vm": vm, "created_at": "2026-09-24 03:00:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": 6_443_499_520} for t in disks], "size_bytes": size, "version": "0.11.0", "note": note} + (path / "meta.json").write_text(json.dumps(meta)) + return path + + +class FakeVirsh: + """Имитация virsh с состоянием: машина выключается после `virsh shutdown`.""" + + def __init__(self, running=True, shuts_down=True, exists=("web01", "db01"), paused=False, rejects_shutdown=False): + self.running = running + self.paused = paused + self.shuts_down = shuts_down + self.rejects_shutdown = rejects_shutdown + self.exists = set(exists) + self.calls: list[list[str]] = [] + self.defined_xml = "" + + def __call__(self, cmd, timeout=12, **_kwargs): + self.calls.append(list(cmd)) + if cmd[:2] == ["virsh", "domstate"]: + return fake_result("paused" if self.running and self.paused else "running" if self.running else "shut off") + if cmd[:2] == ["virsh", "shutdown"]: + if self.rejects_shutdown: + return fake_result(ok=False, stderr="error: Requested operation is not valid: domain is not running") + if self.shuts_down: + self.running = False + return fake_result() + if cmd[:2] == ["virsh", "managedsave"]: + self.running = False + return fake_result(f"Domain '{cmd[2]}' state saved by libvirt") + if cmd[:2] == ["virsh", "destroy"]: + self.running = False + return fake_result() + if cmd[:2] == ["virsh", "start"]: + self.running = True + return fake_result() + if cmd[:2] == ["virsh", "dominfo"]: + return fake_result(f"Name: {cmd[-1]}\nState: running\n") if cmd[-1] in self.exists else fake_result(ok=False, stderr="error: failed to get domain") + if cmd[:2] == ["virsh", "define"]: + self.defined_xml = Path(cmd[2]).read_text() + return fake_result(f"Domain defined from {cmd[2]}") + if cmd[:2] == ["virsh", "undefine"]: + self.exists.discard(cmd[2]) + return fake_result() + return fake_run_cmd(cmd, timeout) + + +def fake_stream(progress_lines=("(0.00/100%)", "(48.50/100%)", "(100.00/100%)"), code=0, calls=None): + """Имитация qemu-img convert. calls — общий список с FakeVirsh, если важен порядок команд.""" + calls = [] if calls is None else calls + + def stream(cmd, on_line, timeout=None): + calls.append(list(cmd)) + Path(cmd[-1]).write_bytes(b"QFI\xfb" + b"\0" * 1000) + for line in progress_lines: + on_line(line) + return code + + stream.calls = calls + return stream + + +class FixedDatetime(datetime): + """datetime.now() всегда 24 сентября 2026, 03:00 — чтобы id копий были предсказуемы.""" + + @classmethod + def now(cls, tz=None): + return cls(2026, 9, 24, 3, 0, 0) + + +@pytest.fixture() +def fast_worker(monkeypatch, data_dirs): + """Операции и фоновые потоки выполняются синхронно, ожидание выключения — мгновенно.""" + monkeypatch.setattr(core, "run_operation", lambda operation, worker: worker(operation)) + monkeypatch.setattr(backups, "start_thread", lambda target, *args: target(*args)) + monkeypatch.setattr(backups, "POLL_INTERVAL", 0.001) + monkeypatch.setattr(backups, "SHUTDOWN_TIMEOUT", 0.05) + monkeypatch.setattr(backups, "LATE_SHUTDOWN_WATCH", 0.05) + monkeypatch.setattr(backups, "free_bytes", lambda path: 500 * 1024 ** 3) + return data_dirs + + +# ---------------------------------------------------------------- парсеры +def test_parse_progress(): + assert backups.parse_progress(" (12.34/100%)") == 12 + assert backups.parse_progress("(100.00/100%)") == 100 + assert backups.parse_progress("qemu-img: Could not open") is None + + +def test_parse_qemu_img_info_fixture(): + info = backups.parse_qemu_img_info(QEMU_IMG_INFO) + assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"} + assert backups.parse_qemu_img_info("not json")["actual_size"] == 0 + assert backups.parse_qemu_img_info("[1, 2]")["format"] == "" + + +def test_vm_disks_skips_cdrom(data_dirs): + disks = backups.vm_disks("web01") + assert [(d["target"], d["source"]) for d in disks] == [("vda", str(core.IMAGES_DIR / "web01.qcow2"))] + assert DOMBLKLIST_DETAILS.count("cdrom") == 1 + + +def test_stream_cmd_splits_carriage_returns(): + lines: list[str] = [] + code = backups.stream_cmd(["python3", "-c", "import sys; sys.stdout.write('a\\r (10.00/100%)\\r(55.50/100%)\\nend')"], lines.append) + assert code == 0 + assert lines == ["a", "(10.00/100%)", "(55.50/100%)", "end"] + + +def test_stream_cmd_stops_hung_command(): + lines: list[str] = [] + code = backups.stream_cmd(["python3", "-c", "import time; time.sleep(30)"], lines.append, timeout=0.2) + assert code != 0 + assert any("остановлена" in line for line in lines) + + +def test_disk_info_uses_shared_lock_and_raises_on_failure(monkeypatch): + calls: list[list[str]] = [] + + def info_ok(cmd, timeout=12, **_kw): + calls.append(list(cmd)) + return fake_result(QEMU_IMG_INFO) + + monkeypatch.setattr(core, "run_cmd", info_ok) + assert backups.disk_info("/x/web01.qcow2")["actual_size"] == 6443499520 + assert calls == [["qemu-img", "info", "-U", "--output=json", "/x/web01.qcow2"]] # -U: диск занят работающей машиной + monkeypatch.setattr(core, "run_cmd", lambda cmd, timeout=12, **_kw: fake_result(ok=False, stderr='qemu-img: Could not open: Failed to get shared "write" lock')) + with pytest.raises(backups.BackupError) as exc: + backups.disk_info("/x/web01.qcow2") + assert "размер диска" in str(exc.value) and "write" in str(exc.value) + + +def test_rewrite_xml_renames_and_detaches_identity(): + xml = backups.rewrite_xml((FIXTURES / "dumpxml-web01.xml").read_text(), "web02", {"vda": "/var/lib/virtuality/images/web02.qcow2"}) + assert "web02" in xml + assert "" not in xml and "id=" not in xml.split(">", 1)[0] + assert "" in xml or "" in xml # привод CD остаётся как был + + +def test_rewrite_xml_keeps_identity_on_replace_and_drops_stale_chain(tmp_path): + present = tmp_path / "debian.iso" + present.write_bytes(b"iso") + source = f"""web016c1b3c1e-0000-0000-0000-000000000001 + + + + + + + + """ + replaced = backups.rewrite_xml(source, "web01", {"vda": "/var/lib/virtuality/images/web01.qcow2"}, keep_identity=True) + assert "6c1b3c1e-0000-0000-0000-000000000001" in replaced and 'mac address="52:54:00:aa:bb:cc"' in replaced + assert "backingStore" not in replaced and "web01.snap.qcow2" not in replaced + assert "gone.iso" not in replaced and str(present) in replaced # пропавший образ вынут, существующий остаётся + assert 'id="7"' not in replaced + fresh = backups.rewrite_xml(source, "web02", {"vda": "/var/lib/virtuality/images/web02.qcow2"}) + assert "" not in fresh and "oldhvm/usr/share/OVMF/OVMF_CODE.fd/var/lib/libvirt/qemu/nvram/old_VARS.fd + """ + xml = backups.rewrite_xml(source, "new", {"vda": "/var/lib/virtuality/images/new.qcow2"}) + assert "" not in xml + assert 'type="file"' in xml and 'file="/var/lib/virtuality/images/new.qcow2"' in xml + assert 'type="qcow2"' in xml and "source dev=" not in xml and 'type="raw"' not in xml + + +def test_format_backup_date(): + assert backups.format_backup_date("20260924-0300") == "24 сентября 2026, 03:00" + assert backups.format_backup_date("20260924-0300-2") == "24 сентября 2026, 03:00" # вторая копия в ту же минуту + assert backups.format_backup_date("garbage") == "garbage" + + +def test_clean_note_limits_length_and_control_chars(): + assert backups.clean_note(" до\x00обновления\n ") == "до обновления" + assert len(backups.clean_note("x" * 500)) == backups.NOTE_MAX + + +# ---------------------------------------------------------------- хранилище +def test_list_and_group_backups(data_dirs): + make_backup(data_dirs, "web01", "20260917-0300", size=100) + make_backup(data_dirs, "web01", "20260924-0300", size=200, note="перед обновлением") + make_backup(data_dirs, "old-vm", "20260901-1200", size=50) + (data_dirs["backups"] / "web01" / "junk").mkdir() + (data_dirs["backups"] / "..hidden").mkdir() + items = backups.list_backups() + assert [(i["vm"], i["id"]) for i in items] == [("web01", "20260924-0300"), ("web01", "20260917-0300"), ("old-vm", "20260901-1200")] + assert items[0]["note"] == "перед обновлением" and items[0]["complete"] is True + groups = backups.group_by_vm(items, {"web01"}) + assert [(g["vm"], g["count"], g["size_bytes"], g["exists"]) for g in groups] == [("old-vm", 1, 50, False), ("web01", 2, 300, True)] + assert backups.list_backups("web01")[0]["id"] == "20260924-0300" + assert backups.storage_summary(items)["used_bytes"] == 350 + + +def test_backup_path_rejects_traversal(data_dirs): + assert backups.backup_path("../etc", "20260924-0300") is None + assert backups.backup_path("web01", "../../x") is None + assert backups.backup_path("web01", "20260924-0300") == data_dirs["backups"] / "web01" / "20260924-0300" + + +def test_incomplete_backup_is_flagged(data_dirs): + make_backup(data_dirs, complete=False) + assert backups.read_backup("web01", "20260924-0300")["complete"] is False + + +def test_backup_with_unsafe_disk_target_is_incomplete(data_dirs): + path = make_backup(data_dirs) + meta = json.loads((path / "meta.json").read_text()) + meta["disks"].append({"target": "../../etc/shadow", "source": "/x", "size": 1}) + (path / "meta.json").write_text(json.dumps(meta)) + backup = backups.read_backup("web01", "20260924-0300") + assert [disk["target"] for disk in backup["disks"]] == ["vda"] and backup["complete"] is False + + +def test_storage_summary_percent_is_share_of_whole_disk(data_dirs, monkeypatch): + monkeypatch.setattr(backups, "total_bytes", lambda path: 1000) + monkeypatch.setattr(backups, "free_bytes", lambda path: 400) + summary = backups.storage_summary([{"size_bytes": 350}]) + assert summary["used_pct"] == 35 and summary["free_bytes"] == 400 + + +# ---------------------------------------------------------------- создание копии +def test_create_backup_refuses_without_space(data_dirs, monkeypatch): + monkeypatch.setattr(backups, "free_bytes", lambda path: 1024) + with pytest.raises(backups.BackupError) as exc: + backups.create_backup("web01") + assert "Недостаточно места" in str(exc.value) and "6.0 ГБ" in str(exc.value) + + +def test_create_backup_rejects_unknown_vm(fast_worker): + with pytest.raises(backups.BackupError): + backups.create_backup("nope") + with pytest.raises(backups.BackupError): + backups.create_backup("bad name") + + +def test_create_backup_shuts_down_copies_and_restarts(fast_worker, monkeypatch): + virsh = FakeVirsh(running=True) + monkeypatch.setattr(core, "run_cmd", virsh) + stream = fake_stream() + monkeypatch.setattr(backups, "stream_cmd", stream) + queued = backups.create_backup("web01", " ночная\tкопия ") + operation = core.read_operation(queued["id"]) + assert operation["status"] == "success", operation["message"] + assert operation["progress"] == 100 and "Резервная копия создана" in operation["message"] + assert ["virsh", "shutdown", "web01"] in virsh.calls + assert ["virsh", "dumpxml", "web01", "--migratable"] in virsh.calls + assert ["qemu-img", "info", "-U", "--output=json", str(core.IMAGES_DIR / "web01.qcow2")] in virsh.calls + assert virsh.calls[-1] == ["virsh", "start", "web01"] + assert ["virsh", "destroy", "web01"] not in virsh.calls + assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", "-c", str(core.IMAGES_DIR / "web01.qcow2"), str(fast_worker["backups"] / "web01" / operation["backup_id"] / "vda.qcow2")]] + path = fast_worker["backups"] / "web01" / operation["backup_id"] + meta = json.loads((path / "meta.json").read_text()) + assert meta["vm"] == "web01" and meta["note"] == "ночная копия" and meta["disks"][0]["target"] == "vda" + assert meta["size_bytes"] == sum(f.stat().st_size for f in path.iterdir() if f.name != "meta.json") + assert "Сайт компании" in (path / "vm.xml").read_text() + log = core.read_operation(operation["id"])["log_tail"] + assert "48%" in log and "Машина запущена снова" in log + + +def test_create_backup_wait_returns_finished_operation(fast_worker, monkeypatch): + monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False)) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + operation = backups.create_backup("db01", wait=True) + assert operation["status"] == "success" and operation["type"] == "backup" + + +def test_backup_fails_when_vm_does_not_shut_down(fast_worker, monkeypatch): + virsh = FakeVirsh(running=True, shuts_down=False) + monkeypatch.setattr(core, "run_cmd", virsh) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + operation = backups.create_backup("web01") + fresh = core.read_operation(operation["id"]) + assert fresh["status"] == "error" and "не выключилась" in fresh["message"] and "запустит её снова" in fresh["message"] + assert ["virsh", "destroy", "web01"] not in virsh.calls + assert ["virsh", "start", "web01"] not in virsh.calls # так и не выключилась — запускать нечего + assert "так и не выключилась" in fresh["log_tail"] + assert not (fast_worker["backups"] / "web01").exists() + + +def test_backup_restarts_vm_that_shuts_down_late(fast_worker, monkeypatch): + virsh = FakeVirsh(running=True, shuts_down=False) + monkeypatch.setattr(core, "run_cmd", virsh) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + + def shut_down_after_giving_up(target, *args): + virsh.running = False # ACPI-выключение дошло уже после того, как задача сдалась + target(*args) + + monkeypatch.setattr(backups, "start_thread", shut_down_after_giving_up) + operation = backups.create_backup("web01") + fresh = core.read_operation(operation["id"]) + assert fresh["status"] == "error" and "не выключилась" in fresh["message"] + assert virsh.calls[-1] == ["virsh", "start", "web01"] and virsh.running + assert "запускаем её снова" in fresh["log_tail"] + + +def test_backup_reports_rejected_shutdown_command(fast_worker, monkeypatch): + virsh = FakeVirsh(running=True, rejects_shutdown=True) + monkeypatch.setattr(core, "run_cmd", virsh) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + monkeypatch.setattr(backups, "start_thread", lambda target, *args: pytest.fail("нечего ждать — команда не принята")) + operation = backups.create_backup("web01") + fresh = core.read_operation(operation["id"]) + assert fresh["status"] == "error" + assert "не приняла команду выключения" in fresh["message"] and "domain is not running" in fresh["message"] + assert "не выключилась" not in fresh["message"] + assert ["virsh", "start", "web01"] not in virsh.calls + assert not (fast_worker["backups"] / "web01").exists() + + +def test_backup_of_paused_vm_saves_state_instead_of_shutdown(fast_worker, monkeypatch): + virsh = FakeVirsh(running=True, paused=True) + monkeypatch.setattr(core, "run_cmd", virsh) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + operation = backups.create_backup("web01") + fresh = core.read_operation(operation["id"]) + assert fresh["status"] == "success", fresh["message"] + assert ["virsh", "managedsave", "web01"] in virsh.calls and ["virsh", "shutdown", "web01"] not in virsh.calls + assert virsh.calls.index(["virsh", "managedsave", "web01"]) < virsh.calls.index(["virsh", "dumpxml", "web01", "--migratable"]) + assert virsh.calls[-1] == ["virsh", "start", "web01"] + assert "на паузе" in fresh["log_tail"] + + +def test_backup_cleans_up_and_restarts_after_copy_error(fast_worker, monkeypatch): + virsh = FakeVirsh(running=True) + monkeypatch.setattr(core, "run_cmd", virsh) + monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1)) + operation = backups.create_backup("web01") + fresh = core.read_operation(operation["id"]) + assert fresh["status"] == "error" and "vda" in fresh["message"] + assert not (fast_worker["backups"] / "web01").exists() + assert virsh.calls[-1] == ["virsh", "start", "web01"] + + +def test_create_backup_refuses_while_another_runs(fast_worker, monkeypatch): + monkeypatch.setattr(core, "run_operation", lambda operation, worker: None) # остаётся в очереди + backups.create_backup("web01") + with pytest.raises(backups.BackupError) as exc: + backups.create_backup("web01") + assert "уже идёт" in str(exc.value) + + +def test_second_backup_in_same_minute_gets_own_folder_and_keeps_first(fast_worker, monkeypatch): + monkeypatch.setattr(backups, "datetime", FixedDatetime) + monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False)) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + first = core.read_operation(backups.create_backup("web01", "первая")["id"]) + assert first["status"] == "success" and first["backup_id"] == "20260924-0300" + monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1)) + second = core.read_operation(backups.create_backup("web01", "вторая")["id"]) + assert second["status"] == "error" and second["backup_id"] == "20260924-0300-2" + first_dir = fast_worker["backups"] / "web01" / "20260924-0300" + assert (first_dir / "meta.json").exists() and (first_dir / "vda.qcow2").exists() # чужую папку неудачная задача не трогает + assert not (fast_worker["backups"] / "web01" / "20260924-0300-2").exists() + assert [item["id"] for item in backups.list_backups("web01")] == ["20260924-0300"] + assert backups.backup_path("web01", "20260924-0300-2") == fast_worker["backups"] / "web01" / "20260924-0300-2" + + +# ---------------------------------------------------------------- восстановление +def test_restore_under_new_name(fast_worker, monkeypatch): + make_backup(fast_worker) + virsh = FakeVirsh(running=False) + monkeypatch.setattr(core, "run_cmd", virsh) + stream = fake_stream() + monkeypatch.setattr(backups, "stream_cmd", stream) + operation = backups.start_restore("web01", "20260924-0300", "web02", False) + fresh = core.read_operation(operation["id"]) + assert fresh["status"] == "success", fresh["message"] + assert fresh["vm_name"] == "web02" + target = fast_worker["images"] / "web02.qcow2" + assert stream.calls[0][:6] == ["qemu-img", "convert", "-p", "-O", "qcow2", str(fast_worker["backups"] / "web01" / "20260924-0300" / "vda.qcow2")] + assert stream.calls[0][6].startswith(str(fast_worker["images"] / "web02.restore-")) # сначала во временный файл + assert target.exists() and not list(fast_worker["images"].glob("*.restore-*")) + assert "web02" in virsh.defined_xml and str(target) in virsh.defined_xml and "web01" in virsh.defined_xml + # при замене машина остаётся «той же»: UUID и MAC из копии сохраняются + assert "6c1b3c1e-0000-0000-0000-000000000001" in virsh.defined_xml and "52:54:00:aa:bb:cc" in virsh.defined_xml + + +def test_replace_restore_keeps_old_vm_when_copy_fails(fast_worker, monkeypatch): + make_backup(fast_worker) + virsh = FakeVirsh(running=True) + monkeypatch.setattr(core, "run_cmd", virsh) + monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1)) + operation = backups.start_restore("web01", "20260924-0300", "web01", True) + fresh = core.read_operation(operation["id"]) + assert fresh["status"] == "error" and "Не удалось восстановить диск vda" in fresh["message"] and "qemu-img" not in fresh["message"] + assert "qemu-img завершился с кодом 1" in fresh["log_tail"] + assert ["virsh", "destroy", "web01"] not in virsh.calls and UNDEFINE not in virsh.calls and virsh.running + assert not list(fast_worker["images"].iterdir()) + + +def test_restore_refuses_when_space_is_tight_even_if_old_vm_would_free_it(fast_worker, monkeypatch): + make_backup(fast_worker) + monkeypatch.setattr(backups, "free_bytes", lambda path: 1024) + with pytest.raises(backups.BackupError) as exc: + backups.start_restore("web01", "20260924-0300", "web01", True) + assert exc.value.code == "space" and "рядом со старой машиной" in str(exc.value) + with pytest.raises(backups.BackupError) as exc: + backups.start_restore("web01", "20260924-0300", "web02", False) + assert exc.value.code == "space" and "рядом со старой машиной" not in str(exc.value) + + +def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch): + make_backup(fast_worker) + virsh = FakeVirsh(running=False) + base = virsh.__call__ + + def with_iso(cmd, timeout=12, **kw): + if cmd[:2] == ["virsh", "domblklist"]: + virsh.calls.append(list(cmd)) + return fake_result(DOMBLKLIST_DETAILS.replace("sda -", "sda /var/lib/virtuality/iso/debian.iso")) + return base(cmd, timeout, **kw) + + monkeypatch.setattr(core, "run_cmd", with_iso) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + backups.start_restore("web01", "20260924-0300", "web01", True) + eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"] + assert eject in virsh.calls + assert virsh.calls.index(eject) < virsh.calls.index(UNDEFINE) + + +def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker): + make_backup(fast_worker) + with pytest.raises(backups.BackupError) as exc: + backups.start_restore("web01", "20260924-0300", "-bad", False) + assert exc.value.code == "name" + make_backup(fast_worker, backup_id="20260901-0000", complete=False) + with pytest.raises(backups.BackupError) as exc: + backups.start_restore("web01", "20260901-0000", "web02", False) + assert "неполная" in str(exc.value) + + +def test_restore_cleans_up_on_define_failure(fast_worker, monkeypatch): + make_backup(fast_worker) + virsh = FakeVirsh(running=False) + base = virsh.__call__ + + def failing_define(cmd, timeout=12, **kw): + if cmd[:2] == ["virsh", "define"]: + return fake_result(ok=False, stderr="error: XML error: bad thing") + return base(cmd, timeout, **kw) + + monkeypatch.setattr(core, "run_cmd", failing_define) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + operation = backups.start_restore("web01", "20260924-0300", "web02", False) + fresh = core.read_operation(operation["id"]) + assert fresh["status"] == "error" and "bad thing" in fresh["message"] + assert not (fast_worker["images"] / "web02.qcow2").exists() and not list(fast_worker["images"].glob("*.restore-*")) + + +def test_restore_disk_path_avoids_collisions(data_dirs): + (data_dirs["images"] / "web02.qcow2").write_bytes(b"x") + assert backups.restore_disk_path("web02", "vda", 0) == data_dirs["images"] / "web02-vda.qcow2" + assert backups.restore_disk_path("web02", "vdb", 1) == data_dirs["images"] / "web02-vdb.qcow2" + (data_dirs["images"] / "web02-vda.qcow2").write_bytes(b"x") + assert backups.restore_disk_path("web02", "vda", 0) == data_dirs["images"] / "web02-vda-2.qcow2" + + +# ---------------------------------------------------------------- страницы +def test_backup_pages_require_login(client): + for path in ("/backups", "/vm/web01/backups", "/backups/web01/20260924-0300/restore"): + response = client.get(path, follow_redirects=False) + assert response.status_code == 303 and response.headers["location"] == "/login" + + +def test_backups_page_lists_groups(logged_in, data_dirs): + make_backup(data_dirs, "web01", "20260924-0300", size=2_684_354_560, note="перед обновлением") + make_backup(data_dirs, "gone-vm", "20260901-1200", size=1024 ** 3) + html = logged_in.get("/backups").text + assert "24 сентября 2026, 03:00" in html and "2.5 ГБ" in html and "перед обновлением" in html + assert 'href="/backups/web01/20260924-0300/restore"' in html + assert "машина удалена" in html and 'href="/vm/web01/backups"' in html + assert "Резервные копии" in html + + +def test_backups_page_empty_state(logged_in): + html = logged_in.get("/backups").text + assert "Копий пока нет" in html + + +def test_vm_backups_page_and_tab_link(logged_in, data_dirs): + make_backup(data_dirs) + html = logged_in.get("/vm/web01/backups").text + assert 'action="/vm/web01/backups/create"' in html and "Создать копию" in html + assert "24 сентября 2026, 03:00" in html + assert 'href="/vm/web01/backups"' in logged_in.get("/vm/web01").text + assert logged_in.get("/vm/nope/backups", follow_redirects=False).headers["location"] == "/" + + +def test_vm_backup_create_route(logged_in, fast_worker, monkeypatch): + monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False)) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + response = logged_in.post("/vm/web01/backups/create", data={"note": "перед обновлением"}, follow_redirects=False) + assert response.status_code == 303 and response.headers["location"].startswith("/operations/") + operation_id = response.headers["location"].rsplit("/", 1)[1] + assert core.read_operation(operation_id)["status"] == "success" + assert logged_in.get(f"/operations/{operation_id}").status_code == 200 + + +def test_vm_backup_create_route_shows_error(logged_in, data_dirs, monkeypatch): + monkeypatch.setattr(backups, "free_bytes", lambda path: 0) + response = logged_in.post("/vm/web01/backups/create", data={"note": ""}, follow_redirects=False) + assert response.status_code == 303 + assert "backup_error=" in response.headers["location"] and "Недостаточно" in unquote(response.headers["location"]) + assert "Недостаточно места" in logged_in.get(response.headers["location"]).text + + +def test_restore_page_and_submit(logged_in, fast_worker, monkeypatch): + make_backup(fast_worker) + monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False)) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + html = logged_in.get("/backups/web01/20260924-0300/restore").text + assert 'name="replace"' in html and "Заменить существующую машину web01" in html + assert "Старая машина удаляется только после распаковки" in html and "Сетевые адреса сохраняются" in html + response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web01", "replace": ""}) + assert response.status_code == 400 and "уже есть на сервере" in response.text + response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web02", "replace": ""}, follow_redirects=False) + assert response.status_code == 303 and response.headers["location"].startswith("/operations/") + response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "-bad"}) + assert response.status_code == 400 + assert logged_in.get("/backups/web01/20990101-0000/restore", follow_redirects=False).headers["location"].startswith("/backups?backup_error=") + + +def test_delete_backup_route(logged_in, data_dirs): + path = make_backup(data_dirs) + response = logged_in.post("/backups/web01/20260924-0300/delete", data={"next": "/vm/web01/backups"}, follow_redirects=False) + assert response.status_code == 303 and response.headers["location"].startswith("/vm/web01/backups?backup_message=") + assert not path.exists() and not path.parent.exists() + response = logged_in.post("/backups/web01/20260924-0300/delete", data={"next": "http://evil"}, follow_redirects=False) + assert response.headers["location"].startswith("/backups?backup_error=") + + +def test_delete_backup_path_traversal(logged_in, data_dirs, tmp_path): + victim = tmp_path / "victim" + victim.mkdir() + (victim / "20260924-0300").mkdir() + # %2F раскрывается до маршрутизации: лишние сегменты не подходят ни под один маршрут + assert logged_in.post("/backups/..%2Fvictim/20260924-0300/delete", follow_redirects=False).status_code == 404 + assert logged_in.post("/backups/web01/..%2F..%2Fvictim/delete", follow_redirects=False).status_code == 404 + # а сама проверка путей — в delete_backup, независимо от маршрутизации + with pytest.raises(backups.BackupError): + backups.delete_backup("../victim", "20260924-0300") + with pytest.raises(backups.BackupError): + backups.delete_backup("web01", "../x") + assert (victim / "20260924-0300").exists() + + +def test_delete_backup_refused_while_copy_is_in_use(fast_worker, monkeypatch): + monkeypatch.setattr(core, "run_operation", lambda operation, worker: None) # задачи остаются в очереди + monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False)) + queued = backups.create_backup("web01") + with pytest.raises(backups.BackupError) as exc: + backups.delete_backup("web01", queued["backup_id"]) + assert "идёт работа" in str(exc.value) + core.finish_operation(queued, False, "прервано") + backups.delete_backup("web01", queued["backup_id"]) + assert not (fast_worker["backups"] / "web01").exists() + + path = make_backup(fast_worker) + restore = backups.start_restore("web01", "20260924-0300", "web02", False) # под другим именем: задача записана на web02 + second = backups.start_restore("web01", "20260924-0300", "web03", False) # две распаковки одной копии сразу + with pytest.raises(backups.BackupError) as exc: + backups.delete_backup("web01", "20260924-0300") + assert "идёт работа" in str(exc.value) + core.finish_operation(second, False, "прервано") # первая ещё идёт — копию по-прежнему нельзя удалить + with pytest.raises(backups.BackupError): + backups.delete_backup("web01", "20260924-0300") + core.finish_operation(restore, False, "прервано") + backups.delete_backup("web01", "20260924-0300") + assert not path.exists() + + +def test_late_restart_watch_is_cancelled_by_user_action(fast_worker, monkeypatch): + import threading + + virsh = FakeVirsh(running=True, shuts_down=False) + monkeypatch.setattr(core, "run_cmd", virsh) + monkeypatch.setattr(backups, "stream_cmd", fake_stream()) + monkeypatch.setattr(backups, "LATE_SHUTDOWN_WATCH", 5.0) + watchers = [] + monkeypatch.setattr(backups, "start_thread", lambda target, *args: watchers.append(threading.Thread(target=target, args=args)) or watchers[-1].start()) + operation = backups.create_backup("web01") + for _ in range(200): # наблюдатель запущен и ждёт + if "web01" in backups.LATE_WATCHES: + break + __import__("time").sleep(0.01) + backups.cancel_late_restart("web01") # пользователь нажал «Выключить» в панели + virsh.running = False + watchers[0].join(timeout=5) + assert not watchers[0].is_alive() and ["virsh", "start", "web01"] not in virsh.calls + assert "Наблюдение снято" in core.read_operation(operation["id"])["log_tail"] diff --git a/tests/test_core.py b/tests/test_core.py index 92e7cb5..4f8f23f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -102,3 +102,41 @@ def test_update_not_offered_when_local_is_newer(tmp_path, monkeypatch, data_dirs git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "upstream", cwd=remote) git("reset", "-q", "--hard", "origin/main", cwd=local) assert update_core.check_updates(fetch=True)["has_update"] is True + + +def test_stable_channel_follows_release_tags(tmp_path, monkeypatch, data_dirs): + import subprocess + + def git(*args, cwd): + subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=cwd, check=True, capture_output=True) + + remote, local = tmp_path / "remote", tmp_path / "local" + remote.mkdir() + git("init", "-q", "-b", "main", cwd=remote) + (remote / "VERSION").write_text("0.10.0\n") + git("add", "VERSION", cwd=remote) + git("commit", "-q", "-m", "0.10.0", cwd=remote) + git("clone", "-q", str(remote), str(local), cwd=tmp_path) + monkeypatch.setattr(update_core, "SOURCE_DIR", local) + monkeypatch.setattr(update_core, "NODE_ENV", tmp_path / "web.env") + monkeypatch.delenv("VIRTUALITY_UPDATE_CHANNEL", raising=False) + assert update_core.update_channel() == "stable" + + # Work on main without a release: stable follows main until the first tag exists. + (remote / "VERSION").write_text("0.11.0\n") + git("commit", "-q", "-am", "0.11.0", cwd=remote) + data = update_core.check_updates(fetch=True) + assert data["has_update"] and data["target"] == "main" + + # With a release tag, stable offers the tag and ignores newer commits on main. + git("tag", "v0.11.0", cwd=remote) + git("commit", "-q", "--allow-empty", "-m", "work in progress", cwd=remote) + data = update_core.check_updates(fetch=True) + assert data["channel"] == "stable" and data["target"] == "v0.11.0" and data["latest_version"] == "0.11.0" and data["has_update"] + git("checkout", "-q", "v0.11.0", cwd=local) + assert update_core.check_updates(fetch=True)["has_update"] is False + + # The early-access channel sees every change on main. + (tmp_path / "web.env").write_text("VIRTUALITY_UPDATE_CHANNEL=main\n") + data = update_core.check_updates(fetch=True) + assert data["channel"] == "main" and data["target"] == "main" and data["has_update"] diff --git a/tests/test_downloads.py b/tests/test_downloads.py new file mode 100644 index 0000000..0b0f060 --- /dev/null +++ b/tests/test_downloads.py @@ -0,0 +1,638 @@ +"""Скачивание образов по ссылке и из каталога: парсер контрольных сумм, +проверка ссылок, фоновая загрузка с локального http-сервера, отмена.""" +import hashlib +import lzma +import socket +import ssl +import threading +import time +import urllib.error +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace +from urllib.parse import unquote + +import pytest + +import app # noqa: F401 — подключает features.downloads к приложению +import catalog +import core +import features.downloads as downloads + +FIXTURES = Path(__file__).resolve().parent / "fixtures" +MB = 1024 * 1024 +SLOW_TOTAL = 4 * MB +CHUNKED_TOTAL = 40 * 64 * 1024 + + +class Handler(SimpleHTTPRequestHandler): + """Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет, + /chunked.iso — 2,5 МБ с паузами без заголовка Content-Length (размер заранее неизвестен).""" + + root = "" + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=self.root, **kwargs) + + def log_message(self, *args): # noqa: D102 — тише в тестах + pass + + def do_GET(self): + if self.path == "/redirect.iso": + self.send_response(302) + self.send_header("Location", "/small.iso") + self.end_headers() + return + if self.path in ("/slow.iso", "/chunked.iso"): + total = SLOW_TOTAL if self.path == "/slow.iso" else CHUNKED_TOTAL + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + if self.path == "/slow.iso": + self.send_header("Content-Length", str(total)) + self.end_headers() + try: + for _ in range(total // (64 * 1024)): + self.wfile.write(b"\0" * 64 * 1024) + self.wfile.flush() + time.sleep(0.05) + except (BrokenPipeError, ConnectionResetError): + pass + return + super().do_GET() + + +@pytest.fixture() +def www(tmp_path): + root = tmp_path / "www" + root.mkdir() + (root / "small.iso").write_bytes(b"ISO" * 5000) + handler = type("TestHandler", (Handler,), {"root": str(root)}) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield SimpleNamespace(root=root, url=f"http://127.0.0.1:{server.server_port}") + server.shutdown() + server.server_close() + + +def wait_for(operation_id: str, timeout: float = 20.0) -> dict: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + operation = core.read_operation(operation_id) + if operation and operation.get("status") in ("success", "error"): + return operation + time.sleep(0.05) + raise AssertionError("операция не завершилась вовремя") + + +def wait_until(predicate, timeout: float = 10.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.05) + raise AssertionError("условие не наступило вовремя") + + +def message_of(operation_id: str) -> str: + return str((core.read_operation(operation_id) or {}).get("message", "")) + + +def seed_running_download(kind: str, filename: str, **meta) -> dict: + """Операция «скачивается» без потока — как после перезапуска панели.""" + target = downloads.target_path(kind, filename) + operation = core.new_operation("download", f"Скачивание {filename}", download_kind=kind, url=f"http://127.0.0.1:9/{filename}", filename=filename, target_path=str(target), cancel_requested=False, **meta) + core.update_operation(operation, status="running") + return operation + + +# ---------------------------------------------------------------- parsers & formatting +def test_parse_checksums_ubuntu_star_format(): + text = (FIXTURES / "SHA256SUMS").read_text() + assert downloads.parse_checksums(text, "resolute-server-cloudimg-amd64.img") == "5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51" + assert downloads.parse_checksums(text, "resolute-server-cloudimg-arm64.img").startswith("f6916295") + assert downloads.parse_checksums(text, "missing.img") is None + + +def test_parse_checksums_debian_two_spaces_and_comments(): + text = (FIXTURES / "SHA512SUMS").read_text() + assert downloads.parse_checksums(text, "debian-13-genericcloud-amd64.qcow2").startswith("1412d62c2d55") + assert downloads.parse_checksums(text, "debian-13-genericcloud-arm64.qcow2").startswith("27c03aab0b5e") + + +def test_parse_checksums_alpine_bare_hash_and_bsd_style(): + bare = (FIXTURES / "nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512").read_text() + assert downloads.parse_checksums(bare, "anything.qcow2") == bare.strip().lower() + bsd = "SHA256 (disk.qcow2) = ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789\n" + assert downloads.parse_checksums(bsd, "disk.qcow2") == "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + assert downloads.parse_checksums(bsd, "other.qcow2") is None + assert downloads.parse_checksums("not a checksum file\n", "disk.qcow2") is None + + +def test_human_size_and_eta(): + assert downloads.human_size(0) == "0 Б" + assert downloads.human_size(1023) == "1023 Б" + assert downloads.human_size(1536) == "1,5 КБ" + assert downloads.human_size(245 * MB) == "245 МБ" + assert downloads.human_size(int(3.1 * 1024 * MB)) == "3,1 ГБ" + assert downloads.human_size(10 * MB) == "10 МБ" + assert downloads.human_eta(45) == "45 с" + assert downloads.human_eta(244) == "4 мин" + assert downloads.human_eta(3700) == "1 ч 2 мин" + assert downloads.human_eta(7200) == "2 ч" + + +def test_progress_message(): + assert downloads.progress_message(245 * MB, int(3.1 * 1024 * MB), 12 * MB) == "245 МБ из 3,1 ГБ · 12 МБ/с · осталось 4 мин" + assert downloads.progress_message(245 * MB, None, 12 * MB) == "245 МБ · 12 МБ/с" + assert downloads.progress_message(0, 100 * MB, None) == "0 Б из 100 МБ" + + +# ---------------------------------------------------------------- validation +@pytest.mark.parametrize("url", ["", "ftp://example.org/x.iso", "file:///etc/passwd", "javascript:alert(1)", "http://", "http:///x.iso", "http://example.org/a b.iso", "http://example.org:abc/x.iso", "http://[::1/x.iso"]) +def test_validate_url_rejects(url): + with pytest.raises(downloads.DownloadError): + downloads.validate_url(url) + + +def test_encode_url_handles_cyrillic_and_keeps_encoded_links(): + encoded = downloads.encode_url("http://пример.рф:8080/папка/образ диска.iso?имя=ы#x") + assert encoded == "http://xn--e1afmkfd.xn--p1ai:8080/%D0%BF%D0%B0%D0%BF%D0%BA%D0%B0/%D0%BE%D0%B1%D1%80%D0%B0%D0%B7%20%D0%B4%D0%B8%D1%81%D0%BA%D0%B0.iso?%D0%B8%D0%BC%D1%8F=%D1%8B" + assert downloads.encode_url(encoded) == encoded + for same in ("https://user:pw@h.example/a%20b/x.iso?a=1&b=%2F", "http://[::1]:9/x.iso", "http://127.0.0.1/x.iso"): + assert downloads.encode_url(same) == same + assert downloads.validate_url("https://пример.рф/образ.iso") == "https://пример.рф/образ.iso" + + +def test_describe_network_error_is_friendly(): + describe = downloads.describe_network_error + assert describe(urllib.error.HTTPError("u", 404, "Not Found", {}, None)) == "Файл по ссылке не найден (ошибка 404)" + assert describe(urllib.error.HTTPError("u", 403, "Forbidden", {}, None)) == "Сервер запретил скачивание (ошибка 403)" + assert describe(urllib.error.HTTPError("u", 418, "Teapot", {}, None)) == "Сервер ответил ошибкой 418" + assert describe(urllib.error.URLError(socket.gaierror(-2, "Name or service not known"))) == "Не удалось найти сервер по этой ссылке — проверьте адрес" + assert describe(urllib.error.URLError(ConnectionRefusedError(111, "Connection refused"))) == "Сервер отказал в подключении" + assert describe(urllib.error.URLError(ssl.SSLCertVerificationError("CERTIFICATE_VERIFY_FAILED"))) == "У сервера проблема с сертификатом безопасности" + assert describe(urllib.error.URLError(TimeoutError())).startswith("Сервер не отвечает") + assert describe(UnicodeEncodeError("ascii", "образ", 0, 5, "ordinal not in range(128)")) == "Ссылка содержит символы, которые сервер не понимает" + assert describe(ConnectionResetError(104, "reset")) == "Сервер разорвал соединение" + assert describe(RuntimeError("boom")) == "boom" + + +def test_filename_from_url_sanitises_and_checks_suffix(): + assert downloads.filename_from_url("https://h.example/a%20b%20(1).ISO?x=1#frag", "iso") == "a-b-_1.iso" + assert downloads.filename_from_url("https://h.example/dir/debian-13.qcow2", "disk") == "debian-13.qcow2" + assert downloads.filename_from_url("https://h.example/rpi/2026-01-01-raspios.img.xz", "disk") == "2026-01-01-raspios.img.xz" + assert downloads.filename_from_url("https://h.example/../../etc.iso", "iso") == "etc.iso" + for url, kind in (("https://h.example/", "iso"), ("https://h.example/setup.exe", "iso"), ("https://h.example/x.iso", "disk"), ("https://h.example/x.qcow2", "iso")): + with pytest.raises(downloads.DownloadError): + downloads.filename_from_url(url, kind) + + +def test_start_download_refuses_existing_file(data_dirs): + (data_dirs["iso"] / "small.iso").write_bytes(b"x") + with pytest.raises(downloads.DownloadError, match="уже есть"): + downloads.start_download("http://127.0.0.1:9/small.iso", "iso") + + +def test_start_download_refuses_when_extracted_image_exists(data_dirs): + # Из pi.img.xz после скачивания остаётся pi.img (или pi.qcow2 после конвертации) — это тот же образ. + (data_dirs["disk_images"] / "pi.img").write_bytes(b"x") + with pytest.raises(downloads.DownloadError, match="pi.img уже есть"): + downloads.start_download("http://127.0.0.1:9/pi.img.xz", "disk") + (data_dirs["disk_images"] / "pi.img").unlink() + (data_dirs["disk_images"] / "pi.qcow2").write_bytes(b"x") + with pytest.raises(downloads.DownloadError, match="pi.qcow2 уже есть"): + downloads.start_download("http://127.0.0.1:9/pi.img", "disk") + + +def test_start_download_refuses_when_already_downloading(data_dirs, monkeypatch): + seed_running_download("iso", "small.iso") + with pytest.raises(downloads.DownloadError, match="уже скачивается"): + downloads.start_download("http://127.0.0.1:9/small.iso", "iso") + # проверка идёт по всем операциям, а не по последним 50 + for index in range(60): + core.new_operation("vm_create", f"Создание VM {index}") + with pytest.raises(downloads.DownloadError, match="уже скачивается"): + downloads.start_download("http://127.0.0.1:9/small.iso", "iso") + + +def test_start_download_checks_free_space(data_dirs, monkeypatch): + monkeypatch.setattr(downloads.shutil, "disk_usage", lambda path: SimpleNamespace(free=1000, total=1000, used=0)) + with pytest.raises(downloads.DownloadError, match="мало места"): + downloads.start_download("http://127.0.0.1:9/small.iso", "iso") + + +def test_image_candidates(): + assert downloads.image_candidates("a.img.xz") == ["a.img.xz", "a.img", "a.qcow2"] + assert downloads.image_candidates("a.img") == ["a.img", "a.qcow2"] + assert downloads.image_candidates("a.qcow2") == ["a.qcow2"] + + +# ---------------------------------------------------------------- background download +def test_download_iso_success_refreshes_pool(data_dirs, www, monkeypatch): + calls = [] + monkeypatch.setattr(downloads, "run_cmd", lambda cmd, timeout=12: calls.append(cmd) or {"ok": True, "stdout": "", "stderr": ""}) + operation = downloads.start_download(f"{www.url}/small.iso", "iso") + assert operation["type"] == "download" and operation["download_kind"] == "iso" + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert done["progress"] == 100 + assert done["total_bytes"] == 15000 and done["downloaded_bytes"] == 15000 + assert (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000 + assert not (data_dirs["iso"] / ".small.iso.part").exists() + assert ["virsh", "pool-refresh", "virtuality-iso"] in calls + assert "Готово" in done["message"] + wait_until(lambda: not downloads.is_live(operation["id"])) # поток снимает отметку сразу после завершения + + +def test_download_follows_redirect(data_dirs, www): + operation = downloads.start_download(f"{www.url}/redirect.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert (data_dirs["iso"] / "redirect.iso").stat().st_size == 15000 + assert "Перенаправление" in done["log_tail"] + + +def test_download_with_cyrillic_file_name_in_url(data_dirs, www): + (www.root / "образ.iso").write_bytes(b"cyrillic" * 10) + operation = downloads.start_download(f"{www.url}/образ.iso", "iso") + assert operation["filename"] == "virtuality-iso.iso" + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert (data_dirs["iso"] / "virtuality-iso.iso").read_bytes() == b"cyrillic" * 10 + assert "Перенаправление" not in done["log_tail"] + + +def test_download_missing_file_fails(data_dirs, www): + operation = downloads.start_download(f"{www.url}/nope.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert done["message"] == "Файл по ссылке не найден (ошибка 404)" + assert "HTTP Error 404" in done["log_tail"] # исходный текст остаётся в журнале + assert not list(data_dirs["iso"].iterdir()) + + +def test_download_refuses_when_disk_fills_up(data_dirs, www, monkeypatch): + real = downloads.free_space_ok + seen = [] + + def fake(directory, needed): + seen.append(needed) + return real(directory, needed) if len(seen) == 1 else (False, 42) + + monkeypatch.setattr(downloads, "free_space_ok", fake) + operation = downloads.start_download(f"{www.url}/small.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "мало места" in done["message"] and "42 Б" in done["message"] + assert not list(data_dirs["iso"].iterdir()) + + +def test_download_without_content_length_stops_when_disk_fills_up(data_dirs, www, monkeypatch): + real = downloads.free_space_ok + seen = [] + + def fake(directory, needed): + seen.append(needed) + return real(directory, needed) if len(seen) < 3 else (False, 42) + + monkeypatch.setattr(downloads, "free_space_ok", fake) + operation = downloads.start_download(f"{www.url}/chunked.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "закончилось место" in done["message"] and "42 Б" in done["message"] + assert done["total_bytes"] is None and seen == [None, None, None] + assert not (data_dirs["iso"] / ".chunked.iso.part").exists() + assert not list(data_dirs["iso"].iterdir()) + + +def test_download_cancel(data_dirs, www, logged_in): + operation = downloads.start_download(f"{www.url}/slow.iso", "iso") + wait_until(lambda: (core.read_operation(operation["id"]) or {}).get("status") == "running") + wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists()) + response = logged_in.post(f"/api/operations/{operation['id']}/cancel") + assert response.status_code == 200 and response.json()["ok"] is True + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert done["message"] == "Скачивание отменено" + assert done["cancelled"] is True and done["cancel_requested"] is True + assert not (data_dirs["iso"] / ".slow.iso.part").exists() + assert not (data_dirs["iso"] / "slow.iso").exists() + # The worker thread releases its markers right after it finishes the operation. + wait_until(lambda: not downloads.cancel_requested(operation["id"]) and not downloads.is_live(operation["id"])) + + +def test_cancel_flag_on_disk_is_honoured_and_not_overwritten(data_dirs, www): + # Отмену могла принять другая копия панели: поток видит флаг в JSON операции раз в секунду. + operation = downloads.start_download(f"{www.url}/slow.iso", "iso") + wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists()) + fresh = core.read_operation(operation["id"]) + fresh.pop("log_tail", None) + core.update_operation(fresh, cancel_requested=True, message="Останавливаем скачивание…") + done = wait_for(operation["id"]) + assert done["status"] == "error" and done["cancelled"] is True and done["cancel_requested"] is True + assert not (data_dirs["iso"] / ".slow.iso.part").exists() + + +def test_second_download_of_same_file_does_not_touch_the_first(data_dirs, www): + first = downloads.start_download(f"{www.url}/slow.iso", "iso") + part = data_dirs["iso"] / ".slow.iso.part" + wait_until(part.exists) + # Второй запуск, проскочивший проверку «уже скачивается» (например, из другого процесса): операцию создаём руками. + second = core.new_operation("download", "Скачивание slow.iso", download_kind="iso", url=f"{www.url}/slow.iso", filename="slow.iso", target_path=first["target_path"], cancel_requested=False) + core.update_operation(second, status="running") + downloads.download_worker(second) + done = core.read_operation(second["id"]) + assert done["status"] == "error" and "уже скачивается" in done["message"] + assert part.exists() # чужой файл не тронут + assert wait_for(first["id"])["status"] == "success" + assert (data_dirs["iso"] / "slow.iso").stat().st_size == SLOW_TOTAL + + +def test_leftover_part_file_without_owner_does_not_block_download(data_dirs, www): + part = data_dirs["iso"] / ".small.iso.part" + part.write_bytes(b"half of a crashed download") + operation = downloads.start_download(f"{www.url}/small.iso", "iso") + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000 + assert not part.exists() + + +def test_stale_downloads_are_reaped_on_startup(data_dirs, www, monkeypatch): + stale = seed_running_download("iso", "small.iso") + part = downloads.part_path(Path(stale["target_path"])) + part.write_bytes(b"half") + (data_dirs["disk_images"] / ".orphan.img.part").write_bytes(b"x") + live = downloads.start_download(f"{www.url}/slow.iso", "iso") + wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists()) + + assert downloads.reap_stale_downloads() == 1 + closed = core.read_operation(stale["id"]) + assert closed["status"] == "error" and closed["interrupted"] is True and "перезапущена" in closed["message"] + assert not part.exists() and not (data_dirs["disk_images"] / ".orphan.img.part").exists() + assert (data_dirs["iso"] / ".slow.iso.part").exists() # живое скачивание не трогаем + assert wait_for(live["id"])["status"] == "success" + # после закрытия зависшей операции тот же файл можно скачать снова + done = wait_for(downloads.start_download(f"{www.url}/small.iso", "iso")["id"]) + assert done["status"] == "success" and (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000 + assert downloads.close_stale_downloads() is None + + +def test_cancel_of_stale_download_finishes_it_immediately(data_dirs, logged_in): + stale = seed_running_download("disk", "y.qcow2") + part = downloads.part_path(Path(stale["target_path"])) + part.write_bytes(b"x") + response = logged_in.post(f"/api/operations/{stale['id']}/cancel") + operation = response.json()["operation"] + assert response.status_code == 200 and operation["status"] == "error" and operation["cancelled"] is True + assert operation["message"] == "Скачивание отменено" and operation["cancel_requested"] is True + assert not part.exists() + assert not downloads.cancel_requested(stale["id"]) + assert logged_in.post(f"/api/operations/{stale['id']}/cancel").status_code == 409 + + +def test_catalog_download_verifies_checksum(data_dirs, www): + payload = b"QCOW2 image bytes" * 100 + (www.root / "disk.qcow2").write_bytes(payload) + (www.root / "SHA256SUMS").write_text(f"{hashlib.sha256(payload).hexdigest()} *disk.qcow2\n") + operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256", catalog_id="test") + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert "совпала" in done["log_tail"] + assert (data_dirs["disk_images"] / "disk.qcow2").read_bytes() == payload + + +def test_catalog_download_checksum_mismatch_deletes_file(data_dirs, www): + (www.root / "disk.qcow2").write_bytes(b"corrupted" * 100) + (www.root / "SHA512SUMS").write_text(f"{'0' * 128} disk.qcow2\n") + operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA512SUMS", algo="sha512") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "Контрольная сумма не совпала" in done["message"] + assert not list(data_dirs["disk_images"].iterdir()) + + +def test_catalog_download_fails_without_sums_entry(data_dirs, www): + (www.root / "disk.qcow2").write_bytes(b"data") + (www.root / "SHA256SUMS").write_text(f"{'a' * 64} *other.qcow2\n") + operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256") + done = wait_for(operation["id"]) + assert done["status"] == "error" + assert "нет записи" in done["message"] + assert not list(data_dirs["disk_images"].iterdir()) + + +def test_cancel_during_checksum_verification_removes_file(data_dirs, www, logged_in, monkeypatch): + payload = b"QCOW2 image bytes" * 100 + (www.root / "disk.qcow2").write_bytes(payload) + (www.root / "SHA256SUMS").write_text(f"{hashlib.sha256(payload).hexdigest()} *disk.qcow2\n") + + def slow_digest(path, algo, on_progress=None): + for _ in range(200): + time.sleep(0.05) + if on_progress: + on_progress(1) + return hashlib.sha256(payload).hexdigest() + + monkeypatch.setattr(downloads, "file_digest", slow_digest) + operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256") + wait_until(lambda: "Проверяем контрольную сумму" in message_of(operation["id"])) + assert logged_in.post(f"/api/operations/{operation['id']}/cancel").status_code == 200 + done = wait_for(operation["id"]) + assert done["status"] == "error" and done["cancelled"] is True and done["message"] == "Скачивание отменено" + assert "во время проверки" in done["log_tail"] + assert not list(data_dirs["disk_images"].iterdir()) + + +def test_disk_download_extracts_xz_and_starts_conversion(data_dirs, www, monkeypatch): + raw = bytes(range(256)) * 256 + (www.root / "pi.img.xz").write_bytes(lzma.compress(raw)) + started = [] + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path) or {"id": "conv-1"}) + operation = downloads.start_download(f"{www.url}/pi.img.xz", "disk") + done = wait_for(operation["id"]) + assert done["status"] == "success", done + assert (data_dirs["disk_images"] / "pi.img").read_bytes() == raw + assert not (data_dirs["disk_images"] / "pi.img.xz").exists() + assert started == [data_dirs["disk_images"] / "pi.img"] + assert done["convert_operation_id"] == "conv-1" + assert "qcow2" in done["message"] + + +def test_cancel_during_unpack_removes_archive_and_partial_image(data_dirs, www, logged_in, monkeypatch): + raw = bytes(range(256)) * 4096 # 1 МБ, распаковка кусками по 16 КБ с паузой — успеваем отменить + (www.root / "pi.img.xz").write_bytes(lzma.compress(raw)) + real = downloads.extract_xz_image + + def slow_extract(compressed, should_stop=None): + def stop(): + time.sleep(0.05) + return bool(should_stop and should_stop()) + + return real(compressed, stop) + + started = [] + monkeypatch.setattr(downloads, "extract_xz_image", slow_extract) + monkeypatch.setattr(downloads, "CHUNK_SIZE", 16 * 1024) + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path)) + operation = downloads.start_download(f"{www.url}/pi.img.xz", "disk") + wait_until(lambda: "Распаковываем" in message_of(operation["id"])) + assert logged_in.post(f"/api/operations/{operation['id']}/cancel").status_code == 200 + done = wait_for(operation["id"]) + assert done["status"] == "error" and done["cancelled"] is True and done["message"] == "Скачивание отменено" + assert "Распаковка остановлена" in done["log_tail"] + assert not list(data_dirs["disk_images"].iterdir()) and started == [] + + +def test_corrupt_xz_archive_is_removed_and_can_be_downloaded_again(data_dirs, www, monkeypatch): + (www.root / "bad.img.xz").write_bytes(b"definitely not an xz archive" * 100) + started = [] + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path)) + done = wait_for(downloads.start_download(f"{www.url}/bad.img.xz", "disk")["id"]) + assert done["status"] == "error" + assert done["message"] == "Не удалось распаковать скачанный архив — файл повреждён и удалён. Попробуйте скачать ещё раз" + assert "Ошибка распаковки" in done["log_tail"] + assert not list(data_dirs["disk_images"].iterdir()) and started == [] + # архив убран, поэтому повторное скачивание не упирается в «уже есть на сервере» + assert wait_for(downloads.start_download(f"{www.url}/bad.img.xz", "disk")["id"])["status"] == "error" + + +def test_extract_xz_image_stops_and_cleans_up(data_dirs): + raw = bytes(range(256)) * 1024 + compressed = data_dirs["disk_images"] / "pi.img.xz" + compressed.write_bytes(lzma.compress(raw)) + assert downloads.extract_xz_image(compressed).read_bytes() == raw + (data_dirs["disk_images"] / "pi.img").unlink() + with pytest.raises(downloads.DownloadCancelled): + downloads.extract_xz_image(compressed, lambda: True) + assert sorted(item.name for item in data_dirs["disk_images"].iterdir()) == ["pi.img.xz"] + + +def test_qcow2_download_needs_no_conversion(data_dirs, www, monkeypatch): + (www.root / "ready.qcow2").write_bytes(b"q" * 10) + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None) + done = wait_for(downloads.start_download(f"{www.url}/ready.qcow2", "disk")["id"]) + assert done["status"] == "success" and "convert_operation_id" not in done + assert done["result_path"] == str(data_dirs["disk_images"] / "ready.qcow2") + + +# ---------------------------------------------------------------- routes +def test_url_route_starts_download(logged_in, data_dirs, www, monkeypatch): + # The worker is started by hand below: a 15 KB file would otherwise finish before the checks. + pending = {} + monkeypatch.setattr(downloads, "run_operation", lambda operation, worker: pending.update(operation=operation, worker=worker)) + response = logged_in.post("/downloads/url", data={"url": f"{www.url}/small.iso", "kind": "iso"}, follow_redirects=False) + assert response.status_code == 303 + location = unquote(response.headers["location"]) + assert location.startswith("/iso?download_message=") and "small.iso" in location + running = downloads.active_downloads("iso") + assert len(running) == 1 and running[0]["filename"] == "small.iso" + core.run_operation(pending["operation"], pending["worker"]) + assert wait_for(running[0]["id"])["status"] == "success" + assert (data_dirs["iso"] / "small.iso").exists() + + +def test_url_route_rejects_bad_links(logged_in, data_dirs): + response = logged_in.post("/downloads/url", data={"url": "ftp://example.org/x.iso", "kind": "iso"}, follow_redirects=False) + assert response.status_code == 303 + assert "download_error=" in response.headers["location"] and response.headers["location"].startswith("/iso?") + response = logged_in.post("/downloads/url", data={"url": "https://example.org/setup.exe", "kind": "disk"}, follow_redirects=False) + assert response.headers["location"].startswith("/disk-images?download_error=") + assert not core.running_operations("download") and not downloads.unfinished_downloads() + + +def test_url_route_requires_login(client): + assert client.post("/downloads/url", data={"url": "https://example.org/x.iso"}, follow_redirects=False).headers["location"] == "/login" + assert client.post("/api/operations/x/cancel").status_code == 401 + + +def test_catalog_route(logged_in, data_dirs, www, monkeypatch): + payload = b"debian" * 1000 + (www.root / "debian-13-genericcloud-amd64.qcow2").write_bytes(payload) + (www.root / "SHA512SUMS").write_text(f"{hashlib.sha512(payload).hexdigest()} debian-13-genericcloud-amd64.qcow2\n") + entry = {"id": "debian-13", "title": "Debian 13", "hint": "x", "login": "debian", "arches": {"x86_64": {"url": f"{www.url}/debian-13-genericcloud-amd64.qcow2", "sums": f"{www.url}/SHA512SUMS", "algo": "sha512"}}} + monkeypatch.setattr(catalog, "CLOUD_IMAGES", [entry]) + monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64") + monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None) + + assert unquote(logged_in.post("/downloads/catalog/nope", follow_redirects=False).headers["location"]).startswith("/disk-images?download_error=Такого образа нет") + # The worker is started by hand below: a 6 KB file would otherwise finish before the page is rendered. + pending = {} + monkeypatch.setattr(downloads, "run_operation", lambda operation, worker: pending.update(operation=operation, worker=worker)) + response = logged_in.post("/downloads/catalog/debian-13", follow_redirects=False) + assert response.status_code == 303 and "download_message=" in response.headers["location"] + page = logged_in.get("/disk-images").text + assert "Скачивается" in page + running = downloads.active_downloads("disk") + assert running[0]["catalog_id"] == "debian-13" + core.run_operation(pending["operation"], pending["worker"]) + assert wait_for(running[0]["id"])["status"] == "success" + assert (data_dirs["disk_images"] / "debian-13-genericcloud-amd64.qcow2").read_bytes() == payload + page = logged_in.get("/disk-images").text + assert "Загружен" in page and "/vm/create?image=debian-13-genericcloud-amd64.qcow2" in page + # второй раз — файл уже есть + response = logged_in.post("/downloads/catalog/debian-13", follow_redirects=False) + assert "download_error=" in response.headers["location"] + + +def test_catalog_route_skips_missing_arch(logged_in, data_dirs, monkeypatch): + monkeypatch.setattr(catalog, "CLOUD_IMAGES", [{"id": "only-arm", "title": "ARM only", "arches": {"aarch64": {"url": "https://example.org/a.qcow2"}}}]) + monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64") + location = unquote(logged_in.post("/downloads/catalog/only-arm", follow_redirects=False).headers["location"]) + assert "нет версии для этого сервера (x86_64)" in location + assert downloads.catalog_entries() == [] + assert "готовых образов в каталоге нет" in logged_in.get("/disk-images").text + + +def test_cancel_api_rules(logged_in, data_dirs): + assert logged_in.post("/api/operations/00000000-0000-0000-0000-000000000000/cancel").status_code == 404 + other = core.new_operation("vm_create", "Создание VM x") + response = logged_in.post(f"/api/operations/{other['id']}/cancel") + assert response.status_code == 400 and "только скачивание" in response.json()["error"] + finished = core.new_operation("download", "Скачивание x", download_kind="iso", filename="x.iso") + core.finish_operation(finished, True, "Готово") + assert logged_in.post(f"/api/operations/{finished['id']}/cancel").status_code == 409 + # операция без потока и без файла (панель перезапускалась) закрывается сразу + running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2") + core.update_operation(running, status="running") + response = logged_in.post(f"/api/operations/{running['id']}/cancel") + operation = response.json()["operation"] + assert response.status_code == 200 and operation["cancel_requested"] is True and operation["status"] == "error" and operation["cancelled"] is True + assert not downloads.cancel_requested(running["id"]) + + +def test_cancel_form_redirects_to_page(logged_in, data_dirs, www): + stale = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2") + core.update_operation(stale, status="running") + response = logged_in.post(f"/downloads/{stale['id']}/cancel", follow_redirects=False) + assert response.status_code == 303 + assert unquote(response.headers["location"]).startswith("/disk-images?download_message=Скачивание y.qcow2 отменено") + assert core.read_operation(stale["id"])["status"] == "error" + live = downloads.start_download(f"{www.url}/slow.iso", "iso") + response = logged_in.post(f"/downloads/{live['id']}/cancel", follow_redirects=False) + assert unquote(response.headers["location"]).startswith("/iso?download_message=Останавливаем скачивание slow.iso") + assert wait_for(live["id"])["cancelled"] is True + assert unquote(logged_in.post("/downloads/nope/cancel", follow_redirects=False).headers["location"]).startswith("/iso?download_error=") + + +def test_pages_show_download_cards(logged_in, data_dirs, monkeypatch): + reads = [] + real = downloads.unfinished_downloads + monkeypatch.setattr(downloads, "unfinished_downloads", lambda: reads.append(1) or real()) + iso_page = logged_in.get("/iso").text + assert "Скачать по ссылке" in iso_page and 'name="kind" value="iso"' in iso_page + assert "Скачиваем на сервер" not in iso_page + assert "не нужно сначала загружать его на компьютер" in iso_page + disk_page = logged_in.get("/disk-images").text + assert "Каталог готовых систем" in disk_page and "Рекомендуем" in disk_page and 'name="kind" value="disk"' in disk_page + assert "подходящие этому серверу" in disk_page + assert len(reads) == 2 # каталог операций читается один раз на страницу, а не в каждой вставке + running = core.new_operation("download", "Скачивание big.iso", download_kind="iso", filename="big.iso", url="https://example.org/big.iso", total_bytes=None) + core.update_operation(running, status="running", progress=0, message="Подключаемся к серверу…") + iso_page = logged_in.get("/iso?download_message=Скачиваем").text + assert "Скачиваем на сервер" in iso_page and "big.iso" in iso_page and "indeterminate" in iso_page + assert f"/downloads/{running['id']}/cancel" in iso_page and "Скачиваем" in iso_page + # отмена — не ошибка: без тоста при отправке формы, опрос показывает её как предупреждение + assert 'data-toast="Скачивание отменено"' not in iso_page and "op.cancelled" in iso_page + assert "big.iso" not in logged_in.get("/disk-images").text diff --git a/tests/test_quickvm.py b/tests/test_quickvm.py new file mode 100644 index 0000000..8c9605f --- /dev/null +++ b/tests/test_quickvm.py @@ -0,0 +1,152 @@ +"""Quick machines from cloud images (cloud-init), OS types for installers, fixed NAT addresses.""" +import shlex + +import app +import cloudinit +import conftest +import network_core +from test_audit_fixes import find, recording + + +def create(logged_in, monkeypatch, form): + captured = {} + monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: captured.update(operation=operation, cmd=cmd)) + monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {}) + response = logged_in.post("/vm/create", data=form, follow_redirects=False) + return response, captured + + +def cloud_form(image, **extra): + form = {"name": "cloud-vm", "memory": 2048, "vcpus": 2, "disk_size": 20, "disk_image_path": str(image), "source_type": "disk_image", "network_mode": "nat", "cloud_init": "1", "ci_user": "viktor", "ci_password": "secret123"} + form.update(extra) + return form + + +# ---------------------------------------------------------------- cloud-init seed +def test_user_data_has_user_password_and_keys(): + text = cloudinit.build_user_data("web", "viktor", "$6$abc", ["ssh-ed25519 AAAAC3 me@laptop"]) + assert "hostname: web" in text and "- name: viktor" in text and "passwd: '$6$abc'" in text + assert "- ssh-ed25519 AAAAC3 me@laptop" in text and "ssh_pwauth: true" in text and "growpart" in text + assert "packages: [qemu-guest-agent]" in text and "package_update: true" in text + assert "ssh_pwauth: false" in cloudinit.build_user_data("web", "viktor", "", ["ssh-ed25519 AAAAC3 me"]) + + +def test_ssh_keys_and_users_are_validated(): + assert cloudinit.parse_ssh_keys("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 me\n\nssh-rsa AAAAB3 x\n") == ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 me", "ssh-rsa AAAAB3 x"] + assert cloudinit.parse_ssh_keys("-----BEGIN OPENSSH PRIVATE KEY-----") is None + assert cloudinit.parse_ssh_keys("") == [] + assert cloudinit.valid_user("viktor") and cloudinit.valid_user("web_admin-2") + assert not cloudinit.valid_user("root") and not cloudinit.valid_user("Viktor") and not cloudinit.valid_user("") + + +def test_password_hash_uses_sha512_crypt(): + digest = cloudinit.hash_password("secret123") + assert digest.startswith("$6$") and len(digest) > 60 + + +def test_default_login_comes_from_catalog(): + assert cloudinit.default_user("resolute-server-cloudimg-amd64.img") == "ubuntu" + assert cloudinit.default_user("debian-13-genericcloud-arm64.qcow2") == "debian" + assert cloudinit.default_user("my-own-image.qcow2") == "admin" + + +# ---------------------------------------------------------------- creating from a cloud image +def test_cloud_image_vm_gets_seed_resize_and_fixed_address(logged_in, data_dirs, monkeypatch): + image = data_dirs["disk_images"] / "resolute-server-cloudimg-amd64.img" + image.write_bytes(b"QFI\xfb") + monkeypatch.setattr(cloudinit, "hash_password", lambda password: "$6$fake$hash") + calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))]) + response, captured = create(logged_in, monkeypatch, cloud_form(image, disk_size=60)) + assert response.status_code == 303, response.text[:400] + script = captured["cmd"][-1] + assert "qemu-img convert -p -f qcow2 -O qcow2" in script + assert f"qemu-img resize {shlex.quote(str(data_dirs['images'] / 'cloud-vm.qcow2'))} 60G" in script + assert "--import" in script and "--osinfo ubuntu24.04" in script and "--cloud-init user-data=" in script and "bus=virtio" in script + operation = captured["operation"] + assert operation["cloud_init_user"] == "viktor" and operation["nat_ip"] == "192.168.100.50" + assert "Вход: viktor по паролю" in operation["success_message"] and "192.168.100.50" in operation["success_message"] + mac = network_core.stable_mac("cloud-vm") + assert f"mac={mac}" in operation["network"] and mac.startswith("52:54:00:") + reservation = find(calls, "virsh", "net-update", "virtuality-nat", "add-last", "ip-dhcp-host")[0] + assert f"mac='{mac}' name='cloud-vm' ip='192.168.100.50'" in reservation[5] and "--live" in reservation and "--config" in reservation + user_data = open(operation["seed_file"]).read() + assert "- name: viktor" in user_data and "$6$fake$hash" in user_data and "hostname: cloud-vm" in user_data + cloudinit.remove_seed(operation["seed_file"]) + + +def test_cloud_image_smaller_disk_is_not_resized(logged_in, data_dirs, monkeypatch): + image = data_dirs["disk_images"] / "cloud.qcow2" + image.write_bytes(b"qcow") + monkeypatch.setattr(cloudinit, "hash_password", lambda password: "$6$fake$hash") + response, captured = create(logged_in, monkeypatch, cloud_form(image, disk_size=20)) + assert response.status_code == 303 + assert "qemu-img resize" not in captured["cmd"][-1] and "--osinfo detect=on,require=off" in captured["cmd"][-1] + cloudinit.remove_seed(captured["operation"]["seed_file"]) + + +def test_cloud_init_can_be_skipped(logged_in, data_dirs, monkeypatch): + image = data_dirs["disk_images"] / "cloud.qcow2" + image.write_bytes(b"qcow") + response, captured = create(logged_in, monkeypatch, cloud_form(image, cloud_init="0", ci_user="", ci_password="")) + assert response.status_code == 303 and "--cloud-init" not in captured["cmd"][-1] and captured["operation"]["seed_file"] == "" + + +def test_cloud_init_validation(logged_in, data_dirs, monkeypatch): + image = data_dirs["disk_images"] / "cloud.qcow2" + image.write_bytes(b"qcow") + assert create(logged_in, monkeypatch, cloud_form(image, ci_password="123"))[0].status_code == 400 + assert create(logged_in, monkeypatch, cloud_form(image, ci_user="Root User"))[0].status_code == 400 + assert create(logged_in, monkeypatch, cloud_form(image, ci_user="root"))[0].status_code == 400 + assert create(logged_in, monkeypatch, cloud_form(image, ci_ssh_key="not a key"))[0].status_code == 400 + monkeypatch.setattr(cloudinit, "hash_password", lambda password: "$6$fake$hash") + ok, captured = create(logged_in, monkeypatch, cloud_form(image, ci_password="", ci_ssh_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA me@pc")) + assert ok.status_code == 303 and "по SSH-ключу" in captured["operation"]["success_message"] + cloudinit.remove_seed(captured["operation"]["seed_file"]) + + +# ---------------------------------------------------------------- installers by OS type +def iso_form(iso, **extra): + form = {"name": "new-vm", "memory": 4096, "vcpus": 2, "disk_size": 64, "iso_path": str(iso), "source_type": "iso", "network_mode": "nat"} + form.update(extra) + return form + + +def test_windows11_gets_uefi_tpm_sata_and_e1000e(logged_in, data_dirs, monkeypatch): + iso = data_dirs["iso"] / "win11.iso" + iso.write_bytes(b"iso") + response, captured = create(logged_in, monkeypatch, iso_form(iso, os_type="windows11")) + assert response.status_code == 303 + cmd = captured["cmd"] + assert "--osinfo" in cmd and cmd[cmd.index("--osinfo") + 1] == "win11" + assert cmd[cmd.index("--boot") + 1] == "uefi,cdrom,hd" + assert "--tpm" in cmd and "backend.version=2.0" in cmd[cmd.index("--tpm") + 1] + assert "bus=sata" in cmd[cmd.index("--disk") + 1] and "model=e1000e" in cmd[cmd.index("--network") + 1] + assert "--channel" not in cmd # Windows has no guest agent without extra drivers + + +def test_linux_installer_detects_os_and_uses_virtio(logged_in, data_dirs, monkeypatch): + iso = data_dirs["iso"] / "debian.iso" + iso.write_bytes(b"iso") + response, captured = create(logged_in, monkeypatch, iso_form(iso)) + cmd = captured["cmd"] + assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "cdrom,hd" + assert "bus=virtio" in cmd[cmd.index("--disk") + 1] and "model=virtio" in cmd[cmd.index("--network") + 1] and "--tpm" not in cmd + assert cmd[cmd.index("--channel") + 1] == "unix,target.type=virtio,target.name=org.qemu.guest_agent.0" + assert create(logged_in, monkeypatch, iso_form(iso, os_type="bsd"))[0].status_code == 400 + + +# ---------------------------------------------------------------- fixed addresses +def test_reservation_is_reused_and_released(data_dirs, monkeypatch): + calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))]) + assert network_core.reserve_nat_address("web01", "52:54:00:aa:bb:cc") == "192.168.100.51" + assert not find(calls, "virsh", "net-update") + network_core.release_nat_address("web01") + deleted = find(calls, "virsh", "net-update", "virtuality-nat", "delete")[0] + assert "name='web01' ip='192.168.100.51'" in deleted[5] + + +def test_vm_delete_releases_address(logged_in, data_dirs, monkeypatch): + calls = recording(monkeypatch, app, []) + net_calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))]) + assert logged_in.post("/vm/web01/delete", follow_redirects=False).status_code == 303 + assert find(calls, "virsh", "undefine") and find(net_calls, "virsh", "net-update", "virtuality-nat", "delete") diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..792b372 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,88 @@ +import nodectl +from test_setup import prepare + + +def ready(data_dirs, monkeypatch, ctl=True): + prepare(data_dirs, monkeypatch) + nodectl.mark_wizard_done() + monkeypatch.setattr(nodectl, "ctl_available", lambda: ctl) + nodectl.NODE_ENV = data_dirs["config"] / "web.env" + nodectl.NODE_ENV.write_text("VIRTUALITY_WEB_PORT=8088\nVIRTUALITY_TLS=0\nVIRTUALITY_AUTO_UPDATE=1\nVIRTUALITY_UPDATE_CHANNEL=stable\n") + + +def test_settings_page_renders(logged_in, data_dirs, monkeypatch): + ready(data_dirs, monkeypatch) + html = logged_in.get("/settings").text + for text in ("Доступ к панели", "Сеть машин", "Хранилище", "Часовой пояс", "Обновления", "Перезагрузить", "http://panel.test:8088"): + assert text in html, text + + +def test_settings_page_without_ctl_disables_actions(logged_in, data_dirs, monkeypatch): + ready(data_dirs, monkeypatch, ctl=False) + html = logged_in.get("/settings").text + assert "virtuality-ctl не найдена" in html and "disabled" in html + + +def test_settings_requires_login(client, data_dirs, monkeypatch): + ready(data_dirs, monkeypatch) + assert client.get("/settings", follow_redirects=False).status_code == 303 + + +def test_access_change_applies_and_shows_new_url(logged_in, data_dirs, monkeypatch): + ready(data_dirs, monkeypatch) + calls = [] + monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""}) + response = logged_in.post("/settings/access", data={"tls": "1", "port": "8088"}) + assert response.status_code == 200 and "https://panel.test:8443" in response.text + assert calls == [("set", "VIRTUALITY_TLS=1", "VIRTUALITY_WEB_PORT=8088")] + assert logged_in.post("/settings/access", data={"tls": "0", "port": "22"}).status_code == 400 + unchanged = logged_in.post("/settings/access", data={"tls": "0", "port": "8088"}, follow_redirects=False) + assert unchanged.status_code == 303 and "message=" in unchanged.headers["location"] + + +def test_password_change(logged_in, data_dirs, monkeypatch): + ready(data_dirs, monkeypatch) + seen = {} + monkeypatch.setattr(nodectl, "ctl", lambda *args, **kw: seen.update(args=args, stdin=kw.get("stdin")) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""}) + assert logged_in.post("/settings/password", data={"password": "abc", "password2": "abd"}).status_code == 400 + assert logged_in.post("/settings/password", data={"password": "abc", "password2": "abc"}).status_code == 400 + response = logged_in.post("/settings/password", data={"password": "secret1", "password2": "secret1"}, follow_redirects=False) + assert response.status_code == 303 and seen["args"] == ("passwd", "tester") and seen["stdin"] == "secret1\n" + + +def test_timezone_and_updates(logged_in, data_dirs, monkeypatch): + ready(data_dirs, monkeypatch) + calls = [] + monkeypatch.setattr(nodectl, "ctl", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""}) + monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""}) + assert logged_in.post("/settings/timezone", data={"timezone": "Europe/Moscow"}, follow_redirects=False).status_code == 303 + assert logged_in.post("/settings/timezone", data={"timezone": "../etc"}).status_code == 400 + assert logged_in.post("/settings/updates", data={"channel": "main"}, follow_redirects=False).status_code == 303 + assert calls == [("timezone", "Europe/Moscow"), ("set", "VIRTUALITY_AUTO_UPDATE=0", "VIRTUALITY_UPDATE_CHANNEL=main")] + + +def test_network_bridge_and_revert(logged_in, data_dirs, monkeypatch): + ready(data_dirs, monkeypatch) + calls = [] + monkeypatch.setattr(nodectl, "enable_bridge", lambda iface, mode="dhcp": calls.append(("bridge", iface, mode)) or (True, "ok")) + monkeypatch.setattr(nodectl, "revert_bridge", lambda: calls.append(("revert",)) or (True, "Возвращаем прежнюю сеть")) + response = logged_in.post("/settings/network", data={"mode": "bridge"}) + assert response.status_code == 200 and "Перенастраиваем сеть" in response.text and 'data-next-url="/settings"' in response.text + assert logged_in.post("/settings/network/revert", follow_redirects=False).headers["location"].startswith("/settings?message=") + assert calls == [("bridge", "enp3s0", "dhcp"), ("revert",)] + + +def test_storage_power_and_downloads(logged_in, data_dirs, monkeypatch, tmp_path): + ready(data_dirs, monkeypatch) + monkeypatch.setattr(nodectl, "use_disk_for_storage", lambda path: (path == "/dev/sdb", "Диск подключён" if path == "/dev/sdb" else "Нельзя")) + assert "message=" in logged_in.post("/settings/storage", data={"disk": "/dev/sdb"}, follow_redirects=False).headers["location"] + assert "error=" in logged_in.post("/settings/storage", data={"disk": "/dev/sda"}, follow_redirects=False).headers["location"] + monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""}) + assert "Перезагружаем сервер" in logged_in.post("/settings/power", data={"action": "reboot"}).text + assert logged_in.post("/settings/power", data={"action": "halt"}).status_code == 400 + archive = tmp_path / "config-1.tar.gz" + archive.write_bytes(b"\x1f\x8b" + b"0" * 16) + monkeypatch.setattr(nodectl, "ctl", lambda *args, **kw: {"ok": True, "stdout": str(archive), "stderr": "", "code": 0, "cmd": ""}) + response = logged_in.post("/settings/backup-config") + assert response.status_code == 200 and response.headers["content-disposition"].endswith('"config-1.tar.gz"') and response.content.startswith(b"\x1f\x8b") + assert logged_in.post("/settings/support-bundle").status_code == 200 diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..99633b5 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,101 @@ +import json + +import app +import nodectl +from features import setup + + +def prepare(data_dirs, monkeypatch, stage="done"): + config = data_dirs["config"] + monkeypatch.setattr(nodectl, "CONFIG_DIR", config) + monkeypatch.setattr(nodectl, "SETUP_STATE", config / "setup.json") + monkeypatch.setattr(nodectl, "SETUP_DONE", config / "setup_done") + monkeypatch.setattr(nodectl, "WIZARD_FILE", config / "wizard.json") + monkeypatch.setattr(nodectl, "FIRSTBOOT_LOG", config / "firstboot.log") + monkeypatch.setattr(nodectl, "ctl_available", lambda: False) + (config / "setup.json").write_text(json.dumps({"stage": stage, "message": "Устанавливаем…", "steps": [{"id": "panel", "title": "Панель", "status": "done"}, {"id": "virt", "title": "KVM", "status": "running" if stage != "done" else "done"}]})) + monkeypatch.setattr(nodectl, "network_facts", lambda: {"interface": "enp3s0", "gateway": "192.168.1.1", "address": "192.168.1.10", "prefix": 24, "mac": "aa:bb:cc:dd:ee:ff", "wireless": False, "private": True, "is_vps": False, "virt": "none", "bridge_present": False, "on_bridge": False, "recommended": "bridge", "revert_armed": False, "bridge_possible": True}) + monkeypatch.setattr(nodectl, "spare_disks", lambda: []) + monkeypatch.setattr(nodectl, "timezones", lambda: {"current": "UTC", "popular": ["Europe/Moscow", "UTC"], "all": ["Europe/Moscow", "UTC"]}) + + +def test_dashboard_redirects_to_wizard_until_done(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + response = logged_in.get("/", follow_redirects=False) + assert response.status_code == 303 and response.headers["location"] == "/setup" + nodectl.mark_wizard_done() + assert logged_in.get("/", follow_redirects=False).status_code == 200 + + +def test_dashboard_without_installer_state_has_no_wizard(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + nodectl.SETUP_STATE.unlink() + assert logged_in.get("/", follow_redirects=False).status_code == 200 + + +def test_install_step_shown_while_installing(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch, stage="installing") + assert logged_in.get("/setup", follow_redirects=False).headers["location"] == "/setup/install" + html = logged_in.get("/setup/install").text + assert "Настраиваем сервер" in html and "Устанавливаем…" in html + # other steps are locked until the installation finishes + assert logged_in.get("/setup/welcome", follow_redirects=False).headers["location"] == "/setup/install" + state = logged_in.get("/api/setup/state").json() + assert state["ok"] and state["state"]["installed"] is False + + +def test_wizard_steps_render_and_store_choices(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + monkeypatch.setattr(nodectl, "set_timezone", lambda tz: (True, tz)) + for step in setup.STEP_IDS[1:]: + assert logged_in.get(f"/setup/{step}").status_code == 200, step + assert logged_in.post("/setup/welcome", data={"timezone": "Europe/Moscow"}, follow_redirects=False).headers["location"] == "/setup/account" + assert logged_in.post("/setup/account", data={"password": "", "password2": ""}, follow_redirects=False).headers["location"] == "/setup/access" + assert logged_in.post("/setup/account", data={"password": "abcdef", "password2": "other"}).status_code == 400 + assert logged_in.post("/setup/access", data={"tls": "0", "port": "8090"}, follow_redirects=False).headers["location"] == "/setup/network" + assert logged_in.post("/setup/access", data={"tls": "1", "port": "22"}).status_code == 400 + monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {}) + assert logged_in.post("/setup/network", data={"mode": "nat"}, follow_redirects=False).headers["location"] == "/setup/storage" + assert logged_in.post("/setup/storage", data={"disk": "keep"}, follow_redirects=False).headers["location"] == "/setup/updates" + assert logged_in.post("/setup/updates", data={"auto_update": "0", "channel": "stable"}, follow_redirects=False).headers["location"] == "/setup/finish" + choices = nodectl.wizard_choices() + assert choices["timezone"] == "Europe/Moscow" and choices["port"] == "8090" and choices["tls"] == "0" and choices["network"] == "nat" and choices["auto_update"] == "0" + finish = logged_in.get("/setup/finish").text + assert "http://" in finish and "Europe/Moscow" in finish and "Вручную" in finish + + +def test_finish_marks_done_without_ctl(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + response = logged_in.post("/setup/finish", follow_redirects=False) + assert response.status_code == 303 and response.headers["location"].startswith("/?message=") + assert nodectl.wizard_done() + + +def test_finish_applies_settings_through_ctl(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + calls = [] + monkeypatch.setattr(nodectl, "ctl_available", lambda: True) + monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""}) + nodectl.save_wizard_choices(tls="1", port="8088", auto_update="1", channel="stable") + response = logged_in.post("/setup/finish", follow_redirects=False) + assert response.status_code == 200 and "https://panel.test:8443" in response.text + assert calls and calls[0][0] == "set" and "VIRTUALITY_TLS=1" in calls[0] + + +def test_bridge_mode_starts_network_change(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + calls = [] + monkeypatch.setattr(nodectl, "enable_bridge", lambda iface, mode="dhcp": calls.append((iface, mode)) or (True, "ok")) + response = logged_in.post("/setup/network", data={"mode": "bridge"}) + assert response.status_code == 200 and "Перенастраиваем сеть" in response.text + assert calls == [("enp3s0", "dhcp")] + monkeypatch.setattr(nodectl, "confirm_bridge", lambda: (True, "Сеть подтверждена")) + assert logged_in.post("/setup/network/confirm").json()["ok"] is True + assert nodectl.wizard_choices()["bridge_pending"] is False + + +def test_restart_wizard_from_settings(logged_in, data_dirs, monkeypatch): + prepare(data_dirs, monkeypatch) + nodectl.mark_wizard_done() + assert logged_in.post("/setup/restart", follow_redirects=False).headers["location"] == "/setup/welcome" + assert not nodectl.wizard_done() diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py new file mode 100644 index 0000000..c70b00d --- /dev/null +++ b/tests/test_snapshots.py @@ -0,0 +1,286 @@ +"""Снимки (точки восстановления) машины: парсеры и маршруты /vm/{name}/snapshots.""" +from datetime import datetime +from pathlib import Path +from urllib.parse import unquote + +import pytest + +import conftest +import core +from features import snapshots + +FIXTURES = Path(__file__).resolve().parent / "fixtures" + + +# ---------------------------------------------------------------- parsers +def test_parse_snapshot_list_fixture(): + rows = snapshots.parse_snapshot_list((FIXTURES / "snapshot-list.txt").read_text()) + assert [row["name"] for row in rows] == ["before-upd", "clean"] # новые сверху + assert rows[0] == {"name": "before-upd", "created_at": "2026-09-25 10:12:03", "tz": "+0000", "state": "shutoff"} + assert rows[1]["state"] == "running" + + +def test_parse_snapshot_list_without_timezone_and_garbage(): + text = " Name Creation Time State\n------\n snap1 2026-01-02 03:04:05 running\nerror: something\n\n" + rows = snapshots.parse_snapshot_list(text) + assert rows == [{"name": "snap1", "created_at": "2026-01-02 03:04:05", "tz": "", "state": "running"}] + assert snapshots.parse_snapshot_list("") == [] + + +def test_parse_snapshot_xml(): + meta = snapshots.parse_snapshot_xml(conftest.fake_snapshot_xml("clean")) + assert meta["description"] == "Чистая система после установки" + assert meta["state"] == "running" and meta["with_memory"] is True + assert snapshots.parse_snapshot_xml(conftest.fake_snapshot_xml("before-upd"))["with_memory"] is False + assert snapshots.parse_snapshot_xml("x + hvm/usr/share/OVMF/OVMF_CODE.fd/var/lib/libvirt/qemu/nvram/x.fd + + + + """ + info = snapshots.inspect_domain_xml(xml) + assert info["unsupported"] == [{"target": "vda", "format": "raw"}] + assert info["uefi"] is True + assert snapshots.inspect_domain_xml("") + return original(cmd, timeout, **kwargs) + + monkeypatch.setattr(snapshots, "run_cmd", run) + response = logged_in.post("/vm/web01/snapshots/create", data={"description": "x"}, follow_redirects=False) + assert "qcow2" in unquote(response.headers["location"]) + assert not find(recorder, "virsh", "snapshot-create-as") + + +def test_create_snapshot_surfaces_libvirt_error(logged_in, data_dirs, recorder, monkeypatch): + original = snapshots.run_cmd + + def run(cmd, timeout=12, **kwargs): + if cmd[:2] == ["virsh", "snapshot-create-as"]: + return conftest.fake_result(ok=False, stderr="error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported") + return original(cmd, timeout, **kwargs) + + monkeypatch.setattr(snapshots, "run_cmd", run) + response = logged_in.post("/vm/web01/snapshots/create", data={}, follow_redirects=False) + location = response.headers["location"] + operation = core.read_operation(location.split("op=", 1)[1]) + assert operation["status"] == "error" + assert operation["message"].startswith("Для машин с UEFI-прошивкой") and "pflash" in operation["message"] + assert "pflash" in operation["log_tail"] + html = logged_in.get(location).text + assert "Для машин с UEFI-прошивкой" in html + + +def test_create_snapshot_waits_for_active_operation(logged_in, data_dirs, recorder): + core.new_operation("snapshot_create", "Снимок машины web01", vm_name="web01", snapshot="snap-x") + response = logged_in.post("/vm/web01/snapshots/create", data={}, follow_redirects=False) + assert "предыдущей операции" in unquote(response.headers["location"]) + assert not find(recorder, "virsh", "snapshot-create-as") + html = logged_in.get("/vm/web01/snapshots").text + assert 'data-watch-operation=' in html and "/api/operations/" in html + + +def test_revert_snapshot(logged_in, data_dirs, recorder): + response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False) + assert response.status_code == 303 + assert find(recorder, "virsh", "snapshot-revert") == [["virsh", "snapshot-revert", "--domain", "web01", "--snapshotname", "clean"]] + operation = core.read_operation(response.headers["location"].split("op=", 1)[1]) + assert operation["status"] == "success" and "возвращена" in operation["message"] and "работает" in operation["message"] + + +def test_revert_retries_with_force(logged_in, data_dirs, recorder, monkeypatch): + original = snapshots.run_cmd + + def run(cmd, timeout=12, **kwargs): + if cmd[:2] == ["virsh", "snapshot-revert"] and "--force" not in cmd: + recorder.append(list(cmd)) + return conftest.fake_result(ok=False, stderr="error: revert requires force: snapshot was created with a different configuration") + return original(cmd, timeout, **kwargs) + + monkeypatch.setattr(snapshots, "run_cmd", run) + response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False) + reverts = find(recorder, "virsh", "snapshot-revert") + assert len(reverts) == 2 and reverts[1][-1] == "--force" + assert core.read_operation(response.headers["location"].split("op=", 1)[1])["status"] == "success" + + +def test_revert_reports_error(logged_in, data_dirs, recorder, monkeypatch): + original = snapshots.run_cmd + + def run(cmd, timeout=12, **kwargs): + if cmd[:2] == ["virsh", "snapshot-revert"]: + return conftest.fake_result(ok=False, stderr="error: Domain snapshot not found: no domain snapshot with matching name 'clean'") + return original(cmd, timeout, **kwargs) + + monkeypatch.setattr(snapshots, "run_cmd", run) + response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False) + operation = core.read_operation(response.headers["location"].split("op=", 1)[1]) + assert operation["status"] == "error" and operation["message"].startswith("Такого снимка уже нет") + + +def test_delete_snapshot(logged_in, data_dirs, recorder): + response = logged_in.post("/vm/web01/snapshots/before-upd/delete", follow_redirects=False) + assert response.status_code == 303 + assert "удалён" in unquote(response.headers["location"]) + assert find(recorder, "virsh", "snapshot-delete") == [["virsh", "snapshot-delete", "--domain", "web01", "--snapshotname", "before-upd"]] + + +def test_delete_snapshot_error(logged_in, data_dirs, recorder, monkeypatch): + original = snapshots.run_cmd + + def run(cmd, timeout=12, **kwargs): + if cmd[:2] == ["virsh", "snapshot-delete"]: + return conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock") + return original(cmd, timeout, **kwargs) + + monkeypatch.setattr(snapshots, "run_cmd", run) + response = logged_in.post("/vm/web01/snapshots/before-upd/delete", follow_redirects=False) + assert "snapshot_error=" in response.headers["location"] and "занята" in unquote(response.headers["location"]) + + +def test_snapshot_routes_validate_names(logged_in, data_dirs, recorder): + bad = logged_in.post("/vm/web01/snapshots/-bad/revert", follow_redirects=False) + assert bad.status_code == 303 and "snapshot_error=" in bad.headers["location"] + assert logged_in.post("/vm/web01/snapshots/..%2Fx/delete", follow_redirects=False).status_code in (400, 404) + assert logged_in.post("/vm/-bad/snapshots/create", data={}, follow_redirects=False).status_code == 400 + assert not find(recorder, "virsh", "snapshot-delete") and not find(recorder, "virsh", "snapshot-revert") + + +def test_snapshot_routes_require_login(client): + for path in ("/vm/web01/snapshots/create", "/vm/web01/snapshots/clean/revert", "/vm/web01/snapshots/clean/delete"): + response = client.post(path, follow_redirects=False) + assert response.status_code == 303 and response.headers["location"] == "/login" diff --git a/tests/test_vmops.py b/tests/test_vmops.py new file mode 100644 index 0000000..5d3dd32 --- /dev/null +++ b/tests/test_vmops.py @@ -0,0 +1,437 @@ +"""vmops: увеличение диска, клонирование, пауза и спящий режим, заметки, живая нагрузка.""" +import threading +from pathlib import Path +from urllib.parse import unquote + +import pytest + +import conftest +import core +import presenters +from features import vmops + +FIXTURES = Path(__file__).resolve().parent / "fixtures" +DOMSTATS = (FIXTURES / "domstats.txt").read_text() +GIB = 1024 ** 3 + + +class Recorder: + """Записывает команды; отвечает по overrides {(префикс,): результат|функция}, иначе как fake_run_cmd.""" + + def __init__(self, overrides=None): + self.calls: list[list[str]] = [] + self.overrides = overrides or {} + + def __call__(self, cmd, timeout=12, **kwargs): + self.calls.append(list(cmd)) + for prefix, result in self.overrides.items(): + if tuple(cmd[: len(prefix)]) == prefix: + return result(cmd) if callable(result) else result + return conftest.fake_run_cmd(cmd, timeout, **kwargs) + + def find(self, *prefix): + return [call for call in self.calls if tuple(call[: len(prefix)]) == prefix] + + +@pytest.fixture() +def record(data_dirs, monkeypatch): + """Подменяет run_cmd и в core (vmops), и в app (обзор, страница машины).""" + import app + + recorder = Recorder() + for module in (core, app): + monkeypatch.setattr(module, "run_cmd", recorder) + monkeypatch.setattr(vmops, "_samples", {}) + return recorder + + +@pytest.fixture() +def inline_operations(monkeypatch): + """Фоновые операции выполняются сразу, в этом же потоке.""" + + def run_now(operation, worker): + core.update_operation(operation, status="running") + worker(operation) + + monkeypatch.setattr(vmops, "run_operation", run_now) + + +def shut_off(record): + record.overrides[("virsh", "domstate")] = conftest.fake_result("shut off") + + +def location(response) -> str: + assert response.status_code == 303, response.text[:300] + return unquote(response.headers["location"]) + + +# ---------------------------------------------------------------- parsers +def test_parse_domstats_fixture(): + stats = vmops.parse_domstats(DOMSTATS) + assert list(stats) == ["web01"] + assert stats["web01"]["cpu.time"] == "48123456789" + assert stats["web01"]["balloon.rss"] == "1534208" + assert stats["web01"]["vcpu.current"] == "2" + + +def test_parse_domstats_several_domains(): + text = DOMSTATS + "Domain: 'db01'\n\nDomain: 'win 11'\n cpu.time=5\n vcpu.current=4\n" + stats = vmops.parse_domstats(text) + assert set(stats) == {"web01", "db01", "win 11"} + assert stats["db01"] == {} + assert stats["win 11"] == {"cpu.time": "5", "vcpu.current": "4"} + + +def test_compute_stats_cpu_percent_from_two_samples(): + samples: dict = {} + first = vmops.compute_stats(vmops.parse_domstats(DOMSTATS), 100.0, samples) + assert first["web01"]["cpu_pct"] is None # первый замер — сравнивать не с чем + assert first["web01"]["mem_used_mb"] == 1498 and first["web01"]["mem_total_mb"] == 2048 + later = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=49123456789") # +1 с процессорного времени + second = vmops.compute_stats(vmops.parse_domstats(later), 101.0, samples) + assert second["web01"]["cpu_pct"] == 50.0 # 1 с на 2 ядра за 1 с стены + burst = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=99123456789") + third = vmops.compute_stats(vmops.parse_domstats(burst), 101.5, samples) + assert third["web01"]["cpu_pct"] == 100.0 # не больше 100 % + + +def test_compute_stats_skips_shut_off_and_forgets_stale(): + samples = {"old": (1.0, 5)} + stats = vmops.compute_stats({"db01": {}, "web01": {"cpu.time": "10", "balloon.current": "1024", "vcpu.current": "1"}}, 2.0, samples) + assert set(stats) == {"web01"} + assert stats["web01"]["mem_used_mb"] == 1 and stats["web01"]["mem_total_mb"] == 1 + assert set(samples) == {"web01"} + + +def test_parse_qemu_img_info(): + info = vmops.parse_qemu_img_info((FIXTURES / "qemu-img-info.json").read_text()) + assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"} + assert vmops.parse_qemu_img_info("not json") == {} + assert vmops.parse_qemu_img_info("[1, 2]") == {} + + +def test_parse_virsh_list_titles(): + titles = presenters.parse_virsh_list_titles((FIXTURES / "list-title.txt").read_text()) + assert titles == {"web01": "Сайт компании", "win11": "Бухгалтерия 1С"} + assert presenters.parse_virsh_list_titles(" Id Name State\n---\n 1 web01 running") == {} + assert presenters.parse_virsh_list_titles("") == {} + + +def test_parse_desc_output(): + assert vmops.parse_desc_output(conftest.fake_result("Сайт компании\n")) == "Сайт компании" + assert vmops.parse_desc_output(conftest.fake_result("No title for domain: web01")) == "" + assert vmops.parse_desc_output(conftest.fake_result(ok=False, stderr="error: failed to get domain")) == "" + + +def test_has_managed_save(): + assert vmops.has_managed_save(conftest.DOMINFO + "Managed save: yes\n") + assert not vmops.has_managed_save(conftest.DOMINFO + "Managed save: no\n") + assert not vmops.has_managed_save(conftest.DOMINFO) + + +def test_clean_notes(): + assert vmops.clean_title(" Сайт \n компании ") == "Сайт компании" + assert len(vmops.clean_title("x" * 200)) == vmops.TITLE_MAX + assert vmops.clean_description("строка 1\r\nстрока 2 \r\n\r\n") == "строка 1\nстрока 2" + + +def test_clone_names_and_targets(data_dirs): + assert vmops.suggest_clone_name("web01", {"web01"}) == "web01-copy" + assert vmops.suggest_clone_name("web01", {"web01", "web01-copy", "web01-copy2"}) == "web01-copy3" + disks = [ + {"device": "disk", "source": "/var/lib/virtuality/images/web01.qcow2"}, + {"device": "cdrom", "source": ""}, + {"device": "disk", "source": "/var/lib/virtuality/images/data.img"}, + ] + targets = vmops.clone_targets("web02", disks) + assert targets == [data_dirs["images"] / "web02.qcow2", data_dirs["images"] / "web02-disk2.img"] + + +def test_explain_error(): + assert "снимки" in vmops.explain_error("qemu-img: Can't resize an image which has snapshots", "x") + assert vmops.explain_error("", "запасной текст") == "запасной текст" + + +# ---------------------------------------------------------------- live stats +def test_live_stats_requires_auth(client): + assert client.get("/live/stats").status_code == 401 + + +def test_live_stats_json(logged_in, record): + first = logged_in.get("/live/stats").json() + assert first["ok"] is True + assert first["stats"]["web01"]["cpu_pct"] is None + assert first["stats"]["web01"]["mem_used_mb"] == 1498 and first["stats"]["web01"]["mem_total_mb"] == 2048 + second = logged_in.get("/live/stats").json() + assert second["stats"]["web01"]["cpu_pct"] == 0.0 # cpu.time в подделке не растёт + assert record.find("virsh", "domstats") == [["virsh", "domstats", "--cpu-total", "--balloon", "--vcpu", "web01"]] * 2 # только активные машины, один вызов на запрос + + +def test_live_stats_without_active_vms(logged_in, record): + record.overrides[("virsh", "list")] = conftest.fake_result("") + assert logged_in.get("/live/stats").json()["stats"] == {} + assert record.find("virsh", "domstats") == [] + + +# ---------------------------------------------------------------- pages +def test_dashboard_shows_title_and_meters(logged_in, record): + html = logged_in.get("/").text + assert "Сайт компании" in html + assert 'data-live-stats="web01"' in html and 'data-live-stats="db01"' in html + assert "/vm/web01/ops/pause" in html and "/vm/web01/ops/hibernate" in html + assert "/vm/web01/clone" in html and "/vm/db01/clone" in html + assert record.find("virsh", "list", "--all", "--title") + + +def test_dashboard_paused_vm_offers_resume(logged_in, record): + record.overrides[("virsh", "list")] = conftest.fake_result(" Id Name State\n-----------------------\n 2 web01 paused\n - db01 shut off") + html = logged_in.get("/").text + assert 'action="/vm/web01/ops/resume"' in html + assert 'action="/vm/web01/start"' not in html + assert 'action="/vm/db01/start"' in html + + +def test_vm_page_shows_disks_notes_and_ops(logged_in, record): + html = logged_in.get("/vm/web01").text + assert "40 ГБ" in html and "занято 6.0 ГБ" in html + assert 'action="/vm/web01/disks/vda/resize"' in html and 'min="41"' in html + assert 'action="/vm/web01/disks/sda/resize"' not in html # привод не увеличивают + assert 'value="Сайт компании"' in html and "Nginx + база. Бэкап по пятницам." in html + assert "Приостановить" in html and "Спящий режим" in html and "Клонировать" in html + assert 'data-live-stats="web01"' in html # карточка «Нагрузка сейчас» + assert record.find("qemu-img", "info") == [["qemu-img", "info", "--output=json", "-U", str(core.IMAGES_DIR / "web01.qcow2")]] + + +def test_vm_page_paused_and_saved_states(logged_in, record): + record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: paused")) + html = logged_in.get("/vm/web01").text + assert 'action="/vm/web01/ops/resume"' in html and "Продолжить" in html + record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: shut off") + "Managed save: yes\n") + html = logged_in.get("/vm/web01").text + assert "Машина в спящем режиме" in html and 'action="/vm/web01/ops/forget-save"' in html + assert 'data-live-stats="web01"' not in html # выключенная машина — нагрузки нет + + +def test_vm_page_messages(logged_in, record): + html = logged_in.get("/vm/web01?disk_message=Диск+увеличен¬es_error=Не+вышло").text + assert "Диск увеличен" in html and "Не вышло" in html + + +# ---------------------------------------------------------------- disk resize +def test_disk_resize_shut_off_uses_qemu_img(logged_in, record): + shut_off(record) + response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False) + assert location(response).startswith("/vm/web01?disk_message=Диск web01.qcow2 увеличен до 50 ГБ") + assert location(response).endswith("#storage") + assert record.find("qemu-img", "resize") == [["qemu-img", "resize", str(core.IMAGES_DIR / "web01.qcow2"), "50G"]] + assert record.find("virsh", "blockresize") == [] + + +def test_disk_resize_running_uses_blockresize(logged_in, record): + response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "41"}, follow_redirects=False) + assert "disk_message" in location(response) + assert record.find("virsh", "blockresize") == [["virsh", "blockresize", "web01", "vda", "41G"]] + assert record.find("qemu-img", "resize") == [] + + +def test_disk_resize_only_grows(logged_in, record): + response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "40"}, follow_redirects=False) + assert "не меньше 41 ГБ" in location(response) + response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "999999"}, follow_redirects=False) + assert "Слишком большой" in location(response) + assert record.find("virsh", "blockresize") == [] and record.find("qemu-img", "resize") == [] + + +def test_disk_resize_rejects_unknown_or_cdrom(logged_in, record): + assert "Такого диска" in location(logged_in.post("/vm/web01/disks/vdz/resize", data={"size_gb": "50"}, follow_redirects=False)) + assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/sda/resize", data={"size_gb": "50"}, follow_redirects=False)) + assert logged_in.post("/vm/web01/disks/../etc/resize", data={"size_gb": "50"}, follow_redirects=False).status_code in (400, 404) + assert logged_in.post("/vm/-bad/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False).status_code == 400 + + +def test_disk_resize_outside_storage_refused(logged_in, record): + record.overrides[("virsh", "domblklist")] = conftest.fake_result((FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images", "/root")) + assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)) + + +def test_disk_resize_checks_free_space(logged_in, record, monkeypatch): + monkeypatch.setattr(vmops, "free_space", lambda path: 2 * GIB) + response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False) + assert "свободно только 2.0 ГБ" in location(response) + assert record.find("virsh", "blockresize") == [] + + +def test_disk_resize_reports_tool_error(logged_in, record): + record.overrides[("virsh", "blockresize")] = conftest.fake_result(ok=False, stderr="error: internal error: Can't resize an image which has snapshots") + assert "удалите снимки" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)) + + +# ---------------------------------------------------------------- pause / resume / hibernate +def test_ops_pause_and_resume(logged_in, record): + response = logged_in.post("/vm/web01/ops/pause", data={"next": "/"}, follow_redirects=False) + assert location(response).startswith("/?ops_message=Машина web01 приостановлена") + assert record.find("virsh", "suspend") == [["virsh", "suspend", "web01"]] + response = logged_in.post("/vm/web01/ops/resume", follow_redirects=False) + assert "ops_error=Продолжить можно только приостановленную" in location(response) + record.overrides[("virsh", "domstate")] = conftest.fake_result("paused") + response = logged_in.post("/vm/web01/ops/resume", data={"next": "http://evil.example/"}, follow_redirects=False) + assert location(response).startswith("/vm/web01?ops_message=") # чужой next не используется + assert record.find("virsh", "resume") == [["virsh", "resume", "web01"]] + + +def test_ops_validation(logged_in, record): + assert logged_in.post("/vm/web01/ops/explode", follow_redirects=False).status_code == 400 + assert logged_in.post("/vm/-bad/ops/pause", follow_redirects=False).status_code == 400 + assert location(logged_in.post("/vm/ghost/ops/pause", follow_redirects=False)) == "/" + shut_off(record) + assert "ops_error=Приостановить можно только работающую" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False)) + record.overrides[("virsh", "suspend")] = conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock") + record.overrides[("virsh", "domstate")] = conftest.fake_result("running") + assert "занята другой операцией" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False)) + + +def test_ops_requires_login(client): + assert client.post("/vm/web01/ops/pause", follow_redirects=False).headers["location"] == "/login" + + +def test_hibernate_runs_operation(logged_in, record, inline_operations, data_dirs): + response = logged_in.post("/vm/web01/ops/hibernate", data={"next": "/"}, follow_redirects=False) + assert location(response).startswith("/?ops_message=Машина web01 засыпает") + assert record.find("virsh", "managedsave") == [["virsh", "managedsave", "web01"]] + operations = core.list_operations(5) + assert operations[0]["type"] == "vm_hibernate" and operations[0]["status"] == "success" and operations[0]["vm_name"] == "web01" + + +def test_hibernate_blocked(logged_in, record, monkeypatch): + shut_off(record) + assert "ops_error=В спящий режим можно" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False)) + record.overrides[("virsh", "domstate")] = conftest.fake_result("running") + monkeypatch.setattr(vmops, "free_space", lambda path: 0) + assert "свободного места" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False)) + assert record.find("virsh", "managedsave") == [] + + +def test_hibernate_failure_is_reported(logged_in, record, inline_operations): + record.overrides[("virsh", "managedsave")] = conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: domain is not running") + logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False) + operation = core.list_operations(1)[0] + assert operation["status"] == "error" and "выключена" in operation["message"] + + +def test_forget_saved_state(logged_in, record): + shut_off(record) + response = logged_in.post("/vm/web01/ops/forget-save", follow_redirects=False) + assert "ops_message=Сохранённое состояние удалено" in location(response) + assert record.find("virsh", "managedsave-remove") == [["virsh", "managedsave-remove", "web01"]] + + +# ---------------------------------------------------------------- notes +def test_notes_save_running(logged_in, record): + response = logged_in.post("/vm/web01/notes/save", data={"title": " Сайт компании ", "description": "строка 1\r\nстрока 2"}, follow_redirects=False) + assert location(response) == "/vm/web01?notes_message=Заметки сохранены" + assert record.find("virsh", "desc") == [ + ["virsh", "desc", "web01", "--title", "--new-desc=Сайт компании", "--config", "--live"], + ["virsh", "desc", "web01", "--new-desc=строка 1\nстрока 2", "--config", "--live"], + ] + + +def test_notes_save_shut_off_and_dashes(logged_in, record): + shut_off(record) + logged_in.post("/vm/web01/notes/save", data={"title": "--edit", "description": ""}, follow_redirects=False) + calls = record.find("virsh", "desc") + assert calls[0] == ["virsh", "desc", "web01", "--title", "--new-desc=--edit", "--config"] # текст одним аргументом, без --live + assert calls[1] == ["virsh", "desc", "web01", "--new-desc=", "--config"] # пустое описание очищает + + +def test_notes_save_error(logged_in, record): + record.overrides[("virsh", "desc")] = conftest.fake_result(ok=False, stderr="error: failed to get domain 'web01'") + assert "notes_error=" in location(logged_in.post("/vm/web01/notes/save", data={"title": "x"}, follow_redirects=False)) + assert logged_in.post("/vm/-bad/notes/save", data={"title": "x"}, follow_redirects=False).status_code == 400 + + +# ---------------------------------------------------------------- clone +def test_clone_page_blocked_while_running(logged_in, record): + response = logged_in.get("/vm/web01/clone") + assert response.status_code == 200 + assert "Сначала выключите машину" in response.text + assert 'action="/vm/web01/shutdown"' in response.text + assert "disabled" in response.text + + +def test_clone_page_ready(logged_in, record): + shut_off(record) + html = logged_in.get("/vm/web01/clone").text + assert "Сначала выключите" not in html + assert 'value="web01-copy"' in html + assert "6.0 ГБ" in html # нужно места — реальный размер диска + assert "web01.qcow2" in html + + +def test_clone_validation(logged_in, record): + shut_off(record) + assert "Имя копии" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "bad name!"}, follow_redirects=False)) + assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "db01"}, follow_redirects=False)) + assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "WEB01"}, follow_redirects=False)) + record.overrides[("virsh", "domstate")] = conftest.fake_result("running") + assert "Сначала выключите" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)) + assert record.find("virt-clone") == [] + assert logged_in.post("/vm/-bad/clone/start", data={"new_name": "web02"}, follow_redirects=False).status_code == 400 + + +def test_clone_checks_space_and_existing_file(logged_in, record, data_dirs, monkeypatch): + shut_off(record) + (data_dirs["images"] / "web02.qcow2").write_bytes(b"x") + assert "уже есть в хранилище" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)) + monkeypatch.setattr(vmops, "free_space", lambda path: GIB) + assert "Не хватает места" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web03"}, follow_redirects=False)) + + +def test_clone_starts_operation(logged_in, record, inline_operations, data_dirs): + shut_off(record) + response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False) + assert location(response).startswith("/operations/") + operation = core.read_operation(location(response).split("/")[-1]) + assert operation["status"] == "success" and operation["type"] == "vm_clone" + assert operation["vm_name"] == "web02" and operation["source_vm"] == "web01" + assert "web02 создана" in operation["message"] + assert record.find("virt-clone") == [["virt-clone", "--original", "web01", "--name", "web02", "--file", str(data_dirs["images"] / "web02.qcow2")]] + assert record.find("virsh", "pool-refresh") == [["virsh", "pool-refresh", "virtuality-images"]] + assert "virt-clone --original web01" in operation["log_tail"] + + +def test_clone_failure_is_reported(logged_in, record, inline_operations): + shut_off(record) + record.overrides[("virt-clone",)] = conftest.fake_result(ok=False, stderr="ERROR Storage volume already exists") + response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False) + operation = core.read_operation(location(response).split("/")[-1]) + assert operation["status"] == "error" and "уже есть" in operation["message"] + assert record.find("virsh", "pool-refresh") == [] + + +def test_clone_progress_watcher(data_dirs): + target = data_dirs["images"] / "copy.qcow2" + target.write_bytes(b"\0" * (512 * 1024)) + operation = core.new_operation("vm_clone", "test", vm_name="copy", source_vm="web01") + + class OneTick: + def __init__(self): + self.ticks = 0 + + def wait(self, timeout): + self.ticks += 1 + return self.ticks > 1 + + vmops._watch_clone_progress(operation, [target], 1024 * 1024, OneTick()) + fresh = core.read_operation(operation["id"]) + assert 3 <= fresh["progress"] <= 95 and "Копируем диски" in fresh["message"] + assert vmops._allocated_bytes(data_dirs["images"] / "missing.qcow2") == 0 + + +def test_clone_worker_stops_watcher(data_dirs, monkeypatch): + """Даже если virt-clone упал, поток прогресса останавливается.""" + monkeypatch.setattr(core, "run_cmd", lambda cmd, timeout=12: conftest.fake_result(ok=False, stderr="boom")) + operation = core.new_operation("vm_clone", "test", vm_name="web02", source_vm="web01", targets=[str(data_dirs["images"] / "web02.qcow2")], expected_bytes=10) + before = threading.active_count() + vmops.clone_worker(operation) + assert threading.active_count() <= before + assert core.read_operation(operation["id"])["status"] == "error" diff --git a/updates/versions.json b/updates/versions.json index 5ada224..564e681 100644 --- a/updates/versions.json +++ b/updates/versions.json @@ -1,5 +1,31 @@ { "versions": [ + { + "version": "0.11.0", + "date": "2026-09-25", + "title": "Мастер настройки, снимки, резервные копии и быстрые машины из готовых образов", + "summary": "Virtuality стала полноценной заменой Proxmox для дома: после установки с ISO панель открывается сразу, а мастер настройки проводит через HTTPS, пароль, сеть машин, хранилище и обновления. Добавлены снимки, резервные копии, клонирование, увеличение диска, спящий режим, скачивание образов по ссылке и из каталога, создание машины из облачного образа с пользователем и паролем, установка Windows 11 с UEFI и TPM. Исправлено больше двадцати ошибок, найденных аудитом.", + "features": [ + "Мастер первичной настройки: часовой пояс, пароль, HTTPS, сеть NAT или локальная сеть (с автооткатом), отдельный диск под машины, обновления", + "Первая загрузка образа: панель ставится первой и показывает ход установки в браузере; при ошибке установка продолжается сама", + "Страница «Настройки»: всё то же, что в мастере, плюс перезагрузка и выключение сервера, архив настроек, отчёт для поддержки", + "HTTPS для панели с сертификатом сервера и перенаправлением с обычного адреса", + "Снимки машин: создать, вернуться, удалить — до 8 на машину", + "Резервные копии машин: создание, восстановление в новую или ту же машину, удаление", + "Клонирование, увеличение диска, пауза и спящий режим, заметки, живая нагрузка процессора и памяти", + "Скачивание ISO и образов по ссылке и из каталога: Ubuntu 26.04/24.04, Debian 13, Alpine — с проверкой контрольных сумм", + "Машина из готового образа за минуту: пользователь, пароль и SSH-ключ через cloud-init, диск нужного размера", + "Тип системы при установке с ISO: Linux, Windows 11 (UEFI + TPM), Windows 10, другая", + "Постоянный адрес машины в сети NAT — пробросы портов не ломаются после перезагрузки", + "Стабильный канал обновлений: только выпущенные версии; ранний доступ — по желанию", + "Консоль машин доступна только через панель: VNC больше не открыт в сеть", + "Операции, прерванные перезапуском панели, больше не блокируют машину", + "Исправлены: формат облачных образов .img, CD-ROM, удаление UEFI-машин, правила firewall удалённых пробросов, русская локаль virsh, чужой IP у остановленных машин", + "Убраны Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE и устаревшие скрипты", + "Команда virtuality-ctl: статус, настройки, пароль, часовой пояс, мост, диск, резервная копия настроек, откат, удаление", + "Версия обновлена до 0.11.0" + ] + }, { "version": "0.10.0", "date": "2026-09-25", diff --git a/web/app.py b/web/app.py index c911878..2f480a7 100644 --- a/web/app.py +++ b/web/app.py @@ -4,6 +4,7 @@ import json import os import platform import re +import shlex import shutil import subprocess import tempfile @@ -26,6 +27,8 @@ from itsdangerous import BadSignature, URLSafeSerializer, URLSafeTimedSerializer import auth import host_profile +import catalog +import cloudinit import network_core import presenters import update_core @@ -293,12 +296,16 @@ def run_operation_worker(operation_id: str, cmd: list[str]) -> None: update_operation(fresh, progress=new_progress, message=line.strip()[:240] or fresh.get("message")) exit_code = process.wait() fresh = read_operation(operation_id) or operation + if fresh.get("seed_file"): + cloudinit.remove_seed(fresh["seed_file"]) if exit_code == 0: append_operation_log(operation_id, "virt-install завершился успешно.") run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20) - update_operation(fresh, status="success", progress=100, exit_code=exit_code, message="VM создана успешно", finished_at=utc_now()) + update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=fresh.get("success_message") or "Машина создана", finished_at=utc_now()) else: append_operation_log(operation_id, f"virt-install завершился с ошибкой. Exit code: {exit_code}") + if fresh.get("vm_name") and fresh.get("network_mode") == "nat": + network_core.release_nat_address(fresh["vm_name"]) update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"virt-install завершился с ошибкой: {exit_code}", finished_at=utc_now()) except Exception as exc: fresh = read_operation(operation_id) or operation @@ -399,15 +406,17 @@ def parse_virsh_list() -> list[dict[str, str]]: 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 + # DHCP lease first (NAT network), then the guest agent (machines in the home network via br0). + for extra in ([], ["--source", "agent"]): + try: + result = run_cmd(["virsh", "domifaddr", name, *extra], 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: @@ -460,11 +469,13 @@ def parse_virsh_list() -> list[dict[str, str]]: manual_ips = manual_ip_map() - def resolve_ip(name: str) -> str: + def resolve_ip(name: str, state: str = "running") -> str: if not name: return "—" if manual_ips.get(name): return manual_ips[name] + if "running" not in state and "paus" not in state: + return "—" 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: @@ -492,7 +503,7 @@ def parse_virsh_list() -> list[dict[str, str]]: label = "enabled" if enabled else "disabled" if info["autostart_known"] else "unknown" rows.append({"id": vm_id, "name": name, "state": state, "autostart_enabled": enabled, "autostart_label": label, "autostart_css": "ok" if enabled else "warn", "vcpus": info["vcpus"], "memory_mb": info["memory_mb"]}) for row in rows: - row["ip"] = resolve_ip(row.get("name", "")) + row["ip"] = resolve_ip(row.get("name", ""), row.get("state", "")) row["manual_ip"] = manual_ips.get(row.get("name", ""), "") return rows @@ -560,26 +571,10 @@ def refresh_iso_pool() -> None: run_cmd(["virsh", "pool-refresh", "virtuality-iso"], timeout=20) -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 guest_arch_for_host(profile: dict[str, Any]) -> str: + """Machines run natively: aarch64 on ARM boards, x86_64 everywhere else.""" + arch = str(profile.get("recommended_guest_arch") or profile.get("arch") or platform.machine() or "x86_64") + return "aarch64" if arch in ("aarch64", "arm64") else "x86_64" def list_disk_image_files() -> list[dict[str, str]]: @@ -593,7 +588,8 @@ def list_disk_image_files() -> list[dict[str, str]]: 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}) + entry = catalog.entry_for_filename(item.name) + files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated, "login": cloudinit.default_user(item.name), "title": entry["title"] if entry else "", "catalog_id": entry["id"] if entry else ""}) return files @@ -618,10 +614,24 @@ def disk_image_path_by_name(name: str) -> Path | None: def disk_image_format(path: Path) -> str: - suffix = path.suffix.lower().lstrip('.') - if suffix == 'qcow2': - return 'qcow2' - return 'raw' + """Real image format: Ubuntu cloud images are qcow2 despite the .img name, so the suffix is only a fallback.""" + info = run_cmd(["qemu-img", "info", "--output=json", str(path)], timeout=30) + if info.get("ok"): + try: + fmt = str(json.loads(info.get("stdout") or "{}").get("format", "")).lower() + if fmt in ("qcow2", "raw", "vmdk", "vdi", "vpc", "vhdx", "qcow"): + return fmt + except (json.JSONDecodeError, AttributeError): + pass + return 'qcow2' if path.suffix.lower() == '.qcow2' else 'raw' + + +def disk_image_virtual_size(path: Path) -> int: + info = run_cmd(["qemu-img", "info", "--output=json", str(path)], timeout=30) + try: + return int(json.loads(info.get("stdout") or "{}").get("virtual-size", 0)) if info.get("ok") else 0 + except (json.JSONDecodeError, TypeError, ValueError): + return 0 def bridge_exists(name: str) -> bool: @@ -853,7 +863,10 @@ def disk_upload_response(request: Request, payload: dict[str, Any]): def valid_vm_name(name: str) -> bool: - return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or "")) + """Latin letters, digits, dot, dash, underscore; never a bare number or a UUID — virsh would read those as an ID/UUID of another machine.""" + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""): + return False + return not name.isdigit() and not re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", name) def vm_exists(name: str) -> bool: @@ -908,7 +921,6 @@ def boot_order_to_devs(value: str) -> list[str]: "disk": ["hd"], "cdrom_disk": ["cdrom", "hd"], "disk_cdrom": ["hd", "cdrom"], - "network_disk": ["network", "hd"], } return mapping.get(value, ["hd"]) @@ -919,8 +931,6 @@ def boot_devs_to_order(devs: list[str]) -> str: 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" @@ -948,7 +958,7 @@ def current_vm_boot_order(name: str) -> str: 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"): + if boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom"): return False, "Некорректный порядок загрузки VM." selected = normalize_boot_order(boot_order, "disk_image") @@ -1041,18 +1051,16 @@ def vm_resource_settings(name: str) -> dict[str, Any]: } -def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str) -> tuple[bool, str]: +def apply_vm_resources(name: str, memory_mb: int, vcpus: int) -> 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." + return False, "Процессор и память можно менять только когда машина выключена." 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"): @@ -1076,22 +1084,6 @@ def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str) - 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) @@ -1102,11 +1094,7 @@ def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str) - 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 + return True, f"Ресурсы VM применены: CPU {vcpus}, RAM {memory_mb} MB" def vm_state(name: str) -> str: @@ -1115,57 +1103,57 @@ def vm_state(name: str) -> str: 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(): + return next((drive["source"] for drive in vm_cdrom_devices(name) if drive["source"]), "") + + +def vms_using_iso(path: Path) -> list[str]: + """Machines that have this ISO in their CD-ROM drive (they would not start without the file).""" + listed = run_cmd(["virsh", "list", "--all", "--name"], timeout=10) + target = str(path) + return [name for name in (listed.get("stdout") or "").split() if any(drive["source"] == target for drive in vm_cdrom_devices(name))] + + +def vm_cdrom_devices(name: str) -> list[dict[str, str]]: + """CD-ROM drives of the VM from its XML: [{"target": "sda", "source": "/path.iso" or ""}].""" + xml = run_cmd(["virsh", "dumpxml", name], timeout=12) + drives: list[dict[str, str]] = [] + if not xml.get("ok"): + return drives + try: + root = ET.fromstring(xml.get("stdout") or "") + except ET.ParseError: + return drives + devices = root.find("devices") + for disk in devices.findall("disk") if devices is not None else []: + if disk.attrib.get("device") != "cdrom": continue - parts = line.split() - if parts: - return parts[-1] - return "" + target = disk.find("target") + source = disk.find("source") + dev = target.attrib.get("dev", "") if target is not None else "" + if dev: + drives.append({"target": dev, "source": (source.attrib.get("file") or source.attrib.get("dev") or "") if source is not None else ""}) + return drives + + +def change_media_flags(name: str) -> list[str]: + # --live --config: the running VM and its saved configuration change together. + return ["--config", "--live"] if vm_state(name) == "running" else ["--config"] 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" + drives = [drive for drive in vm_cdrom_devices(name) if drive["source"]] + if not drives: + return False, "Подключенный ISO не найден." 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 не найден." + for drive in drives: + result = run_cmd(["virsh", "change-media", name, drive["target"], "--eject", *change_media_flags(name)], timeout=30) + if not result.get("ok"): + errors.append(result.get("stderr") or "virsh change-media завершился с ошибкой") + if errors: + return False, errors[-1] + return True, "ISO был отмонтирован." def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]: @@ -1179,17 +1167,19 @@ def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]: 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 или обнови устройства внутри гостевой ОС." + drives = vm_cdrom_devices(name) + if drives: + result = run_cmd(["virsh", "change-media", name, drives[0]["target"], str(iso), "--update", *change_media_flags(name)], timeout=30) + if not result.get("ok"): + return False, result.get("stderr") or "Не удалось подключить ISO." + return True, "ISO был смонтирован в VM. Если гостевая ОС его не увидела сразу, перезагрузи VM или обнови устройства внутри гостевой ОС." + # No CD-ROM drive at all: add one to the configuration; a SATA drive appears after the next start. + result = run_cmd(["virsh", "attach-disk", name, str(iso), "sda", "--type", "cdrom", "--mode", "readonly", "--config"], timeout=30) + if not result.get("ok"): + return False, result.get("stderr") or "Не удалось добавить CD-ROM в конфигурацию VM." + if vm_state(name) == "running": + return True, "CD-ROM добавлен в конфигурацию. Он появится в машине после её перезапуска." + return True, "ISO был смонтирован в VM." def vm_details(name: str) -> dict[str, Any]: @@ -1243,7 +1233,6 @@ def vm_boot_order_options() -> list[dict[str, str]]: {"value": "disk", "label": "Сначала диск"}, {"value": "cdrom_disk", "label": "Сначала ISO/CD-ROM, потом диск"}, {"value": "disk_cdrom", "label": "Сначала диск, потом ISO/CD-ROM"}, - {"value": "network_disk", "label": "Сначала сеть/PXE, потом диск"}, ] @@ -1251,7 +1240,7 @@ def normalize_boot_order(value: str, source_type: str) -> str: value = (value or "auto").strip() if value == "auto": return "cdrom_disk" if source_type == "iso" else "disk" - if value in ("disk", "cdrom_disk", "disk_cdrom", "network_disk"): + if value in ("disk", "cdrom_disk", "disk_cdrom"): return value return "cdrom_disk" if source_type == "iso" else "disk" @@ -1261,7 +1250,6 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str: "disk": "hd", "cdrom_disk": "cdrom,hd", "disk_cdrom": "hd,cdrom", - "network_disk": "network,hd", } value = mapping.get(boot_order, "hd") if is_arm: @@ -1272,7 +1260,7 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str: def vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200): profile = host_profile.load_host_profile() default_mode = profile.get("recommended_network", "nat") - return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "arch_options": vm_arch_options(), "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "guest_arch": "auto", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code) + return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "os_types": catalog.OS_TYPES, "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code) # Virtuality noVNC console patch @@ -1341,7 +1329,9 @@ def dashboard(request: Request): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect - services = {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")} + if setup.wizard_pending(): + return RedirectResponse(url="/setup", status_code=303) + services = {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "web": service_state("virtuality-web.service")} service_rows = [{"key": key, "name": presenters.SERVICE_LABELS.get(key, key), "state": state, **presenters.service_state(state)} for key, state in services.items()] return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "service_rows": service_rows, "services_ok": all(row["tone"] == "success" for row in service_rows if row["key"] in ("libvirtd", "web")), "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css, "host": presenters.host_stats(STORAGE_DIR), "greeting": presenters.greeting()}) @@ -1412,6 +1402,9 @@ def iso_delete(request: Request, name: str): return auth_redirect path = iso_path_by_name(name) if path and path.exists() and path.is_file(): + users = vms_using_iso(path) + if users: + return redirect_with_message("/iso", "error", f"Образ вставлен в привод машин: {', '.join(users)}. Сначала извлеките его на странице машины — иначе машина не запустится.") path.unlink() refresh_iso_pool() return RedirectResponse(url="/iso", status_code=303) @@ -1770,7 +1763,7 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""): if not iso and not image: return vm_form_context(request) profile = host_profile.load_host_profile() - form = {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "disk_image" if image else "iso", "guest_arch": "auto", "boot_order": "auto", "network_mode": profile.get("recommended_network", "nat"), "bridge": DEFAULT_BRIDGE} + form = {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "disk_image" if image else "iso", "boot_order": "auto", "network_mode": profile.get("recommended_network", "nat"), "bridge": DEFAULT_BRIDGE} if iso: form["iso_path"] = str(ISO_DIR / Path(iso).name) if image: @@ -1779,12 +1772,15 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""): @app.post("/vm/create", response_class=HTMLResponse) -def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(20), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)): +def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(20), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE), os_type: str = Form("linux"), cloud_init: str = Form("0"), ci_user: str = Form(""), ci_password: str = Form(""), ci_ssh_key: str = Form("")): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect - form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "boot_order": boot_order, "network_mode": network_mode, "bridge": bridge} + ci_user = ci_user.strip() + 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, "boot_order": boot_order, "network_mode": network_mode, "bridge": bridge, "os_type": os_type, "cloud_init": cloud_init, "ci_user": ci_user, "ci_ssh_key": ci_ssh_key} error = None + use_cloud_init = source_type == "disk_image" and cloud_init == "1" + ssh_keys = cloudinit.parse_ssh_keys(ci_ssh_key) if use_cloud_init else [] if not valid_vm_name(name): error = "Имя VM может содержать латиницу, цифры, точку, дефис и подчёркивание. Длина 2–63 символа." elif vm_exists(name): @@ -1801,10 +1797,16 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form error = "Некорректное имя bridge." elif source_type not in ("iso", "disk_image"): error = "Некорректный источник VM." - elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"): - error = "Некорректная архитектура VM." - elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"): + elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom"): error = "Некорректный порядок загрузки VM." + elif os_type not in {item["id"] for item in catalog.OS_TYPES}: + error = "Некорректный тип системы." + elif use_cloud_init and not cloudinit.valid_user(ci_user): + error = "Имя пользователя: строчные латинские буквы, цифры, дефис и подчёркивание, до 32 символов." + elif use_cloud_init and ssh_keys is None: + error = "SSH-ключ не похож на публичный ключ. Нужна строка вида «ssh-ed25519 AAAA… комментарий» — из файла ~/.ssh/id_ed25519.pub." + elif use_cloud_init and not ssh_keys and len(ci_password) < 6: + error = "Задайте пароль не короче 6 символов или вставьте публичный SSH-ключ — иначе в машину нельзя будет войти." elif network_mode == "bridge" and not bridge_exists(bridge): error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}." else: @@ -1831,32 +1833,67 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form 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) + selected_arch = guest_arch_for_host(profile) selected_boot_order = normalize_boot_order(boot_order, source_type) - 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" + virt_type = "kvm" if profile.get("kvm_device") else "qemu" + os_info = catalog.os_type(os_type) if source_type == "iso" else None + windows = os_type.startswith("windows") if source_type == "iso" else False + # Windows installers have no virtio drivers: SATA disk and an Intel network card work out of the box. + disk_bus = "sata" if windows or os_type == "other" and source_type == "iso" else "virtio" + nic_model = "e1000e" if disk_bus == "sata" else "virtio" + nat_ip = None + if network_mode == "nat": + mac = network_core.stable_mac(name) + nat_ip = network_core.reserve_nat_address(name, mac) + network_arg = f"network={network_core.NETWORK_NAME},model={nic_model},mac={mac}" + else: + network_arg = f"bridge={bridge},model={nic_model}" 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: + if is_arm: cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57"] + uefi = is_arm or os_type == "windows11" and source_type == "iso" + catalog_entry = catalog.entry_for_filename(Path(disk_image_path).name) if source_type == "disk_image" else None + if catalog_entry: + uefi = uefi or bool(catalog_entry["arches"].get(selected_arch, {}).get("uefi")) + boot_arg = virt_boot_arg(selected_boot_order, uefi) + cmd += ["--boot", boot_arg] + if os_type == "windows11" and source_type == "iso": + # Windows 11 refuses to install without a TPM 2.0. + cmd += ["--tpm", "model=tpm-crb,backend.type=emulator,backend.version=2.0"] + graphics = ["--graphics", "vnc,listen=127.0.0.1", "--noautoconsole"] + if not windows: + # Channel for qemu-guest-agent (installed by cloud-init; on ISO installs — by the user or the distro). + graphics = ["--channel", "unix,target.type=virtio,target.name=org.qemu.guest_agent.0", *graphics] - cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)] - + seed_file = "" + login_hint = "" 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}"] + osinfo = catalog_entry["osinfo"] if catalog_entry else "detect=on,require=off" + steps = [f"qemu-img convert -p -f {source_format} -O qcow2 {shlex.quote(str(source_disk))} {shlex.quote(str(disk_path))}"] + if disk_size * 1024 ** 3 > disk_image_virtual_size(source_disk): + # cloud-init grows the root file system to the new size on first boot. + steps.append(f"qemu-img resize {shlex.quote(str(disk_path))} {disk_size}G") + virt = cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus={disk_bus}", "--osinfo", osinfo, "--network", network_arg, *graphics] + if use_cloud_init: + try: + password_hash = cloudinit.hash_password(ci_password) if ci_password else "" + except RuntimeError as exc: + return vm_form_context(request, error=f"Не удалось подготовить пароль: {exc}", form=form, status_code=500) + user_file, meta_file = cloudinit.write_seed(name, cloudinit.build_user_data(name, ci_user, password_hash, ssh_keys or []), cloudinit.build_meta_data(name)) + seed_file = str(user_file) + virt += ["--cloud-init", f"user-data={user_file},meta-data={meta_file}"] + login_hint = f"Вход: {ci_user}" + (" по паролю" if password_hash else "") + (" и по SSH-ключу" if ssh_keys else "") + steps.append(" ".join(shlex.quote(part) for part in virt)) + cmd = ["bash", "-lc", "set -euo pipefail; " + "; ".join(steps)] 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"] + cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics] + success_message = "Машина создана" + (f". {login_hint}" if login_hint else "") + (f". Адрес в сети машин: {nat_ip}" if nat_ip else "") operation_id = str(uuid.uuid4()) - operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "host_profile": profile.get("profile"), "guest_arch": selected_arch, "boot_order": selected_boot_order, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "cmd": " ".join(cmd)} + operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "host_profile": profile.get("profile"), "guest_arch": selected_arch, "boot_order": selected_boot_order, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "os_type": os_type, "cloud_init_user": ci_user if use_cloud_init else "", "nat_ip": nat_ip or "", "seed_file": seed_file, "success_message": success_message, "cmd": " ".join(cmd)} start_background_operation(operation, cmd) return RedirectResponse(url=f"/operations/{operation_id}", status_code=303) @@ -1883,7 +1920,8 @@ async def console_websocket(websocket: WebSocket, token: str): except Exception: await websocket.close(code=1008) return - if not valid_vm_name(vm_name) or not vm_exists(vm_name) or target_port < 5900 or target_port > 5999: + # vm_exists runs virsh: in a thread, so one slow call does not freeze every console and page. + if not valid_vm_name(vm_name) or target_port < 5900 or target_port > 5999 or not await asyncio.to_thread(vm_exists, vm_name): await websocket.close(code=1008) return await websocket.accept() @@ -1921,11 +1959,11 @@ def vm_detail_page(request: Request, name: str): @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")): +def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...), vcpus: int = Form(...)): auth_redirect = require_auth(request) if auth_redirect: return auth_redirect - ok, message = apply_vm_resources(name, memory_mb, vcpus, guest_arch) + ok, message = apply_vm_resources(name, memory_mb, vcpus) if ok: return redirect_with_message(f"/vm/{name}", "resource_message", message) return redirect_with_message(f"/vm/{name}", "resource_error", message) @@ -1964,6 +2002,23 @@ def vm_iso_unmount_apply(request: Request, name: str): return redirect_with_message(f"/vm/{name}", "iso_error", message) +VIRSH_ERRORS = { + "Domain is already active": "Машина уже запущена", + "domain is not running": "Машина не запущена", + "Domain not found": "Машина не найдена", + "Requested operation is not valid: domain is not running": "Машина не запущена", +} + + +def virsh_error(result: dict[str, Any]) -> str: + text = (result.get("stderr") or result.get("stdout") or "virsh завершился с ошибкой").strip() + text = re.sub(r"^error: ", "", text, flags=re.M) + for needle, human in VIRSH_ERRORS.items(): + if needle.lower() in text.lower(): + return human + return text.splitlines()[0][:300] if text else "virsh завершился с ошибкой" + + @app.post("/vm/{name}/{action}") def vm_action(request: Request, name: str, action: str): auth_redirect = require_auth(request) @@ -1972,13 +2027,22 @@ def vm_action(request: Request, name: str, action: str): if not valid_vm_name(name): return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400) allowed = {"start": ["virsh", "start", name], "shutdown": ["virsh", "shutdown", name], "reboot": ["virsh", "reboot", name], "destroy": ["virsh", "destroy", name], "autostart": ["virsh", "autostart", name], "autostart-disable": ["virsh", "autostart", "--disable", name]} + if action in ("shutdown", "destroy", "delete"): + backups.cancel_late_restart(name) if action == "delete": - run_cmd(["virsh", "destroy", name], timeout=20) - run_cmd(["virsh", "undefine", name, "--remove-all-storage"], timeout=60) - return RedirectResponse(url="/", status_code=303) + if vm_state(name) == "running": + run_cmd(["virsh", "destroy", name], timeout=20) + # --nvram: UEFI machines keep firmware variables that block a plain undefine. + result = run_cmd(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120) + if not result["ok"]: + return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {virsh_error(result)}") + network_core.release_nat_address(name) + return redirect_with_message("/", "message", f"Машина {name} удалена") if action not in allowed: return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400) - run_cmd(allowed[action], timeout=30) + result = run_cmd(allowed[action], timeout=30) + if not result["ok"]: + return redirect_with_message(f"/vm/{name}", "error", virsh_error(result)) return RedirectResponse(url=f"/vm/{name}", status_code=303) @@ -1996,7 +2060,7 @@ def live_status(request: Request): 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 "—", "autostart_enabled": vm.get("autostart_enabled", False), "autostart_label": vm.get("autostart_label", "unknown"), "autostart_css": vm.get("autostart_css", "warn")}) - 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)}) + return JSONResponse({"ok": True, "generated_at": utc_now(), "vms": vms, "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "web": service_state("virtuality-web.service")}, "operations": list_operations(5)}) @app.get("/live/operations") @@ -2015,4 +2079,22 @@ def healthz(): def api_health(request: Request): if not get_current_user(request): return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) - return {"system": system_summary(), "host_profile": host_profile.load_host_profile(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "dashboard": service_state("virtuality-console-dashboard.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()} + return {"system": system_summary(), "host_profile": host_profile.load_host_profile(), "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "web": service_state("virtuality-web.service")}, "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "virtuality_nat": network_core.network_context()} + + +# Feature modules register template globals into app.templates, so they are imported once it exists. +from features import backups, downloads, settings, setup, snapshots, vmops # noqa: E402 + +@app.on_event("startup") +def close_orphaned_operations() -> None: + import core + + core.interrupt_orphaned_operations() + + +app.include_router(setup.router) +app.include_router(settings.router) +app.include_router(snapshots.router) +app.include_router(backups.router) +app.include_router(downloads.router) +app.include_router(vmops.router) diff --git a/web/catalog.py b/web/catalog.py new file mode 100644 index 0000000..9d8da61 --- /dev/null +++ b/web/catalog.py @@ -0,0 +1,91 @@ +"""Catalog of ready-to-run cloud images for the quick VM flow. + +Each entry points to the vendor's official cloud image. Images boot in seconds +and are configured through cloud-init (user, password, SSH key) — no installer. +""" +from typing import Any + +# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13. +CLOUD_IMAGES: list[dict[str, Any]] = [ + { + "id": "ubuntu-26.04", + "title": "Ubuntu Server 26.04 LTS", + "vendor": "Ubuntu", + "hint": "Самая свежая LTS, поддержка до 2031 года. Рекомендуем.", + "osinfo": "ubuntu24.04", + "recommended": True, + "login": "ubuntu", + "arches": { + "x86_64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"}, + "aarch64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"}, + }, + }, + { + "id": "ubuntu-24.04", + "title": "Ubuntu Server 24.04 LTS", + "vendor": "Ubuntu", + "hint": "Проверенная LTS, поддержка до 2029 года.", + "osinfo": "ubuntu24.04", + "login": "ubuntu", + "arches": { + "x86_64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"}, + "aarch64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"}, + }, + }, + { + "id": "debian-13", + "title": "Debian 13", + "vendor": "Debian", + "hint": "Лёгкий и стабильный. Хорош для домашних сервисов.", + "osinfo": "debian13", + "login": "debian", + "arches": { + "x86_64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"}, + "aarch64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"}, + }, + }, + { + "id": "alpine-3.23", + "title": "Alpine Linux 3.23", + "vendor": "Alpine", + "hint": "Очень маленький: 200 МБ на диске, стартует мгновенно.", + "osinfo": "alpinelinux3.21", + "login": "alpine", + "arches": { + "x86_64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True}, + "aarch64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True}, + }, + }, +] + +# OS families for the "install from ISO" flow. virt-install picks the right +# virtual hardware from osinfo: Windows gets SATA/e1000e (works without extra +# drivers), win11 additionally gets UEFI + TPM. +OS_TYPES: list[dict[str, Any]] = [ + {"id": "linux", "title": "Linux", "hint": "Ubuntu, Debian, Fedora и другие", "osinfo": "detect=on,require=off"}, + {"id": "windows11", "title": "Windows 11 / Server 2025", "hint": "Нужны UEFI и TPM — включим сами", "osinfo": "win11"}, + {"id": "windows10", "title": "Windows 10 / Server 2019–2022", "hint": "Классическая загрузка", "osinfo": "win10"}, + {"id": "other", "title": "Другая система", "hint": "FreeBSD, старые ОС, экзотика", "osinfo": "generic"}, +] + + +def cloud_image(image_id: str) -> dict[str, Any] | None: + return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None) + + +def os_type(type_id: str) -> dict[str, Any]: + return next((item for item in OS_TYPES if item["id"] == type_id), OS_TYPES[0]) + + +def image_filename(entry: dict[str, Any], arch: str) -> str: + url = entry["arches"][arch]["url"] + return url.rsplit("/", 1)[-1] + + +def entry_for_filename(name: str) -> dict[str, Any] | None: + """Catalog entry whose download has this file name (any architecture).""" + for entry in CLOUD_IMAGES: + for arch in entry["arches"].values(): + if arch["url"].rsplit("/", 1)[-1] == name: + return entry + return None diff --git a/web/cloudinit.py b/web/cloudinit.py new file mode 100644 index 0000000..19e964a --- /dev/null +++ b/web/cloudinit.py @@ -0,0 +1,108 @@ +"""cloud-init seed for machines created from ready-made (cloud) images. + +Cloud images ship without a password: the first login is configured here — +user, password and/or SSH key — and virt-install attaches the seed as a small +CD-ROM (`--cloud-init user-data=…,meta-data=…`).""" +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +import catalog +import core + +USER_RE = re.compile(r"[a-z_][a-z0-9_-]{0,31}") +SSH_KEY_RE = re.compile(r"^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com) [A-Za-z0-9+/=]+( .*)?$") +RESERVED_USERS = {"root", "daemon", "bin", "sys", "nobody", "systemd-network", "syslog", "messagebus"} + + +def default_user(image_name: str) -> str: + entry = catalog.entry_for_filename(image_name) + return str(entry.get("login") or "admin") if entry else "admin" + + +def valid_user(name: str) -> bool: + return bool(USER_RE.fullmatch(name or "")) and name not in RESERVED_USERS + + +def parse_ssh_keys(text: str) -> list[str] | None: + """Public keys, one per line. None when a line is not a public key.""" + keys = [] + for line in (text or "").splitlines(): + line = line.strip() + if not line: + continue + if not SSH_KEY_RE.match(line) or len(line) > 4096: + return None + keys.append(line) + return keys + + +def hash_password(password: str) -> str: + """SHA-512 crypt hash: the seed CD-ROM stays readable on the server, so the password is never stored as text.""" + try: + result = subprocess.run(["openssl", "passwd", "-6", "-stdin"], input=password + "\n", capture_output=True, text=True, timeout=20, check=False) + except (OSError, subprocess.SubprocessError) as exc: + raise RuntimeError(f"openssl недоступен: {exc}") from exc + digest = result.stdout.strip() + if result.returncode != 0 or not digest.startswith("$6$"): + raise RuntimeError(result.stderr.strip() or "openssl passwd завершился с ошибкой") + return digest + + +def build_user_data(hostname: str, user: str, password_hash: str, ssh_keys: list[str]) -> str: + lines = [ + "#cloud-config", + f"hostname: {hostname}", + "manage_etc_hosts: true", + "users:", + f" - name: {user}", + " sudo: ALL=(ALL) NOPASSWD:ALL", + " groups: [sudo, wheel, adm]", + " shell: /bin/bash", + " lock_passwd: false", + ] + if password_hash: + lines.append(f" passwd: '{password_hash}'") + if ssh_keys: + lines.append(" ssh_authorized_keys:") + lines += [f" - {key}" for key in ssh_keys] + lines += [ + f"ssh_pwauth: {'true' if password_hash else 'false'}", + "chpasswd:", + " expire: false", + "disable_root: true", + "growpart:", + " mode: auto", + " devices: ['/']", + "resize_rootfs: true", + # The guest agent lets the panel see the machine's address in any network and shut it down cleanly. + "package_update: true", + "packages: [qemu-guest-agent]", + "runcmd:", + " - [sh, -c, 'systemctl enable --now qemu-guest-agent 2>/dev/null || { rc-update add qemu-guest-agent default && rc-service qemu-guest-agent start; } || true']", + ] + return "\n".join(lines) + "\n" + + +def build_meta_data(name: str) -> str: + return f"instance-id: virtuality-{name}\nlocal-hostname: {name}\n" + + +def write_seed(name: str, user_data: str, meta_data: str) -> tuple[Path, Path]: + """Files for virt-install --cloud-init; the folder is removed once the machine is created.""" + root = core.STORAGE_DIR / "tmp" + root.mkdir(parents=True, exist_ok=True) + folder = Path(tempfile.mkdtemp(prefix=f"cloudinit-{name}-", dir=str(root))) + user_file, meta_file = folder / "user-data", folder / "meta-data" + user_file.write_text(user_data) + meta_file.write_text(meta_data) + user_file.chmod(0o600) + return user_file, meta_file + + +def remove_seed(user_file: Path | str) -> None: + folder = Path(user_file).parent + if folder.name.startswith("cloudinit-"): + shutil.rmtree(folder, ignore_errors=True) diff --git a/web/core.py b/web/core.py new file mode 100644 index 0000000..4393ec8 --- /dev/null +++ b/web/core.py @@ -0,0 +1,363 @@ +"""Shared primitives of the Virtuality panel: settings, auth, command runner, +background operations store, templates. Feature modules import from here; +app.py re-exports the same names for backwards compatibility.""" +import json +import os +import re +import subprocess +import threading +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Callable +from urllib.parse import quote, urlsplit + +from fastapi import Request +from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from itsdangerous import BadSignature, URLSafeSerializer, URLSafeTimedSerializer + +import auth +import presenters +import update_core + +BASE_DIR = Path(__file__).resolve().parent +APP_NAME = "Virtuality" +ENV_FILE = BASE_DIR / ".env" +# virsh, ip and qemu-img print translated text under a Russian locale; the panel parses their output. +os.environ["LC_ALL"] = "C.UTF-8" +os.environ["LANG"] = "C.UTF-8" + +STORAGE_DIR = Path("/var/lib/virtuality") +ISO_DIR = Path("/var/lib/virtuality/iso") +IMAGES_DIR = Path("/var/lib/virtuality/images") +DISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images") +BACKUPS_DIR = Path("/var/lib/virtuality/backups") +CONFIG_DIR = Path("/var/lib/virtuality/config") +OPERATIONS_DIR = Path("/var/log/virtuality/operations") +DEFAULT_BRIDGE = "br0" +IMAGES_POOL = "virtuality-images" +ISO_POOL = "virtuality-iso" + +OP_LOCK = threading.Lock() + + +def load_env() -> dict[str, str]: + data: dict[str, str] = {} + if ENV_FILE.exists(): + for raw_line in ENV_FILE.read_text().splitlines(): + line = raw_line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + data[key.strip()] = value.strip().strip('"').strip("'") + return data + + +CONFIG = load_env() +AUTH_USER = CONFIG.get("VIRTUALITY_AUTH_USER", os.environ.get("VIRTUALITY_AUTH_USER", "root")) +SESSION_SECRET = CONFIG.get("VIRTUALITY_SESSION_SECRET", os.environ.get("VIRTUALITY_SESSION_SECRET", "dev-secret-change-me")) +COOKIE_SECURE = CONFIG.get("VIRTUALITY_COOKIE_SECURE", os.environ.get("VIRTUALITY_COOKIE_SECURE", "0")) == "1" +SESSION_MAX_AGE = 60 * 60 * 12 +login_throttle = auth.LoginThrottle() +serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="virtuality-session") +console_serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-console") + + +def is_configured() -> bool: + return bool(AUTH_USER and SESSION_SECRET != "dev-secret-change-me") + + +def read_version() -> str: + for path in (BASE_DIR / "VERSION", BASE_DIR.parent / "VERSION"): + try: + return path.read_text().strip() or "unknown" + except OSError: + continue + return "unknown" + + +APP_VERSION = read_version() + + +# ---------------------------------------------------------------- templates +def update_notice() -> dict[str, Any]: + try: + data = json.loads((update_core.STATE_DIR / "last_check.json").read_text()) + except Exception: + return {"has_update": False} + return {"has_update": bool(data.get("has_update")), "latest_version": data.get("latest_version", "")} + + +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) +templates.env.globals.update( + update_notice=update_notice, + vm_presets=presenters.VM_PRESETS, + port_presets=presenters.PORT_PRESETS, + plural=presenters.plural, + app_version=APP_VERSION, + app_name=APP_NAME, +) +templates.env.filters.update( + vm_state=presenters.vm_state, + operation_state=presenters.operation_state, + service_state=presenters.service_state, + format_mb=presenters.format_mb, + format_bytes=presenters.format_bytes, + level_tone=presenters.level_tone, + port_service=presenters.port_service, +) + + +def render(request: Request, template: str, context: dict[str, Any] | None = None, status_code: int = 200, **extra: Any): + data = {"request": request, "app_name": APP_NAME, "user": AUTH_USER} + data.update(context or {}) + data.update(extra) + return templates.TemplateResponse(template, data, status_code=status_code) + + +# ---------------------------------------------------------------- auth +def verify_linux_password(username: str, password: str) -> bool: + if username != AUTH_USER: + return False + return auth.verify_password(username, password) + + +def user_from_session_token(token: str | None) -> str | None: + if not token: + return None + try: + data = serializer.loads(token, max_age=SESSION_MAX_AGE) + except BadSignature: + return None + if not isinstance(data, dict): + return None + return AUTH_USER if data.get("user") == AUTH_USER else None + + +def get_current_user(request: Request) -> str | None: + return user_from_session_token(request.cookies.get("virtuality_session")) + + +def require_auth(request: Request): + """Return a redirect to /login for anonymous requests, otherwise None.""" + if not get_current_user(request): + return RedirectResponse(url="/login", status_code=303) + return None + + +def require_api_auth(request: Request): + """JSON variant of require_auth for /api and /live endpoints.""" + if not get_current_user(request): + return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) + return None + + +def client_key(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def same_origin(request: Request) -> bool: + origin = request.headers.get("origin") or request.headers.get("referer") + if not origin or origin == "null": + # Browsers always send Origin on cross-site form posts; tools like curl do not. + return origin != "null" + origin_host = urlsplit(origin).netloc.lower() + allowed = {request.headers.get("host", "").lower()} + forwarded = request.headers.get("x-forwarded-host") + if forwarded: + allowed.update(item.strip().lower() for item in forwarded.split(",")) + return origin_host in allowed + + +def redirect_with_message(path: str, key: str, message: str) -> RedirectResponse: + return RedirectResponse(url=f"{path}?{key}={quote(message)}", status_code=303) + + +# ---------------------------------------------------------------- commands +def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) + return {"ok": result.returncode == 0, "code": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "cmd": " ".join(cmd)} + except Exception as exc: + return {"ok": False, "code": -1, "stdout": "", "stderr": str(exc), "cmd": " ".join(cmd)} + + +def cmd_error(result: dict[str, Any], fallback: str) -> str: + """Best human-readable error text from a run_cmd result.""" + text = (result.get("stderr") or result.get("stdout") or "").strip() + text = re.sub(r"^error:\s*", "", text, flags=re.IGNORECASE) + return text.splitlines()[0] if text else fallback + + +def utc_now() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def tail_text(path: Path, max_lines: int = 220) -> str: + if not path.exists(): + return "" + try: + return "\n".join(path.read_text(errors="replace").splitlines()[-max_lines:]) + except OSError: + return "" + + +def valid_vm_name(name: str) -> bool: + """Latin letters, digits, dot, dash, underscore; never a bare number or a UUID — virsh would read those as an ID/UUID of another machine.""" + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""): + return False + return not name.isdigit() and not re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", name) + + +def valid_label(name: str) -> bool: + """Snapshot, backup and similar user-chosen names.""" + return bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,62}", name or "")) + + +def vm_exists(name: str) -> bool: + return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"] + + +def vm_state(name: str) -> str: + result = run_cmd(["virsh", "domstate", name], timeout=8) + return (result.get("stdout") or "unknown").strip().lower() + + +def vm_is_running(name: str) -> bool: + return vm_state(name) in ("running", "idle", "blocked", "paused", "pmsuspended", "in shutdown") + + +# ---------------------------------------------------------------- operations +def operation_meta_path(operation_id: str) -> Path: + return OPERATIONS_DIR / f"{operation_id}.json" + + +def operation_log_path(operation_id: str) -> Path: + return OPERATIONS_DIR / f"{operation_id}.log" + + +def ensure_operations_dir() -> None: + OPERATIONS_DIR.mkdir(parents=True, exist_ok=True) + + +def write_operation(operation: dict[str, Any]) -> None: + ensure_operations_dir() + path = operation_meta_path(operation["id"]) + tmp_path = path.with_suffix(".json.tmp") + with OP_LOCK: + tmp_path.write_text(json.dumps(operation, ensure_ascii=False, indent=2)) + tmp_path.replace(path) + + +def read_operation(operation_id: str) -> dict[str, Any] | None: + if not re.fullmatch(r"[a-f0-9-]{36}", operation_id or ""): + return None + path = operation_meta_path(operation_id) + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + except Exception: + return None + data["log_tail"] = tail_text(operation_log_path(operation_id)) + return data + + +def list_operations(limit: int = 25) -> list[dict[str, Any]]: + ensure_operations_dir() + operations: list[dict[str, Any]] = [] + for path in sorted(OPERATIONS_DIR.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True): + try: + data = json.loads(path.read_text()) + data["log_tail"] = tail_text(operation_log_path(data["id"]), max_lines=20) + operations.append(data) + except Exception: + continue + if len(operations) >= limit: + break + return operations + + +def interrupt_orphaned_operations() -> int: + """Operations run in threads of the panel process: after a restart the ones still marked + queued/running will never finish. Close them so they do not block the next attempt.""" + if not OPERATIONS_DIR.exists(): + return 0 + count = 0 + for path in OPERATIONS_DIR.glob("*.json"): + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + continue + if data.get("status") not in ("queued", "running"): + continue + data.pop("log_tail", None) + message = "Прервано: панель перезапускалась во время операции. Проверьте состояние машины и запустите действие снова." + update_operation(data, status="error", progress=100, message=message, finished_at=utc_now(), interrupted=True) + append_operation_log(data["id"], message) + count += 1 + return count + + +def active_operations_for(vm_name: str, kinds: tuple[str, ...] | set[str] | None = None) -> list[dict[str, Any]]: + """Unfinished operations of one machine, over the whole operations directory (not only the latest N).""" + ensure_operations_dir() + found = [] + for path in OPERATIONS_DIR.glob("*.json"): + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + continue + if data.get("vm_name") == vm_name and data.get("status") in ("queued", "running") and (kinds is None or data.get("type") in kinds): + found.append(data) + return found + + +def running_operations(kind: str | None = None) -> list[dict[str, Any]]: + return [op for op in list_operations(50) if op.get("status") in ("queued", "running") and (kind is None or op.get("type") == kind)] + + +def append_operation_log(operation_id: str, message: str) -> None: + ensure_operations_dir() + with operation_log_path(operation_id).open("a", encoding="utf-8") as handle: + handle.write(f"[{utc_now()}] {message.rstrip()}\n") + + +def operation_css(status: str) -> str: + return "ok" if status == "success" else "err" if status == "error" else "warn" + + +def update_operation(operation: dict[str, Any], **changes: Any) -> None: + operation.update(changes) + operation["updated_at"] = utc_now() + write_operation(operation) + + +def new_operation(kind: str, title: str, message: str = "Операция поставлена в очередь", **meta: Any) -> dict[str, Any]: + operation = {"id": str(uuid.uuid4()), "type": kind, "title": title, "status": "queued", "progress": 0, "message": message, "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER} + operation.update(meta) + write_operation(operation) + append_operation_log(operation["id"], "Операция поставлена в очередь.") + return operation + + +def finish_operation(operation: dict[str, Any], ok: bool, message: str, **meta: Any) -> None: + fresh = read_operation(operation["id"]) or operation + fresh.pop("log_tail", None) + update_operation(fresh, status="success" if ok else "error", progress=100, message=message, finished_at=utc_now(), **meta) + append_operation_log(operation["id"], message) + + +def run_operation(operation: dict[str, Any], worker: Callable[[dict[str, Any]], None]) -> None: + """Run worker(operation) in a daemon thread; the worker reports progress via + update_operation/append_operation_log and must call finish_operation.""" + + def runner() -> None: + try: + update_operation(operation, status="running", started_at=utc_now()) + worker(operation) + except Exception as exc: # noqa: BLE001 — surface any failure to the user + append_operation_log(operation["id"], f"Ошибка: {exc}") + finish_operation(operation, False, str(exc)[:240]) + + threading.Thread(target=runner, daemon=True).start() diff --git a/web/features/__init__.py b/web/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/features/backups.py b/web/features/backups.py new file mode 100644 index 0000000..e0a1a75 --- /dev/null +++ b/web/features/backups.py @@ -0,0 +1,867 @@ +"""Резервные копии виртуальных машин. + +Копия лежит в BACKUPS_DIR/<машина>/<ГГГГММДД-ЧЧММ>/ и состоит из vm.xml +(настройки), <диск>.qcow2 на каждый диск и meta.json. Создание и +восстановление выполняются как фоновые операции с прогрессом и журналом. + +`create_backup(vm_name, note)` можно вызывать без HTTP — например, из +расписания на сервере (зависимости панели лежат в её virtualenv): +`/opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия"`. +""" +import json +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import xml.etree.ElementTree as ET +from datetime import datetime +from pathlib import Path +from typing import Any, Callable + +if __name__ == "__main__": # запуск как скрипта: нужен web/ в sys.path + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from fastapi import APIRouter, Form, Request # noqa: E402 +from fastapi.responses import RedirectResponse # noqa: E402 + +import core # noqa: E402 +import presenters # noqa: E402 + +router = APIRouter() + +BACKUP_ID_RE = re.compile(r"\d{8}-\d{4}(?:-\d{1,2})?") # ГГГГММДД-ЧЧММ, при второй копии в ту же минуту — суффикс -2, -3… +TARGET_RE = re.compile(r"[a-z]{2,3}[a-z0-9]{0,8}") # имя диска в meta.json: vda, sdb, hda… +OPERATION_TYPES = ("backup", "restore") +SHUTDOWN_TIMEOUT = 180.0 # секунд ждём мягкого выключения +LATE_SHUTDOWN_WATCH = 600.0 # секунд после отказа ещё следим: если машина выключится позже, запустим её снова +SAVE_TIMEOUT = 1800 # секунд на сохранение памяти машины на паузе +COPY_TIMEOUT = 12 * 3600.0 # секунд на копирование одного диска — дольше только зависший qemu-img +POLL_INTERVAL = 2.0 +SPACE_MARGIN = 256 * 1024 * 1024 # запас, чтобы не забить диск под завязку +NOTE_MAX = 120 +MONTHS = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря") +BACKUP_ID_LOCK = threading.Lock() + + +class BackupError(Exception): + """Ошибка, текст которой можно показать пользователю как есть.""" + + def __init__(self, message: str, code: str = "") -> None: + super().__init__(message) + self.code = code + + +# ---------------------------------------------------------------- helpers +def run(cmd: list[str], timeout: int = 12) -> dict[str, Any]: + return core.run_cmd(cmd, timeout=timeout) + + +def stream_cmd(cmd: list[str], on_line: Callable[[str], None], timeout: float = COPY_TIMEOUT) -> int: + """Запустить команду и отдавать каждую строку вывода (по \\r и \\n) в on_line. + + qemu-img с ключом -p печатает прогресс через \\r, поэтому обычное чтение + по строкам не подходит. Команду, не уложившуюся в timeout, убиваем, чтобы + зависший qemu-img не держал задачу «в работе» вечно. Возвращает код завершения.""" + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + expired = threading.Event() + + def kill() -> None: + expired.set() + process.kill() + + timer = threading.Timer(timeout, kill) + timer.start() + pending = b"" + assert process.stdout is not None + try: + while True: + chunk = process.stdout.read(512) + if not chunk: + break + pending += chunk + parts = re.split(rb"[\r\n]+", pending) + pending = parts.pop() + for part in parts: + text = part.decode("utf-8", "replace").strip() + if text: + on_line(text) + tail = pending.decode("utf-8", "replace").strip() + if tail: + on_line(tail) + code = process.wait() + finally: + timer.cancel() + if expired.is_set(): + on_line(f"Команда не завершилась за отведённое время ({int(timeout)} с) и была остановлена.") + return code + + +def start_thread(target: Callable[..., None], *args: Any) -> None: + threading.Thread(target=target, args=args, daemon=True).start() + + +def parse_progress(line: str) -> int | None: + """' (12.34/100%)' → 12. None, если в строке нет прогресса qemu-img.""" + match = re.search(r"\((\d+(?:\.\d+)?)/100%\)", line or "") + return min(100, int(float(match.group(1)))) if match else None + + +def parse_qemu_img_info(text: str) -> dict[str, Any]: + """Вывод `qemu-img info --output=json` → {virtual_size, actual_size, format}.""" + empty = {"virtual_size": 0, "actual_size": 0, "format": ""} + try: + data = json.loads(text or "") + except ValueError: + return empty + if not isinstance(data, dict): + return empty + try: + return {"virtual_size": int(data.get("virtual-size") or 0), "actual_size": int(data.get("actual-size") or 0), "format": str(data.get("format") or "")} + except (TypeError, ValueError): + return empty + + +def disk_info(path: str) -> dict[str, Any]: + # -U: диск работающей машины занят QEMU, без этого ключа qemu-img info отказывает («Failed to get shared write lock»). + result = run(["qemu-img", "info", "-U", "--output=json", path], timeout=30) + if not result["ok"]: + raise BackupError(f"Не удалось определить размер диска {path}: " + core.cmd_error(result, "qemu-img info")) + return parse_qemu_img_info(result["stdout"]) + + +def vm_disks(name: str) -> list[dict[str, str]]: + """Диски машины (без приводов CD/DVD) из `virsh domblklist --details`.""" + result = run(["virsh", "domblklist", name, "--details"], timeout=10) + if not result["ok"]: + raise BackupError("Не удалось получить список дисков: " + core.cmd_error(result, "virsh domblklist")) + return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "disk" and disk["source"]] + + +def vm_cdroms_with_media(name: str) -> list[dict[str, str]]: + result = run(["virsh", "domblklist", name, "--details"], timeout=10) + if not result["ok"]: + return [] + return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "cdrom" and disk["source"]] + + +def existing_parent(path: Path) -> Path: + """Сам путь или ближайший существующий родитель — чтобы спросить у ФС о разделе.""" + target = path + while not target.exists() and target != target.parent: + target = target.parent + return target + + +def free_bytes(path: Path) -> int: + try: + return shutil.disk_usage(existing_parent(path)).free + except OSError: + return 0 + + +def total_bytes(path: Path) -> int: + try: + return shutil.disk_usage(existing_parent(path)).total + except OSError: + return 0 + + +def format_bytes(value: int | float) -> str: + return presenters.format_bytes(value) + + +def format_backup_date(backup_id: str) -> str: + try: + moment = datetime.strptime((backup_id or "")[:13], "%Y%m%d-%H%M") + except ValueError: + return backup_id + return f"{moment.day} {MONTHS[moment.month - 1]} {moment.year}, {moment:%H:%M}" + + +def clean_note(note: str) -> str: + return re.sub(r"[\x00-\x1f\x7f]+", " ", note or "").strip()[:NOTE_MAX] + + +def existing_vm_names() -> set[str]: + result = run(["virsh", "list", "--all", "--name"], timeout=10) + return {line.strip() for line in (result.get("stdout") or "").splitlines() if line.strip()} if result["ok"] else set() + + +# ---------------------------------------------------------------- storage layout +def backup_root() -> Path: + return core.BACKUPS_DIR + + +def backup_path(vm: str, backup_id: str) -> Path | None: + """Папка копии или None, если имя машины/копии некорректно или уводит из BACKUPS_DIR.""" + if not core.valid_vm_name(vm) or not BACKUP_ID_RE.fullmatch(backup_id or ""): + return None + root = backup_root() + path = root / vm / backup_id + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return None + return path + + +def describe_backup(vm: str, backup_id: str, path: Path, meta: dict[str, Any]) -> dict[str, Any]: + listed = meta.get("disks") + listed = [disk for disk in listed if isinstance(disk, dict)] if isinstance(listed, list) else [] + # Имя диска попадает в пути файлов, поэтому испорченный meta.json не должен увести за пределы папок. + disks = [disk for disk in listed if TARGET_RE.fullmatch(str(disk.get("target") or ""))] + size = int(meta.get("size_bytes") or 0) + complete = (path / "vm.xml").exists() and bool(disks) and len(disks) == len(listed) and all((path / f"{disk['target']}.qcow2").exists() for disk in disks) + return { + "vm": vm, + "id": backup_id, + "path": str(path), + "created_at": meta.get("created_at") or "", + "created_label": format_backup_date(backup_id), + "note": clean_note(str(meta.get("note") or "")), + "disks": disks, + "disk_count": len(disks), + "size_bytes": size, + "size": format_bytes(size), + "version": str(meta.get("version") or ""), + "complete": complete, + } + + +def read_backup(vm: str, backup_id: str) -> dict[str, Any] | None: + path = backup_path(vm, backup_id) + if not path or not (path / "meta.json").is_file(): + return None + try: + meta = json.loads((path / "meta.json").read_text()) + except (OSError, ValueError): + return None + if not isinstance(meta, dict): + return None + return describe_backup(vm, backup_id, path, meta) + + +def list_backups(vm: str | None = None) -> list[dict[str, Any]]: + """Все копии (или копии одной машины), новые сверху.""" + root = backup_root() + if not root.is_dir(): + return [] + if vm is not None: + vm_dirs = [root / vm] if core.valid_vm_name(vm) else [] + else: + vm_dirs = sorted(item for item in root.iterdir() if item.is_dir() and core.valid_vm_name(item.name)) + items: list[dict[str, Any]] = [] + for vm_dir in vm_dirs: + if not vm_dir.is_dir(): + continue + for item in vm_dir.iterdir(): + if item.is_dir() and BACKUP_ID_RE.fullmatch(item.name): + backup = read_backup(vm_dir.name, item.name) + if backup: + items.append(backup) + items.sort(key=lambda item: (item["id"], item["vm"]), reverse=True) + return items + + +def group_by_vm(items: list[dict[str, Any]], existing: set[str] | None = None) -> list[dict[str, Any]]: + groups: dict[str, dict[str, Any]] = {} + for item in items: + group = groups.setdefault(item["vm"], {"vm": item["vm"], "backups": [], "size_bytes": 0, "exists": existing is None or item["vm"] in existing}) + group["backups"].append(item) + group["size_bytes"] += item["size_bytes"] + for group in groups.values(): + group["size"] = format_bytes(group["size_bytes"]) + group["count"] = len(group["backups"]) + return sorted(groups.values(), key=lambda group: group["vm"]) + + +def storage_summary(items: list[dict[str, Any]] | None = None) -> dict[str, Any]: + items = list_backups() if items is None else items + used = sum(item["size_bytes"] for item in items) + free = free_bytes(backup_root()) + total = total_bytes(backup_root()) # доля именно от всего диска сервера, как и написано на странице + return { + "count": len(items), + "used_bytes": used, + "used": format_bytes(used), + "free_bytes": free, + "free": format_bytes(free), + "used_pct": int(round(used / total * 100)) if total else 0, + } + + +def remove_backup_dir(path: Path) -> None: + """Удалить папку копии; пустую папку машины над ней — тоже.""" + shutil.rmtree(path, ignore_errors=True) + parent = path.parent + try: + if parent != backup_root() and parent.is_dir() and not any(parent.iterdir()): + parent.rmdir() + except OSError: + pass + + +def unfinished_restores_of(vm: str, backup_id: str) -> list[dict[str, Any]]: + """Все незавершённые восстановления из этой копии — под любым именем машины (их может быть несколько).""" + found = [] + for path in core.OPERATIONS_DIR.glob("*.json") if core.OPERATIONS_DIR.exists() else []: + try: + operation = json.loads(path.read_text()) + except (OSError, ValueError): + continue + if operation.get("type") == "restore" and operation.get("status") in ("queued", "running") and operation.get("source_vm") == vm and operation.get("backup_id") == backup_id: + found.append(operation) + return found + + +def backup_in_use(vm: str, backup_id: str, path: Path) -> bool: + """Пишет ли сейчас эту копию задача копирования или читает задача восстановления.""" + if any(operation.get("backup_id") == backup_id for operation in core.active_operations_for(vm, OPERATION_TYPES)): + return True + return bool(unfinished_restores_of(vm, backup_id)) + + +def delete_backup(vm: str, backup_id: str) -> None: + path = backup_path(vm, backup_id) + if not path or not path.is_dir(): + raise BackupError("Копия не найдена — возможно, её уже удалили") + if backup_in_use(vm, backup_id, path): + raise BackupError("С этой копией сейчас идёт работа — дождитесь завершения задачи") + remove_backup_dir(path) + + +def active_operation(vm: str) -> dict[str, Any] | None: + """Идущая сейчас задача копирования/восстановления этой машины (по всей папке задач, а не последним N).""" + found = core.active_operations_for(vm, OPERATION_TYPES) + return found[0] if found else None + + +def reserve_backup_dir(vm_name: str) -> str: + """Занять папку под новую копию и вернуть её id. + + Папка создаётся здесь, атомарно и под замком, чтобы две задачи в одну минуту + получили разные папки (вторая — с суффиксом -2) и при ошибке каждая удаляла + только свою.""" + stamp = datetime.now().strftime("%Y%m%d-%H%M") + with BACKUP_ID_LOCK: + for counter in range(1, 100): + backup_id = stamp if counter == 1 else f"{stamp}-{counter}" + try: + (backup_root() / vm_name / backup_id).mkdir(parents=True, exist_ok=False) + except FileExistsError: + continue + return backup_id + raise BackupError("Слишком много копий этой машины за одну минуту. Подождите минуту и повторите.") + + +# ---------------------------------------------------------------- создание копии +def create_backup(vm_name: str, note: str = "", wait: bool = False) -> dict[str, Any]: + """Поставить создание копии в очередь и вернуть операцию. + + Проверки (машина есть, диски есть, места хватает) выполняются сразу и + поднимают BackupError с понятным текстом. С wait=True копия делается + в текущем потоке — удобно для расписания.""" + cancel_late_restart(vm_name) + if not core.valid_vm_name(vm_name): + raise BackupError("Некорректное имя машины") + note = clean_note(note) + if not core.vm_exists(vm_name): + raise BackupError(f"Машина {vm_name} не найдена") + if active_operation(vm_name): + raise BackupError("С этой машиной уже идёт копирование или восстановление — дождитесь завершения") + disks = vm_disks(vm_name) + if not disks: + raise BackupError("У машины нет дисков — копировать нечего") + plan: list[dict[str, Any]] = [] + need = 0 + for disk in disks: + info = disk_info(disk["source"]) + size = info["actual_size"] or info["virtual_size"] + need += size + plan.append({"target": disk["target"], "source": disk["source"], "size": size}) + free = free_bytes(backup_root()) + if free < need + SPACE_MARGIN: + raise BackupError(f"Недостаточно места для копии: нужно около {format_bytes(need)}, свободно {format_bytes(free)}. Удалите старые копии или освободите место на сервере.", "space") + backup_id = reserve_backup_dir(vm_name) + try: + operation = core.new_operation("backup", f"Резервная копия {vm_name}", "Ожидает начала", vm_name=vm_name, backup_id=backup_id, note=note, disks=plan, size_bytes=need) + except Exception: + remove_backup_dir(backup_root() / vm_name / backup_id) + raise + if wait: + core.update_operation(operation, status="running", started_at=core.utc_now()) + backup_worker(operation) + return core.read_operation(operation["id"]) or operation + core.run_operation(operation, backup_worker) + return operation + + +def disk_progress_reporter(operation: dict[str, Any], index: int, total: int, label: str) -> Callable[[str], None]: + """on_line для qemu-img: общий прогресс = 5…95 %, разложенный по дискам.""" + state = {"last": -1, "logged": -10} + + def on_line(line: str) -> None: + pct = parse_progress(line) + if pct is None: + core.append_operation_log(operation["id"], line) + return + overall = int(5 + (index + pct / 100) / total * 90) + if overall == state["last"]: + return + state["last"] = overall + core.update_operation(operation, progress=overall, message=f"{label}: {pct}%") + if pct - state["logged"] >= 10 or pct == 100: + state["logged"] = pct + core.append_operation_log(operation["id"], f"{label}: {pct}%") + + return on_line + + +def shutdown_and_wait(operation: dict[str, Any], vm: str) -> str: + """Мягко выключить машину. Возвращает пустую строку или текст проблемы для пользователя.""" + log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731 + log("Машина работает — отправляем команду выключения (как кнопка питания, система завершит работу сама).") + core.update_operation(operation, progress=2, message="Выключаем машину…") + result = run(["virsh", "shutdown", vm], timeout=20) + if not result["ok"]: + problem = core.cmd_error(result, "virsh shutdown") + log("Команда выключения не принята: " + problem) + return f"Машина не приняла команду выключения: {problem}. Выключите её вручную и создайте копию ещё раз." + deadline = time.monotonic() + SHUTDOWN_TIMEOUT + polls = 0 + while time.monotonic() < deadline: + time.sleep(POLL_INTERVAL) + polls += 1 + if not core.vm_is_running(vm): + log("Машина выключена.") + return "" + if polls % 5 == 0: + waited = int(polls * POLL_INTERVAL) + core.update_operation(operation, message=f"Ждём выключения машины… {waited} с из {int(SHUTDOWN_TIMEOUT)}") + log(f"Всё ещё работает, ждём ({waited} с).") + minutes = max(1, int(SHUTDOWN_TIMEOUT // 60)) + watch = max(1, int(LATE_SHUTDOWN_WATCH // 60)) + log(f"Машина не выключилась за {minutes} {presenters.plural(minutes, 'минуту', 'минуты', 'минут')} — прекращаем, но ещё {watch} {presenters.plural(watch, 'минуту', 'минуты', 'минут')} следим за ней.") + start_thread(restart_after_late_shutdown, operation, vm) + return ( + f"Машина не выключилась за {minutes} {presenters.plural(minutes, 'минуту', 'минуты', 'минут')}. Сохраните работу внутри неё, выключите её вручную и создайте копию ещё раз. " + f"Команда выключения уже отправлена: если машина выключится сама в ближайшие {watch} {presenters.plural(watch, 'минуту', 'минуты', 'минут')}, панель запустит её снова." + ) + + +LATE_WATCHES: dict[str, threading.Event] = {} + + +def cancel_late_restart(vm: str) -> None: + """Пользователь сам выключает машину или снова делает копию — наблюдатель не должен её запускать.""" + event = LATE_WATCHES.pop(vm, None) + if event: + event.set() + + +def restart_after_late_shutdown(operation: dict[str, Any], vm: str) -> None: + """Команда выключения уже ушла в машину: если она всё же выключится с опозданием, вернуть её в работу.""" + cancel_late_restart(vm) + cancelled = LATE_WATCHES.setdefault(vm, threading.Event()) + deadline = time.monotonic() + LATE_SHUTDOWN_WATCH + try: + while time.monotonic() < deadline: + if cancelled.wait(POLL_INTERVAL): + core.append_operation_log(operation["id"], "Наблюдение снято: машиной занялись вручную или новой задачей.") + return + if not core.vm_is_running(vm): + if any(other["id"] != operation["id"] for other in core.active_operations_for(vm, OPERATION_TYPES)): + core.append_operation_log(operation["id"], "Машина выключилась, но с ней уже работает другая задача — не запускаем.") + return + core.append_operation_log(operation["id"], "Машина всё-таки выключилась — запускаем её снова.") + start_vm(operation, vm) + return + core.append_operation_log(operation["id"], "Машина так и не выключилась — оставляем её работать.") + finally: + if LATE_WATCHES.get(vm) is cancelled: + LATE_WATCHES.pop(vm, None) + + +def save_paused(operation: dict[str, Any], vm: str) -> str: + """Машина на паузе не обработает команду выключения — сохраняем её память на диск (как спящий режим). + + После копирования `virsh start` восстановит её с того же места. Возвращает пустую строку или текст проблемы.""" + log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731 + log("Машина на паузе — сохраняем её состояние на диск сервера, после копирования она продолжит с того же места.") + core.update_operation(operation, progress=2, message="Сохраняем состояние машины…") + result = run(["virsh", "managedsave", vm], timeout=SAVE_TIMEOUT) + if not result["ok"]: + problem = core.cmd_error(result, "virsh managedsave") + log("Не удалось сохранить состояние: " + problem) + return f"Машина на паузе, и сохранить её состояние не удалось: {problem}. Снимите её с паузы или выключите и создайте копию ещё раз." + log("Состояние сохранено, машина остановлена.") + return "" + + +def stop_for_backup(operation: dict[str, Any], vm: str) -> str: + """Остановить работающую машину перед копированием; вернуть текст проблемы или пустую строку.""" + if core.vm_state(vm) == "paused": + return save_paused(operation, vm) + return shutdown_and_wait(operation, vm) + + +def start_vm(operation: dict[str, Any], vm: str) -> str: + """Запустить машину обратно; вернуть текст проблемы или пустую строку.""" + result = run(["virsh", "start", vm], timeout=40) + if result["ok"]: + core.append_operation_log(operation["id"], "Машина запущена снова.") + return "" + problem = core.cmd_error(result, "virsh start") + core.append_operation_log(operation["id"], "Не удалось запустить машину: " + problem) + return problem + + +def backup_worker(operation: dict[str, Any]) -> None: + vm = operation["vm_name"] + plan = operation["disks"] + log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731 + dest = backup_root() / vm / operation["backup_id"] # папка занята ещё в create_backup, поэтому она точно наша + was_running = core.vm_is_running(vm) + if was_running: + problem = stop_for_backup(operation, vm) + if problem: + remove_backup_dir(dest) + core.finish_operation(operation, False, problem) + return + size = 0 + try: + dest.mkdir(parents=True, exist_ok=True) + result = run(["virsh", "dumpxml", vm, "--migratable"], timeout=20) + if not result["ok"]: + raise BackupError("Не удалось сохранить настройки машины: " + core.cmd_error(result, "virsh dumpxml")) + (dest / "vm.xml").write_text(result["stdout"].rstrip() + "\n", encoding="utf-8") + log("Настройки машины сохранены в vm.xml.") + total = len(plan) + for index, disk in enumerate(plan): + target_file = dest / f"{disk['target']}.qcow2" + label = f"Копируем диск {disk['target']} ({index + 1} из {total})" + core.update_operation(operation, progress=int(5 + index / total * 90), message=label) + log(f"{label}: {disk['source']} → {target_file}") + cmd = ["qemu-img", "convert", "-p", "-O", "qcow2", "-c", disk["source"], str(target_file)] + log(" ".join(cmd)) + code = stream_cmd(cmd, disk_progress_reporter(operation, index, total, label)) + if code != 0: + log(f"qemu-img завершился с кодом {code}.") + raise BackupError(f"Не удалось скопировать диск {disk['target']} — подробности в журнале задачи") + size = sum(item.stat().st_size for item in dest.iterdir() if item.is_file()) + meta = {"vm": vm, "created_at": core.utc_now(), "disks": plan, "size_bytes": size, "version": core.APP_VERSION, "note": operation.get("note", "")} + (dest / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + log(f"Копия готова: {format_bytes(size)} (сжатие включено).") + except Exception as exc: # noqa: BLE001 — любая ошибка = операция с понятным текстом + remove_backup_dir(dest) + log(f"Ошибка: {exc}") + problem = start_vm(operation, vm) if was_running else "" + message = str(exc)[:200] + if problem: + message += f". Машину тоже не удалось запустить: {problem}" + core.finish_operation(operation, False, message) + return + problem = start_vm(operation, vm) if was_running else "" + message = f"Резервная копия создана: {format_bytes(size)}" + if problem: + message += f". Но машину не удалось запустить снова: {problem}" + core.finish_operation(operation, True, message, size_bytes=size) + + +# ---------------------------------------------------------------- восстановление +def rewrite_xml(xml_text: str, new_name: str, disk_map: dict[str, str], keep_identity: bool = False) -> str: + """Подготовить XML копии к `virsh define` под именем new_name. + + Меняет , убирает id, переключает диски на новые файлы (без + — копии сплошные, а старая цепочка могла указывать на диски + удалённой машины), вынимает из привода образ, которого больше нет на + сервере, и удаляет (libvirt заведёт свой файл под новое имя). + Без keep_identity убирает и с , чтобы вторая машина не мешала + оригиналу; при замене оригинала (keep_identity) их лучше оставить — гость + не увидит «новую» сетевую карту и не потребует повторной активации.""" + root = ET.fromstring(xml_text) + root.attrib.pop("id", None) + name = root.find("name") + if name is None: + name = ET.Element("name") + root.insert(0, name) + name.text = new_name + if not keep_identity: + for element in root.findall("uuid"): + root.remove(element) + devices = root.find("devices") + if devices is not None: + for disk in devices.findall("disk"): + target = disk.find("target") + dev = target.get("dev") if target is not None else None + source = disk.find("source") + if disk.get("device") == "cdrom": + if source is not None and source.get("file") and not Path(source.get("file", "")).is_file(): + disk.remove(source) + continue + if disk.get("device") != "disk" or dev not in disk_map: + continue + if source is None: + source = ET.SubElement(disk, "source") + source.attrib.clear() + source.set("file", disk_map[dev]) + disk.set("type", "file") + driver = disk.find("driver") + if driver is None: + driver = ET.SubElement(disk, "driver") + driver.set("name", "qemu") + driver.set("type", "qcow2") + for chain in disk.findall("backingStore"): + disk.remove(chain) + if not keep_identity: + for interface in devices.findall("interface"): + for mac in interface.findall("mac"): + interface.remove(mac) + os_element = root.find("os") + if os_element is not None: + for nvram in os_element.findall("nvram"): + os_element.remove(nvram) + return ET.tostring(root, encoding="unicode") + + +def restore_disk_path(new_name: str, target: str, index: int) -> Path: + images = core.IMAGES_DIR + candidates = [images / f"{new_name}.qcow2"] if index == 0 else [] + candidates.append(images / f"{new_name}-{target}.qcow2") + for candidate in candidates: + if not candidate.exists(): + return candidate + counter = 2 + while (images / f"{new_name}-{target}-{counter}.qcow2").exists(): + counter += 1 + return images / f"{new_name}-{target}-{counter}.qcow2" + + +def restore_temp_path(new_name: str, operation_id: str, target: str) -> Path: + """Куда распаковывать диск, пока он не готов: <машина>.restore--<диск>.qcow2 рядом с остальными.""" + return core.IMAGES_DIR / f"{new_name}.restore-{operation_id[:8]}-{target}.qcow2" + + +def plan_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict[str, Any]: + backup = read_backup(vm, backup_id) + if not backup: + raise BackupError("Копия не найдена") + if not backup["complete"]: + raise BackupError("Копия неполная — не хватает файлов. Восстановить из неё нельзя.") + new_name = (new_name or "").strip() or vm + if not core.valid_vm_name(new_name): + raise BackupError("Название машины: латинские буквы, цифры, точка, дефис, от 2 до 63 символов", "name") + exists = core.vm_exists(new_name) + if exists and not replace: + raise BackupError(f"Машина {new_name} уже есть на сервере. Укажите другое название или подтвердите замену.", "exists") + if active_operation(new_name) or active_operation(vm): + raise BackupError("С этой машиной уже идёт копирование или восстановление — дождитесь завершения") + need = sum(int(disk.get("size") or 0) for disk in backup["disks"]) + # Диски старой машины не считаем освобождающимися: копия распаковывается рядом с ними, а старая удаляется только потом. + free = free_bytes(core.IMAGES_DIR) + if free < need + SPACE_MARGIN: + hint = " Копия сначала распаковывается рядом со старой машиной и только потом заменяет её, поэтому место нужно на обе." if exists else "" + raise BackupError(f"Недостаточно места для дисков машины: нужно около {format_bytes(need)}, свободно {format_bytes(free)}.{hint}", "space") + return {"backup": backup, "new_name": new_name, "replace": exists, "need": need} + + +def start_restore(vm: str, backup_id: str, new_name: str, replace: bool) -> dict[str, Any]: + plan = plan_restore(vm, backup_id, new_name, replace) + backup = plan["backup"] + title = f"Восстановление {plan['new_name']} из копии" + operation = core.new_operation("restore", title, "Ожидает начала", vm_name=plan["new_name"], source_vm=vm, backup_id=backup_id, replace=plan["replace"], disks=backup["disks"], size_bytes=plan["need"]) + core.run_operation(operation, restore_worker) + return operation + + +def remove_vm(operation: dict[str, Any], name: str) -> None: + """Удалить существующую машину вместе с дисками перед восстановлением.""" + log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731 + if core.vm_is_running(name): + log(f"Машина {name} работает — выключаем принудительно.") + result = run(["virsh", "destroy", name], timeout=30) + if not result["ok"]: + raise BackupError("Не удалось выключить старую машину: " + core.cmd_error(result, "virsh destroy")) + for cdrom in vm_cdroms_with_media(name): + # Иначе `undefine --remove-all-storage` удалит и вставленный ISO-образ. + result = run(["virsh", "change-media", name, cdrom["target"], "--eject", "--config"], timeout=20) + if not result["ok"]: + raise BackupError(f"Не удалось извлечь образ из привода {cdrom['target']}: " + core.cmd_error(result, "virsh change-media")) + log(f"Образ извлечён из привода {cdrom['target']}, чтобы он не был удалён вместе с машиной.") + result = run(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"], timeout=180) + if not result["ok"]: + raise BackupError("Не удалось удалить старую машину: " + core.cmd_error(result, "virsh undefine")) + log(f"Старая машина {name} удалена вместе с дисками.") + + +def restore_worker(operation: dict[str, Any]) -> None: + """Сначала распаковать все диски во временные файлы, и только когда они готовы — + удалить старую машину (при замене), переименовать диски и зарегистрировать новую. + Так ошибка на середине копирования не оставляет пользователя без машины.""" + new_name = operation["vm_name"] + log = lambda text: core.append_operation_log(operation["id"], text) # noqa: E731 + backup = read_backup(operation["source_vm"], operation["backup_id"]) + created: list[Path] = [] # файлы, которые появились по вине этой задачи — только их и убираем при ошибке + try: + if not backup or not backup["complete"]: + raise BackupError("Копия не найдена или неполная") + core.IMAGES_DIR.mkdir(parents=True, exist_ok=True) + copies: list[tuple[str, Path]] = [] + total = len(backup["disks"]) + for index, disk in enumerate(backup["disks"]): + source = Path(backup["path"]) / f"{disk['target']}.qcow2" + temp = restore_temp_path(new_name, operation["id"], disk["target"]) + created.append(temp) + label = f"Восстанавливаем диск {disk['target']} ({index + 1} из {total})" + core.update_operation(operation, progress=int(5 + index / total * 90), message=label) + log(f"{label}: {source} → {temp}") + cmd = ["qemu-img", "convert", "-p", "-O", "qcow2", str(source), str(temp)] + log(" ".join(cmd)) + code = stream_cmd(cmd, disk_progress_reporter(operation, index, total, label)) + if code != 0: + log(f"qemu-img завершился с кодом {code}.") + raise BackupError(f"Не удалось восстановить диск {disk['target']} — подробности в журнале задачи") + copies.append((disk["target"], temp)) + if operation.get("replace"): + core.update_operation(operation, progress=95, message=f"Диски готовы — удаляем существующую машину {new_name}…") + remove_vm(operation, new_name) + disk_map: dict[str, str] = {} + for index, (target, temp) in enumerate(copies): + final = restore_disk_path(new_name, target, index) + temp.replace(final) + created[created.index(temp)] = final + disk_map[target] = str(final) + log(f"Диск {target} готов: {final}") + core.update_operation(operation, progress=96, message="Регистрируем машину…") + xml = rewrite_xml((Path(backup["path"]) / "vm.xml").read_text(encoding="utf-8"), new_name, disk_map, keep_identity=bool(operation.get("replace"))) + with tempfile.NamedTemporaryFile("w", dir=backup["path"], prefix="restore-", suffix=".xml", delete=False, encoding="utf-8") as handle: + handle.write(xml) + tmp_xml = Path(handle.name) + try: + result = run(["virsh", "define", str(tmp_xml)], timeout=30) + finally: + tmp_xml.unlink(missing_ok=True) + if not result["ok"]: + raise BackupError("Не удалось зарегистрировать машину: " + core.cmd_error(result, "virsh define")) + run(["virsh", "pool-refresh", core.IMAGES_POOL], timeout=20) + log(f"Машина {new_name} зарегистрирована.") + except Exception as exc: # noqa: BLE001 + for path in created: + path.unlink(missing_ok=True) + log(f"Ошибка: {exc}") + core.finish_operation(operation, False, str(exc)[:240]) + return + created_label = backup["created_label"] if backup else "" + core.finish_operation(operation, True, f"Машина {new_name} восстановлена из копии от {created_label}. Она выключена — запустите её, когда будете готовы.") + + +# ---------------------------------------------------------------- страницы +def page_messages(request: Request) -> dict[str, str]: + return {"backup_message": request.query_params.get("backup_message", ""), "backup_error": request.query_params.get("backup_error", "")} + + +def vm_summary(name: str) -> dict[str, Any]: + dominfo = run(["virsh", "dominfo", name], timeout=10)["stdout"] + info = presenters.parse_dominfo(dominfo) + return {"name": name, "info": info, "state": info["state"] or core.vm_state(name)} + + +@router.get("/backups") +def backups_page(request: Request): + auth = core.require_auth(request) + if auth: + return auth + items = list_backups() + context = { + "groups": group_by_vm(items, existing_vm_names()), + "storage": storage_summary(items), + "running": [op for op in core.running_operations() if op.get("type") in ("backup", "restore")], + **page_messages(request), + } + return core.render(request, "backups.html", context) + + +@router.post("/backups/{vm}/{backup_id}/delete") +def backup_delete(request: Request, vm: str, backup_id: str, back_to: str = Form("", alias="next")): + auth = core.require_auth(request) + if auth: + return auth + back = back_to if back_to == f"/vm/{vm}/backups" and core.valid_vm_name(vm) else "/backups" + try: + delete_backup(vm, backup_id) + except BackupError as exc: + return core.redirect_with_message(back, "backup_error", str(exc)) + return core.redirect_with_message(back, "backup_message", f"Копия от {format_backup_date(backup_id)} удалена") + + +def restore_form(request: Request, backup: dict[str, Any], name: str, exists: bool, error: str = "", status_code: int = 200): + need = sum(int(disk.get("size") or 0) for disk in backup["disks"]) + context = {"backup": backup, "name": name, "exists": exists, "error": error, "need": format_bytes(need), "free": format_bytes(free_bytes(core.IMAGES_DIR)), "images_dir": str(core.IMAGES_DIR)} + return core.render(request, "backup_restore.html", context, status_code=status_code) + + +@router.get("/backups/{vm}/{backup_id}/restore") +def backup_restore_page(request: Request, vm: str, backup_id: str): + auth = core.require_auth(request) + if auth: + return auth + backup = read_backup(vm, backup_id) + if not backup: + return core.redirect_with_message("/backups", "backup_error", "Копия не найдена") + return restore_form(request, backup, vm, core.vm_exists(vm)) + + +@router.post("/backups/{vm}/{backup_id}/restore") +def backup_restore_submit(request: Request, vm: str, backup_id: str, name: str = Form(""), replace: str = Form("")): + auth = core.require_auth(request) + if auth: + return auth + backup = read_backup(vm, backup_id) + if not backup: + return core.redirect_with_message("/backups", "backup_error", "Копия не найдена") + name = (name or "").strip() or vm + try: + operation = start_restore(vm, backup_id, name, replace == "1") + except BackupError as exc: + exists = exc.code == "exists" or (core.valid_vm_name(name) and core.vm_exists(name)) + return restore_form(request, backup, name, exists, str(exc), status_code=400) + return RedirectResponse(url=f"/operations/{operation['id']}", status_code=303) + + +@router.get("/vm/{name}/backups") +def vm_backups_page(request: Request, name: str): + auth = core.require_auth(request) + if auth: + return auth + if not core.valid_vm_name(name) or not core.vm_exists(name): + return RedirectResponse(url="/", status_code=303) + items = list_backups(name) + context = {"vm": vm_summary(name), "backups": items, "storage": storage_summary(), "active": active_operation(name), **page_messages(request)} + return core.render(request, "vm_backups.html", context) + + +@router.post("/vm/{name}/backups/create") +def vm_backup_create(request: Request, name: str, note: str = Form("")): + auth = core.require_auth(request) + if auth: + return auth + if not core.valid_vm_name(name): + return RedirectResponse(url="/", status_code=303) + try: + operation = create_backup(name, note) + except BackupError as exc: + return core.redirect_with_message(f"/vm/{name}/backups", "backup_error", str(exc)) + return RedirectResponse(url=f"/operations/{operation['id']}", status_code=303) + + +if __name__ == "__main__": # /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py <машина> [заметка] + try: + finished = create_backup(sys.argv[1], " ".join(sys.argv[2:]), wait=True) + except (IndexError, BackupError) as error: + print(f"Ошибка: {error or 'укажите имя машины'}") + sys.exit(2) + print(f"{finished['status']}: {finished['message']}") + sys.exit(0 if finished["status"] == "success" else 1) diff --git a/web/features/downloads.py b/web/features/downloads.py new file mode 100644 index 0000000..1fc98c0 --- /dev/null +++ b/web/features/downloads.py @@ -0,0 +1,805 @@ +"""Скачивание образов ОС по ссылке и из встроенного каталога готовых систем. + +Файл качается на сервере в фоне как операция типа «download»: прогресс, +скорость и оставшееся время видны на страницах «Образы ОС» и в «Задачах». +Пока идёт загрузка, файл лежит рядом как `.<имя>.part` и переименовывается +только после успешного завершения (и проверки контрольной суммы для +образов из каталога). Скачивание можно отменить — недокачанный файл удаляется. +""" +import hashlib +import json +import lzma +import re +import shutil +import socket +import ssl +import threading +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Callable +from urllib.parse import quote, unquote, urlsplit, urlunsplit + +from fastapi import APIRouter, Form, Request +from fastapi.responses import JSONResponse + +import catalog +import core +import host_profile +from core import ( + APP_VERSION, + append_operation_log, + finish_operation, + new_operation, + read_operation, + redirect_with_message, + require_api_auth, + require_auth, + run_cmd, + run_operation, + update_operation, +) + +router = APIRouter() + +CHUNK_SIZE = 1024 * 1024 +TIMEOUT = 30 +RESERVE_BYTES = 512 * 1024 * 1024 +SUMS_MAX_BYTES = 4 * 1024 * 1024 +USER_AGENT = f"Virtuality/{APP_VERSION}" +ISO_POOL = "virtuality-iso" + +KINDS: dict[str, dict[str, Any]] = { + "iso": {"suffixes": (".iso",), "page": "/iso", "label": "установочный образ", "hint": "Ссылка должна вести на файл .iso"}, + "disk": {"suffixes": (".qcow2", ".img", ".raw", ".img.xz"), "page": "/disk-images", "label": "образ диска", "hint": "Подойдут файлы .qcow2, .img, .raw и сжатые .img.xz"}, +} + +# Состояние скачиваний этого процесса (под одним замком): +# - CANCEL_REQUESTS — операции, которые попросили остановить; поток загрузки +# проверяет множество между кусками файла, при проверке суммы и распаковке; +# - LIVE_DOWNLOADS — операция → путь к файлу для скачиваний, которыми владеет +# живой поток. Операция без потока (после перезапуска панели) — сирота: её +# закрывают сразу, ничего не ожидая. +# Множества живут в памяти одного процесса: панель запускается с одним воркером +# uvicorn. На случай нескольких процессов поток раз в секунду читает флаг +# cancel_requested и из JSON операции. +STATE_LOCK = threading.Lock() +CANCEL_REQUESTS: set[str] = set() +LIVE_DOWNLOADS: dict[str, str] = {} +# Проверки «файл уже есть» / «уже скачивается» и создание операции идут под одним +# замком, чтобы два запуска одного файла не прошли проверку одновременно. +START_LOCK = threading.Lock() + +# Понятные объяснения частых ошибок HTTP; остальные коды показываем числом. +HTTP_ERRORS = { + 401: "Сервер требует вход по паролю (ошибка 401)", + 403: "Сервер запретил скачивание (ошибка 403)", + 404: "Файл по ссылке не найден (ошибка 404)", + 410: "Файл по ссылке больше не выдаётся (ошибка 410)", + 429: "Сервер просит подождать: слишком много запросов (ошибка 429)", + 500: "На сервере произошла ошибка (500)", + 502: "Сервер временно недоступен (ошибка 502)", + 503: "Сервер временно недоступен (ошибка 503)", + 504: "Сервер временно недоступен (ошибка 504)", +} + + +class DownloadError(ValueError): + """Понятная пользователю ошибка проверки ссылки или файла.""" + + +class DownloadCancelled(Exception): + """Пользователь отменил скачивание во время проверки суммы или распаковки.""" + + +# ---------------------------------------------------------------- formatting +def human_size(value: float | int | None) -> str: + """1234567 → «1,2 МБ». Десятичная запятая, как принято в русском.""" + value = float(value or 0) + for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"): + if value < 1024 or unit == "ТБ": + if unit == "Б" or value >= 100: + return f"{value:.0f} {unit}" + text = f"{value:.1f}".rstrip("0").rstrip(".") + return f"{text.replace('.', ',')} {unit}" + value /= 1024 + return f"{value:.0f} ТБ" + + +def human_eta(seconds: float | None) -> str: + """Оставшееся время словами: «40 с», «4 мин», «1 ч 12 мин».""" + if seconds is None or seconds < 0: + return "" + seconds = int(round(seconds)) + if seconds < 60: + return f"{max(seconds, 1)} с" + minutes = (seconds + 30) // 60 + if minutes < 60: + return f"{minutes} мин" + hours, minutes = divmod(minutes, 60) + return f"{hours} ч {minutes} мин" if minutes else f"{hours} ч" + + +def progress_message(done: int, total: int | None, speed: float | None) -> str: + """«245 МБ из 3,1 ГБ · 12 МБ/с · осталось 4 мин».""" + parts = [f"{human_size(done)} из {human_size(total)}" if total else human_size(done)] + if speed and speed > 0: + parts.append(f"{human_size(speed)}/с") + if total and total > done: + parts.append(f"осталось {human_eta((total - done) / speed)}") + return " · ".join(parts) + + +# ---------------------------------------------------------------- validation +def validate_url(url: str) -> str: + url = (url or "").strip() + if not url: + raise DownloadError("Вставьте ссылку на файл") + if len(url) > 2048 or any(ch.isspace() for ch in url): + raise DownloadError("Ссылка выглядит некорректно") + try: + parts = urlsplit(url) + except ValueError as exc: + raise DownloadError("Ссылка выглядит некорректно") from exc + if parts.scheme.lower() not in ("http", "https"): + raise DownloadError("Ссылка должна начинаться с http:// или https://") + if not parts.hostname: + raise DownloadError("В ссылке не указан адрес сервера") + encode_url(url) # кириллица в домене или пути, порт — проверяем сразу, а не в фоне + return url + + +def encode_url(url: str) -> str: + """Ссылка в виде, который понимает urllib: домен с кириллицей — в IDNA, не-ASCII + символы в пути и параметрах — в проценты. Уже закодированные ссылки не меняются.""" + parts = urlsplit(url) + try: + port = parts.port + except ValueError as exc: + raise DownloadError("Ссылка выглядит некорректно") from exc + try: + host = (parts.hostname or "").encode("idna").decode("ascii") + except UnicodeError as exc: + raise DownloadError("Ссылка содержит символы, которые сервер не понимает") from exc + netloc = f"[{host}]" if ":" in host else host + if port: + netloc = f"{netloc}:{port}" + if "@" in parts.netloc: + netloc = f"{parts.netloc.rsplit('@', 1)[0]}@{netloc}" + path = quote(parts.path, safe="/%:@!$&'()*+,;=~") + query = quote(parts.query, safe="/%:@!$&'()*+,;=?~") + return urlunsplit((parts.scheme.lower(), netloc, path, query, "")) + + +def filename_from_url(url: str, kind: str) -> str: + """Имя файла из ссылки, приведённое к безопасному виду (как при загрузке с компьютера).""" + if kind not in KINDS: + raise DownloadError("Неизвестный тип образа") + original = unquote(urlsplit(url).path).rstrip("/").rsplit("/", 1)[-1].strip() + if not original: + raise DownloadError("В ссылке нет имени файла — она должна вести на сам файл, а не на страницу") + lower = original.lower() + suffix = next((item for item in KINDS[kind]["suffixes"] if lower.endswith(item)), None) + if not suffix: + allowed = ", ".join(KINDS[kind]["suffixes"]) + raise DownloadError(f"Файл «{original}» не подходит: нужен {KINDS[kind]['label']} ({allowed})") + stem = original[: -len(suffix)] + 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-iso" if kind == "iso" else "virtuality-disk" + name = f"{stem}{suffix}" + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,180}", name): + raise DownloadError("Не удалось составить безопасное имя файла из ссылки") + return name + + +def kind_dir(kind: str) -> Path: + return core.ISO_DIR if kind == "iso" else core.DISK_IMAGES_DIR + + +def target_path(kind: str, filename: str) -> Path: + directory = kind_dir(kind) + directory.mkdir(parents=True, exist_ok=True) + path = (directory / filename).resolve() + if directory.resolve() not in path.parents: + raise DownloadError("Недопустимое имя файла") + return path + + +def part_path(target: Path) -> Path: + return target.with_name(f".{target.name}.part") + + +def free_space_ok(directory: Path, needed: int | None) -> tuple[bool, int]: + free = shutil.disk_usage(str(directory)).free + return free >= (needed or 0) + RESERVE_BYTES, free + + +# ---------------------------------------------------------------- checksums +HASH_RE = re.compile(r"^[0-9a-fA-F]{32,128}$") + + +def parse_checksums(text: str, filename: str) -> str | None: + """Хеш файла из файла контрольных сумм. + + Понимает « *name» и « name» (Ubuntu, Debian), BSD-стиль + «SHA256 (name) = » и файл из одного хеша (Alpine *.sha512). + """ + bare: str | None = None + for raw in (text or "").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + bsd = re.match(r"^[A-Za-z0-9-]+\s*\((.+)\)\s*=\s*([0-9a-fA-F]{32,128})$", line) + if bsd: + if Path(bsd.group(1).strip()).name == filename: + return bsd.group(2).lower() + continue + pieces = line.split(None, 1) + if len(pieces) == 2 and HASH_RE.match(pieces[0]): + name = pieces[1].strip().lstrip("*").strip() + if Path(name).name == filename: + return pieces[0].lower() + continue + if len(pieces) == 1 and HASH_RE.match(pieces[0]) and bare is None: + bare = pieces[0].lower() + return bare + + +def file_digest(path: Path, algo: str, on_progress: Any = None) -> str: + digest = hashlib.new(algo) + with path.open("rb") as handle: + while True: + chunk = handle.read(CHUNK_SIZE) + if not chunk: + break + digest.update(chunk) + if on_progress: + on_progress(len(chunk)) + return digest.hexdigest() + + +# ---------------------------------------------------------------- operations +def unfinished_downloads() -> list[dict[str, Any]]: + """Незавершённые скачивания по всему каталогу операций, а не по последним 50, + как running_operations: зависшая старая операция иначе не попадёт в проверку.""" + core.ensure_operations_dir() + found = [] + for path in core.OPERATIONS_DIR.glob("*.json"): + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + continue + if data.get("type") == "download" and data.get("status") in ("queued", "running"): + found.append(data) + found.sort(key=lambda op: str(op.get("created_at") or "")) + return found + + +def is_live(operation_id: str) -> bool: + with STATE_LOCK: + return operation_id in LIVE_DOWNLOADS + + +def release_download(operation_id: str) -> None: + with STATE_LOCK: + LIVE_DOWNLOADS.pop(operation_id, None) + CANCEL_REQUESTS.discard(operation_id) + + +def reap_stale_downloads() -> int: + """Скачивания без живого потока (панель перезапускалась) закрываются, а их + недокачанные .part удаляются — иначе они лежали бы в каталоге образов вечно. + + Операции всех типов закрывает core.interrupt_orphaned_operations() при старте; + здесь остаётся убрать файлы .part, которых та не касается. Возвращает число + закрытых операций.""" + count = 0 + for op in unfinished_downloads(): + if is_live(op["id"]): + continue + message = "Скачивание прервано: панель была перезапущена. Запустите его заново" + if op.get("target_path"): + part_path(Path(op["target_path"])).unlink(missing_ok=True) + release_download(op["id"]) + finish_operation(op, False, message, interrupted=True) + count += 1 + with STATE_LOCK: + live_targets = set(LIVE_DOWNLOADS.values()) + for directory in (core.ISO_DIR, core.DISK_IMAGES_DIR): + if not directory.is_dir(): + continue + for part in directory.glob(".*.part"): + if str(part.with_name(part.name[1:-5]).resolve()) in live_targets: + continue + part.unlink(missing_ok=True) + return count + + +# ---------------------------------------------------------------- catalog +def host_arch() -> str: + arch = str(host_profile.load_host_profile().get("arch") or "").lower() + return {"amd64": "x86_64", "arm64": "aarch64"}.get(arch, arch or "x86_64") + + +def image_candidates(filename: str) -> list[str]: + """Имена файлов, под которыми образ может лежать после скачивания: + сам файл, распакованный .img для .img.xz и qcow2 после конвертации.""" + names = [filename] + lower = filename.lower() + if lower.endswith(".img.xz"): + names.append(filename[:-3]) + names.append(filename[:-7] + ".qcow2") + elif lower.endswith((".img", ".raw")): + names.append(filename[: filename.rfind(".")] + ".qcow2") + return names + + +def catalog_entries(arch: str | None = None, active: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: + """Каталог готовых систем для процессора сервера с пометками «Загружен» / «Скачивается». + Список идущих скачиваний можно передать готовым, чтобы не читать каталог операций дважды.""" + arch = arch or host_arch() + ops = unfinished_downloads() if active is None else active + by_catalog_id = {op.get("catalog_id"): op for op in ops if op.get("catalog_id")} + entries: list[dict[str, Any]] = [] + for item in catalog.CLOUD_IMAGES: + source = item.get("arches", {}).get(arch) + if not source: + continue + try: + filename = filename_from_url(source["url"], "disk") + except DownloadError: + continue + ready = next((name for name in reversed(image_candidates(filename)) if (core.DISK_IMAGES_DIR / name).exists()), None) + operation = by_catalog_id.get(item["id"]) + entries.append({ + "id": item["id"], "title": item["title"], "vendor": item.get("vendor", ""), "hint": item.get("hint", ""), + "recommended": bool(item.get("recommended")), "login": item.get("login", ""), "filename": filename, + "uefi": bool(source.get("uefi")), "status": "downloading" if operation else "ready" if ready else "none", + "ready_file": ready, "operation_id": operation["id"] if operation else None, + }) + return entries + + +def active_downloads(kind: str | None = None, ops: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: + result = [] + for op in unfinished_downloads() if ops is None else ops: + if kind and op.get("download_kind") != kind: + continue + op["indeterminate"] = not op.get("total_bytes") and op.get("status") == "running" + result.append(op) + return result + + +def downloads_panel(kind: str) -> dict[str, Any]: + """Данные для карточек скачивания на страницах /iso и /disk-images (глобальная функция шаблонов). + Каталог операций читается один раз на вызов; страница вызывает функцию один раз и передаёт результат во вставки.""" + kind = kind if kind in KINDS else "iso" + ops = unfinished_downloads() + return {"kind": kind, "page": KINDS[kind]["page"], "hint": KINDS[kind]["hint"], "active": active_downloads(kind, ops), "catalog": catalog_entries(active=ops) if kind == "disk" else []} + + +# ---------------------------------------------------------------- cancel +def cancel_requested(operation_id: str) -> bool: + with STATE_LOCK: + return operation_id in CANCEL_REQUESTS + + +def clear_cancel(operation_id: str) -> None: + with STATE_LOCK: + CANCEL_REQUESTS.discard(operation_id) + + +def request_cancel(operation_id: str) -> tuple[dict[str, Any] | None, str | None]: + """Попросить остановить скачивание. Возвращает (операция, ошибка). + + Если операцией не владеет ни один поток (осталась от перезапуска панели), + ждать некого — она закрывается сразу, а недокачанный файл удаляется.""" + operation = read_operation(operation_id) + if not operation: + return None, "Задача не найдена" + if operation.get("type") != "download": + return operation, "Отменить можно только скачивание" + if operation.get("status") not in ("queued", "running"): + return operation, "Скачивание уже завершено" + operation.pop("log_tail", None) + with STATE_LOCK: + live = operation_id in LIVE_DOWNLOADS + if live: + CANCEL_REQUESTS.add(operation_id) + if not live: + if operation.get("target_path"): + part_path(Path(operation["target_path"])).unlink(missing_ok=True) + append_operation_log(operation_id, "Пользователь отменил скачивание. Поток загрузки не найден (панель перезапускалась), недокачанный файл удалён.") + finish_operation(operation, False, "Скачивание отменено", cancelled=True, cancel_requested=True) + return read_operation(operation_id) or operation, None + update_operation(operation, cancel_requested=True, message="Останавливаем скачивание…") + append_operation_log(operation_id, "Пользователь попросил отменить скачивание.") + return operation, None + + +# ---------------------------------------------------------------- download +def start_download(url: str, kind: str, *, title: str | None = None, sums_url: str | None = None, algo: str | None = None, catalog_id: str | None = None) -> dict[str, Any]: + """Проверить ссылку и запустить фоновое скачивание. Бросает DownloadError.""" + if kind not in KINDS: + raise DownloadError("Неизвестный тип образа") + url = validate_url(url) + filename = filename_from_url(url, kind) + target = target_path(kind, filename) + if sums_url: + validate_url(sums_url) + if algo not in hashlib.algorithms_available: + raise DownloadError("Неизвестный алгоритм контрольной суммы") + with START_LOCK: + # Образ мог остаться под другим именем: распакованный .img или qcow2 после конвертации. + existing = next((name for name in image_candidates(filename) if (target.parent / name).exists()), None) + if existing: + raise DownloadError(f"Файл {existing} уже есть на сервере. Удалите его, если нужно скачать заново") + if any(op.get("target_path") == str(target) for op in unfinished_downloads()): + raise DownloadError(f"Файл {filename} уже скачивается") + # Владельца у .part нет (проверили выше) — это остаток прерванной загрузки. + part_path(target).unlink(missing_ok=True) + ok, free = free_space_ok(target.parent, None) + if not ok: + raise DownloadError(f"На сервере мало места: свободно {human_size(free)}") + operation = new_operation( + "download", title or f"Скачивание {filename}", message="Подключаемся к серверу…", + download_kind=kind, url=url, filename=filename, target_path=str(target), source_path=str(target), page=KINDS[kind]["page"], + sums_url=sums_url, algo=algo, catalog_id=catalog_id, total_bytes=None, downloaded_bytes=0, cancel_requested=False, + ) + with STATE_LOCK: + LIVE_DOWNLOADS[operation["id"]] = str(target) + append_operation_log(operation["id"], f"Ссылка: {url}") + append_operation_log(operation["id"], f"Файл: {target}") + run_operation(operation, download_worker) + return operation + + +def open_url(url: str) -> Any: + request = urllib.request.Request(encode_url(url), headers={"User-Agent": USER_AGENT, "Accept": "*/*"}) + return urllib.request.urlopen(request, timeout=TIMEOUT) # noqa: S310 — схема проверена в validate_url + + +def describe_network_error(exc: Exception) -> str: + """Ошибка сети словами для пользователя; исходный текст остаётся в журнале операции.""" + if isinstance(exc, urllib.error.HTTPError): + return HTTP_ERRORS.get(exc.code, f"Сервер ответил ошибкой {exc.code}") + if isinstance(exc, urllib.error.URLError): + reason = exc.reason + text = str(reason).lower() + if isinstance(reason, TimeoutError) or "timed out" in text: + return f"Сервер не отвечает (ждали {TIMEOUT} с)" + if isinstance(reason, socket.gaierror) or "name or service not known" in text or "nodename nor servname" in text or "name resolution" in text: + return "Не удалось найти сервер по этой ссылке — проверьте адрес" + if isinstance(reason, ConnectionRefusedError) or "connection refused" in text: + return "Сервер отказал в подключении" + if isinstance(reason, ssl.SSLError) or "certificate" in text or "ssl" in text: + return "У сервера проблема с сертификатом безопасности" + if isinstance(reason, UnicodeError): + return "Ссылка содержит символы, которые сервер не понимает" + return f"Не удалось подключиться к серверу: {reason}" + if isinstance(exc, UnicodeError): + return "Ссылка содержит символы, которые сервер не понимает" + if isinstance(exc, TimeoutError) or "timed out" in str(exc).lower(): + return f"Сервер перестал отвечать (ждали {TIMEOUT} с)" + if isinstance(exc, (ConnectionResetError, BrokenPipeError, ConnectionAbortedError)): + return "Сервер разорвал соединение" + return str(exc)[:240] + + +def fetch_expected_checksum(sums_url: str, filename: str, operation_id: str) -> str: + append_operation_log(operation_id, f"Скачиваем контрольные суммы: {sums_url}") + try: + with open_url(sums_url) as response: + text = response.read(SUMS_MAX_BYTES).decode("utf-8", errors="replace") + except Exception as exc: # noqa: BLE001 + append_operation_log(operation_id, f"Ошибка: {exc}") + raise DownloadError(f"Не удалось скачать файл контрольных сумм: {describe_network_error(exc)}") from exc + expected = parse_checksums(text, filename) + if not expected: + raise DownloadError(f"В файле контрольных сумм нет записи для {filename}") + return expected + + +def download_worker(operation: dict[str, Any]) -> None: + try: + run_download(operation) + finally: + release_download(operation["id"]) + + +def run_download(operation: dict[str, Any]) -> None: + operation_id = operation["id"] + kind = operation["download_kind"] + target = Path(operation["target_path"]) + part = part_path(target) + verify = bool(operation.get("sums_url")) + download_share = 96 if verify else 100 + + def cancelled() -> bool: + return cancel_requested(operation_id) + + def check_cancel(*_args: Any) -> None: + if cancelled(): + raise DownloadCancelled + + def fail(message: str, *, keep_part: bool = False, **meta: Any) -> None: + if not keep_part: + part.unlink(missing_ok=True) + finish_operation(operation, False, message, **meta) + + def stop(note: str = "Скачивание остановлено, недокачанный файл удалён.") -> None: + append_operation_log(operation_id, note) + fail("Скачивание отменено", cancelled=True, cancel_requested=True) + + def network_failure(prefix: str, exc: Exception) -> None: + append_operation_log(operation_id, f"Ошибка: {exc}") + fail(f"{prefix}{describe_network_error(exc)}") + + if cancelled(): + stop() + return + try: + response = open_url(operation["url"]) + except Exception as exc: # noqa: BLE001 + network_failure("", exc) + return + + with response: + final_url = response.geturl() + if final_url != encode_url(operation["url"]): + append_operation_log(operation_id, f"Перенаправление: {final_url}") + length = response.headers.get("Content-Length") + total = int(length) if length and length.isdigit() else None + ok, free = free_space_ok(target.parent, total) + if not ok: + need = f"нужно {human_size((total or 0) + RESERVE_BYTES)}, " if total else "" + fail(f"На сервере мало места: {need}свободно {human_size(free)}") + return + append_operation_log(operation_id, f"Размер файла: {human_size(total) if total else 'неизвестен'}") + update_operation(operation, total_bytes=total, message=progress_message(0, total, None)) + + done = 0 + started = last_tick = time.monotonic() + last_done = 0 + speed: float | None = None + try: + # Файл создаётся монопольно: если .part уже пишет другой поток, вторая загрузка + # не должна ни писать в него, ни удалять его при ошибке. + with part.open("xb") as handle: + while True: + if cancelled(): + stop() + return + chunk = response.read(CHUNK_SIZE) + if not chunk: + break + handle.write(chunk) + done += len(chunk) + now = time.monotonic() + if now - last_tick >= 1.0: + # Раз в секунду перечитываем JSON операции: так запись прогресса не затирает + # флаг cancel_requested, который выставил request_cancel, а отмена из другого + # процесса панели (несколько воркеров uvicorn) тоже будет замечена. + fresh = read_operation(operation_id) or {} + fresh.pop("log_tail", None) + operation.update(fresh) + if cancelled() or operation.get("cancel_requested"): + stop() + return + instant = (done - last_done) / (now - last_tick) + speed = instant if speed is None else speed * 0.7 + instant * 0.3 + last_tick, last_done = now, done + progress = int(done * download_share / total) if total else 0 + if not total: + ok, free = free_space_ok(target.parent, None) + if not ok: + fail(f"На сервере закончилось место: свободно {human_size(free)}") + return + update_operation(operation, progress=min(progress, download_share), downloaded_bytes=done, speed_bps=int(speed), message=progress_message(done, total, speed)) + except FileExistsError: + append_operation_log(operation_id, f"Файл {part.name} уже создан другой загрузкой — эта копия остановлена, чужой файл не тронут.") + fail(f"Файл {target.name} уже скачивается", keep_part=True) + return + except Exception as exc: # noqa: BLE001 + network_failure("Загрузка прервалась: ", exc) + return + + if total is not None and done != total: + fail(f"Файл скачался не полностью: получено {human_size(done)} из {human_size(total)}") + return + elapsed = max(time.monotonic() - started, 0.001) + append_operation_log(operation_id, f"Скачано {human_size(done)} за {human_eta(elapsed) or '1 с'} ({human_size(done / elapsed)}/с).") + update_operation(operation, progress=download_share, downloaded_bytes=done, total_bytes=done if total is None else total, message="Файл скачан, проверяем…") + + try: + if verify: + expected = fetch_expected_checksum(operation["sums_url"], operation["filename"], operation_id) + check_cancel() + algo = operation.get("algo") or "sha256" + update_operation(operation, progress=97, message=f"Проверяем контрольную сумму ({algo})…") + actual = file_digest(part, algo, on_progress=check_cancel) + if actual != expected: + append_operation_log(operation_id, f"Ожидали {algo}: {expected}") + append_operation_log(operation_id, f"Получили {algo}: {actual}") + fail("Контрольная сумма не совпала — файл повреждён или подменён, поэтому удалён. Попробуйте скачать ещё раз") + return + append_operation_log(operation_id, f"Контрольная сумма {algo} совпала.") + check_cancel() + except DownloadCancelled: + stop("Скачивание остановлено во время проверки, файл удалён.") + return + except DownloadError as exc: + fail(str(exc)) + return + + if target.exists(): + fail(f"Файл {target.name} появился на сервере, пока шло скачивание. Скачанная копия удалена") + return + part.replace(target) + append_operation_log(operation_id, f"Файл сохранён: {target}") + try: + result = after_download(operation, target, kind, cancelled) + except DownloadCancelled: + append_operation_log(operation_id, "Распаковка остановлена, скачанный архив и недораспакованный файл удалены.") + finish_operation(operation, False, "Скачивание отменено", cancelled=True, cancel_requested=True) + return + except DownloadError as exc: + finish_operation(operation, False, str(exc)) + return + except Exception as exc: # noqa: BLE001 + finish_operation(operation, False, f"Файл скачан, но не удалось его подготовить: {str(exc)[:200]}", result_path=str(target)) + return + finish_operation(operation, True, result["message"], **result["meta"]) + + +def after_download(operation: dict[str, Any], target: Path, kind: str, should_stop: Callable[[], bool] | None = None) -> dict[str, Any]: + """Что делать со скачанным файлом: обновить пул ISO либо распаковать и конвертировать диск.""" + operation_id = operation["id"] + if kind == "iso": + refresh = run_cmd(["virsh", "pool-refresh", ISO_POOL], timeout=20) + append_operation_log(operation_id, "Список установочных образов обновлён." if refresh["ok"] else f"Не удалось обновить пул {ISO_POOL}: {refresh.get('stderr') or refresh.get('stdout')}") + return {"message": f"Готово: {target.name} — можно создавать машину", "meta": {"result_path": str(target)}} + + path = target + if target.name.lower().endswith(".img.xz"): + update_operation(operation, progress=98, message=f"Распаковываем {target.name}…") + append_operation_log(operation_id, "Распаковываем сжатый образ…") + try: + path = extract_xz_image(target, should_stop) + except DownloadCancelled: + target.unlink(missing_ok=True) + raise + except Exception as exc: # noqa: BLE001 + # Битый архив со страницы не видно и не удалить (там только .img/.raw/.qcow2), + # а повторное скачивание он бы блокировал — убираем сами. + target.unlink(missing_ok=True) + append_operation_log(operation_id, f"Ошибка распаковки: {exc}") + raise DownloadError("Не удалось распаковать скачанный архив — файл повреждён и удалён. Попробуйте скачать ещё раз") from exc + target.unlink(missing_ok=True) + append_operation_log(operation_id, f"Распакован: {path}") + convert = start_convert(path) + meta = {"result_path": str(path)} + if convert: + meta["convert_operation_id"] = convert["id"] + append_operation_log(operation_id, f"Запущена конвертация в qcow2, задача {convert['id']}.") + return {"message": f"Готово: {path.name} скачан, конвертируем в формат qcow2", "meta": meta} + return {"message": f"Готово: {path.name} — можно создавать машину", "meta": meta} + + +def extract_xz_image(compressed: Path, should_stop: Callable[[], bool] | None = None) -> Path: + """Распаковать .img.xz рядом с архивом (имя подбирает app, как при загрузке с компьютера). + Между кусками проверяет should_stop: при отмене бросает DownloadCancelled, недораспакованный файл удаляется.""" + import app # локальный импорт: app.py подключает этот модуль в самом конце + + extracted = app.unique_disk_image_path(compressed.name[:-3]) + try: + with lzma.open(compressed, "rb") as source, extracted.open("wb") as handle: + while True: + if should_stop and should_stop(): + raise DownloadCancelled + chunk = source.read(CHUNK_SIZE) + if not chunk: + break + handle.write(chunk) + except BaseException: + extracted.unlink(missing_ok=True) + raise + return extracted + + +def start_convert(path: Path) -> dict[str, Any] | None: + import app # локальный импорт: app.py подключает этот модуль в самом конце + + return app.start_disk_convert_operation(path) + + +# ---------------------------------------------------------------- routes +def page_for(kind: str) -> str: + return KINDS.get(kind, KINDS["iso"])["page"] + + +@router.post("/downloads/url") +def download_from_url(request: Request, url: str = Form(""), kind: str = Form("iso")): + auth = require_auth(request) + if auth: + return auth + kind = kind if kind in KINDS else "iso" + try: + operation = start_download(url, kind) + except DownloadError as exc: + return redirect_with_message(page_for(kind), "download_error", str(exc)) + return redirect_with_message(page_for(kind), "download_message", f"Скачиваем {operation['filename']} — ход загрузки виден выше") + + +@router.post("/downloads/catalog/{image_id}") +def download_from_catalog(request: Request, image_id: str): + auth = require_auth(request) + if auth: + return auth + entry = catalog.cloud_image(image_id) + if not entry: + return redirect_with_message("/disk-images", "download_error", "Такого образа нет в каталоге") + arch = host_arch() + source = entry.get("arches", {}).get(arch) + if not source: + return redirect_with_message("/disk-images", "download_error", f"У {entry['title']} нет версии для этого сервера ({arch})") + try: + operation = start_download(source["url"], "disk", title=f"Скачивание {entry['title']}", sums_url=source.get("sums"), algo=source.get("algo"), catalog_id=image_id) + except DownloadError as exc: + return redirect_with_message("/disk-images", "download_error", str(exc)) + return redirect_with_message("/disk-images", "download_message", f"Скачиваем {entry['title']} ({operation['filename']}) — после загрузки проверим контрольную сумму") + + +@router.post("/downloads/{operation_id}/cancel") +def cancel_download(request: Request, operation_id: str): + auth = require_auth(request) + if auth: + return auth + operation, error = request_cancel(operation_id) + page = page_for(str(operation.get("download_kind"))) if operation else "/iso" + if error: + return redirect_with_message(page, "download_error", error) + filename = operation.get("filename", "") + if operation.get("status") == "error": + # Потока не было — операция уже закрыта, ждать нечего. + return redirect_with_message(page, "download_message", " ".join(word for word in ("Скачивание", filename, "отменено") if word)) + return redirect_with_message(page, "download_message", f"Останавливаем скачивание {filename}".rstrip()) + + +@router.post("/api/operations/{operation_id}/cancel") +def api_cancel_operation(request: Request, operation_id: str): + auth = require_api_auth(request) + if auth: + return auth + operation, error = request_cancel(operation_id) + if operation is None: + return JSONResponse({"ok": False, "error": error}, status_code=404) + if error: + return JSONResponse({"ok": False, "error": error}, status_code=400 if operation.get("type") != "download" else 409) + return {"ok": True, "operation": read_operation(operation_id) or operation} + + +@router.on_event("startup") +def close_stale_downloads() -> None: + """После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part.""" + reap_stale_downloads() + + +# ---------------------------------------------------------------- templates +def register_template_globals() -> None: + """Функция downloads_panel нужна шаблонам iso.html и disk_images.html, которые + рендерит app.py своим окружением Jinja; регистрируем её и в core, и в app. + + Импорт app здесь циклический, но безопасный: app.py подключает этот модуль + в самом конце, когда его `templates` уже создан, а наш `router` — уже определён.""" + import app + + for env in {id(item): item for item in (core.templates.env, app.templates.env)}.values(): + env.globals["downloads_panel"] = downloads_panel + + +register_template_globals() diff --git a/web/features/settings.py b/web/features/settings.py new file mode 100644 index 0000000..48eb5ca --- /dev/null +++ b/web/features/settings.py @@ -0,0 +1,166 @@ +"""Settings page: everything the setup wizard configures, changeable later in one place.""" +import re +from pathlib import Path +from typing import Any + +import network_core +import nodectl +from fastapi import APIRouter, Form, Request +from fastapi.responses import FileResponse +from core import APP_VERSION, AUTH_USER, redirect_with_message, render, require_auth + +router = APIRouter() + + +def page(request: Request, error: str | None = None, message: str | None = None, status_code: int = 200): + settings = nodectl.node_settings() + host = request.url.hostname or "127.0.0.1" + context: dict[str, Any] = { + "page_title": "Настройки", "error": error, "message": message, "settings": settings, "auth_user": AUTH_USER, + "tls": settings.get("VIRTUALITY_TLS") == "1", "port": settings.get("VIRTUALITY_WEB_PORT", "8088"), "tls_port": settings.get("VIRTUALITY_TLS_PORT", "8443"), + "auto_update": settings.get("VIRTUALITY_AUTO_UPDATE", "1") == "1", "channel": settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable"), + "timezones": nodectl.timezones(), "net": nodectl.network_facts(), "storage": nodectl.storage_facts(), + "hardware": nodectl.hardware_summary(), "ctl": nodectl.ctl_available(), "version": APP_VERSION, + "panel_url": nodectl.panel_url(host, settings), "source_dir": settings.get("VIRTUALITY_SOURCE_DIR", "/opt/virtuality/source"), + } + return render(request, "settings.html", context, status_code=status_code) + + +@router.get("/settings") +def settings_page(request: Request, message: str = "", error: str = ""): + auth = require_auth(request) + if auth: + return auth + return page(request, error=error or None, message=message or None) + + +@router.post("/settings/access") +def settings_access(request: Request, tls: str = Form("0"), port: str = Form("8088")): + auth = require_auth(request) + if auth: + return auth + if not re.fullmatch(r"\d{1,5}", port) or not 1 <= int(port) <= 65535 or int(port) in (22, 9090): + return page(request, error="Порт должен быть числом от 1 до 65535 (кроме 22 и 9090)", status_code=400) + settings = nodectl.node_settings() + tls = "1" if tls == "1" else "0" + if tls == settings.get("VIRTUALITY_TLS", "0") and port == settings.get("VIRTUALITY_WEB_PORT", "8088"): + return redirect_with_message("/settings", "message", "Доступ уже настроен так") + ok, detail = nodectl.apply_settings({"VIRTUALITY_TLS": tls, "VIRTUALITY_WEB_PORT": port}) + if not ok: + return page(request, error=detail, status_code=500) + host = request.url.hostname or "127.0.0.1" + final_url = f"https://{host}:{settings.get('VIRTUALITY_TLS_PORT', '8443')}" if tls == "1" else f"http://{host}:{port}" + return render(request, "setup_applying.html", {"page_title": "Применяем настройки", "final_url": final_url, "tls": tls == "1"}) + + +@router.post("/settings/password") +def settings_password(request: Request, password: str = Form(""), password2: str = Form("")): + auth = require_auth(request) + if auth: + return auth + if password != password2: + return page(request, error="Пароли не совпадают", status_code=400) + ok, detail = nodectl.change_password(AUTH_USER, password) + if not ok: + return page(request, error=detail, status_code=400) + return redirect_with_message("/settings", "message", detail) + + +@router.post("/settings/timezone") +def settings_timezone(request: Request, timezone: str = Form("")): + auth = require_auth(request) + if auth: + return auth + ok, detail = nodectl.set_timezone(timezone) + if not ok: + return page(request, error=detail, status_code=400) + return redirect_with_message("/settings", "message", f"Часовой пояс: {detail}") + + +@router.post("/settings/updates") +def settings_updates(request: Request, auto_update: str = Form("0"), channel: str = Form("stable")): + auth = require_auth(request) + if auth: + return auth + if channel not in ("stable", "main"): + channel = "stable" + ok, detail = nodectl.apply_settings({"VIRTUALITY_AUTO_UPDATE": "1" if auto_update == "1" else "0", "VIRTUALITY_UPDATE_CHANNEL": channel}) + return redirect_with_message("/settings", "message" if ok else "error", "Настройки обновлений сохранены" if ok else detail) + + +@router.post("/settings/network") +def settings_network(request: Request, mode: str = Form("nat")): + auth = require_auth(request) + if auth: + return auth + facts = nodectl.network_facts() + if mode == "nat": + try: + network_core.create_nat_network() + except network_core.NetworkError as exc: + return page(request, error=f"Не удалось создать сеть машин: {exc}", status_code=500) + return redirect_with_message("/settings", "message", "Сеть NAT готова. Новые машины будут подключаться к ней.") + if mode != "bridge": + return page(request, error="Выберите режим сети", status_code=400) + if facts["on_bridge"]: + return redirect_with_message("/settings", "message", "Сервер уже подключён через мост br0") + if not facts["bridge_possible"]: + return page(request, error="Мост недоступен: Wi-Fi или не найден проводной интерфейс", status_code=400) + ok, detail = nodectl.enable_bridge(facts["interface"], "dhcp") + if not ok: + return page(request, error=detail, status_code=500) + return render(request, "setup_bridge_wait.html", {"page_title": "Перенастройка сети", "next_url": "/settings", "confirm_url": "/setup/network/confirm", "revert_url": "/settings/network/revert", "back_url": "/settings"}) + + +@router.post("/settings/network/revert") +def settings_network_revert(request: Request): + auth = require_auth(request) + if auth: + return auth + ok, detail = nodectl.revert_bridge() + return redirect_with_message("/settings", "message" if ok else "error", detail) + + +@router.post("/settings/storage") +def settings_storage(request: Request, disk: str = Form("")): + auth = require_auth(request) + if auth: + return auth + ok, detail = nodectl.use_disk_for_storage(disk) + return redirect_with_message("/settings", "message" if ok else "error", detail) + + +@router.post("/settings/power") +def settings_power(request: Request, action: str = Form("")): + auth = require_auth(request) + if auth: + return auth + ok, detail = nodectl.power(action) + if not ok: + return page(request, error=detail, status_code=400) + return render(request, "settings_power.html", {"page_title": "Сервер", "action": action, "message": detail}) + + +@router.post("/settings/backup-config") +def settings_backup_config(request: Request): + auth = require_auth(request) + if auth: + return auth + ok, detail = nodectl.backup_config() + if not ok or not Path(detail).is_file(): + return page(request, error=detail if not ok else "Архив не найден", status_code=500) + return FileResponse(detail, media_type="application/gzip", filename=Path(detail).name) + + +@router.post("/settings/support-bundle") +def settings_support_bundle(request: Request): + auth = require_auth(request) + if auth: + return auth + ok, detail = nodectl.support_bundle() + if not ok or not Path(detail).is_file(): + return page(request, error=detail if not ok else "Отчёт не найден", status_code=500) + return FileResponse(detail, media_type="application/gzip", filename=Path(detail).name) + + + diff --git a/web/features/setup.py b/web/features/setup.py new file mode 100644 index 0000000..2fe1599 --- /dev/null +++ b/web/features/setup.py @@ -0,0 +1,287 @@ +"""First-run setup wizard (/setup) and the "applying settings" page.""" +import re +from typing import Any + +from fastapi import APIRouter, Form, Request +from fastapi.responses import JSONResponse, RedirectResponse + +import network_core +import nodectl +import presenters +from core import AUTH_USER, cmd_error, get_current_user, redirect_with_message, render, require_auth, run_cmd + +router = APIRouter() + +STEPS = [ + {"id": "install", "title": "Установка", "hint": "Компоненты сервера"}, + {"id": "welcome", "title": "Сервер", "hint": "Проверка и время"}, + {"id": "account", "title": "Администратор", "hint": "Вход в панель"}, + {"id": "access", "title": "Доступ", "hint": "Адрес и HTTPS"}, + {"id": "network", "title": "Сеть машин", "hint": "NAT или домашняя сеть"}, + {"id": "storage", "title": "Хранилище", "hint": "Где живут машины"}, + {"id": "updates", "title": "Обновления", "hint": "Как обновляться"}, + {"id": "finish", "title": "Готово", "hint": "Проверка и запуск"}, +] +STEP_IDS = [step["id"] for step in STEPS] + + +def wizard_pending() -> bool: + """The wizard is offered until finished on nodes set up by the installer or the ISO.""" + return not nodectl.wizard_done() and nodectl.SETUP_STATE.exists() + + +def step_index(step_id: str) -> int: + return STEP_IDS.index(step_id) if step_id in STEP_IDS else 0 + + +def next_step(step_id: str) -> str: + index = step_index(step_id) + return STEP_IDS[min(index + 1, len(STEP_IDS) - 1)] + + +def render_step(request: Request, step_id: str, error: str | None = None, message: str | None = None, status_code: int = 200): + choices = nodectl.wizard_choices() + settings = nodectl.node_settings() + state = nodectl.setup_state() + host = request.url.hostname or "127.0.0.1" + context: dict[str, Any] = { + "steps": STEPS, "step": step_id, "step_index": step_index(step_id), "choices": choices, "settings": settings, + "state": state, "error": error, "message": message, "host": host, "auth_user": AUTH_USER, + "page_title": "Мастер настройки", + } + if step_id == "welcome": + context["hardware"] = nodectl.hardware_summary() + context["timezones"] = nodectl.timezones() + context["profile_ready"] = context["hardware"]["kvm"] + elif step_id == "access": + context["tls"] = choices.get("tls", settings.get("VIRTUALITY_TLS", "0") == "1" or not settings.get("VIRTUALITY_TLS_SET")) + context["port"] = choices.get("port", settings.get("VIRTUALITY_WEB_PORT", "8088")) + elif step_id == "network": + context["net"] = nodectl.network_facts() + context["nat_ready"] = network_core.libvirt_network_info()["exists"] + elif step_id == "storage": + context["storage"] = nodectl.storage_facts() + elif step_id == "finish": + context["net"] = nodectl.network_facts() + context["hardware"] = nodectl.hardware_summary() + context["storage"] = nodectl.storage_facts() + context["timezone"] = choices.get("timezone") or nodectl.timezones()["current"] + tls = str(choices.get("tls", "1")) == "1" + port = str(choices.get("port", settings.get("VIRTUALITY_WEB_PORT", "8088"))) + context["final_url"] = f"https://{host}:{settings.get('VIRTUALITY_TLS_PORT', '8443')}" if tls else f"http://{host}:{port}" + context["restart_needed"] = (tls != (settings.get("VIRTUALITY_TLS") == "1")) or port != settings.get("VIRTUALITY_WEB_PORT", "8088") or str(choices.get("auto_update", settings.get("VIRTUALITY_AUTO_UPDATE", "1"))) != settings.get("VIRTUALITY_AUTO_UPDATE", "1") or str(choices.get("channel", settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable"))) != settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable") + return render(request, "setup.html", context, status_code=status_code) + + +@router.get("/setup") +def setup_index(request: Request): + auth = require_auth(request) + if auth: + return auth + choices = nodectl.wizard_choices() + state = nodectl.setup_state() + if not state["installed"]: + return RedirectResponse(url="/setup/install", status_code=303) + return RedirectResponse(url=f"/setup/{choices.get('step', 'welcome')}", status_code=303) + + +@router.get("/setup/{step_id}") +def setup_step(request: Request, step_id: str, message: str = "", error: str = ""): + auth = require_auth(request) + if auth: + return auth + if step_id not in STEP_IDS: + return RedirectResponse(url="/setup", status_code=303) + if step_id != "install" and not nodectl.setup_state()["installed"]: + return RedirectResponse(url="/setup/install", status_code=303) + if step_id != "install": + nodectl.save_wizard_choices(step=step_id) + return render_step(request, step_id, error=error or None, message=message or None) + + +@router.get("/api/setup/state") +def setup_state_api(request: Request): + if not get_current_user(request): + return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) + state = nodectl.setup_state() + return {"ok": True, "state": state, "net": {"revert_armed": nodectl.network_facts()["revert_armed"]} if request.query_params.get("net") else None} + + +# ---------------------------------------------------------------- step handlers +@router.post("/setup/welcome") +def setup_welcome(request: Request, timezone: str = Form("")): + auth = require_auth(request) + if auth: + return auth + if timezone: + ok, detail = nodectl.set_timezone(timezone) + if not ok: + return render_step(request, "welcome", error=detail, status_code=400) + nodectl.save_wizard_choices(timezone=timezone) + return RedirectResponse(url="/setup/account", status_code=303) + + +@router.post("/setup/account") +def setup_account(request: Request, password: str = Form(""), password2: str = Form("")): + auth = require_auth(request) + if auth: + return auth + if password or password2: + if password != password2: + return render_step(request, "account", error="Пароли не совпадают", status_code=400) + ok, detail = nodectl.change_password(AUTH_USER, password) + if not ok: + return render_step(request, "account", error=detail, status_code=400) + nodectl.save_wizard_choices(password_changed=True) + return RedirectResponse(url="/setup/access", status_code=303) + + +@router.post("/setup/access") +def setup_access(request: Request, tls: str = Form("1"), port: str = Form("8088")): + auth = require_auth(request) + if auth: + return auth + if not re.fullmatch(r"\d{2,5}", port) or not 1 <= int(port) <= 65535 or int(port) in (22, 9090): + return render_step(request, "access", error="Порт должен быть числом от 1 до 65535 (кроме 22 и 9090)", status_code=400) + nodectl.save_wizard_choices(tls="1" if tls == "1" else "0", port=port) + return RedirectResponse(url="/setup/network", status_code=303) + + +@router.post("/setup/network") +def setup_network(request: Request, mode: str = Form("nat")): + auth = require_auth(request) + if auth: + return auth + if mode not in ("nat", "bridge"): + return render_step(request, "network", error="Выберите режим сети", status_code=400) + if mode == "nat": + try: + network_core.create_nat_network() + except network_core.NetworkError as exc: + return render_step(request, "network", error=f"Не удалось создать сеть машин: {exc}", status_code=500) + nodectl.save_wizard_choices(network="nat") + return RedirectResponse(url="/setup/storage", status_code=303) + facts = nodectl.network_facts() + if facts["on_bridge"]: + nodectl.save_wizard_choices(network="bridge") + return RedirectResponse(url="/setup/storage", status_code=303) + if not facts["bridge_possible"]: + return render_step(request, "network", error="На этом сервере мост недоступен (Wi-Fi или не найден проводной интерфейс). Выберите NAT.", status_code=400) + ok, detail = nodectl.enable_bridge(facts["interface"], "dhcp") + if not ok: + return render_step(request, "network", error=detail, status_code=500) + nodectl.save_wizard_choices(network="bridge", bridge_pending=True) + return render(request, "setup_bridge_wait.html", {"page_title": "Перенастройка сети", "next_url": "/setup/storage", "confirm_url": "/setup/network/confirm", "revert_url": "/setup/network/revert", "back_url": "/setup/network"}) + + +@router.post("/setup/network/confirm") +def setup_network_confirm(request: Request): + if not get_current_user(request): + return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) + ok, detail = nodectl.confirm_bridge() + if ok: + nodectl.save_wizard_choices(bridge_pending=False, network="bridge") + profile = None + try: + import host_profile + + profile = host_profile.detect_host_profile() + host_profile.save_host_profile(profile) + except Exception: + pass + return JSONResponse({"ok": ok, "message": detail}) + + +@router.post("/setup/network/revert") +def setup_network_revert(request: Request): + auth = require_auth(request) + if auth: + return auth + ok, detail = nodectl.revert_bridge() + nodectl.save_wizard_choices(bridge_pending=False, network="nat") + return redirect_with_message("/setup/network", "message" if ok else "error", detail) + + +@router.post("/setup/storage") +def setup_storage(request: Request, disk: str = Form("")): + auth = require_auth(request) + if auth: + return auth + if disk and disk != "keep": + ok, detail = nodectl.use_disk_for_storage(disk) + if not ok: + return render_step(request, "storage", error=detail, status_code=400) + nodectl.save_wizard_choices(storage_disk=disk) + return RedirectResponse(url="/setup/updates", status_code=303) + + +@router.post("/setup/updates") +def setup_updates(request: Request, auto_update: str = Form("1"), channel: str = Form("stable")): + auth = require_auth(request) + if auth: + return auth + if channel not in ("stable", "main"): + channel = "stable" + nodectl.save_wizard_choices(auto_update="1" if auto_update == "1" else "0", channel=channel) + return RedirectResponse(url="/setup/finish", status_code=303) + + +@router.post("/setup/finish") +def setup_finish(request: Request): + auth = require_auth(request) + if auth: + return auth + choices = nodectl.wizard_choices() + settings = nodectl.node_settings() + host = request.url.hostname or "127.0.0.1" + changes = { + "VIRTUALITY_TLS": str(choices.get("tls", "1")), + "VIRTUALITY_WEB_PORT": str(choices.get("port", settings.get("VIRTUALITY_WEB_PORT", "8088"))), + "VIRTUALITY_AUTO_UPDATE": str(choices.get("auto_update", settings.get("VIRTUALITY_AUTO_UPDATE", "1"))), + "VIRTUALITY_UPDATE_CHANNEL": str(choices.get("channel", settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable"))), + } + current = {"VIRTUALITY_TLS": settings.get("VIRTUALITY_TLS", "0"), "VIRTUALITY_WEB_PORT": settings.get("VIRTUALITY_WEB_PORT", "8088"), "VIRTUALITY_AUTO_UPDATE": settings.get("VIRTUALITY_AUTO_UPDATE", "1"), "VIRTUALITY_UPDATE_CHANNEL": settings.get("VIRTUALITY_UPDATE_CHANNEL", "stable")} + nodectl.mark_wizard_done() + nodectl.save_wizard_choices(step="finish", finished=True) + if changes != current and nodectl.ctl_available(): + ok, detail = nodectl.apply_settings(changes) + if not ok: + return render_step(request, "finish", error=detail, status_code=500) + final_url = f"https://{host}:{settings.get('VIRTUALITY_TLS_PORT', '8443')}" if changes["VIRTUALITY_TLS"] == "1" else f"http://{host}:{changes['VIRTUALITY_WEB_PORT']}" + return render(request, "setup_applying.html", {"page_title": "Применяем настройки", "final_url": final_url, "tls": changes["VIRTUALITY_TLS"] == "1"}) + return redirect_with_message("/", "message", "Сервер настроен. Можно создавать машины.") + + +@router.get("/setup/skip") +def setup_skip(request: Request): + auth = require_auth(request) + if auth: + return auth + nodectl.mark_wizard_done() + return redirect_with_message("/", "message", "Мастер пропущен. Его можно запустить позже в Настройках.") + + +@router.post("/setup/restart") +def setup_restart(request: Request): + """Re-run the wizard from Settings.""" + auth = require_auth(request) + if auth: + return auth + try: + nodectl.SETUP_DONE.unlink() + except OSError: + pass + nodectl.save_wizard_choices(step="welcome", finished=False) + if not nodectl.SETUP_STATE.exists(): + nodectl.CONFIG_DIR.mkdir(parents=True, exist_ok=True) + nodectl.SETUP_STATE.write_text('{"stage": "done", "steps": []}\n') + return RedirectResponse(url="/setup/welcome", status_code=303) + + +def virtualization_hint() -> str: + result = run_cmd(["systemd-detect-virt"], timeout=5) + return cmd_error(result, "") if not result["ok"] else result["stdout"] + + +def format_bytes(value: int) -> str: + return presenters.format_bytes(value) diff --git a/web/features/snapshots.py b/web/features/snapshots.py new file mode 100644 index 0000000..50394d1 --- /dev/null +++ b/web/features/snapshots.py @@ -0,0 +1,361 @@ +"""«Снимки» (точки восстановления) виртуальной машины — внутренние снимки +libvirt через `virsh snapshot-*`. + +Снимок хранится внутри qcow2-диска машины. У работающей машины в снимок +попадает и память (машина на несколько секунд замирает), у выключенной — +только диск. Создание и возврат выполняются как фоновые операции. +""" +import re +import threading +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta +from typing import Any + +from fastapi import APIRouter, Form, Request +from fastapi.responses import JSONResponse, RedirectResponse + +import presenters +from core import ( + active_operations_for, + append_operation_log, + cmd_error, + finish_operation, + list_operations, + new_operation, + read_operation, + redirect_with_message, + render, + require_auth, + run_cmd, + run_operation, + update_operation, + valid_label, + valid_vm_name, +) + +router = APIRouter() + +MAX_SNAPSHOTS = 8 +DESCRIPTION_MAX = 200 +SNAPSHOT_TIMEOUT = 1800 # секунд: снимок памяти большой машины идёт минуты +OPERATION_TYPES = ("snapshot_create", "snapshot_revert") + +SNAPSHOT_STATES = { + "running": ("Работала", "success"), + "paused": ("Была на паузе", "warning"), + "shutoff": ("Была выключена", "neutral"), + "disk-snapshot": ("Только диск", "info"), +} + +MONTHS = ["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"] + +# Известные ошибки libvirt → понятное объяснение (подстрока ищется без учёта регистра). +KNOWN_ERRORS = [ + ("pflash", "Для машин с UEFI-прошивкой эта версия libvirt не умеет делать внутренние снимки."), + ("nvram", "Для машин с UEFI-прошивкой эта версия libvirt не умеет делать внутренние снимки."), + ("unsupported for storage type", "Внутренние снимки работают только с дисками qcow2."), + ("does not support snapshot", "Внутренние снимки работают только с дисками qcow2."), + ("internal snapshot for disk", "Внутренние снимки работают только с дисками qcow2."), + ("cannot acquire state change lock", "Машина занята другой операцией. Подождите минуту и попробуйте снова."), + ("timed out during operation", "Машина занята другой операцией. Подождите минуту и попробуйте снова."), + ("snapshot not found", "Такого снимка уже нет — возможно, его удалили."), + ("already exists", "Снимок с таким именем уже есть."), + ("no space left", "На диске сервера закончилось место."), + ("is not running", "Машина выключена, поэтому это действие сейчас недоступно."), +] + +_ROW_RE = re.compile(r"^\s*(\S.*?)\s+(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})(?:\s+([+-]\d{4}))?\s+(\S+)\s*$") + + +# ---------------------------------------------------------------- parsers +def parse_snapshot_list(text: str) -> list[dict[str, Any]]: + """Таблица `virsh snapshot-list` → список снимков, новые сверху.""" + rows = [] + for line in (text or "").splitlines(): + match = _ROW_RE.match(line) + if not match: + continue + name, date, time, tz, state = match.groups() + rows.append({"name": name, "created_at": f"{date} {time}", "tz": tz or "", "state": state}) + return sorted(rows, key=lambda row: row["created_at"], reverse=True) + + +def parse_snapshot_xml(text: str) -> dict[str, Any]: + """`virsh snapshot-dumpxml` → описание, состояние, есть ли память.""" + try: + root = ET.fromstring(text or "") + except ET.ParseError: + return {} + memory = root.find("memory") + parent = root.find("parent/name") + return { + "description": (root.findtext("description") or "").strip(), + "state": (root.findtext("state") or "").strip(), + "with_memory": memory is not None and memory.get("snapshot", "no") not in ("no", ""), + "parent": (parent.text or "").strip() if parent is not None else "", + } + + +def inspect_domain_xml(text: str) -> dict[str, Any]: + """`virsh dumpxml` → форматы дисков и признак UEFI (для проверок перед снимком).""" + info: dict[str, Any] = {"disks": [], "unsupported": [], "uefi": False} + try: + root = ET.fromstring(text or "") + except ET.ParseError: + return info + for disk in root.iter("disk"): + if disk.get("device", "disk") != "disk": + continue + driver = disk.find("driver") + target = disk.find("target") + fmt = (driver.get("type") if driver is not None else "") or "" + dev = (target.get("dev") if target is not None else "") or "?" + info["disks"].append({"target": dev, "format": fmt or "неизвестно"}) + if fmt and fmt != "qcow2": + info["unsupported"].append({"target": dev, "format": fmt}) + os_node = root.find("os") + if os_node is not None: + loader = os_node.find("loader") + info["uefi"] = os_node.get("firmware") == "efi" or os_node.find("nvram") is not None or (loader is not None and loader.get("type") == "pflash") + return info + + +def snapshot_state(state: str) -> dict[str, str]: + label, tone = SNAPSHOT_STATES.get((state or "").strip().lower(), (state or "—", "neutral")) + return {"label": label, "tone": tone} + + +def format_when(created_at: str, now: datetime | None = None) -> str: + """'2026-09-25 10:12:03' → 'Сегодня, 10:12' / 'Вчера, 10:12' / '25 сентября 2026, 10:12'.""" + try: + moment = datetime.strptime(created_at, "%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError): + return created_at or "—" + today = (now or datetime.now()).date() + clock = moment.strftime("%H:%M") + if moment.date() == today: + return f"Сегодня, {clock}" + if moment.date() == today - timedelta(days=1): + return f"Вчера, {clock}" + return f"{moment.day} {MONTHS[moment.month - 1]} {moment.year}, {clock}" + + +def explain_error(raw: str) -> str: + """Текст ошибки libvirt → понятная фраза; технический текст остаётся в скобках.""" + text = re.sub(r"^error:\s*", "", (raw or "").strip(), flags=re.IGNORECASE) + first = text.splitlines()[0] if text else "" + low = first.lower() + for needle, explanation in KNOWN_ERRORS: + if needle in low: + return f"{explanation} (libvirt: {first})" + return first or "libvirt не выполнил команду и не объяснил причину." + + +def next_snapshot_name(existing: list[str], now: datetime | None = None) -> str: + stamp = (now or datetime.now()).strftime("snap-%Y%m%d-%H%M") + name, counter = stamp, 2 + while name in existing: + name, counter = f"{stamp}-{counter}", counter + 1 + return name + + +def clean_description(text: str) -> str: + text = re.sub(r"[\x00-\x1f\x7f]+", " ", text or "") + return re.sub(r"\s+", " ", text).strip().lstrip("-").strip()[:DESCRIPTION_MAX] + + +# ---------------------------------------------------------------- virsh +def current_snapshot(vm: str) -> str: + result = run_cmd(["virsh", "snapshot-current", "--domain", vm, "--name"], timeout=10) + return result["stdout"].strip() if result["ok"] else "" + + +def list_snapshots(vm: str) -> list[dict[str, Any]]: + result = run_cmd(["virsh", "snapshot-list", "--domain", vm], timeout=20) + rows = parse_snapshot_list(result["stdout"]) if result["ok"] else [] + current = current_snapshot(vm) if rows else "" + for row in rows: + xml = run_cmd(["virsh", "snapshot-dumpxml", "--domain", vm, "--snapshotname", row["name"]], timeout=10) + meta = parse_snapshot_xml(xml["stdout"]) if xml["ok"] else {} + row["description"] = meta.get("description", "") + row["with_memory"] = meta.get("with_memory", row["state"] == "running") + row["is_current"] = row["name"] == current + row["state_info"] = snapshot_state(row["state"]) + row["when"] = format_when(row["created_at"]) + row["title"] = row["description"] or row["name"] + # Snapshots made outside the panel (virsh, virt-manager) may have names the panel's URLs do not accept. + row["manageable"] = valid_label(row["name"]) + return rows + + +def domain_checks(vm: str) -> dict[str, Any]: + result = run_cmd(["virsh", "dumpxml", vm], timeout=10) + return inspect_domain_xml(result["stdout"] if result["ok"] else "") + + +def vm_operations(vm: str) -> list[dict[str, Any]]: + return [op for op in list_operations(50) if op.get("type") in OPERATION_TYPES and op.get("vm_name") == vm] + + +def active_operations(vm: str) -> list[dict[str, Any]]: + return active_operations_for(vm, OPERATION_TYPES) + + +def create_blockers(vm: str, snapshots: list[dict[str, Any]], checks: dict[str, Any]) -> list[str]: + """Почему снимок сейчас нельзя создать (пустой список — можно).""" + reasons = [] + if len(snapshots) >= MAX_SNAPSHOTS: + reasons.append(f"Уже {MAX_SNAPSHOTS} снимков — это максимум для одной машины. Удалите ненужный снимок, чтобы создать новый.") + if checks.get("unsupported"): + disks = ", ".join(f"{d['target']} ({d['format']})" for d in checks["unsupported"]) + reasons.append(f"Внутренние снимки работают только с дисками qcow2, а у этой машины диск другого формата: {disks}.") + if active_operations(vm): + reasons.append("Дождитесь окончания предыдущей операции со снимками этой машины.") + return reasons + + +def _log_result(operation: dict[str, Any], result: dict[str, Any]) -> None: + for stream in ("stdout", "stderr"): + if result.get(stream): + append_operation_log(operation["id"], result[stream]) + + +def create_worker(operation: dict[str, Any]) -> None: + vm, snap, description = operation["vm_name"], operation["snapshot"], operation.get("description", "") + cmd = ["virsh", "snapshot-create-as", "--domain", vm, "--name", snap] + if description: + cmd += ["--description", description] + cmd.append("--atomic") + append_operation_log(operation["id"], "$ " + " ".join(cmd)) + update_operation(operation, progress=15, message="Сохраняем состояние машины и памяти — она на несколько секунд замрёт…" if operation.get("vm_running") else "Сохраняем состояние диска…", cmd=" ".join(cmd)) + result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT) + _log_result(operation, result) + if result["ok"]: + finish_operation(operation, True, f"Снимок «{snap}» создан") + else: + finish_operation(operation, False, explain_error(cmd_error(result, "virsh завершился с ошибкой"))) + + +def revert_worker(operation: dict[str, Any]) -> None: + vm, snap = operation["vm_name"], operation["snapshot"] + cmd = ["virsh", "snapshot-revert", "--domain", vm, "--snapshotname", snap] + append_operation_log(operation["id"], "$ " + " ".join(cmd)) + update_operation(operation, progress=15, message="Возвращаем машину к сохранённому состоянию…", cmd=" ".join(cmd)) + result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT) + _log_result(operation, result) + if not result["ok"] and "revert requires force" in (result.get("stderr") or "").lower(): + # libvirt просит --force, когда конфигурация машины изменилась после снимка: пользователь уже подтвердил возврат. + append_operation_log(operation["id"], "libvirt требует подтверждения (--force): повторяем принудительно.") + cmd.append("--force") + append_operation_log(operation["id"], "$ " + " ".join(cmd)) + result = run_cmd(cmd, timeout=SNAPSHOT_TIMEOUT) + _log_result(operation, result) + if not result["ok"]: + finish_operation(operation, False, explain_error(cmd_error(result, "virsh завершился с ошибкой"))) + return + state = presenters.vm_state(run_cmd(["virsh", "domstate", vm], timeout=8)["stdout"]) + finish_operation(operation, True, f"Машина возвращена к снимку «{snap}». Сейчас она {'работает' if state['running'] else 'выключена'}.") + + +# ---------------------------------------------------------------- routes +def _guard(request: Request, name: str): + """Редирект на /login, 400 для плохого имени, редирект на / для несуществующей машины — или None + dominfo.""" + auth_redirect = require_auth(request) + if auth_redirect: + return auth_redirect, "" + if not valid_vm_name(name): + return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400), "" + dominfo = run_cmd(["virsh", "dominfo", name], timeout=8) + if not dominfo["ok"]: + return RedirectResponse(url="/", status_code=303), "" + return None, dominfo["stdout"] + + +@router.get("/vm/{name}/snapshots") +def snapshots_page(request: Request, name: str): + blocked, dominfo = _guard(request, name) + if blocked: + return blocked + info = presenters.parse_dominfo(dominfo) + snapshots = list_snapshots(name) + checks = domain_checks(name) + running = vm_active(info["state"]) + op_id = request.query_params.get("op", "") + operation = read_operation(op_id) if op_id else None + if operation and (operation.get("vm_name") != name or operation.get("type") not in OPERATION_TYPES): + operation = None + return render(request, "vm_snapshots.html", { + "vm": {"name": name, "info": info, "dominfo": dominfo}, + "vm_running": running, + "snapshots": snapshots, + "max_snapshots": MAX_SNAPSHOTS, + "checks": checks, + "blockers": create_blockers(name, snapshots, checks), + "suggested_name": next_snapshot_name([s["name"] for s in snapshots]), + "description_max": DESCRIPTION_MAX, + "operation": operation, + "active_operations": active_operations(name), + "snapshot_message": request.query_params.get("snapshot_message", ""), + "snapshot_error": request.query_params.get("snapshot_error", ""), + "raw_list": run_cmd(["virsh", "snapshot-list", "--domain", name], timeout=20)["stdout"], + }) + + +def vm_active(state: str) -> bool: + """Running or paused: libvirt saves the memory of both into the snapshot.""" + return presenters.vm_state(state)["running"] or "paus" in (state or "").lower() + + +_create_lock = threading.Lock() + + +@router.post("/vm/{name}/snapshots/create") +def snapshot_create(request: Request, name: str, description: str = Form("")): + with _create_lock: # two quick clicks must not both pass the checks below + return _snapshot_create(request, name, description) + + +def _snapshot_create(request: Request, name: str, description: str): + blocked, dominfo = _guard(request, name) + if blocked: + return blocked + snapshots = list_snapshots(name) + blockers = create_blockers(name, snapshots, domain_checks(name)) + if blockers: + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", blockers[0]) + running = vm_active(presenters.parse_dominfo(dominfo)["state"]) + snap = next_snapshot_name([s["name"] for s in snapshots]) + if not valid_label(snap): + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Не удалось подобрать имя снимка.") + operation = new_operation("snapshot_create", f"Снимок машины {name}", vm_name=name, snapshot=snap, description=clean_description(description), vm_running=running) + run_operation(operation, create_worker) + return RedirectResponse(url=f"/vm/{name}/snapshots?op={operation['id']}", status_code=303) + + +@router.post("/vm/{name}/snapshots/{snap}/revert") +def snapshot_revert(request: Request, name: str, snap: str): + blocked, _ = _guard(request, name) + if blocked: + return blocked + if not valid_label(snap): + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Этот снимок создан вне панели и имеет необычное имя — управляйте им через virsh.") + if active_operations(name): + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Дождитесь окончания предыдущей операции со снимками этой машины.") + operation = new_operation("snapshot_revert", f"Возврат машины {name} к снимку {snap}", vm_name=name, snapshot=snap) + run_operation(operation, revert_worker) + return RedirectResponse(url=f"/vm/{name}/snapshots?op={operation['id']}", status_code=303) + + +@router.post("/vm/{name}/snapshots/{snap}/delete") +def snapshot_delete(request: Request, name: str, snap: str): + blocked, _ = _guard(request, name) + if blocked: + return blocked + if not valid_label(snap): + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Этот снимок создан вне панели и имеет необычное имя — управляйте им через virsh.") + if active_operations(name): + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Дождитесь окончания предыдущей операции со снимками этой машины.") + result = run_cmd(["virsh", "snapshot-delete", "--domain", name, "--snapshotname", snap], timeout=180) + if not result["ok"]: + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_error", "Не удалось удалить снимок: " + explain_error(cmd_error(result, "virsh завершился с ошибкой"))) + return redirect_with_message(f"/vm/{name}/snapshots", "snapshot_message", f"Снимок «{snap}» удалён") diff --git a/web/features/vmops.py b/web/features/vmops.py new file mode 100644 index 0000000..1912d54 --- /dev/null +++ b/web/features/vmops.py @@ -0,0 +1,599 @@ +"""Повседневные операции с виртуальной машиной, которых ждут от «простого +Proxmox»: увеличение диска, клонирование, пауза и спящий режим, заметки +(название и описание) и живая нагрузка процессора и памяти. + +Всё, что занимает больше пары секунд (клонирование, спящий режим), идёт как +фоновая операция с прогрессом и журналом на странице /operations/{id}. +""" +import json +import re +import shutil +import threading +import time +from pathlib import Path +from typing import Any +from urllib.parse import quote + +from fastapi import APIRouter, Form, Request +from fastapi.responses import JSONResponse, RedirectResponse + +import core +import presenters +from core import ( + IMAGES_POOL, + append_operation_log, + cmd_error, + finish_operation, + new_operation, + redirect_with_message, + render, + require_api_auth, + require_auth, + run_operation, + running_operations, + templates, + update_operation, + valid_vm_name, + vm_exists, + vm_state, +) + +router = APIRouter() + +GIB = 1024 ** 3 +MAX_DISK_GB = 16 * 1024 # 16 ТБ — потолок формы, чтобы опечатка не сделала диск на петабайт +TITLE_MAX = 80 +DESCRIPTION_MAX = 2000 +CLONE_TIMEOUT = 6 * 3600 # секунд: копия терабайтного диска на HDD идёт часами +HIBERNATE_TIMEOUT = 1800 +SHUT_OFF_STATES = ("shut off", "shutoff", "shut-off") +ACTIVE_STATES = ("running", "idle", "blocked") +SAVE_DIR = Path("/var/lib/libvirt/qemu/save") # сюда libvirt кладёт память «спящих» машин +RESIZABLE_FORMATS = ("qcow2", "raw") + +GUEST_GROW_HINT = "Если система ставилась из облачного образа, место внутри появится само. Иначе расширьте раздел в самой системе: в Linux — growpart или GParted, в Windows — «Управление дисками» → «Расширить том»." + +# Известные ошибки virsh/qemu-img/virt-clone → понятное объяснение (подстрока без учёта регистра). +KNOWN_ERRORS = [ + ("has snapshots", "У диска есть снимки — сначала удалите снимки машины, затем увеличьте диск."), + ("no space left", "На диске сервера закончилось место."), + ("cannot acquire state change lock", "Машина занята другой операцией. Подождите минуту и попробуйте снова."), + ("timed out during operation", "Машина занята другой операцией. Подождите минуту и попробуйте снова."), + ("already exists", "Машина или файл с таким именем уже есть."), + ("is not running", "Машина выключена, поэтому это действие сейчас недоступно."), + ("not paused", "Машина не приостановлена."), + ("already active", "Машина уже работает."), + ("guest agent", "Внутри машины нет гостевого агента, поэтому это действие недоступно."), + ("permission denied", "Нет прав на файл диска. Проверьте, что диск лежит в хранилище Virtuality."), +] + +OPS = { + "pause": { + "cmd": ["virsh", "suspend"], + "states": ACTIVE_STATES, + "done": "Машина {name} приостановлена: она не тратит процессор, но остаётся в памяти сервера", + "wrong": "Приостановить можно только работающую машину", + "fail": "Не удалось приостановить машину", + }, + "resume": { + "cmd": ["virsh", "resume"], + "states": ("paused",), + "done": "Машина {name} продолжает работу", + "wrong": "Продолжить можно только приостановленную машину", + "fail": "Не удалось продолжить работу машины", + }, + "forget-save": { + "cmd": ["virsh", "managedsave-remove"], + "states": SHUT_OFF_STATES, + "done": "Сохранённое состояние удалено: при запуске машина {name} загрузится заново", + "wrong": "Сохранённое состояние есть только у выключенной машины", + "fail": "Не удалось удалить сохранённое состояние", + }, +} + + +def run(cmd: list[str], timeout: int = 12) -> dict[str, Any]: + """Все команды идут через core.run_cmd — тесты подменяют его целиком.""" + return core.run_cmd(cmd, timeout=timeout) + + +def explain_error(raw: str, fallback: str) -> str: + low = (raw or "").lower() + for needle, text in KNOWN_ERRORS: + if needle in low: + return text + return raw or fallback + + +def is_shut_off(state: str) -> bool: + return (state or "").strip().lower() in SHUT_OFF_STATES + + +def redirect_tab(name: str, tab: str, key: str, message: str) -> RedirectResponse: + """Как redirect_with_message, но открывает нужную вкладку страницы машины.""" + return RedirectResponse(url=f"/vm/{name}?{key}={quote(message)}#{tab}", status_code=303) + + +def safe_next(value: str, name: str) -> str: + """Куда вернуть пользователя после действия: только обзор или страница машины.""" + return value if value in ("/", f"/vm/{name}") else f"/vm/{name}" + + +def _guard(request: Request, name: str): + """Общая проверка для всех маршрутов: вход, имя, существование машины.""" + denied = require_auth(request) + if denied: + return denied + if not valid_vm_name(name): + return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400) + if not vm_exists(name): + return RedirectResponse(url="/", status_code=303) + return None + + +# ---------------------------------------------------------------- parsers +def parse_qemu_img_info(text: str) -> dict[str, Any]: + """`qemu-img info --output=json` → виртуальный и реальный размер, формат.""" + try: + data = json.loads(text or "") + except ValueError: + return {} + if not isinstance(data, dict): + return {} + try: + return {"virtual_size": int(data.get("virtual-size") or 0), "actual_size": int(data.get("actual-size") or 0), "format": str(data.get("format") or "")} + except (TypeError, ValueError): + return {} + + +def parse_domstats(text: str) -> dict[str, dict[str, str]]: + """`virsh domstats` → {имя: {поле: значение}}. Машины без статистики дают пустой словарь.""" + stats: dict[str, dict[str, str]] = {} + current: str | None = None + for raw_line in (text or "").splitlines(): + line = raw_line.strip() + match = re.fullmatch(r"Domain:\s*'(.+)'", line) + if match: + current = match.group(1) + stats[current] = {} + continue + if current is None or "=" not in line: + continue + key, value = line.split("=", 1) + stats[current][key.strip()] = value.strip() + return stats + + +def parse_desc_output(result: dict[str, Any]) -> str: + """Вывод `virsh desc`: пусто, если названия/описания нет.""" + if not result.get("ok"): + return "" + text = (result.get("stdout") or "").strip() + if re.match(r"^No (title|description) for domain", text): + return "" + return text + + +def saved_state(name: str) -> bool: + """The machine is hibernated (virsh managedsave): its disks must not change until it is resumed.""" + return has_managed_save(run(["virsh", "dominfo", name], timeout=10)["stdout"]) + + +def has_managed_save(dominfo: str) -> bool: + """Строка «Managed save: yes» в `virsh dominfo` — машина в спящем режиме.""" + match = re.search(r"^Managed save:\s*(\S+)", dominfo or "", re.MULTILINE | re.IGNORECASE) + return bool(match) and match.group(1).lower() in ("yes", "on", "true") + + +def _int(value: str | None) -> int: + try: + return int(value or 0) + except ValueError: + return 0 + + +def compute_stats(stats: dict[str, dict[str, str]], now: float, samples: dict[str, tuple[float, int]]) -> dict[str, dict[str, Any]]: + """Проценты процессора считаются по разнице cpu.time с прошлым замером той же машины. + + Первый замер даёт cpu_pct=None (нечего сравнивать). Память — balloon.rss + (реально занятая на сервере), если нет — balloon.current. + """ + result: dict[str, dict[str, Any]] = {} + for name, fields in stats.items(): + cpu_time = _int(fields.get("cpu.time")) + vcpus = _int(fields.get("vcpu.current")) or _int(fields.get("vcpu.maximum")) or 1 + mem_used_kib = _int(fields.get("balloon.rss")) or _int(fields.get("balloon.current")) + mem_total_kib = _int(fields.get("balloon.maximum")) or _int(fields.get("balloon.current")) + if not cpu_time and not mem_total_kib: + samples.pop(name, None) + continue # выключенная машина — статистики нет + cpu_pct = None + previous = samples.get(name) + if previous and now > previous[0] and cpu_time >= previous[1]: + cpu_pct = (cpu_time - previous[1]) / ((now - previous[0]) * 1e9) / vcpus * 100 + cpu_pct = round(min(100.0, max(0.0, cpu_pct)), 1) + samples[name] = (now, cpu_time) + result[name] = {"cpu_pct": cpu_pct, "mem_used_mb": mem_used_kib // 1024, "mem_total_mb": mem_total_kib // 1024, "vcpus": vcpus} + for stale in set(samples) - set(stats): + samples.pop(stale, None) + return result + + +# ---------------------------------------------------------------- live stats +_samples: dict[str, tuple[float, int]] = {} +_samples_lock = threading.Lock() + + +def vm_stats(names: list[str]) -> dict[str, dict[str, Any]]: + """Нагрузка нескольких машин одним вызовом `virsh domstats`.""" + if not names: + return {} + result = run(["virsh", "domstats", "--cpu-total", "--balloon", "--vcpu", *names], timeout=10) + if not result["ok"]: + return {} + with _samples_lock: + return compute_stats(parse_domstats(result["stdout"]), time.monotonic(), _samples) + + +def active_vm_names() -> list[str]: + """Имена работающих и приостановленных машин (`virsh list --name`).""" + result = run(["virsh", "list", "--name"], timeout=8) + if not result["ok"]: + return [] + return [line.strip() for line in result["stdout"].splitlines() if valid_vm_name(line.strip())] + + +def all_vm_names() -> list[str]: + result = run(["virsh", "list", "--all", "--name"], timeout=8) + if not result["ok"]: + return [] + return [line.strip() for line in result["stdout"].splitlines() if line.strip()] + + +@router.get("/live/stats") +def live_stats(request: Request): + denied = require_api_auth(request) + if denied: + return denied + return JSONResponse({"ok": True, "generated_at": core.utc_now(), "stats": vm_stats(active_vm_names())}) + + +# ---------------------------------------------------------------- titles & notes +def vm_titles() -> dict[str, str]: + """Названия всех машин одним вызовом — для списка на обзоре.""" + result = run(["virsh", "list", "--all", "--title"], timeout=10) + return presenters.parse_virsh_list_titles(result["stdout"]) if result["ok"] else {} + + +def read_notes(name: str) -> dict[str, str]: + """Название и описание машины. Без флагов virsh отдаёт текущее значение: + у работающей машины — живое, у выключенной — из конфигурации.""" + title = run(["virsh", "-q", "desc", name, "--title"], timeout=8) + description = run(["virsh", "-q", "desc", name], timeout=8) + return {"title": parse_desc_output(title), "description": parse_desc_output(description)} + + +def clean_title(value: str) -> str: + return " ".join((value or "").split())[:TITLE_MAX].strip() + + +def clean_description(value: str) -> str: + text = (value or "").replace("\r\n", "\n").replace("\r", "\n") + lines = [line.rstrip() for line in text.split("\n")] + return "\n".join(lines).strip()[:DESCRIPTION_MAX].strip() + + +def write_notes(name: str, title: str, description: str) -> tuple[bool, str]: + """`virsh desc` в конфигурацию, а у работающей машины — ещё и вживую. + Текст передаётся как --new-desc=ТЕКСТ одним аргументом: так virsh не + примет описание вида «--edit» за свою опцию.""" + flags = ["--config"] + (["--live"] if not is_shut_off(vm_state(name)) else []) + for extra, value in ((["--title"], title), ([], description)): + result = run(["virsh", "desc", name, *extra, f"--new-desc={value}", *flags], timeout=10) + if not result["ok"]: + return False, explain_error(cmd_error(result, "virsh desc завершился с ошибкой"), "Не удалось сохранить заметки") + return True, "Заметки сохранены" + + +@router.post("/vm/{name}/notes/save") +def notes_save(request: Request, name: str, title: str = Form(""), description: str = Form("")): + denied = _guard(request, name) + if denied: + return denied + ok, message = write_notes(name, clean_title(title), clean_description(description)) + return redirect_with_message(f"/vm/{name}", "notes_message" if ok else "notes_error", message) + + +# ---------------------------------------------------------------- disks +def inside_storage(path: str) -> bool: + """Диск должен лежать в /var/lib/virtuality — чужие файлы панель не трогает.""" + try: + resolved = Path(path).resolve() + except (OSError, RuntimeError): + return False + return Path(core.STORAGE_DIR).resolve() in resolved.parents + + +def free_space(path: str | Path) -> int: + """Свободное место рядом с файлом диска (или в хранилище образов).""" + for candidate in (Path(path).parent, Path(core.IMAGES_DIR), Path("/")): + try: + return shutil.disk_usage(candidate).free + except OSError: + continue + return 0 + + +def disk_info(path: str) -> dict[str, Any]: + # -U (--force-share): иначе qemu-img не откроет диск работающей машины из-за блокировки. + result = run(["qemu-img", "info", "--output=json", "-U", path], timeout=20) + return parse_qemu_img_info(result["stdout"]) if result["ok"] else {} + + +def vm_disks(name: str) -> list[dict[str, Any]]: + """Диски машины с размерами; у подходящих — флаг resizable и минимальный новый размер.""" + result = run(["virsh", "domblklist", name, "--details"], timeout=10) + disks = presenters.parse_domblklist(result["stdout"]) if result["ok"] else [] + items = [] + for disk in disks: + item: dict[str, Any] = dict(disk, virtual_size=0, actual_size=0, format="", size_text="", used_text="", resizable=False, min_gb=0) + if disk["device"] == "disk" and disk["source"] and disk["type"] == "file": + info = disk_info(disk["source"]) + virtual, actual = info.get("virtual_size", 0), info.get("actual_size", 0) + item.update(virtual_size=virtual, actual_size=actual, format=info.get("format", "")) + if virtual: + item["size_text"] = presenters.format_bytes(virtual) + item["used_text"] = presenters.format_bytes(actual) if actual else "" + item["min_gb"] = virtual // GIB + 1 + item["resizable"] = item["format"] in RESIZABLE_FORMATS and inside_storage(disk["source"]) + items.append(item) + return items + + +def disks_context(name: str) -> dict[str, Any]: + """Данные для карточки «Диски» на странице машины (вызывается из шаблона).""" + disks = vm_disks(name) + first = next((d["source"] for d in disks if d["source"]), None) + return {"disks": disks, "free_text": presenters.format_bytes(free_space(first or core.IMAGES_DIR)), "max_gb": MAX_DISK_GB, "hint": GUEST_GROW_HINT} + + +@router.post("/vm/{name}/disks/{target}/resize") +def disk_resize(request: Request, name: str, target: str, size_gb: int = Form(...)): + denied = _guard(request, name) + if denied: + return denied + if not re.fullmatch(r"[a-z0-9]{1,16}", target or ""): + return JSONResponse({"ok": False, "error": "Invalid disk target"}, status_code=400) + disk = next((d for d in vm_disks(name) if d["target"] == target), None) + if not disk: + return redirect_tab(name, "storage", "disk_error", "Такого диска у машины нет") + if not disk["resizable"]: + return redirect_tab(name, "storage", "disk_error", f"Диск {disk['name']} нельзя увеличить из панели: он не в хранилище Virtuality или в необычном формате") + if size_gb < disk["min_gb"]: + return redirect_tab(name, "storage", "disk_error", f"Диск можно только увеличивать: укажите не меньше {disk['min_gb']} ГБ") + if size_gb > MAX_DISK_GB: + return redirect_tab(name, "storage", "disk_error", f"Слишком большой размер: не больше {MAX_DISK_GB // 1024} ТБ") + growth = size_gb * GIB - disk["virtual_size"] + free = free_space(disk["source"]) + if growth > free: + return redirect_tab(name, "storage", "disk_error", f"На сервере свободно только {presenters.format_bytes(free)}: столько добавить диску не получится") + if saved_state(name): + return redirect_tab(name, "storage", "disk_error", "Машина в спящем режиме: сохранённая память рассчитана на старый размер диска. Сначала запустите её и выключите обычным образом.") + if is_shut_off(vm_state(name)): + result = run(["qemu-img", "resize", disk["source"], f"{size_gb}G"], timeout=120) + else: + result = run(["virsh", "blockresize", name, target, f"{size_gb}G"], timeout=60) + if not result["ok"]: + return redirect_tab(name, "storage", "disk_error", explain_error(cmd_error(result, "Не удалось увеличить диск"), "Не удалось увеличить диск")) + return redirect_tab(name, "storage", "disk_message", f"Диск {disk['name']} увеличен до {size_gb} ГБ. {GUEST_GROW_HINT}") + + +# ---------------------------------------------------------------- clone +def suggest_clone_name(name: str, existing: set[str]) -> str: + base = f"{name}-copy"[:60] + candidate, index = base, 2 + while candidate in existing: + candidate = f"{base}{index}" + index += 1 + return candidate + + +def clone_targets(new_name: str, disks: list[dict[str, Any]]) -> list[Path]: + """Файлы дисков копии: DST.qcow2, DST-disk2.qcow2, … в порядке domblklist.""" + targets = [] + cloneable = [d for d in disks if d["device"] == "disk" and d["source"]] + for index, disk in enumerate(cloneable): + ext = Path(disk["source"]).suffix.lower() + ext = ext if ext in (".qcow2", ".img", ".raw") else ".qcow2" + suffix = "" if index == 0 else f"-disk{index + 1}" + targets.append(Path(core.IMAGES_DIR) / f"{new_name}{suffix}{ext}") + return targets + + +def clone_context(name: str) -> dict[str, Any]: + state = vm_state(name) + disks = vm_disks(name) + cloneable = [d for d in disks if d["device"] == "disk" and d["source"]] + needed = sum(d["actual_size"] or d["virtual_size"] for d in cloneable) + free = free_space(core.IMAGES_DIR) + existing = set(all_vm_names()) + blockers = [] + if not is_shut_off(state): + blockers.append("running") + elif saved_state(name): + blockers.append("saved") + if not cloneable: + blockers.append("no_disks") + if needed > free: + blockers.append("space") + if any(op.get("source_vm") == name for op in running_operations("vm_clone")): + blockers.append("busy") + return { + "state": state, "is_shut_off": is_shut_off(state), "disks": cloneable, "needed_text": presenters.format_bytes(needed), + "free_text": presenters.format_bytes(free), "suggested": suggest_clone_name(name, existing), "blockers": blockers, + "vcpus": 0, "memory_mb": 0, + } + + +def _allocated_bytes(path: Path) -> int: + try: + stat = path.stat() + except OSError: + return 0 + blocks = getattr(stat, "st_blocks", None) + return blocks * 512 if blocks is not None else stat.st_size + + +def _watch_clone_progress(operation: dict[str, Any], targets: list[Path], expected: int, stop: threading.Event) -> None: + """Пока virt-clone копирует, следим за ростом файлов копии и обновляем прогресс.""" + while not stop.wait(1.0): + copied = sum(_allocated_bytes(target) for target in targets) + if expected > 0: + pct = min(95, max(3, int(copied * 95 / expected))) + update_operation(operation, progress=pct, message=f"Копируем диски: {presenters.format_bytes(copied)} из {presenters.format_bytes(expected)}") + + +def clone_worker(operation: dict[str, Any]) -> None: + source, new_name = operation["source_vm"], operation["vm_name"] + targets = [Path(item) for item in operation.get("targets", [])] + cmd = ["virt-clone", "--original", source, "--name", new_name] + for target in targets: + cmd += ["--file", str(target)] + update_operation(operation, progress=3, message=f"Копируем диски машины {source}…", cmd=" ".join(cmd)) + append_operation_log(operation["id"], " ".join(cmd)) + stop = threading.Event() + watcher = threading.Thread(target=_watch_clone_progress, args=(operation, targets, int(operation.get("expected_bytes") or 0), stop), daemon=True) + watcher.start() + try: + result = run(cmd, timeout=CLONE_TIMEOUT) + finally: + stop.set() + watcher.join(timeout=5) + for stream in ("stdout", "stderr"): + if result.get(stream): + append_operation_log(operation["id"], result[stream]) + if not result["ok"]: + for target in targets: # virt-clone обычно сам убирает недокопированные файлы, но подстрахуемся + try: + if target.exists() and not run(["virsh", "dominfo", new_name], timeout=8)["ok"]: + target.unlink() + except OSError: + pass + finish_operation(operation, False, explain_error(cmd_error(result, "virt-clone завершился с ошибкой"), "Не удалось клонировать машину")) + return + update_operation(operation, progress=97, message="Обновляем список дисков…") + refresh = run(["virsh", "pool-refresh", IMAGES_POOL], timeout=60) + if not refresh["ok"]: + append_operation_log(operation["id"], f"pool-refresh: {cmd_error(refresh, 'не удалось обновить хранилище (не страшно)')}") + finish_operation(operation, True, f"Машина {new_name} создана — это копия {source}. Запустите её и смените имя компьютера внутри системы.") + + +@router.get("/vm/{name}/clone") +def clone_page(request: Request, name: str): + denied = _guard(request, name) + if denied: + return denied + context = clone_context(name) + context.update(vm_name=name, error=request.query_params.get("clone_error", ""), new_name=request.query_params.get("new_name", "") or context["suggested"]) + return render(request, "vm_clone.html", context) + + +@router.post("/vm/{name}/clone/start") +def clone_vm(request: Request, name: str, new_name: str = Form("")): + denied = _guard(request, name) + if denied: + return denied + new_name = (new_name or "").strip() + + def fail(message: str): + return RedirectResponse(url=f"/vm/{name}/clone?clone_error={quote(message)}&new_name={quote(new_name)}", status_code=303) + + if not valid_vm_name(new_name): + return fail("Имя копии: от 2 до 63 символов — латинские буквы, цифры, точка, дефис и подчёркивание, первый символ — буква или цифра") + if new_name.lower() == name.lower() or new_name in set(all_vm_names()) or vm_exists(new_name): + return fail(f"Машина с именем {new_name} уже есть — выберите другое имя") + context = clone_context(name) + if "running" in context["blockers"]: + return fail("Сначала выключите машину: копировать диски можно только у выключенной машины") + if "no_disks" in context["blockers"]: + return fail("У машины нет дисков, которые можно скопировать") + if "busy" in context["blockers"]: + return fail("Эта машина уже клонируется — дождитесь окончания в разделе «Задачи»") + if any(op.get("vm_name") == new_name for op in running_operations("vm_clone")): + return fail(f"Копия с именем {new_name} уже создаётся — выберите другое имя") + if "space" in context["blockers"]: + return fail(f"Не хватает места: копии нужно {context['needed_text']}, свободно {context['free_text']}") + targets = clone_targets(new_name, context["disks"]) + for target in targets: + if target.exists(): + return fail(f"Файл {target.name} уже есть в хранилище — выберите другое имя") + needed = sum(d["actual_size"] or d["virtual_size"] for d in context["disks"]) + operation = new_operation("vm_clone", f"Клонирование {name} → {new_name}", vm_name=new_name, source_vm=name, targets=[str(t) for t in targets], expected_bytes=needed) + run_operation(operation, clone_worker) + return RedirectResponse(url=f"/operations/{operation['id']}", status_code=303) + + +# ---------------------------------------------------------------- pause / resume / hibernate +def hibernate_worker(operation: dict[str, Any]) -> None: + name = operation["vm_name"] + update_operation(operation, progress=10, message="Сохраняем память машины на диск сервера…") + result = run(["virsh", "managedsave", name], timeout=HIBERNATE_TIMEOUT) + for stream in ("stdout", "stderr"): + if result.get(stream): + append_operation_log(operation["id"], result[stream]) + if not result["ok"]: + finish_operation(operation, False, explain_error(cmd_error(result, "virsh managedsave завершился с ошибкой"), "Не удалось перевести машину в спящий режим")) + return + finish_operation(operation, True, f"Машина {name} в спящем режиме. При запуске она продолжит работу с того же места.") + + +def hibernate(name: str, state: str, target: str): + if state not in ACTIVE_STATES + ("paused",): + return redirect_with_message(target, "ops_error", "В спящий режим можно перевести только работающую или приостановленную машину") + if running_operations("vm_hibernate"): + return redirect_with_message(target, "ops_error", "Другая машина сейчас засыпает — подождите минуту") + info = presenters.parse_dominfo(run(["virsh", "dominfo", name], timeout=8).get("stdout", "")) + needed = info["memory_mb"] * 1024 * 1024 + if needed and needed > free_space(SAVE_DIR / "x"): + return redirect_with_message(target, "ops_error", f"Для спящего режима нужно {presenters.format_bytes(needed)} свободного места на сервере, а его нет") + operation = new_operation("vm_hibernate", f"Спящий режим: {name}", vm_name=name) + run_operation(operation, hibernate_worker) + return redirect_with_message(target, "ops_message", f"Машина {name} засыпает: состояние сохраняется на диск, это займёт до минуты") + + +@router.post("/vm/{name}/ops/{action}") +def vm_ops(request: Request, name: str, action: str, next: str = Form("")): + denied = require_auth(request) + if denied: + return denied + if not valid_vm_name(name): + return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400) + if action != "hibernate" and action not in OPS: + return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400) + if not vm_exists(name): + return RedirectResponse(url="/", status_code=303) + target = safe_next(next, name) + state = vm_state(name) + if action == "hibernate": + return hibernate(name, state, target) + spec = OPS[action] + if state not in spec["states"]: + return redirect_with_message(target, "ops_error", spec["wrong"]) + result = run(spec["cmd"] + [name], timeout=30) + if not result["ok"]: + return redirect_with_message(target, "ops_error", explain_error(cmd_error(result, spec["fail"]), spec["fail"])) + return redirect_with_message(target, "ops_message", spec["done"].format(name=name)) + + +# Шаблоны берут данные напрямую (как update_notice() в base.html), чтобы +# страница машины и обзор в app.py не менялись. +TEMPLATE_GLOBALS = {"vmops_disks": disks_context, "vmops_notes": read_notes, "vmops_titles": vm_titles, "vmops_saved": has_managed_save} +templates.env.globals.update(TEMPLATE_GLOBALS) + +# Обзор и страница машины пока рендерятся отдельным окружением шаблонов из app.py — +# подключаем и его. Циклический импорт безопасен: app.py импортирует этот модуль +# последними строками, когда templates уже создан. +import app as _app # noqa: E402 + +if getattr(_app, "templates", None) is not None and _app.templates is not templates: + _app.templates.env.globals.update(TEMPLATE_GLOBALS) diff --git a/web/host_profile.py b/web/host_profile.py index 6804bcb..f9f3ec9 100644 --- a/web/host_profile.py +++ b/web/host_profile.py @@ -153,7 +153,8 @@ def detect_host_profile() -> dict[str, Any]: } checks = [] checks.append({'name': 'Аппаратное ускорение (KVM)', 'ok': data['kvm_device'], 'level': 'ok' if data['kvm_device'] else 'warn', 'hint': 'KVM недоступен. На физическом сервере включи Intel VT-x / AMD-V в BIOS/UEFI; на VPS проверь nested virtualization. Пока доступен медленный QEMU fallback.'}) - checks.append({'name': 'Поддержка виртуализации процессором', 'ok': virtualization_flags > 0, 'level': 'ok' if virtualization_flags > 0 else 'warn', 'hint': 'CPU-флаги vmx/svm не видны. Для реального сервера это обычно значит, что виртуализация выключена в BIOS/UEFI.'}) + if not is_arm: # ARM CPUs have no vmx/svm flags; /dev/kvm above is what matters there + checks.append({'name': 'Поддержка виртуализации процессором', 'ok': virtualization_flags > 0, 'level': 'ok' if virtualization_flags > 0 else 'warn', 'hint': 'CPU-флаги vmx/svm не видны. Для реального сервера это обычно значит, что виртуализация выключена в BIOS/UEFI.'}) if is_arm: checks.append({'name': 'Эмулятор ARM64 (QEMU)', 'ok': data['qemu_system_aarch64'], 'level': 'ok' if data['qemu_system_aarch64'] else 'err', 'hint': 'Пакет qemu-system-arm / qemu-system-aarch64.'}) checks.append({'name': 'Загрузчик UEFI для ARM64', 'ok': data['uefi_aarch64_hint'], 'level': 'ok' if data['uefi_aarch64_hint'] else 'warn', 'hint': 'Пакет qemu-efi-aarch64 или AAVMF.'}) diff --git a/web/network_core.py b/web/network_core.py index dade7a7..e806891 100644 --- a/web/network_core.py +++ b/web/network_core.py @@ -1,5 +1,6 @@ import json import re +import shlex import socket import subprocess import uuid @@ -9,6 +10,7 @@ from typing import Any CONFIG_DIR = Path('/var/lib/virtuality/config') NETWORK_DIR = Path('/var/lib/virtuality/network') NFT_DIR = Path('/etc/virtuality/nftables') +UFW_STATE_FILE = NETWORK_DIR / 'ufw_rules.json' PORT_FORWARDS_FILE = NETWORK_DIR / 'port_forwards.json' NAT_XML_FILE = NETWORK_DIR / 'virtuality-nat.xml' NFT_FILE = NFT_DIR / 'virtuality.nft' @@ -24,6 +26,13 @@ class NetworkError(Exception): pass +def valid_vm_name(name: str) -> bool: + """Same rule as core.valid_vm_name (kept here: this module does not import core).""" + if not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', name or ''): + return False + return not name.isdigit() and not re.fullmatch(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', name) + + def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]: try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False) @@ -175,6 +184,60 @@ def nat_network_xml() -> str: """ +def stable_mac(vm_name: str) -> str: + """Locally administered QEMU MAC derived from the machine name (same name → same address).""" + import hashlib + + digest = hashlib.sha1(vm_name.encode()).digest() + return '52:54:00:' + ':'.join(f'{b:02x}' for b in digest[:3]) + + +def nat_reservations() -> list[dict[str, str]]: + """DHCP reservations of the NAT network: [{'name', 'mac', 'ip'}].""" + result = run_cmd(['virsh', 'net-dumpxml', NETWORK_NAME], timeout=8) + if not result['ok']: + return [] + return [ + {'mac': m.group(1).lower(), 'name': m.group(2), 'ip': m.group(3)} + for m in re.finditer(r"", result['stdout']) + ] + + +def _net_update(action: str, host_xml: str) -> dict[str, Any]: + base = ['virsh', 'net-update', NETWORK_NAME, action, 'ip-dhcp-host', host_xml] + result = run_cmd(base + ['--live', '--config'], timeout=15) + if not result['ok'] and 'not active' in (result['stderr'] + result['stdout']).lower(): + result = run_cmd(base + ['--config'], timeout=15) + return result + + +def reserve_nat_address(vm_name: str, mac: str) -> str | None: + """Pin the machine to one NAT address for its whole life; None when libvirt refused.""" + if not valid_vm_name(vm_name): + return None + reservations = nat_reservations() + for item in reservations: + if item['name'] == vm_name: + if item['mac'] != mac.lower(): + _net_update('modify', f"") + return item['ip'] + taken = {item['ip'] for item in reservations} + leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8) + taken.update(re.findall(r'\b(192\.168\.100\.\d+)/', leases['stdout'] if leases['ok'] else '')) + start, end = int(DHCP_START.rsplit('.', 1)[1]), int(DHCP_END.rsplit('.', 1)[1]) + free = next((f'192.168.100.{last}' for last in range(start, end + 1) if f'192.168.100.{last}' not in taken), None) + if not free: + return None + result = _net_update('add-last', f"") + return free if result['ok'] else None + + +def release_nat_address(vm_name: str) -> None: + for item in nat_reservations(): + if item['name'] == vm_name: + _net_update('delete', f"") + + def libvirt_network_info() -> dict[str, Any]: info = run_cmd(['virsh', 'net-info', NETWORK_NAME], timeout=8) leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8) @@ -304,13 +367,16 @@ def resolve_vm_ip(vm_name: str) -> str | None: if match: return match.group(1) + macs = set(vm_mac_addresses(vm_name)) + if not macs: + # Without the machine's MAC any lease would be a guess — possibly another machine's address. + return None leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8) if not leases['ok']: return None - macs = set(vm_mac_addresses(vm_name)) for line in leases['stdout'].splitlines(): low = line.lower() - if macs and not any(mac in low for mac in macs): + if not any(mac in low for mac in macs): continue match = re.search(r'\b(192\.168\.100\.\d+|\d+\.\d+\.\d+\.\d+)/\d+', line) if match: @@ -327,7 +393,7 @@ def tcp_connect_check(host: str, port: int, timeout: float = 2.0) -> dict[str, A def add_port_forward(vm_name: str, guest_ip: str, external_port: Any, guest_port: Any, protocol: str, note: str = '') -> dict[str, Any]: - if not vm_name or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name): + if not valid_vm_name(vm_name): raise NetworkError('Некорректное имя VM') if guest_ip == 'auto': resolved_ip = resolve_vm_ip(vm_name) @@ -415,6 +481,25 @@ def render_nft_rules() -> str: return '\n'.join(lines) + '\n' +def ufw_rules_for(items: list[dict[str, Any]], ext: str) -> list[list[str]]: + rules: list[list[str]] = [] + for raw_item in items: + item = normalize_forward(raw_item) + guest_port = iptables_port_value(item['guest_port_start'], item['guest_port_end']) + external_port = iptables_port_value(item['external_port_start'], item['external_port_end']) + rules.append(['route', 'allow', 'in', 'on', ext, 'out', 'on', forward_guest_interface(item), 'to', item['guest_ip'], 'port', guest_port, 'proto', item['protocol']]) + rules.append(['allow', f"{external_port}/{item['protocol']}"]) + return rules + + +def load_ufw_state() -> list[list[str]]: + try: + data = json.loads(UFW_STATE_FILE.read_text()) + return [list(map(str, rule)) for rule in data] if isinstance(data, list) else [] + except (OSError, ValueError): + return [] + + def apply_ufw_route_rules(items: list[dict[str, Any]]) -> list[dict[str, Any]]: ext = external_interface() results: list[dict[str, Any]] = [] @@ -423,20 +508,16 @@ def apply_ufw_route_rules(items: list[dict[str, Any]]) -> list[dict[str, Any]]: status = run_cmd(['ufw', 'status'], timeout=8) if 'Status: active' not in status['stdout']: return results - for raw_item in items: - item = normalize_forward(raw_item) - guest_port = iptables_port_value(item['guest_port_start'], item['guest_port_end']) - external_port = iptables_port_value(item['external_port_start'], item['external_port_end']) - cmd = [ - 'ufw', 'route', 'allow', - 'in', 'on', ext, - 'out', 'on', forward_guest_interface(item), - 'to', item['guest_ip'], - 'port', guest_port, - 'proto', item['protocol'], - ] - results.append(run_cmd(cmd, timeout=15)) - results.append(run_cmd(['ufw', 'allow', f"{external_port}/{item['protocol']}"], timeout=15)) + wanted = ufw_rules_for(items, ext) + # Rules of forwards that were removed (or moved to another port) are deleted; ufw only keeps what is still wanted. + for rule in load_ufw_state(): + if rule not in wanted: + delete = ['ufw', 'route', 'delete', *rule[1:]] if rule[0] == 'route' else ['ufw', 'delete', *rule] + results.append(run_cmd(delete, timeout=15)) + for rule in wanted: + results.append(run_cmd(['ufw', *rule], timeout=15)) + ensure_dirs() + UFW_STATE_FILE.write_text(json.dumps(wanted)) run_cmd(['ufw', 'reload'], timeout=20) return results @@ -456,11 +537,28 @@ def ensure_iptables_rule(cmd: list[str]) -> dict[str, Any]: return run_cmd(cmd, timeout=10) +IPTABLES_TAG = ['-m', 'comment', '--comment', 'virtuality-forward'] + + +def clear_iptables_fallback() -> None: + """Delete every rule this module added earlier (they carry the virtuality-forward comment).""" + for table, chains in (('filter', ['FORWARD']), ('nat', ['PREROUTING', 'POSTROUTING'])): + for chain in chains: + listing = run_cmd(['iptables', '-t', table, '-S', chain], timeout=8) + if not listing['ok']: + continue + for line in listing['stdout'].splitlines(): + if 'virtuality-forward' not in line or not line.startswith('-A '): + continue + run_cmd(['iptables', '-t', table, '-D', *shlex.split(line)[1:]], timeout=8) + + def apply_iptables_fallback(items: list[dict[str, Any]]) -> list[dict[str, Any]]: ext = external_interface() results: list[dict[str, Any]] = [] if not run_cmd(['sh', '-lc', 'command -v iptables >/dev/null 2>&1'], timeout=5)['ok']: return results + clear_iptables_fallback() for raw_item in items: item = normalize_forward(raw_item) proto = item['protocol'] @@ -470,11 +568,11 @@ def apply_iptables_fallback(items: list[dict[str, Any]]) -> list[dict[str, Any]] guest_to_port = iptables_dnat_port_value(item['guest_port_start'], item['guest_port_end']) guest_to = f"{guest_ip}:{guest_to_port}" 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'])) - results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-s', NAT_SUBNET, '-o', ext, '-j', 'MASQUERADE'])) + results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', guest_iface, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, *IPTABLES_TAG, '-j', 'ACCEPT'])) + results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', guest_iface, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', *IPTABLES_TAG, '-j', 'ACCEPT'])) + results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, *IPTABLES_TAG, '-j', 'DNAT', '--to-destination', guest_to])) + results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-d', guest_ip, '-o', guest_iface, *IPTABLES_TAG, '-j', 'MASQUERADE'])) + results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-s', NAT_SUBNET, '-o', ext, *IPTABLES_TAG, '-j', 'MASQUERADE'])) return results @@ -523,7 +621,7 @@ def find_matching_forward(forwards: list[dict[str, Any]], vm_name: str, external def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, protocol: str = 'tcp') -> dict[str, Any]: - if not vm_name or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}', vm_name): + if not valid_vm_name(vm_name): raise NetworkError('Некорректное имя VM') if not valid_port(external_port) or not valid_port(guest_port): raise NetworkError('Порт должен быть от 1 до 65535') diff --git a/web/nodectl.py b/web/nodectl.py new file mode 100644 index 0000000..4b06415 --- /dev/null +++ b/web/nodectl.py @@ -0,0 +1,318 @@ +"""Facts about the node (network, disks, time zone, first-boot progress) and +actions that change the node itself. Actions go through virtuality-ctl so the +panel and the command line behave identically.""" +import ipaddress +import json +import os +import re +import shutil +import subprocess +from datetime import datetime +from pathlib import Path +from typing import Any + +from core import CONFIG_DIR, STORAGE_DIR, cmd_error, run_cmd + +CTL = "/usr/local/bin/virtuality-ctl" +NODE_ENV = Path(os.environ.get("VIRTUALITY_NODE_ENV", str(CONFIG_DIR / "web.env"))) +SETUP_STATE = CONFIG_DIR / "setup.json" +SETUP_DONE = CONFIG_DIR / "setup_done" +WIZARD_FILE = CONFIG_DIR / "wizard.json" +FIRSTBOOT_LOG = Path("/var/log/virtuality/firstboot.log") +MIN_SPARE_DISK = 16 * 1024 ** 3 + +POPULAR_TIMEZONES = [ + "Europe/Moscow", "Europe/Kaliningrad", "Europe/Samara", "Asia/Yekaterinburg", "Asia/Omsk", "Asia/Novosibirsk", + "Asia/Krasnoyarsk", "Asia/Irkutsk", "Asia/Yakutsk", "Asia/Vladivostok", "Asia/Magadan", "Asia/Kamchatka", + "Europe/Minsk", "Europe/Kiev", "Asia/Almaty", "Asia/Tashkent", "Asia/Tbilisi", "Asia/Yerevan", "Asia/Baku", + "Europe/Berlin", "Europe/London", "Europe/Istanbul", "Asia/Dubai", "America/New_York", "UTC", +] + + +# ---------------------------------------------------------------- node settings +def node_settings() -> dict[str, str]: + data: dict[str, str] = {} + try: + for raw in NODE_ENV.read_text().splitlines(): + line = raw.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + data[key.strip()] = value.strip().strip('"').strip("'") + except OSError: + pass + data.setdefault("VIRTUALITY_WEB_PORT", "8088") + data.setdefault("VIRTUALITY_TLS_PORT", "8443") + data.setdefault("VIRTUALITY_TLS", "0") + data.setdefault("VIRTUALITY_AUTO_UPDATE", "1") + data.setdefault("VIRTUALITY_UPDATE_CHANNEL", "stable") + return data + + +def panel_url(host: str, settings: dict[str, str] | None = None) -> str: + settings = settings or node_settings() + if settings.get("VIRTUALITY_TLS") == "1": + return f"https://{host}:{settings['VIRTUALITY_TLS_PORT']}" + return f"http://{host}:{settings['VIRTUALITY_WEB_PORT']}" + + +def ctl_available() -> bool: + return Path(CTL).exists() + + +def ctl(*args: str, timeout: int = 60, stdin: str | None = None) -> dict[str, Any]: + if not ctl_available(): + return {"ok": False, "code": -1, "stdout": "", "stderr": "virtuality-ctl не установлен", "cmd": CTL} + try: + result = subprocess.run([CTL, *args], capture_output=True, text=True, timeout=timeout, check=False, input=stdin) + return {"ok": result.returncode == 0, "code": result.returncode, "stdout": result.stdout.strip(), "stderr": result.stderr.strip(), "cmd": " ".join([CTL, *args])} + except Exception as exc: + return {"ok": False, "code": -1, "stdout": "", "stderr": str(exc), "cmd": " ".join([CTL, *args])} + + +def ctl_detached(*args: str, delay_seconds: int = 2) -> dict[str, Any]: + """Run a virtuality-ctl command after the HTTP response is sent (the command may restart the panel).""" + unit = f"virtuality-apply-{datetime.now().strftime('%Y%m%d%H%M%S%f')}" + return run_cmd(["systemd-run", "--unit", unit, "--collect", "--quiet", f"--on-active={delay_seconds}", CTL, *args], timeout=15) + + +# ---------------------------------------------------------------- first boot state +def setup_state() -> dict[str, Any]: + try: + data = json.loads(SETUP_STATE.read_text()) + except Exception: + data = {} + data.setdefault("stage", "done" if not SETUP_STATE.exists() else "installing") + data.setdefault("steps", []) + data.setdefault("message", "") + data["installed"] = data["stage"] == "done" + data["log_tail"] = "" + if FIRSTBOOT_LOG.exists(): + try: + data["log_tail"] = "\n".join(FIRSTBOOT_LOG.read_text(errors="replace").splitlines()[-40:]) + except OSError: + pass + return data + + +def wizard_done() -> bool: + return SETUP_DONE.exists() + + +def mark_wizard_done() -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + SETUP_DONE.write_text(datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "\n") + + +def wizard_choices() -> dict[str, Any]: + try: + return json.loads(WIZARD_FILE.read_text()) + except Exception: + return {} + + +def save_wizard_choices(**changes: Any) -> dict[str, Any]: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + data = wizard_choices() + data.update(changes) + WIZARD_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2)) + return data + + +# ---------------------------------------------------------------- hardware & time +def hardware_summary() -> dict[str, Any]: + cpu_model = "—" + try: + for line in Path("/proc/cpuinfo").read_text().splitlines(): + key, _, value = line.partition(":") + if key.strip().lower() == "model name" and value.strip(): + cpu_model = value.strip() + break + except OSError: + pass + mem_total = 0 + try: + for line in Path("/proc/meminfo").read_text().splitlines(): + if line.startswith("MemTotal:"): + mem_total = int(line.split()[1]) * 1024 + except (OSError, ValueError): + pass + try: + usage = shutil.disk_usage(str(STORAGE_DIR) if STORAGE_DIR.exists() else "/") + disk_total, disk_free = usage.total, usage.free + except OSError: + disk_total = disk_free = 0 + return { + "hostname": run_cmd(["hostname"])["stdout"] or "—", + "cpu_model": cpu_model, + "cpu_count": os.cpu_count() or 1, + "mem_total": mem_total, + "disk_total": disk_total, + "disk_free": disk_free, + "kvm": Path("/dev/kvm").exists(), + } + + +def timezones() -> dict[str, Any]: + current = run_cmd(["timedatectl", "show", "-p", "Timezone", "--value"])["stdout"] or "UTC" + listed = run_cmd(["timedatectl", "list-timezones"], timeout=10) + names = [line.strip() for line in listed["stdout"].splitlines() if "/" in line or line.strip() == "UTC"] if listed["ok"] else [] + if not names: + names = list(POPULAR_TIMEZONES) + popular = [tz for tz in POPULAR_TIMEZONES if tz in names] + return {"current": current, "popular": popular, "all": names} + + +def set_timezone(tz: str) -> tuple[bool, str]: + if not re.fullmatch(r"[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+){0,2}", tz or ""): + return False, "Некорректный часовой пояс" + result = ctl("timezone", tz, timeout=30) + return result["ok"], cmd_error(result, "Не удалось сменить часовой пояс") if not result["ok"] else tz + + +# ---------------------------------------------------------------- network facts +def _ip_json(*args: str) -> list[dict[str, Any]]: + result = run_cmd(["ip", "-j", *args], timeout=8) + if not result["ok"]: + return [] + try: + data = json.loads(result["stdout"] or "[]") + return data if isinstance(data, list) else [] + except json.JSONDecodeError: + return [] + + +def network_facts() -> dict[str, Any]: + routes = _ip_json("route", "show", "default") + default = routes[0] if routes else {} + iface = default.get("dev", "") + gateway = default.get("gateway", "") + address = "" + prefix = 0 + mac = "" + for entry in _ip_json("addr", "show", iface) if iface else []: + mac = entry.get("address", "") or mac + for info in entry.get("addr_info", []): + if info.get("family") == "inet" and not address: + address = info.get("local", "") + prefix = int(info.get("prefixlen", 0) or 0) + wireless = bool(iface) and Path(f"/sys/class/net/{iface}/wireless").exists() + bridge_present = Path("/sys/class/net/br0").exists() + on_bridge = iface == "br0" + private = False + try: + private = ipaddress.ip_address(address).is_private if address else False + except ValueError: + private = False + is_vps = bool(address) and not private + virt = run_cmd(["systemd-detect-virt"], timeout=5)["stdout"] + if virt and virt != "none": + is_vps = True + if on_bridge: + recommended = "bridge" + elif wireless or is_vps or not iface: + recommended = "nat" + else: + recommended = "bridge" + revert_armed = run_cmd(["systemctl", "is-active", "virtuality-netplan-revert.timer"], timeout=5)["stdout"] == "active" + return { + "interface": iface, "gateway": gateway, "address": address, "prefix": prefix, "mac": mac, + "wireless": wireless, "private": private, "is_vps": is_vps, "virt": virt or "none", + "bridge_present": bridge_present, "on_bridge": on_bridge, "recommended": recommended, + "revert_armed": revert_armed, + "bridge_possible": bool(iface) and not wireless and not on_bridge, + } + + +def enable_bridge(iface: str, mode: str = "dhcp") -> tuple[bool, str]: + if not re.fullmatch(r"[a-zA-Z0-9_.:-]{1,15}", iface or "") or mode not in ("dhcp", "static"): + return False, "Некорректный интерфейс" + result = ctl_detached("bridge", iface, mode, delay_seconds=1) + return result["ok"], cmd_error(result, "Не удалось запустить настройку сети") if not result["ok"] else "Сеть перенастраивается" + + +def confirm_bridge() -> tuple[bool, str]: + result = ctl("bridge-confirm", timeout=30) + return result["ok"], cmd_error(result, "Не удалось подтвердить сеть") if not result["ok"] else "Сеть подтверждена" + + +def revert_bridge() -> tuple[bool, str]: + result = ctl_detached("bridge-revert", delay_seconds=1) + return result["ok"], cmd_error(result, "Не удалось вернуть прежнюю сеть") if not result["ok"] else "Возвращаем прежнюю сеть" + + +# ---------------------------------------------------------------- storage facts +def _lsblk() -> list[dict[str, Any]]: + result = run_cmd(["lsblk", "-J", "-b", "-o", "NAME,PATH,TYPE,SIZE,FSTYPE,MOUNTPOINT,MODEL,TRAN,RM,PKNAME"], timeout=10) + if not result["ok"]: + return [] + try: + return json.loads(result["stdout"] or "{}").get("blockdevices", []) + except json.JSONDecodeError: + return [] + + +def spare_disks() -> list[dict[str, Any]]: + """Whole disks with no partitions or file system: safe candidates for VM storage.""" + disks = [] + for dev in _lsblk(): + if dev.get("type") != "disk" or dev.get("children") or dev.get("fstype") or dev.get("mountpoint"): + continue + if str(dev.get("rm")) in ("1", "True", "true") or dev.get("tran") == "usb": + continue + size = int(dev.get("size") or 0) + if size < MIN_SPARE_DISK: + continue + disks.append({"path": dev.get("path") or f"/dev/{dev.get('name')}", "name": dev.get("name"), "size": size, "model": (dev.get("model") or "").strip() or "Диск", "tran": dev.get("tran") or ""}) + return disks + + +def storage_facts() -> dict[str, Any]: + try: + usage = shutil.disk_usage(str(STORAGE_DIR) if STORAGE_DIR.exists() else "/") + total, free = usage.total, usage.free + except OSError: + total = free = 0 + mount = run_cmd(["findmnt", "-n", "-o", "SOURCE,TARGET", "--target", str(STORAGE_DIR)], timeout=5)["stdout"] + source, _, target = mount.partition(" ") + return {"path": str(STORAGE_DIR), "total": total, "free": free, "source": source, "mount_target": target.strip() or "/", "dedicated": target.strip() == str(STORAGE_DIR), "spare": spare_disks()} + + +def use_disk_for_storage(path: str) -> tuple[bool, str]: + if not any(d["path"] == path for d in spare_disks()): + return False, "Этот диск нельзя использовать: он занят или на нём уже есть данные" + result = ctl("storage-use", path, timeout=600) + return result["ok"], cmd_error(result, "Не удалось подготовить диск") if not result["ok"] else "Диск подключён для хранения машин" + + +# ---------------------------------------------------------------- account & settings +def change_password(user: str, new_password: str) -> tuple[bool, str]: + if len(new_password) < 6: + return False, "Пароль должен быть не короче 6 символов" + result = ctl("passwd", user, stdin=new_password + "\n", timeout=30) + return result["ok"], cmd_error(result, "Не удалось сменить пароль") if not result["ok"] else "Пароль изменён" + + +def apply_settings(changes: dict[str, str]) -> tuple[bool, str]: + allowed = {"VIRTUALITY_WEB_PORT", "VIRTUALITY_TLS", "VIRTUALITY_TLS_PORT", "VIRTUALITY_AUTO_UPDATE", "VIRTUALITY_UPDATE_CHANNEL"} + pairs = [f"{key}={value}" for key, value in changes.items() if key in allowed and re.fullmatch(r"[A-Za-z0-9._-]+", str(value))] + if not pairs: + return False, "Нечего применять" + result = ctl_detached("set", *pairs) + return result["ok"], cmd_error(result, "Не удалось применить настройки") if not result["ok"] else "Настройки применяются" + + +def power(action: str) -> tuple[bool, str]: + if action not in ("reboot", "poweroff"): + return False, "Неизвестное действие" + result = ctl_detached("power", action, delay_seconds=3) + return result["ok"], cmd_error(result, "Не удалось выполнить") if not result["ok"] else ("Сервер перезагружается" if action == "reboot" else "Сервер выключается") + + +def backup_config() -> tuple[bool, str]: + result = ctl("backup-config", timeout=120) + return result["ok"], cmd_error(result, "Не удалось создать архив настроек") if not result["ok"] else result["stdout"] + + +def support_bundle() -> tuple[bool, str]: + result = ctl("support-bundle", timeout=120) + return result["ok"], cmd_error(result, "Не удалось собрать отчёт") if not result["ok"] else result["stdout"] diff --git a/web/presenters.py b/web/presenters.py index 89e525e..df53e55 100644 --- a/web/presenters.py +++ b/web/presenters.py @@ -29,8 +29,6 @@ OPERATION_STATES = { SERVICE_LABELS = { "libvirtd": "Виртуализация", "virtlogd": "Журналы машин", - "cockpit": "Cockpit", - "dashboard": "Экран сервера", "web": "Панель управления", } @@ -242,3 +240,37 @@ def plural(count: int, one: str, few: str, many: str) -> str: def port_service(port: int) -> str: return WELL_KNOWN_PORTS.get(int(port or 0), "") + + +def format_bytes(value: int | float) -> str: + value = float(value or 0) + for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"): + if value < 1024 or unit == "ТБ": + return f"{value:.0f} {unit}" if value >= 10 or unit == "Б" else f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.0f} ТБ" + + +def parse_virsh_list_titles(text: str) -> dict[str, str]: + """`virsh list --all --title` → {имя: название}. Колонки virsh выровнены + пробелами, поэтому название берём по смещению колонки Title из заголовка: + так не ломаются состояния с пробелом («shut off») и названия из нескольких слов.""" + lines = (text or "").splitlines() + if not lines: + return {} + header = lines[0] + name_col, title_col = re.search(r"\bName\b", header), re.search(r"\bTitle\b", header) + if not name_col or not title_col: + return {} + titles: dict[str, str] = {} + for line in lines[1:]: + stripped = line.strip() + if not stripped or set(stripped) <= {"-"}: + continue + name_part = line[name_col.start():].split() + if not name_part: + continue + title = line[title_col.start():].strip() + if title: + titles[name_part[0]] = title + return titles diff --git a/web/redirect_http.py b/web/redirect_http.py new file mode 100644 index 0000000..c6ef07f --- /dev/null +++ b/web/redirect_http.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Tiny HTTP → HTTPS redirector for the panel (used when TLS is enabled). + +Listens on the plain port so that a user typing http://server:8088 lands on +https://server:8443 instead of a TLS error. +""" +import argparse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8088) + parser.add_argument("--target-port", type=int, default=8443) + args = parser.parse_args() + + class Handler(BaseHTTPRequestHandler): + server_version = "Virtuality" + sys_version = "" + + def do_GET(self): # noqa: N802 + host = (self.headers.get("Host") or "").split(":")[0] or "localhost" + self.send_response(301) + self.send_header("Location", f"https://{host}:{args.target_port}{self.path}") + self.send_header("Content-Length", "0") + self.end_headers() + + do_HEAD = do_GET # noqa: N815 + do_POST = do_GET # noqa: N815 + + def log_message(self, *_args): + return + + ThreadingHTTPServer((args.host, args.port), Handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/web/static/app.css b/web/static/app.css index 33b98fa..ed0667a 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -33,7 +33,7 @@ --border-strong: #d3d7e1; --text: #0e1525; --text-2: #4a5468; - --text-3: #7b8498; + --text-3: #5f6980; --brand: #5a48f5; --brand-hover: #4a38e6; --brand-soft: #efedff; @@ -46,6 +46,9 @@ --danger: #e03e45; --danger-soft: #fdecec; --info: #2f73f0; --info-soft: #e9f1ff; --neutral: #6b7488; --neutral-soft: #eff1f5; + /* Badge text: darker than the accents so small labels stay readable (WCAG 4.5:1 on the soft backgrounds). */ + --success-text: #0b7a45; --warning-text: #8a5600; --danger-text: #b8262d; --info-text: #1f5fd0; --neutral-text: #525b6e; + --switch-off: #8a93a6; --terminal-bg: #0d1117; --terminal-text: #d6deeb; @@ -84,6 +87,8 @@ --danger: #ff6369; --danger-soft: rgba(255, 99, 105, .13); --info: #5b9bff; --info-soft: rgba(91, 155, 255, .13); --neutral: #8d96a8; --neutral-soft: rgba(141, 150, 168, .13); + --success-text: var(--success); --warning-text: var(--warning); --danger-text: var(--danger); --info-text: var(--info); --neutral-text: var(--neutral); + --switch-off: #5c6678; --terminal-bg: #07090d; --shadow-xs: 0 1px 2px rgba(0, 0, 0, .3); --shadow-sm: 0 1px 3px rgba(0, 0, 0, .35); @@ -225,13 +230,13 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; } .badge { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; - background: var(--neutral-soft); color: var(--neutral); + background: var(--neutral-soft); color: var(--neutral-text); } .badge .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } -.badge.success { background: var(--success-soft); color: var(--success); } -.badge.warning { background: var(--warning-soft); color: var(--warning); } -.badge.danger { background: var(--danger-soft); color: var(--danger); } -.badge.info { background: var(--info-soft); color: var(--info); } +.badge.success { background: var(--success-soft); color: var(--success-text); } +.badge.warning { background: var(--warning-soft); color: var(--warning-text); } +.badge.danger { background: var(--danger-soft); color: var(--danger-text); } +.badge.info { background: var(--info-soft); color: var(--info-text); } .badge.brand { background: var(--brand-soft); color: var(--brand-text); } .badge.success .dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 22%, transparent); animation: pulse 2.4s ease-in-out infinite; } .badge.info .dot { animation: pulse 1.4s ease-in-out infinite; } @@ -293,11 +298,20 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; } .switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .switch-row .switch-text strong { display: block; font-weight: 600; } .switch-row .switch-text span { color: var(--text-3); font-size: 12.5px; } -.switch { position: relative; width: 42px; height: 24px; flex: none; padding: 0; border: 0; border-radius: 999px; background: var(--border-strong); cursor: pointer; transition: background .2s; } +.switch { position: relative; width: 42px; height: 24px; flex: none; padding: 0; border: 0; border-radius: 999px; background: var(--switch-off); cursor: pointer; transition: background .2s; } .switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(16,24,40,.25); transition: transform .2s cubic-bezier(.2,.8,.2,1); } .switch[aria-checked="true"] { background: var(--success); } .switch[aria-checked="true"]::after { transform: translateX(18px); } +/* checkbox row (consent for destructive actions) */ +.check { display: flex; gap: 12px; align-items: flex-start; padding: 13px 16px; border: 1.5px solid var(--border); border-radius: var(--radius); background: var(--surface-2); cursor: pointer; } +.check input { width: 18px; height: 18px; flex: none; margin: 2px 0 0; accent-color: var(--brand); cursor: pointer; } +.check > span { display: grid; gap: 2px; } +.check > span > span { color: var(--text-3); font-size: 12.5px; } +.check.danger { border-color: color-mix(in srgb, var(--danger) 30%, transparent); background: var(--danger-soft); } +.check.danger input { accent-color: var(--danger); } +.check.danger strong { color: var(--danger); } + /* option cards (radio) */ .options { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); } .options-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -584,3 +598,65 @@ details.advanced > .advanced-body { padding: 4px 16px 16px; } .hide-sm { display: none !important; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } + +/* ---------------------------------------------------------- setup wizard */ +.setup-page { background: var(--bg); } +.setup-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 32px; border-bottom: 1px solid var(--border); background: var(--surface); } +.setup-top .logo { padding: 0; } +.setup-main { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 32px; width: 100%; max-width: 1180px; margin: 0 auto; padding: 36px 32px 60px; } +.setup-rail { display: grid; gap: 4px; align-content: start; position: sticky; top: 24px; } +.setup-step { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; color: var(--text-3); } +.setup-step:hover { text-decoration: none; } +.setup-step[href]:hover { background: var(--surface-3); color: var(--text); } +.setup-step strong { display: block; font-weight: 600; color: inherit; } +.setup-step small { display: block; font-size: 12px; } +.setup-step-num { display: grid; place-items: center; width: 28px; height: 28px; flex: none; border-radius: 50%; border: 1.5px solid var(--border-strong); font-size: 12.5px; font-weight: 700; } +.setup-step.active { color: var(--text); } +.setup-step.active .setup-step-num { border-color: var(--brand); background: var(--brand); color: #fff; box-shadow: 0 0 0 4px var(--brand-soft); } +.setup-step.done { color: var(--text-2); } +.setup-step.done .setup-step-num { border-color: var(--success); background: var(--success-soft); color: var(--success); } +.setup-content { min-width: 0; } +.setup-card { box-shadow: var(--shadow-md); } +.setup-card .card-header h2 { font-size: 20px; } +.setup-card .card-header p { font-size: 14px; color: var(--text-2); margin-top: 4px; } +@media (max-width: 900px) { + .setup-top { padding: 12px 16px; } + .setup-main { grid-template-columns: 1fr; padding: 20px 16px 48px; gap: 16px; } + .setup-rail { position: static; display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; } + .setup-step { flex: none; padding: 6px 10px; } + .setup-step small { display: none; } +} + +/* ---------------------------------------------------------- downloads */ +.downloads { margin-bottom: 20px; } +.download-item .download-pct { flex: none; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; } +.download-item[data-status="error"] .download-pct { color: var(--danger); } +.download-item[data-status="success"] .download-pct { color: var(--success); } +.download-error { color: var(--danger); } +.catalog-item .meta code { padding: 1px 5px; border-radius: 5px; background: var(--surface-3); font: 12px/1.4 var(--mono); color: var(--text-2); } +@media (max-width: 640px) { .download-item, .catalog-item { flex-wrap: wrap; } .download-item .grow, .catalog-item .grow { flex-basis: calc(100% - 50px); } } + +/* ---------------------------------------------------------- vmops: live load, disks, notes */ +.list > * { min-width: 0; } /* длинное сообщение задачи обрезается, а не растягивает карточку на телефоне */ +.vm-title { color: var(--text-2); font-weight: 500; } +.vm-meters { display: grid; gap: 3px; margin-top: 6px; max-width: 220px; } +.vm-meter { display: grid; grid-template-columns: 14px minmax(0, 1fr) 46px; align-items: center; gap: 7px; color: var(--text-3); font-size: 11.5px; } +.vm-meter .icon-sm { width: 14px; height: 14px; } +.vm-meter .meter { display: block; margin: 0; height: 5px; } +.vm-meter b { font-weight: 600; color: var(--text-2); text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } +.load-grid { display: grid; gap: 16px; } +.load-row { display: grid; gap: 8px; } +.load-row .row-between { font-size: 13px; } +.load-row .row-between .subtle { display: inline-flex; align-items: center; gap: 6px; } +.load-row .meter { margin-top: 0; } +.disk-resize { display: flex; align-items: center; gap: 8px; flex: none; } +.disk-resize .input { width: 84px; height: 32px; padding: 0 10px; border-radius: 8px 0 0 8px; font-size: 13px; } +.disk-resize .addon { padding: 0 9px; border-radius: 0 8px 8px 0; font-size: 12px; } +.disk-footer { display: block; } +.disk-footer p { margin: 0; line-height: 1.5; } +textarea.input { height: auto; min-height: 96px; padding: 9px 12px; resize: vertical; line-height: 1.45; } +textarea.input:focus { border-color: var(--brand); outline: none; box-shadow: 0 0 0 4px var(--focus); } +@media (max-width: 640px) { + .list-item.disk-item { flex-wrap: wrap; } + .disk-resize { width: 100%; justify-content: flex-end; } +} diff --git a/web/static/icons.svg b/web/static/icons.svg index 12ffba4..d13d805 100644 --- a/web/static/icons.svg +++ b/web/static/icons.svg @@ -57,4 +57,8 @@ + + + + diff --git a/web/static/panel.js b/web/static/panel.js index 5581d03..60a41b1 100644 --- a/web/static/panel.js +++ b/web/static/panel.js @@ -67,6 +67,8 @@ const raw = sessionStorage.getItem('virtualityToast'); if (!raw) return; sessionStorage.removeItem('virtualityToast'); + // The toast was queued when the form was submitted; if the page came back with an error, the action failed. + if (document.querySelector('.alert.danger') || /(^|[?&_])error=/.test(location.search)) return; const data = JSON.parse(raw); toast(data.message, data.tone); } catch (_) {} @@ -196,6 +198,41 @@ setInterval(refreshLive, 5000); } + /* ---------------------------------------------------- live VM load (CPU / RAM meters) */ + const levelTone = (pct) => (pct >= 90 ? 'danger' : pct >= 75 ? 'warning' : 'success'); + const formatMb = (mb) => (mb >= 1024 ? `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ` : `${Math.round(mb)} МБ`); + function applyStatMeter(root, key, pct, label) { + const bar = $(`[data-stat="${key}-bar"]`, root); + const meter = $(`[data-stat="${key}-meter"]`, root); + const text = $(`[data-stat="${key}"]`, root); + if (bar) bar.style.setProperty('--value', `${pct == null ? 0 : Math.round(pct)}%`); + if (meter) meter.className = `meter ${pct == null ? '' : levelTone(pct)}`; + if (text) text.textContent = label; + } + async function refreshStats() { + if (document.hidden) return; + try { + const response = await fetch('/live/stats', { cache: 'no-store', headers: { Accept: 'application/json' } }); + if (!response.ok) return; + const payload = await response.json(); + if (!payload.ok) return; + $$('[data-live-stats]').forEach((root) => { + const stat = payload.stats[root.dataset.liveStats]; + root.hidden = !stat; + if (!stat) return; + const cpu = stat.cpu_pct == null ? null : Math.round(stat.cpu_pct); + const memPct = stat.mem_total_mb ? Math.min(100, Math.round((stat.mem_used_mb / stat.mem_total_mb) * 100)) : null; + applyStatMeter(root, 'cpu', cpu, cpu == null ? '…' : `${cpu}%`); + applyStatMeter(root, 'mem', memPct, stat.mem_total_mb ? (root.classList.contains('vm-meters') ? `${memPct}%` : `${formatMb(stat.mem_used_mb)} из ${formatMb(stat.mem_total_mb)}`) : '—'); + }); + } catch (_) {} + } + function wireStats() { + if (!$('[data-live-stats]')) return; + refreshStats(); + setInterval(refreshStats, 5000); + } + /* ---------------------------------------------------- uploads */ function wireUploader(form) { const input = $('input[type=file]', form); @@ -408,8 +445,17 @@ }); $$('[data-visible-when]', form).forEach((el) => { const [key, expected] = el.dataset.visibleWhen.split('='); - el.hidden = value(key) !== expected; + const target = field(key); + const current = target instanceof HTMLInputElement && target.type === 'checkbox' ? (target.checked ? target.value : '0') : value(key); + el.hidden = current !== expected; }); + // Default login of the chosen cloud image (ubuntu, debian, …) until the user types a name. + const login = field('ci_user'); + if (login && login.dataset.autoLogin === '1') { + const image = field('disk_image_path'); + const option = image && image.selectedIndex >= 0 ? image.options[image.selectedIndex] : null; + login.value = option?.dataset.login || 'admin'; + } const preset = value('preset'); const custom = preset === 'custom'; const presetInput = $(`input[name=preset][value="${preset}"]`, form); @@ -424,13 +470,13 @@ setSummary('source', source === 'disk_image' ? optionText(field('disk_image_path')) : optionText(field('iso_path'))); setSummary('cpu', `${value('vcpus')} ${Number(value('vcpus')) === 1 ? 'ядро' : 'ядра'}`); setSummary('memory', memory >= 1024 ? `${+(memory / 1024).toFixed(1)} ГБ` : `${memory} МБ`); - setSummary('disk', source === 'disk_image' ? 'из образа' : `${value('disk_size')} ГБ`); + setSummary('disk', `${value('disk_size')} ГБ`); setSummary('network', value('network_mode') === 'bridge' ? 'Локальная сеть' : 'Автоматически (NAT)'); const hasSource = source === 'disk_image' ? form.dataset.hasDisks === '1' : form.dataset.hasIsos === '1'; $$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); }); $$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; }); } - form.addEventListener('input', sync); + form.addEventListener('input', (event) => { if (event.target.name === 'ci_user') event.target.dataset.autoLogin = event.target.value ? '0' : '1'; sync(); }); form.addEventListener('change', sync); sync(); } @@ -466,6 +512,7 @@ wireTabs(); wireCopy(); wireLive(); + wireStats(); $$('form[data-uploader]').forEach(wireUploader); wireOperation(); wireLogs(); @@ -475,3 +522,46 @@ flashToast(); }); })(); + +/* Setup wizard: live progress of the first-boot installation. */ +(() => { + 'use strict'; + document.addEventListener('DOMContentLoaded', () => { + const root = document.querySelector('[data-setup-install]'); + if (!root) return; + const list = root.querySelector('[data-setup-steps]'); + const log = root.querySelector('[data-setup-log]'); + const status = document.querySelector('[data-setup-status]'); + const next = document.querySelector('[data-setup-next]'); + const escapeHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch])); + const icon = (name) => ``; + let announced = root.dataset.installed === '1'; + async function poll() { + try { + const response = await fetch('/api/setup/state', { cache: 'no-store' }); + if (!response.ok) return; + const { state } = await response.json(); + if (state.steps && state.steps.length) { + list.innerHTML = state.steps.map((item) => { + const tone = item.status === 'done' ? 'success' : item.status === 'error' ? 'danger' : item.status === 'running' ? 'warning' : ''; + const mark = item.status === 'done' ? icon('circle-check') : item.status === 'error' ? icon('circle-x') : item.status === 'running' ? '' : icon('circle-dot'); + const detail = item.status === 'running' && state.message ? `${escapeHtml(state.message)}` : ''; + return `
${mark}
${escapeHtml(item.title)}${detail}
`; + }).join(''); + } + if (log && state.log_tail) { const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 40; log.textContent = state.log_tail; if (atBottom) log.scrollTop = log.scrollHeight; } + if (state.installed) { + next.classList.remove('disabled'); + status.textContent = 'Всё установлено'; + if (!announced) { announced = true; window.vToast && window.vToast('Компоненты установлены'); setTimeout(() => { location.href = root.dataset.next; }, 2000); } + } else if (state.stage === 'waiting-network') { + status.textContent = 'Ждём подключение к интернету…'; + } else if (state.stage === 'error') { + status.textContent = 'Ошибка установки — повторим автоматически'; + } + } catch (_) {} + } + poll(); + setInterval(poll, 3000); + }); +})(); diff --git a/web/templates/_backup_rows.html b/web/templates/_backup_rows.html new file mode 100644 index 0000000..9484e95 --- /dev/null +++ b/web/templates/_backup_rows.html @@ -0,0 +1,18 @@ +{% from "_ui.html" import icon, badge %} +{#- Строки списка копий. Ожидает: rows (список копий), back (куда вернуться после удаления). -#} +{% for b in rows %} +
+ {{ icon('database' if b.complete else 'triangle-alert') }} +
+
{{ b.created_label }}{% if b.note %} {{ b.note }}{% endif %}
+
{{ b.size }} · {{ b.disk_count }} {{ plural(b.disk_count, 'диск', 'диска', 'дисков') }}{% if b.version %} · Virtuality {{ b.version }}{% endif %}{% if not b.complete %} · не хватает файлов{% endif %}
+
+ {% if b.complete %} + {{ icon('rotate-cw', 'icon-sm') }}Восстановить + {% endif %} +
+ + +
+
+{% endfor %} diff --git a/web/templates/_downloads_active.html b/web/templates/_downloads_active.html new file mode 100644 index 0000000..3fd1b62 --- /dev/null +++ b/web/templates/_downloads_active.html @@ -0,0 +1,79 @@ +{#- Идущие скачивания (операции типа download) с живым прогрессом. Ожидает переменную kind ('iso' | 'disk'); + страница может заранее положить в dl результат downloads_panel(kind), чтобы не читать операции дважды. -#} +{% from "_ui.html" import icon %} +{% set dl = dl if dl is defined else downloads_panel(kind) %} +{% if dl.active %} +
+
+

Скачиваем на сервер

Страницу можно закрыть — загрузка продолжится. Когда файл будет готов, он появится в списке.

+ {{ icon('list-checks', 'icon-sm') }}Все задачи +
+
+ {% for op in dl.active %} +
+ {{ icon('download') }} +
+
+
{{ op.filename }}
+ {{ op.progress|default(0) }}% +
+
+
{{ op.message }}
+
+ Подробнее + {#- Без data-toast: сервер лишь просит поток остановиться, а итог («Скачивание отменено») покажет опрос ниже. -#} +
+ +
+
+ {% endfor %} +
+
+ +{% endif %} diff --git a/web/templates/_downloads_catalog.html b/web/templates/_downloads_catalog.html new file mode 100644 index 0000000..61bab65 --- /dev/null +++ b/web/templates/_downloads_catalog.html @@ -0,0 +1,36 @@ +{#- Карточка «Каталог готовых систем» для страницы /disk-images; страница может заранее положить в dl результат downloads_panel('disk'). -#} +{% from "_ui.html" import icon, badge %} +{% set dl = dl if dl is defined else downloads_panel('disk') %} +
+
+

Каталог готовых систем

Официальные образы, подходящие этому серверу. Скачаем на сервер и проверим контрольную сумму — останется только создать машину.

+
+ {% if dl.catalog %} +
+ {% for item in dl.catalog %} +
+ {{ icon('circle-check' if item.status == 'ready' else 'download' if item.status == 'downloading' else 'box') }} +
+
+ {{ item.title }} + {% if item.recommended %}{{ badge('Рекомендуем', 'brand', dot=False) }}{% endif %} + {% if item.status == 'ready' %}{{ badge('Загружен', 'success') }}{% elif item.status == 'downloading' %}{{ badge('Скачивается', 'info') }}{% endif %} +
+
{{ item.hint }}{% if item.login %} · вход: {{ item.login }}{% endif %}
+
+ {% if item.status == 'ready' %} + {{ icon('plus', 'icon-sm') }}Создать машину + {% elif item.status == 'downloading' %} + Прогресс{{ icon('chevron-right', 'icon-sm') }} + {% else %} +
+ +
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

Подходящих этому серверу готовых образов в каталоге нет. Можно скачать образ по ссылке или загрузить с компьютера.

+ {% endif %} +
diff --git a/web/templates/_downloads_flash.html b/web/templates/_downloads_flash.html new file mode 100644 index 0000000..d8bb2a6 --- /dev/null +++ b/web/templates/_downloads_flash.html @@ -0,0 +1,4 @@ +{#- Сообщения после запуска/отмены скачивания (download_message / download_error в query string). -#} +{% from "_ui.html" import alert %} +{% if request.query_params.get('download_message') %}{{ alert('success', request.query_params.get('download_message')) }}{% endif %} +{% if request.query_params.get('download_error') %}{{ alert('danger', request.query_params.get('download_error')) }}{% endif %} diff --git a/web/templates/_downloads_url.html b/web/templates/_downloads_url.html new file mode 100644 index 0000000..aed6e12 --- /dev/null +++ b/web/templates/_downloads_url.html @@ -0,0 +1,17 @@ +{#- Карточка «Скачать по ссылке». Ожидает переменную kind ('iso' | 'disk'). -#} +{% from "_ui.html" import icon %} +
+

Скачать по ссылке

Сервер скачает файл сам — не нужно сначала загружать его на компьютер

+
+
+ +
+ + + {{ 'Ссылка должна вести на файл .iso — например, со страницы загрузки Ubuntu или Debian.' if kind == 'iso' else 'Подойдут файлы .qcow2, .img, .raw и сжатые .img.xz — распакуем и подготовим сами.' }} +
+
+
+
+
diff --git a/web/templates/_vm_disks.html b/web/templates/_vm_disks.html new file mode 100644 index 0000000..ed2df64 --- /dev/null +++ b/web/templates/_vm_disks.html @@ -0,0 +1,27 @@ +{#- Карточка «Диски» на вкладке «Диски и загрузка»: размеры и форма увеличения. Ожидает vm из vm_detail.html. -#} +{% from "_ui.html" import icon %} +{% set dk = vmops_disks(vm.name) %} +
+

Диски

Диск можно увеличить, уменьшить — нельзя

+
+ {% for disk in dk.disks %} +
+ {{ icon('disc-3' if disk.is_cdrom else 'hard-drive') }} +
+
{{ disk.name }}
+
{{ disk.label }} {{ disk.target }}{% if disk.size_text %} · {{ disk.size_text }}{% if disk.used_text %}, занято {{ disk.used_text }}{% endif %}{% endif %}
+
+ {% if disk.resizable %} +
+ +
ГБ
+ +
+ {% endif %} +
+ {% else %} +
Дисков не найдено
+ {% endfor %} +
+ +
diff --git a/web/templates/_vm_live_meters.html b/web/templates/_vm_live_meters.html new file mode 100644 index 0000000..424d77b --- /dev/null +++ b/web/templates/_vm_live_meters.html @@ -0,0 +1,6 @@ +{#- Две маленькие шкалы (процессор, память) в строке машины на обзоре; panel.js заполняет их из /live/stats. -#} +{% from "_ui.html" import icon %} +
+ {{ icon('cpu', 'icon-sm') }}— + {{ icon('memory-stick', 'icon-sm') }}— +
diff --git a/web/templates/_vm_ops_alerts.html b/web/templates/_vm_ops_alerts.html new file mode 100644 index 0000000..8a51940 --- /dev/null +++ b/web/templates/_vm_ops_alerts.html @@ -0,0 +1,6 @@ +{#- Сообщения после действий vmops (пауза, диски, заметки): читаются из адреса страницы. -#} +{% from "_ui.html" import alert %} +{% for key in ('ops', 'disk', 'notes') %} + {% if request.query_params.get(key ~ '_message') %}{{ alert('success', request.query_params.get(key ~ '_message')) }}{% endif %} + {% if request.query_params.get(key ~ '_error') %}{{ alert('danger', request.query_params.get(key ~ '_error')) }}{% endif %} +{% endfor %} diff --git a/web/templates/_vm_ops_cards.html b/web/templates/_vm_ops_cards.html new file mode 100644 index 0000000..0be1257 --- /dev/null +++ b/web/templates/_vm_ops_cards.html @@ -0,0 +1,43 @@ +{#- Карточки на обзоре машины: нагрузка сейчас, спящий режим, заметки. Ожидает vm из vm_detail.html. -#} +{% from "_ui.html" import icon %} +{% set notes = vmops_notes(vm.name) %} +{% set ops_s = (vm.info.state or 'shut off')|vm_state %} +{% if ops_s.tone != 'neutral' %} +
+

Нагрузка сейчас

Обновляется каждые 5 секунд

{{ icon('activity', 'icon-sm') }}
+
+
+
{{ icon('cpu', 'icon-sm') }}Процессор—
+
+
+
+
{{ icon('memory-stick', 'icon-sm') }}Память—
+
+
+
+
+{% endif %} +{% if vmops_saved(vm.dominfo) %} +
+
+
{{ icon('moon') }}
Машина в спящем режиме
При запуске она продолжит работу с того же места: открытые программы и окна останутся.
+
+
+
+{% endif %} +
+

Заметки

Название видно в списке машин

{{ icon('notebook-pen', 'icon-sm') }}
+
+
+
+ + +
+
+ + +
+
+ +
+
diff --git a/web/templates/_vm_ops_menu.html b/web/templates/_vm_ops_menu.html new file mode 100644 index 0000000..2ac3c55 --- /dev/null +++ b/web/templates/_vm_ops_menu.html @@ -0,0 +1,15 @@ +{#- Пункты меню машины: пауза, продолжение, спящий режим, клонирование. + Перед include задайте ops_name (имя), ops_state (состояние virsh), + ops_next (куда вернуться после действия) и ops_hints (подсказки под пунктами). -#} +{% from "_ui.html" import icon %} +{%- set ops = ops_state|vm_state -%} +{%- macro item(label, hint, hints) -%}{{ label }}{% if hints %}{{ hint }}{% endif %}{%- endmacro -%} +{% if ops.running %} +
+
+{% elif ops.raw == 'paused' %} +
+
+{% endif %} +{{ icon('copy') }}{{ item('Клонировать', 'Точная копия с новым именем', ops_hints) }} + diff --git a/web/templates/backup_restore.html b/web/templates/backup_restore.html new file mode 100644 index 0000000..594419e --- /dev/null +++ b/web/templates/backup_restore.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, alert %} +{% set page_title = 'Восстановить ' ~ backup.vm %} + +{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% if error %}{{ alert('danger', error) }}{% endif %} +{% endblock %} + +{% block content %} +
+
+
+
+
+ + + Оставьте как есть, чтобы вернуть машину {{ backup.vm }}, или введите новое название — получится вторая независимая машина. +
+ + {% if exists %} +
{{ icon('triangle-alert') }}
Машина {{ name }} уже есть на сервереЧтобы восстановить копию под этим названием, существующую машину придётся удалить вместе с её дисками. Если хотите сохранить обе — укажите другое название.
+ + {% else %} + + {% endif %} + +
+
Дисков в копии{{ backup.disk_count }} · {{ backup.size }}
+
Понадобится местаоколо {{ need }}
+
Свободно{{ free }}
+
+
+ +
+
+ + +
+{% endblock %} diff --git a/web/templates/backups.html b/web/templates/backups.html new file mode 100644 index 0000000..44f87ff --- /dev/null +++ b/web/templates/backups.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, meter, empty, alert %} +{% set page_title = 'Резервные копии' %} + +{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% if backup_message %}{{ alert('success', backup_message) }}{% endif %} + {% if backup_error %}{{ alert('danger', backup_error) }}{% endif %} + {% for op in running %} +
{{ icon('activity') }}
{{ op.title }} — {{ op.progress|default(0) }}%{{ op.message }}
Следить
+ {% endfor %} +{% endblock %} + +{% block content %} +
+
+
Копий на сервере{{ icon('database', 'icon-sm') }}
+
{{ storage.count }}{{ plural(storage.count, 'копия', 'копии', 'копий') }}
+
{{ groups|length }} {{ plural(groups|length, 'машина', 'машины', 'машин') }}
+
+
+
Занято копиями{{ icon('hard-drive', 'icon-sm') }}
+
{{ storage.used }}
+ {{ meter(storage.used_pct, 'brand') }} +
{{ storage.used_pct }}% места на диске сервера
+
+
+
Свободно для новых копий{{ icon('layers', 'icon-sm') }}
+
{{ storage.free }}
+
Перед копированием место проверяется автоматически
+
+
+ +{% if groups %} +
+ {% for g in groups %} +
+
+
+ {{ icon('monitor') }} +
+

{% if g.exists %}{{ g.vm }}{% else %}{{ g.vm }}{% endif %}{% if not g.exists %} {{ badge('машина удалена', 'warning', cls='hide-sm') }}{% endif %}

+

{{ g.count }} {{ plural(g.count, 'копия', 'копии', 'копий') }} · {{ g.size }}{% if not g.exists %} · машины больше нет на сервере, но её можно восстановить{% endif %}

+
+
+ {% if g.exists %}{{ icon('plus', 'icon-sm') }}Создать копию{% endif %} +
+
+ {% with rows = g.backups, back = '/backups' %}{% include "_backup_rows.html" %}{% endwith %} +
+
+ {% endfor %} +
+{% else %} +
+ {{ empty('database', 'Копий пока нет', 'Откройте страницу машины, перейдите на вкладку «Резервные копии» и нажмите «Создать копию». Копирование идёт в фоне — страницу можно закрыть.', '/', 'К списку машин', 'monitor') }} +
+{% endif %} + +
+
+
+
{{ icon('shield-check') }}

Что входит в копию

+

Настройки машины (процессор, память, сеть, порядок загрузки) и все её диски целиком. Установочные образы (ISO) не копируются — они лежат отдельно в разделе «Образы ОС». Диски сжимаются, поэтому копия обычно меньше самой машины.

+
+
+
+
+
{{ icon('info') }}

Как это работает

+

Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной: под новым названием — с новыми сетевыми адресами, чтобы не мешать оригиналу; при замене — с прежними. Копии хранятся в папке /var/lib/virtuality/backups.

+
+
+
+{% endblock %} diff --git a/web/templates/base.html b/web/templates/base.html index 5364d58..c5cb203 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -39,12 +39,14 @@ {{ nav_link('/iso', 'disc-3', 'Образы ОС', path.startswith('/iso') or path.startswith('/disk-images')) }} {{ nav_link('/network', 'globe', 'Сеть и доступ', path.startswith('/network')) }} + {{ nav_link('/backups', 'database', 'Резервные копии', path.startswith('/backups')) }} {{ nav_link('/operations', 'list-checks', 'Задачи', path.startswith('/operations')) }} {{ nav_link('/logs', 'scroll-text', 'Журналы', path.startswith('/logs')) }} {{ nav_link('/update', 'refresh-cw', 'Обновления', path.startswith('/update'), '' if notice.has_update else '') }} {{ nav_link('/host', 'server', 'Сервер', path.startswith('/host')) }} + {{ nav_link('/settings', 'settings', 'Настройки', path.startswith('/settings')) }} {{ nav_link('/help', 'circle-help', 'Помощь', path.startswith('/help')) }} @@ -69,7 +71,8 @@
{% block page_header %}{% endblock %} -
{% block alerts %}{% if error %}{{ alert('danger', error) }}{% endif %}{% endblock %}
+ {% set flash_error = error or request.query_params.get('error') %}{% set flash_message = message or request.query_params.get('message') %} +
{% block alerts %}{% if flash_error %}{{ alert('danger', flash_error) }}{% endif %}{% if flash_message %}{{ alert('success', flash_message) }}{% endif %}{% endblock %}
{% block content %}{% endblock %}
diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 45ab270..17d6467 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -2,6 +2,7 @@ {% 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 %} +{% set titles = vmops_titles() if vms else {} %} {% block page_header %} + {% include "_downloads_url.html" %}

Где взять образ

diff --git a/web/templates/settings.html b/web/templates/settings.html new file mode 100644 index 0000000..52fc193 --- /dev/null +++ b/web/templates/settings.html @@ -0,0 +1,174 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, alert %} +{% set page_title = 'Настройки' %} + +{% block page_header %} + +{% endblock %} + +{% block content %} +{% if not ctl %} + {{ alert('warning', 'Команда virtuality-ctl не найдена, поэтому изменения применить нельзя. Переустановите панель: sudo bash scripts/install_web_panel.sh', 'Панель установлена без virtuality-ctl') }} +{% endif %} +
+
+ + {# ------------------------------------------------ access #} +
+

Доступ к панели

Сейчас панель открывается по адресу {{ panel_url }}

{{ badge('HTTPS' if tls else 'HTTP', 'success' if tls else 'warning') }}
+
+
+
+ + +
+
+
Стандартный — 8088. С HTTPS панель отвечает на {{ tls_port }}, а этот порт перенаправляет на защищённый.
+
+
+ +
+
+ + {# ------------------------------------------------ password #} +
+

Пароль

Вход в панель — по пользователю {{ auth_user }} и его паролю Linux. Смена пароля здесь меняет его и для SSH.

+
+
+
+
+
+ +
+
+ + {# ------------------------------------------------ network #} +
+

Сеть машин

{% if net.on_bridge %}Машины подключены к вашей локальной сети через мост br0 — роутер выдаёт им адреса как обычным устройствам.{% else %}Машины живут в отдельной сети NAT: у них есть интернет, а доступ снаружи открывается в разделе «Сеть и доступ».{% endif %}

{{ badge('Локальная сеть' if net.on_bridge else 'NAT', 'success' if net.on_bridge else 'info') }}
+
+
+
Интерфейс{{ net.interface or '—' }}
+
Адрес сервера{{ net.address or '—' }}{% if net.prefix %}/{{ net.prefix }}{% endif %}
+
Шлюз{{ net.gateway or '—' }}
+
+ {% if net.revert_armed %} + {{ alert('warning', 'Мост включён, но ещё не подтверждён. Если не подтвердить, через 2 минуты сеть вернётся к прежней.', 'Ожидается подтверждение сети') }} + {% endif %} +
+ +
+ + {# ------------------------------------------------ storage #} +
+

Хранилище

Диски машин, образы и резервные копии лежат в {{ storage.path }}.

{{ badge('Отдельный диск' if storage.dedicated else 'Системный диск', 'success' if storage.dedicated else 'neutral') }}
+
+
+
Свободно{{ storage.free|format_bytes }} из {{ storage.total|format_bytes }}
+
Устройство{{ storage.source or '—' }}
+
+ {% if storage.spare %} +
+
+ {% for disk in storage.spare %} + + {% endfor %} +
+
+
+ {% else %} +

Свободных дисков не найдено. Подключите пустой диск от 16 ГБ — он появится здесь.

+ {% endif %} +
+
+
+ + +
+{% endblock %} diff --git a/web/templates/settings_power.html b/web/templates/settings_power.html new file mode 100644 index 0000000..fea039d --- /dev/null +++ b/web/templates/settings_power.html @@ -0,0 +1,39 @@ +{% extends "setup_base.html" %} +{% from "_ui.html" import icon %} +{% block rail %}{% endblock %} +{% block step %} +

{{ 'Перезагружаем сервер' if action == 'reboot' else 'Выключаем сервер' }}

Машины выключаются корректно, это может занять до нескольких минут.

+
+
{{ message }}…
+
+ {% if action == 'reboot' %} +

Когда сервер снова включится, эта страница откроет панель сама. Обычно это занимает 1–2 минуты.

+ {% else %} +

Чтобы включить сервер снова, нажмите кнопку питания на нём. Эту вкладку можно закрыть.

+ {% endif %} +
+ +{% endblock %} +{% block scripts %} +{% if action == 'reboot' %} + +{% endif %} +{% endblock %} diff --git a/web/templates/setup.html b/web/templates/setup.html new file mode 100644 index 0000000..ce2b8f1 --- /dev/null +++ b/web/templates/setup.html @@ -0,0 +1,241 @@ +{% extends "setup_base.html" %} +{% from "_ui.html" import icon, badge, alert, meter %} + +{% block step %} +{# ============================================================ install #} +{% if step == 'install' %} +

Настраиваем сервер

Устанавливаются компоненты виртуализации. Обычно это занимает 5–15 минут — можно оставить страницу открытой, дальше всё произойдёт само.

+
+ {% if state.stage == 'error' %} + {{ alert('danger', state.message or 'Установка остановилась с ошибкой. Она будет повторена автоматически через минуту.', 'Что-то пошло не так') }} + {% elif state.stage == 'waiting-network' %} + {{ alert('warning', 'Подключите сервер к интернету кабелем или настройте Wi-Fi — установка продолжится сама, ничего нажимать не нужно.', 'Нет доступа в интернет') }} + {% endif %} +
+ {% for item in state.steps %} +
+ {% if item.status == 'done' %}{{ icon('circle-check') }}{% elif item.status == 'error' %}{{ icon('circle-x') }}{% elif item.status == 'running' %}{% else %}{{ icon('circle-dot') }}{% endif %} +
{{ item.title }}{% if item.status == 'running' and state.message %}{{ state.message }}{% endif %}
+
+ {% else %} +
Подготовка…
+ {% endfor %} +
+
+ {{ icon('terminal', 'icon-sm') }}Что происходит{{ icon('chevron-down', 'icon-sm') }} +
{{ state.log_tail or 'Журнал пока пуст.' }}
+
+
+ + +{# ============================================================ welcome #} +{% elif step == 'welcome' %} +

Добро пожаловать в Virtuality

Проверим сервер и зададим время. Дальше — пять коротких шагов.

+
+
+
+
Сервер{{ hardware.hostname }}
+
Процессор{{ hardware.cpu_count }} {{ plural(hardware.cpu_count, 'ядро', 'ядра', 'ядер') }}
+
Память{{ hardware.mem_total|format_bytes }}
+
Свободно для машин{{ hardware.disk_free|format_bytes }}
+
+ {% if hardware.kvm %} + {{ alert('success', 'Аппаратное ускорение включено — машины будут работать быстро.', 'Виртуализация готова') }} + {% else %} + {{ alert('warning', 'Машины будут работать очень медленно. Перезагрузите сервер, войдите в BIOS/UEFI (обычно клавиша Del или F2) и включите Intel VT-x / AMD-V (может называться SVM Mode или Virtualization Technology). Продолжить можно и без этого.', 'Аппаратное ускорение выключено') }} + {% endif %} +
+ + + Сейчас на сервере: {{ timezones.current }}. От пояса зависят время в журналах и час ночного обновления. +
+
+ +
+ +{# ============================================================ account #} +{% elif step == 'account' %} +

Администратор

В панель входят по имени и паролю пользователя сервера. Пароль нигде не сохраняется.

+
+
+
{{ icon('user') }}
{{ auth_user }}Пользователь для входа в панель и по SSH
{{ badge('Администратор', 'brand', False) }}
+
+ {{ icon('lock', 'icon-sm') }}Сменить пароль{{ icon('chevron-down', 'icon-sm') }} +
+
+
+
+
+ Не короче 6 символов. Этот же пароль будет действовать для SSH и консоли сервера. +
+
+

{{ icon('info', 'icon-sm') }} Если пароль забудется, его можно сбросить с монитора и клавиатуры сервера: sudo virtuality-ctl passwd.

+
+ +
+ +{# ============================================================ access #} +{% elif step == 'access' %} +

Доступ к панели

Как вы будете открывать Virtuality в браузере.

+
+
+
+ + +
+
+ {{ icon('wrench', 'icon-sm') }}Порт панели{{ icon('chevron-down', 'icon-sm') }} +
+
Стандартный — 8088. Меняйте, только если он занят.
+

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

+
+
+

{{ icon('info', 'icon-sm') }} Настройки применятся на последнем шаге, после чего панель перезапустится и откроется по новому адресу.

+
+ +
+ +{# ============================================================ network #} +{% elif step == 'network' %} +

Сеть машин

Как виртуальные машины будут видны в сети. Это можно изменить позже в разделе «Сеть и доступ».

+
+
+ {% if net.on_bridge %} + {{ alert('success', 'Машины будут получать адреса от вашего роутера, как обычные устройства.', 'Сервер уже подключён через мост br0') }} + {% elif net.wireless %} + {{ alert('info', 'Сервер подключён по Wi-Fi, поэтому машины не могут появиться в домашней сети напрямую — они выйдут в интернет через сервер (NAT). Подключите кабель, если нужен режим домашней сети.') }} + {% elif net.is_vps %} + {{ alert('info', 'Похоже, это VPS или облачный сервер: у него публичный адрес. Машины будут скрыты за сервером, а нужные сервисы вы откроете наружу в разделе «Сеть и доступ».') }} + {% endif %} +
+ + +
+ {% if net.bridge_possible %} +
{{ icon('info') }}
Как включается домашняя сетьИнтерфейс {{ net.interface }} ({{ net.address or 'адрес по DHCP' }}) объединится в мост br0 с тем же MAC-адресом, поэтому адрес сервера не изменится. Если панель не ответит в течение 2 минут, сервер сам вернёт прежнюю сеть.
+ {% endif %} +
+ +
+ +{# ============================================================ storage #} +{% elif step == 'storage' %} +

Хранилище

Где будут лежать диски машин, образы и резервные копии.

+
+
+
+
Папка{{ storage.path }}
+
Свободно{{ storage.free|format_bytes }} из {{ storage.total|format_bytes }}
+
Диск{{ storage.source or 'системный' }}{% if storage.dedicated %} (отдельный){% endif %}
+
+ {% if storage.spare %} +
{{ icon('sparkles') }}
Найден свободный дискЕго можно отдать под машины целиком: системный диск останется для Ubuntu, а место для машин будет отдельным.
+
+ + {% for disk in storage.spare %} + + {% endfor %} +
+ {% else %} +

Свободных дисков не найдено — машины будут храниться на системном диске. Если позже добавите диск, его можно подключить в Настройках.

+ {% endif %} +
+ +
+ +{# ============================================================ updates #} +{% elif step == 'updates' %} +

Обновления

Virtuality умеет обновляться сама. Машины во время обновления продолжают работать.

+
+
+
+ + +
+
+ {{ icon('layers', 'icon-sm') }}Канал обновлений{{ icon('chevron-down', 'icon-sm') }} +
+ +
+
+
+ +
+ +{# ============================================================ finish #} +{% elif step == 'finish' %} +

Всё готово

Проверьте и нажмите «Завершить». {% if restart_needed %}Панель перезапустится и откроется по новому адресу.{% endif %}

+
+
+
+
Адрес панели{{ final_url }}
+
Администратор{{ auth_user }}{% if choices.get('password_changed') %} · пароль обновлён{% endif %}
+
Защищённое соединение{{ 'Включено (HTTPS)' if choices.get('tls', '1')|string == '1' else 'Выключено' }}
+
Сеть машин{{ 'Домашняя сеть (мост br0)' if choices.get('network') == 'bridge' or net.on_bridge else 'Отдельная сеть (NAT)' }}
+
Хранилище{{ storage.free|format_bytes }} свободно{% if storage.dedicated %} · отдельный диск{% endif %}
+
Обновления{{ 'Автоматически' if choices.get('auto_update', settings.VIRTUALITY_AUTO_UPDATE)|string == '1' else 'Вручную' }}{% if choices.get('channel', settings.VIRTUALITY_UPDATE_CHANNEL) == 'main' %} · ранний доступ{% endif %}
+
Часовой пояс{{ timezone }}
+
Ускорение{{ 'KVM включён' if hardware.kvm else 'Выключено — машины будут медленными' }}
+
+ {% if choices.get('tls', '1')|string == '1' %} +
{{ icon('shield-check') }}
При первом открытии по HTTPS браузер предупредит о сертификатеНажмите «Дополнительно» → «Перейти на сайт» (в Chrome) или «Принять риск» (в Firefox). Это происходит один раз.
+ {% endif %} +

Что дальше: загрузите установочный образ или выберите готовую систему из каталога и создайте первую машину. Все настройки можно изменить в разделе «Настройки».

+
+ +
+{% endif %} +{% endblock %} diff --git a/web/templates/setup_applying.html b/web/templates/setup_applying.html new file mode 100644 index 0000000..bd327d8 --- /dev/null +++ b/web/templates/setup_applying.html @@ -0,0 +1,30 @@ +{% extends "setup_base.html" %} +{% from "_ui.html" import icon, alert %} +{% block rail %}{% endblock %} +{% block step %} +

Применяем настройки

Панель перезапускается — это занимает несколько секунд.

+
+
Перезапускаем панель…
+
+
Новый адрес панели{{ final_url }}
+ {% if tls %} +
{{ icon('shield-check') }}
Браузер предупредит о сертификатеСертификат создан этим сервером, поэтому браузер его не знает. Нажмите «Дополнительно» → «Перейти на сайт» (Chrome) или «Принять риск и продолжить» (Firefox). Дальше предупреждение не появится.
+ {% endif %} +

Сохраните новый адрес в закладки. Если страница не открылась сама через 15 секунд, перейдите по ссылке выше.

+
+ +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/web/templates/setup_base.html b/web/templates/setup_base.html new file mode 100644 index 0000000..45b4c19 --- /dev/null +++ b/web/templates/setup_base.html @@ -0,0 +1,60 @@ +{% from "_ui.html" import icon, logo, alert %} + + + + + + + {{ page_title|default('Мастер настройки') }} — Virtuality + + + + + +
+ +
+ {{ user }} + +
+
+
+ {% block rail %} + {% if steps is defined %} + + {% endif %} + {% endblock %} +
+ {% if error %}
{{ alert('danger', error) }}
{% endif %} + {% if message %}
{{ alert('success', message) }}
{% endif %} +
+ {% block step %}{% endblock %} +
+
+
+ +
+
{{ icon('triangle-alert', 'icon-lg') }}

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

+
+
+
+
+ + {% block scripts %}{% endblock %} + + diff --git a/web/templates/setup_bridge_wait.html b/web/templates/setup_bridge_wait.html new file mode 100644 index 0000000..4dc51ea --- /dev/null +++ b/web/templates/setup_bridge_wait.html @@ -0,0 +1,48 @@ +{% extends "setup_base.html" %} +{% from "_ui.html" import icon, alert %} +{% block rail %}{% endblock %} +{% block step %} +

Перенастраиваем сеть

Сервер объединяет сетевой порт в мост br0. Это займёт около 20 секунд, страница не перезагружается.

+
+
Ждём, пока сервер снова ответит…
+
+
{{ icon('info') }}
Если страница так и не оживётНичего страшного: через 2 минуты сервер сам вернёт прежнюю сеть. Обновите страницу и выберите «Отдельная сеть (NAT)» или попробуйте ещё раз.
+
+ +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/web/templates/update.html b/web/templates/update.html index 582e198..1abecb6 100644 --- a/web/templates/update.html +++ b/web/templates/update.html @@ -66,7 +66,9 @@
Установлена{{ info.current_version }}
{% if info.has_update %}
Доступна{{ info.latest_version }}
{% endif %} -
Канал{{ info.branch }}
+
Канал{{ info.channel_label or info.branch }}
+ {% if info.target %}
Обновляемся до{{ info.target }}
{% endif %} +
Сборка{{ info.current_commit[:8] if info.current_commit else '—' }}
diff --git a/web/templates/vm_backups.html b/web/templates/vm_backups.html new file mode 100644 index 0000000..a3e4907 --- /dev/null +++ b/web/templates/vm_backups.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, vm_badge, empty, alert %} +{% set page_title = vm.name ~ ' — резервные копии' %} +{% set s = vm.state|vm_state %} + +{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% if backup_message %}{{ alert('success', backup_message) }}{% endif %} + {% if backup_error %}{{ alert('danger', backup_error) }}{% endif %} + {% if active %} +
{{ icon('activity') }}
{{ active.title }} — {{ active.progress|default(0) }}%{{ active.message }}
Следить
+ {% endif %} +{% endblock %} + +{% block content %} + + +
+
+

Копии этой машины

Новые сверху. Из любой можно восстановить машину — под тем же или новым названием.

+ {% if backups %} +
+ {% with rows = backups, back = '/vm/' ~ vm.name ~ '/backups' %}{% include "_backup_rows.html" %}{% endwith %} +
+ {% else %} + {{ empty('database', 'Копий пока нет', 'Создайте первую копию — это займёт несколько минут и защитит от неудачных обновлений и случайных ошибок.') }} + {% endif %} +
+ + +
+{% endblock %} diff --git a/web/templates/vm_clone.html b/web/templates/vm_clone.html new file mode 100644 index 0000000..3fc41bd --- /dev/null +++ b/web/templates/vm_clone.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, vm_badge, alert %} +{% set page_title = 'Клонировать ' ~ vm_name %} +{% set st = state|vm_state %} + +{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% if error %}{{ alert('danger', error) }}{% endif %} +{% endblock %} + +{% block content %} +
+
+
+

Новая машина

Копия появится в списке машин выключенной

+ {% if blockers %} +
+ {% if 'running' in blockers %} +
{{ icon('triangle-alert') }}
Сначала выключите машинуДиски копируются только у выключенной машины, иначе копия может получиться повреждённой.
+ {% if st.running %}
{% endif %} +
+ {% endif %} + {% if 'saved' in blockers %}{{ alert('warning', 'Машина в спящем режиме, её память сохранена на диске. Запустите машину и выключите её обычным образом — тогда копия будет целой.', 'Сначала разбудите машину') }}{% endif %} + {% if 'space' in blockers %}{{ alert('danger', 'Копии нужно ' ~ needed_text ~ ', а свободно только ' ~ free_text ~ '. Удалите ненужные машины или образы.', 'Не хватает места на сервере') }}{% endif %} + {% if 'no_disks' in blockers %}{{ alert('danger', 'У машины нет дисков, которые можно скопировать.') }}{% endif %} + {% if 'busy' in blockers %}{{ alert('info', 'Эта машина уже клонируется. Дождитесь окончания в разделе «Задачи».') }}{% endif %} +
+ {% endif %} +
+
+
+ + + Латинские буквы, цифры, точка, дефис и подчёркивание. Например: {{ suggested }} +
+
+
Что скопируется{% for d in disks %}{{ d.name }}{% if d.size_text %} · {{ d.size_text }}{% endif %}{% if not loop.last %}
{% endif %}{% else %}—{% endfor %}
+
Нужно места{{ needed_text }}
+
Свободно на сервере{{ free_text }}
+
+
+ +
+
+
+ + +
+{% endblock %} diff --git a/web/templates/vm_create.html b/web/templates/vm_create.html index cb89a2c..4fbaf00 100644 --- a/web/templates/vm_create.html +++ b/web/templates/vm_create.html @@ -55,15 +55,40 @@ Нужен другой? Загрузите образ. {% endif %} +
+ +
+ {% for os in os_types %} + + {% endfor %} +
+ От этого зависят виртуальные устройства: Windows получит совместимые диск и сеть, Windows 11 — UEFI и TPM. +
{% if disk_images %} Образ будет скопирован — оригинал останется для других машин. {% endif %}
+
+ +
+
+
Получит права администратора (sudo)
+
Не короче 6 символов
+
+
С ключом можно обойтись без пароля: вход по SSH будет только по ключу.
+
+
@@ -95,7 +120,7 @@
-
+
{% if profile.virtualization_warning %}{{ alert('warning', 'На этом сервере нет аппаратного ускорения — машины будут заметно медленнее.') }}{% endif %} @@ -126,19 +151,11 @@
{{ icon('wrench', 'icon-sm') }}Дополнительные настройки{{ icon('chevron-down', 'icon-sm') }}
-
-
- - -
-
- - -
+
+ +
diff --git a/web/templates/vm_detail.html b/web/templates/vm_detail.html index 7764bd1..5f3aa13 100644 --- a/web/templates/vm_detail.html +++ b/web/templates/vm_detail.html @@ -21,12 +21,15 @@ {% if s.running %} {{ icon('monitor') }}Открыть экран
+ {% elif s.raw == 'paused' %} +
{% else %}
{% endif %}
@@ -198,16 +196,7 @@
-
-

Диски

-
- {% for disk in vm.disk_list %} -
{{ icon('disc-3' if disk.is_cdrom else 'hard-drive') }}
{{ disk.name }}
{{ disk.label }} · {{ disk.target }}
- {% else %} -
Дисков не найдено
- {% endfor %} -
-
+ {% include "_vm_disks.html" %}

Сетевые карты

diff --git a/web/templates/vm_snapshots.html b/web/templates/vm_snapshots.html new file mode 100644 index 0000000..82da02f --- /dev/null +++ b/web/templates/vm_snapshots.html @@ -0,0 +1,171 @@ +{% extends "base.html" %} +{% from "_ui.html" import icon, badge, vm_badge, op_badge, empty, alert %} +{% set page_title = 'Снимки — ' ~ vm.name %} +{% set s = vm.info.state|vm_state %} +{% set count = snapshots|length %} + +{% block page_header %} + +{% endblock %} + +{% block alerts %} + {% if snapshot_message %}{{ alert('success', snapshot_message) }}{% endif %} + {% if snapshot_error %}{{ alert('danger', snapshot_error) }}{% endif %} + {% if operation and operation.status == 'success' %}{{ alert('success', operation.message) }}{% endif %} + {% if operation and operation.status == 'error' %}{{ alert('danger', operation.message, title='Не получилось: ' ~ operation.title|lower) }}{% endif %} +{% endblock %} + +{% block content %} + + +
+
+ {% for op in active_operations %} +
+ +
+ {{ op.title }} + {{ op.message }} +
+
+ Подробнее{{ icon('chevron-right', 'icon-sm') }} +
+ {% endfor %} + +
+
+

Точки восстановления

Сохранённые состояния машины, к которым можно вернуться

+ {{ count }} из {{ max_snapshots }} +
+ {% if snapshots %} +
+ {% for snap in snapshots %} +
+ {{ icon('camera') }} +
+
+ {{ snap.title }} + {% if snap.is_current %}{{ badge('Текущий', 'brand', dot=False, attrs='title="Машина сейчас продолжает работу от этого снимка"') }}{% endif %} +
+
{{ snap.when }} · {{ snap.state_info.label }}{% if snap.with_memory %} — сохранена вместе с памятью{% endif %}{% if snap.description %} · {{ snap.name }}{% endif %}
+
+ {% if snap.manageable %} +
+ +
+
+ +
+ {% else %} + Создан вне панели + {% endif %} +
+ {% endfor %} +
+ {% else %} + {{ empty('camera', 'Снимков пока нет', 'Сделайте снимок перед обновлением или рискованной настройкой. Если что-то пойдёт не так, машину можно будет вернуть назад за минуту.') }} + {% endif %} +
+ +
+ {{ icon('terminal', 'icon-sm') }}Для специалистов{{ icon('chevron-down', 'icon-sm') }} +
+

Это внутренние снимки libvirt (virsh snapshot-create-as … --atomic): они хранятся внутри qcow2-файла диска. Возврат — virsh snapshot-revert, удаление — virsh snapshot-delete.

+
+
virsh snapshot-list --domain {{ vm.name }}
+
{{ raw_list or '—' }}
+
+ {% if checks.disks %} +

Диски: {% for d in checks.disks %}{{ d.target }} — {{ d.format }}{{ ', ' if not loop.last }}{% endfor %}{% if checks.uefi %} · прошивка UEFI{% endif %}

+ {% endif %} +
+
+
+ + +
+{% endblock %} + +{% block scripts %} +{% if active_operations %} + +{% endif %} +{% endblock %} diff --git a/web/update_core.py b/web/update_core.py index 270b083..e146127 100644 --- a/web/update_core.py +++ b/web/update_core.py @@ -12,6 +12,8 @@ STATE_FILE = STATE_DIR / 'state.json' LOG_FILE = Path('/var/log/virtuality/update.log') DEFAULT_BRANCH = os.environ.get('VIRTUALITY_UPDATE_BRANCH', 'main') REMOTE = os.environ.get('VIRTUALITY_UPDATE_REMOTE', 'origin') +NODE_ENV = Path(os.environ.get('VIRTUALITY_NODE_ENV', '/var/lib/virtuality/config/web.env')) +CHANNELS = {'stable': 'Стабильный — выпущенные версии', 'main': 'Ранний доступ — все изменения'} class UpdateError(Exception): @@ -69,8 +71,37 @@ def current_commit() -> str: return result['stdout'] if result['ok'] else '' -def latest_commit() -> str: - result = run_cmd(['git', 'rev-parse', f'{REMOTE}/{DEFAULT_BRANCH}'], cwd=SOURCE_DIR, timeout=10) +def update_channel() -> str: + """stable: the latest release tag vX.Y.Z; main: every change on the main branch.""" + value = os.environ.get('VIRTUALITY_UPDATE_CHANNEL', '') + if not value: + try: + for line in NODE_ENV.read_text().splitlines(): + if line.startswith('VIRTUALITY_UPDATE_CHANNEL='): + value = line.split('=', 1)[1].strip().strip('"').strip("'") + except OSError: + pass + return value if value in CHANNELS else 'stable' + + +def latest_release_tag() -> str: + result = run_cmd(['git', 'tag', '--list', 'v[0-9]*', '--sort=-v:refname'], cwd=SOURCE_DIR, timeout=10) + tags = [tag for tag in result['stdout'].split() if all(part.isdigit() for part in tag[1:].split('.'))] if result['ok'] else [] + return tags[0] if tags else '' + + +def target_ref(channel: str | None = None) -> str: + """Git ref the node updates to. Until the first release is tagged, stable follows the main branch.""" + channel = channel or update_channel() + if channel == 'stable': + tag = latest_release_tag() + if tag: + return f'refs/tags/{tag}' + return f'{REMOTE}/{DEFAULT_BRANCH}' + + +def latest_commit(ref: str | None = None) -> str: + result = run_cmd(['git', 'rev-parse', f'{ref or target_ref()}^{{commit}}'], cwd=SOURCE_DIR, timeout=10) return result['stdout'] if result['ok'] else '' @@ -91,8 +122,8 @@ def current_version() -> str: return local_file_text('VERSION') or '0.0.0' -def latest_version() -> str: - value = file_from_git(f'{REMOTE}/{DEFAULT_BRANCH}', 'VERSION').strip() +def latest_version(ref: str | None = None) -> str: + value = file_from_git(ref or target_ref(), 'VERSION').strip() return value or current_version() @@ -169,13 +200,15 @@ def check_updates(fetch: bool = True) -> dict[str, Any]: fetch_result = {'ok': True, 'stdout': '', 'stderr': '', 'cmd': 'skip'} if fetch: - fetch_result = run_cmd(['git', 'fetch', '--quiet', REMOTE, DEFAULT_BRANCH], cwd=SOURCE_DIR, timeout=60) + fetch_result = run_cmd(['git', 'fetch', '--quiet', '--tags', '--force', REMOTE, DEFAULT_BRANCH], cwd=SOURCE_DIR, timeout=60) + channel = update_channel() + ref = target_ref(channel) local_commit = current_commit() - remote_commit = latest_commit() + remote_commit = latest_commit(ref) local_version = current_version() - remote_version = latest_version() - manifest = load_manifest(f'{REMOTE}/{DEFAULT_BRANCH}') + remote_version = latest_version(ref) + manifest = load_manifest(ref) 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 and is_ancestor(local_commit, remote_commit)) @@ -185,6 +218,9 @@ def check_updates(fetch: bool = True) -> dict[str, Any]: 'source_dir': str(SOURCE_DIR), 'remote': REMOTE, 'branch': DEFAULT_BRANCH, + 'channel': channel, + 'channel_label': CHANNELS[channel], + 'target': ref.removeprefix('refs/tags/').removeprefix(f'{REMOTE}/'), 'fetch_ok': fetch_result['ok'], 'fetch_error': fetch_result['stderr'] or fetch_result['stdout'], 'current_commit': local_commit, @@ -230,6 +266,6 @@ def start_update() -> dict[str, Any]: # 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 + cmd = ['systemd-run', '--unit', unit, '--collect', '--quiet', f'--setenv=VIRTUALITY_SOURCE_DIR={SOURCE_DIR}', f'--setenv=VIRTUALITY_UPDATE_CHANNEL={update_channel()}', 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