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 -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
|
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"
|
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,
|
# Ubuntu server ISOs are larger than the 2 GB GitHub release asset limit,
|
||||||
# so images are published as workflow artifacts.
|
# so images are published as workflow artifacts.
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -94,3 +94,6 @@ composer.lock
|
|||||||
|
|
||||||
# Offline Python wheels bundled into system images
|
# Offline Python wheels bundled into system images
|
||||||
wheels/
|
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
|
||||||
|
|
||||||
**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.
|
Что умеет:
|
||||||
|
|
||||||
---
|
- ставится с готового ISO-образа, как Proxmox: три экрана установщика — и через несколько минут панель открыта в браузере;
|
||||||
|
- создаёт машины из установочных ISO (в том числе Windows 11) и за минуту — из готовых облачных образов Ubuntu, Debian и Alpine;
|
||||||
## Поддерживаемые профили хоста
|
- снимки, резервные копии, клонирование, увеличение диска, спящий режим;
|
||||||
|
- сеть машин «из коробки»: отдельная сеть NAT с постоянными адресами и пробросом портов или ваша домашняя сеть через мост;
|
||||||
Virtuality автоматически определяет профиль сборки во время установки:
|
- экран машины прямо в браузере, HTTPS для панели, автообновления со стабильным каналом.
|
||||||
|
|
||||||
```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 не являются целевым режимом.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | 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
|
```text
|
||||||
Если запуск под root → login: root
|
VIRTUALITY_USER=admin # какой Linux-пользователь входит в панель (будет создан, если его нет)
|
||||||
Если запуск через sudo → login: текущий sudo-пользователь
|
VIRTUALITY_WEB_PORT=8089 # порт панели (по умолчанию 8088)
|
||||||
Если задан VIRTUALITY_USER → login: указанный Linux-пользователь
|
VIRTUALITY_AUTO_UPDATE=0 # не включать ночное автообновление
|
||||||
```
|
VIRTUALITY_BRANCH=main # ветка репозитория
|
||||||
|
VIRTUALITY_INSTALL_VERBOSE=1 # показывать вывод команд вместо спиннера
|
||||||
Принудительно выбрать пользователя:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=<linux_user> bash
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
@@ -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
|
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=admin bash
|
||||||
```
|
```
|
||||||
|
|
||||||
Установка с авторизацией под `root`:
|
При первом входе в панель откроется мастер настройки — тот же, что после установки с ISO.
|
||||||
|
|
||||||
```bash
|
### Способ 3: вручную
|
||||||
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=root bash
|
|
||||||
```
|
Если хочется видеть каждый шаг. Всё делается от `root` или через `sudo`:
|
||||||
|
|
||||||
Если пароль выбранного 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-панели:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
sudo mkdir -p /opt/virtuality
|
||||||
|
sudo git clone https://github.com/viktor138irk/virtuality.git /opt/virtuality/source
|
||||||
cd /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
|
|
||||||
```
|
|
||||||
|
|
||||||
Вход:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Login: выбранный Linux-пользователь
|
|
||||||
Pass: пароль этого Linux-пользователя
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Сеть: Bridge и NAT Router
|
## Первая загрузка и мастер настройки
|
||||||
|
|
||||||
### Bridge br0
|
### Что происходит после установки с ISO
|
||||||
|
|
||||||
Подходит для домашнего/офисного сервера, где VM должны получать IP из локальной сети.
|
После перезагрузки запускается `virtuality-firstboot.service` (`scripts/virtuality_firstboot.sh`). Порядок шагов выбран так, чтобы вы как можно раньше увидели, что происходит:
|
||||||
|
|
||||||
По умолчанию one-command установщик **не включает `br0` автоматически**, чтобы не уронить SSH-сессию.
|
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) всё это время показана подсказка, а после завершения — адрес панели и логин.
|
||||||
|
|
||||||
```bash
|
Логин в панель — пользователь, созданный в установщике Ubuntu; пароль — его пароль.
|
||||||
ip -br a
|
|
||||||
ip route
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем запустить bridge setup, например для `enp2s0`:
|
### Мастер настройки
|
||||||
|
|
||||||
```bash
|
Мастер (`/setup`) открывается при первом входе в панель — и после ISO, и после `install.sh`. Каждый шаг можно пропустить, а весь мастер — запустить заново из раздела «Настройки».
|
||||||
cd /opt/virtuality/source
|
|
||||||
sudo bash scripts/setup_bridge_br0.sh enp2s0 static
|
|
||||||
```
|
|
||||||
|
|
||||||
Ожидаемый результат:
|
| Шаг | Что делает |
|
||||||
|
|---|---|
|
||||||
```text
|
| **Установка** | Показывает ход первой загрузки (только после ISO). |
|
||||||
enp2s0 UP
|
| **Сервер** | Проверяет процессор, память, диск и доступность KVM; задаёт часовой пояс. |
|
||||||
br0 UP SERVER_IP/24
|
| **Администратор** | Смена пароля пользователя панели (он же пароль SSH). |
|
||||||
default via GATEWAY dev br0
|
| **Доступ** | HTTPS с сертификатом сервера (панель на порту 8443, с 8088 — перенаправление) или обычный HTTP; порт. |
|
||||||
```
|
| **Сеть машин** | **Отдельная сеть (NAT)** — машины получают адреса 192.168.100.x, работает везде. **Домашняя сеть (мост)** — машины становятся устройствами вашей сети; сервер сам перестроит сеть в мост `br0`, а если панель не ответит за 2 минуты — вернёт всё как было. |
|
||||||
|
| **Хранилище** | Отдать пустой диск (от 16 ГБ) под машины: он форматируется и монтируется в `/var/lib/virtuality`. Или оставить системный диск. |
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ISO и создание VM
|
## Возможности
|
||||||
|
|
||||||
Загрузка ISO через web-панель:
|
Разделы панели: **Обзор**, **Создать машину**, **Образы ОС**, **Сеть и доступ**, **Резервные копии**, **Задачи**, **Журналы**, **Обновления**, **Сервер**, **Настройки**, **Помощь**.
|
||||||
|
|
||||||
```text
|
**Машины**
|
||||||
/iso
|
|
||||||
```
|
|
||||||
|
|
||||||
Создание VM:
|
- Создание из установочного 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 наружу не открыт: консоль доступна только через панель.
|
||||||
|
|
||||||
```text
|
**Снимки и копии**
|
||||||
/vm/create
|
|
||||||
```
|
|
||||||
|
|
||||||
При создании VM можно выбрать сеть:
|
- **Снимки** — точки восстановления внутри диска машины, до 8 на машину. У работающей машины в снимок попадает и память. Вернуться или удалить — одной кнопкой.
|
||||||
|
- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием; копию можно делать и по расписанию из командной строки.
|
||||||
|
|
||||||
```text
|
**Образы ОС**
|
||||||
VPS NAT Router — virtuality-nat
|
|
||||||
Bridge — br0 / локальная сеть
|
|
||||||
```
|
|
||||||
|
|
||||||
Для x86_64 можно использовать обычные x86_64 ISO. Для Raspberry Pi / Orange Pi 5 нужны ARM64 ISO или, в будущем, ARM64 cloud images.
|
- Загрузка ISO и образов дисков перетаскиванием (с прогрессом и скоростью), поддержка `.qcow2`, `.img`, `.raw`, сжатых `.img.xz` и архивов.
|
||||||
|
- **Скачивание по ссылке** — сервер качает файл сам, в фоне.
|
||||||
|
- **Каталог готовых систем** — официальные облачные образы Ubuntu Server 26.04 LTS и 24.04 LTS, Debian 13, Alpine Linux 3.23 с проверкой контрольных сумм.
|
||||||
|
|
||||||
Пример загрузки Alpine x86_64 ISO:
|
**Сеть**
|
||||||
|
|
||||||
```bash
|
- Сеть **NAT** `virtuality-nat` (192.168.100.0/24): каждая машина получает **постоянный адрес** по имени, поэтому пробросы портов не ломаются после перезагрузок.
|
||||||
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
|
- **Проброс портов** из списка готовых сервисов (SSH, сайт, RDP, Minecraft, VPN и другие) или любой порт и диапазон; проверка правила одной кнопкой; правила переживают перезагрузку сервера.
|
||||||
sudo virsh pool-refresh virtuality-iso
|
- **Домашняя сеть через мост** `br0` с автооткатом, если сеть пропала.
|
||||||
```
|
|
||||||
|
**Сервер**
|
||||||
|
|
||||||
|
- Мастер настройки и страница «Настройки».
|
||||||
|
- **HTTPS** для панели с сертификатом сервера и перенаправлением с HTTP.
|
||||||
|
- **Обновления** из панели или автоматически ночью, **стабильный канал** (только выпущенные версии) и ранний доступ, откат на предыдущую версию.
|
||||||
|
- Задачи с прогрессом и журналом, журналы служб, диагностика `vhealth`, отчёт для поддержки, архив настроек.
|
||||||
|
- Светлая и тёмная темы, работает на телефоне, не требует доступа в интернет для самой панели.
|
||||||
|
|
||||||
|
Как сделать первую машину — в [docs/FIRST_VM.md](docs/FIRST_VM.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Обновление
|
## Настройки
|
||||||
|
|
||||||
Из web-панели: раздел `/update`. Вручную:
|
Страница **Настройки** (`/settings`) — всё то же, что в мастере, плюс управление сервером:
|
||||||
|
|
||||||
```bash
|
- **Доступ к панели** — HTTPS или HTTP, порт. После применения панель перезапускается и открывается по новому адресу.
|
||||||
cd /opt/virtuality/source
|
- **Пароль** — смена пароля пользователя панели (он же пароль Linux и SSH).
|
||||||
sudo git pull
|
- **Сеть машин** — переключение между NAT и домашней сетью (мост `br0`), возврат прежней сети, проверка сети NAT.
|
||||||
sudo bash scripts/install_web_panel.sh
|
- **Хранилище** — подключить пустой диск под машины.
|
||||||
sudo systemctl restart virtuality-web
|
- **Время** — часовой пояс сервера (по нему показываются журналы и час ночного обновления).
|
||||||
```
|
- **Обновления** — автообновление и канал (стабильный / ранний доступ).
|
||||||
|
- **Сервер** — скачать архив настроек, собрать отчёт для поддержки, перезагрузить или выключить сервер (машины выключаются корректно).
|
||||||
|
- **Мастер настройки** — пройти все шаги заново.
|
||||||
|
|
||||||
Порт панели, пользователь и режим автообновления сохраняются в `/var/lib/virtuality/config/web.env` и не сбрасываются при обновлениях.
|
Настройки ноды хранятся в `/var/lib/virtuality/config/web.env` и не сбрасываются обновлениями:
|
||||||
|
|
||||||
Полная повторная установка компонентов ноды:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/virtuality/source
|
|
||||||
sudo bash install_virtuality_node.sh
|
|
||||||
sudo bash scripts/install_web_panel.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Продакшен-настройки
|
|
||||||
|
|
||||||
Настройки ноды — `/var/lib/virtuality/config/web.env`:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
VIRTUALITY_WEB_PORT=8088 # порт панели
|
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_WEB_HOST=0.0.0.0 # 127.0.0.1, если панель стоит за reverse proxy
|
||||||
VIRTUALITY_AUTH_USER=admin # Linux-пользователь для входа
|
VIRTUALITY_AUTH_USER=admin # Linux-пользователь для входа
|
||||||
VIRTUALITY_AUTO_UPDATE=1 # 0 — отключить ночное автообновление с GitHub
|
VIRTUALITY_AUTO_UPDATE=1 # 0 — без ночного автообновления
|
||||||
VIRTUALITY_COOKIE_SECURE=0 # 1 — cookie только по HTTPS (за reverse proxy с TLS)
|
VIRTUALITY_UPDATE_CHANNEL=stable # stable — выпущенные версии, main — все изменения
|
||||||
|
VIRTUALITY_SOURCE_DIR=/opt/virtuality/source
|
||||||
```
|
```
|
||||||
|
|
||||||
После изменения: `cd /opt/virtuality/source && sudo bash scripts/install_web_panel.sh`. Любой параметр можно передать и переменной окружения при установке, например `VIRTUALITY_AUTO_UPDATE=0`.
|
Менять их руками не нужно: `sudo virtuality-ctl set КЛЮЧ=ЗНАЧЕНИЕ` сохраняет значение и сразу применяет его.
|
||||||
|
|
||||||
Рекомендации:
|
---
|
||||||
|
|
||||||
- для продакшена отключите автообновление (`VIRTUALITY_AUTO_UPDATE=0`) и обновляйтесь вручную через `/update` после проверки версии;
|
## Команда virtuality-ctl
|
||||||
- открывайте панель наружу только через 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;
|
`virtuality-ctl` ставится в `/usr/local/bin` вместе с панелью; панель сама вызывает его для всех действий с сервером, так что из терминала можно сделать то же самое. Запускается от `root`:
|
||||||
- мониторинг: `GET /healthz` без авторизации возвращает `{"ok": true, "version": "..."}`;
|
|
||||||
- после 5 неверных паролей вход с этого адреса блокируется на 5 минут; сессия живёт 12 часов;
|
```text
|
||||||
- правила проброса портов восстанавливаются после перезагрузки сервисом `virtuality-network.service`.
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Обновления и откат
|
||||||
|
|
||||||
|
- **Из панели**: раздел **Обновления** → «Проверить сейчас» → «Обновить». Машины во время обновления продолжают работать. Что изменилось — видно на той же странице.
|
||||||
|
- **Автоматически**: таймер `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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Удаление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo virtuality-ctl uninstall # убирает панель, службы и настройки; машины, образы и копии остаются
|
||||||
|
sudo virtuality-ctl uninstall --purge # удаляет ВСЁ: машины, диски, образы, резервные копии, сеть NAT
|
||||||
|
```
|
||||||
|
|
||||||
|
Команда спрашивает подтверждение (`YES`); `--yes` пропускает вопрос. Пакеты KVM/QEMU/libvirt остаются в системе.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Если что-то не работает
|
||||||
|
|
||||||
|
1. Раздел **Сервер** в панели показывает, чего не хватает (KVM, пакеты, мост). Раздел **Задачи** — журнал каждой операции. Раздел **Журналы** — панель, обновления, установка, виртуализация.
|
||||||
|
2. Полная диагностика в терминале:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo vhealth # проверка процессора, служб, сети, firewall, libvirt, дисков
|
||||||
|
sudo virtuality-ctl status # версия, адрес панели, HTTPS, автообновление, хранилище
|
||||||
|
```
|
||||||
|
|
||||||
|
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 + журнал каждой
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Для мониторинга: `GET /healthz` без авторизации отвечает `{"ok": true, "version": "..."}`.
|
||||||
|
|
||||||
|
Полезные команды libvirt: `virsh list --all`, `virsh net-list --all`, `virsh net-dhcp-leases virtuality-nat`, `virsh pool-list --all`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Где что лежит
|
||||||
|
|
||||||
|
```text
|
||||||
|
/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 # диагностика
|
||||||
|
```
|
||||||
|
|
||||||
|
Порты: панель `8088` (HTTP) и `8443` (HTTPS), SSH `22`. VNC-порты машин наружу не открываются.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -450,50 +336,27 @@ VIRTUALITY_COOKIE_SECURE=0 # 1 — cookie только по HTTPS (за rev
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r web/requirements.txt -r requirements-dev.txt
|
pip install -r web/requirements.txt -r requirements-dev.txt
|
||||||
make check # pyflakes + shellcheck + pytest
|
make test # pytest (virsh и systemctl имитируются в tests/conftest.py)
|
||||||
python3 tests/dev_server.py # панель на http://127.0.0.1:8765 с имитацией virsh, вход: любой пароль
|
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
|
```bash
|
||||||
sudo vhealth
|
python3 tests/dev_server.py 8765 # http://127.0.0.1:8765, вход: tester / любой пароль
|
||||||
virsh list --all
|
VIRTUALITY_DEV_SETUP=installing python3 tests/dev_server.py # мастер на шаге «Установка»
|
||||||
virsh pool-list --all
|
VIRTUALITY_DEV_SETUP=done python3 tests/dev_server.py # мастер после установки
|
||||||
virsh net-list --all
|
VIRTUALITY_DEV_SETUP=finished python3 tests/dev_server.py # настроенный сервер, страница «Настройки»
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Устройство кода описано в [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
|
GitHub Actions: `ci.yml` запускает pyflakes, shellcheck и тесты на Python 3.11–3.14; `image.yml` собирает ISO по тегу `v*` и вручную. Ветка `main` — рабочая ветка нод на канале `main`; выпуск для стабильного канала — тег `vX.Y.Z`.
|
||||||
|
|
||||||
- ARM64 cloud-image templates;
|
|
||||||
- cloud-init для быстрых VM;
|
|
||||||
- управление storage pools;
|
|
||||||
- backup/snapshot manager;
|
|
||||||
- сетевой менеджер bridge/VLAN;
|
|
||||||
- роли и права пользователей;
|
|
||||||
- журнал событий;
|
|
||||||
- кластеризация;
|
|
||||||
- готовые образы для Raspberry Pi / Orange Pi 5.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
Рекомендуемая лицензия: **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
|
||||||
|
|
||||||
Virtuality — это платформа виртуализации в стиле Proxmox, но с более компактной и контролируемой архитектурой.
|
Virtuality — одна нода: сервер с KVM/QEMU/libvirt, поверх которого работает панель на FastAPI. Панель не хранит собственную базу данных: источник правды о машинах — libvirt (`virsh`), о ноде — файлы в `/var/lib/virtuality/config`, о задачах — файлы в `/var/log/virtuality/operations`. Всё, что меняет сам сервер (службы, firewall, сеть, диски), делает один скрипт — `virtuality-ctl`, и панель вызывает его же.
|
||||||
|
|
||||||
## Цель
|
|
||||||
|
|
||||||
Создать собственную систему управления виртуальными машинами на базе стабильных Linux-компонентов:
|
|
||||||
|
|
||||||
- KVM;
|
|
||||||
- QEMU;
|
|
||||||
- libvirt;
|
|
||||||
- Cockpit на раннем этапе;
|
|
||||||
- собственная веб-панель на следующих этапах.
|
|
||||||
|
|
||||||
## Базовая схема
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Веб-интерфейс
|
Браузер
|
||||||
↓
|
│ HTTP 8088 / HTTPS 8443
|
||||||
Backend API
|
▼
|
||||||
↓
|
virtuality-web.service — uvicorn, web/app.py (FastAPI, Jinja2)
|
||||||
Node services
|
├── web/features/* мастер, настройки, снимки, копии, загрузки, операции с машиной
|
||||||
↓
|
├── web/core.py общие примитивы, хранилище задач
|
||||||
libvirt / QEMU / KVM
|
├── web/nodectl.py ───────► /usr/local/bin/virtuality-ctl (systemd, TLS, ufw, br0, диск, пароль)
|
||||||
↓
|
├── web/network_core.py ──► virsh net-*, nft, ufw, iptables (сеть NAT и пробросы)
|
||||||
Host OS / Storage / Network
|
└── 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;
|
Один bash-скрипт, устанавливается в `/usr/local/bin`. Панель (через `nodectl.py`) и администратор в терминале делают одно и то же:
|
||||||
- валидировать действия;
|
|
||||||
- работать с libvirt;
|
|
||||||
- вести журнал событий;
|
|
||||||
- отдавать состояние VM, storage и сети.
|
|
||||||
|
|
||||||
### 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
|
```text
|
||||||
/opt/virtuality # код и конфигурация проекта
|
/var/lib/virtuality/
|
||||||
/var/lib/virtuality # runtime-хранилище
|
├── iso/ установочные образы; libvirt-пул virtuality-iso
|
||||||
/var/lib/virtuality/iso # ISO-образы
|
├── images/ диски машин <имя>.qcow2; libvirt-пул virtuality-images
|
||||||
/var/lib/virtuality/images # диски VM
|
├── disk-images/ готовые образы систем (каталог, ссылки, загрузки с компьютера)
|
||||||
/var/lib/virtuality/backups # резервные копии
|
├── backups/ резервные копии машин (<машина>/<ГГГГММДД-ЧЧММ>/) и архивы настроек config-*.tar.gz
|
||||||
/var/log/virtuality # логи
|
├── 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;
|
1. **Канал** (`VIRTUALITY_UPDATE_CHANNEL` в `web.env`): `stable` — последний тег `vX.Y.Z` (пока тегов нет — ветка `main`); `main` — ветка `main`. Логика одна и та же в `web/update_core.py` и `scripts/update_target.sh`.
|
||||||
- storage pools;
|
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`.
|
||||||
- GitHub-синхронизация;
|
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;
|
## 1. Быстрая машина из каталога: Ubuntu Server 26.04
|
||||||
- libvirt;
|
|
||||||
- Cockpit;
|
|
||||||
- storage pools `virtuality-images` и `virtuality-iso`;
|
|
||||||
- bridge `br0`;
|
|
||||||
- default route через `br0`.
|
|
||||||
|
|
||||||
Проверка:
|
Готовые (облачные) образы — это уже установленная система без пароля. Панель скачивает образ, делает из него диск нужного размера и через 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
|
```bash
|
||||||
sudo vhealth
|
sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия"
|
||||||
ip -br a
|
|
||||||
ip route
|
|
||||||
virsh pool-list --all
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Нормальная сеть:
|
---
|
||||||
|
|
||||||
```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
|
||||||
|
|
||||||
Virtuality можно поставить двумя способами:
|
Загрузочный ISO ставит Virtuality на чистый сервер «с нуля», как Proxmox: записали на флешку, загрузились, ответили на три экрана — после перезагрузки панель уже открыта в браузере, а мастер настройки показывает, как ставятся остальные компоненты.
|
||||||
|
|
||||||
1. **Скрипт на готовый сервер** — `curl ... install.sh | bash` (см. README).
|
Образ собирается из официального, ничем не изменённого **Ubuntu Server 26.04 LTS live-server ISO** (amd64 или arm64) и дополняется:
|
||||||
2. **Установочный ISO** — загрузочный образ «с нуля», как у Proxmox: записываете на флешку, загружаетесь, отвечаете на 3 экрана, после перезагрузки получаете готовую ноду.
|
|
||||||
|
|
||||||
Этот документ про второй способ.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что внутри образа
|
|
||||||
|
|
||||||
Образ собирается из официального, ничем не изменённого **Ubuntu Server 26.04 LTS live-server ISO** (amd64 или arm64; поддержка до 2031 года) и дополняется:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall)
|
/autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall)
|
||||||
/virtuality/source # исходники Virtuality (git-репозиторий нужной версии)
|
/virtuality/source # исходники Virtuality нужной версии (git-репозиторий)
|
||||||
/virtuality/source/wheels # Python-колёса web-панели для офлайн-установки
|
/virtuality/source/wheels # Python-колёса панели и pip — для установки без интернета
|
||||||
/virtuality/image.env # параметры сборки: порт панели, автообновление, пользователь
|
/virtuality/image.env # параметры сборки: версия, порт панели, автообновление
|
||||||
/virtuality/virtuality-firstboot.service
|
/virtuality/virtuality-firstboot.service # служба первой загрузки
|
||||||
/boot/grub/grub.cfg # меню «Install Virtuality X.Y.Z»
|
/boot/grub/grub.cfg # меню «Установить Virtuality X.Y.Z» с фирменной темой
|
||||||
|
/boot/grub/themes/virtuality/ # тема GRUB
|
||||||
```
|
```
|
||||||
|
|
||||||
Загрузочные записи BIOS и UEFI берутся из оригинального ISO без изменений.
|
Загрузочные записи 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
|
Workflow `.github/workflows/image.yml` собирает ISO в GitHub Actions:
|
||||||
Web-панель: http://SERVER_IP:8088 (логин — пользователь, созданный в установщике)
|
|
||||||
Cockpit: https://SERVER_IP:9090
|
|
||||||
```
|
|
||||||
|
|
||||||
Адрес панели также показывается на экране входа консоли. Ход первой настройки:
|
- автоматически при выпуске — пуш тега `v*` (amd64 и arm64);
|
||||||
|
- при pull request, который меняет `image/**`, `scripts/virtuality_firstboot.sh`, `web/requirements.txt` или сам workflow;
|
||||||
|
- вручную: Actions → **Build ISO image** → Run workflow → архитектура `amd64`, `arm64` или `both`.
|
||||||
|
|
||||||
```bash
|
После сборки workflow проверяет, что в образе есть загрузочная запись UEFI, `autoinstall.yaml` разбирается как YAML, а в `grub.cfg` есть пункт Virtuality. Образы Ubuntu Server больше лимита GitHub Releases (2 ГБ на файл), поэтому ISO публикуются как **artifacts** запуска (`virtuality-iso-amd64`, `virtuality-iso-arm64`) и хранятся 30 дней. В артефакте — `.iso` и `.sha256`.
|
||||||
sudo journalctl -fu virtuality-firstboot
|
|
||||||
cat /var/log/virtuality/firstboot.log
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Сборка ISO
|
## Сборка своими руками
|
||||||
|
|
||||||
Нужна Linux-машина (root не нужен), ~10 GB свободного места и пакеты:
|
Нужна Linux-машина (root не нужен), около 10 ГБ свободного места и пакеты:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install -y xorriso git curl python3-pip
|
sudo apt install -y xorriso git curl python3-pip
|
||||||
@@ -63,81 +47,154 @@ make iso # amd64
|
|||||||
make iso ARCH=arm64 # ARM64-серверы с UEFI
|
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
|
```text
|
||||||
dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso
|
dist/virtuality-0.11.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.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
Скачанный ISO Ubuntu кешируется в `.cache/iso`. Уже скачанный образ можно передать явно:
|
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./image/build-iso.sh --ubuntu-iso ~/Downloads/ubuntu-26.04.1-live-server-amd64.iso
|
./image/build-iso.sh --ubuntu-iso ~/Downloads/ubuntu-26.04.1-live-server-amd64.iso
|
||||||
```
|
```
|
||||||
|
|
||||||
### Параметры
|
### Параметры `image/build-iso.sh`
|
||||||
|
|
||||||
```text
|
```text
|
||||||
--arch amd64|arm64 архитектура
|
--arch amd64|arm64 архитектура (по умолчанию amd64)
|
||||||
--ubuntu 26.04|24.04 база образа (по умолчанию 26.04 LTS)
|
--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)
|
--ref GIT_REF какую версию Virtuality положить в образ (по умолчанию HEAD)
|
||||||
--web-port PORT порт web-панели (по умолчанию 8088)
|
--repo-url URL откуда установленная нода будет получать обновления
|
||||||
--no-auto-update не включать ночное автообновление с GitHub
|
--web-port PORT порт панели на установленной ноде (по умолчанию 8088)
|
||||||
--locale / --keyboard / --timezone
|
--no-auto-update не включать ночное автообновление
|
||||||
|
--locale LOCALE локаль установщика и системы (по умолчанию en_US.UTF-8)
|
||||||
|
--keyboard LAYOUT раскладка (по умолчанию us)
|
||||||
|
--timezone TZ часовой пояс (по умолчанию geoip — установщик определит по сети; мастер настройки может сменить)
|
||||||
--no-wheels не класть Python-колёса (тогда первая загрузка скачает их из PyPI)
|
--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
|
```bash
|
||||||
make iso ISO_ARGS="--web-port 8089 --no-auto-update --timezone Europe/Moscow"
|
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
|
```bash
|
||||||
make iso ISO_ARGS="--unattended --username admin --password-hash '$(openssl passwd -6)' --hostname node01"
|
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
|
- **Windows**: [Rufus](https://rufus.ie) (режим записи DD) или [balenaEtcher](https://etcher.balena.io).
|
||||||
sudo dd if=dist/virtuality-0.10.0-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
|
- **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
|
||||||
|
```
|
||||||
|
|
||||||
Проверка целостности:
|
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
|
||||||
|
|
||||||
```bash
|
|
||||||
cd dist && sha256sum -c virtuality-0.10.0-ubuntu-26.04-amd64.iso.sha256
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Сборка в GitHub Actions
|
|
||||||
|
|
||||||
Workflow `.github/workflows/image.yml` собирает ISO:
|
|
||||||
|
|
||||||
- автоматически при пуше тега `v*` (amd64 и arm64);
|
|
||||||
- вручную: Actions → **Build ISO image** → Run workflow.
|
|
||||||
|
|
||||||
Образы Ubuntu Server больше лимита GitHub Releases (2 GB на файл), поэтому ISO публикуются как **artifacts** workflow и хранятся 30 дней.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ограничения
|
## Ограничения
|
||||||
|
|
||||||
- База образа — Ubuntu Server 26.04 LTS (или 24.04 LTS через `--ubuntu 24.04`).
|
- База — Ubuntu Server 26.04 LTS или 24.04 LTS (`--ubuntu 24.04`).
|
||||||
- Для первой загрузки нужен интернет: пакеты KVM/libvirt/Cockpit ставятся из репозиториев Ubuntu. Python-зависимости панели уже лежат в образе.
|
- Для первой загрузки нужен интернет: пакеты KVM/libvirt ставятся из репозиториев Ubuntu. Панель и её Python-зависимости уже в образе.
|
||||||
- ARM64-образ рассчитан на серверы с UEFI. Raspberry Pi и Orange Pi 5 грузятся иначе — для них используйте их штатный образ Ubuntu Server и `install.sh`.
|
- 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
|
Virtuality работает на:
|
||||||
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: один проводной интерфейс
|
|
||||||
```
|
|
||||||
|
|
||||||
## Рекомендуемые требования
|
- **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-прав;
|
**Виртуализация должна быть включена в BIOS/UEFI** (Intel VT-x / Virtualization Technology, AMD-V / SVM Mode). Без неё машины будут работать через медленную программную эмуляцию; панель предупредит об этом в разделе «Сервер», а `sudo vhealth` покажет ошибку `/dev/kvm missing`.
|
||||||
- наличие `apt`;
|
|
||||||
- свободное место на `/`;
|
|
||||||
- свободное место для `/var/lib`;
|
|
||||||
- объём RAM;
|
|
||||||
- количество CPU cores;
|
|
||||||
- наличие флага аппаратной виртуализации `vmx` или `svm`.
|
|
||||||
|
|
||||||
## Значения по умолчанию
|
Внутри другой виртуальной машины или на 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
|
```text
|
||||||
VIRTUALITY_MIN_ROOT_FREE_MB=8192
|
VIRTUALITY_MIN_ROOT_FREE_MB=8192
|
||||||
VIRTUALITY_MIN_VAR_FREE_MB=20480
|
VIRTUALITY_MIN_VAR_FREE_MB=20480
|
||||||
VIRTUALITY_MIN_RAM_MB=4096
|
VIRTUALITY_MIN_RAM_MB=4096
|
||||||
VIRTUALITY_MIN_CPU_CORES=2
|
VIRTUALITY_MIN_CPU_CORES=2
|
||||||
|
VIRTUALITY_SKIP_REQUIREMENTS=1 # отключить проверки совсем — только для тестов
|
||||||
```
|
```
|
||||||
|
|
||||||
## Переопределение требований
|
Например, для тестового стенда с маленьким диском:
|
||||||
|
|
||||||
Для тестового стенда можно временно уменьшить требования:
|
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
df -h
|
df -h
|
||||||
df -i
|
sudo apt clean && sudo apt autoremove -y
|
||||||
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 journalctl --vacuum-time=3d
|
sudo journalctl --vacuum-time=3d
|
||||||
```
|
```
|
||||||
|
|
||||||
## Где Virtuality хранит данные
|
## Браузер
|
||||||
|
|
||||||
```text
|
Панель работает в актуальных Chrome, Firefox, Safari и Edge, в том числе на телефоне. Экран машины (noVNC) требует поддержки WebSocket — она есть во всех перечисленных. При включённом HTTPS сертификат выпускается самим сервером, поэтому браузер один раз спросит, доверять ли ему.
|
||||||
/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 # логи установки и диагностики
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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** — вместо самоподписанного, для доступа к панели по доменному имени.
|
||||||
|
- **История нагрузки** — графики процессора, памяти и диска за день и неделю по серверу и машинам.
|
||||||
|
- **Проверка обновлений образов каталога** — предлагать обновить скачанный облачный образ, когда вышел новый.
|
||||||
|
|
||||||
Цель: начать собственную панель управления.
|
## Не планируется
|
||||||
|
|
||||||
Задачи:
|
- Многосерверные кластеры с общим хранилищем, Ceph, живая миграция — это уровень Proxmox для дата-центров, не для дома.
|
||||||
|
- Поддержка других гипервизоров, кроме KVM/QEMU/libvirt.
|
||||||
- лёгкая веб-панель;
|
- Гости x86_64 на ARM64-серверах через эмуляцию — слишком медленно, чтобы этим пользоваться.
|
||||||
- авторизация;
|
|
||||||
- список виртуальных машин;
|
|
||||||
- статус 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;
|
|
||||||
- базовая безопасность;
|
|
||||||
- документация;
|
|
||||||
- обновлятор.
|
|
||||||
|
|||||||
|
После Ширина: | Высота: | Размер: 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"
|
HOSTNAME_VALUE="virtuality"
|
||||||
LOCALE="en_US.UTF-8"
|
LOCALE="en_US.UTF-8"
|
||||||
KEYBOARD="us"
|
KEYBOARD="us"
|
||||||
TIMEZONE="Etc/UTC"
|
TIMEZONE="geoip"
|
||||||
WITH_WHEELS="1"
|
WITH_WHEELS="1"
|
||||||
TARGET_PYTHON=""
|
TARGET_PYTHON=""
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ Options:
|
|||||||
--no-auto-update Disable the nightly GitHub auto-update on installed nodes
|
--no-auto-update Disable the nightly GitHub auto-update on installed nodes
|
||||||
--locale LOCALE Installer/system locale (default: ${LOCALE})
|
--locale LOCALE Installer/system locale (default: ${LOCALE})
|
||||||
--keyboard LAYOUT Keyboard layout (default: ${KEYBOARD})
|
--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)
|
--no-wheels Do not bundle Python wheels (first boot downloads them from PyPI)
|
||||||
|
|
||||||
Unattended mode (no questions at all, ERASES THE LARGEST DISK):
|
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
|
for glibc_minor in 17 28 31 34 35 39; do
|
||||||
platform_args+=(--platform "manylinux_2_${glibc_minor}_${PY_PLATFORM_ARCH}")
|
platform_args+=(--platform "manylinux_2_${glibc_minor}_${PY_PLATFORM_ARCH}")
|
||||||
done
|
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 \
|
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" \
|
--only-binary=:all: --implementation cp --python-version "$TARGET_PYTHON" \
|
||||||
--abi "cp${TARGET_PYTHON/./}" --abi abi3 --abi none "${platform_args[@]}"
|
--abi "cp${TARGET_PYTHON/./}" --abi abi3 --abi none "${platform_args[@]}"
|
||||||
fi
|
fi
|
||||||
@@ -216,20 +218,41 @@ import sys
|
|||||||
path, unattended, version = sys.argv[1], sys.argv[2] == "1", sys.argv[3]
|
path, unattended, version = sys.argv[1], sys.argv[2] == "1", sys.argv[3]
|
||||||
text = open(path).read()
|
text = open(path).read()
|
||||||
# /autoinstall.yaml applies to every entry, so all Ubuntu entries become Virtuality entries.
|
# /autoinstall.yaml applies to every entry, so all Ubuntu entries become Virtuality entries.
|
||||||
suffix = " (UNATTENDED: erases disk)" if unattended else ""
|
suffix = " — автоматически, диск будет стёрт" if unattended else ""
|
||||||
text = text.replace('menuentry "Try or Install Ubuntu Server"', 'menuentry "Install Virtuality %s%s"' % (version, suffix), 1)
|
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 "Install Virtuality %s with the HWE kernel%s"' % (version, suffix), 1)
|
text = text.replace('menuentry "Ubuntu Server with the HWE kernel"', 'menuentry "Установить Virtuality %s (новое ядро HWE)%s"' % (version, suffix), 1)
|
||||||
if "Install Virtuality" not in text:
|
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")
|
raise SystemExit("unexpected grub.cfg menu titles")
|
||||||
if unattended:
|
if unattended:
|
||||||
text = re.sub(r"^(\s*linux\s+\S+\s+)---", r"\1autoinstall ---", text, flags=re.M)
|
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)
|
text = re.sub(r"^set timeout=\d+\n", "", text, count=1, flags=re.M)
|
||||||
open(path, "w").write(text)
|
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
|
PYEOF
|
||||||
new_md5="$(md5sum "${WORK_DIR}/grub.cfg" | cut -d' ' -f1)"
|
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"
|
sed -i "s|^[0-9a-f]\{32\} \./boot/grub/grub.cfg$|${new_md5} ./boot/grub/grub.cfg|" "${WORK_DIR}/md5sum.txt"
|
||||||
|
|
||||||
# ---------------------------------------------------------- repack
|
# ---------------------------------------------------------- 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}"
|
say "Writing ${OUTPUT_ISO}"
|
||||||
rm -f "$OUTPUT_ISO"
|
rm -f "$OUTPUT_ISO"
|
||||||
chmod -R a+rX,go-w "$PAYLOAD"
|
chmod -R a+rX,go-w "$PAYLOAD"
|
||||||
@@ -238,6 +261,7 @@ if ! xorriso -indev "$UBUNTU_ISO" -outdev "$OUTPUT_ISO" \
|
|||||||
-map "$PAYLOAD" /virtuality \
|
-map "$PAYLOAD" /virtuality \
|
||||||
-map "${WORK_DIR}/autoinstall.yaml" /autoinstall.yaml \
|
-map "${WORK_DIR}/autoinstall.yaml" /autoinstall.yaml \
|
||||||
-map "${WORK_DIR}/grub.cfg" /boot/grub/grub.cfg \
|
-map "${WORK_DIR}/grub.cfg" /boot/grub/grub.cfg \
|
||||||
|
-map "${THEME_DIR}" /boot/grub/themes/virtuality \
|
||||||
-map "${WORK_DIR}/md5sum.txt" /md5sum.txt \
|
-map "${WORK_DIR}/md5sum.txt" /md5sum.txt \
|
||||||
-boot_image any replay > "${WORK_DIR}/xorriso.log" 2>&1; then
|
-boot_image any replay > "${WORK_DIR}/xorriso.log" 2>&1; then
|
||||||
tail -n 30 "${WORK_DIR}/xorriso.log" >&2
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
chmod +x "$tmp_installer"
|
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
|
fi
|
||||||
echo "Root or sudo is required. Run: curl -fsSL $SELF_INSTALL_URL | sudo bash" >&2
|
echo "Root or sudo is required. Run: curl -fsSL $SELF_INSTALL_URL | sudo bash" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -32,9 +32,7 @@ DEFAULT_INSTALL_USER="${SUDO_USER:-root}"
|
|||||||
INSTALL_USER="${VIRTUALITY_USER:-$DEFAULT_INSTALL_USER}"
|
INSTALL_USER="${VIRTUALITY_USER:-$DEFAULT_INSTALL_USER}"
|
||||||
PROJECT_BASE_DIR="${VIRTUALITY_PROJECT_BASE_DIR:-/opt/virtuality}"
|
PROJECT_BASE_DIR="${VIRTUALITY_PROJECT_BASE_DIR:-/opt/virtuality}"
|
||||||
PROJECT_DIR="${VIRTUALITY_PROJECT_DIR:-${PROJECT_BASE_DIR}/source}"
|
PROJECT_DIR="${VIRTUALITY_PROJECT_DIR:-${PROJECT_BASE_DIR}/source}"
|
||||||
RUN_BRIDGE="${VIRTUALITY_SETUP_BRIDGE:-0}"
|
BRANCH="${VIRTUALITY_BRANCH:-main}"
|
||||||
BRIDGE_IFACE="${VIRTUALITY_BRIDGE_IFACE:-}"
|
|
||||||
RUN_TEST_VM="${VIRTUALITY_CREATE_TEST_VM:-0}"
|
|
||||||
WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
||||||
LOG_DIR="/var/log/virtuality"
|
LOG_DIR="/var/log/virtuality"
|
||||||
LOG_FILE="${LOG_DIR}/install_$(date +%Y%m%d_%H%M%S).log"
|
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}"
|
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
|
||||||
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
|
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
|
||||||
INSTALL_VERBOSE="${VIRTUALITY_INSTALL_VERBOSE:-0}"
|
INSTALL_VERBOSE="${VIRTUALITY_INSTALL_VERBOSE:-0}"
|
||||||
CLEAN_BEFORE_INSTALL="${VIRTUALITY_CLEAN_BEFORE_INSTALL:-0}"
|
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
ESC="\033"
|
ESC="\033"
|
||||||
@@ -118,7 +115,7 @@ header() {
|
|||||||
clear 2>/dev/null || true
|
clear 2>/dev/null || true
|
||||||
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
||||||
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Installer${RESET} ${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 -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||||
echo
|
echo
|
||||||
echo -e "${GRAY}Repository:${RESET} ${REPO_URL}"
|
echo -e "${GRAY}Repository:${RESET} ${REPO_URL}"
|
||||||
@@ -251,13 +248,6 @@ ensure_user
|
|||||||
step "Готовим рабочую директорию в /opt"
|
step "Готовим рабочую директорию в /opt"
|
||||||
prepare_project_dir
|
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 "Проверяем место после подготовки пользователя"
|
step "Проверяем место после подготовки пользователя"
|
||||||
root_free_after="$(free_mb_for_path /)"
|
root_free_after="$(free_mb_for_path /)"
|
||||||
if (( root_free_after < MIN_ROOT_FREE_MB )); then
|
if (( root_free_after < MIN_ROOT_FREE_MB )); then
|
||||||
@@ -267,14 +257,9 @@ ok "Место после подготовки пользователя: ${root_
|
|||||||
|
|
||||||
step "Клонируем или обновляем репозиторий"
|
step "Клонируем или обновляем репозиторий"
|
||||||
if [[ -d "$PROJECT_DIR/.git" ]]; then
|
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
|
else
|
||||||
run_logged "Репозиторий склонирован" run_as_user "cd '$PROJECT_BASE_DIR' && git clone '$REPO_URL' source"
|
run_logged "Репозиторий склонирован (${BRANCH})" run_as_user "cd '$PROJECT_BASE_DIR' && git clone --branch '$BRANCH' '$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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
step "Запускаем основной установщик ноды"
|
step "Запускаем основной установщик ноды"
|
||||||
@@ -283,9 +268,6 @@ run_logged "Virtuality Node установлен" env VIRTUALITY_USER="$INSTALL_
|
|||||||
step "Устанавливаем healthcheck-команду"
|
step "Устанавливаем healthcheck-команду"
|
||||||
run_logged "Healthcheck установлен" bash "$PROJECT_DIR/scripts/install_healthcheck_command.sh"
|
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-панель"
|
step "Устанавливаем web-панель"
|
||||||
if [[ "$INSTALL_VERBOSE" == "1" ]]; then
|
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"
|
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-панель не установлена"
|
[[ "$web_code" -eq 0 ]] && ok "Web-панель установлена" || fail "Web-панель не установлена"
|
||||||
fi
|
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 "Финальная диагностика"
|
step "Финальная диагностика"
|
||||||
if command -v vhealth >/dev/null 2>&1; then
|
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} ${BOLD}Virtuality установка завершена${RESET} ${GREEN}${BOLD}│${RESET}"
|
||||||
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||||
echo
|
echo
|
||||||
echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:9090"
|
echo -e "${BOLD}Панель:${RESET} http://${SERVER_IP}:${WEB_PORT}"
|
||||||
echo -e "${BOLD}Web Panel:${RESET} http://${SERVER_IP}:${WEB_PORT}"
|
echo -e "${BOLD}Логин:${RESET} ${INSTALL_USER} / пароль пользователя Linux"
|
||||||
echo -e "${BOLD}Login:${RESET} ${INSTALL_USER} / пароль Linux-пользователя"
|
echo -e "${BOLD}Исходники:${RESET} ${PROJECT_DIR}"
|
||||||
echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
|
echo -e "${BOLD}Лог:${RESET} ${LOG_FILE}"
|
||||||
echo -e "${BOLD}Log:${RESET} ${LOG_FILE}"
|
|
||||||
echo
|
echo
|
||||||
echo -e "${BOLD}После установки:${RESET}"
|
echo -e "${BOLD}Дальше:${RESET} откройте панель в браузере — мастер настройки поможет включить HTTPS,"
|
||||||
echo " passwd ${INSTALL_USER} # если пароль ещё не задан или заблокирован"
|
echo "выбрать сеть для машин (NAT или ваша локальная сеть) и хранилище."
|
||||||
echo " sudo vhealth"
|
echo "Если пароль ещё не задан: passwd ${INSTALL_USER}"
|
||||||
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
|
echo
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
# Virtuality Node Installer
|
# 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"
|
PROJECT_NAME="Virtuality"
|
||||||
@@ -13,9 +13,8 @@ ISO_DIR="${STORAGE_DIR}/iso"
|
|||||||
IMAGES_DIR="${STORAGE_DIR}/images"
|
IMAGES_DIR="${STORAGE_DIR}/images"
|
||||||
BACKUP_DIR="${STORAGE_DIR}/backups"
|
BACKUP_DIR="${STORAGE_DIR}/backups"
|
||||||
LOG_DIR="/var/log/virtuality"
|
LOG_DIR="/var/log/virtuality"
|
||||||
COCKPIT_PORT="9090"
|
|
||||||
LOG_FILE="${LOG_DIR}/install_node_$(date +%Y%m%d_%H%M%S).log"
|
LOG_FILE="${LOG_DIR}/install_node_$(date +%Y%m%d_%H%M%S).log"
|
||||||
TOTAL_STEPS=13
|
TOTAL_STEPS=12
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
MIN_ROOT_FREE_MB="${VIRTUALITY_MIN_ROOT_FREE_MB:-8192}"
|
MIN_ROOT_FREE_MB="${VIRTUALITY_MIN_ROOT_FREE_MB:-8192}"
|
||||||
MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}"
|
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}"
|
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
|
||||||
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
|
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
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"
|
ESC="\033"
|
||||||
RESET="${ESC}[0m"
|
RESET="${ESC}[0m"
|
||||||
@@ -45,12 +46,11 @@ print_header() {
|
|||||||
clear 2>/dev/null || true
|
clear 2>/dev/null || true
|
||||||
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
|
||||||
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Node Installer${RESET} ${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 -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||||
echo
|
echo
|
||||||
echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}"
|
echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}"
|
||||||
echo -e "${GRAY}Storage:${RESET} ${STORAGE_DIR}"
|
echo -e "${GRAY}Storage:${RESET} ${STORAGE_DIR}"
|
||||||
echo -e "${GRAY}Cockpit:${RESET} ${COCKPIT_PORT}/tcp"
|
|
||||||
echo
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,15 +76,18 @@ run_logged() {
|
|||||||
spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
|
||||||
i=0
|
i=0
|
||||||
while kill -0 "$pid" 2>/dev/null; do
|
while kill -0 "$pid" 2>/dev/null; do
|
||||||
|
# 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 ))
|
elapsed=$(( $(date +%s) - start_ts ))
|
||||||
symbol="${spinner[$((i % ${#spinner[@]}))]}"
|
symbol="${spinner[$((i % ${#spinner[@]}))]}"
|
||||||
printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$description" "$elapsed" "$LOG_FILE"
|
printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$description" "$elapsed" "$LOG_FILE"
|
||||||
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
i=$((i + 1))
|
i=$((i + 1))
|
||||||
done
|
done
|
||||||
wait "$pid"
|
code=0
|
||||||
code=$?
|
wait "$pid" || code=$?
|
||||||
printf "\r%*s\r" 120 ""
|
[[ -t 1 ]] && printf "\r%*s\r" 120 ""
|
||||||
if [[ "$code" -eq 0 ]]; then
|
if [[ "$code" -eq 0 ]]; then
|
||||||
ok "$description"
|
ok "$description"
|
||||||
else
|
else
|
||||||
@@ -105,7 +108,7 @@ install_available() {
|
|||||||
warn "Пакет ${pkg} недоступен в этом дистрибутиве, пропускаю"
|
warn "Пакет ${pkg} недоступен в этом дистрибутиве, пропускаю"
|
||||||
fi
|
fi
|
||||||
done
|
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"; }
|
service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
|
||||||
@@ -166,7 +169,9 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
step "Обновляем apt cache"
|
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 "Устанавливаем базовые утилиты и зависимости диагностики"
|
step "Устанавливаем базовые утилиты и зависимости диагностики"
|
||||||
install_available "Базовые пакеты и диагностические утилиты установлены" \
|
install_available "Базовые пакеты и диагностические утилиты установлены" \
|
||||||
@@ -180,11 +185,11 @@ case "$(uname -m)" in
|
|||||||
esac
|
esac
|
||||||
install_available "Пакеты виртуализации установлены" \
|
install_available "Пакеты виртуализации установлены" \
|
||||||
"${QEMU_SYSTEM_PACKAGES[@]}" qemu-utils libvirt-daemon-system libvirt-clients virtinst \
|
"${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 и модули"
|
step "Устанавливаем зависимости панели управления"
|
||||||
install_available "Cockpit установлен" \
|
install_available "Пакеты панели установлены" \
|
||||||
cockpit cockpit-machines cockpit-networkmanager cockpit-storaged cockpit-packagekit
|
python3 python3-venv rsync openssl nftables novnc python3-websockify
|
||||||
|
|
||||||
step "Создаём структуру Virtuality"
|
step "Создаём структуру Virtuality"
|
||||||
run_logged "Директории Virtuality созданы" mkdir -p "$PROJECT_DIR" "$STORAGE_DIR" "$ISO_DIR" "$IMAGES_DIR" "$BACKUP_DIR" "$LOG_DIR"
|
run_logged "Директории Virtuality созданы" mkdir -p "$PROJECT_DIR" "$STORAGE_DIR" "$ISO_DIR" "$IMAGES_DIR" "$BACKUP_DIR" "$LOG_DIR"
|
||||||
@@ -208,15 +213,13 @@ fi
|
|||||||
step "Включаем systemd-сервисы"
|
step "Включаем systemd-сервисы"
|
||||||
run_logged "libvirtd включён и запущен" systemctl enable --now libvirtd
|
run_logged "libvirtd включён и запущен" systemctl enable --now libvirtd
|
||||||
run_logged "virtlogd включён и запущен" systemctl enable --now virtlogd
|
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 "libvirtd: $(service_state libvirtd) / $(service_enabled libvirtd)"
|
||||||
ok "virtlogd: $(service_state virtlogd) / $(service_enabled virtlogd)"
|
ok "virtlogd: $(service_state virtlogd) / $(service_enabled virtlogd)"
|
||||||
ok "cockpit.socket: $(service_state cockpit.socket) / $(service_enabled cockpit.socket)"
|
|
||||||
|
|
||||||
step "Настраиваем firewall"
|
step "Настраиваем firewall"
|
||||||
run_logged "Разрешён OpenSSH" ufw allow OpenSSH
|
run_logged "Разрешён OpenSSH" ufw allow OpenSSH
|
||||||
run_logged "Разрешён Cockpit ${COCKPIT_PORT}/tcp" ufw allow "${COCKPIT_PORT}/tcp"
|
# Consoles are proxied by the panel; VNC ports stay closed from outside.
|
||||||
run_logged "Разрешены VNC-порты 5900:5999/tcp" ufw allow 5900:5999/tcp
|
ufw delete allow 5900:5999/tcp >> "$LOG_FILE" 2>&1 || true
|
||||||
if ufw status | grep -qi inactive; then
|
if ufw status | grep -qi inactive; then
|
||||||
echo "y" | ufw enable >> "$LOG_FILE" 2>&1 && ok "UFW включён" || warn "Не удалось включить UFW"
|
echo "y" | ufw enable >> "$LOG_FILE" 2>&1 && ok "UFW включён" || warn "Не удалось включить UFW"
|
||||||
else
|
else
|
||||||
@@ -243,7 +246,6 @@ ISO_DIR="${ISO_DIR}"
|
|||||||
IMAGES_DIR="${IMAGES_DIR}"
|
IMAGES_DIR="${IMAGES_DIR}"
|
||||||
BACKUP_DIR="${BACKUP_DIR}"
|
BACKUP_DIR="${BACKUP_DIR}"
|
||||||
LOG_DIR="${LOG_DIR}"
|
LOG_DIR="${LOG_DIR}"
|
||||||
COCKPIT_PORT="${COCKPIT_PORT}"
|
|
||||||
EOF
|
EOF
|
||||||
ok "Создан ${PROJECT_DIR}/virtuality.env"
|
ok "Создан ${PROJECT_DIR}/virtuality.env"
|
||||||
|
|
||||||
@@ -254,11 +256,6 @@ if command -v virsh >/dev/null 2>&1; then
|
|||||||
else
|
else
|
||||||
warn "virsh не найден после установки"
|
warn "virsh не найден после установки"
|
||||||
fi
|
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
|
if [[ -e /dev/kvm ]]; then
|
||||||
ok "/dev/kvm доступен"
|
ok "/dev/kvm доступен"
|
||||||
else
|
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} ${BOLD}Установка Virtuality Node завершена${RESET} ${GREEN}${BOLD}│${RESET}"
|
||||||
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
|
||||||
echo
|
echo
|
||||||
echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:${COCKPIT_PORT}"
|
|
||||||
echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
|
echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
|
||||||
echo -e "${BOLD}ISO:${RESET} ${ISO_DIR}"
|
echo -e "${BOLD}ISO:${RESET} ${ISO_DIR}"
|
||||||
echo -e "${BOLD}Images:${RESET} ${IMAGES_DIR}"
|
echo -e "${BOLD}Images:${RESET} ${IMAGES_DIR}"
|
||||||
@@ -283,9 +279,7 @@ echo -e "${BOLD}Проверка:${RESET}"
|
|||||||
echo " virsh list --all"
|
echo " virsh list --all"
|
||||||
echo " virsh pool-list --all"
|
echo " virsh pool-list --all"
|
||||||
echo " systemctl status libvirtd --no-pager"
|
echo " systemctl status libvirtd --no-pager"
|
||||||
echo " systemctl status cockpit.socket --no-pager"
|
|
||||||
echo
|
echo
|
||||||
line
|
line
|
||||||
echo -e "${DIM}Следующий шаг: sudo bash scripts/setup_bridge_br0.sh enp2s0 static${RESET}"
|
echo -e "${DIM}Дальше: sudo bash scripts/install_web_panel.sh — панель управления и мастер настройки${RESET}"
|
||||||
echo -e "${DIM}После bridge: sudo bash scripts/create_test_vm.sh${RESET}"
|
|
||||||
line
|
line
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ STATE_FILE="${STATE_DIR}/state.json"
|
|||||||
LOG_FILE="/var/log/virtuality/update.log"
|
LOG_FILE="/var/log/virtuality/update.log"
|
||||||
REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}"
|
REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}"
|
||||||
BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}"
|
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"
|
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")"
|
mkdir -p "$STATE_DIR" "$TMP_DIR" "$(dirname "$LOG_FILE")"
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ try_run() {
|
|||||||
|
|
||||||
fetch_zip_update() {
|
fetch_zip_update() {
|
||||||
local zip_file extract_dir extracted_root
|
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"
|
extract_dir="${TMP_DIR}/zip-update"
|
||||||
rm -rf "$zip_file" "$extract_dir"
|
rm -rf "$zip_file" "$extract_dir"
|
||||||
mkdir -p "$extract_dir"
|
mkdir -p "$extract_dir"
|
||||||
@@ -127,9 +130,10 @@ GIT_UPDATED=0
|
|||||||
if [ -d "$SOURCE_DIR/.git" ]; then
|
if [ -d "$SOURCE_DIR/.git" ]; then
|
||||||
cd "$SOURCE_DIR" || exit 1
|
cd "$SOURCE_DIR" || exit 1
|
||||||
write_state "running" "Получаем изменения из GitHub через git"
|
write_state "running" "Получаем изменения из GitHub через git"
|
||||||
if try_run git fetch "$REMOTE" "$BRANCH"; then
|
if try_run git fetch --tags --force "$REMOTE" "$BRANCH"; then
|
||||||
TARGET_REF="${REMOTE}/${BRANCH}"
|
resolve_update_target
|
||||||
TARGET_COMMIT="$(git rev-parse "$TARGET_REF" 2>/dev/null || true)"
|
log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}"
|
||||||
|
TARGET_COMMIT="$(git rev-parse "${TARGET_REF}^{commit}" 2>/dev/null || true)"
|
||||||
if [ -n "$TARGET_COMMIT" ]; then
|
if [ -n "$TARGET_COMMIT" ]; then
|
||||||
LOCAL_STATUS="$(git status --porcelain 2>/dev/null || true)"
|
LOCAL_STATUS="$(git status --porcelain 2>/dev/null || true)"
|
||||||
if [ -n "$LOCAL_STATUS" ]; then
|
if [ -n "$LOCAL_STATUS" ]; then
|
||||||
@@ -137,8 +141,8 @@ if [ -d "$SOURCE_DIR/.git" ]; then
|
|||||||
printf '%s\n' "$LOCAL_STATUS" >> "$LOG_FILE"
|
printf '%s\n' "$LOCAL_STATUS" >> "$LOG_FILE"
|
||||||
fi
|
fi
|
||||||
write_state "running" "Синхронизируем исходники с GitHub через git reset"
|
write_state "running" "Синхронизируем исходники с GitHub через git reset"
|
||||||
log "Deploy mode: git reset --hard $TARGET_REF"
|
log "Deploy mode: git checkout -B ${BRANCH} ${TARGET_LABEL}"
|
||||||
if try_run git reset --hard "$TARGET_REF" && try_run git clean -fd; then
|
if try_run git checkout -q -f -B "$BRANCH" "$TARGET_COMMIT" && try_run git clean -fd; then
|
||||||
GIT_UPDATED=1
|
GIT_UPDATED=1
|
||||||
else
|
else
|
||||||
log "git reset/clean failed, ZIP fallback will be used"
|
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}"
|
REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}"
|
||||||
BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}"
|
BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}"
|
||||||
APPLY_SCRIPT="${SOURCE_DIR}/scripts/apply_github_update.sh"
|
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")"
|
mkdir -p "$STATE_DIR" "$(dirname "$LOG_FILE")"
|
||||||
|
|
||||||
@@ -89,16 +90,18 @@ diag_network() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -d "$SOURCE_DIR/.git" ]; then
|
if [ -d "$SOURCE_DIR/.git" ]; then
|
||||||
if git fetch --quiet "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then
|
if git fetch --quiet --tags --force "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then
|
||||||
latest="$(git rev-parse "${REMOTE}/${BRANCH}" 2>/dev/null || true)"
|
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
|
if [ -n "$latest" ] && [ "$current" = "$latest" ]; then
|
||||||
log "no updates: ${current:0:12} / version $current_version"
|
log "no updates: ${current:0:12} / version $current_version"
|
||||||
write_state "idle" "Автообновление: новых обновлений нет"
|
write_state "idle" "Автообновление: новых обновлений нет"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ -n "$latest" ] && [ "$current" != "unknown" ] && ! git merge-base --is-ancestor "$current" "$latest" 2>/dev/null; then
|
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"
|
log "local build ${current:0:12} is not behind ${TARGET_LABEL} (${latest:0:12}); skip to avoid a downgrade"
|
||||||
write_state "idle" "Автообновление: установленная сборка новее или отличается от ${BRANCH}, обновление пропущено"
|
write_state "idle" "Автообновление: установленная версия новее ${TARGET_LABEL}, обновление пропущено"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
log "update may be available or current commit unknown: ${current:0:12} -> ${latest:0:12}"
|
log "update may be available or current commit unknown: ${current:0:12} -> ${latest:0:12}"
|
||||||
@@ -117,6 +120,6 @@ diag_network() {
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
write_state "running" "Автообновление: проверяем и устанавливаем обновление через устойчивый механизм"
|
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"
|
VIRTUALITY_SOURCE_DIR="$SOURCE_DIR" VIRTUALITY_UPDATE_REMOTE="$REMOTE" VIRTUALITY_UPDATE_BRANCH="$BRANCH" bash "$APPLY_SCRIPT"
|
||||||
) 9>"$LOCK_FILE"
|
) 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
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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)"
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
WEB_DIR="${REPO_DIR}/web"
|
WEB_DIR="${REPO_DIR}/web"
|
||||||
APP_DIR="/opt/virtuality/web"
|
APP_DIR="/opt/virtuality/web"
|
||||||
VENV_DIR="/opt/virtuality/venv"
|
VENV_DIR="/opt/virtuality/venv"
|
||||||
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
|
CTL_SRC="${REPO_DIR}/scripts/virtuality-ctl"
|
||||||
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
|
CTL_DST="/usr/local/bin/virtuality-ctl"
|
||||||
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
|
|
||||||
NETWORK_SERVICE_FILE="/etc/systemd/system/virtuality-network.service"
|
|
||||||
LOG_DIR="/var/log/virtuality"
|
LOG_DIR="/var/log/virtuality"
|
||||||
LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log"
|
LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log"
|
||||||
PROFILE_DIR="/var/lib/virtuality/config"
|
CONFIG_DIR="/var/lib/virtuality/config"
|
||||||
PROFILE_FILE="${PROFILE_DIR}/host_profile.json"
|
PROFILE_FILE="${CONFIG_DIR}/host_profile.json"
|
||||||
SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret"
|
SESSION_SECRET_FILE="${CONFIG_DIR}/session_secret"
|
||||||
NODE_CONFIG_FILE="${PROFILE_DIR}/web.env"
|
NODE_CONFIG_FILE="${CONFIG_DIR}/web.env"
|
||||||
|
SETUP_STATE_FILE="${CONFIG_DIR}/setup.json"
|
||||||
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
|
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
|
||||||
WHEELS_DIR="${VIRTUALITY_WHEELS_DIR:-${REPO_DIR}/wheels}"
|
WHEELS_DIR="${VIRTUALITY_WHEELS_DIR:-${REPO_DIR}/wheels}"
|
||||||
TOTAL_STEPS=13
|
PANEL_ONLY="${VIRTUALITY_PANEL_ONLY:-0}"
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
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() {
|
saved_setting() {
|
||||||
local key="$1"
|
local key="$1"
|
||||||
[[ -f "$NODE_CONFIG_FILE" ]] || return 0
|
[[ -f "$NODE_CONFIG_FILE" ]] || return 0
|
||||||
sed -n "s/^${key}=//p" "$NODE_CONFIG_FILE" | tail -n1 | tr -d "\"'"
|
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() {
|
first_human_user() {
|
||||||
getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $7 !~ /(nologin|false)$/ {print $1; exit}'
|
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="${VIRTUALITY_WEB_PORT:-$(saved_setting VIRTUALITY_WEB_PORT)}"
|
||||||
PORT="${PORT:-$(legacy_port)}"
|
|
||||||
PORT="${PORT:-8088}"
|
PORT="${PORT:-8088}"
|
||||||
AUTH_USER="${VIRTUALITY_AUTH_USER:-$(saved_setting VIRTUALITY_AUTH_USER)}"
|
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:-${SUDO_USER:-}}"
|
||||||
AUTH_USER="${AUTH_USER:-$(first_human_user)}"
|
AUTH_USER="${AUTH_USER:-$(first_human_user)}"
|
||||||
AUTH_USER="${AUTH_USER:-root}"
|
AUTH_USER="${AUTH_USER:-root}"
|
||||||
AUTO_UPDATE="${VIRTUALITY_AUTO_UPDATE:-$(saved_setting VIRTUALITY_AUTO_UPDATE)}"
|
AUTO_UPDATE="${VIRTUALITY_AUTO_UPDATE:-$(saved_setting VIRTUALITY_AUTO_UPDATE)}"
|
||||||
AUTO_UPDATE="${AUTO_UPDATE:-1}"
|
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
|
CURRENT_STEP=0
|
||||||
|
TOTAL_STEPS=9
|
||||||
|
|
||||||
ESC="\033"
|
ESC="\033"
|
||||||
RESET="${ESC}[0m"
|
RESET="${ESC}[0m"
|
||||||
@@ -70,36 +72,22 @@ CYAN="${ESC}[36m"
|
|||||||
BLUE="${ESC}[34m"
|
BLUE="${ESC}[34m"
|
||||||
GRAY="${ESC}[90m"
|
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"
|
chmod 1777 "$UPLOAD_TMP_DIR"
|
||||||
|
|
||||||
timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
|
timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||||
line() { printf '%*s\n' 72 '' | tr ' ' '─'; }
|
line() { printf '%*s\n' 72 '' | tr ' ' '─'; }
|
||||||
log() { echo "[$(timestamp)] $*" >> "$LOG_FILE"; }
|
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() {
|
step() {
|
||||||
CURRENT_STEP=$((CURRENT_STEP + 1))
|
CURRENT_STEP=$((CURRENT_STEP + 1))
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}${BOLD}[${CURRENT_STEP}/${TOTAL_STEPS}]${RESET} ${BOLD}$*${RESET}"
|
echo -e "${BLUE}${BOLD}[${CURRENT_STEP}/${TOTAL_STEPS}]${RESET} ${BOLD}$*${RESET}"
|
||||||
log "STEP ${CURRENT_STEP}/${TOTAL_STEPS}: $*"
|
log "STEP ${CURRENT_STEP}/${TOTAL_STEPS}: $*"
|
||||||
}
|
}
|
||||||
|
|
||||||
ok() { echo -e " ${GREEN}✓${RESET} $*"; log "OK: $*"; }
|
ok() { echo -e " ${GREEN}✓${RESET} $*"; log "OK: $*"; }
|
||||||
warn() { echo -e " ${YELLOW}!${RESET} $*"; log "WARN: $*"; }
|
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() {
|
run_logged() {
|
||||||
local description="$1"
|
local description="$1"
|
||||||
@@ -112,282 +100,149 @@ run_logged() {
|
|||||||
fi
|
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; }
|
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 "Проверяем права и структуру проекта"
|
step "Проверяем права и исходники"
|
||||||
require_root
|
[[ "$EUID" -eq 0 ]] || fail "Запустите через sudo: sudo bash scripts/install_web_panel.sh"
|
||||||
ok "Права root подтверждены"
|
[[ -f "$WEB_DIR/app.py" && -f "$WEB_DIR/requirements.txt" ]] || fail "Не найдены исходники панели в ${WEB_DIR}"
|
||||||
[[ -d "$WEB_DIR" ]] || fail "Не найдена директория web: $WEB_DIR"
|
[[ -f "$CTL_SRC" ]] || fail "Не найден ${CTL_SRC}"
|
||||||
ok "Исходники web найдены: $WEB_DIR"
|
for cmd in systemctl rsync python3 hostname openssl; do
|
||||||
[[ -f "$WEB_DIR/app.py" ]] || fail "Не найден web/app.py"
|
command -v "$cmd" >/dev/null 2>&1 || fail "Не найдена команда ${cmd}"
|
||||||
ok "FastAPI entrypoint найден: web/app.py"
|
done
|
||||||
[[ -f "$WEB_DIR/requirements.txt" ]] || fail "Не найден web/requirements.txt"
|
ok "Исходники: ${WEB_DIR}"
|
||||||
ok "requirements.txt найден"
|
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
|
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
|
else
|
||||||
bash "${REPO_DIR}/scripts/detect_host_profile.sh" >>"$LOG_FILE" 2>&1 || true
|
bash "${REPO_DIR}/scripts/detect_host_profile.sh" >>"$LOG_FILE" 2>&1 || true
|
||||||
PROFILE="$(json_value profile)"
|
PROFILE="$(json_value profile)"
|
||||||
fi
|
fi
|
||||||
[[ -n "$PROFILE" ]] || PROFILE="x86_64"
|
ok "Профиль: $(json_value label)${PROFILE:+ (${PROFILE})}"
|
||||||
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"
|
|
||||||
|
|
||||||
step "Проверяем Linux-пользователя для входа"
|
step "Системные пакеты"
|
||||||
if id "$AUTH_USER" >/dev/null 2>&1; then
|
if [[ "$PANEL_ONLY" == "1" ]]; then
|
||||||
ok "Пользователь найден: $AUTH_USER"
|
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
|
else
|
||||||
fail "Пользователь $AUTH_USER не найден. Создай его или передай VIRTUALITY_AUTH_USER=username"
|
warn "apt не найден — пакеты не устанавливались"
|
||||||
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"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
step "Проверяем базовые команды"
|
step "Копируем панель в ${APP_DIR}"
|
||||||
for cmd in apt systemctl rsync python3 hostname openssl; do
|
run_logged "Новая версия скопирована" rsync -a --delete --exclude='__pycache__/' --exclude='.env' "$WEB_DIR/" "${APP_DIR}.new/"
|
||||||
if command -v "$cmd" >/dev/null 2>&1; then
|
install -m 0644 "${REPO_DIR}/VERSION" "${APP_DIR}.new/VERSION"
|
||||||
ok "$cmd найден: $(command -v "$cmd")"
|
FRESH_INSTALL=1
|
||||||
else
|
if [[ -d "$APP_DIR" ]]; then
|
||||||
fail "$cmd не найден"
|
FRESH_INSTALL=0
|
||||||
fi
|
rm -rf "${APP_DIR}.prev"
|
||||||
done
|
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"
|
step "Python-окружение"
|
||||||
run_logged "apt update выполнен" apt update
|
if [[ -x "$VENV_DIR/bin/python" ]] && "$VENV_DIR/bin/python" -c 'import sys' >/dev/null 2>&1; then
|
||||||
|
ok "Virtualenv уже есть: ${VENV_DIR}"
|
||||||
step "Устанавливаем системные зависимости под выбранную сборку"
|
else
|
||||||
COMMON_PACKAGES=(python3 python3-venv python3-pip rsync openssl curl wget unzip nftables novnc python3-websockify)
|
[[ -d "$VENV_DIR" ]] && { warn "Virtualenv повреждён (например, после обновления Python) — пересоздаём"; rm -rf -- "${VENV_DIR:?}"; }
|
||||||
X86_PACKAGES=(qemu-system-x86 qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
|
if python3 -c 'import ensurepip' >/dev/null 2>&1; then
|
||||||
ARM_PACKAGES=(qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
|
run_logged "Virtualenv создан" python3 -m venv "$VENV_DIR"
|
||||||
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)"
|
|
||||||
else
|
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
|
||||||
fi
|
fi
|
||||||
|
if ! "$VENV_DIR/bin/python" -m pip --version >/dev/null 2>&1; then
|
||||||
step "Копируем web-панель в /opt/virtuality"
|
PIP_WHEEL="$(ls "${WHEELS_DIR}"/pip-*.whl 2>/dev/null | head -n1 || true)"
|
||||||
run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality
|
[[ -n "$PIP_WHEEL" ]] || fail "В virtualenv нет pip и нет wheel pip в ${WHEELS_DIR}. Установите python3-venv: sudo apt install python3-venv"
|
||||||
run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete --exclude='__pycache__/' --exclude='.env' "$WEB_DIR/" "$APP_DIR/"
|
run_logged "pip установлен из ${PIP_WHEEL##*/}" "$VENV_DIR/bin/python" "${PIP_WHEEL}/pip" install --no-index --find-links "$WHEELS_DIR" pip
|
||||||
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-панелью при старте"
|
|
||||||
fi
|
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
|
if [[ -s "$SESSION_SECRET_FILE" ]]; then
|
||||||
SESSION_SECRET="$(cat "$SESSION_SECRET_FILE")"
|
SESSION_SECRET="$(cat "$SESSION_SECRET_FILE")"
|
||||||
ok "Используем существующий session secret: $SESSION_SECRET_FILE"
|
|
||||||
else
|
else
|
||||||
SESSION_SECRET="$(openssl rand -hex 32)"
|
SESSION_SECRET="$(openssl rand -hex 32)"
|
||||||
printf '%s\n' "$SESSION_SECRET" > "$SESSION_SECRET_FILE"
|
printf '%s\n' "$SESSION_SECRET" > "$SESSION_SECRET_FILE"
|
||||||
chmod 600 "$SESSION_SECRET_FILE"
|
chmod 600 "$SESSION_SECRET_FILE"
|
||||||
ok "Создан постоянный session secret: $SESSION_SECRET_FILE"
|
ok "Создан ключ сессий: ${SESSION_SECRET_FILE}"
|
||||||
fi
|
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
|
cat > "${APP_DIR}/.env" <<EOF
|
||||||
VIRTUALITY_AUTH_USER=${AUTH_USER}
|
VIRTUALITY_AUTH_USER=${AUTH_USER}
|
||||||
VIRTUALITY_SESSION_SECRET=${SESSION_SECRET}
|
VIRTUALITY_SESSION_SECRET=${SESSION_SECRET}
|
||||||
VIRTUALITY_COOKIE_SECURE=${COOKIE_SECURE}
|
VIRTUALITY_COOKIE_SECURE=$(saved_setting VIRTUALITY_COOKIE_SECURE)
|
||||||
TMPDIR=${UPLOAD_TMP_DIR}
|
TMPDIR=${UPLOAD_TMP_DIR}
|
||||||
TEMP=${UPLOAD_TMP_DIR}
|
TEMP=${UPLOAD_TMP_DIR}
|
||||||
TMP=${UPLOAD_TMP_DIR}
|
TMP=${UPLOAD_TMP_DIR}
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "${APP_DIR}/.env"
|
chmod 600 "${APP_DIR}/.env"
|
||||||
ok "Создан ${APP_DIR}/.env"
|
|
||||||
ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
|
|
||||||
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
|
|
||||||
|
|
||||||
step "Создаём Python virtualenv"
|
step "Устанавливаем virtuality-ctl"
|
||||||
if [[ -x "$VENV_DIR/bin/python" ]] && "$VENV_DIR/bin/python" -c 'import sys' >/dev/null 2>&1; then
|
install -m 0755 "$CTL_SRC" "$CTL_DST"
|
||||||
warn "Virtualenv уже существует, будет переиспользован: $VENV_DIR"
|
ok "${CTL_DST}"
|
||||||
else
|
|
||||||
if [[ -d "$VENV_DIR" ]]; then
|
|
||||||
warn "Virtualenv повреждён (например, после обновления Python), пересоздаём"
|
|
||||||
rm -rf -- "${VENV_DIR:?}"
|
|
||||||
fi
|
|
||||||
run_logged "Virtualenv создан: $VENV_DIR" python3 -m venv "$VENV_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
step "Устанавливаем Python-зависимости"
|
step "Службы, firewall и запуск"
|
||||||
if compgen -G "${WHEELS_DIR}/*.whl" >/dev/null; then
|
run_logged "Юниты созданы, панель запущена" env VIRTUALITY_SOURCE_DIR="$REPO_DIR" "$CTL_DST" reconfigure
|
||||||
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
|
|
||||||
sleep 2
|
sleep 2
|
||||||
STATE="$(service_state virtuality-web.service)"
|
if systemctl is-active --quiet virtuality-web.service; then
|
||||||
if [[ "$STATE" == "active" ]]; then
|
ok "virtuality-web.service работает"
|
||||||
ok "virtuality-web.service active"
|
|
||||||
else
|
else
|
||||||
warn "virtuality-web.service состояние: $STATE"
|
|
||||||
journalctl -u virtuality-web.service -n 40 --no-pager >> "$LOG_FILE" 2>&1 || true
|
journalctl -u virtuality-web.service -n 40 --no-pager >> "$LOG_FILE" 2>&1 || true
|
||||||
|
fail "virtuality-web.service не запустился"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
step "Настраиваем firewall и проверяем порт"
|
step "Мастер настройки"
|
||||||
if command -v ufw >/dev/null 2>&1; then
|
if [[ -f "$SETUP_STATE_FILE" ]]; then
|
||||||
ufw allow "${PORT}/tcp" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить UFW правило для ${PORT}/tcp"
|
ok "Состояние установки уже ведётся"
|
||||||
ok "UFW правило добавлено/проверено: ${PORT}/tcp"
|
elif [[ "${VIRTUALITY_FIRSTBOOT:-0}" == "1" ]]; then
|
||||||
|
ok "Прогресс пишет первая загрузка"
|
||||||
|
elif [[ "$FRESH_INSTALL" != "1" ]]; then
|
||||||
|
ok "Обновление уже настроенного сервера — мастер не нужен (его можно запустить в Настройках)"
|
||||||
else
|
else
|
||||||
warn "ufw не установлен, firewall-правило пропущено"
|
# A node installed by hand gets the same first-run wizard as one installed from the image.
|
||||||
fi
|
printf '{"stage": "done", "message": "", "steps": []}\n' > "$SETUP_STATE_FILE"
|
||||||
if command -v ss >/dev/null 2>&1 && ss -tulpn | grep -q ":${PORT}"; then
|
ok "Мастер настройки откроется при первом входе в панель"
|
||||||
ok "Порт ${PORT} слушается"
|
|
||||||
else
|
|
||||||
warn "Порт ${PORT} пока не найден в ss; проверь service logs"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SERVER_IP="$(hostname -I | awk '{print $1}')"
|
PANEL_URL="$("$CTL_DST" url 2>/dev/null || echo "http://$(hostname -I | awk '{print $1}'):${PORT}")"
|
||||||
|
|
||||||
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}"
|
|
||||||
echo
|
echo
|
||||||
|
echo -e "${GREEN}${BOLD}Панель управления установлена${RESET}"
|
||||||
line
|
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
|
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
|
set -euo pipefail
|
||||||
|
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
# Virtuality first boot setup
|
# Virtuality first boot
|
||||||
# Runs once on a system installed from a Virtuality image:
|
# Runs once on a system installed from the Virtuality image.
|
||||||
# installs KVM/libvirt/Cockpit, the web panel and helpers
|
# The web panel is installed first (offline, from bundled
|
||||||
# from the bundled source in /opt/virtuality/source.
|
# 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}"
|
SOURCE_DIR="${VIRTUALITY_SOURCE_DIR:-/opt/virtuality/source}"
|
||||||
IMAGE_ENV="/etc/virtuality/image.env"
|
IMAGE_ENV="${VIRTUALITY_IMAGE_ENV:-/etc/virtuality/image.env}"
|
||||||
STATE_DIR="/var/lib/virtuality"
|
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"
|
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"
|
LOG_FILE="${LOG_DIR}/firstboot.log"
|
||||||
APT_LOCK_CONF="/etc/apt/apt.conf.d/90virtuality-lock-timeout"
|
APT_LOCK_CONF="${VIRTUALITY_APT_LOCK_CONF:-/etc/apt/apt.conf.d/90virtuality-lock-timeout}"
|
||||||
ISSUE_FILE="/etc/issue.d/virtuality.issue"
|
ISSUE_FILE="${VIRTUALITY_ISSUE_FILE:-/etc/issue.d/virtuality.issue}"
|
||||||
NETWORK_WAIT_SECONDS="${VIRTUALITY_NETWORK_WAIT_SECONDS:-300}"
|
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
|
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||||
|
|
||||||
now() { date '+%Y-%m-%d %H:%M:%S'; }
|
now() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||||
say() { echo "[$(now)] [virtuality-firstboot] $*"; }
|
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
|
if [[ -f "$DONE_FLAG" ]]; then
|
||||||
say "already completed, nothing to do"
|
say "already completed, nothing to do"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -42,54 +137,103 @@ first_human_user() {
|
|||||||
|
|
||||||
AUTH_USER="${VIRTUALITY_USER:-$(first_human_user)}"
|
AUTH_USER="${VIRTUALITY_USER:-$(first_human_user)}"
|
||||||
AUTH_USER="${AUTH_USER:-root}"
|
AUTH_USER="${AUTH_USER:-root}"
|
||||||
WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
|
|
||||||
export VIRTUALITY_USER="$AUTH_USER"
|
export VIRTUALITY_USER="$AUTH_USER"
|
||||||
export VIRTUALITY_AUTH_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}"
|
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_SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-1}"
|
||||||
|
export VIRTUALITY_FIRSTBOOT=1
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
export TERM="${TERM:-dumb}"
|
export TERM="${TERM:-dumb}"
|
||||||
|
|
||||||
cat > "$ISSUE_FILE" <<'EOF'
|
for id in "${STEP_IDS[@]}"; do
|
||||||
Virtuality: first boot setup is running. Progress: sudo journalctl -fu virtuality-firstboot
|
step_done_before "$id" && STEP_STATUS[$id]="done"
|
||||||
|
done
|
||||||
|
|
||||||
EOF
|
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; }
|
||||||
say "source: $SOURCE_DIR"
|
cd "$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; }
|
|
||||||
|
|
||||||
# unattended-upgrades often holds the dpkg lock right after the first boot.
|
# 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)"
|
cat > "$ISSUE_FILE" <<'EOF'
|
||||||
deadline=$(( $(date +%s) + NETWORK_WAIT_SECONDS ))
|
Virtuality настраивается. Ход установки: sudo journalctl -fu virtuality-firstboot
|
||||||
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
|
|
||||||
|
|
||||||
EOF
|
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
|
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:
|
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": ""}
|
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):
|
def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
||||||
joined = " ".join(cmd)
|
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 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")
|
return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
|
||||||
if cmd[:2] == ["virsh", "dominfo"]:
|
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")
|
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"]:
|
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"]:
|
if cmd[:2] == ["virsh", "domstate"]:
|
||||||
return fake_result("running")
|
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"]:
|
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")
|
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"]:
|
if cmd[:2] == ["virsh", "domiflist"]:
|
||||||
@@ -57,6 +106,8 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
|||||||
return fake_result(":0")
|
return fake_result(":0")
|
||||||
if cmd[:2] == ["virsh", "pool-list"]:
|
if cmd[:2] == ["virsh", "pool-list"]:
|
||||||
return fake_result(" Name State Autostart\n---\n virtuality-images active yes")
|
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"]:
|
if cmd[:2] == ["virsh", "net-list"]:
|
||||||
return fake_result(" Name State Autostart Persistent\n---\n virtuality-nat active yes yes")
|
return fake_result(" Name State Autostart Persistent\n---\n virtuality-nat active yes yes")
|
||||||
if cmd[:2] == ["systemctl", "is-active"]:
|
if cmd[:2] == ["systemctl", "is-active"]:
|
||||||
@@ -72,9 +123,15 @@ def fake_run_cmd(cmd, timeout=12, **_kwargs):
|
|||||||
return fake_result()
|
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()
|
@pytest.fixture()
|
||||||
def data_dirs(tmp_path, monkeypatch):
|
def data_dirs(tmp_path, monkeypatch):
|
||||||
import app
|
import app
|
||||||
|
import core
|
||||||
import host_profile
|
import host_profile
|
||||||
import network_core
|
import network_core
|
||||||
import update_core
|
import update_core
|
||||||
@@ -83,6 +140,7 @@ def data_dirs(tmp_path, monkeypatch):
|
|||||||
"iso": tmp_path / "iso",
|
"iso": tmp_path / "iso",
|
||||||
"images": tmp_path / "images",
|
"images": tmp_path / "images",
|
||||||
"disk_images": tmp_path / "disk-images",
|
"disk_images": tmp_path / "disk-images",
|
||||||
|
"backups": tmp_path / "backups",
|
||||||
"operations": tmp_path / "operations",
|
"operations": tmp_path / "operations",
|
||||||
"network": tmp_path / "network",
|
"network": tmp_path / "network",
|
||||||
"config": tmp_path / "config",
|
"config": tmp_path / "config",
|
||||||
@@ -91,14 +149,19 @@ def data_dirs(tmp_path, monkeypatch):
|
|||||||
}
|
}
|
||||||
for path in dirs.values():
|
for path in dirs.values():
|
||||||
path.mkdir()
|
path.mkdir()
|
||||||
monkeypatch.setattr(app, "ISO_DIR", dirs["iso"])
|
for module in (app, core):
|
||||||
monkeypatch.setattr(app, "IMAGES_DIR", dirs["images"])
|
monkeypatch.setattr(module, "ISO_DIR", dirs["iso"])
|
||||||
monkeypatch.setattr(app, "DISK_IMAGES_DIR", dirs["disk_images"])
|
monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"])
|
||||||
monkeypatch.setattr(app, "OPERATIONS_DIR", dirs["operations"])
|
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, "CONFIG_DIR", dirs["config"])
|
||||||
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
|
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
|
||||||
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
|
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
|
||||||
monkeypatch.setattr(network_core, "PORT_FORWARDS_FILE", dirs["network"] / "port_forwards.json")
|
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, "NAT_XML_FILE", dirs["network"] / "virtuality-nat.xml")
|
||||||
monkeypatch.setattr(network_core, "NFT_FILE", dirs["nft"] / "virtuality.nft")
|
monkeypatch.setattr(network_core, "NFT_FILE", dirs["nft"] / "virtuality.nft")
|
||||||
monkeypatch.setattr(network_core, "enable_ip_forward", lambda: None)
|
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, "STATE_FILE", dirs["update"] / "state.json")
|
||||||
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
|
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
|
||||||
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
|
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)
|
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
python3 tests/dev_server.py [port] # login: tester / any password
|
python3 tests/dev_server.py [port] # login: tester / any password
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
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 conftest # noqa: E402 (sets sys.path and env)
|
||||||
|
|
||||||
import app # noqa: E402
|
import app # noqa: E402
|
||||||
|
import core # noqa: E402
|
||||||
import host_profile # noqa: E402
|
import host_profile # noqa: E402
|
||||||
import network_core # noqa: E402
|
import network_core # noqa: E402
|
||||||
import update_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:
|
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():
|
for path in dirs.values():
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
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"]
|
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.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.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.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml"
|
||||||
network_core.NFT_FILE = dirs["nft"] / "virtuality.nft"
|
network_core.NFT_FILE = dirs["nft"] / "virtuality.nft"
|
||||||
network_core.enable_ip_forward = lambda: None
|
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"
|
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.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]
|
update_core.SOURCE_DIR = Path(__file__).resolve().parents[1]
|
||||||
for module in (app, network_core, host_profile):
|
for module in (app, core, network_core, host_profile, *conftest.feature_modules()):
|
||||||
module.run_cmd = conftest.fake_run_cmd
|
module.run_cmd = demo_run_cmd
|
||||||
app.auth.verify_password = lambda user, password: True
|
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)):
|
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:
|
with (dirs["iso"] / name).open("wb") as handle:
|
||||||
@@ -49,10 +186,36 @@ def seed(root: Path) -> None:
|
|||||||
op_id = str(uuid.uuid4())
|
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.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 ...")
|
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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
seed(Path(tempfile.mkdtemp(prefix="virtuality-dev-")))
|
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")
|
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"]
|
cmd = captured["cmd"]
|
||||||
assert cmd[0] == "virt-install"
|
assert cmd[0] == "virt-install"
|
||||||
assert "--cdrom" in cmd and str(iso) in cmd
|
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"
|
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("-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)
|
git("reset", "-q", "--hard", "origin/main", cwd=local)
|
||||||
assert update_core.check_updates(fetch=True)["has_update"] is True
|
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": [
|
"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",
|
"version": "0.10.0",
|
||||||
"date": "2026-09-25",
|
"date": "2026-09-25",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -26,6 +27,8 @@ from itsdangerous import BadSignature, URLSafeSerializer, URLSafeTimedSerializer
|
|||||||
|
|
||||||
import auth
|
import auth
|
||||||
import host_profile
|
import host_profile
|
||||||
|
import catalog
|
||||||
|
import cloudinit
|
||||||
import network_core
|
import network_core
|
||||||
import presenters
|
import presenters
|
||||||
import update_core
|
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"))
|
update_operation(fresh, progress=new_progress, message=line.strip()[:240] or fresh.get("message"))
|
||||||
exit_code = process.wait()
|
exit_code = process.wait()
|
||||||
fresh = read_operation(operation_id) or operation
|
fresh = read_operation(operation_id) or operation
|
||||||
|
if fresh.get("seed_file"):
|
||||||
|
cloudinit.remove_seed(fresh["seed_file"])
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
append_operation_log(operation_id, "virt-install завершился успешно.")
|
append_operation_log(operation_id, "virt-install завершился успешно.")
|
||||||
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
|
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:
|
else:
|
||||||
append_operation_log(operation_id, f"virt-install завершился с ошибкой. Exit code: {exit_code}")
|
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())
|
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:
|
except Exception as exc:
|
||||||
fresh = read_operation(operation_id) or operation
|
fresh = read_operation(operation_id) or operation
|
||||||
@@ -399,8 +406,10 @@ def parse_virsh_list() -> list[dict[str, str]]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def ip_from_domifaddr(name: str) -> str:
|
def ip_from_domifaddr(name: str) -> str:
|
||||||
|
# DHCP lease first (NAT network), then the guest agent (machines in the home network via br0).
|
||||||
|
for extra in ([], ["--source", "agent"]):
|
||||||
try:
|
try:
|
||||||
result = run_cmd(["virsh", "domifaddr", name], timeout=8)
|
result = run_cmd(["virsh", "domifaddr", name, *extra], timeout=8)
|
||||||
if result.get("ok"):
|
if result.get("ok"):
|
||||||
for ip in re.findall(r"\b(\d{1,3}(?:\.\d{1,3}){3})/\d+", result.get("stdout", "")):
|
for ip in re.findall(r"\b(\d{1,3}(?:\.\d{1,3}){3})/\d+", result.get("stdout", "")):
|
||||||
ip = clean_ip(ip)
|
ip = clean_ip(ip)
|
||||||
@@ -460,11 +469,13 @@ def parse_virsh_list() -> list[dict[str, str]]:
|
|||||||
|
|
||||||
manual_ips = manual_ip_map()
|
manual_ips = manual_ip_map()
|
||||||
|
|
||||||
def resolve_ip(name: str) -> str:
|
def resolve_ip(name: str, state: str = "running") -> str:
|
||||||
if not name:
|
if not name:
|
||||||
return "—"
|
return "—"
|
||||||
if manual_ips.get(name):
|
if manual_ips.get(name):
|
||||||
return manual_ips[name]
|
return manual_ips[name]
|
||||||
|
if "running" not in state and "paus" not in state:
|
||||||
|
return "—"
|
||||||
macs = vm_macs(name)
|
macs = vm_macs(name)
|
||||||
for resolver in (lambda: ip_from_domifaddr(name), lambda: ip_from_network_core(name), lambda: ip_from_dnsmasq_leases(macs), lambda: ip_from_neighbor_tables(macs)):
|
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:
|
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"
|
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"]})
|
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:
|
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", ""), "")
|
row["manual_ip"] = manual_ips.get(row.get("name", ""), "")
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
@@ -560,26 +571,10 @@ def refresh_iso_pool() -> None:
|
|||||||
run_cmd(["virsh", "pool-refresh", "virtuality-iso"], timeout=20)
|
run_cmd(["virsh", "pool-refresh", "virtuality-iso"], timeout=20)
|
||||||
|
|
||||||
|
|
||||||
def vm_arch_options() -> list[dict[str, str]]:
|
def guest_arch_for_host(profile: dict[str, Any]) -> str:
|
||||||
return [
|
"""Machines run natively: aarch64 on ARM boards, x86_64 everywhere else."""
|
||||||
{"value": "auto", "label": "Auto — по профилю хоста"},
|
arch = str(profile.get("recommended_guest_arch") or profile.get("arch") or platform.machine() or "x86_64")
|
||||||
{"value": "x86_64", "label": "x86_64 / amd64"},
|
return "aarch64" if arch in ("aarch64", "arm64") else "x86_64"
|
||||||
{"value": "aarch64", "label": "ARM64 / aarch64"},
|
|
||||||
{"value": "generic", "label": "Generic / no arch override"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_guest_arch(value: str, profile: dict[str, Any]) -> str:
|
|
||||||
value = (value or "auto").strip()
|
|
||||||
if value == "auto":
|
|
||||||
return str(profile.get("recommended_guest_arch") or "x86_64")
|
|
||||||
if value in ("x86_64", "amd64"):
|
|
||||||
return "x86_64"
|
|
||||||
if value in ("aarch64", "arm64"):
|
|
||||||
return "aarch64"
|
|
||||||
if value == "generic":
|
|
||||||
return "generic"
|
|
||||||
return str(profile.get("recommended_guest_arch") or "x86_64")
|
|
||||||
|
|
||||||
|
|
||||||
def list_disk_image_files() -> list[dict[str, str]]:
|
def list_disk_image_files() -> list[dict[str, str]]:
|
||||||
@@ -593,7 +588,8 @@ def list_disk_image_files() -> list[dict[str, str]]:
|
|||||||
except OSError:
|
except OSError:
|
||||||
size_gb = 0
|
size_gb = 0
|
||||||
updated = "unknown"
|
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
|
return files
|
||||||
|
|
||||||
|
|
||||||
@@ -618,10 +614,24 @@ def disk_image_path_by_name(name: str) -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def disk_image_format(path: Path) -> str:
|
def disk_image_format(path: Path) -> str:
|
||||||
suffix = path.suffix.lower().lstrip('.')
|
"""Real image format: Ubuntu cloud images are qcow2 despite the .img name, so the suffix is only a fallback."""
|
||||||
if suffix == 'qcow2':
|
info = run_cmd(["qemu-img", "info", "--output=json", str(path)], timeout=30)
|
||||||
return 'qcow2'
|
if info.get("ok"):
|
||||||
return 'raw'
|
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:
|
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:
|
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:
|
def vm_exists(name: str) -> bool:
|
||||||
@@ -908,7 +921,6 @@ def boot_order_to_devs(value: str) -> list[str]:
|
|||||||
"disk": ["hd"],
|
"disk": ["hd"],
|
||||||
"cdrom_disk": ["cdrom", "hd"],
|
"cdrom_disk": ["cdrom", "hd"],
|
||||||
"disk_cdrom": ["hd", "cdrom"],
|
"disk_cdrom": ["hd", "cdrom"],
|
||||||
"network_disk": ["network", "hd"],
|
|
||||||
}
|
}
|
||||||
return mapping.get(value, ["hd"])
|
return mapping.get(value, ["hd"])
|
||||||
|
|
||||||
@@ -919,8 +931,6 @@ def boot_devs_to_order(devs: list[str]) -> str:
|
|||||||
return "cdrom_disk"
|
return "cdrom_disk"
|
||||||
if clean[:2] == ["hd", "cdrom"]:
|
if clean[:2] == ["hd", "cdrom"]:
|
||||||
return "disk_cdrom"
|
return "disk_cdrom"
|
||||||
if clean[:2] == ["network", "hd"]:
|
|
||||||
return "network_disk"
|
|
||||||
if clean[:1] == ["hd"]:
|
if clean[:1] == ["hd"]:
|
||||||
return "disk"
|
return "disk"
|
||||||
return "auto"
|
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]:
|
def apply_vm_boot_order(name: str, boot_order: str) -> tuple[bool, str]:
|
||||||
if not valid_vm_name(name) or not vm_exists(name):
|
if not valid_vm_name(name) or not vm_exists(name):
|
||||||
return False, "VM не найдена."
|
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."
|
return False, "Некорректный порядок загрузки VM."
|
||||||
|
|
||||||
selected = normalize_boot_order(boot_order, "disk_image")
|
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):
|
if not valid_vm_name(name) or not vm_exists(name):
|
||||||
return False, "VM не найдена."
|
return False, "VM не найдена."
|
||||||
resources = vm_resource_settings(name)
|
resources = vm_resource_settings(name)
|
||||||
if not resources.get("is_shutoff"):
|
if not resources.get("is_shutoff"):
|
||||||
return False, "CPU/RAM/архитектуру можно менять только когда VM выключена. Сначала выключи VM."
|
return False, "Процессор и память можно менять только когда машина выключена."
|
||||||
if memory_mb < 512 or memory_mb > 262144:
|
if memory_mb < 512 or memory_mb > 262144:
|
||||||
return False, "RAM должна быть от 512 MB до 262144 MB."
|
return False, "RAM должна быть от 512 MB до 262144 MB."
|
||||||
if vcpus < 1 or vcpus > 128:
|
if vcpus < 1 or vcpus > 128:
|
||||||
return False, "CPU должен быть от 1 до 128 vCPU."
|
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)
|
result = run_cmd(["virsh", "dumpxml", name], timeout=15)
|
||||||
if not result.get("ok"):
|
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.text = str(int(vcpus))
|
||||||
vcpu_node.set("placement", "static")
|
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")
|
xml_text = ET.tostring(root, encoding="unicode")
|
||||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
|
||||||
handle.write(xml_text)
|
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)
|
Path(tmp_name).unlink(missing_ok=True)
|
||||||
if not define.get("ok"):
|
if not define.get("ok"):
|
||||||
return False, define.get("stderr") or "virsh define завершился ошибкой."
|
return False, define.get("stderr") or "virsh define завершился ошибкой."
|
||||||
|
return True, f"Ресурсы VM применены: CPU {vcpus}, RAM {memory_mb} MB"
|
||||||
message = f"Ресурсы VM применены: CPU {vcpus}, RAM {memory_mb} MB"
|
|
||||||
if arch_changed:
|
|
||||||
message += f", архитектура {guest_arch}. Важно: смена архитектуры может потребовать совместимый диск/загрузчик."
|
|
||||||
return True, message
|
|
||||||
|
|
||||||
|
|
||||||
def vm_state(name: str) -> str:
|
def vm_state(name: str) -> str:
|
||||||
@@ -1115,57 +1103,57 @@ def vm_state(name: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def current_vm_iso(name: str) -> str:
|
def current_vm_iso(name: str) -> str:
|
||||||
result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)
|
return next((drive["source"] for drive in vm_cdrom_devices(name) if drive["source"]), "")
|
||||||
if not result.get("ok"):
|
|
||||||
return ""
|
|
||||||
for line in (result.get("stdout") or "").splitlines():
|
def vms_using_iso(path: Path) -> list[str]:
|
||||||
if ".iso" not in line.lower():
|
"""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
|
continue
|
||||||
parts = line.split()
|
target = disk.find("target")
|
||||||
if parts:
|
source = disk.find("source")
|
||||||
return parts[-1]
|
dev = target.attrib.get("dev", "") if target is not None else ""
|
||||||
return ""
|
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]:
|
def detach_vm_iso(name: str) -> tuple[bool, str]:
|
||||||
if not valid_vm_name(name) or not vm_exists(name):
|
if not valid_vm_name(name) or not vm_exists(name):
|
||||||
return False, "VM не найдена."
|
return False, "VM не найдена."
|
||||||
xml = run_cmd(["virsh", "dumpxml", name], timeout=12)
|
drives = [drive for drive in vm_cdrom_devices(name) if drive["source"]]
|
||||||
cdrom_targets: list[str] = []
|
if not drives:
|
||||||
if xml.get("ok"):
|
return False, "Подключенный ISO не найден."
|
||||||
try:
|
|
||||||
root = ET.fromstring(xml.get("stdout") or "")
|
|
||||||
devices = root.find("devices")
|
|
||||||
if devices is not None:
|
|
||||||
for disk in devices.findall("disk"):
|
|
||||||
if disk.attrib.get("device") != "cdrom":
|
|
||||||
continue
|
|
||||||
target = disk.find("target")
|
|
||||||
dev = target.attrib.get("dev") if target is not None else ""
|
|
||||||
if dev:
|
|
||||||
cdrom_targets.append(dev)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not cdrom_targets:
|
|
||||||
cdrom_targets = ["sda", "hda", "sdb", "hdc"]
|
|
||||||
|
|
||||||
running = vm_state(name) == "running"
|
|
||||||
errors = []
|
errors = []
|
||||||
changed_any = False
|
for drive in drives:
|
||||||
for target in cdrom_targets:
|
result = run_cmd(["virsh", "change-media", name, drive["target"], "--eject", *change_media_flags(name)], timeout=30)
|
||||||
commands = []
|
if not result.get("ok"):
|
||||||
if running:
|
errors.append(result.get("stderr") or "virsh change-media завершился с ошибкой")
|
||||||
commands.append(["virsh", "detach-disk", name, target, "--live"])
|
if errors:
|
||||||
commands.append(["virsh", "detach-disk", name, target, "--config"])
|
return False, errors[-1]
|
||||||
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 True, "ISO был отмонтирован."
|
||||||
return False, errors[-1] if errors else "Подключенный ISO не найден."
|
|
||||||
|
|
||||||
|
|
||||||
def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]:
|
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():
|
if iso_root not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
|
||||||
return False, "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
|
return False, "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
|
||||||
|
|
||||||
detach_vm_iso(name)
|
drives = vm_cdrom_devices(name)
|
||||||
running = vm_state(name) == "running"
|
if drives:
|
||||||
base = ["virsh", "attach-disk", name, str(iso), "sda", "--type", "cdrom", "--mode", "readonly"]
|
result = run_cmd(["virsh", "change-media", name, drives[0]["target"], str(iso), "--update", *change_media_flags(name)], timeout=30)
|
||||||
if running:
|
if not result.get("ok"):
|
||||||
live = run_cmd(base + ["--live"], timeout=30)
|
return False, result.get("stderr") or "Не удалось подключить ISO."
|
||||||
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 или обнови устройства внутри гостевой ОС."
|
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]:
|
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": "disk", "label": "Сначала диск"},
|
||||||
{"value": "cdrom_disk", "label": "Сначала ISO/CD-ROM, потом диск"},
|
{"value": "cdrom_disk", "label": "Сначала ISO/CD-ROM, потом диск"},
|
||||||
{"value": "disk_cdrom", "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()
|
value = (value or "auto").strip()
|
||||||
if value == "auto":
|
if value == "auto":
|
||||||
return "cdrom_disk" if source_type == "iso" else "disk"
|
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 value
|
||||||
return "cdrom_disk" if source_type == "iso" else "disk"
|
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",
|
"disk": "hd",
|
||||||
"cdrom_disk": "cdrom,hd",
|
"cdrom_disk": "cdrom,hd",
|
||||||
"disk_cdrom": "hd,cdrom",
|
"disk_cdrom": "hd,cdrom",
|
||||||
"network_disk": "network,hd",
|
|
||||||
}
|
}
|
||||||
value = mapping.get(boot_order, "hd")
|
value = mapping.get(boot_order, "hd")
|
||||||
if is_arm:
|
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):
|
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()
|
profile = host_profile.load_host_profile()
|
||||||
default_mode = profile.get("recommended_network", "nat")
|
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
|
# Virtuality noVNC console patch
|
||||||
@@ -1341,7 +1329,9 @@ def dashboard(request: Request):
|
|||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return 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()]
|
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()})
|
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
|
return auth_redirect
|
||||||
path = iso_path_by_name(name)
|
path = iso_path_by_name(name)
|
||||||
if path and path.exists() and path.is_file():
|
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()
|
path.unlink()
|
||||||
refresh_iso_pool()
|
refresh_iso_pool()
|
||||||
return RedirectResponse(url="/iso", status_code=303)
|
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:
|
if not iso and not image:
|
||||||
return vm_form_context(request)
|
return vm_form_context(request)
|
||||||
profile = host_profile.load_host_profile()
|
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:
|
if iso:
|
||||||
form["iso_path"] = str(ISO_DIR / Path(iso).name)
|
form["iso_path"] = str(ISO_DIR / Path(iso).name)
|
||||||
if image:
|
if image:
|
||||||
@@ -1779,12 +1772,15 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/create", response_class=HTMLResponse)
|
@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)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return 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
|
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):
|
if not valid_vm_name(name):
|
||||||
error = "Имя VM может содержать латиницу, цифры, точку, дефис и подчёркивание. Длина 2–63 символа."
|
error = "Имя VM может содержать латиницу, цифры, точку, дефис и подчёркивание. Длина 2–63 символа."
|
||||||
elif vm_exists(name):
|
elif vm_exists(name):
|
||||||
@@ -1801,10 +1797,16 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
|
|||||||
error = "Некорректное имя bridge."
|
error = "Некорректное имя bridge."
|
||||||
elif source_type not in ("iso", "disk_image"):
|
elif source_type not in ("iso", "disk_image"):
|
||||||
error = "Некорректный источник VM."
|
error = "Некорректный источник VM."
|
||||||
elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):
|
elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom"):
|
||||||
error = "Некорректная архитектура VM."
|
|
||||||
elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):
|
|
||||||
error = "Некорректный порядок загрузки VM."
|
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):
|
elif network_mode == "bridge" and not bridge_exists(bridge):
|
||||||
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
|
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
|
||||||
else:
|
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)
|
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
|
||||||
|
|
||||||
profile = host_profile.load_host_profile()
|
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)
|
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"
|
is_arm = selected_arch == "aarch64"
|
||||||
# ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation.
|
virt_type = "kvm" if profile.get("kvm_device") else "qemu"
|
||||||
virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu")
|
os_info = catalog.os_type(os_type) if source_type == "iso" else None
|
||||||
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
|
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]
|
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--virt-type", virt_type]
|
||||||
if selected_arch == "x86_64":
|
if is_arm:
|
||||||
cmd += ["--arch", "x86_64"]
|
|
||||||
elif is_arm:
|
|
||||||
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57"]
|
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":
|
if source_type == "disk_image":
|
||||||
source_disk = Path(disk_image_path).resolve()
|
source_disk = Path(disk_image_path).resolve()
|
||||||
source_format = disk_image_format(source_disk)
|
source_format = disk_image_format(source_disk)
|
||||||
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
|
osinfo = catalog_entry["osinfo"] if catalog_entry else "detect=on,require=off"
|
||||||
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"])
|
steps = [f"qemu-img convert -p -f {source_format} -O qcow2 {shlex.quote(str(source_disk))} {shlex.quote(str(disk_path))}"]
|
||||||
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
|
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:
|
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 = 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)
|
start_background_operation(operation, cmd)
|
||||||
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
|
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
|
||||||
|
|
||||||
@@ -1883,7 +1920,8 @@ async def console_websocket(websocket: WebSocket, token: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
await websocket.close(code=1008)
|
await websocket.close(code=1008)
|
||||||
return
|
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)
|
await websocket.close(code=1008)
|
||||||
return
|
return
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
@@ -1921,11 +1959,11 @@ def vm_detail_page(request: Request, name: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/vm/{name}/resources")
|
@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)
|
auth_redirect = require_auth(request)
|
||||||
if auth_redirect:
|
if auth_redirect:
|
||||||
return 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:
|
if ok:
|
||||||
return redirect_with_message(f"/vm/{name}", "resource_message", message)
|
return redirect_with_message(f"/vm/{name}", "resource_message", message)
|
||||||
return redirect_with_message(f"/vm/{name}", "resource_error", 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)
|
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}")
|
@app.post("/vm/{name}/{action}")
|
||||||
def vm_action(request: Request, name: str, action: str):
|
def vm_action(request: Request, name: str, action: str):
|
||||||
auth_redirect = require_auth(request)
|
auth_redirect = require_auth(request)
|
||||||
@@ -1972,13 +2027,22 @@ def vm_action(request: Request, name: str, action: str):
|
|||||||
if not valid_vm_name(name):
|
if not valid_vm_name(name):
|
||||||
return JSONResponse({"ok": False, "error": "Invalid VM name"}, status_code=400)
|
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]}
|
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":
|
if action == "delete":
|
||||||
|
if vm_state(name) == "running":
|
||||||
run_cmd(["virsh", "destroy", name], timeout=20)
|
run_cmd(["virsh", "destroy", name], timeout=20)
|
||||||
run_cmd(["virsh", "undefine", name, "--remove-all-storage"], timeout=60)
|
# --nvram: UEFI machines keep firmware variables that block a plain undefine.
|
||||||
return RedirectResponse(url="/", status_code=303)
|
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:
|
if action not in allowed:
|
||||||
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
|
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)
|
return RedirectResponse(url=f"/vm/{name}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@@ -1996,7 +2060,7 @@ def live_status(request: Request):
|
|||||||
ip = "—"
|
ip = "—"
|
||||||
css = "ok" if "running" in state else "err" if "shut" in state else "warn"
|
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")})
|
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")
|
@app.get("/live/operations")
|
||||||
@@ -2015,4 +2079,22 @@ def healthz():
|
|||||||
def api_health(request: Request):
|
def api_health(request: Request):
|
||||||
if not get_current_user(request):
|
if not get_current_user(request):
|
||||||
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
|
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,6 +153,7 @@ def detect_host_profile() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
checks = []
|
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': 'Аппаратное ускорение (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.'})
|
||||||
|
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.'})
|
checks.append({'name': 'Поддержка виртуализации процессором', 'ok': virtualization_flags > 0, 'level': 'ok' if virtualization_flags > 0 else 'warn', 'hint': 'CPU-флаги vmx/svm не видны. Для реального сервера это обычно значит, что виртуализация выключена в BIOS/UEFI.'})
|
||||||
if is_arm:
|
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': 'Эмулятор ARM64 (QEMU)', 'ok': data['qemu_system_aarch64'], 'level': 'ok' if data['qemu_system_aarch64'] else 'err', 'hint': 'Пакет qemu-system-arm / qemu-system-aarch64.'})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import uuid
|
import uuid
|
||||||
@@ -9,6 +10,7 @@ from typing import Any
|
|||||||
CONFIG_DIR = Path('/var/lib/virtuality/config')
|
CONFIG_DIR = Path('/var/lib/virtuality/config')
|
||||||
NETWORK_DIR = Path('/var/lib/virtuality/network')
|
NETWORK_DIR = Path('/var/lib/virtuality/network')
|
||||||
NFT_DIR = Path('/etc/virtuality/nftables')
|
NFT_DIR = Path('/etc/virtuality/nftables')
|
||||||
|
UFW_STATE_FILE = NETWORK_DIR / 'ufw_rules.json'
|
||||||
PORT_FORWARDS_FILE = NETWORK_DIR / 'port_forwards.json'
|
PORT_FORWARDS_FILE = NETWORK_DIR / 'port_forwards.json'
|
||||||
NAT_XML_FILE = NETWORK_DIR / 'virtuality-nat.xml'
|
NAT_XML_FILE = NETWORK_DIR / 'virtuality-nat.xml'
|
||||||
NFT_FILE = NFT_DIR / 'virtuality.nft'
|
NFT_FILE = NFT_DIR / 'virtuality.nft'
|
||||||
@@ -24,6 +26,13 @@ class NetworkError(Exception):
|
|||||||
pass
|
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]:
|
def run_cmd(cmd: list[str], timeout: int = 12) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
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]:
|
def libvirt_network_info() -> dict[str, Any]:
|
||||||
info = run_cmd(['virsh', 'net-info', NETWORK_NAME], timeout=8)
|
info = run_cmd(['virsh', 'net-info', NETWORK_NAME], timeout=8)
|
||||||
leases = run_cmd(['virsh', 'net-dhcp-leases', 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:
|
if match:
|
||||||
return match.group(1)
|
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)
|
leases = run_cmd(['virsh', 'net-dhcp-leases', NETWORK_NAME], timeout=8)
|
||||||
if not leases['ok']:
|
if not leases['ok']:
|
||||||
return None
|
return None
|
||||||
macs = set(vm_mac_addresses(vm_name))
|
|
||||||
for line in leases['stdout'].splitlines():
|
for line in leases['stdout'].splitlines():
|
||||||
low = line.lower()
|
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
|
continue
|
||||||
match = re.search(r'\b(192\.168\.100\.\d+|\d+\.\d+\.\d+\.\d+)/\d+', line)
|
match = re.search(r'\b(192\.168\.100\.\d+|\d+\.\d+\.\d+\.\d+)/\d+', line)
|
||||||
if match:
|
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]:
|
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')
|
raise NetworkError('Некорректное имя VM')
|
||||||
if guest_ip == 'auto':
|
if guest_ip == 'auto':
|
||||||
resolved_ip = resolve_vm_ip(vm_name)
|
resolved_ip = resolve_vm_ip(vm_name)
|
||||||
@@ -415,6 +481,25 @@ def render_nft_rules() -> str:
|
|||||||
return '\n'.join(lines) + '\n'
|
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]]:
|
def apply_ufw_route_rules(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
ext = external_interface()
|
ext = external_interface()
|
||||||
results: list[dict[str, Any]] = []
|
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)
|
status = run_cmd(['ufw', 'status'], timeout=8)
|
||||||
if 'Status: active' not in status['stdout']:
|
if 'Status: active' not in status['stdout']:
|
||||||
return results
|
return results
|
||||||
for raw_item in items:
|
wanted = ufw_rules_for(items, ext)
|
||||||
item = normalize_forward(raw_item)
|
# Rules of forwards that were removed (or moved to another port) are deleted; ufw only keeps what is still wanted.
|
||||||
guest_port = iptables_port_value(item['guest_port_start'], item['guest_port_end'])
|
for rule in load_ufw_state():
|
||||||
external_port = iptables_port_value(item['external_port_start'], item['external_port_end'])
|
if rule not in wanted:
|
||||||
cmd = [
|
delete = ['ufw', 'route', 'delete', *rule[1:]] if rule[0] == 'route' else ['ufw', 'delete', *rule]
|
||||||
'ufw', 'route', 'allow',
|
results.append(run_cmd(delete, timeout=15))
|
||||||
'in', 'on', ext,
|
for rule in wanted:
|
||||||
'out', 'on', forward_guest_interface(item),
|
results.append(run_cmd(['ufw', *rule], timeout=15))
|
||||||
'to', item['guest_ip'],
|
ensure_dirs()
|
||||||
'port', guest_port,
|
UFW_STATE_FILE.write_text(json.dumps(wanted))
|
||||||
'proto', item['protocol'],
|
|
||||||
]
|
|
||||||
results.append(run_cmd(cmd, timeout=15))
|
|
||||||
results.append(run_cmd(['ufw', 'allow', f"{external_port}/{item['protocol']}"], timeout=15))
|
|
||||||
run_cmd(['ufw', 'reload'], timeout=20)
|
run_cmd(['ufw', 'reload'], timeout=20)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -456,11 +537,28 @@ def ensure_iptables_rule(cmd: list[str]) -> dict[str, Any]:
|
|||||||
return run_cmd(cmd, timeout=10)
|
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]]:
|
def apply_iptables_fallback(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
ext = external_interface()
|
ext = external_interface()
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
if not run_cmd(['sh', '-lc', 'command -v iptables >/dev/null 2>&1'], timeout=5)['ok']:
|
if not run_cmd(['sh', '-lc', 'command -v iptables >/dev/null 2>&1'], timeout=5)['ok']:
|
||||||
return results
|
return results
|
||||||
|
clear_iptables_fallback()
|
||||||
for raw_item in items:
|
for raw_item in items:
|
||||||
item = normalize_forward(raw_item)
|
item = normalize_forward(raw_item)
|
||||||
proto = item['protocol']
|
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_port = iptables_dnat_port_value(item['guest_port_start'], item['guest_port_end'])
|
||||||
guest_to = f"{guest_ip}:{guest_to_port}"
|
guest_to = f"{guest_ip}:{guest_to_port}"
|
||||||
guest_iface = forward_guest_interface(item)
|
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', 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', '-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, '-j', 'DNAT', '--to-destination', guest_to]))
|
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, '-j', 'MASQUERADE']))
|
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, '-j', 'MASQUERADE']))
|
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-s', NAT_SUBNET, '-o', ext, *IPTABLES_TAG, '-j', 'MASQUERADE']))
|
||||||
return results
|
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]:
|
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')
|
raise NetworkError('Некорректное имя VM')
|
||||||
if not valid_port(external_port) or not valid_port(guest_port):
|
if not valid_port(external_port) or not valid_port(guest_port):
|
||||||
raise NetworkError('Порт должен быть от 1 до 65535')
|
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 = {
|
SERVICE_LABELS = {
|
||||||
"libvirtd": "Виртуализация",
|
"libvirtd": "Виртуализация",
|
||||||
"virtlogd": "Журналы машин",
|
"virtlogd": "Журналы машин",
|
||||||
"cockpit": "Cockpit",
|
|
||||||
"dashboard": "Экран сервера",
|
|
||||||
"web": "Панель управления",
|
"web": "Панель управления",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,3 +240,37 @@ def plural(count: int, one: str, few: str, many: str) -> str:
|
|||||||
|
|
||||||
def port_service(port: int) -> str:
|
def port_service(port: int) -> str:
|
||||||
return WELL_KNOWN_PORTS.get(int(port or 0), "")
|
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;
|
--border-strong: #d3d7e1;
|
||||||
--text: #0e1525;
|
--text: #0e1525;
|
||||||
--text-2: #4a5468;
|
--text-2: #4a5468;
|
||||||
--text-3: #7b8498;
|
--text-3: #5f6980;
|
||||||
--brand: #5a48f5;
|
--brand: #5a48f5;
|
||||||
--brand-hover: #4a38e6;
|
--brand-hover: #4a38e6;
|
||||||
--brand-soft: #efedff;
|
--brand-soft: #efedff;
|
||||||
@@ -46,6 +46,9 @@
|
|||||||
--danger: #e03e45; --danger-soft: #fdecec;
|
--danger: #e03e45; --danger-soft: #fdecec;
|
||||||
--info: #2f73f0; --info-soft: #e9f1ff;
|
--info: #2f73f0; --info-soft: #e9f1ff;
|
||||||
--neutral: #6b7488; --neutral-soft: #eff1f5;
|
--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-bg: #0d1117;
|
||||||
--terminal-text: #d6deeb;
|
--terminal-text: #d6deeb;
|
||||||
@@ -84,6 +87,8 @@
|
|||||||
--danger: #ff6369; --danger-soft: rgba(255, 99, 105, .13);
|
--danger: #ff6369; --danger-soft: rgba(255, 99, 105, .13);
|
||||||
--info: #5b9bff; --info-soft: rgba(91, 155, 255, .13);
|
--info: #5b9bff; --info-soft: rgba(91, 155, 255, .13);
|
||||||
--neutral: #8d96a8; --neutral-soft: rgba(141, 150, 168, .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;
|
--terminal-bg: #07090d;
|
||||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, .3);
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, .3);
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, .35);
|
--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 {
|
.badge {
|
||||||
display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px;
|
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;
|
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 .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
.badge.success { background: var(--success-soft); color: var(--success); }
|
.badge.success { background: var(--success-soft); color: var(--success-text); }
|
||||||
.badge.warning { background: var(--warning-soft); color: var(--warning); }
|
.badge.warning { background: var(--warning-soft); color: var(--warning-text); }
|
||||||
.badge.danger { background: var(--danger-soft); color: var(--danger); }
|
.badge.danger { background: var(--danger-soft); color: var(--danger-text); }
|
||||||
.badge.info { background: var(--info-soft); color: var(--info); }
|
.badge.info { background: var(--info-soft); color: var(--info-text); }
|
||||||
.badge.brand { background: var(--brand-soft); color: var(--brand-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.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; }
|
.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 { 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 strong { display: block; font-weight: 600; }
|
||||||
.switch-row .switch-text span { color: var(--text-3); font-size: 12.5px; }
|
.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::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"] { background: var(--success); }
|
||||||
.switch[aria-checked="true"]::after { transform: translateX(18px); }
|
.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) */
|
/* option cards (radio) */
|
||||||
.options { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
.options { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||||
.options-4 { grid-template-columns: repeat(4, minmax(0, 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; }
|
.hide-sm { display: none !important; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: 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-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-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-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>
|
</svg>
|
||||||
|
|||||||
|
До Ширина: | Высота: | Размер: 10 KiB После Ширина: | Высота: | Размер: 12 KiB |
@@ -67,6 +67,8 @@
|
|||||||
const raw = sessionStorage.getItem('virtualityToast');
|
const raw = sessionStorage.getItem('virtualityToast');
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
sessionStorage.removeItem('virtualityToast');
|
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);
|
const data = JSON.parse(raw);
|
||||||
toast(data.message, data.tone);
|
toast(data.message, data.tone);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -196,6 +198,41 @@
|
|||||||
setInterval(refreshLive, 5000);
|
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 */
|
/* ---------------------------------------------------- uploads */
|
||||||
function wireUploader(form) {
|
function wireUploader(form) {
|
||||||
const input = $('input[type=file]', form);
|
const input = $('input[type=file]', form);
|
||||||
@@ -408,8 +445,17 @@
|
|||||||
});
|
});
|
||||||
$$('[data-visible-when]', form).forEach((el) => {
|
$$('[data-visible-when]', form).forEach((el) => {
|
||||||
const [key, expected] = el.dataset.visibleWhen.split('=');
|
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 preset = value('preset');
|
||||||
const custom = preset === 'custom';
|
const custom = preset === 'custom';
|
||||||
const presetInput = $(`input[name=preset][value="${preset}"]`, form);
|
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('source', source === 'disk_image' ? optionText(field('disk_image_path')) : optionText(field('iso_path')));
|
||||||
setSummary('cpu', `${value('vcpus')} ${Number(value('vcpus')) === 1 ? 'ядро' : 'ядра'}`);
|
setSummary('cpu', `${value('vcpus')} ${Number(value('vcpus')) === 1 ? 'ядро' : 'ядра'}`);
|
||||||
setSummary('memory', memory >= 1024 ? `${+(memory / 1024).toFixed(1)} ГБ` : `${memory} МБ`);
|
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)');
|
setSummary('network', value('network_mode') === 'bridge' ? 'Локальная сеть' : 'Автоматически (NAT)');
|
||||||
const hasSource = source === 'disk_image' ? form.dataset.hasDisks === '1' : form.dataset.hasIsos === '1';
|
const hasSource = source === 'disk_image' ? form.dataset.hasDisks === '1' : form.dataset.hasIsos === '1';
|
||||||
$$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); });
|
$$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); });
|
||||||
$$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; });
|
$$('[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);
|
form.addEventListener('change', sync);
|
||||||
sync();
|
sync();
|
||||||
}
|
}
|
||||||
@@ -466,6 +512,7 @@
|
|||||||
wireTabs();
|
wireTabs();
|
||||||
wireCopy();
|
wireCopy();
|
||||||
wireLive();
|
wireLive();
|
||||||
|
wireStats();
|
||||||
$$('form[data-uploader]').forEach(wireUploader);
|
$$('form[data-uploader]').forEach(wireUploader);
|
||||||
wireOperation();
|
wireOperation();
|
||||||
wireLogs();
|
wireLogs();
|
||||||
@@ -475,3 +522,46 @@
|
|||||||
flashToast();
|
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 %}
|
||||||