Virtuality 0.11.0: мастер настройки, снимки, резервные копии, быстрые машины из готовых образов (#2)
Virtuality 0.11.0: мастер настройки, снимки, резервные копии, быстрые машины из готовых образов
@@ -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
|
||||
|
||||
@@ -94,3 +94,6 @@ composer.lock
|
||||
|
||||
# Offline Python wheels bundled into system images
|
||||
wheels/
|
||||
|
||||
# рабочие копии агентов и локальные настройки Claude Code
|
||||
.claude/
|
||||
|
||||
@@ -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.
|
||||
@@ -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 для панели, автообновления со стабильным каналом.
|
||||
|
||||

|
||||
|
||||
@@ -43,41 +22,84 @@ generic-arm64 # другая ARM64-плата
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
---
|
||||
| Мастер настройки: установка | Мастер настройки: сеть машин |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
## Установка с ISO-образа
|
||||
| Каталог готовых систем | Настройки |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
Для установки на «голое» железо собирается загрузочный ISO на базе чистого Ubuntu Server 26.04 LTS: установщик спрашивает только сеть, диск и пользователя, а при первой загрузке нода настраивается сама.
|
||||
| Снимки | Резервные копии |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
```bash
|
||||
make iso # dist/virtuality-<версия>-ubuntu-26.04-amd64.iso
|
||||
make iso ARCH=arm64
|
||||
```
|
||||
|
||||
Подробно: [`docs/IMAGE.md`](docs/IMAGE.md).
|
||||
| Вход | На телефоне |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
## Быстрая установка одной командой
|
||||
## Содержание
|
||||
|
||||
Одна команда работает и под `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 @@
|
||||
0.10.0
|
||||
0.11.0
|
||||
|
||||
@@ -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" "$@"
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
- базовый журнал событий.
|
||||
- **Клонировать** (меню на странице машины) — точная копия с новым именем и своим адресом, удобно для тестов.
|
||||
- **Спящий режим** — сохранить память на диск и выключить; после запуска машина продолжит с того же места.
|
||||
- **Автозапуск** — переключатель на странице машины: машина включится сама после перезагрузки сервера.
|
||||
- **Ресурсы** — процессор и память можно поменять, когда машина выключена; диск — увеличить в любой момент во вкладке **Диски и загрузка**.
|
||||
- Раздел **Помощь** в панели повторяет главное из этого документа.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 сертификат выпускается самим сервером, поэтому браузер один раз спросит, доверять ли ему.
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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 |
@@ -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 с"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "=================================================="
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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 "============================================================"
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 "============================================================"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 \"Описание изменений\""
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51 *resolute-server-cloudimg-amd64.img
|
||||
f69162950f235e3cdbbad33f1f912d1a504be90d8a37d002c735d6f3e3882265 *resolute-server-cloudimg-arm64.img
|
||||
e0361263a9f568c21a59e75c3b7810bc5867866e5bd48f499f8b2e6ab723f059 *resolute-server-cloudimg-amd64.vmdk
|
||||
@@ -0,0 +1,3 @@
|
||||
# generated by debian cloud team
|
||||
1412d62c2d55ae2432df6d22c36b9edf4512a59a43bb4eadb1bd3a5ef201154a3052d83ffae704d7079b50388e61992013348e4bb4dee45b3c2a266e2b296135 debian-13-genericcloud-amd64.qcow2
|
||||
27c03aab0b5ee1f5918bfaa94821d2a1500fc171ffeb0ae159e7d18662f49171551f397683004434096e4006380fa607577b3c90fafa1eceb3a92702ebd49a15 debian-13-genericcloud-arm64.qcow2
|
||||
@@ -0,0 +1,3 @@
|
||||
Capacity: 42949672960
|
||||
Allocation: 6442450944
|
||||
Physical: 6443499520
|
||||
@@ -0,0 +1,4 @@
|
||||
Type Device Target Source
|
||||
------------------------------------------------------------
|
||||
file disk vda /var/lib/virtuality/images/web01.qcow2
|
||||
file cdrom sda -
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
Id Name State Title
|
||||
--------------------------------------------
|
||||
1 web01 running Сайт компании
|
||||
- db01 shut off
|
||||
- win11 shut off Бухгалтерия 1С
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
2ae30006299896d225c9d4de36e9a7c3d047c9b4a5793efe3a6422ec5040e8ee5f7d71326aa9922ffa446fbd11ed6cd1a8d0af4659b3d00233d3fea3550d2d78
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
Name: clean
|
||||
Domain: web01
|
||||
Current: yes
|
||||
State: running
|
||||
Location: internal
|
||||
Parent: -
|
||||
Children: 1
|
||||
Descendants: 1
|
||||
Metadata: yes
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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"] == "Поддержка виртуализации процессором"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -0,0 +1,437 @@
|
||||
"""vmops: увеличение диска, клонирование, пауза и спящий режим, заметки, живая нагрузка."""
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
||||
import conftest
|
||||
import core
|
||||
import presenters
|
||||
from features import vmops
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
DOMSTATS = (FIXTURES / "domstats.txt").read_text()
|
||||
GIB = 1024 ** 3
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""Записывает команды; отвечает по overrides {(префикс,): результат|функция}, иначе как fake_run_cmd."""
|
||||
|
||||
def __init__(self, overrides=None):
|
||||
self.calls: list[list[str]] = []
|
||||
self.overrides = overrides or {}
|
||||
|
||||
def __call__(self, cmd, timeout=12, **kwargs):
|
||||
self.calls.append(list(cmd))
|
||||
for prefix, result in self.overrides.items():
|
||||
if tuple(cmd[: len(prefix)]) == prefix:
|
||||
return result(cmd) if callable(result) else result
|
||||
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
|
||||
|
||||
def find(self, *prefix):
|
||||
return [call for call in self.calls if tuple(call[: len(prefix)]) == prefix]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def record(data_dirs, monkeypatch):
|
||||
"""Подменяет run_cmd и в core (vmops), и в app (обзор, страница машины)."""
|
||||
import app
|
||||
|
||||
recorder = Recorder()
|
||||
for module in (core, app):
|
||||
monkeypatch.setattr(module, "run_cmd", recorder)
|
||||
monkeypatch.setattr(vmops, "_samples", {})
|
||||
return recorder
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def inline_operations(monkeypatch):
|
||||
"""Фоновые операции выполняются сразу, в этом же потоке."""
|
||||
|
||||
def run_now(operation, worker):
|
||||
core.update_operation(operation, status="running")
|
||||
worker(operation)
|
||||
|
||||
monkeypatch.setattr(vmops, "run_operation", run_now)
|
||||
|
||||
|
||||
def shut_off(record):
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("shut off")
|
||||
|
||||
|
||||
def location(response) -> str:
|
||||
assert response.status_code == 303, response.text[:300]
|
||||
return unquote(response.headers["location"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- parsers
|
||||
def test_parse_domstats_fixture():
|
||||
stats = vmops.parse_domstats(DOMSTATS)
|
||||
assert list(stats) == ["web01"]
|
||||
assert stats["web01"]["cpu.time"] == "48123456789"
|
||||
assert stats["web01"]["balloon.rss"] == "1534208"
|
||||
assert stats["web01"]["vcpu.current"] == "2"
|
||||
|
||||
|
||||
def test_parse_domstats_several_domains():
|
||||
text = DOMSTATS + "Domain: 'db01'\n\nDomain: 'win 11'\n cpu.time=5\n vcpu.current=4\n"
|
||||
stats = vmops.parse_domstats(text)
|
||||
assert set(stats) == {"web01", "db01", "win 11"}
|
||||
assert stats["db01"] == {}
|
||||
assert stats["win 11"] == {"cpu.time": "5", "vcpu.current": "4"}
|
||||
|
||||
|
||||
def test_compute_stats_cpu_percent_from_two_samples():
|
||||
samples: dict = {}
|
||||
first = vmops.compute_stats(vmops.parse_domstats(DOMSTATS), 100.0, samples)
|
||||
assert first["web01"]["cpu_pct"] is None # первый замер — сравнивать не с чем
|
||||
assert first["web01"]["mem_used_mb"] == 1498 and first["web01"]["mem_total_mb"] == 2048
|
||||
later = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=49123456789") # +1 с процессорного времени
|
||||
second = vmops.compute_stats(vmops.parse_domstats(later), 101.0, samples)
|
||||
assert second["web01"]["cpu_pct"] == 50.0 # 1 с на 2 ядра за 1 с стены
|
||||
burst = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=99123456789")
|
||||
third = vmops.compute_stats(vmops.parse_domstats(burst), 101.5, samples)
|
||||
assert third["web01"]["cpu_pct"] == 100.0 # не больше 100 %
|
||||
|
||||
|
||||
def test_compute_stats_skips_shut_off_and_forgets_stale():
|
||||
samples = {"old": (1.0, 5)}
|
||||
stats = vmops.compute_stats({"db01": {}, "web01": {"cpu.time": "10", "balloon.current": "1024", "vcpu.current": "1"}}, 2.0, samples)
|
||||
assert set(stats) == {"web01"}
|
||||
assert stats["web01"]["mem_used_mb"] == 1 and stats["web01"]["mem_total_mb"] == 1
|
||||
assert set(samples) == {"web01"}
|
||||
|
||||
|
||||
def test_parse_qemu_img_info():
|
||||
info = vmops.parse_qemu_img_info((FIXTURES / "qemu-img-info.json").read_text())
|
||||
assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"}
|
||||
assert vmops.parse_qemu_img_info("not json") == {}
|
||||
assert vmops.parse_qemu_img_info("[1, 2]") == {}
|
||||
|
||||
|
||||
def test_parse_virsh_list_titles():
|
||||
titles = presenters.parse_virsh_list_titles((FIXTURES / "list-title.txt").read_text())
|
||||
assert titles == {"web01": "Сайт компании", "win11": "Бухгалтерия 1С"}
|
||||
assert presenters.parse_virsh_list_titles(" Id Name State\n---\n 1 web01 running") == {}
|
||||
assert presenters.parse_virsh_list_titles("") == {}
|
||||
|
||||
|
||||
def test_parse_desc_output():
|
||||
assert vmops.parse_desc_output(conftest.fake_result("Сайт компании\n")) == "Сайт компании"
|
||||
assert vmops.parse_desc_output(conftest.fake_result("No title for domain: web01")) == ""
|
||||
assert vmops.parse_desc_output(conftest.fake_result(ok=False, stderr="error: failed to get domain")) == ""
|
||||
|
||||
|
||||
def test_has_managed_save():
|
||||
assert vmops.has_managed_save(conftest.DOMINFO + "Managed save: yes\n")
|
||||
assert not vmops.has_managed_save(conftest.DOMINFO + "Managed save: no\n")
|
||||
assert not vmops.has_managed_save(conftest.DOMINFO)
|
||||
|
||||
|
||||
def test_clean_notes():
|
||||
assert vmops.clean_title(" Сайт \n компании ") == "Сайт компании"
|
||||
assert len(vmops.clean_title("x" * 200)) == vmops.TITLE_MAX
|
||||
assert vmops.clean_description("строка 1\r\nстрока 2 \r\n\r\n") == "строка 1\nстрока 2"
|
||||
|
||||
|
||||
def test_clone_names_and_targets(data_dirs):
|
||||
assert vmops.suggest_clone_name("web01", {"web01"}) == "web01-copy"
|
||||
assert vmops.suggest_clone_name("web01", {"web01", "web01-copy", "web01-copy2"}) == "web01-copy3"
|
||||
disks = [
|
||||
{"device": "disk", "source": "/var/lib/virtuality/images/web01.qcow2"},
|
||||
{"device": "cdrom", "source": ""},
|
||||
{"device": "disk", "source": "/var/lib/virtuality/images/data.img"},
|
||||
]
|
||||
targets = vmops.clone_targets("web02", disks)
|
||||
assert targets == [data_dirs["images"] / "web02.qcow2", data_dirs["images"] / "web02-disk2.img"]
|
||||
|
||||
|
||||
def test_explain_error():
|
||||
assert "снимки" in vmops.explain_error("qemu-img: Can't resize an image which has snapshots", "x")
|
||||
assert vmops.explain_error("", "запасной текст") == "запасной текст"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- live stats
|
||||
def test_live_stats_requires_auth(client):
|
||||
assert client.get("/live/stats").status_code == 401
|
||||
|
||||
|
||||
def test_live_stats_json(logged_in, record):
|
||||
first = logged_in.get("/live/stats").json()
|
||||
assert first["ok"] is True
|
||||
assert first["stats"]["web01"]["cpu_pct"] is None
|
||||
assert first["stats"]["web01"]["mem_used_mb"] == 1498 and first["stats"]["web01"]["mem_total_mb"] == 2048
|
||||
second = logged_in.get("/live/stats").json()
|
||||
assert second["stats"]["web01"]["cpu_pct"] == 0.0 # cpu.time в подделке не растёт
|
||||
assert record.find("virsh", "domstats") == [["virsh", "domstats", "--cpu-total", "--balloon", "--vcpu", "web01"]] * 2 # только активные машины, один вызов на запрос
|
||||
|
||||
|
||||
def test_live_stats_without_active_vms(logged_in, record):
|
||||
record.overrides[("virsh", "list")] = conftest.fake_result("")
|
||||
assert logged_in.get("/live/stats").json()["stats"] == {}
|
||||
assert record.find("virsh", "domstats") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pages
|
||||
def test_dashboard_shows_title_and_meters(logged_in, record):
|
||||
html = logged_in.get("/").text
|
||||
assert "Сайт компании" in html
|
||||
assert 'data-live-stats="web01"' in html and 'data-live-stats="db01"' in html
|
||||
assert "/vm/web01/ops/pause" in html and "/vm/web01/ops/hibernate" in html
|
||||
assert "/vm/web01/clone" in html and "/vm/db01/clone" in html
|
||||
assert record.find("virsh", "list", "--all", "--title")
|
||||
|
||||
|
||||
def test_dashboard_paused_vm_offers_resume(logged_in, record):
|
||||
record.overrides[("virsh", "list")] = conftest.fake_result(" Id Name State\n-----------------------\n 2 web01 paused\n - db01 shut off")
|
||||
html = logged_in.get("/").text
|
||||
assert 'action="/vm/web01/ops/resume"' in html
|
||||
assert 'action="/vm/web01/start"' not in html
|
||||
assert 'action="/vm/db01/start"' in html
|
||||
|
||||
|
||||
def test_vm_page_shows_disks_notes_and_ops(logged_in, record):
|
||||
html = logged_in.get("/vm/web01").text
|
||||
assert "40 ГБ" in html and "занято 6.0 ГБ" in html
|
||||
assert 'action="/vm/web01/disks/vda/resize"' in html and 'min="41"' in html
|
||||
assert 'action="/vm/web01/disks/sda/resize"' not in html # привод не увеличивают
|
||||
assert 'value="Сайт компании"' in html and "Nginx + база. Бэкап по пятницам." in html
|
||||
assert "Приостановить" in html and "Спящий режим" in html and "Клонировать" in html
|
||||
assert 'data-live-stats="web01"' in html # карточка «Нагрузка сейчас»
|
||||
assert record.find("qemu-img", "info") == [["qemu-img", "info", "--output=json", "-U", str(core.IMAGES_DIR / "web01.qcow2")]]
|
||||
|
||||
|
||||
def test_vm_page_paused_and_saved_states(logged_in, record):
|
||||
record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: paused"))
|
||||
html = logged_in.get("/vm/web01").text
|
||||
assert 'action="/vm/web01/ops/resume"' in html and "Продолжить" in html
|
||||
record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: shut off") + "Managed save: yes\n")
|
||||
html = logged_in.get("/vm/web01").text
|
||||
assert "Машина в спящем режиме" in html and 'action="/vm/web01/ops/forget-save"' in html
|
||||
assert 'data-live-stats="web01"' not in html # выключенная машина — нагрузки нет
|
||||
|
||||
|
||||
def test_vm_page_messages(logged_in, record):
|
||||
html = logged_in.get("/vm/web01?disk_message=Диск+увеличен¬es_error=Не+вышло").text
|
||||
assert "Диск увеличен" in html and "Не вышло" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- disk resize
|
||||
def test_disk_resize_shut_off_uses_qemu_img(logged_in, record):
|
||||
shut_off(record)
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)
|
||||
assert location(response).startswith("/vm/web01?disk_message=Диск web01.qcow2 увеличен до 50 ГБ")
|
||||
assert location(response).endswith("#storage")
|
||||
assert record.find("qemu-img", "resize") == [["qemu-img", "resize", str(core.IMAGES_DIR / "web01.qcow2"), "50G"]]
|
||||
assert record.find("virsh", "blockresize") == []
|
||||
|
||||
|
||||
def test_disk_resize_running_uses_blockresize(logged_in, record):
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "41"}, follow_redirects=False)
|
||||
assert "disk_message" in location(response)
|
||||
assert record.find("virsh", "blockresize") == [["virsh", "blockresize", "web01", "vda", "41G"]]
|
||||
assert record.find("qemu-img", "resize") == []
|
||||
|
||||
|
||||
def test_disk_resize_only_grows(logged_in, record):
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "40"}, follow_redirects=False)
|
||||
assert "не меньше 41 ГБ" in location(response)
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "999999"}, follow_redirects=False)
|
||||
assert "Слишком большой" in location(response)
|
||||
assert record.find("virsh", "blockresize") == [] and record.find("qemu-img", "resize") == []
|
||||
|
||||
|
||||
def test_disk_resize_rejects_unknown_or_cdrom(logged_in, record):
|
||||
assert "Такого диска" in location(logged_in.post("/vm/web01/disks/vdz/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||
assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/sda/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||
assert logged_in.post("/vm/web01/disks/../etc/resize", data={"size_gb": "50"}, follow_redirects=False).status_code in (400, 404)
|
||||
assert logged_in.post("/vm/-bad/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False).status_code == 400
|
||||
|
||||
|
||||
def test_disk_resize_outside_storage_refused(logged_in, record):
|
||||
record.overrides[("virsh", "domblklist")] = conftest.fake_result((FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images", "/root"))
|
||||
assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||
|
||||
|
||||
def test_disk_resize_checks_free_space(logged_in, record, monkeypatch):
|
||||
monkeypatch.setattr(vmops, "free_space", lambda path: 2 * GIB)
|
||||
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)
|
||||
assert "свободно только 2.0 ГБ" in location(response)
|
||||
assert record.find("virsh", "blockresize") == []
|
||||
|
||||
|
||||
def test_disk_resize_reports_tool_error(logged_in, record):
|
||||
record.overrides[("virsh", "blockresize")] = conftest.fake_result(ok=False, stderr="error: internal error: Can't resize an image which has snapshots")
|
||||
assert "удалите снимки" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pause / resume / hibernate
|
||||
def test_ops_pause_and_resume(logged_in, record):
|
||||
response = logged_in.post("/vm/web01/ops/pause", data={"next": "/"}, follow_redirects=False)
|
||||
assert location(response).startswith("/?ops_message=Машина web01 приостановлена")
|
||||
assert record.find("virsh", "suspend") == [["virsh", "suspend", "web01"]]
|
||||
response = logged_in.post("/vm/web01/ops/resume", follow_redirects=False)
|
||||
assert "ops_error=Продолжить можно только приостановленную" in location(response)
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("paused")
|
||||
response = logged_in.post("/vm/web01/ops/resume", data={"next": "http://evil.example/"}, follow_redirects=False)
|
||||
assert location(response).startswith("/vm/web01?ops_message=") # чужой next не используется
|
||||
assert record.find("virsh", "resume") == [["virsh", "resume", "web01"]]
|
||||
|
||||
|
||||
def test_ops_validation(logged_in, record):
|
||||
assert logged_in.post("/vm/web01/ops/explode", follow_redirects=False).status_code == 400
|
||||
assert logged_in.post("/vm/-bad/ops/pause", follow_redirects=False).status_code == 400
|
||||
assert location(logged_in.post("/vm/ghost/ops/pause", follow_redirects=False)) == "/"
|
||||
shut_off(record)
|
||||
assert "ops_error=Приостановить можно только работающую" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False))
|
||||
record.overrides[("virsh", "suspend")] = conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock")
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
|
||||
assert "занята другой операцией" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False))
|
||||
|
||||
|
||||
def test_ops_requires_login(client):
|
||||
assert client.post("/vm/web01/ops/pause", follow_redirects=False).headers["location"] == "/login"
|
||||
|
||||
|
||||
def test_hibernate_runs_operation(logged_in, record, inline_operations, data_dirs):
|
||||
response = logged_in.post("/vm/web01/ops/hibernate", data={"next": "/"}, follow_redirects=False)
|
||||
assert location(response).startswith("/?ops_message=Машина web01 засыпает")
|
||||
assert record.find("virsh", "managedsave") == [["virsh", "managedsave", "web01"]]
|
||||
operations = core.list_operations(5)
|
||||
assert operations[0]["type"] == "vm_hibernate" and operations[0]["status"] == "success" and operations[0]["vm_name"] == "web01"
|
||||
|
||||
|
||||
def test_hibernate_blocked(logged_in, record, monkeypatch):
|
||||
shut_off(record)
|
||||
assert "ops_error=В спящий режим можно" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False))
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
|
||||
monkeypatch.setattr(vmops, "free_space", lambda path: 0)
|
||||
assert "свободного места" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False))
|
||||
assert record.find("virsh", "managedsave") == []
|
||||
|
||||
|
||||
def test_hibernate_failure_is_reported(logged_in, record, inline_operations):
|
||||
record.overrides[("virsh", "managedsave")] = conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: domain is not running")
|
||||
logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False)
|
||||
operation = core.list_operations(1)[0]
|
||||
assert operation["status"] == "error" and "выключена" in operation["message"]
|
||||
|
||||
|
||||
def test_forget_saved_state(logged_in, record):
|
||||
shut_off(record)
|
||||
response = logged_in.post("/vm/web01/ops/forget-save", follow_redirects=False)
|
||||
assert "ops_message=Сохранённое состояние удалено" in location(response)
|
||||
assert record.find("virsh", "managedsave-remove") == [["virsh", "managedsave-remove", "web01"]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- notes
|
||||
def test_notes_save_running(logged_in, record):
|
||||
response = logged_in.post("/vm/web01/notes/save", data={"title": " Сайт компании ", "description": "строка 1\r\nстрока 2"}, follow_redirects=False)
|
||||
assert location(response) == "/vm/web01?notes_message=Заметки сохранены"
|
||||
assert record.find("virsh", "desc") == [
|
||||
["virsh", "desc", "web01", "--title", "--new-desc=Сайт компании", "--config", "--live"],
|
||||
["virsh", "desc", "web01", "--new-desc=строка 1\nстрока 2", "--config", "--live"],
|
||||
]
|
||||
|
||||
|
||||
def test_notes_save_shut_off_and_dashes(logged_in, record):
|
||||
shut_off(record)
|
||||
logged_in.post("/vm/web01/notes/save", data={"title": "--edit", "description": ""}, follow_redirects=False)
|
||||
calls = record.find("virsh", "desc")
|
||||
assert calls[0] == ["virsh", "desc", "web01", "--title", "--new-desc=--edit", "--config"] # текст одним аргументом, без --live
|
||||
assert calls[1] == ["virsh", "desc", "web01", "--new-desc=", "--config"] # пустое описание очищает
|
||||
|
||||
|
||||
def test_notes_save_error(logged_in, record):
|
||||
record.overrides[("virsh", "desc")] = conftest.fake_result(ok=False, stderr="error: failed to get domain 'web01'")
|
||||
assert "notes_error=" in location(logged_in.post("/vm/web01/notes/save", data={"title": "x"}, follow_redirects=False))
|
||||
assert logged_in.post("/vm/-bad/notes/save", data={"title": "x"}, follow_redirects=False).status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- clone
|
||||
def test_clone_page_blocked_while_running(logged_in, record):
|
||||
response = logged_in.get("/vm/web01/clone")
|
||||
assert response.status_code == 200
|
||||
assert "Сначала выключите машину" in response.text
|
||||
assert 'action="/vm/web01/shutdown"' in response.text
|
||||
assert "disabled" in response.text
|
||||
|
||||
|
||||
def test_clone_page_ready(logged_in, record):
|
||||
shut_off(record)
|
||||
html = logged_in.get("/vm/web01/clone").text
|
||||
assert "Сначала выключите" not in html
|
||||
assert 'value="web01-copy"' in html
|
||||
assert "6.0 ГБ" in html # нужно места — реальный размер диска
|
||||
assert "web01.qcow2" in html
|
||||
|
||||
|
||||
def test_clone_validation(logged_in, record):
|
||||
shut_off(record)
|
||||
assert "Имя копии" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "bad name!"}, follow_redirects=False))
|
||||
assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "db01"}, follow_redirects=False))
|
||||
assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "WEB01"}, follow_redirects=False))
|
||||
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
|
||||
assert "Сначала выключите" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False))
|
||||
assert record.find("virt-clone") == []
|
||||
assert logged_in.post("/vm/-bad/clone/start", data={"new_name": "web02"}, follow_redirects=False).status_code == 400
|
||||
|
||||
|
||||
def test_clone_checks_space_and_existing_file(logged_in, record, data_dirs, monkeypatch):
|
||||
shut_off(record)
|
||||
(data_dirs["images"] / "web02.qcow2").write_bytes(b"x")
|
||||
assert "уже есть в хранилище" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False))
|
||||
monkeypatch.setattr(vmops, "free_space", lambda path: GIB)
|
||||
assert "Не хватает места" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web03"}, follow_redirects=False))
|
||||
|
||||
|
||||
def test_clone_starts_operation(logged_in, record, inline_operations, data_dirs):
|
||||
shut_off(record)
|
||||
response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)
|
||||
assert location(response).startswith("/operations/")
|
||||
operation = core.read_operation(location(response).split("/")[-1])
|
||||
assert operation["status"] == "success" and operation["type"] == "vm_clone"
|
||||
assert operation["vm_name"] == "web02" and operation["source_vm"] == "web01"
|
||||
assert "web02 создана" in operation["message"]
|
||||
assert record.find("virt-clone") == [["virt-clone", "--original", "web01", "--name", "web02", "--file", str(data_dirs["images"] / "web02.qcow2")]]
|
||||
assert record.find("virsh", "pool-refresh") == [["virsh", "pool-refresh", "virtuality-images"]]
|
||||
assert "virt-clone --original web01" in operation["log_tail"]
|
||||
|
||||
|
||||
def test_clone_failure_is_reported(logged_in, record, inline_operations):
|
||||
shut_off(record)
|
||||
record.overrides[("virt-clone",)] = conftest.fake_result(ok=False, stderr="ERROR Storage volume already exists")
|
||||
response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)
|
||||
operation = core.read_operation(location(response).split("/")[-1])
|
||||
assert operation["status"] == "error" and "уже есть" in operation["message"]
|
||||
assert record.find("virsh", "pool-refresh") == []
|
||||
|
||||
|
||||
def test_clone_progress_watcher(data_dirs):
|
||||
target = data_dirs["images"] / "copy.qcow2"
|
||||
target.write_bytes(b"\0" * (512 * 1024))
|
||||
operation = core.new_operation("vm_clone", "test", vm_name="copy", source_vm="web01")
|
||||
|
||||
class OneTick:
|
||||
def __init__(self):
|
||||
self.ticks = 0
|
||||
|
||||
def wait(self, timeout):
|
||||
self.ticks += 1
|
||||
return self.ticks > 1
|
||||
|
||||
vmops._watch_clone_progress(operation, [target], 1024 * 1024, OneTick())
|
||||
fresh = core.read_operation(operation["id"])
|
||||
assert 3 <= fresh["progress"] <= 95 and "Копируем диски" in fresh["message"]
|
||||
assert vmops._allocated_bytes(data_dirs["images"] / "missing.qcow2") == 0
|
||||
|
||||
|
||||
def test_clone_worker_stops_watcher(data_dirs, monkeypatch):
|
||||
"""Даже если virt-clone упал, поток прогресса останавливается."""
|
||||
monkeypatch.setattr(core, "run_cmd", lambda cmd, timeout=12: conftest.fake_result(ok=False, stderr="boom"))
|
||||
operation = core.new_operation("vm_clone", "test", vm_name="web02", source_vm="web01", targets=[str(data_dirs["images"] / "web02.qcow2")], expected_bytes=10)
|
||||
before = threading.active_count()
|
||||
vmops.clone_worker(operation)
|
||||
assert threading.active_count() <= before
|
||||
assert core.read_operation(operation["id"])["status"] == "error"
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}» удалён")
|
||||
@@ -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)
|
||||
@@ -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.'})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -67,6 +67,8 @@
|
||||
const raw = sessionStorage.getItem('virtualityToast');
|
||||
if (!raw) return;
|
||||
sessionStorage.removeItem('virtualityToast');
|
||||
// The toast was queued when the form was submitted; if the page came back with an error, the action failed.
|
||||
if (document.querySelector('.alert.danger') || /(^|[?&_])error=/.test(location.search)) return;
|
||||
const data = JSON.parse(raw);
|
||||
toast(data.message, data.tone);
|
||||
} catch (_) {}
|
||||
@@ -196,6 +198,41 @@
|
||||
setInterval(refreshLive, 5000);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- live VM load (CPU / RAM meters) */
|
||||
const levelTone = (pct) => (pct >= 90 ? 'danger' : pct >= 75 ? 'warning' : 'success');
|
||||
const formatMb = (mb) => (mb >= 1024 ? `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ` : `${Math.round(mb)} МБ`);
|
||||
function applyStatMeter(root, key, pct, label) {
|
||||
const bar = $(`[data-stat="${key}-bar"]`, root);
|
||||
const meter = $(`[data-stat="${key}-meter"]`, root);
|
||||
const text = $(`[data-stat="${key}"]`, root);
|
||||
if (bar) bar.style.setProperty('--value', `${pct == null ? 0 : Math.round(pct)}%`);
|
||||
if (meter) meter.className = `meter ${pct == null ? '' : levelTone(pct)}`;
|
||||
if (text) text.textContent = label;
|
||||
}
|
||||
async function refreshStats() {
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
const response = await fetch('/live/stats', { cache: 'no-store', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
if (!payload.ok) return;
|
||||
$$('[data-live-stats]').forEach((root) => {
|
||||
const stat = payload.stats[root.dataset.liveStats];
|
||||
root.hidden = !stat;
|
||||
if (!stat) return;
|
||||
const cpu = stat.cpu_pct == null ? null : Math.round(stat.cpu_pct);
|
||||
const memPct = stat.mem_total_mb ? Math.min(100, Math.round((stat.mem_used_mb / stat.mem_total_mb) * 100)) : null;
|
||||
applyStatMeter(root, 'cpu', cpu, cpu == null ? '…' : `${cpu}%`);
|
||||
applyStatMeter(root, 'mem', memPct, stat.mem_total_mb ? (root.classList.contains('vm-meters') ? `${memPct}%` : `${formatMb(stat.mem_used_mb)} из ${formatMb(stat.mem_total_mb)}`) : '—');
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
function wireStats() {
|
||||
if (!$('[data-live-stats]')) return;
|
||||
refreshStats();
|
||||
setInterval(refreshStats, 5000);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- uploads */
|
||||
function wireUploader(form) {
|
||||
const input = $('input[type=file]', form);
|
||||
@@ -408,8 +445,17 @@
|
||||
});
|
||||
$$('[data-visible-when]', form).forEach((el) => {
|
||||
const [key, expected] = el.dataset.visibleWhen.split('=');
|
||||
el.hidden = value(key) !== expected;
|
||||
const target = field(key);
|
||||
const current = target instanceof HTMLInputElement && target.type === 'checkbox' ? (target.checked ? target.value : '0') : value(key);
|
||||
el.hidden = current !== expected;
|
||||
});
|
||||
// Default login of the chosen cloud image (ubuntu, debian, …) until the user types a name.
|
||||
const login = field('ci_user');
|
||||
if (login && login.dataset.autoLogin === '1') {
|
||||
const image = field('disk_image_path');
|
||||
const option = image && image.selectedIndex >= 0 ? image.options[image.selectedIndex] : null;
|
||||
login.value = option?.dataset.login || 'admin';
|
||||
}
|
||||
const preset = value('preset');
|
||||
const custom = preset === 'custom';
|
||||
const presetInput = $(`input[name=preset][value="${preset}"]`, form);
|
||||
@@ -424,13 +470,13 @@
|
||||
setSummary('source', source === 'disk_image' ? optionText(field('disk_image_path')) : optionText(field('iso_path')));
|
||||
setSummary('cpu', `${value('vcpus')} ${Number(value('vcpus')) === 1 ? 'ядро' : 'ядра'}`);
|
||||
setSummary('memory', memory >= 1024 ? `${+(memory / 1024).toFixed(1)} ГБ` : `${memory} МБ`);
|
||||
setSummary('disk', source === 'disk_image' ? 'из образа' : `${value('disk_size')} ГБ`);
|
||||
setSummary('disk', `${value('disk_size')} ГБ`);
|
||||
setSummary('network', value('network_mode') === 'bridge' ? 'Локальная сеть' : 'Автоматически (NAT)');
|
||||
const hasSource = source === 'disk_image' ? form.dataset.hasDisks === '1' : form.dataset.hasIsos === '1';
|
||||
$$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); });
|
||||
$$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; });
|
||||
}
|
||||
form.addEventListener('input', sync);
|
||||
form.addEventListener('input', (event) => { if (event.target.name === 'ci_user') event.target.dataset.autoLogin = event.target.value ? '0' : '1'; sync(); });
|
||||
form.addEventListener('change', sync);
|
||||
sync();
|
||||
}
|
||||
@@ -466,6 +512,7 @@
|
||||
wireTabs();
|
||||
wireCopy();
|
||||
wireLive();
|
||||
wireStats();
|
||||
$$('form[data-uploader]').forEach(wireUploader);
|
||||
wireOperation();
|
||||
wireLogs();
|
||||
@@ -475,3 +522,46 @@
|
||||
flashToast();
|
||||
});
|
||||
})();
|
||||
|
||||
/* Setup wizard: live progress of the first-boot installation. */
|
||||
(() => {
|
||||
'use strict';
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const root = document.querySelector('[data-setup-install]');
|
||||
if (!root) return;
|
||||
const list = root.querySelector('[data-setup-steps]');
|
||||
const log = root.querySelector('[data-setup-log]');
|
||||
const status = document.querySelector('[data-setup-status]');
|
||||
const next = document.querySelector('[data-setup-next]');
|
||||
const escapeHtml = (v) => String(v ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
||||
const icon = (name) => `<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);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||