Virtuality 0.11.0: мастер настройки, снимки, резервные копии, быстрые машины из готовых образов (#2)

Virtuality 0.11.0: мастер настройки, снимки, резервные копии, быстрые машины из готовых образов
Этот коммит содержится в:
Виктор
2026-09-26 03:12:31 +09:00
коммит произвёл GitHub
родитель 873b32c517 e13887ad76
Коммит 3613c02b7c
126 изменённых файлов: 11069 добавлений и 2454 удалений
+1 -1
Просмотреть файл
@@ -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
+3
Просмотреть файл
@@ -94,3 +94,6 @@ composer.lock
# Offline Python wheels bundled into system images
wheels/
# рабочие копии агентов и локальные настройки Claude Code
.claude/
+60
Просмотреть файл
@@ -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.
+268 -405
Просмотреть файл
@@ -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=<linux_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 <linux_user>
```
Для 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 <iface> [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).
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.10.0
0.11.0
-16
Просмотреть файл
@@ -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" "$@"
+137 -87
Просмотреть файл
@@ -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/<id>.json` (статус, прогресс, сообщение, метаданные) и `<id>.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 <iface> [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.
+127 -93
Просмотреть файл
@@ -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;
- базовый журнал событий.
- **Клонировать** (меню на странице машины) — точная копия с новым именем и своим адресом, удобно для тестов.
- **Спящий режим** — сохранить память на диск и выключить; после запуска машина продолжит с того же места.
- **Автозапуск** — переключатель на странице машины: машина включится сама после перезагрузки сервера.
- **Ресурсы** — процессор и память можно поменять, когда машина выключена; диск — увеличить в любой момент во вкладке **Диски и загрузка**.
- Раздел **Помощь** в панели повторяет главное из этого документа.
+128 -71
Просмотреть файл
@@ -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`.
+46 -64
Просмотреть файл
@@ -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/<user>/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 сертификат выпускается самим сервером, поэтому браузер один раз спросит, доверять ли ему.
+34 -103
Просмотреть файл
@@ -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-серверах через эмуляцию — слишком медленно, чтобы этим пользоваться.
Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 146 KiB

Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 201 KiB

Двоичные данные
Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 174 KiB

После

Ширина:  |  Высота:  |  Размер: 203 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 156 KiB

После

Ширина:  |  Высота:  |  Размер: 194 KiB

Двоичные данные
Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 155 KiB

После

Ширина:  |  Высота:  |  Размер: 194 KiB

Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 231 KiB

Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 193 KiB

Двоичные данные
Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 120 KiB

После

Ширина:  |  Высота:  |  Размер: 156 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 83 KiB

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 100 KiB

+33 -9
Просмотреть файл
@@ -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
+12
Просмотреть файл
@@ -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).
+19
Просмотреть файл
@@ -0,0 +1,19 @@
<!doctype html><html><head><meta charset="utf-8"><style>
html,body{margin:0;width:1024px;height:768px;overflow:hidden;font-family:Inter,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
body{background:radial-gradient(900px 520px at 8% 0%, rgba(123,107,255,.55), transparent 60%), radial-gradient(760px 520px at 100% 100%, rgba(47,115,240,.45), transparent 55%), #0b0a1f;color:#fff;position:relative}
.grid{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.045) 1px,transparent 1px);background-size:48px 48px;-webkit-mask-image:radial-gradient(ellipse at 30% 30%,#000 15%,transparent 72%)}
.brand{position:absolute;left:72px;top:64px;display:flex;align-items:center;gap:18px}
.mark{width:64px;height:64px;border-radius:18px;background:linear-gradient(135deg,#7b6bff 0%,#5a48f5 45%,#2f73f0 100%);box-shadow:0 14px 40px -10px rgba(90,72,245,.9), inset 0 1px 0 rgba(255,255,255,.3);display:grid;place-items:center}
.mark svg{width:38px;height:38px}
.name{font-size:38px;font-weight:700;letter-spacing:-.02em;line-height:1}
.name small{display:block;margin-top:8px;font-size:16px;font-weight:500;color:rgba(255,255,255,.62);letter-spacing:0}
.tag{position:absolute;left:72px;top:176px;font-size:30px;font-weight:600;letter-spacing:-.02em;line-height:1.25;max-width:620px}
.tag span{background:linear-gradient(90deg,#c4bcff,#8fb8ff);-webkit-background-clip:text;background-clip:text;color:transparent}
.foot{position:absolute;left:72px;bottom:56px;color:rgba(255,255,255,.5);font-size:15px}
.hint{position:absolute;right:72px;bottom:56px;color:rgba(255,255,255,.5);font-size:15px;text-align:right}
</style></head><body><div class="grid"></div>
<div class="brand"><div class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M4.5 5.5 12 19l7.5-13.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M9 5.5h6" stroke="#fff" stroke-opacity=".55" stroke-width="2.6" stroke-linecap="round"/></svg></div><div class="name">Virtuality<small>Платформа виртуализации</small></div></div>
<div class="tag">Ваши серверы.<br><span>Под полным контролем.</span></div>
<div class="foot">Установщик · KVM · QEMU · libvirt</div>
<div class="hint">↑ ↓ — выбор пункта, Enter — запуск</div>
</body></html>
Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 151 KiB

Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 75 B

Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 75 B

Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 75 B

Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 75 B

Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 75 B

Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 75 B

Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 75 B

Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 75 B

Двоичные данные
Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 75 B

+41
Просмотреть файл
@@ -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 с"
}
+12 -55
Просмотреть файл
@@ -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
+25 -31
Просмотреть файл
@@ -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
+11 -7
Просмотреть файл
@@ -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"
+10 -7
Просмотреть файл
@@ -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"
-90
Просмотреть файл
@@ -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 "=================================================="
-111
Просмотреть файл
@@ -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 <<EOF
${BOLD}Virtuality clean install${RESET}
Will remove:
- /opt/virtuality/web
- /opt/virtuality/venv
- /etc/systemd/system/virtuality-web.service
- /var/lib/virtuality/config
- /var/lib/virtuality/update
- /var/log/virtuality/update.log and install logs
Will preserve by default:
- /opt/virtuality/source
- /var/lib/virtuality/iso
- /var/lib/virtuality/images
- /var/lib/virtuality/disk-images
- /var/lib/virtuality/backups
- existing libvirt VMs and storage pools
EOF
if [[ "$PURGE_DATA" -eq 1 ]]; then
cat <<EOF
${RED}${BOLD}PURGE DATA MODE ENABLED${RESET}
Will also remove:
- /var/lib/virtuality/iso
- /var/lib/virtuality/images
- /var/lib/virtuality/disk-images
- /var/lib/virtuality/backups
- /var/lib/virtuality/network
Existing libvirt VM definitions are not deleted automatically.
EOF
fi
if [[ "$YES" -ne 1 ]]; then
echo
read -r -p "Continue? Type YES: " answer
if [[ "$answer" != "YES" ]]; then
echo "Cancelled."
exit 0
fi
fi
systemctl stop virtuality-web.service 2>/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"
-71
Просмотреть файл
@@ -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}"
-104
Просмотреть файл
@@ -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 <<EOF
${CYAN}${BOLD}Virtuality Cockpit auth check${RESET}
${GRAY}User:${RESET} ${USER_NAME}
EOF
echo
if ! id "$USER_NAME" >/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
-84
Просмотреть файл
@@ -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" <<EOF
[Unit]
Description=Virtuality console dashboard on tty1
After=multi-user.target network-online.target libvirtd.service cockpit.socket
Wants=network-online.target
Conflicts=getty@tty1.service
[Service]
Type=simple
ExecStart=/usr/local/bin/virtuality-console-dashboard
Restart=always
RestartSec=2
StandardInput=tty
StandardOutput=tty
TTYPath=/dev/tty1
TTYReset=yes
TTYVHangup=yes
TTYVTDisallocate=yes
Environment=TERM=linux
Environment=VIRTUALITY_DASHBOARD_INTERVAL=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl disable --now getty@tty1.service >/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 "============================================================"
+131 -276
Просмотреть файл
@@ -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" <<EOF
# Virtuality node settings. Edit and re-run: sudo bash scripts/install_web_panel.sh
VIRTUALITY_WEB_PORT=${PORT}
VIRTUALITY_WEB_HOST=${WEB_HOST}
VIRTUALITY_AUTH_USER=${AUTH_USER}
VIRTUALITY_AUTO_UPDATE=${AUTO_UPDATE}
VIRTUALITY_COOKIE_SECURE=${COOKIE_SECURE}
EOF
chmod 644 "$NODE_CONFIG_FILE"
ok "Настройки ноды сохранены: $NODE_CONFIG_FILE"
cat > "${APP_DIR}/.env" <<EOF
VIRTUALITY_AUTH_USER=${AUTH_USER}
VIRTUALITY_SESSION_SECRET=${SESSION_SECRET}
VIRTUALITY_COOKIE_SECURE=${COOKIE_SECURE}
VIRTUALITY_COOKIE_SECURE=$(saved_setting VIRTUALITY_COOKIE_SECURE)
TMPDIR=${UPLOAD_TMP_DIR}
TEMP=${UPLOAD_TMP_DIR}
TMP=${UPLOAD_TMP_DIR}
EOF
chmod 600 "${APP_DIR}/.env"
ok "Создан ${APP_DIR}/.env"
ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
step "Создаём Python virtualenv"
if [[ -x "$VENV_DIR/bin/python" ]] && "$VENV_DIR/bin/python" -c 'import sys' >/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" <<EOF
[Unit]
Description=Virtuality Web Panel
After=network-online.target libvirtd.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host ${WEB_HOST} --port ${PORT} --workers 1 --no-server-header
Restart=always
RestartSec=3
TimeoutStopSec=20
LimitNOFILE=65536
User=root
Group=root
Environment=PYTHONUNBUFFERED=1
Environment=VIRTUALITY_AUTH_USER=${AUTH_USER}
Environment=VIRTUALITY_SOURCE_DIR=${REPO_DIR}
Environment=TMPDIR=${UPLOAD_TMP_DIR}
Environment=TEMP=${UPLOAD_TMP_DIR}
Environment=TMP=${UPLOAD_TMP_DIR}
[Install]
WantedBy=multi-user.target
EOF
ok "Создан service: $SERVICE_FILE"
cat > "$NETWORK_SERVICE_FILE" <<EOF
[Unit]
Description=Virtuality NAT port forwarding restore
After=network-online.target libvirtd.service nftables.service ufw.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/restore_network.py
[Install]
WantedBy=multi-user.target
EOF
ok "Создан service: $NETWORK_SERVICE_FILE"
run_logged "systemd daemon-reload выполнен" systemctl daemon-reload
run_logged "virtuality-network.service включён" systemctl enable virtuality-network.service
step "Настраиваем автообновление"
if [[ -f "${REPO_DIR}/scripts/auto_update_check.sh" ]]; then
chmod +x "${REPO_DIR}/scripts/auto_update_check.sh" || true
cat > "$AUTO_UPDATE_SERVICE_FILE" <<EOF
[Unit]
Description=Virtuality automatic GitHub update check
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
Group=root
Environment=VIRTUALITY_SOURCE_DIR=${REPO_DIR}
Environment=TMPDIR=${UPLOAD_TMP_DIR}
ExecStart=/bin/bash ${REPO_DIR}/scripts/auto_update_check.sh
EOF
cat > "$AUTO_UPDATE_TIMER_FILE" <<EOF
[Unit]
Description=Run Virtuality automatic update check daily at 00:00 Moscow time
[Timer]
OnCalendar=*-*-* 21:00:00 UTC
AccuracySec=1min
Persistent=true
Unit=virtuality-auto-update.service
[Install]
WantedBy=timers.target
EOF
run_logged "systemd daemon-reload выполнен для автообновлений" systemctl daemon-reload
if [[ "$AUTO_UPDATE" == "1" ]]; then
run_logged "virtuality-auto-update.timer включён" systemctl enable --now virtuality-auto-update.timer
ok "Автообновление будет проверять GitHub 1 раз в сутки в 00:00 по Москве"
else
systemctl disable --now virtuality-auto-update.timer >> "$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}"
-169
Просмотреть файл
@@ -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" <<EOF
#!/usr/bin/env bash
set -euo pipefail
rm -f "$BRIDGE_FILE"
cp -a "$BACKUP_DIR"/*.yaml "$NETPLAN_DIR"/
chmod 600 "$NETPLAN_DIR"/*.yaml 2>/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" <<EOF
network:
version: 2
ethernets:
${IFACE}:
dhcp4: false
dhcp6: false
EOF
chmod 600 "$file"
fi
done
if [[ "$MODE" == "static" ]]; then
if [[ -z "${CURRENT_IP:-}" || -z "${CURRENT_GW:-}" ]]; then
echo "Ошибка: не удалось определить текущий IP/gateway для static mode."
echo "Откат: sudo $ROLLBACK_SCRIPT"
exit 1
fi
cat > "$BRIDGE_FILE" <<EOF
network:
version: 2
ethernets:
${IFACE}:
dhcp4: false
dhcp6: false
bridges:
${BRIDGE}:
interfaces:
- ${IFACE}
addresses:
- ${CURRENT_IP}
routes:
- to: default
via: ${CURRENT_GW}
nameservers:
addresses:
- ${DNS_1}
- ${DNS_2}
dhcp4: false
dhcp6: false
parameters:
stp: false
forward-delay: 0
EOF
else
cat > "$BRIDGE_FILE" <<EOF
network:
version: 2
ethernets:
${IFACE}:
dhcp4: false
dhcp6: false
bridges:
${BRIDGE}:
interfaces:
- ${IFACE}
dhcp4: true
dhcp6: false
parameters:
stp: false
forward-delay: 0
EOF
fi
chmod 600 "$BRIDGE_FILE"
chmod 600 "$NETPLAN_DIR"/*.yaml 2>/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 "============================================================"
+29
Просмотреть файл
@@ -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
}
+744
Просмотреть файл
@@ -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 <<EOF
Usage: virtuality-ctl <command> [options]
status Что установлено, порты, адреса панели
url Адрес панели управления
reconfigure Пересоздать systemd-юниты из ${NODE_ENV} и перезапустить панель
set KEY=VALUE ... Изменить настройку в web.env (например set VIRTUALITY_TLS=1) и применить
passwd [USER] Сменить пароль пользователя панели (пароль читается со стандартного ввода)
timezone <Area/City> Установить часовой пояс сервера
power reboot|poweroff Перезагрузить или выключить сервер (машины выключаются корректно)
backup-config [FILE] Сохранить настройки ноды и описания машин в архив
restore-config FILE Восстановить настройки ноды из архива
rollback Вернуть предыдущую версию панели после неудачного обновления
maintenance Ежедневное обслуживание: старые задачи, временные файлы, свободное место
bridge <iface> [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" <<EOF
[Unit]
Description=Virtuality Web Panel
After=network-online.target libvirtd.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host ${host} --port ${listen_port} --workers 1 --no-server-header ${ssl_args}
Restart=always
RestartSec=3
TimeoutStopSec=20
LimitNOFILE=65536
User=root
Group=root
Environment=PYTHONUNBUFFERED=1
Environment=LC_ALL=C.UTF-8
Environment=VIRTUALITY_AUTH_USER=${auth_user}
Environment=VIRTUALITY_SOURCE_DIR=${SOURCE_DIR}
Environment=VIRTUALITY_NODE_ENV=${NODE_ENV}
Environment=TMPDIR=${DATA_DIR}/tmp
Environment=TEMP=${DATA_DIR}/tmp
Environment=TMP=${DATA_DIR}/tmp
[Install]
WantedBy=multi-user.target
EOF
if [[ "$tls" == "1" ]]; then
cat > "${UNIT_DIR}/virtuality-redirect.service" <<EOF
[Unit]
Description=Virtuality HTTP to HTTPS redirect
After=network-online.target
[Service]
Type=simple
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/redirect_http.py --port ${port} --target-port ${tls_port}
Restart=always
RestartSec=3
User=root
[Install]
WantedBy=multi-user.target
EOF
else
rm -f "${UNIT_DIR}/virtuality-redirect.service"
fi
cat > "${UNIT_DIR}/virtuality-network.service" <<EOF
[Unit]
Description=Virtuality NAT port forwarding restore
After=network-online.target libvirtd.service nftables.service ufw.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/restore_network.py
[Install]
WantedBy=multi-user.target
EOF
cat > "${UNIT_DIR}/virtuality-auto-update.service" <<EOF
[Unit]
Description=Virtuality automatic update check
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
Environment=VIRTUALITY_SOURCE_DIR=${SOURCE_DIR}
Environment=TMPDIR=${DATA_DIR}/tmp
ExecStart=/bin/bash ${SOURCE_DIR}/scripts/auto_update_check.sh
EOF
cat > "${UNIT_DIR}/virtuality-auto-update.timer" <<EOF
[Unit]
Description=Virtuality nightly update check
[Timer]
OnCalendar=*-*-* 04:00:00
RandomizedDelaySec=30min
AccuracySec=1min
Persistent=true
Unit=virtuality-auto-update.service
[Install]
WantedBy=timers.target
EOF
cat > "${UNIT_DIR}/virtuality-maintenance.service" <<EOF
[Unit]
Description=Virtuality daily maintenance
[Service]
Type=oneshot
User=root
ExecStart=/usr/local/bin/virtuality-ctl maintenance
EOF
cat > "${UNIT_DIR}/virtuality-maintenance.timer" <<EOF
[Unit]
Description=Virtuality daily maintenance
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
Unit=virtuality-maintenance.service
[Install]
WantedBy=timers.target
EOF
mkdir -p /etc/logrotate.d
cat > /etc/logrotate.d/virtuality <<EOF
${LOG_DIR}/*.log {
weekly
rotate 4
compress
delaycompress
missingok
notifempty
copytruncate
}
EOF
# Guests shut down gracefully with the host instead of being killed (Proxmox behaviour).
# Only once libvirt is installed: the file is a dpkg conffile, and creating it earlier (the panel is
# installed first on the image) makes the libvirt package stop at a "keep your version?" question.
if [[ -f /usr/lib/libvirt/libvirt-guests.sh ]]; then
cat > /etc/default/libvirt-guests <<EOF
ON_BOOT=ignore
ON_SHUTDOWN=shutdown
SHUTDOWN_TIMEOUT=180
PARALLEL_SHUTDOWN=5
EOF
fi
systemctl daemon-reload
systemctl enable virtuality-web.service virtuality-network.service virtuality-maintenance.timer >/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 <iface> [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" <<EOF
network:
version: 2
ethernets:
${iface}:
dhcp4: false
dhcp6: false
EOF
fi
done
{
echo "network:"
echo " version: 2"
echo " ethernets:"
echo " ${iface}:"
echo " dhcp4: false"
echo " dhcp6: false"
echo " bridges:"
echo " br0:"
echo " interfaces: [${iface}]"
echo " macaddress: \"${mac}\""
echo " parameters:"
echo " stp: false"
echo " forward-delay: 0"
if [[ "$mode" == "static" ]]; then
[[ -n "$ip" && -n "$gw" ]] || die "не удалось определить текущий адрес и шлюз"
echo " addresses: [${ip}]"
echo " routes:"
echo " - to: default"
echo " via: ${gw}"
echo " nameservers:"
echo " addresses: [${gw}, 1.1.1.1]"
else
echo " dhcp4: true"
echo " dhcp-identifier: mac"
echo " dhcp6: false"
fi
} > "$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
-224
Просмотреть файл
@@ -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<height-1; i++)); do
move $((row+i)) "$col"; printf "${FG_BLUE}│${RESET}"
printf "${BG_PANEL}%s${RESET}" "$(repeat_char ' ' "$inner")"
printf "${FG_BLUE}│${RESET}"
done
move $((row+height-1)) "$col"; printf "${FG_BLUE}╰%s╯${RESET}" "$(repeat_char '─' "$inner")"
}
put() {
local row="$1" col="$2" width="$3" text="$4"
move "$row" "$col"
printf "${BG_PANEL}"
printf "%b" "$text" | cut -c 1-"$width"
printf "${RESET}"
}
bar() {
local percent="$1" width="$2"
local filled=$(( percent * width / 100 ))
local empty=$(( width - filled ))
local color="$FG_GREEN"
if (( percent >= 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
+192 -48
Просмотреть файл
@@ -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" <<EOF
Virtuality web panel: http://\4:${WEB_PORT} (login: ${AUTH_USER})
Cockpit: https://\4:9090
cat > "$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" <<EOF
Virtuality: откройте панель управления в браузере — ${PANEL_URL}
Логин: ${AUTH_USER} (пароль как у пользователя Linux)
EOF
touch "$DONE_FLAG"
STEP_STATUS[finish]="done"
CURRENT_STEP=""
write_state done "Сервер готов"
systemctl disable virtuality-firstboot.service >/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}"
-72
Просмотреть файл
@@ -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 \"Описание изменений\""
+69 -6
Просмотреть файл
@@ -35,20 +35,69 @@ DUMPXML = """<domain type='kvm'>
"""
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 = "<memory snapshot='internal'/>" if state == "running" else "<memory snapshot='no'/>"
return f"<domainsnapshot><name>{snap}</name><description>{description}</description><state>{state}</state>{memory}<creationTime>1758794400</creationTime></domainsnapshot>"
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
+166 -3
Просмотреть файл
@@ -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"<domainsnapshot><name>{snap}</name><description>{description}</description><state>{state}</state><memory snapshot='{memory}'/></domainsnapshot>")
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")
+3
Просмотреть файл
@@ -0,0 +1,3 @@
5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51 *resolute-server-cloudimg-amd64.img
f69162950f235e3cdbbad33f1f912d1a504be90d8a37d002c735d6f3e3882265 *resolute-server-cloudimg-arm64.img
e0361263a9f568c21a59e75c3b7810bc5867866e5bd48f499f8b2e6ab723f059 *resolute-server-cloudimg-amd64.vmdk
+3
Просмотреть файл
@@ -0,0 +1,3 @@
# generated by debian cloud team
1412d62c2d55ae2432df6d22c36b9edf4512a59a43bb4eadb1bd3a5ef201154a3052d83ffae704d7079b50388e61992013348e4bb4dee45b3c2a266e2b296135 debian-13-genericcloud-amd64.qcow2
27c03aab0b5ee1f5918bfaa94821d2a1500fc171ffeb0ae159e7d18662f49171551f397683004434096e4006380fa607577b3c90fafa1eceb3a92702ebd49a15 debian-13-genericcloud-arm64.qcow2
+3
Просмотреть файл
@@ -0,0 +1,3 @@
Capacity: 42949672960
Allocation: 6442450944
Physical: 6443499520
+4
Просмотреть файл
@@ -0,0 +1,4 @@
Type Device Target Source
------------------------------------------------------------
file disk vda /var/lib/virtuality/images/web01.qcow2
file cdrom sda -
+16
Просмотреть файл
@@ -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
+37
Просмотреть файл
@@ -0,0 +1,37 @@
<domain type='kvm' id='1'>
<name>web01</name>
<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>
<title>Сайт компании</title>
<description>Nginx + база. Бэкап по пятницам.</description>
<memory unit='KiB'>2097152</memory>
<currentMemory unit='KiB'>2097152</currentMemory>
<vcpu placement='static'>2</vcpu>
<os>
<type arch='x86_64' machine='pc-q35-10.2'>hvm</type>
<boot dev='hd'/>
</os>
<devices>
<emulator>/usr/bin/qemu-system-x86_64</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='/var/lib/virtuality/images/web01.qcow2'/>
<target dev='vda' bus='virtio'/>
</disk>
<disk type='file' device='cdrom'>
<driver name='qemu' type='raw'/>
<target dev='sda' bus='sata'/>
<readonly/>
</disk>
<interface type='network'>
<mac address='52:54:00:aa:bb:cc'/>
<source network='virtuality-nat'/>
<model type='virtio'/>
</interface>
<channel type='unix'>
<target type='virtio' name='org.qemu.guest_agent.0'/>
</channel>
<graphics type='vnc' port='5900' autoport='yes' listen='127.0.0.1'>
<listen type='address' address='127.0.0.1'/>
</graphics>
</devices>
</domain>
+6
Просмотреть файл
@@ -0,0 +1,6 @@
Id Name State Title
--------------------------------------------
1 web01 running Сайт компании
- db01 shut off
- win11 shut off Бухгалтерия 1С
+17
Просмотреть файл
@@ -0,0 +1,17 @@
<network connections='2'>
<name>virtuality-nat</name>
<uuid>6c1b3c1e-1111-2222-3333-444444444444</uuid>
<forward mode='nat'>
<nat>
<port start='1024' end='65535'/>
</nat>
</forward>
<bridge name='virbr100' stp='on' delay='0'/>
<mac address='52:54:00:12:34:56'/>
<ip address='192.168.100.1' netmask='255.255.255.0'>
<dhcp>
<range start='192.168.100.50' end='192.168.100.200'/>
<host mac='52:54:00:aa:bb:cc' name='web01' ip='192.168.100.51'/>
</dhcp>
</ip>
</network>
+1
Просмотреть файл
@@ -0,0 +1 @@
2ae30006299896d225c9d4de36e9a7c3d047c9b4a5793efe3a6422ec5040e8ee5f7d71326aa9922ffa446fbd11ed6cd1a8d0af4659b3d00233d3fea3550d2d78
+10
Просмотреть файл
@@ -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
}
+9
Просмотреть файл
@@ -0,0 +1,9 @@
Name: clean
Domain: web01
Current: yes
State: running
Location: internal
Parent: -
Children: 1
Descendants: 1
Metadata: yes
+4
Просмотреть файл
@@ -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
+10
Просмотреть файл
@@ -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();
+1 -1
Просмотреть файл
@@ -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"
+203
Просмотреть файл
@@ -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(
"<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk>",
"<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk><disk type='file' device='cdrom'><source file='/var/lib/virtuality/iso/old.iso'/><target dev='sda' bus='sata'/></disk>",
)
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("<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk>", "<disk type='file' device='disk'><source file='/var/lib/virtuality/images/web.iso.qcow2'/><target dev='vda' bus='virtio'/></disk><disk type='file' device='cdrom'><target dev='sda' bus='sata'/></disk>")
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"] == "Поддержка виртуализации процессором"]
+645
Просмотреть файл
@@ -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 "<name>web02</name>" in xml
assert "<uuid>" not in xml and "id=" not in xml.split(">", 1)[0]
assert "<mac" not in xml
assert "file=\"/var/lib/virtuality/images/web02.qcow2\"" in xml
assert "Сайт компании" in xml # заголовок и описание сохраняются
assert "<readonly />" in xml or "<readonly/>" 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"""<domain type='kvm' id='7'><name>web01</name><uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>
<devices>
<disk type='file' device='disk'><driver name='qemu' type='qcow2'/><source file='/var/lib/virtuality/images/web01.snap.qcow2'/>
<backingStore type='file'><format type='qcow2'/><source file='/var/lib/virtuality/images/web01.qcow2'/></backingStore>
<target dev='vda' bus='virtio'/></disk>
<disk type='file' device='cdrom'><driver name='qemu' type='raw'/><source file='/var/lib/virtuality/iso/gone.iso'/><target dev='sda' bus='sata'/><readonly/></disk>
<disk type='file' device='cdrom'><driver name='qemu' type='raw'/><source file='{present}'/><target dev='sdb' bus='sata'/><readonly/></disk>
<interface type='network'><mac address='52:54:00:aa:bb:cc'/><source network='virtuality-nat'/></interface>
</devices></domain>"""
replaced = backups.rewrite_xml(source, "web01", {"vda": "/var/lib/virtuality/images/web01.qcow2"}, keep_identity=True)
assert "<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>" 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 "<uuid>" not in fresh and "<mac" not in fresh and "backingStore" not in fresh
def test_rewrite_xml_converts_block_disk_and_drops_nvram():
source = """<domain type='kvm'><name>old</name><os><type>hvm</type><loader type='pflash'>/usr/share/OVMF/OVMF_CODE.fd</loader><nvram>/var/lib/libvirt/qemu/nvram/old_VARS.fd</nvram></os>
<devices><disk type='block' device='disk'><driver name='qemu' type='raw'/><source dev='/dev/vg/old'/><target dev='vda' bus='virtio'/></disk></devices></domain>"""
xml = backups.rewrite_xml(source, "new", {"vda": "/var/lib/virtuality/images/new.qcow2"})
assert "<nvram>" 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 "<name>web02</name>" in virsh.defined_xml and str(target) in virsh.defined_xml and "<mac" not in virsh.defined_xml
assert not any(cmd[:2] == ["virsh", "undefine"] for cmd in virsh.calls)
assert not list((fast_worker["backups"] / "web01" / "20260924-0300").glob("restore-*.xml"))
def test_restore_requires_replace_confirmation(fast_worker):
make_backup(fast_worker)
with pytest.raises(backups.BackupError) as exc:
backups.start_restore("web01", "20260924-0300", "web01", False)
assert exc.value.code == "exists"
UNDEFINE = ["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"]
def test_restore_replaces_existing_vm_only_after_disks_are_ready(fast_worker, monkeypatch):
make_backup(fast_worker)
virsh = FakeVirsh(running=True)
monkeypatch.setattr(core, "run_cmd", virsh)
monkeypatch.setattr(backups, "stream_cmd", fake_stream(calls=virsh.calls))
operation = backups.start_restore("web01", "20260924-0300", "", True)
fresh = core.read_operation(operation["id"])
assert fresh["status"] == "success", fresh["message"]
convert = next(index for index, cmd in enumerate(virsh.calls) if cmd[:2] == ["qemu-img", "convert"])
assert convert < virsh.calls.index(["virsh", "destroy", "web01"]) < virsh.calls.index(UNDEFINE)
assert virsh.calls.index(UNDEFINE) < next(index for index, cmd in enumerate(virsh.calls) if cmd[:2] == ["virsh", "define"])
assert (fast_worker["images"] / "web01.qcow2").exists() and not list(fast_worker["images"].glob("*.restore-*"))
assert "<name>web01</name>" in virsh.defined_xml
# при замене машина остаётся «той же»: UUID и MAC из копии сохраняются
assert "<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>" 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"]
+38
Просмотреть файл
@@ -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"]
+638
Просмотреть файл
@@ -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 "Скачиваем</span>" 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
+152
Просмотреть файл
@@ -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")
+88
Просмотреть файл
@@ -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
+101
Просмотреть файл
@@ -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()
+286
Просмотреть файл
@@ -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("<broken") == {}
def test_inspect_domain_xml_qcow2_fixture():
info = snapshots.inspect_domain_xml((FIXTURES / "dumpxml-web01.xml").read_text())
assert info["disks"] == [{"target": "vda", "format": "qcow2"}] # cdrom пропущен
assert info["unsupported"] == [] and info["uefi"] is False
def test_inspect_domain_xml_raw_and_uefi():
xml = """<domain type='kvm'><name>x</name>
<os firmware='efi'><type arch='x86_64'>hvm</type><loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF_CODE.fd</loader><nvram>/var/lib/libvirt/qemu/nvram/x.fd</nvram></os>
<devices>
<disk type='file' device='disk'><driver name='qemu' type='raw'/><source file='/x.img'/><target dev='vda' bus='virtio'/></disk>
<disk type='file' device='disk'><driver name='qemu' type='qcow2'/><source file='/y.qcow2'/><target dev='vdb' bus='virtio'/></disk>
</devices></domain>"""
info = snapshots.inspect_domain_xml(xml)
assert info["unsupported"] == [{"target": "vda", "format": "raw"}]
assert info["uefi"] is True
assert snapshots.inspect_domain_xml("<nope") == {"disks": [], "unsupported": [], "uefi": False}
def test_format_when():
now = datetime(2026, 9, 25, 12, 0, 0)
assert snapshots.format_when("2026-09-25 10:12:03", now) == "Сегодня, 10:12"
assert snapshots.format_when("2026-09-24 09:00:41", now) == "Вчера, 09:00"
assert snapshots.format_when("2026-03-08 18:30:00", now) == "8 марта 2026, 18:30"
assert snapshots.format_when("garbage", now) == "garbage"
def test_explain_error_translates_known_libvirt_messages():
text = snapshots.explain_error("error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
assert text.startswith("Для машин с UEFI-прошивкой") and "libvirt:" in text
assert snapshots.explain_error("error: internal snapshot for disk vda unsupported for storage type raw").startswith("Внутренние снимки работают только с дисками qcow2")
assert snapshots.explain_error("error: Timed out during operation: cannot acquire state change lock").startswith("Машина занята")
assert snapshots.explain_error("error: something odd\nsecond line") == "something odd"
assert "не объяснил" in snapshots.explain_error("")
def test_next_snapshot_name_and_description():
now = datetime(2026, 9, 25, 10, 12)
assert snapshots.next_snapshot_name([], now) == "snap-20260925-1012"
assert snapshots.next_snapshot_name(["snap-20260925-1012"], now) == "snap-20260925-1012-2"
assert snapshots.next_snapshot_name(["snap-20260925-1012", "snap-20260925-1012-2"], now) == "snap-20260925-1012-3"
assert core.valid_label(snapshots.next_snapshot_name([], now))
assert snapshots.clean_description(" --force\x00 перед\nобновлением ") == "force перед обновлением"
assert len(snapshots.clean_description("x" * 500)) == snapshots.DESCRIPTION_MAX
def test_snapshot_state_labels():
assert snapshots.snapshot_state("running") == {"label": "Работала", "tone": "success"}
assert snapshots.snapshot_state("shutoff")["label"] == "Была выключена"
assert snapshots.snapshot_state("weird") == {"label": "weird", "tone": "neutral"}
# ---------------------------------------------------------------- helpers for routes
@pytest.fixture()
def recorder(monkeypatch):
"""run_cmd, который записывает команды virsh и выполняет операции сразу (без потоков)."""
calls: list[list[str]] = []
def run(cmd, timeout=12, **kwargs):
calls.append(list(cmd))
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
monkeypatch.setattr(snapshots, "run_operation", lambda operation, worker: worker(operation))
return calls
def find(calls, *prefix):
return [cmd for cmd in calls if cmd[: len(prefix)] == list(prefix)]
# ---------------------------------------------------------------- routes
def test_snapshots_page_requires_login(client):
response = client.get("/vm/web01/snapshots", follow_redirects=False)
assert response.status_code == 303 and response.headers["location"] == "/login"
def test_snapshots_page_renders_list(logged_in):
html = logged_in.get("/vm/web01/snapshots").text
assert "Перед обновлением ядра" in html and "Чистая система после установки" in html
assert "Текущий" in html and "Была выключена" in html and "Работала" in html
assert 'action="/vm/web01/snapshots/before-upd/revert"' in html
assert 'action="/vm/web01/snapshots/clean/delete"' in html
assert "2 из 8" in html
assert "snap-" in html # предложенное имя
assert "shut off" not in html
def test_snapshots_page_unknown_vm_redirects_home(logged_in):
response = logged_in.get("/vm/ghost/snapshots", follow_redirects=False)
assert response.status_code == 303 and response.headers["location"] == "/"
assert logged_in.get("/vm/-bad/snapshots", follow_redirects=False).status_code == 400
def test_vm_page_links_to_snapshots_tab(logged_in):
assert 'href="/vm/web01/snapshots"' in logged_in.get("/vm/web01").text
def test_create_snapshot_runs_virsh_and_reports_operation(logged_in, data_dirs, recorder):
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "Перед экспериментом"}, follow_redirects=False)
assert response.status_code == 303
location = response.headers["location"]
assert location.startswith("/vm/web01/snapshots?op=")
cmd = find(recorder, "virsh", "snapshot-create-as")[0]
assert cmd[2:6] == ["--domain", "web01", "--name", cmd[5]] and cmd[5].startswith("snap-")
assert cmd[6:] == ["--description", "Перед экспериментом", "--atomic"]
operation = core.read_operation(location.split("op=", 1)[1])
assert operation["status"] == "success" and operation["type"] == "snapshot_create" and operation["vm_name"] == "web01"
assert "создан" in operation["message"]
html = logged_in.get(location).text
assert "создан" in html
def test_create_snapshot_without_description_omits_option(logged_in, data_dirs, recorder):
logged_in.post("/vm/web01/snapshots/create", data={"description": " "}, follow_redirects=False)
cmd = find(recorder, "virsh", "snapshot-create-as")[0]
assert "--description" not in cmd and cmd[-1] == "--atomic"
def test_create_snapshot_limit(logged_in, data_dirs, recorder, monkeypatch):
rows = "\n".join(f" snap-{i:02d} 2026-09-{i + 1:02d} 10:00:00 +0000 shutoff" for i in range(8))
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "snapshot-list"]:
return conftest.fake_result(" Name Creation Time State\n---\n" + rows)
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 response.status_code == 303
assert "максимум" in unquote(response.headers["location"])
assert not find(recorder, "virsh", "snapshot-create-as")
html = logged_in.get("/vm/web01/snapshots").text
assert "8 из 8" in html and "максимум" in html
def test_create_snapshot_rejects_raw_disk(logged_in, data_dirs, recorder, monkeypatch):
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "dumpxml"]:
return conftest.fake_result("<domain><devices><disk device='disk'><driver type='raw'/><target dev='vda'/></disk></devices></domain>")
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"
+437
Просмотреть файл
@@ -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=Диск+увеличен&notes_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"
+26
Просмотреть файл
@@ -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",
+239 -157
Просмотреть файл
@@ -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)
+91
Просмотреть файл
@@ -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
+108
Просмотреть файл
@@ -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)
+363
Просмотреть файл
@@ -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()
Просмотреть файл
+867
Просмотреть файл
@@ -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.
Меняет <name>, убирает id, переключает диски на новые файлы (без
<backingStore> — копии сплошные, а старая цепочка могла указывать на диски
удалённой машины), вынимает из привода образ, которого больше нет на
сервере, и удаляет <nvram> (libvirt заведёт свой файл под новое имя).
Без keep_identity убирает и <uuid> с <mac>, чтобы вторая машина не мешала
оригиналу; при замене оригинала (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-<id задачи>-<диск>.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)
+805
Просмотреть файл
@@ -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:
"""Хеш файла из файла контрольных сумм.
Понимает «<hash> *name» и «<hash> name» (Ubuntu, Debian), BSD-стиль
«SHA256 (name) = <hash>» и файл из одного хеша (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()
+166
Просмотреть файл
@@ -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)
+287
Просмотреть файл
@@ -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)
+361
Просмотреть файл
@@ -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}» удалён")
+599
Просмотреть файл
@@ -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)
+2 -1
Просмотреть файл
@@ -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.'})
+121 -23
Просмотреть файл
@@ -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"<host\s+mac='([^']+)'\s+name='([^']+)'\s+ip='([^']+)'\s*/>", 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"<host mac='{mac}' name='{vm_name}' ip='{item['ip']}'/>")
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"<host mac='{mac}' name='{vm_name}' ip='{free}'/>")
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"<host mac='{item['mac']}' name='{item['name']}' ip='{item['ip']}'/>")
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')
+318
Просмотреть файл
@@ -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"]
+34 -2
Просмотреть файл
@@ -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
+39
Просмотреть файл
@@ -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()
+83 -7
Просмотреть файл
@@ -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; }
}
+4
Просмотреть файл
@@ -57,4 +57,8 @@
<symbol id="i-wrench" viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
<symbol id="i-circle-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1" /><circle cx="12" cy="12" r="10" /></symbol>
<symbol id="i-plug" viewBox="0 0 24 24"><path d="M12 22v-5" /><path d="M15 8V2" /><path d="M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z" /><path d="M9 8V2" /></symbol>
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z" /><circle cx="12" cy="13" r="3" /></symbol>
<symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" /></symbol>
<symbol id="i-notebook-pen" viewBox="0 0 24 24"><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4" /><path d="M2 6h4" /><path d="M2 10h4" /><path d="M2 14h4" /><path d="M2 18h4" /><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" /></symbol>
<symbol id="i-expand" viewBox="0 0 24 24"><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path d="M21 8V3h-5" /><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path d="M9 9 3 3" /></symbol>
</svg>

До

Ширина:  |  Высота:  |  Размер: 10 KiB

После

Ширина:  |  Высота:  |  Размер: 12 KiB

+93 -3
Просмотреть файл
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
const icon = (name) => `<svg class="icon" aria-hidden="true"><use href="/static/icons.svg#i-${name}"></use></svg>`;
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' ? '<span class="spinner" style="width:18px;height:18px;margin-top:2px"></span>' : icon('circle-dot');
const detail = item.status === 'running' && state.message ? `<span>${escapeHtml(state.message)}</span>` : '';
return `<div class="check-item ${tone}">${mark}<div><strong>${escapeHtml(item.title)}</strong>${detail}</div></div>`;
}).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);
});
})();
+18
Просмотреть файл
@@ -0,0 +1,18 @@
{% from "_ui.html" import icon, badge %}
{#- Строки списка копий. Ожидает: rows (список копий), back (куда вернуться после удаления). -#}
{% for b in rows %}
<div class="list-item">
<span class="list-icon {{ 'brand' if b.complete else 'danger' }}">{{ icon('database' if b.complete else 'triangle-alert') }}</span>
<div class="grow">
<div class="title">{{ b.created_label }}{% if b.note %} <span class="tag" style="margin-left:6px">{{ b.note }}</span>{% endif %}</div>
<div class="meta">{{ b.size }} · {{ b.disk_count }} {{ plural(b.disk_count, 'диск', 'диска', 'дисков') }}{% if b.version %} · Virtuality {{ b.version }}{% endif %}{% if not b.complete %} · <span style="color:var(--danger)">не хватает файлов</span>{% endif %}</div>
</div>
{% if b.complete %}
<a class="btn btn-sm" href="/backups/{{ b.vm }}/{{ b.id }}/restore">{{ icon('rotate-cw', 'icon-sm') }}<span class="hide-sm">Восстановить</span></a>
{% endif %}
<form method="post" action="/backups/{{ b.vm }}/{{ b.id }}/delete" class="inline-form" data-confirm="Копия машины {{ b.vm }} от {{ b.created_label }} ({{ b.size }}) будет удалена. Сама машина не пострадает." data-confirm-title="Удалить копию?" data-confirm-button="Удалить">
<input type="hidden" name="next" value="{{ back }}">
<button class="btn btn-sm btn-icon btn-ghost" aria-label="Удалить копию" title="Удалить копию">{{ icon('trash-2', 'icon-sm') }}</button>
</form>
</div>
{% endfor %}
+79
Просмотреть файл
@@ -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 %}
<article class="card downloads" data-downloads>
<div class="card-header">
<div><h2>Скачиваем на сервер</h2><p>Страницу можно закрыть — загрузка продолжится. Когда файл будет готов, он появится в списке.</p></div>
<a class="btn btn-sm btn-ghost hide-sm" href="/operations">{{ icon('list-checks', 'icon-sm') }}Все задачи</a>
</div>
<div class="list">
{% for op in dl.active %}
<div class="list-item download-item" data-download-op="{{ op.id }}" data-status="{{ op.status }}">
<span class="list-icon info">{{ icon('download') }}</span>
<div class="grow">
<div class="row-between">
<div class="title truncate" title="{{ op.url }}">{{ op.filename }}</div>
<strong class="download-pct" data-dl="pct">{{ op.progress|default(0) }}%</strong>
</div>
<div class="progress mt-1{% if op.indeterminate %} indeterminate{% endif %}" data-dl="progress"><span data-dl="bar" style="width:{{ op.progress|default(0) }}%"></span></div>
<div class="meta mt-1" data-dl="message">{{ op.message }}</div>
</div>
<a class="btn btn-sm btn-ghost hide-sm" href="/operations/{{ op.id }}">Подробнее</a>
{#- Без data-toast: сервер лишь просит поток остановиться, а итог («Скачивание отменено») покажет опрос ниже. -#}
<form method="post" action="/downloads/{{ op.id }}/cancel" class="inline-form" data-dl="cancel" data-confirm="Скачивание {{ op.filename }} остановится, а недокачанный файл будет удалён." data-confirm-title="Отменить скачивание?" data-confirm-button="Остановить">
<button class="btn btn-sm btn-danger-soft" {% if op.cancel_requested %}disabled{% endif %}>{{ icon('x', 'icon-sm') }}Отменить</button>
</form>
</div>
{% endfor %}
</div>
</article>
<script>
(function () {
var items = document.querySelectorAll('[data-download-op]');
if (!items.length) return;
items.forEach(function (item) {
var id = item.dataset.downloadOp;
var bar = item.querySelector('[data-dl="bar"]');
var pct = item.querySelector('[data-dl="pct"]');
var message = item.querySelector('[data-dl="message"]');
var progress = item.querySelector('[data-dl="progress"]');
var cancel = item.querySelector('[data-dl="cancel"]');
var timer = setInterval(async function () {
var op;
try {
var response = await fetch('/api/operations/' + encodeURIComponent(id), { cache: 'no-store', headers: { Accept: 'application/json' } });
if (!response.ok) return;
op = (await response.json()).operation || {};
} catch (_) { return; }
var value = Math.max(0, Math.min(100, Number(op.progress) || 0));
bar.style.width = value + '%';
pct.textContent = value + '%';
message.textContent = op.message || '';
if (op.total_bytes) progress.classList.remove('indeterminate');
if (op.cancel_requested) cancel.querySelector('button').disabled = true;
if (op.status !== 'success' && op.status !== 'error') return;
clearInterval(timer);
progress.classList.remove('indeterminate');
cancel.hidden = true;
if (op.status === 'success') {
progress.classList.add('success');
item.dataset.status = op.status;
try { sessionStorage.setItem('virtualityToast', JSON.stringify({ message: op.message || 'Файл скачан', tone: 'success' })); } catch (_) {}
setTimeout(function () { location.replace(location.pathname); }, 900);
} else if (op.cancelled) {
// Отмена по просьбе пользователя — не ошибка: предупреждение, без красной строки и красного процента.
item.dataset.status = 'cancelled';
if (window.vToast) window.vToast(op.message || 'Скачивание отменено', 'warning');
} else {
progress.classList.add('danger');
item.dataset.status = op.status;
message.classList.add('download-error');
if (window.vToast) window.vToast(op.message || 'Скачивание не удалось', 'danger');
}
}, 2000);
});
})();
</script>
{% endif %}
+36
Просмотреть файл
@@ -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') %}
<article class="card">
<div class="card-header">
<div><h2>Каталог готовых систем</h2><p>Официальные образы, подходящие этому серверу. Скачаем на сервер и проверим контрольную сумму — останется только создать машину.</p></div>
</div>
{% if dl.catalog %}
<div class="list">
{% for item in dl.catalog %}
<div class="list-item catalog-item">
<span class="list-icon {{ 'success' if item.status == 'ready' else 'info' if item.status == 'downloading' else 'brand' }}">{{ icon('circle-check' if item.status == 'ready' else 'download' if item.status == 'downloading' else 'box') }}</span>
<div class="grow">
<div class="row" style="gap:8px">
<span class="title">{{ item.title }}</span>
{% if item.recommended %}{{ badge('Рекомендуем', 'brand', dot=False) }}{% endif %}
{% if item.status == 'ready' %}{{ badge('Загружен', 'success') }}{% elif item.status == 'downloading' %}{{ badge('Скачивается', 'info') }}{% endif %}
</div>
<div class="meta">{{ item.hint }}{% if item.login %} · вход: <code>{{ item.login }}</code>{% endif %}</div>
</div>
{% if item.status == 'ready' %}
<a class="btn btn-sm btn-primary" href="/vm/create?image={{ item.ready_file|urlencode }}">{{ icon('plus', 'icon-sm') }}Создать машину</a>
{% elif item.status == 'downloading' %}
<a class="btn btn-sm btn-ghost" href="/operations/{{ item.operation_id }}">Прогресс{{ icon('chevron-right', 'icon-sm') }}</a>
{% else %}
<form method="post" action="/downloads/catalog/{{ item.id }}" class="inline-form" data-busy="Запускаем…">
<button type="submit" class="btn btn-sm">{{ icon('download', 'icon-sm') }}Скачать</button>
</form>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="card-body"><p class="muted">Подходящих этому серверу готовых образов в каталоге нет. Можно скачать образ по ссылке или загрузить с компьютера.</p></div>
{% endif %}
</article>
+4
Просмотреть файл
@@ -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 %}

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше