Сравнить коммиты

Автор SHA1 Сообщение Дата
viktor 5f2bdd921d docs: подробный README (разбор проекта при переносе в Gitea)
Старый README сохранён как README.upstream.md.
2026-08-20 04:18:36 +09:00
63 изменённых файлов: 4847 добавлений и 4050 удалений
-31
Просмотреть файл
@@ -1,31 +0,0 @@
name: CI
on:
push:
pull_request:
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Проверка синтаксиса Python
run: python -m compileall -q web scripts
- name: Проверка синтаксиса bash-скриптов
run: |
bash -n install.sh bootstrap.sh install_virtuality_node.sh setup_github_sync.sh
for script in scripts/*.sh; do bash -n "$script"; done
- name: Установка зависимостей
run: |
python -m pip install --upgrade pip
pip install -r web/requirements.txt pytest httpx
- name: Тесты
run: pytest -q
+85 -401
Просмотреть файл
@@ -1,396 +1,69 @@
# Virtuality
**Virtuality** — лёгкая серверная платформа виртуализации на базе **KVM**, **QEMU**, **libvirt**, **Cockpit** и собственной web-панели.
Платформа управления виртуализацией для одиночной Linux-ноды: KVM/QEMU/libvirt плюс собственная web-панель на FastAPI. Предназначена для администратора домашнего сервера, VPS или ARM64-платы (Raspberry Pi, Orange Pi 5), которому нужен упрощённый аналог Proxmox: создание VM из ISO, управление питанием, NAT/bridge-сеть с пробросом портов, web-консоль noVNC, журнал фоновых операций и самообновление ноды из git.
Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией.
Установка выполняется одной командой; установщик сам определяет профиль хоста (x86_64 / raspberry-arm64 / orangepi5-arm64 / generic-arm64) и ставит соответствующий набор пакетов. Авторизация в панели — по существующему Linux-пользователю (PAM/shadow), отдельной базы пользователей нет.
> Текущий статус: ранняя стадия разработки. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding.
Исходный README автора сохранён в `README.upstream.md` — там подробные разделы про порты, директории, диагностику, NAT Router и полезные команды.
---
## Стек
## Поддерживаемые профили хоста
- Python 3, FastAPI, Uvicorn, Jinja2, itsdangerous (подписанные cookie-сессии)
- Bash — установщики, диагностика, сетевые скрипты, TUI-дашборд
- KVM, QEMU, libvirt (`virsh`, `virt-install`), Cockpit + Cockpit Machines
- nftables — NAT-роутер и port forwarding, netplan — настройка bridge `br0`
- noVNC + websockify — web-консоль VM
- systemd — юниты `virtuality-web`, `virtuality-auto-update.service/.timer`
- Хранение состояния — файлы JSON в `/var/lib/virtuality`, СУБД не используется
- Внешние API отсутствуют; обновления тянутся с GitHub (git pull или zip-архив ветки)
Virtuality автоматически определяет профиль сборки во время установки:
## Структура
```text
x86_64 # обычный сервер, домашний сервер, VPS
raspberry-arm64 # Raspberry Pi ARM64 edge node
orangepi5-arm64 # Orange Pi 5 / RK3588 ARM64 edge node
generic-arm64 # другая ARM64-плата
```
| Путь | Назначение |
| --- | --- |
| `install.sh` | Основной установщик: проверка требований, apt-пакеты, клонирование репозитория, запуск установки ноды и панели |
| `bootstrap.sh` | Публичная точка входа one-command установки на чистый сервер |
| `install_virtuality_node.sh` | Установка компонентов ноды: KVM/QEMU/libvirt, Cockpit, storage pools, firewall |
| `setup_github_sync.sh` | Настройка git-синхронизации репозитория на ноде |
| `VERSION`, `updates/versions.json` | Текущая версия и манифест истории версий, используется центром обновлений панели |
| `web/app.py` | Базовое FastAPI-приложение панели: логин, дашборд, ISO, создание VM, карточка VM, сеть, операции, health |
| `web/network_core.py` | NAT-роутер, nftables-правила, port forwarding, диагностика сети |
| `web/host_profile.py` | Определение и чтение профиля хоста |
| `web/update_core.py` | Центр обновлений: сравнение версий и коммитов, запуск обновления, лог |
| `web/templates/` | Jinja2-шаблоны страниц панели (`dashboard`, `vm_create`, `vm_detail`, `iso`, `disk_images`, `network`, `logs`, `update`, `console`, `operations`, `login`, `_sidebar`) |
| `web/static/` | `app.css`, `themes.css` (переключаемые темы, включая MacOS Flat), `panel.js`, статическая база знаний |
| `scripts/install_web_panel.sh` | Установка панели в `/opt/virtuality/web`, venv, systemd-юнит, применение всех патчей |
| `scripts/patch_*.py` | ~28 python-патчей, дописывающих функциональность в **установленный** `app.py` (диски, boot order, автозапуск VM, noVNC, сетевые диапазоны, центр логов, центр обновлений и т. д.) |
| `scripts/setup_bridge_br0.sh` | Безопасная настройка bridge `br0` через netplan с бэкапом и откатом |
| `scripts/check_node.sh`, `virtuality_healthcheck.sh`, `virtuality_console_dashboard.sh` | Диагностика ноды, healthcheck-команда `vhealth`, консольный btop-подобный дашборд |
| `scripts/auto_update_check.sh`, `apply_github_update.sh` | Автообновление по systemd-таймеру и применение обновления |
| `scripts/create_test_vm.sh`, `clean_install.sh`, `fix_cockpit_auth.sh` | Вспомогательные операции |
| `docs/` | `ARCHITECTURE.md`, `REQUIREMENTS.md`, `ROADMAP.md`, `FIRST_VM.md` |
Профиль сохраняется здесь:
Важная особенность архитектуры: репозиторный `web/app.py` — это **не** полный код панели. `install_web_panel.sh` копирует его в `/opt/virtuality/web/app.py` и затем последовательно применяет патч-скрипты из `scripts/`, которые вставляют в файл недостающие маршруты и функции (менеджер дисковых образов, web-консоль, центр логов, центр обновлений, сетевые доработки). Полная функциональность существует только после установки.
```text
/var/lib/virtuality/config/host_profile.json
```
## Как запустить
В web-панели профиль виден на странице:
```text
/host
```
Для ARM64-плат правильный сценарий — **ARM64-гости**, NAT-сеть и позже cloud-image/cloud-init шаблоны. x86_64 ISO на Raspberry/Orange Pi не являются целевым режимом.
---
## Быстрая установка одной командой
Одна команда работает и под `root`, и под обычным пользователем с `sudo`:
Установка на чистый Ubuntu/Debian-сервер (требует root или sudo):
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | bash
```
Как выбирается пользователь для входа в web-панель:
```text
Если запуск под root → login: root
Если запуск через sudo → login: текущий sudo-пользователь
Если задан VIRTUALITY_USER → login: указанный Linux-пользователь
```
Принудительно выбрать пользователя:
Выбор Linux-пользователя для входа в панель:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=<linux_user> bash
```
Пример:
Установка из локального клона:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=admin bash
```
Установка с авторизацией под `root`:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=root bash
```
Если пароль выбранного Linux-пользователя не задан:
```bash
sudo passwd <linux_user>
```
Для root:
```bash
passwd root
```
---
## Что уже есть
- универсальная one-command установка через `install.sh`;
- автоопределение профиля хоста: x86_64, Raspberry Pi ARM64, Orange Pi 5 ARM64, generic ARM64;
- установка разных пакетных наборов под x86 и ARM64;
- красивый пошаговый installer с live-status и логами;
- preflight-проверка системных требований;
- установка KVM/QEMU/libvirt;
- установка Cockpit и Cockpit Machines;
- рабочая директория `/opt/virtuality/source`;
- libvirt storage pools `virtuality-images` и `virtuality-iso`;
- firewall-правила для SSH, Cockpit, VNC и web-панели;
- безопасная ручная настройка bridge `br0` через `netplan try`;
- NAT Router `virtuality-nat` для VPS/ARM edge nodes;
- port forwarding через nftables;
- автоматическое определение IP VM для проброса портов;
- healthcheck одной командой `vhealth`;
- консольный dashboard для физического монитора;
- web-панель Virtuality на FastAPI;
- авторизация web-панели через системного Linux-пользователя;
- `/host` — профиль хоста и проверки готовности;
- `/network` — NAT, DHCP leases, port forwarding, nftables preview;
- `/iso` — ISO-менеджер;
- `/operations` — журнал фоновых операций;
- список VM;
- управление VM: Start, Shutdown, Reboot, Power off, Autostart on/off, Delete VM with disks;
- создание VM из web-интерфейса;
- выбор сетевого режима при создании VM: `virtuality-nat` или `br0`;
- прогресс создания VM и live-лог `virt-install`;
- страница деталей VM: `dominfo`, VNC display, IP, диски, сетевые интерфейсы;
- web-консоль noVNC прямо в браузере;
- cloud-образы Ubuntu/Debian (x86_64 и ARM64) и создание VM из образа с cloud-init за минуту;
- клонирование VM и шаблоны (эталонные VM с блокировкой запуска);
- снапшоты VM: создание, откат, удаление;
- бэкапы дисков VM со сжатием, ротацией и ночным расписанием;
- HTTPS из коробки (самоподписанный сертификат), CSRF-защита форм, PAM-аутентификация, защита от перебора пароля;
- графики CPU/RAM хоста на дашборде;
- быстрый libvirt-backend с fallback на virsh для слабого железа;
- тесты pytest и CI на GitHub Actions.
---
## Системные требования
Минимально для x86_64 тестового стенда:
```text
CPU: 2 ядра с Intel VT-x / AMD-V
RAM: 4 GB
/: минимум 8 GB свободно
/var/lib: минимум 20 GB свободно
OS: Ubuntu Server 24.04 LTS / Debian-like с apt
Network: один проводной интерфейс
```
Рекомендуемо для x86_64:
```text
CPU: 4+ ядра
RAM: 16+ GB
Storage: 100+ GB SSD/NVMe под /var/lib/virtuality
Network: 1 Gbit/s+
OS: Ubuntu Server 24.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: https://SERVER_IP:8088
VNC: 5900-5999/tcp
SSH: 22/tcp
```
Панель работает по HTTPS с самоподписанным сертификатом — браузер один раз попросит подтвердить исключение. Отключить TLS: `VIRTUALITY_TLS=0` при установке.
Изменить порт 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 фоновых операций
/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
/ # дашборд с графиками CPU/RAM
/host # профиль хоста и проверки
/iso # ISO-менеджер
/disk-images # готовые образы дисков
/cloud-images # cloud-образы Ubuntu/Debian
/vm/create # создание VM из ISO или готового диска
/vm/create-cloud # создание VM из cloud-образа с cloud-init
/backups # бэкапы VM и ночное расписание
/network # NAT Router и проброс портов
/operations # журнал операций
/logs # центр журналов
/update # центр обновлений
```
Переустановка web-панели:
```bash
cd /opt/virtuality/source
sudo bash install_virtuality_node.sh
sudo bash scripts/install_web_panel.sh
```
Статус:
```bash
systemctl status virtuality-web --no-pager
```
Логи:
```bash
journalctl -u virtuality-web -f
```
Вход:
```text
Login: выбранный Linux-пользователь
Pass: пароль этого Linux-пользователя
```
---
## Сеть: Bridge и NAT Router
### Bridge br0
Подходит для домашнего/офисного сервера, где VM должны получать IP из локальной сети.
По умолчанию one-command установщик **не включает `br0` автоматически**, чтобы не уронить SSH-сессию.
Сначала нужно посмотреть сетевой интерфейс:
```bash
ip -br a
ip route
```
Затем запустить bridge setup, например для `enp2s0`:
```bash
cd /opt/virtuality/source
sudo bash scripts/setup_bridge_br0.sh enp2s0 static
```
Ожидаемый результат:
```text
enp2s0 UP
br0 UP SERVER_IP/24
default via GATEWAY dev br0
```
### Virtuality NAT Router
Подходит для VPS, Raspberry Pi, Orange Pi 5 и других ARM64 edge nodes.
Схема:
```text
Интернет / LAN
Хост Virtuality
virtuality-nat / virbr100
VM 192.168.100.x
```
Создать или починить NAT-сеть можно в web-панели:
```text
/network → Создать / починить NAT-сеть
```
Параметры по умолчанию:
```text
Network: virtuality-nat
Bridge: virbr100
Subnet: 192.168.100.0/24
Gateway: 192.168.100.1
DHCP: 192.168.100.50–192.168.100.200
```
Проброс порта добавляется в `/network`. IP VM определяется автоматически через `virsh domifaddr`, а если не получилось — через DHCP leases и MAC-адрес VM.
Пример SSH-проброса:
```text
VM: ubuntu-test
Внешний порт сервера: 2222
Порт внутри VM: 22
Протокол: tcp
```
Подключение:
```bash
ssh user@SERVER_IP -p 2222
```
---
## ISO и создание VM
Загрузка ISO через web-панель:
```text
/iso
```
Создание VM:
```text
/vm/create
```
При создании VM можно выбрать сеть:
```text
VPS NAT Router — virtuality-nat
Bridge — br0 / локальная сеть
```
Для x86_64 можно использовать обычные x86_64 ISO. Для Raspberry Pi / Orange Pi 5 нужны ARM64 ISO или, в будущем, ARM64 cloud images.
Пример загрузки Alpine x86_64 ISO:
```bash
sudo wget -O /var/lib/virtuality/iso/alpine-standard-x86_64.iso https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.3-x86_64.iso
sudo virsh pool-refresh virtuality-iso
```
---
## Обновление
Обновление на ноде:
```bash
cd /opt/virtuality/source
@@ -399,50 +72,61 @@ sudo bash scripts/install_web_panel.sh
sudo systemctl restart virtuality-web
```
Полная повторная установка компонентов ноды:
Доступ после установки:
```bash
cd /opt/virtuality/source
sudo bash install_virtuality_node.sh
sudo bash scripts/install_web_panel.sh
```text
Virtuality UI: http://SERVER_IP:8088
Cockpit: https://SERVER_IP:9090
VNC: 5900-5999/tcp
```
---
Локальный запуск панели без установщика возможен (`uvicorn app:app` из `web/` после `pip install -r web/requirements.txt`), но функциональность будет урезана: не применены патч-скрипты, отсутствуют каталоги `/var/lib/virtuality`, `virsh` и права на libvirt. Полноценный dev-режим в репозитории не предусмотрен.
## Полезные команды
Минимальные требования (из `docs/REQUIREMENTS.md`): 2 ядра с VT-x/AMD-V, 4 GB RAM, 8 GB свободно на `/`, 20 GB под `/var/lib/virtuality`, Ubuntu Server 24.04 LTS или Debian-подобная система с apt.
```bash
sudo vhealth
virsh list --all
virsh pool-list --all
virsh net-list --all
virsh net-dhcp-leases virtuality-nat
ip -br a
ip route
sudo nft list ruleset
sudo ufw status
systemctl status libvirtd --no-pager
systemctl status cockpit.socket --no-pager
systemctl status virtuality-web --no-pager
journalctl -u virtuality-web -f
```
## Конфигурация
---
Переменные окружения установщиков (`install.sh`, `bootstrap.sh`):
## Roadmap
| Переменная | Назначение | Пример / по умолчанию |
| --- | --- | --- |
| `VIRTUALITY_USER` | Linux-пользователь для входа в панель | `viktor`; по умолчанию `SUDO_USER` или `root` |
| `VIRTUALITY_WEB_PORT` | Порт web-панели | `8088` |
| `VIRTUALITY_REPO_URL` | Источник исходников | `https://github.com/viktor138irk/virtuality.git` |
| `VIRTUALITY_INSTALL_URL` | URL самого установщика для self-install | raw.githubusercontent.com/... |
| `VIRTUALITY_PROJECT_BASE_DIR` / `VIRTUALITY_PROJECT_DIR` | Куда ставить проект | `/opt/virtuality`, `/opt/virtuality/source` |
| `VIRTUALITY_SETUP_BRIDGE` / `VIRTUALITY_BRIDGE_IFACE` | Настроить bridge `br0` и на каком интерфейсе | `0`, пусто |
| `VIRTUALITY_CREATE_TEST_VM` | Создать тестовую VM после установки | `0` |
| `VIRTUALITY_SKIP_REQUIREMENTS` | Пропустить проверку требований | `0` |
| `VIRTUALITY_MIN_ROOT_FREE_MB` / `MIN_VAR_FREE_MB` / `MIN_RAM_MB` / `MIN_CPU_CORES` | Пороги проверки требований | `8192` / `20480` / `4096` / `2` |
| `VIRTUALITY_CLEAN_BEFORE_INSTALL` | Очистка перед установкой | `0` |
| `VIRTUALITY_INSTALL_VERBOSE` | Подробный вывод установки | `0` |
- управление storage pools;
- сетевой менеджер bridge/VLAN;
- журнал событий.
Переменные времени выполнения панели (читаются из `/opt/virtuality/virtuality.env` и окружения):
Сделано из прошлого roadmap: cloud-image шаблоны (x86_64 и ARM64), cloud-init,
web-console/noVNC, снапшоты и бэкапы, автообновление.
| Переменная | Назначение | Значение по умолчанию |
| --- | --- | --- |
| `VIRTUALITY_AUTH_USER` | Linux-логин, который принимает форма входа | `viktor` |
| `VIRTUALITY_SESSION_SECRET` | Ключ подписи cookie-сессии | `dev-secret-change-me` — при установке генерируется и кладётся в `/var/lib/virtuality/config/session_secret` |
| `VIRTUALITY_SOURCE_DIR` | Каталог исходников для центра обновлений | `/opt/virtuality/source` |
| `VIRTUALITY_UPDATE_REMOTE` / `VIRTUALITY_UPDATE_BRANCH` | Git-remote и ветка обновлений | `origin` / `main` |
| `VIRTUALITY_UPDATE_ZIP_URL` | Резервный zip-источник обновления | архив ветки `main` на GitHub |
Сознательно не планируется: кластеризация, HA, Ceph/ZFS и роли пользователей —
Virtuality остаётся лёгкой панелью для одиночных нод.
Пароль пользователя нигде не хранится — проверяется напрямую по системному shadow. Профиль хоста пишется в `/var/lib/virtuality/config/host_profile.json`.
---
## Состояние
## Лицензия
Рабочий проект в активной разработке, доведённый до практического применения, но с сырой архитектурой (см. ниже). Версия по `VERSION``0.9.6`. 451 коммит, последний — 2026-05-07. С мая 2026 года работа в репозитории остановлена.
**MIT License** — см. [`LICENSE`](LICENSE).
Разработка шла очень интенсивно двое суток (2026-05-06 — 2026-05-07): от «Initial commit» до 0.9.6. Значительная часть коммитов — механические bump-версии и правки вёрстки панели.
## Что не доделано
- Патч-архитектура вместо нормального кода. Функциональность панели разложена по ~28 скриптам `scripts/patch_*.py`, которые текстовым поиском/вставкой правят установленный `app.py`. Репозиторный `web/app.py` неполон, порядок применения патчей критичен, повторная сборка хрупкая. Это главный технический долг проекта.
- Тесты и CI отсутствуют полностью: нет ни тестов, ни `.github/workflows`, ни линтеров.
- Ролевой модели нет: один Linux-пользователь = полный доступ к панели.
- HTTPS для панели не настраивается — только HTTP на порту 8088 (у Cockpit свой TLS).
- Кластеризация, backup/snapshot, cloud-init/cloud-image шаблоны заявлены в `docs/ROADMAP.md`, но в коде отсутствуют.
- Каталог `/var/lib/virtuality/backups` создаётся, но функций резервного копирования в панели нет.
- Явных `TODO`/`FIXME` в коде нет; незавершённость выражена в расхождении между ROADMAP и реализацией.
- Версия 0.9.6 так и не доведена до 1.0.
+432
Просмотреть файл
@@ -0,0 +1,432 @@
# Virtuality
**Virtuality** — лёгкая серверная платформа виртуализации на базе **KVM**, **QEMU**, **libvirt**, **Cockpit** и собственной web-панели.
Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией.
> Текущий статус: ранняя стадия разработки. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding.
---
## Поддерживаемые профили хоста
Virtuality автоматически определяет профиль сборки во время установки:
```text
x86_64 # обычный сервер, домашний сервер, VPS
raspberry-arm64 # Raspberry Pi ARM64 edge node
orangepi5-arm64 # Orange Pi 5 / RK3588 ARM64 edge node
generic-arm64 # другая ARM64-плата
```
Профиль сохраняется здесь:
```text
/var/lib/virtuality/config/host_profile.json
```
В web-панели профиль виден на странице:
```text
/host
```
Для ARM64-плат правильный сценарий — **ARM64-гости**, NAT-сеть и позже cloud-image/cloud-init шаблоны. x86_64 ISO на Raspberry/Orange Pi не являются целевым режимом.
---
## Быстрая установка одной командой
Одна команда работает и под `root`, и под обычным пользователем с `sudo`:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | bash
```
Как выбирается пользователь для входа в web-панель:
```text
Если запуск под root → login: root
Если запуск через sudo → login: текущий sudo-пользователь
Если задан VIRTUALITY_USER → login: указанный Linux-пользователь
```
Принудительно выбрать пользователя:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=<linux_user> bash
```
Пример:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=admin bash
```
Установка с авторизацией под `root`:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=root bash
```
Если пароль выбранного Linux-пользователя не задан:
```bash
sudo passwd <linux_user>
```
Для root:
```bash
passwd root
```
---
## Что уже есть
- универсальная one-command установка через `install.sh`;
- автоопределение профиля хоста: x86_64, Raspberry Pi ARM64, Orange Pi 5 ARM64, generic ARM64;
- установка разных пакетных наборов под x86 и ARM64;
- красивый пошаговый installer с live-status и логами;
- preflight-проверка системных требований;
- установка KVM/QEMU/libvirt;
- установка Cockpit и Cockpit Machines;
- рабочая директория `/opt/virtuality/source`;
- libvirt storage pools `virtuality-images` и `virtuality-iso`;
- firewall-правила для SSH, Cockpit, VNC и web-панели;
- безопасная ручная настройка bridge `br0` через `netplan try`;
- NAT Router `virtuality-nat` для VPS/ARM edge nodes;
- port forwarding через nftables;
- автоматическое определение IP VM для проброса портов;
- healthcheck одной командой `vhealth`;
- консольный dashboard для физического монитора;
- web-панель Virtuality на FastAPI;
- авторизация web-панели через системного Linux-пользователя;
- `/host` — профиль хоста и проверки готовности;
- `/network` — NAT, DHCP leases, port forwarding, nftables preview;
- `/iso` — ISO-менеджер;
- `/operations` — журнал фоновых операций;
- список VM;
- управление VM: Start, Shutdown, Reboot, Power off, Autostart on/off, Delete VM with disks;
- создание VM из web-интерфейса;
- выбор сетевого режима при создании VM: `virtuality-nat` или `br0`;
- прогресс создания VM и live-лог `virt-install`;
- страница деталей VM: `dominfo`, VNC display, IP, диски, сетевые интерфейсы.
---
## Системные требования
Минимально для x86_64 тестового стенда:
```text
CPU: 2 ядра с Intel VT-x / AMD-V
RAM: 4 GB
/: минимум 8 GB свободно
/var/lib: минимум 20 GB свободно
OS: Ubuntu Server 24.04 LTS / Debian-like с apt
Network: один проводной интерфейс
```
Рекомендуемо для x86_64:
```text
CPU: 4+ ядра
RAM: 16+ GB
Storage: 100+ GB SSD/NVMe под /var/lib/virtuality
Network: 1 Gbit/s+
OS: Ubuntu Server 24.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 фоновых операций
/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
cd /opt/virtuality/source
sudo bash scripts/install_web_panel.sh
```
Статус:
```bash
systemctl status virtuality-web --no-pager
```
Логи:
```bash
journalctl -u virtuality-web -f
```
Вход:
```text
Login: выбранный Linux-пользователь
Pass: пароль этого Linux-пользователя
```
---
## Сеть: Bridge и NAT Router
### Bridge br0
Подходит для домашнего/офисного сервера, где VM должны получать IP из локальной сети.
По умолчанию one-command установщик **не включает `br0` автоматически**, чтобы не уронить SSH-сессию.
Сначала нужно посмотреть сетевой интерфейс:
```bash
ip -br a
ip route
```
Затем запустить bridge setup, например для `enp2s0`:
```bash
cd /opt/virtuality/source
sudo bash scripts/setup_bridge_br0.sh enp2s0 static
```
Ожидаемый результат:
```text
enp2s0 UP
br0 UP SERVER_IP/24
default via GATEWAY dev br0
```
### Virtuality NAT Router
Подходит для VPS, Raspberry Pi, Orange Pi 5 и других ARM64 edge nodes.
Схема:
```text
Интернет / LAN
Хост Virtuality
virtuality-nat / virbr100
VM 192.168.100.x
```
Создать или починить NAT-сеть можно в web-панели:
```text
/network → Создать / починить NAT-сеть
```
Параметры по умолчанию:
```text
Network: virtuality-nat
Bridge: virbr100
Subnet: 192.168.100.0/24
Gateway: 192.168.100.1
DHCP: 192.168.100.50–192.168.100.200
```
Проброс порта добавляется в `/network`. IP VM определяется автоматически через `virsh domifaddr`, а если не получилось — через DHCP leases и MAC-адрес VM.
Пример SSH-проброса:
```text
VM: ubuntu-test
Внешний порт сервера: 2222
Порт внутри VM: 22
Протокол: tcp
```
Подключение:
```bash
ssh user@SERVER_IP -p 2222
```
---
## ISO и создание VM
Загрузка ISO через web-панель:
```text
/iso
```
Создание VM:
```text
/vm/create
```
При создании VM можно выбрать сеть:
```text
VPS NAT Router — virtuality-nat
Bridge — br0 / локальная сеть
```
Для x86_64 можно использовать обычные x86_64 ISO. Для Raspberry Pi / Orange Pi 5 нужны ARM64 ISO или, в будущем, ARM64 cloud images.
Пример загрузки Alpine x86_64 ISO:
```bash
sudo wget -O /var/lib/virtuality/iso/alpine-standard-x86_64.iso https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.3-x86_64.iso
sudo virsh pool-refresh virtuality-iso
```
---
## Обновление
```bash
cd /opt/virtuality/source
sudo git pull
sudo bash scripts/install_web_panel.sh
sudo systemctl restart virtuality-web
```
Полная повторная установка компонентов ноды:
```bash
cd /opt/virtuality/source
sudo bash install_virtuality_node.sh
sudo bash scripts/install_web_panel.sh
```
---
## Полезные команды
```bash
sudo vhealth
virsh list --all
virsh pool-list --all
virsh net-list --all
virsh net-dhcp-leases virtuality-nat
ip -br a
ip route
sudo nft list ruleset
sudo ufw status
systemctl status libvirtd --no-pager
systemctl status cockpit.socket --no-pager
systemctl status virtuality-web --no-pager
journalctl -u virtuality-web -f
```
---
## Roadmap
- ARM64 cloud-image templates;
- cloud-init для быстрых VM;
- web-console/noVNC;
- управление storage pools;
- backup/snapshot manager;
- сетевой менеджер bridge/VLAN;
- роли и права пользователей;
- журнал событий;
- автообновление;
- кластеризация.
---
## Лицензия
Рекомендуемая лицензия: **MIT License**.
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.9.13
0.9.6
+9
Просмотреть файл
@@ -1,11 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="${VIRTUALITY_APP_DIR:-/opt/virtuality/web}"
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DISK_IMAGES_DIR="/var/lib/virtuality/disk-images"
mkdir -p "$DISK_IMAGES_DIR"
chmod 755 "$DISK_IMAGES_DIR"
if [[ -f "${REPO_DIR}/scripts/patch_disk_images.py" ]]; then
python3 "${REPO_DIR}/scripts/patch_disk_images.py" "${APP_DIR}/app.py"
else
echo "patch_disk_images.py not found" >&2
exit 1
fi
systemctl restart virtuality-web.service
echo "Disk image support installed. Open: /disk-images"
+120 -62
Просмотреть файл
@@ -11,8 +11,6 @@ VENV_DIR="/opt/virtuality/venv"
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
BACKUP_SERVICE_FILE="/etc/systemd/system/virtuality-backup.service"
BACKUP_TIMER_FILE="/etc/systemd/system/virtuality-backup.timer"
PORT="${VIRTUALITY_WEB_PORT:-8088}"
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
LOG_DIR="/var/log/virtuality"
@@ -20,10 +18,6 @@ LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log"
PROFILE_DIR="/var/lib/virtuality/config"
PROFILE_FILE="${PROFILE_DIR}/host_profile.json"
SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret"
TLS_DIR="${PROFILE_DIR}/tls"
TLS_CERT="${TLS_DIR}/virtuality.crt"
TLS_KEY="${TLS_DIR}/virtuality.key"
TLS_ENABLED="${VIRTUALITY_TLS:-1}"
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
TOTAL_STEPS=13
CURRENT_STEP=0
@@ -81,6 +75,16 @@ run_logged() {
fi
}
restore_canonical_templates() {
local templates=("_sidebar.html" "dashboard.html" "vm_create.html" "vm_detail.html" "iso.html" "disk_images.html" "operations.html" "operation_detail.html" "host.html" "network.html" "logs.html" "update.html")
local name=""
for name in "${templates[@]}"; do
if [[ -f "${WEB_DIR}/templates/${name}" ]]; then
cp "${WEB_DIR}/templates/${name}" "${APP_DIR}/templates/${name}"
fi
done
}
service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
require_root() { [[ "$EUID" -eq 0 ]] || fail "Запусти через sudo: sudo bash scripts/install_web_panel.sh"; }
json_value() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$PROFILE_FILE" "$1" 2>/dev/null || true; }
@@ -139,7 +143,7 @@ step "Обновляем apt cache"
run_logged "apt update выполнен" apt update
step "Устанавливаем системные зависимости под выбранную сборку"
COMMON_PACKAGES=(python3 python3-venv python3-pip python3-dev rsync openssl curl wget unzip nftables novnc python3-websockify libvirt-dev pkg-config gcc)
COMMON_PACKAGES=(python3 python3-venv python3-pip rsync openssl curl wget unzip nftables novnc python3-websockify)
X86_PACKAGES=(qemu-system-x86 qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
ARM_PACKAGES=(qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
PACKAGES=("${COMMON_PACKAGES[@]}")
@@ -163,6 +167,113 @@ fi
step "Копируем web-панель в /opt/virtuality"
run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality
run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete "$WEB_DIR/" "$APP_DIR/"
if [[ -f "${REPO_DIR}/scripts/patch_web_console.py" ]]; then
run_logged "noVNC web-console patch применён" python3 "${REPO_DIR}/scripts/patch_web_console.py" "${APP_DIR}/app.py"
else
warn "patch_web_console.py не найден, noVNC console patch пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_upload_compat.py" ]]; then
run_logged "upload compatibility patch применён" python3 "${REPO_DIR}/scripts/patch_upload_compat.py" "${APP_DIR}/app.py"
else
warn "patch_upload_compat.py не найден, совместимость загрузки файлов пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" ]]; then
run_logged "upload navigation guard patch применён" python3 "${REPO_DIR}/scripts/patch_upload_navigation_guard.py" "${APP_DIR}/app.py"
else
warn "patch_upload_navigation_guard.py не найден, защита загрузок от переходов пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_disk_images.py" ]]; then
run_logged "disk images patch применён" python3 "${REPO_DIR}/scripts/patch_disk_images.py" "${APP_DIR}/app.py"
else
warn "patch_disk_images.py не найден, менеджер дисковых образов пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_vm_boot_order.py" ]]; then
run_logged "VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_vm_boot_order.py" "${APP_DIR}/app.py"
else
warn "patch_vm_boot_order.py не найден, порядок загрузки VM пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" ]]; then
run_logged "existing VM boot order patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_boot_order.py" "${APP_DIR}/app.py"
else
warn "patch_existing_vm_boot_order.py не найден, порядок загрузки существующих VM пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_resources.py" ]]; then
run_logged "existing VM resources patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_resources.py" "${APP_DIR}/app.py"
else
warn "patch_existing_vm_resources.py не найден, ресурсы существующих VM пропущены"
fi
if [[ -f "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" ]]; then
run_logged "existing VM ISO mount patch применён" python3 "${REPO_DIR}/scripts/patch_existing_vm_iso_mount.py" "${APP_DIR}/app.py"
else
warn "patch_existing_vm_iso_mount.py не найден, монтирование ISO в VM пропущено"
fi
if [[ -f "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" ]]; then
run_logged "VM detail resource layout patch применён" python3 "${REPO_DIR}/scripts/patch_vm_detail_resource_layout.py" "${APP_DIR}/app.py"
else
warn "patch_vm_detail_resource_layout.py не найден, раскладка ресурсов VM пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" ]]; then
run_logged "legacy boot order cleanup patch применён" python3 "${REPO_DIR}/scripts/patch_remove_legacy_boot_order_card.py" "${APP_DIR}/app.py"
else
warn "patch_remove_legacy_boot_order_card.py не найден, удаление старого блока порядка загрузки пропущено"
fi
run_logged "Канонические шаблоны панели восстановлены после VM-патчей" restore_canonical_templates
if [[ -f "${REPO_DIR}/scripts/patch_disk_archives.py" ]]; then
run_logged "disk archive import patch применён" python3 "${REPO_DIR}/scripts/patch_disk_archives.py" "${APP_DIR}/app.py"
else
warn "patch_disk_archives.py не найден, импорт архивов дисков пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" ]]; then
run_logged "DHCP leases empty-state patch применён" python3 "${REPO_DIR}/scripts/patch_dhcp_leases_empty.py" "${APP_DIR}/app.py"
else
warn "patch_dhcp_leases_empty.py не найден, диагностика DHCP leases пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_network_diagnostics.py" ]]; then
run_logged "network diagnostics patch применён" python3 "${REPO_DIR}/scripts/patch_network_diagnostics.py" "${APP_DIR}/app.py"
else
warn "patch_network_diagnostics.py не найден, диагностика сети пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_network_ranges.py" ]]; then
run_logged "network port ranges patch применён" python3 "${REPO_DIR}/scripts/patch_network_ranges.py" "${APP_DIR}/app.py"
else
warn "patch_network_ranges.py не найден, поддержка диапазонов портов пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_network_bridge_forwards.py" ]]; then
run_logged "network bridge forward patch применён" python3 "${REPO_DIR}/scripts/patch_network_bridge_forwards.py" "${APP_DIR}/app.py"
else
warn "patch_network_bridge_forwards.py не найден, проброс bridge/static VM пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_network_nat_errors.py" ]]; then
run_logged "network NAT error patch применён" python3 "${REPO_DIR}/scripts/patch_network_nat_errors.py" "${APP_DIR}/app.py"
else
warn "patch_network_nat_errors.py не найден, безопасные ошибки NAT пропущены"
fi
if [[ -f "${REPO_DIR}/scripts/patch_logs_center.py" ]]; then
run_logged "logs center patch применён" python3 "${REPO_DIR}/scripts/patch_logs_center.py" "${APP_DIR}/app.py"
else
warn "patch_logs_center.py не найден, центр журналов пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_vm_network_guard.py" ]]; then
run_logged "VM network guard patch применён" python3 "${REPO_DIR}/scripts/patch_vm_network_guard.py" "${APP_DIR}/app.py"
else
warn "patch_vm_network_guard.py не найден, защита от отсутствующего bridge пропущена"
fi
if [[ -f "${REPO_DIR}/scripts/patch_update_center.py" ]]; then
run_logged "update center patch применён" python3 "${REPO_DIR}/scripts/patch_update_center.py" "${APP_DIR}/app.py"
else
warn "patch_update_center.py не найден, центр обновлений пропущен"
fi
if [[ -f "${REPO_DIR}/scripts/patch_live_status.py" ]]; then
run_logged "live status patch применён" python3 "${REPO_DIR}/scripts/patch_live_status.py" "${APP_DIR}/app.py"
else
warn "patch_live_status.py не найден, live-статусы пропущены"
fi
if [[ -f "${REPO_DIR}/scripts/patch_vm_autostart.py" ]]; then
run_logged "VM autostart patch применён" python3 "${REPO_DIR}/scripts/patch_vm_autostart.py" "${APP_DIR}/app.py"
else
warn "patch_vm_autostart.py не найден, нормализация автозапуска VM пропущена"
fi
run_logged "Канонические шаблоны панели финально восстановлены" restore_canonical_templates
run_logged "Конфиг профиля доступен web-панели" mkdir -p "$PROFILE_DIR"
if [[ -f "$PROFILE_FILE" ]]; then
ok "Профиль уже сохранён: $PROFILE_FILE"
@@ -189,18 +300,6 @@ TMP=${UPLOAD_TMP_DIR}
EOF
chmod 600 "${APP_DIR}/.env"
ok "Создан ${APP_DIR}/.env"
if [[ "$TLS_ENABLED" == "1" ]]; then
if [[ -s "$TLS_CERT" && -s "$TLS_KEY" ]]; then
ok "Используем существующий TLS-сертификат: $TLS_CERT"
else
mkdir -p "$TLS_DIR"
run_logged "Самоподписанный TLS-сертификат создан на 10 лет" openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes -keyout "$TLS_KEY" -out "$TLS_CERT" -subj "/CN=virtuality" -addext "subjectAltName=DNS:virtuality,IP:127.0.0.1"
chmod 600 "$TLS_KEY"
fi
ok "Панель будет работать по HTTPS (отключить: VIRTUALITY_TLS=0)"
else
warn "TLS отключён через VIRTUALITY_TLS=0 — панель будет работать по HTTP"
fi
ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
@@ -214,17 +313,8 @@ fi
step "Устанавливаем Python-зависимости"
run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
if "$VENV_DIR/bin/pip" install libvirt-python >> "$LOG_FILE" 2>&1; then
ok "libvirt-python установлен — панель работает через быстрый libvirt-backend"
else
warn "libvirt-python не установился — панель будет работать через virsh (медленнее, но полностью функционально)"
fi
step "Создаём systemd service"
UVICORN_TLS_ARGS=""
if [[ "$TLS_ENABLED" == "1" ]]; then
UVICORN_TLS_ARGS=" --ssl-certfile ${TLS_CERT} --ssl-keyfile ${TLS_KEY}"
fi
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=Virtuality Web Panel
@@ -234,7 +324,7 @@ Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host 0.0.0.0 --port ${PORT}${UVICORN_TLS_ARGS}
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host 0.0.0.0 --port ${PORT}
Restart=always
RestartSec=3
User=root
@@ -288,33 +378,6 @@ EOF
else
warn "auto_update_check.sh не найден, автообновление пропущено"
fi
cat > "$BACKUP_SERVICE_FILE" <<EOF
[Unit]
Description=Virtuality scheduled VM backups
After=libvirtd.service
[Service]
Type=oneshot
User=root
Group=root
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/backup_core.py --scheduled
EOF
cat > "$BACKUP_TIMER_FILE" <<EOF
[Unit]
Description=Run Virtuality VM backups nightly at 04:30 Moscow time
[Timer]
OnCalendar=*-*-* 01:30:00 UTC
AccuracySec=5min
Persistent=true
Unit=virtuality-backup.service
[Install]
WantedBy=timers.target
EOF
run_logged "systemd daemon-reload выполнен для бэкапов" systemctl daemon-reload
run_logged "virtuality-backup.timer включён" systemctl enable --now virtuality-backup.timer
ok "Ночные бэкапы VM: расписание настраивается на странице /backups"
step "Запускаем Virtuality Web Panel"
run_logged "virtuality-web.service включён и запущен" systemctl enable --now virtuality-web.service
@@ -341,18 +404,13 @@ else
fi
SERVER_IP="$(hostname -I | awk '{print $1}')"
PANEL_SCHEME="http"
[[ "$TLS_ENABLED" == "1" ]] && PANEL_SCHEME="https"
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} ${PANEL_SCHEME}://${SERVER_IP}:${PORT}"
if [[ "$TLS_ENABLED" == "1" ]]; then
echo -e "${DIM}Сертификат самоподписанный — браузер один раз спросит подтверждение.${RESET}"
fi
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}"
+210
Просмотреть файл
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helpers = r'''
def is_x86_host(profile: dict[str, Any]) -> bool:
return str(profile.get("arch", "")) in ("x86_64", "amd64")
def is_arm64_guest_on_x86(guest_arch: str, profile: dict[str, Any]) -> bool:
return guest_arch == "aarch64" and is_x86_host(profile)
def available_memory_mib() -> int:
try:
for line in Path('/proc/meminfo').read_text().splitlines():
if line.startswith('MemAvailable:'):
return int(line.split()[1]) // 1024
except Exception:
return 0
return 0
def arm64_emulation_memory_error(memory: int) -> str | None:
available = available_memory_mib()
requested = int(memory)
reserve = 768
if available <= 0:
return None
if requested + reserve <= available:
return None
recommended = max(512, available - reserve)
return (
f"Недостаточно RAM для ARM64-эмуляции: запрошено {requested} MB, "
f"доступно около {available} MB, нужен запас минимум {reserve} MB. "
f"Уменьши RAM VM примерно до {recommended} MB или освободи память на хосте."
)
def xml_escape(value: str) -> str:
return str(value).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def arm64_emulation_xml(name: str, memory: int, vcpus: int, disk_path: Path, network_mode: str, bridge: str) -> str:
if network_mode == "nat":
interface_xml = f"""
<interface type='network'>
<source network='{xml_escape(network_core.NETWORK_NAME)}'/>
<model type='virtio'/>
</interface>"""
else:
interface_xml = f"""
<interface type='bridge'>
<source bridge='{xml_escape(bridge)}'/>
<model type='virtio'/>
</interface>"""
return f"""<domain type='qemu'>
<name>{xml_escape(name)}</name>
<memory unit='MiB'>{int(memory)}</memory>
<currentMemory unit='MiB'>{int(memory)}</currentMemory>
<vcpu placement='static'>{int(vcpus)}</vcpu>
<os>
<type arch='aarch64' machine='virt'>hvm</type>
</os>
<cpu mode='custom' match='exact'>
<model fallback='allow'>cortex-a57</model>
</cpu>
<features>
<gic version='3'/>
</features>
<clock offset='utc'/>
<on_poweroff>destroy</on_poweroff>
<on_reboot>restart</on_reboot>
<on_crash>restart</on_crash>
<devices>
<emulator>/usr/bin/qemu-system-aarch64</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='{xml_escape(str(disk_path))}'/>
<target dev='vda' bus='virtio'/>
</disk>{interface_xml}
<graphics type='vnc' port='-1' autoport='yes' listen='0.0.0.0'>
<listen type='address' address='0.0.0.0'/>
</graphics>
<video>
<model type='virtio'/>
</video>
<console type='pty'>
<target type='serial' port='0'/>
</console>
</devices>
</domain>
"""
def make_arm64_emulation_script(name: str, memory: int, vcpus: int, disk_path: Path, network_mode: str, bridge: str) -> str:
xml_path = Path('/tmp') / f"virtuality-{name}-arm64.xml"
xml_path.write_text(arm64_emulation_xml(name, memory, vcpus, disk_path, network_mode, bridge))
return f"virsh define {xml_path} && virsh start {name}"
'''
if 'def arm64_emulation_xml(' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker not in text:
raise SystemExit('valid_vm_name marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('ARM64 emulation XML helpers added')
else:
if 'def arm64_emulation_memory_error(' not in text:
marker = '\n\ndef xml_escape(value: str) -> str:'
if marker not in text:
raise SystemExit('xml_escape marker not found')
memory_helpers = r'''
def available_memory_mib() -> int:
try:
for line in Path('/proc/meminfo').read_text().splitlines():
if line.startswith('MemAvailable:'):
return int(line.split()[1]) // 1024
except Exception:
return 0
return 0
def arm64_emulation_memory_error(memory: int) -> str | None:
available = available_memory_mib()
requested = int(memory)
reserve = 768
if available <= 0:
return None
if requested + reserve <= available:
return None
recommended = max(512, available - reserve)
return (
f"Недостаточно RAM для ARM64-эмуляции: запрошено {requested} MB, "
f"доступно около {available} MB, нужен запас минимум {reserve} MB. "
f"Уменьши RAM VM примерно до {recommended} MB или освободи память на хосте."
)
'''
text = text.replace(marker, memory_helpers + marker, 1)
changed.append('ARM64 memory guard helpers added')
else:
changed.append('ARM64 memory guard helpers already present')
changed.append('ARM64 emulation XML helpers already present')
old_disk_cmd = ''' if source_type == "disk_image":
source_disk = Path(disk_image_path).resolve()
source_format = disk_image_format(source_disk)
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
else:
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
new_disk_cmd = ''' if source_type == "disk_image":
source_disk = Path(disk_image_path).resolve()
source_format = disk_image_format(source_disk)
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
if is_arm64_guest_on_x86(resolved_guest_arch, profile):
memory_error = arm64_emulation_memory_error(memory)
if memory_error:
return vm_form_context(request, error=memory_error, form=form, status_code=400)
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
else:
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
else:
if is_arm64_guest_on_x86(resolved_guest_arch, profile):
return vm_form_context(request, error="ARM64 ISO на x86-хосте пока не поддерживается. Загрузи готовый ARM64 .img/.qcow2 в разделе «Диски» и создай VM из готового диска.", form=form, status_code=400)
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
old_arm64_xml_cmd = ''' if is_arm64_guest_on_x86(resolved_guest_arch, profile):
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
else:
'''
new_arm64_xml_cmd = ''' if is_arm64_guest_on_x86(resolved_guest_arch, profile):
memory_error = arm64_emulation_memory_error(memory)
if memory_error:
return vm_form_context(request, error=memory_error, form=form, status_code=400)
create_cmd = make_arm64_emulation_script(name, memory, vcpus, disk_path, network_mode, bridge)
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {create_cmd}"]
else:
'''
if old_disk_cmd in text:
text = text.replace(old_disk_cmd, new_disk_cmd, 1)
changed.append('ARM64 disk image on x86 uses direct libvirt XML with memory guard')
elif old_arm64_xml_cmd in text:
text = text.replace(old_arm64_xml_cmd, new_arm64_xml_cmd, 1)
changed.append('ARM64 memory guard added to existing XML path')
elif 'memory_error = arm64_emulation_memory_error(memory)' in text:
changed.append('ARM64 memory guard already present')
elif 'make_arm64_emulation_script' in text:
changed.append('ARM64 disk image XML path already present')
else:
raise SystemExit('disk image create command marker not found')
app_path.write_text(text)
print('arm64 emulation xml patch applied:')
for item in changed:
print(f'- {item}')
+271
Просмотреть файл
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
changed: list[str] = []
warnings: list[str] = []
if not app_path.exists():
print(f'WARN: app.py not found: {app_path}')
raise SystemExit(0)
templates_dir = app_path.parent / 'templates'
dashboard_template_path = templates_dir / 'dashboard.html'
network_template_path = templates_dir / 'network.html'
if not dashboard_template_path.exists():
print(f'WARN: dashboard.html not found: {dashboard_template_path}')
raise SystemExit(0)
text = app_path.read_text(encoding='utf-8')
text, removed_helpers = re.subn(
r"\n\ndef parse_dhcp_leases_output\(output: str\).*?\n\ndef parse_virsh_list\(\) -> list\[dict\[str, str\]\]:",
"\n\ndef parse_virsh_list() -> list[dict[str, str]]:",
text,
count=1,
flags=re.S,
)
if removed_helpers:
changed.append('old VM IP helper block was removed')
new_parse_virsh_list = '''def parse_virsh_list() -> list[dict[str, str]]:
def clean_ip(ip: str) -> str:
ip = (ip or "").split("/")[0].strip()
if not ip or ip.startswith("127.") or ip.startswith("169.254.") or ip == "0.0.0.0":
return ""
return ip
def manual_ip_map() -> dict[str, str]:
path = Path("/var/lib/virtuality/network/vm_ips.json")
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return {str(k): clean_ip(str(v)) for k, v in data.items() if clean_ip(str(v))}
except Exception:
pass
return {}
def vm_macs(name: str) -> list[str]:
try:
result = run_cmd(["virsh", "domiflist", name], timeout=8)
if not result.get("ok"):
return []
return [mac.lower() for mac in re.findall(r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}", result.get("stdout", ""))]
except Exception:
return []
def ip_from_domifaddr(name: str) -> str:
try:
result = run_cmd(["virsh", "domifaddr", name], timeout=8)
if result.get("ok"):
for ip in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})/\\d+", result.get("stdout", "")):
ip = clean_ip(ip)
if ip:
return ip
except Exception:
pass
return ""
def ip_from_network_core(name: str) -> str:
try:
resolved = network_core.resolve_vm_ip(name)
if resolved:
return clean_ip(str(resolved))
except Exception:
pass
return ""
def ip_from_dnsmasq_leases(macs: list[str]) -> str:
if not macs:
return ""
try:
for lease_file in Path("/var/lib/libvirt/dnsmasq").glob("*.leases"):
for line in lease_file.read_text(errors="ignore").splitlines():
low = line.lower()
if not any(mac in low for mac in macs):
continue
parts = line.split()
if len(parts) >= 3:
ip = clean_ip(parts[2])
if ip:
return ip
except Exception:
pass
return ""
def ip_from_neighbor_tables(macs: list[str]) -> str:
if not macs:
return ""
commands = [["ip", "neigh", "show"], ["ip", "neigh", "show", "dev", "virbr100"], ["ip", "neigh", "show", "dev", "br0"], ["arp", "-an"]]
for cmd in commands:
try:
result = run_cmd(cmd, timeout=8)
if not result.get("ok"):
continue
for line in result.get("stdout", "").splitlines():
low = line.lower()
if not any(mac in low for mac in macs):
continue
for value in re.findall(r"\\b(\\d{1,3}(?:\\.\\d{1,3}){3})\\b", line):
ip = clean_ip(value)
if ip:
return ip
except Exception:
pass
return ""
manual_ips = manual_ip_map()
def resolve_ip(name: str) -> str:
if not name:
return ""
if manual_ips.get(name):
return manual_ips[name]
macs = vm_macs(name)
for resolver in (lambda: ip_from_domifaddr(name), lambda: ip_from_network_core(name), lambda: ip_from_dnsmasq_leases(macs), lambda: ip_from_neighbor_tables(macs)):
try:
ip = resolver()
if ip:
return ip
except Exception:
pass
return ""
result = run_cmd(["virsh", "list", "--all"])
rows = []
if not result["ok"]:
return rows
for line in result["stdout"].splitlines()[2:]:
parts = line.strip().split(None, 2)
if len(parts) == 3:
rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})
elif len(parts) == 2:
rows.append({"id": "-", "name": parts[0], "state": parts[1]})
for row in rows:
row["ip"] = resolve_ip(row.get("name", ""))
row["manual_ip"] = manual_ips.get(row.get("name", ""), "")
return rows
'''
pattern = r"def parse_virsh_list\(\) -> list\[dict\[str, str\]\]:.*?\n\ndef parse_pool_list\(\) -> list\[dict\[str, str\]\]:"
replacement = new_parse_virsh_list + "\n\ndef parse_pool_list() -> list[dict[str, str]]:"
text, replaced = re.subn(pattern, lambda _match: replacement, text, count=1, flags=re.S)
if replaced:
changed.append('parse_virsh_list was replaced with manual-first VM IP resolver')
else:
warnings.append('parse_virsh_list block not found, app.py IP injection skipped')
manual_route = '''
@app.post("/network/vm-ip/save")
def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
if not valid_vm_name(vm_name):
return RedirectResponse(url="/network", status_code=303)
manual_ip = (manual_ip or "").strip()
if manual_ip and not re.fullmatch(r"(25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}", manual_ip):
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": "Некорректный ручной IP VM"}, status_code=400)
path = Path("/var/lib/virtuality/network/vm_ips.json")
path.parent.mkdir(parents=True, exist_ok=True)
try:
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
if not isinstance(data, dict):
data = {}
except Exception:
data = {}
if manual_ip:
data[vm_name] = manual_ip
else:
data.pop(vm_name, None)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
return RedirectResponse(url="/network", status_code=303)
'''
if '/network/vm-ip/save' not in text:
marker = '\n\n@app.post("/network/nat/setup")'
if marker in text:
text = text.replace(marker, manual_route + marker, 1)
changed.append('manual VM IP route was added')
else:
warnings.append('network NAT route marker not found, manual IP route skipped')
else:
changed.append('manual VM IP route was already present')
app_path.write_text(text, encoding='utf-8')
dashboard_html = dashboard_template_path.read_text(encoding='utf-8')
header_old = '<tr><th>ID</th><th>Name</th><th>State</th><th>Actions</th></tr>'
header_new = '<tr><th>ID</th><th>Name</th><th>IP</th><th>State</th><th>Actions</th></tr>'
name_cell = '''<td class="strong"><a class="table-link" href="/vm/{{ vm.name }}">{{ vm.name }}</a></td>
<td><span'''
name_ip_cell = '''<td class="strong"><a class="table-link" href="/vm/{{ vm.name }}">{{ vm.name }}</a></td>
<td class="strong">{{ vm.ip|default("") }}</td>
<td><span'''
if '<th>IP</th>' not in dashboard_html:
dashboard_html = dashboard_html.replace(header_old, header_new).replace(name_cell, name_ip_cell).replace('colspan="4" class="muted">Виртуальных машин пока нет', 'colspan="5" class="muted">Виртуальных машин пока нет')
changed.append('Dashboard VM IP column was added')
elif 'vm.ip' not in dashboard_html:
dashboard_html = dashboard_html.replace(name_cell, name_ip_cell)
changed.append('Dashboard VM IP cell was added')
else:
changed.append('Dashboard VM IP column was already present')
dashboard_template_path.write_text(dashboard_html, encoding='utf-8')
if network_template_path.exists():
network_html = network_template_path.read_text(encoding='utf-8')
manual_card = '''
<article class="card">
<div class="card-head">
<h2>Ручные IP VM</h2>
<span class="pill">override</span>
</div>
<div class="notice">Если VM в bridge/static-сети и IP не виден через DHCP/ARP, укажи адрес вручную. Этот IP будет первым источником для таблицы и проброса портов.</div>
<table class="top-space">
<thead><tr><th>VM</th><th>Текущий IP</th><th>Ручной IP</th><th>Действие</th></tr></thead>
<tbody>
{% for vm in vms %}
<tr>
<td class="strong">{{ vm.name }}</td>
<td>{{ vm.ip|default("") }}</td>
<td>
<form method="post" action="/network/vm-ip/save" class="inline-form">
<input type="hidden" name="vm_name" value="{{ vm.name }}">
<input type="text" name="manual_ip" value="{{ vm.manual_ip|default("") }}" placeholder="например 10.0.0.50" pattern="(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])){3}">
</td>
<td><button>Сохранить</button></form></td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">VM пока нет</td></tr>
{% endfor %}
</tbody>
</table>
</article>
'''
if 'Ручные IP VM' not in network_html:
marker = ' <article class="card">\n <div class="card-head">\n <h2>Диагностика публичного доступа</h2>'
if marker in network_html:
network_html = network_html.replace(marker, manual_card + '\n' + marker, 1)
changed.append('manual VM IP card was added to network page')
else:
warnings.append('network diagnostics card marker not found, manual IP card skipped')
else:
changed.append('manual VM IP card was already present')
network_template_path.write_text(network_html, encoding='utf-8')
else:
warnings.append('network.html not found, manual IP card skipped')
print('DHCP leases empty-state patch completed:')
for item in changed:
print(f'- {item}')
for item in warnings:
print(f'WARN: {item}')
raise SystemExit(0)
+316
Просмотреть файл
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
if 'import zipfile' not in text:
text = text.replace('import uuid\n', 'import uuid\nimport lzma\nimport tarfile\nimport zipfile\n', 1)
changed.append('archive imports added')
elif 'import lzma' not in text:
text = text.replace('import tarfile\n', 'import lzma\nimport tarfile\n', 1) if 'import tarfile\n' in text else text.replace('import zipfile\n', 'import lzma\nimport zipfile\n', 1)
changed.append('lzma import added')
else:
changed.append('archive imports already present')
helpers = r'''
def safe_disk_upload_filename(filename: str) -> str | None:
original = Path(filename or "").name.strip()
lower = original.lower()
if lower.endswith(".tar.gz"):
suffix = ".tar.gz"
stem = original[:-7]
elif lower.endswith(".img.xz"):
suffix = ".img.xz"
stem = original[:-7]
elif lower.endswith(".tgz"):
suffix = ".tgz"
stem = original[:-4]
else:
suffix = Path(original).suffix.lower()
stem = Path(original).stem
if suffix not in (".img", ".raw", ".qcow2", ".img.xz", ".zip", ".tar.gz", ".tgz"):
return None
stem = re.sub(r"\s+", "-", stem.strip())
stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem)
stem = stem.strip("._-")[:120]
if not stem:
stem = "virtuality-disk"
return f"{stem}{suffix}"
def disk_upload_is_archive(name: str) -> bool:
lower = str(name or "").lower()
return lower.endswith((".zip", ".tar.gz", ".tgz"))
def disk_upload_is_xz_image(name: str) -> bool:
return str(name or "").lower().endswith(".img.xz")
def disk_upload_is_image(name: str) -> bool:
lower = str(name or "").lower()
return lower.endswith((".img", ".raw", ".qcow2", ".img.xz"))
def archive_member_is_safe(name: str) -> bool:
if not name:
return False
p = Path(name)
if p.is_absolute():
return False
return ".." not in p.parts
def archive_member_basename(name: str) -> str | None:
return safe_disk_image_filename(Path(name).name)
def unique_disk_image_path(name: str) -> Path:
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
safe = safe_disk_image_filename(name)
if not safe:
raise ValueError("Некорректное имя образа диска")
target = DISK_IMAGES_DIR / safe
if not target.exists():
return target
stem = target.stem
suffix = target.suffix
for index in range(1, 1000):
candidate = DISK_IMAGES_DIR / f"{stem}-{index}{suffix}"
if not candidate.exists():
return candidate
raise ValueError("Не удалось подобрать свободное имя файла")
def find_archive_disk_members(archive_path: Path) -> list[dict[str, Any]]:
members: list[dict[str, Any]] = []
lower = archive_path.name.lower()
if lower.endswith(".zip"):
with zipfile.ZipFile(archive_path) as archive:
for info in archive.infolist():
if info.is_dir() or not archive_member_is_safe(info.filename):
continue
safe_name = archive_member_basename(info.filename)
if safe_name:
members.append({"kind": "zip", "name": info.filename, "safe_name": safe_name, "size": int(info.file_size)})
elif lower.endswith((".tar.gz", ".tgz")):
with tarfile.open(archive_path, "r:gz") as archive:
for info in archive.getmembers():
if not info.isfile() or not archive_member_is_safe(info.name):
continue
safe_name = archive_member_basename(info.name)
if safe_name:
members.append({"kind": "tar", "name": info.name, "safe_name": safe_name, "size": int(info.size)})
else:
raise ValueError("Поддерживаются только .zip, .tar.gz и .tgz")
return sorted(members, key=lambda item: item.get("size", 0), reverse=True)
def extract_disk_archive(archive_path: Path) -> list[Path]:
members = find_archive_disk_members(archive_path)
if not members:
raise ValueError("В архиве не найдено .img, .raw или .qcow2 файлов")
extracted: list[Path] = []
lower = archive_path.name.lower()
if lower.endswith(".zip"):
with zipfile.ZipFile(archive_path) as archive:
for member in members:
target = unique_disk_image_path(member["safe_name"])
with archive.open(member["name"], "r") as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
extracted.append(target)
else:
with tarfile.open(archive_path, "r:gz") as archive:
for member in members:
file_obj = archive.extractfile(member["name"])
if file_obj is None:
continue
target = unique_disk_image_path(member["safe_name"])
with file_obj as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
extracted.append(target)
return extracted
def extract_xz_disk_image(compressed_path: Path, safe_name: str | None = None) -> Path:
source_name = safe_name or compressed_path.name
if not source_name.lower().endswith(".img.xz"):
raise ValueError("Поддерживаются только сжатые образы .img.xz")
raw_name = source_name[:-3]
target = unique_disk_image_path(raw_name)
try:
with lzma.open(compressed_path, "rb") as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
except Exception:
target.unlink(missing_ok=True)
raise
return target
def disk_convert_target_path(source_path: Path) -> Path:
if source_path.suffix.lower() == ".qcow2":
return source_path
target = source_path.with_suffix(".qcow2")
if not target.exists():
return target
for index in range(1, 1000):
candidate = source_path.with_name(f"{source_path.stem}-{index}.qcow2")
if not candidate.exists():
return candidate
raise ValueError("Не удалось подобрать имя qcow2 для конвертации")
def disk_convert_progress(line: str, current: int) -> int:
match = re.search(r"\((\d+(?:\.\d+)?)/100%\)", line or "")
if match:
return max(current, int(float(match.group(1))))
match = re.search(r"(\d+(?:\.\d+)?)%", line or "")
if match:
return max(current, int(float(match.group(1))))
return current
def run_disk_convert_worker(operation_id: str, source_path: str, target_path: str) -> None:
operation = read_operation(operation_id)
if not operation:
return
source = Path(source_path)
target = Path(target_path)
update_operation(operation, status="running", progress=1, message=f"Конвертация {source.name} в qcow2", started_at=utc_now())
cmd = ["qemu-img", "convert", "-p", "-f", disk_image_format(source), "-O", "qcow2", str(source), str(target)]
append_operation_log(operation_id, "Запуск конвертации:")
append_operation_log(operation_id, " ".join(cmd))
try:
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
progress = 1
if process.stdout:
for line in process.stdout:
append_operation_log(operation_id, line)
progress = disk_convert_progress(line, progress)
fresh = read_operation(operation_id) or operation
update_operation(fresh, progress=progress, message=f"Конвертация {source.name}: {progress}%")
exit_code = process.wait()
fresh = read_operation(operation_id) or operation
if exit_code == 0:
append_operation_log(operation_id, f"Конвертация завершена: {target}")
update_operation(fresh, status="success", progress=100, exit_code=exit_code, message=f"Готово: {target.name}", finished_at=utc_now(), target_path=str(target))
else:
append_operation_log(operation_id, f"qemu-img завершился с ошибкой. Exit code: {exit_code}")
target.unlink(missing_ok=True)
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"qemu-img завершился с ошибкой: {exit_code}", finished_at=utc_now())
except Exception as exc:
fresh = read_operation(operation_id) or operation
append_operation_log(operation_id, f"Ошибка конвертации: {exc}")
target.unlink(missing_ok=True)
update_operation(fresh, status="error", progress=100, exit_code=-1, message=str(exc), finished_at=utc_now())
def start_disk_convert_operation(source_path: Path) -> dict[str, Any] | None:
if source_path.suffix.lower() == ".qcow2":
return None
target_path = disk_convert_target_path(source_path)
operation_id = str(uuid.uuid4())
operation = {
"id": operation_id,
"type": "disk_convert",
"title": f"Конвертация {source_path.name}",
"status": "queued",
"progress": 0,
"message": "Конвертация поставлена в очередь",
"created_at": utc_now(),
"updated_at": utc_now(),
"created_by": AUTH_USER,
"source_path": str(source_path),
"target_path": str(target_path),
}
write_operation(operation)
append_operation_log(operation_id, "Операция поставлена в очередь.")
threading.Thread(target=run_disk_convert_worker, args=(operation_id, str(source_path), str(target_path)), daemon=True).start()
return operation
def disk_upload_response(request: Request, payload: dict[str, Any]):
if request.headers.get("x-requested-with") == "XMLHttpRequest" or "application/json" in request.headers.get("accept", ""):
return JSONResponse(payload)
return RedirectResponse(url="/disk-images", status_code=303)
'''
if 'def safe_disk_upload_filename(' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker not in text:
marker = '\n\ndef bridge_exists(name: str) -> bool:'
if marker not in text:
raise SystemExit('helper insert marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('disk archive helpers added')
else:
pattern = r"\n\ndef safe_disk_upload_filename\(filename: str\).*?\n\ndef disk_upload_response\(request: Request, payload: dict\[str, Any\]\):.*?\n return RedirectResponse\(url=\"/disk-images\", status_code=303\)\n"
text, count = re.subn(pattern, helpers, text, count=1, flags=re.S)
changed.append('disk archive helpers replaced with img.xz support' if count else 'disk archive helpers already present')
old_route_start = text.find('@app.post("/disk-images/upload"')
if old_route_start == -1:
raise SystemExit('disk image upload route not found')
next_route = text.find('\n\n@app.post("/disk-images/{name}/delete")', old_route_start)
if next_route == -1:
raise SystemExit('disk image delete route marker not found')
new_route = r'''@app.post("/disk-images/upload", response_class=HTMLResponse)
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_disk_upload_filename(image_file.filename or "")
if not safe_name:
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw, .qcow2, .img.xz, .zip, .tar.gz или .tgz файлы."}, status_code=400)
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
saved_paths: list[Path] = []
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out, length=1024 * 1024)
if disk_upload_is_archive(safe_name):
saved_paths = extract_disk_archive(tmp_target)
tmp_target.unlink(missing_ok=True)
elif disk_upload_is_xz_image(safe_name):
saved_paths = [extract_xz_disk_image(tmp_target, safe_name)]
tmp_target.unlink(missing_ok=True)
else:
target = unique_disk_image_path(safe_name)
tmp_target.rename(target)
saved_paths = [target]
except Exception as exc:
tmp_target.unlink(missing_ok=True)
for path in saved_paths:
path.unlink(missing_ok=True)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500)
operations = []
for path in saved_paths:
operation = start_disk_convert_operation(path)
if operation:
operations.append(operation)
payload = {
"ok": True,
"mode": "converting" if operations else "ready",
"operation_id": operations[0]["id"] if operations else None,
"operation_ids": [op["id"] for op in operations],
"files": [path.name for path in saved_paths],
"message": f"Загружено файлов: {len(saved_paths)}. Конвертаций запущено: {len(operations)}.",
}
return disk_upload_response(request, payload)
'''
text = text[:old_route_start] + new_route + text[next_route:]
changed.append('disk image upload route supports img.xz and archives')
app_path.write_text(text)
print('disk archive import patch applied:')
for item in changed:
print(f'- {item}')
+171
Просмотреть файл
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
print(f'WARN: app.py not found: {app_path}')
raise SystemExit(0)
text = app_path.read_text()
changed = []
warnings = []
old_progress = '''def progress_from_line(current: int, line: str) -> int:
low = line.lower()
if "allocating" in low or "creating storage" in low:
return max(current, 30)
if "starting install" in low or "installing" in low:
return max(current, 50)
if "creating domain" in low:
return max(current, 75)
if "domain creation completed" in low or "installation continues" in low:
return max(current, 90)
return current
'''
new_progress = '''def progress_from_line(current: int, line: str) -> int:
low = line.lower()
convert_match = re.search(r"\\((\\d+(?:\\.\\d+)?)\\s*/\\s*100%\\)", line)
if convert_match:
return max(1, min(99, int(float(convert_match.group(1)))))
percent_match = re.search(r"(\\d+(?:\\.\\d+)?)%", line)
if "qemu-img" in low and percent_match:
return max(1, min(99, int(float(percent_match.group(1)))))
if "allocating" in low or "creating storage" in low:
return max(current, 30)
if "starting install" in low or "installing" in low:
return max(current, 50)
if "creating domain" in low:
return max(current, 75)
if "domain creation completed" in low or "installation continues" in low:
return max(current, 90)
return current
'''
if old_progress in text:
text = text.replace(old_progress, new_progress, 1)
changed.append('qemu-img percent parser added')
elif 'convert_match = re.search' in text:
changed.append('qemu-img percent parser already present')
else:
warnings.append('progress_from_line marker not found, progress parser skipped')
helpers = r'''
def qemu_img_format_for_path(path: Path) -> str:
suffix = path.suffix.lower()
if suffix == ".qcow2":
return "qcow2"
return "raw"
def qcow2_name_for_upload(safe_name: str) -> str:
base = Path(safe_name).stem
return f"{base}.qcow2"
def start_disk_image_convert_operation(source_path: Path, target_path: Path, source_format: str) -> dict[str, Any]:
operation_id = str(uuid.uuid4())
cmd = ["qemu-img", "convert", "-p", "-f", source_format, "-O", "qcow2", str(source_path), str(target_path)]
operation = {
"id": operation_id,
"type": "disk_image_convert",
"title": f"Конвертация образа {source_path.name}",
"status": "queued",
"progress": 0,
"message": "Ожидание конвертации образа",
"created_at": utc_now(),
"updated_at": utc_now(),
"created_by": AUTH_USER,
"source_path": str(source_path),
"target_path": str(target_path),
"source_format": source_format,
"cmd": " ".join(cmd),
}
start_background_operation(operation, cmd)
return operation
'''
if 'def start_disk_image_convert_operation(' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('disk conversion operation helpers added')
else:
warnings.append('valid_vm_name marker not found, conversion helpers skipped')
else:
changed.append('disk conversion operation helpers already present')
old_upload = r'''@app.post("/disk-images/upload", response_class=HTMLResponse)
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_disk_image_filename(image_file.filename or "")
if not safe_name:
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем."}, status_code=400)
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
target = DISK_IMAGES_DIR / safe_name
if target.exists():
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Образ уже существует: {safe_name}"}, status_code=400)
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out)
tmp_target.rename(target)
except Exception as exc:
tmp_target.unlink(missing_ok=True)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки образа: {exc}"}, status_code=500)
return RedirectResponse(url="/disk-images", status_code=303)
'''
new_upload = r'''@app.post("/disk-images/upload")
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_disk_image_filename(image_file.filename or "")
wants_json = "application/json" in request.headers.get("accept", "") or request.headers.get("x-requested-with") == "XMLHttpRequest"
def disk_error(message: str, status_code: int = 400):
if wants_json:
return JSONResponse({"ok": False, "error": message}, status_code=status_code)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": message}, status_code=status_code)
if not safe_name:
return disk_error("Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем.")
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
target = DISK_IMAGES_DIR / safe_name
final_qcow2 = DISK_IMAGES_DIR / qcow2_name_for_upload(safe_name)
if target.exists() or final_qcow2.exists():
return disk_error(f"Образ уже существует: {safe_name}")
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out)
tmp_target.rename(target)
if target.suffix.lower() == ".qcow2":
payload = {"ok": True, "mode": "ready", "name": target.name, "redirect": "/disk-images"}
else:
operation = start_disk_image_convert_operation(target, final_qcow2, qemu_img_format_for_path(target))
payload = {"ok": True, "mode": "converting", "name": target.name, "target": final_qcow2.name, "operation_id": operation["id"], "redirect": "/disk-images"}
except Exception as exc:
tmp_target.unlink(missing_ok=True)
return disk_error(f"Ошибка загрузки образа: {exc}", status_code=500)
if wants_json:
return JSONResponse(payload)
return RedirectResponse(url="/disk-images", status_code=303)
'''
if old_upload in text:
text = text.replace(old_upload, new_upload, 1)
changed.append('disk upload returns JSON and starts conversion operation')
elif 'mode": "converting"' in text and ('start_disk_image_convert_operation' in text or 'start_disk_convert_operation' in text):
changed.append('disk upload conversion already present')
else:
warnings.append('disk_image_upload marker not found, upload conversion patch skipped')
app_path.write_text(text)
print('disk convert progress patch completed:')
for item in changed:
print(f'- {item}')
for item in warnings:
print(f'WARN: {item}')
raise SystemExit(0)
+298
Просмотреть файл
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
warnings = []
if 'DISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")' not in text:
text = text.replace('IMAGES_DIR = Path("/var/lib/virtuality/images")\n', 'IMAGES_DIR = Path("/var/lib/virtuality/images")\nDISK_IMAGES_DIR = Path("/var/lib/virtuality/disk-images")\n', 1)
changed.append('DISK_IMAGES_DIR added')
else:
changed.append('DISK_IMAGES_DIR already present')
helpers = r'''
def vm_arch_options() -> list[dict[str, str]]:
return [
{"value": "auto", "label": "Auto — по профилю хоста"},
{"value": "x86_64", "label": "x86_64 / amd64"},
{"value": "aarch64", "label": "ARM64 / aarch64"},
{"value": "generic", "label": "Generic / no arch override"},
]
def normalize_guest_arch(value: str, profile: dict[str, Any]) -> str:
value = (value or "auto").strip()
if value == "auto":
return str(profile.get("recommended_guest_arch") or "x86_64")
if value in ("x86_64", "amd64"):
return "x86_64"
if value in ("aarch64", "arm64"):
return "aarch64"
if value == "generic":
return "generic"
return str(profile.get("recommended_guest_arch") or "x86_64")
def list_disk_image_files() -> list[dict[str, str]]:
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
files = []
for item in sorted(list(DISK_IMAGES_DIR.glob("*.img")) + list(DISK_IMAGES_DIR.glob("*.raw")) + list(DISK_IMAGES_DIR.glob("*.qcow2"))):
try:
stat = item.stat()
size_gb = stat.st_size / 1024 / 1024 / 1024
updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
except OSError:
size_gb = 0
updated = "unknown"
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated})
return files
def safe_disk_image_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip().replace(" ", "-")
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
if not name.lower().endswith((".img", ".raw", ".qcow2")):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(img|raw|qcow2)", name, re.IGNORECASE):
return None
return name
def disk_image_path_by_name(name: str) -> Path | None:
safe_name = safe_disk_image_filename(name)
if not safe_name:
return None
path = (DISK_IMAGES_DIR / safe_name).resolve()
if DISK_IMAGES_DIR.resolve() not in path.parents:
return None
return path
def disk_image_format(path: Path) -> str:
suffix = path.suffix.lower().lstrip('.')
if suffix == 'qcow2':
return 'qcow2'
return 'raw'
def bridge_exists(name: str) -> bool:
if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):
return False
return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]
'''
if 'def list_disk_image_files() -> list[dict[str, str]]:' not in text:
marker = '\n\ndef valid_vm_name(name: str) -> bool:'
if marker not in text:
raise SystemExit('valid_vm_name marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('disk image and architecture helpers added')
else:
if 'def vm_arch_options()' not in text:
insert = helpers.split('\n\ndef list_disk_image_files()', 1)[0]
marker = '\n\ndef list_disk_image_files() -> list[dict[str, str]]:'
text = text.replace(marker, insert + marker, 1)
changed.append('architecture helpers added')
else:
changed.append('architecture helpers already present')
changed.append('disk image helpers already present')
text = text.replace('\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n', '\n\ndef bridge_exists(name: str) -> bool:\n if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):\n return False\n return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]\n')
old_context = '"isos": list_iso_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
new_context = '"isos": list_iso_files(), "disk_images": list_disk_image_files(), "arch_options": vm_arch_options(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
if old_context in text:
text = text.replace(old_context, new_context, 1)
changed.append('vm form context gets disk images and arch options')
elif '"disk_images": list_disk_image_files()' in text and '"arch_options": vm_arch_options()' not in text:
text = text.replace('"disk_images": list_disk_image_files(),', '"disk_images": list_disk_image_files(), "arch_options": vm_arch_options(),')
text = text.replace('"source_type": "iso", "network_mode"', '"source_type": "iso", "guest_arch": "auto", "network_mode"')
changed.append('vm form context upgraded with arch options')
elif '"arch_options": vm_arch_options()' in text:
changed.append('vm form context already has arch options')
else:
warnings.append('vm_form_context marker not found')
routes = r'''
@app.get("/disk-images", response_class=HTMLResponse)
def disk_images_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": error})
@app.post("/disk-images/upload", response_class=HTMLResponse)
def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
safe_name = safe_disk_image_filename(image_file.filename or "")
if not safe_name:
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": "Можно загружать только .img, .raw или .qcow2 файлы с безопасным именем."}, status_code=400)
DISK_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
target = DISK_IMAGES_DIR / safe_name
if target.exists():
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Образ уже существует: {safe_name}"}, status_code=400)
tmp_target = DISK_IMAGES_DIR / f".{safe_name}.uploading"
try:
with tmp_target.open("wb") as out:
shutil.copyfileobj(image_file.file, out)
tmp_target.rename(target)
except Exception as exc:
tmp_target.unlink(missing_ok=True)
return templates.TemplateResponse("disk_images.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки образа: {exc}"}, status_code=500)
return RedirectResponse(url="/disk-images", status_code=303)
@app.post("/disk-images/{name}/delete")
def disk_image_delete(request: Request, name: str):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
path = disk_image_path_by_name(name)
if path and path.exists() and path.is_file():
path.unlink()
return RedirectResponse(url="/disk-images", status_code=303)
'''
if '@app.get("/disk-images"' not in text:
marker = '\n\n@app.get("/network", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('network route marker not found')
text = text.replace(marker, routes + marker, 1)
changed.append('disk image routes added')
else:
changed.append('disk image routes already present')
old_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig in text:
text = text.replace(old_sig, new_sig, 1)
changed.append('vm create signature supports disk images and guest arch')
elif 'source_type: str = Form("iso")' in text and 'guest_arch: str = Form("auto")' not in text:
text = text.replace('source_type: str = Form("iso"), network_mode:', 'source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode:', 1)
changed.append('vm create signature upgraded with guest arch')
elif 'guest_arch: str = Form("auto")' in text:
changed.append('vm create signature already supports guest arch')
else:
warnings.append('vm_create_submit signature marker not found')
old_body = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "network_mode": network_mode, "bridge": bridge}'
new_body = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
if old_body in text:
text = text.replace(old_body, new_body, 1)
changed.append('vm create form state supports source type and guest arch')
elif '"source_type": source_type' in text and '"guest_arch": guest_arch' not in text:
text = text.replace('"source_type": source_type, "network_mode"', '"source_type": source_type, "guest_arch": guest_arch, "network_mode"')
changed.append('vm create form state upgraded with guest arch')
old_iso_validation = ''' else:
iso = Path(iso_path).resolve()
if ISO_DIR.resolve() not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
'''
new_iso_validation = ''' elif source_type not in ("iso", "disk_image"):
error = "Некорректный источник VM."
elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):
error = "Некорректная архитектура VM."
elif network_mode == "bridge" and not bridge_exists(bridge):
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
else:
if source_type == "iso":
iso = Path(iso_path).resolve()
if ISO_DIR.resolve() not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
error = "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
else:
disk_image = Path(disk_image_path).resolve()
if DISK_IMAGES_DIR.resolve() not in disk_image.parents or disk_image.suffix.lower() not in (".img", ".raw", ".qcow2") or not disk_image.exists():
error = "Образ диска должен быть существующим .img, .raw или .qcow2 файлом из /var/lib/virtuality/disk-images."
'''
if old_iso_validation in text:
text = text.replace(old_iso_validation, new_iso_validation, 1)
changed.append('vm create validation supports disk image source and guest arch')
elif 'guest_arch not in' not in text and 'source_type == "iso"' in text:
text = text.replace('elif network_mode not in ("nat", "bridge"):\n error = "Некорректный режим сети."', 'elif network_mode not in ("nat", "bridge"):\n error = "Некорректный режим сети."\n elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."', 1)
changed.append('vm create validation upgraded with guest arch')
elif 'guest_arch not in' in text:
changed.append('vm create validation already supports guest arch')
old_cmd = ''' IMAGES_DIR.mkdir(parents=True, exist_ok=True)
disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists():
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
profile = host_profile.load_host_profile()
is_arm = profile.get("recommended_guest_arch") == "aarch64"
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
if is_arm:
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
new_cmd = ''' IMAGES_DIR.mkdir(parents=True, exist_ok=True)
disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists():
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
profile = host_profile.load_host_profile()
selected_arch = normalize_guest_arch(guest_arch, profile)
host_arch = str(profile.get("arch") or platform.machine() or "")
is_arm = selected_arch == "aarch64"
# ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation.
virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu")
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus), "--virt-type", virt_type]
if selected_arch == "x86_64":
cmd += ["--arch", "x86_64"]
elif is_arm:
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]
if source_type == "disk_image":
source_disk = Path(disk_image_path).resolve()
source_format = disk_image_format(source_disk)
convert_cmd = f"qemu-img convert -p -f {source_format} -O qcow2 {source_disk} {disk_path}"
virt_cmd = " ".join(cmd + ["--import", "--disk", f"path={disk_path},format=qcow2,bus=virtio", "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"])
cmd = ["bash", "-lc", f"set -euo pipefail; {convert_cmd}; {virt_cmd}"]
else:
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus=virtio", "--cdrom", iso_path, "--os-variant", "generic", "--network", network_arg, "--graphics", "vnc,listen=0.0.0.0", "--noautoconsole"]
'''
if old_cmd in text:
text = text.replace(old_cmd, new_cmd, 1)
changed.append('vm create command supports ARM64 qemu fallback, guest arch and disk image import')
elif 'selected_arch = normalize_guest_arch' in text:
# Upgrade existing selected_arch block if it still uses kvm for aarch64 on x86.
text = re.sub(
r'virt_type = "kvm" if profile\.get\("kvm_device"\) else "qemu"',
'host_arch = str(profile.get("arch") or platform.machine() or "")\n # ARM64 guest on x86 host cannot use KVM. It must use QEMU emulation.\n virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else ("kvm" if profile.get("kvm_device") else "qemu")',
text,
count=1,
)
changed.append('existing vm create command was upgraded with ARM64 qemu fallback')
elif 'virt_type = "kvm" if profile.get("kvm_device") else "qemu"' in text:
text = text.replace('is_arm = profile.get("recommended_guest_arch") == "aarch64"\n virt_type =', 'selected_arch = normalize_guest_arch(guest_arch, profile)\n host_arch = str(profile.get("arch") or platform.machine() or "")\n is_arm = selected_arch == "aarch64"\n virt_type = "qemu" if (is_arm and host_arch not in ("aarch64", "arm64")) else', 1)
text = text.replace('if is_arm:\n cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]', 'if selected_arch == "x86_64":\n cmd += ["--arch", "x86_64"]\n elif is_arm:\n cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host" if virt_type == "kvm" else "cortex-a57", "--boot", "uefi"]', 1)
changed.append('existing vm create command was upgraded with guest arch and ARM64 qemu fallback')
else:
warnings.append('vm command marker not found')
if '"guest_arch": selected_arch' not in text and '"guest_arch": profile.get("recommended_guest_arch")' in text:
text = text.replace('"guest_arch": profile.get("recommended_guest_arch")', '"guest_arch": selected_arch')
if '"disk_image_path": disk_image_path' not in text:
text = text.replace('"iso_path": iso_path, "host_profile"', '"iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": selected_arch, "host_profile"')
app_path.write_text(text)
print('disk images patch applied:')
for item in changed:
print(f'- {item}')
for item in warnings:
print(f'WARN: {item}')
+224
Просмотреть файл
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.resolve().parent
template_path = app_dir / 'templates' / 'vm_detail.html'
changed = []
warnings = []
text = app_path.read_text()
if 'import tempfile' not in text:
text = text.replace('import subprocess\n', 'import subprocess\nimport tempfile\n', 1)
changed.append('tempfile import added')
else:
changed.append('tempfile import already present')
if 'import xml.etree.ElementTree as ET' not in text:
text = text.replace('from typing import Any\n', 'from typing import Any\nimport xml.etree.ElementTree as ET\n', 1)
changed.append('ElementTree import added')
else:
changed.append('ElementTree import already present')
helpers = r'''
def vm_boot_order_label(value: str) -> str:
labels = {item["value"]: item["label"] for item in vm_boot_order_options()}
return labels.get(value or "auto", "Auto — по источнику VM")
def boot_order_to_devs(value: str) -> list[str]:
value = normalize_boot_order(value, "disk_image")
mapping = {
"disk": ["hd"],
"cdrom_disk": ["cdrom", "hd"],
"disk_cdrom": ["hd", "cdrom"],
"network_disk": ["network", "hd"],
}
return mapping.get(value, ["hd"])
def boot_devs_to_order(devs: list[str]) -> str:
clean = [item for item in devs if item in ("hd", "cdrom", "network")]
if clean[:2] == ["cdrom", "hd"]:
return "cdrom_disk"
if clean[:2] == ["hd", "cdrom"]:
return "disk_cdrom"
if clean[:2] == ["network", "hd"]:
return "network_disk"
if clean[:1] == ["hd"]:
return "disk"
return "auto"
def current_vm_boot_order(name: str) -> str:
result = run_cmd(["virsh", "dumpxml", name], timeout=12)
if not result.get("ok"):
return "auto"
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception:
return "auto"
os_node = root.find("os")
if os_node is None:
return "auto"
devs = []
for boot in os_node.findall("boot"):
dev = boot.attrib.get("dev", "").strip()
if dev:
devs.append(dev)
return boot_devs_to_order(devs)
def apply_vm_boot_order(name: str, boot_order: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
if boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):
return False, "Некорректный порядок загрузки VM."
selected = normalize_boot_order(boot_order, "disk_image")
result = run_cmd(["virsh", "dumpxml", name], timeout=15)
if not result.get("ok"):
return False, result.get("stderr") or "Не удалось получить XML VM."
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception as exc:
return False, f"Не удалось разобрать XML VM: {exc}"
os_node = root.find("os")
if os_node is None:
os_node = ET.SubElement(root, "os")
for boot in list(os_node.findall("boot")):
os_node.remove(boot)
insert_at = 0
for idx, child in enumerate(list(os_node)):
if child.tag in ("type", "loader", "nvram", "firmware", "smbios", "bootmenu"):
insert_at = idx + 1
for dev in reversed(boot_order_to_devs(selected)):
boot_node = ET.Element("boot", {"dev": dev})
os_node.insert(insert_at, boot_node)
xml_text = ET.tostring(root, encoding="unicode")
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
handle.write(xml_text)
tmp_name = handle.name
try:
define = run_cmd(["virsh", "define", tmp_name], timeout=30)
finally:
Path(tmp_name).unlink(missing_ok=True)
if not define.get("ok"):
return False, define.get("stderr") or "virsh define завершился ошибкой."
return True, f"Порядок загрузки применён: {vm_boot_order_label(selected)}. Если VM запущена, изменение сработает после перезапуска."
'''
if 'def apply_vm_boot_order(name: str, boot_order: str)' not in text:
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('existing VM boot order helpers added')
else:
warnings.append('vm_details marker not found, helpers skipped')
else:
changed.append('existing VM boot order helpers already present')
old = '"host_ip": system_summary()["ip"]})'
new = '"host_ip": system_summary()["ip"], "boot_options": vm_boot_order_options(), "current_boot_order": current_vm_boot_order(name), "boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})'
if '"current_boot_order": current_vm_boot_order(name)' not in text:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm detail context gets boot order')
else:
warnings.append('vm_detail context marker not found, skipped')
else:
changed.append('vm detail context already has boot order')
route = r'''
@app.post("/vm/{name}/boot-order")
def vm_boot_order_apply(request: Request, name: str, boot_order: str = Form("auto")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = apply_vm_boot_order(name, boot_order)
if ok:
return RedirectResponse(url=f"/vm/{name}?boot_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?boot_error={message}", status_code=303)
'''
if '@app.post("/vm/{name}/boot-order")' not in text:
marker = '\n\n@app.post("/vm/{name}/{action}")'
if marker in text:
text = text.replace(marker, route + marker, 1)
changed.append('existing VM boot order route added')
else:
warnings.append('generic vm action marker not found, route skipped')
else:
changed.append('existing VM boot order route already present')
app_path.write_text(text)
if template_path.exists():
tpl = template_path.read_text()
original = tpl
if 'action="/vm/{{ vm.name }}/boot-order"' not in tpl:
block = r'''
{% if boot_message %}
<div class="alert success">{{ boot_message }}</div>
{% endif %}
{% if boot_error %}
<div class="alert danger">{{ boot_error }}</div>
{% endif %}
<section class="card">
<div class="card-head">
<h2>Порядок загрузки</h2>
<span class="pill">boot order</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid">
<label>
<span>Порядок загрузки VM</span>
<select name="boot_order" required>
{% for boot in boot_options %}
<option value="{{ boot.value }}" {% if current_boot_order == boot.value %}selected{% endif %}>{{ boot.label }}</option>
{% endfor %}
</select>
</label>
<button class="primary wide" type="submit">Применить порядок загрузки</button>
</form>
<p class="muted small-note">Настройка меняет XML-конфигурацию VM через virsh define. Если машина сейчас запущена, новый порядок загрузки сработает после перезапуска.</p>
</section>
'''
markers = ['\n\n <section class="grid two">', '\n\n <section class="grid two">', '\n\n <section class="card">']
inserted = False
for marker in markers:
if marker in tpl:
tpl = tpl.replace(marker, block + marker, 1)
inserted = True
changed.append('boot order card added to vm_detail.html')
break
if not inserted:
warnings.append('vm detail insertion marker not found, boot order card skipped')
else:
changed.append('boot order card already present in vm_detail.html')
if tpl != original:
template_path.write_text(tpl)
else:
warnings.append(f'vm_detail.html not found: {template_path}')
print('existing VM boot order patch applied:')
for item in changed:
print(f'- {item}')
if warnings:
print('Warnings:')
for item in warnings:
print(f'- {item}')
+280
Просмотреть файл
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.resolve().parent
template_path = app_dir / 'templates' / 'vm_detail.html'
changed = []
text = app_path.read_text()
helpers = r'''
def vm_state(name: str) -> str:
result = run_cmd(["virsh", "domstate", name], timeout=8)
return (result.get("stdout") or "unknown").strip().lower()
def current_vm_iso(name: str) -> str:
result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)
if not result.get("ok"):
return ""
for line in (result.get("stdout") or "").splitlines():
if ".iso" not in line.lower():
continue
parts = line.split()
if parts:
return parts[-1]
return ""
def detach_vm_iso(name: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
xml = run_cmd(["virsh", "dumpxml", name], timeout=12)
cdrom_targets: list[str] = []
if xml.get("ok"):
try:
root = ET.fromstring(xml.get("stdout") or "")
devices = root.find("devices")
if devices is not None:
for disk in devices.findall("disk"):
if disk.attrib.get("device") != "cdrom":
continue
target = disk.find("target")
dev = target.attrib.get("dev") if target is not None else ""
if dev:
cdrom_targets.append(dev)
except Exception:
pass
if not cdrom_targets:
cdrom_targets = ["sda", "hda", "sdb", "hdc"]
running = vm_state(name) == "running"
errors = []
changed_any = False
for target in cdrom_targets:
commands = []
if running:
commands.append(["virsh", "detach-disk", name, target, "--live"])
commands.append(["virsh", "detach-disk", name, target, "--config"])
for cmd in commands:
result = run_cmd(cmd, timeout=30)
if result.get("ok"):
changed_any = True
elif result.get("stderr"):
errors.append(result.get("stderr"))
if changed_any:
return True, "ISO был отмонтирован."
return False, errors[-1] if errors else "Подключенный ISO не найден."
def mount_vm_iso(name: str, iso_path: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
iso = Path(iso_path or "").resolve()
try:
iso_root = ISO_DIR.resolve()
except Exception:
iso_root = Path("/var/lib/virtuality/iso")
if iso_root not in iso.parents or iso.suffix.lower() != ".iso" or not iso.exists():
return False, "ISO должен быть существующим .iso файлом из /var/lib/virtuality/iso."
detach_vm_iso(name)
running = vm_state(name) == "running"
base = ["virsh", "attach-disk", name, str(iso), "sda", "--type", "cdrom", "--mode", "readonly"]
if running:
live = run_cmd(base + ["--live"], timeout=30)
if not live.get("ok"):
return False, live.get("stderr") or "Не удалось подключить ISO к запущенной VM."
config = run_cmd(base + ["--config"], timeout=30)
if not config.get("ok"):
return False, config.get("stderr") or "Не удалось сохранить ISO в конфигурации VM."
return True, "ISO был смонтирован в VM. Если гостевая ОС его не увидела сразу, перезагрузи VM или обнови устройства внутри гостевой ОС."
'''
if 'def mount_vm_iso(name: str, iso_path: str)' not in text:
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('existing VM ISO mount helpers added')
else:
changed.append('ISO helpers skipped: vm_details marker not found')
else:
changed.append('existing VM ISO mount helpers already present')
if '"current_iso": current_vm_iso(name)' not in text:
replacements = [
(
'"resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})',
'"resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
),
(
'"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})',
'"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
),
(
'"host_ip": system_summary()["ip"]})',
'"host_ip": system_summary()["ip"], "isos": list_iso_files(), "current_iso": current_vm_iso(name), "iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})',
),
]
for old, new in replacements:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm detail context gets ISO mount data')
break
else:
changed.append('ISO context skipped: vm detail context marker not found')
else:
changed.append('vm detail context already has ISO mount data')
routes = r'''
@app.post("/vm/{name}/iso/mount")
def vm_iso_mount_apply(request: Request, name: str, iso_path: str = Form(...)):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = mount_vm_iso(name, iso_path)
if ok:
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
@app.post("/vm/{name}/iso/unmount")
def vm_iso_unmount_apply(request: Request, name: str):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = detach_vm_iso(name)
if ok:
return RedirectResponse(url=f"/vm/{name}?iso_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?iso_error={message}", status_code=303)
'''
if '@app.post("/vm/{name}/iso/mount")' not in text:
markers = ['\n\n@app.post("/vm/{name}/{action}")', '\n\n@app.get("/vm/create"', '\n\n@app.get("/operations"']
for marker in markers:
if marker in text:
text = text.replace(marker, routes + marker, 1)
changed.append('existing VM ISO mount routes added')
break
else:
changed.append('ISO routes skipped: safe route marker not found')
else:
changed.append('existing VM ISO mount routes already present')
app_path.write_text(text)
iso_card = r'''
<article class="card">
<div class="card-head">
<h2>ISO-привод</h2>
<span class="pill">cdrom</span>
</div>
{% if current_iso %}
<div class="notice">Сейчас подключен ISO: <b>{{ current_iso }}</b></div>
{% else %}
<div class="muted small-note">ISO сейчас не подключён.</div>
{% endif %}
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="form-grid">
<label>
<span>ISO образ</span>
<select name="iso_path" required>
{% for iso in isos %}
<option value="{{ iso.path }}" {% if current_iso == iso.path %}selected{% endif %}>{{ iso.name }} — {{ iso.size }}</option>
{% endfor %}
</select>
</label>
<button class="primary wide" type="submit" {% if not isos %}disabled{% endif %}>Смонтировать ISO в VM</button>
</form>
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" class="form-grid" onsubmit="return confirm('Отмонтировать ISO из VM {{ vm.name }}?');">
<button type="submit" class="ghost wide">Отмонтировать ISO</button>
</form>
{% if not isos %}
<div class="alert danger">ISO-образов пока нет. Сначала загрузи .iso в разделе ISO.</div>
{% endif %}
<p class="muted small-note">Для запущенной VM ISO подключается live и сохраняется в конфигурации. Для выключенной VM ISO будет доступен при следующем старте.</p>
</article>
'''
boot_card = r'''
<article class="card">
<div class="card-head">
<h2>Порядок загрузки</h2>
<span class="pill">drag boot</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid boot-order-form">
<input type="hidden" name="boot_order" id="boot-order-value" value="{{ current_boot_order }}">
<div class="boot-order-list" id="boot-order-list" data-current="{{ current_boot_order }}">
<div class="boot-order-item" draggable="true" data-device="cdrom"><span class="drag-handle">☰</span><div><b>ISO / CD-ROM</b><small>Установщик или rescue-образ</small></div></div>
<div class="boot-order-item" draggable="true" data-device="hd"><span class="drag-handle">☰</span><div><b>Диск</b><small>Основной qcow2/raw диск VM</small></div></div>
<div class="boot-order-item" draggable="true" data-device="network"><span class="drag-handle">☰</span><div><b>Сеть / PXE</b><small>Загрузка по сети</small></div></div>
</div>
<button class="primary wide" type="submit">Применить порядок загрузки</button>
</form>
<p class="muted small-note">Перетащи нужный источник выше. Для загрузки с ISO поставь ISO / CD-ROM первым. Изменение сработает после перезапуска VM.</p>
<script>
(function(){
const list = document.getElementById('boot-order-list');
const input = document.getElementById('boot-order-value');
if (!list || !input) return;
const initialMap = {'cdrom_disk':['cdrom','hd','network'],'disk_cdrom':['hd','cdrom','network'],'network_disk':['network','hd','cdrom'],'disk':['hd','cdrom','network'],'auto':['hd','cdrom','network']};
const order = initialMap[list.dataset.current || 'auto'] || initialMap.auto;
const nodes = Array.from(list.querySelectorAll('.boot-order-item'));
order.forEach(device => { const node = nodes.find(item => item.dataset.device === device); if (node) list.appendChild(node); });
function updateValue(){
const devices = Array.from(list.querySelectorAll('.boot-order-item')).map(item => item.dataset.device);
const first = devices[0]; const second = devices[1];
if (first === 'cdrom' && second === 'hd') input.value = 'cdrom_disk';
else if (first === 'hd' && second === 'cdrom') input.value = 'disk_cdrom';
else if (first === 'network' && second === 'hd') input.value = 'network_disk';
else if (first === 'hd') input.value = 'disk';
else input.value = 'auto';
}
let dragged = null;
list.addEventListener('dragstart', event => { dragged = event.target.closest('.boot-order-item'); if (!dragged) return; dragged.classList.add('dragging'); event.dataTransfer.effectAllowed = 'move'; });
list.addEventListener('dragend', () => { if (dragged) dragged.classList.remove('dragging'); dragged = null; updateValue(); });
list.addEventListener('dragover', event => { event.preventDefault(); const after = Array.from(list.querySelectorAll('.boot-order-item:not(.dragging)')).find(item => { const box = item.getBoundingClientRect(); return event.clientY < box.top + box.height / 2; }); if (!dragged) return; if (after) list.insertBefore(dragged, after); else list.appendChild(dragged); });
updateValue();
})();
</script>
</article>
'''
if template_path.exists():
tpl = template_path.read_text()
original = tpl
settings_grid = '\n\n <section class="grid two vm-boot-iso-grid">\n' + iso_card + boot_card + ' </section>\n'
if 'action="/vm/{{ vm.name }}/iso/mount"' not in tpl and 'vm-boot-iso-grid' not in tpl:
markers = ['\n\n <section class="grid three vm-resource-boot-iso-grid">', '\n\n <section class="grid two">', '\n\n <section class="card">']
for marker in markers:
if marker in tpl:
if 'vm-resource-boot-iso-grid' in marker:
insert_pos = tpl.find(marker) + len(marker)
tpl = tpl[:insert_pos] + '\n' + iso_card + boot_card + tpl[insert_pos:]
changed.append('ISO and boot cards inserted into three-column grid')
else:
tpl = tpl.replace(marker, settings_grid + marker, 1)
changed.append('ISO and boot cards inserted by fallback')
break
else:
changed.append('ISO layout skipped: safe template marker not found')
elif 'vm-boot-iso-grid' in tpl and 'boot-order-list' not in tpl:
changed.append('ISO grid exists but draggable boot marker absent; left unchanged safely')
else:
changed.append('ISO mount template already present')
if tpl != original:
template_path.write_text(tpl)
else:
changed.append('ISO template skipped: vm_detail.html not found')
print('existing VM ISO mount patch applied:')
for item in changed:
print(f'- {item}')
+205
Просмотреть файл
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
changed = []
warnings = []
text = app_path.read_text()
if 'import tempfile' not in text:
text = text.replace('import subprocess\n', 'import subprocess\nimport tempfile\n', 1)
changed.append('tempfile import added')
else:
changed.append('tempfile import already present')
if 'import xml.etree.ElementTree as ET' not in text:
text = text.replace('from typing import Any\n', 'from typing import Any\nimport xml.etree.ElementTree as ET\n', 1)
changed.append('ElementTree import added')
else:
changed.append('ElementTree import already present')
helpers = r'''
def parse_dominfo_value(dominfo: str, label: str) -> str:
for line in (dominfo or "").splitlines():
if line.strip().lower().startswith(label.lower()):
return line.split(":", 1)[1].strip()
return ""
def kib_text_to_mb(value: str) -> int:
match = re.search(r"(\d+)", value or "")
if not match:
return 0
return max(0, int(int(match.group(1)) / 1024))
def current_vm_arch(name: str) -> str:
result = run_cmd(["virsh", "dumpxml", name], timeout=12)
if not result.get("ok"):
return "unknown"
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception:
return "unknown"
os_type = root.find("os/type")
return (os_type.attrib.get("arch") if os_type is not None else "") or "unknown"
def vm_runtime_state(name: str) -> str:
result = run_cmd(["virsh", "domstate", name], timeout=8)
return (result.get("stdout") or "unknown").strip().lower()
def vm_resource_settings(name: str) -> dict[str, Any]:
dominfo = run_cmd(["virsh", "dominfo", name], timeout=10).get("stdout") or ""
state = (parse_dominfo_value(dominfo, "State") or vm_runtime_state(name)).lower()
vcpus_raw = parse_dominfo_value(dominfo, "CPU(s)")
used_memory_raw = parse_dominfo_value(dominfo, "Used memory")
max_memory_raw = parse_dominfo_value(dominfo, "Max memory")
memory_mb = kib_text_to_mb(used_memory_raw) or kib_text_to_mb(max_memory_raw) or 1024
try:
vcpus = int(re.search(r"\d+", vcpus_raw or "1").group(0))
except Exception:
vcpus = 1
return {
"state": state,
"is_shutoff": state in ("shut off", "shutoff", "shut-off"),
"memory_mb": memory_mb,
"vcpus": vcpus,
"arch": current_vm_arch(name),
}
def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str) -> tuple[bool, str]:
if not valid_vm_name(name) or not vm_exists(name):
return False, "VM не найдена."
resources = vm_resource_settings(name)
if not resources.get("is_shutoff"):
return False, "CPU/RAM/архитектуру можно менять только когда VM выключена. Сначала выключи VM."
if memory_mb < 512 or memory_mb > 262144:
return False, "RAM должна быть от 512 MB до 262144 MB."
if vcpus < 1 or vcpus > 128:
return False, "CPU должен быть от 1 до 128 vCPU."
if guest_arch not in ("keep", "x86_64", "aarch64"):
return False, "Некорректная архитектура VM."
result = run_cmd(["virsh", "dumpxml", name], timeout=15)
if not result.get("ok"):
return False, result.get("stderr") or "Не удалось получить XML VM."
try:
root = ET.fromstring(result.get("stdout") or "")
except Exception as exc:
return False, f"Не удалось разобрать XML VM: {exc}"
memory_kib = str(int(memory_mb) * 1024)
for tag in ("memory", "currentMemory"):
node = root.find(tag)
if node is None:
node = ET.SubElement(root, tag)
node.text = memory_kib
node.set("unit", "KiB")
vcpu_node = root.find("vcpu")
if vcpu_node is None:
vcpu_node = ET.SubElement(root, "vcpu")
vcpu_node.text = str(int(vcpus))
vcpu_node.set("placement", "static")
arch_changed = False
if guest_arch != "keep":
os_type = root.find("os/type")
if os_type is None:
os_node = root.find("os")
if os_node is None:
os_node = ET.SubElement(root, "os")
os_type = ET.SubElement(os_node, "type")
os_type.text = "hvm"
old_arch = os_type.attrib.get("arch", "")
if old_arch != guest_arch:
os_type.set("arch", guest_arch)
if guest_arch == "aarch64":
os_type.set("machine", "virt")
arch_changed = True
xml_text = ET.tostring(root, encoding="unicode")
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".xml", delete=False) as handle:
handle.write(xml_text)
tmp_name = handle.name
try:
define = run_cmd(["virsh", "define", tmp_name], timeout=30)
finally:
Path(tmp_name).unlink(missing_ok=True)
if not define.get("ok"):
return False, define.get("stderr") or "virsh define завершился ошибкой."
message = f"Ресурсы VM применены: CPU {vcpus}, RAM {memory_mb} MB"
if arch_changed:
message += f", архитектура {guest_arch}. Важно: смена архитектуры может потребовать совместимый диск/загрузчик."
return True, message
'''
if 'def apply_vm_resources(name: str, memory_mb: int, vcpus: int, guest_arch: str)' not in text:
marker = '\n\ndef vm_details(name: str) -> dict[str, Any]:'
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('existing VM resources helpers added')
else:
warnings.append('vm_details marker not found, helpers skipped')
else:
changed.append('existing VM resources helpers already present')
if '"resource_settings": vm_resource_settings(name)' not in text:
replacements = [
('"iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", "")})', '"iso_message": request.query_params.get("iso_message", ""), "iso_error": request.query_params.get("iso_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'),
('"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", "")})', '"boot_message": request.query_params.get("boot_message", ""), "boot_error": request.query_params.get("boot_error", ""), "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'),
('"host_ip": system_summary()["ip"]})', '"host_ip": system_summary()["ip"], "resource_settings": vm_resource_settings(name), "resource_message": request.query_params.get("resource_message", ""), "resource_error": request.query_params.get("resource_error", "")})'),
]
for old, new in replacements:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm detail context gets resource settings')
break
else:
warnings.append('vm detail context marker not found, resource context skipped')
else:
changed.append('vm detail context already has resource settings')
route = r'''
@app.post("/vm/{name}/resources")
def vm_resources_apply(request: Request, name: str, memory_mb: int = Form(...), vcpus: int = Form(...), guest_arch: str = Form("keep")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ok, message = apply_vm_resources(name, memory_mb, vcpus, guest_arch)
if ok:
return RedirectResponse(url=f"/vm/{name}?resource_message={message}", status_code=303)
return RedirectResponse(url=f"/vm/{name}?resource_error={message}", status_code=303)
'''
if '@app.post("/vm/{name}/resources")' not in text:
markers = ['\n\n@app.post("/vm/{name}/boot-order")', '\n\n@app.post("/vm/{name}/iso/mount")', '\n\n@app.post("/vm/{name}/{action}")']
for marker in markers:
if marker in text:
text = text.replace(marker, route + marker, 1)
changed.append('existing VM resources route added')
break
else:
warnings.append('vm action marker not found, resources route skipped')
else:
changed.append('existing VM resources route already present')
app_path.write_text(text)
print('existing VM resources patch applied:')
for item in changed:
print(f'- {item}')
if warnings:
print('Warnings:')
for item in warnings:
print(f'- {item}')
+154
Просмотреть файл
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.parent
static_dir = app_dir / 'static'
static_dir.mkdir(exist_ok=True)
panel_js_path = static_dir / 'panel.js'
app_css_path = static_dir / 'app.css'
changed = []
text = app_path.read_text()
live_routes = r'''
@app.get("/live/status")
def live_status(request: Request):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
vms = []
for vm in parse_virsh_list():
name = vm.get("name", "")
state = vm.get("state", "unknown")
try:
ip = vm_ip(name) if name else ""
except Exception:
ip = ""
css = "ok" if "running" in state else "err" if "shut" in state else "warn"
vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else ""})
return JSONResponse({"ok": True, "generated_at": utc_now(), "vms": vms, "services": {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "cockpit": service_state("cockpit.socket"), "web": service_state("virtuality-web.service")}, "operations": list_operations(5)})
@app.get("/live/operations")
def live_operations(request: Request):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return JSONResponse({"ok": True, "generated_at": utc_now(), "operations": list_operations(25)})
'''
if '@app.get("/live/status"' not in text:
marker = '\n\n@app.get("/api/health")'
text = text.replace(marker, live_routes + marker, 1) if marker in text else text.rstrip() + live_routes + '\n'
changed.append('live routes added')
else:
changed.append('live routes already present')
app_path.write_text(text)
panel_js = panel_js_path.read_text() if panel_js_path.exists() else ''
live_js = r'''
/* Virtuality live status + toast layer */
(() => {
if (window.VirtualityLiveStatus) return;
window.VirtualityLiveStatus = true;
function qs(s, r = document) { return r.querySelector(s); }
function qsa(s, r = document) { return Array.from(r.querySelectorAll(s)); }
function toast(message, type = 'ok') {
if (!message) return;
let wrap = qs('#v-toast-wrap');
if (!wrap) { wrap = document.createElement('div'); wrap.id = 'v-toast-wrap'; document.body.appendChild(wrap); }
const item = document.createElement('div');
item.className = 'v-toast ' + type;
item.textContent = message;
wrap.appendChild(item);
setTimeout(() => item.classList.add('show'), 20);
setTimeout(() => { item.classList.remove('show'); setTimeout(() => item.remove(), 260); }, 3600);
}
function vmNameFromRow(row) {
const link = qs('a[href^="/vm/"]', row);
if (!link) return '';
try { const p = new URL(link.href, location.href).pathname.split('/').filter(Boolean); return p[0] === 'vm' ? decodeURIComponent(p[1] || '') : ''; } catch (_) { return ''; }
}
function stateClass(state) { const v = String(state || '').toLowerCase(); return v.includes('running') ? 'ok' : v.includes('shut') ? 'err' : 'warn'; }
function updateDashboardRows(vms) {
if (!Array.isArray(vms)) return;
const map = new Map(vms.map((vm) => [vm.name, vm]));
qsa('table tbody tr').forEach((row) => {
const name = vmNameFromRow(row);
if (!name || !map.has(name)) return;
const vm = map.get(name);
const cells = qsa('td', row);
if (cells[0]) cells[0].textContent = vm.id || '-';
if (cells[2]) cells[2].textContent = vm.ip || '';
if (cells[3]) {
let badge = qs('.status', cells[3]);
if (!badge) { badge = document.createElement('span'); cells[3].textContent = ''; cells[3].appendChild(badge); }
badge.className = 'status ' + (vm.state_css || stateClass(vm.state));
badge.textContent = vm.state || 'unknown';
}
});
}
function updateDetailHeader(vms) {
const title = qs('.brand');
if (!title || !Array.isArray(vms)) return;
const vm = vms.find((item) => item.name === title.textContent.trim());
if (!vm) return;
let badge = qs('#vm-live-badge');
if (!badge) { badge = document.createElement('span'); badge.id = 'vm-live-badge'; title.insertAdjacentElement('afterend', badge); }
badge.className = 'status live-badge ' + (vm.state_css || stateClass(vm.state));
badge.textContent = vm.state || 'unknown';
}
async function refreshLiveStatus() {
if (document.hidden || (!qs('.v-main') && !qs('.shell'))) return;
try {
const r = await fetch('/live/status', { cache: 'no-store', headers: { 'Accept': 'application/json' }});
if (!r.ok) return;
const p = await r.json();
if (!p.ok) return;
updateDashboardRows(p.vms);
updateDetailHeader(p.vms);
} catch (_) {}
}
document.addEventListener('submit', (event) => {
const form = event.target;
if (!form || !form.action) return;
const parts = new URL(form.action, location.href).pathname.split('/').filter(Boolean);
if (parts[0] !== 'vm' || parts.length < 3) return;
const labels = { start: 'Команда запуска VM отправлена', shutdown: 'Команда мягкого выключения VM отправлена', reboot: 'Команда перезагрузки VM отправлена', destroy: 'Команда принудительного выключения VM отправлена', autostart: 'Автозапуск VM включается', 'autostart-disable': 'Автозапуск VM отключается', delete: 'Удаление VM запущено' };
const action = parts[2];
if (labels[action]) { const type = action === 'destroy' || action === 'delete' ? 'warn' : 'ok'; sessionStorage.setItem('virtualityToast', labels[action]); toast(labels[action], type); setTimeout(refreshLiveStatus, 900); setTimeout(refreshLiveStatus, 2500); }
}, true);
function boot() { const m = sessionStorage.getItem('virtualityToast'); if (m) { sessionStorage.removeItem('virtualityToast'); toast(m, 'ok'); } refreshLiveStatus(); setInterval(refreshLiveStatus, 5000); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
})();
'''
if 'Virtuality live status + toast layer' not in panel_js:
panel_js_path.write_text(panel_js.rstrip() + live_js + '\n')
changed.append('live javascript added')
else:
changed.append('live javascript already present')
app_css = app_css_path.read_text() if app_css_path.exists() else ''
live_css = r'''
/* Virtuality live status + toast layer */
#v-toast-wrap { position: fixed; top: 14px; right: 14px; z-index: 10000; display: grid; gap: 8px; width: min(360px, calc(100vw - 28px)); pointer-events: none; }
.v-toast { transform: translateY(-8px); opacity: 0; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: #fff; color: var(--text); box-shadow: 0 12px 32px rgba(15,23,42,.18); font-size: 13px; font-weight: 800; transition: .22s ease; }
.v-toast.show { transform: translateY(0); opacity: 1; }
.v-toast.ok { border-color: rgba(22,163,74,.28); box-shadow: 0 12px 32px rgba(22,163,74,.16); }
.v-toast.warn { border-color: rgba(217,119,6,.32); box-shadow: 0 12px 32px rgba(217,119,6,.16); }
.v-toast.err { border-color: rgba(220,38,38,.32); box-shadow: 0 12px 32px rgba(220,38,38,.16); }
.live-badge { margin-left: 10px; vertical-align: middle; }
'''
if 'Virtuality live status + toast layer' not in app_css:
app_css_path.write_text(app_css.rstrip() + live_css + '\n')
changed.append('live css added')
else:
changed.append('live css already present')
print('live status patch applied:')
for item in changed:
print(f'- {item}')
+166
Просмотреть файл
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
templates_dir = app_path.parent / 'templates'
static_dir = app_path.parent / 'static'
text = app_path.read_text()
changed = []
helpers = r'''
LOG_SOURCES = {
"web": {"title": "Virtuality Web", "kind": "journal", "unit": "virtuality-web.service"},
"update": {"title": "Update Center", "kind": "file", "path": "/var/log/virtuality/update.log"},
"install": {"title": "Последняя установка", "kind": "glob", "pattern": "/var/log/virtuality/install_web_panel_*.log"},
"operations": {"title": "Операции", "kind": "operations"},
"auto-update": {"title": "Auto Update", "kind": "journal", "unit": "virtuality-auto-update.service"},
"libvirtd": {"title": "libvirtd", "kind": "journal", "unit": "libvirtd.service"},
"virtlogd": {"title": "virtlogd", "kind": "journal", "unit": "virtlogd.service"},
}
def read_log_source(source: str, lines: int = 220) -> dict[str, Any]:
key = source if source in LOG_SOURCES else "web"
cfg = LOG_SOURCES[key]
lines = max(20, min(int(lines or 220), 2000))
content = ""
path = ""
cmd = ""
if cfg["kind"] == "journal":
unit = cfg["unit"]
cmd = f"journalctl -u {unit} -n {lines} --no-pager"
result = run_cmd(["journalctl", "-u", unit, "-n", str(lines), "--no-pager"], timeout=15)
content = result["stdout"] or result["stderr"] or "Лог пуст или journalctl недоступен"
elif cfg["kind"] == "file":
path = cfg["path"]
cmd = f"tail -n {lines} {path}"
content = tail_text(Path(path), max_lines=lines) or "Файл лога пока пуст или не найден"
elif cfg["kind"] == "glob":
pattern = cfg["pattern"]
files = sorted(Path('/').glob(pattern.lstrip('/')), key=lambda item: item.stat().st_mtime if item.exists() else 0, reverse=True)
if files:
path = str(files[0])
cmd = f"tail -n {lines} {path}"
content = tail_text(files[0], max_lines=lines) or "Файл лога пуст"
else:
cmd = f"ls {pattern}"
content = "Логи установки ещё не найдены"
elif cfg["kind"] == "operations":
cmd = f"tail -n {lines} /var/log/virtuality/operations/*.log"
parts = []
ensure_operations_dir()
for item in sorted(OPERATIONS_DIR.glob('*.log'), key=lambda p: p.stat().st_mtime, reverse=True)[:10]:
parts.append(f"===== {item.name} =====\n" + tail_text(item, max_lines=max(20, lines // 5)))
content = "\n\n".join(parts) or "Журналы операций пока пусты"
return {"key": key, "title": cfg["title"], "content": content, "path": path, "cmd": cmd, "lines": lines}
'''
if 'LOG_SOURCES = {' not in text:
markers = [
'\n\ndef parse_virsh_list() -> list[dict[str, str]]:',
'\n\ndef list_recent_operations(limit: int = 20) -> list[dict[str, Any]]:',
'\n\ndef safe_iso_filename(filename: str) -> str | None:',
]
for marker in markers:
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('log center helpers added')
break
else:
print('WARN: log helper marker not found, skip helper injection')
changed.append('log center helpers skipped')
else:
start = text.find('LOG_SOURCES = {')
end = text.find('\n}\n\n\ndef read_log_source', start)
if start != -1 and end != -1:
replacement = helpers.strip().split('\n\n\ndef read_log_source', 1)[0]
text = text[:start] + replacement + text[end + 3:]
changed.append('log sources refreshed without telegram')
else:
text = text.replace(' "telegram": {"title": "Telegram notifier", "kind": "file", "path": "/var/log/virtuality/telegram_version_bot.log"},\n', '')
changed.append('telegram log source removed')
route = r'''
@app.get("/logs", response_class=HTMLResponse)
def logs_page(request: Request, source: str = "web", lines: int = 220):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
selected = read_log_source(source, lines)
return templates.TemplateResponse("logs.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"sources": LOG_SOURCES,
"selected": selected,
})
@app.get("/api/logs")
def api_logs(request: Request, source: str = "web", lines: int = 220):
if not get_current_user(request):
return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401)
return {"ok": True, "log": read_log_source(source, lines)}
'''
if '@app.get("/logs"' not in text:
markers = ['\n\n@app.get("/vm/create", response_class=HTMLResponse)', '\n\n@app.get("/api/operations")', '\n\n@app.get("/iso", response_class=HTMLResponse)']
for marker in markers:
if marker in text:
text = text.replace(marker, route + marker, 1)
changed.append('logs routes added')
break
else:
print('WARN: logs route insert marker not found, skip route injection')
changed.append('logs routes skipped')
else:
changed.append('logs routes already present')
app_path.write_text(text)
sidebar_html = '''{% set path = request.url.path %}
<aside class="v-sidebar">
<div class="v-logo">
<strong>Virtuality</strong>
<span>control panel</span>
</div>
<nav class="v-nav">
<a class="{{ 'active' if path == '/' else '' }}" href="/">Обзор</a>
<a class="{{ 'active' if path.startswith('/vm/create') else '' }}" href="/vm/create">Создать VM</a>
<div class="v-nav-group {{ 'open' if path.startswith('/iso') or path.startswith('/disk-images') else '' }}">
<button class="v-nav-group-title" type="button">Образы</button>
<a class="{{ 'active' if path.startswith('/iso') else '' }}" href="/iso">ISO</a>
<a class="{{ 'active' if path.startswith('/disk-images') else '' }}" href="/disk-images">Диски</a>
</div>
<a class="{{ 'active' if path.startswith('/network') else '' }}" href="/network">Сеть</a>
<a class="{{ 'active' if path.startswith('/operations') else '' }}" href="/operations">Операции</a>
<a class="{{ 'active' if path.startswith('/logs') else '' }}" href="/logs">Журналы</a>
<a class="{{ 'active' if path.startswith('/update') else '' }}" href="/update">Обновления</a>
<a class="{{ 'active' if path.startswith('/host') else '' }}" href="/host">Хост</a>
</nav>
</aside>
'''
if templates_dir.exists():
sidebar_path = templates_dir / '_sidebar.html'
sidebar_path.write_text(sidebar_html)
changed.append('_sidebar.html ensured')
for path in sorted(templates_dir.glob('*.html')):
if path.name in {'login.html', 'console.html', '_sidebar.html'}:
continue
html = path.read_text()
if '<script src="/static/panel.js" defer></script>' not in html:
html = html.replace('</body>', ' <script src="/static/panel.js" defer></script>\n</body>', 1)
changed.append(f'{path.name} panel.js attached')
path.write_text(html)
print('logs center and UI dynamics patch applied:')
for item in changed:
print(f'- {item}')
+148
Просмотреть файл
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
core_path = app_path.with_name('network_core.py')
if not core_path.exists():
raise SystemExit(f'network_core.py not found: {core_path}')
text = core_path.read_text()
changed = []
if 'def route_interface_for_ip(' not in text:
marker = '''def external_interface() -> str:
result = run_cmd(['ip', 'route', 'show', 'default'], timeout=5)
if not result['ok']:
return 'eth0'
match = re.search(r'\\bdev\\s+([^\\s]+)', result['stdout'])
return match.group(1) if match else 'eth0'
'''
helper = marker + '''
def route_interface_for_ip(ip: str) -> str:
if not valid_ip(ip):
return NAT_BRIDGE
result = run_cmd(['ip', 'route', 'get', ip], timeout=5)
if not result['ok']:
return NAT_BRIDGE
match = re.search(r'\\bdev\\s+([^\\s]+)', result['stdout'])
return match.group(1) if match else NAT_BRIDGE
def forward_guest_interface(item: dict[str, Any]) -> str:
return route_interface_for_ip(str(item.get('guest_ip', '')))
'''
if marker not in text:
raise SystemExit('external_interface marker not found')
text = text.replace(marker, helper, 1)
changed.append('route_interface_for_ip helper added')
else:
changed.append('route_interface_for_ip helper already present')
old_render = '''def render_nft_rules() -> str:
ext = external_interface()
lines = [
'table ip virtuality {',
' chain prerouting {',
' type nat hook prerouting priority dstnat; policy accept;',
]
for item in load_port_forwards():
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
lines.append(f" iifname \\"{ext}\\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
lines += [
' }',
' chain postrouting {',
' type nat hook postrouting priority srcnat; policy accept;',
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
' }',
' chain forward {',
' type filter hook forward priority filter; policy accept;',
f' ip saddr {NAT_SUBNET} accept',
f' ip daddr {NAT_SUBNET} accept',
' }',
'}',
]
return '\\n'.join(lines) + '\\n'
'''
new_render = '''def render_nft_rules() -> str:
ext = external_interface()
forwards = load_port_forwards()
lines = [
'table ip virtuality {',
' chain prerouting {',
' type nat hook prerouting priority dstnat; policy accept;',
]
for item in forwards:
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
lines.append(f" iifname \\"{ext}\\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
lines += [
' }',
' chain postrouting {',
' type nat hook postrouting priority srcnat; policy accept;',
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
]
for item in forwards:
guest_iface = forward_guest_interface(item)
lines.append(f' ip daddr {item["guest_ip"]} oifname "{guest_iface}" masquerade')
lines += [
' }',
' chain forward {',
' type filter hook forward priority filter; policy accept;',
f' ip saddr {NAT_SUBNET} accept',
f' ip daddr {NAT_SUBNET} accept',
]
for item in forwards:
guest_iface = forward_guest_interface(item)
lines.append(f' iifname "{ext}" oifname "{guest_iface}" ip daddr {item["guest_ip"]} accept')
lines.append(f' iifname "{guest_iface}" oifname "{ext}" ip saddr {item["guest_ip"]} ct state established,related accept')
lines += [
' }',
'}',
]
return '\\n'.join(lines) + '\\n'
'''
if old_render in text:
text = text.replace(old_render, new_render, 1)
changed.append('nft rules now support bridge/static VM interfaces')
elif 'forward_guest_interface(item)' in text and 'ip daddr {item["guest_ip"]}' in text:
changed.append('nft bridge/static rules already present')
else:
raise SystemExit('render_nft_rules marker not found')
text = text.replace(
"'out', 'on', NAT_BRIDGE,\n 'to', item['guest_ip'],",
"'out', 'on', forward_guest_interface(item),\n 'to', item['guest_ip'],",
)
if "'out', 'on', forward_guest_interface(item)" in text:
changed.append('UFW route rules now use guest route interface')
old_iptables_line = """ results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', NAT_BRIDGE, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', NAT_BRIDGE, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
"""
new_iptables_line = """ guest_iface = forward_guest_interface(item)
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', guest_iface, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', guest_iface, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-d', guest_ip, '-o', guest_iface, '-j', 'MASQUERADE']))
"""
if old_iptables_line in text:
text = text.replace(old_iptables_line, new_iptables_line, 1)
changed.append('iptables fallback now supports bridge/static VM interfaces')
elif "guest_iface = forward_guest_interface(item)" in text and "'-o', guest_iface" in text:
changed.append('iptables bridge/static fallback already present')
else:
raise SystemExit('iptables fallback marker not found')
text = text.replace(
"'watch_internal': f\"sudo tcpdump -ni {NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'\",",
"'watch_internal': f\"sudo tcpdump -ni {route_interface_for_ip(vm_ip) if vm_ip else NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'\",",
)
core_path.write_text(text)
print('network bridge forward patch applied:')
for item in changed:
print(f'- {item}')
+43
Просмотреть файл
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
if 'def network_diagnose(' in text:
print('network diagnostics handler already applied')
raise SystemExit(0)
handler = '''
@app.post("/network/diagnose", response_class=HTMLResponse)
def network_diagnose(request: Request, vm_name: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
diagnostics = None
error = None
try:
diagnostics = network_core.diagnose_public_access(vm_name, external_port, guest_port, protocol)
except NetworkError as exc:
error = str(exc)
return templates.TemplateResponse("network.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"vms": parse_virsh_list(),
"ctx": network_core.network_context(),
"error": error,
"diagnostics": diagnostics,
}, status_code=400 if error else 200)
'''
marker = '\n\n@app.get("/operations", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('operations marker not found in app.py')
text = text.replace(marker, handler + marker, 1)
app_path.write_text(text)
print(f'network diagnostics handler applied: {app_path}')
+25
Просмотреть файл
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
template_path = app_path.with_name('templates') / 'network.html'
if not template_path.exists():
raise SystemExit(f'network.html not found: {template_path}')
text = template_path.read_text()
old = '<input type="hidden" name="guest_ip" value="auto">'
new = '''<label>
<span>IP VM</span>
<input type="text" name="guest_ip" value="auto" placeholder="auto или 192.168.100.55" required>
<small>Если IP не определяется автоматически, впиши внутренний IP VM вручную.</small>
</label>'''
if old in text:
text = text.replace(old, new, 1)
template_path.write_text(text)
print('network manual IP field added')
elif 'name="guest_ip" value="auto" placeholder="auto' in text:
print('network manual IP field already present')
else:
raise SystemExit('guest_ip marker not found in network.html')
+126
Просмотреть файл
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = r'''
def safe_network_template(request: Request, error: str | None = None, status_code: int = 200, diagnostics: dict[str, Any] | None = None):
try:
ctx = network_core.network_context()
except Exception as exc:
ctx = {
"nat": {
"name": network_core.NETWORK_NAME,
"bridge": network_core.NAT_BRIDGE,
"subnet": network_core.NAT_SUBNET,
"gateway": network_core.NAT_GATEWAY,
"dhcp": f"{network_core.DHCP_START} - {network_core.DHCP_END}",
"exists": False,
"info": str(exc),
"leases": "",
},
"networks": [],
"forwards": [],
"external_interface": "unknown",
"ip_forward": "unknown",
"nft_rules": "",
}
error = error or f"Ошибка чтения сетевого состояния: {exc}"
return templates.TemplateResponse("network.html", {
"request": request,
"app_name": APP_NAME,
"user": AUTH_USER,
"vms": parse_virsh_list(),
"ctx": ctx,
"error": error,
"diagnostics": diagnostics,
}, status_code=status_code)
'''
if 'def safe_network_template(' not in text:
marker = '\n\n@app.get("/network", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('network route marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('safe network template helper added')
else:
changed.append('safe network template helper already present')
old_get = '''@app.get("/network", response_class=HTMLResponse)
def network_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": error})
'''
new_get = '''@app.get("/network", response_class=HTMLResponse)
def network_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return safe_network_template(request, error=error)
'''
if old_get in text:
text = text.replace(old_get, new_get, 1)
changed.append('network page now uses safe template')
elif 'return safe_network_template(request, error=error)' in text:
changed.append('network page already safe')
else:
changed.append('network page replacement skipped')
old_nat = '''@app.post("/network/nat/setup")
def network_nat_setup(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
network_core.create_nat_network()
network_core.apply_port_forwards()
except NetworkError as exc:
return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=500)
return RedirectResponse(url="/network", status_code=303)
'''
new_nat = '''@app.post("/network/nat/setup")
def network_nat_setup(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
network_core.create_nat_network()
network_core.apply_port_forwards()
except NetworkError as exc:
return safe_network_template(request, error=str(exc), status_code=500)
except Exception as exc:
return safe_network_template(request, error=f"Внутренняя ошибка настройки NAT: {exc}", status_code=500)
return RedirectResponse(url="/network", status_code=303)
'''
if old_nat in text:
text = text.replace(old_nat, new_nat, 1)
changed.append('NAT setup catches all exceptions')
elif 'Внутренняя ошибка настройки NAT' in text:
changed.append('NAT setup already catches all exceptions')
else:
raise SystemExit('network nat setup route marker not found')
replacements = {
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=400)': 'return safe_network_template(request, error=str(exc), status_code=400)',
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc)}, status_code=500)': 'return safe_network_template(request, error=str(exc), status_code=500)',
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": str(exc), "diagnostics": None}, status_code=400)': 'return safe_network_template(request, error=str(exc), status_code=400)',
'return templates.TemplateResponse("network.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vms": parse_virsh_list(), "ctx": network_core.network_context(), "error": None, "diagnostics": diagnostics})': 'return safe_network_template(request, diagnostics=diagnostics)',
}
for old, new in replacements.items():
if old in text:
text = text.replace(old, new)
changed.append('network error response hardened')
app_path.write_text(text)
print('network NAT error patch applied:')
for item in changed:
print(f'- {item}')
+143
Просмотреть файл
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
changed = []
text = app_path.read_text()
old_signature = 'def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: int = Form(...), guest_port: int = Form(...), protocol: str = Form("tcp"), note: str = Form("")):'
new_signature = 'def network_forward_add(request: Request, vm_name: str = Form(...), guest_ip: str = Form(...), external_port: str = Form(...), guest_port: str = Form(...), protocol: str = Form("tcp"), note: str = Form("")):'
if old_signature in text:
text = text.replace(old_signature, new_signature, 1)
app_path.write_text(text)
changed.append('app.py forward/add accepts port ranges as text')
elif new_signature in text:
changed.append('app.py forward/add already accepts port ranges as text')
else:
raise SystemExit('network_forward_add signature marker not found')
core_path = app_path.with_name('network_core.py')
if core_path.exists():
core_text = core_path.read_text()
before = core_text
if 'def iptables_dnat_port_value(' not in core_text:
marker = """def iptables_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
def port_label"""
replacement = """def iptables_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
def iptables_dnat_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
def successful_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in results if item.get('ok')]
def failed_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in results if not item.get('ok')]
def port_label"""
if marker not in core_text:
raise SystemExit('network_core.py port helper marker not found')
core_text = core_text.replace(marker, replacement, 1)
changed.append('network_core.py iptables DNAT range helper added')
else:
changed.append('network_core.py iptables DNAT range helper already present')
core_text = core_text.replace(
'guest_to = f"{guest_ip}:{iptables_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])}"',
'guest_to_port = iptables_dnat_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"',
)
core_text = core_text.replace(
'guest_to_port = nft_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"',
'guest_to_port = iptables_dnat_port_value(item[\'guest_port_start\'], item[\'guest_port_end\'])\n guest_to = f"{guest_ip}:{guest_to_port}"',
)
old_apply = """def apply_port_forwards() -> dict[str, Any]:
ensure_dirs()
enable_ip_forward()
disable_rp_filter()
items = load_port_forwards()
NFT_FILE.write_text(render_nft_rules())
run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
result = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
if not result['ok']:
raise NetworkError(result['stderr'] or result['stdout'] or 'Не удалось применить nftables-правила')
ufw_results = apply_ufw_route_rules(items)
iptables_results = apply_iptables_fallback(items)
return {
'ok': True,
'file': str(NFT_FILE),
'rules': render_nft_rules(),
'ufw': ufw_results,
'iptables': iptables_results,
}
"""
new_apply = """def apply_port_forwards() -> dict[str, Any]:
ensure_dirs()
enable_ip_forward()
disable_rp_filter()
items = load_port_forwards()
NFT_FILE.write_text(render_nft_rules())
nft_delete = run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
ufw_results = apply_ufw_route_rules(items)
iptables_results = apply_iptables_fallback(items)
iptables_ok = bool(successful_results(iptables_results)) or not items
if not nft_apply['ok'] and not iptables_ok:
details = [
nft_apply.get('stderr') or nft_apply.get('stdout') or 'nftables не применился',
*[
item.get('stderr') or item.get('stdout') or item.get('cmd', 'iptables rule failed')
for item in failed_results(iptables_results)
],
]
raise NetworkError('Не удалось применить правила проброса: ' + ' | '.join([d for d in details if d]))
return {
'ok': nft_apply['ok'] or iptables_ok,
'file': str(NFT_FILE),
'rules': render_nft_rules(),
'nft_delete': nft_delete,
'nft_apply': nft_apply,
'ufw': ufw_results,
'iptables': iptables_results,
}
"""
if old_apply in core_text:
core_text = core_text.replace(old_apply, new_apply, 1)
changed.append('network_core.py apply_port_forwards now falls back to iptables when nft fails')
elif 'nft_apply = run_cmd([\'nft\', \'-f\', str(NFT_FILE)]' in core_text or "nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)]" in core_text:
changed.append('network_core.py apply_port_forwards fallback already present')
else:
raise SystemExit('network_core.py apply_port_forwards marker not found')
core_text = core_text.replace(
'f"{vm_ip}:{matching_forward[\'guest_port_label\'].replace(\'-\', \':\')}" in ipt_nat_text',
'(f"{vm_ip}:{matching_forward[\'guest_port_label\']}" in ipt_nat_text or f"{vm_ip}:{matching_forward[\'guest_port_label\'].replace(\'-\', \':\')}" in ipt_nat_text)',
)
if core_text != before:
core_path.write_text(core_text)
changed.append('network_core.py port forwarding rules patched')
else:
changed.append('network_core.py port forwarding rules already ok')
else:
changed.append(f'network_core.py not found near {app_path}')
print('network ranges patch applied:')
for item in changed:
print(f'- {item}')
+53
Просмотреть файл
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
app_dir = app_path.resolve().parent if app_path.exists() else Path('/opt/virtuality/web')
template_path = app_dir / 'templates' / 'vm_detail.html'
changed = []
if not template_path.exists():
print(f'legacy boot order cleanup skipped: vm_detail.html not found: {template_path}')
raise SystemExit(0)
tpl = template_path.read_text()
original = tpl
legacy_pattern = re.compile(
r'\n\s*<section class="card">\s*\n'
r'\s*<div class="card-head">\s*\n'
r'\s*<h2>Порядок загрузки</h2>\s*\n'
r'\s*<span class="pill">boot order</span>\s*\n'
r'.*?'
r'\s*</section>\s*\n',
re.DOTALL,
)
tpl, count = legacy_pattern.subn('\n', tpl)
if count:
changed.append(f'legacy select boot-order card removed: {count}')
# Safety cleanup for any accidental duplicated plain select card without the old pill text.
plain_select_pattern = re.compile(
r'\n\s*<section class="card">\s*\n'
r'\s*<div class="card-head">\s*\n'
r'\s*<h2>Порядок загрузки</h2>.*?'
r'<select name="boot_order" required>.*?'
r'\s*</section>\s*\n',
re.DOTALL,
)
tpl, count2 = plain_select_pattern.subn('\n', tpl)
if count2:
changed.append(f'legacy plain select boot-order card removed: {count2}')
if tpl != original:
template_path.write_text(tpl)
else:
changed.append('legacy boot-order select card not found')
print('legacy boot order cleanup patch applied:')
for item in changed:
print(f'- {item}')
+60
Просмотреть файл
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
templates_dir = app_path.parent / 'templates'
if not templates_dir.exists():
raise SystemExit(f'templates dir not found: {templates_dir}')
skip = {'login.html', 'console.html', '_sidebar.html'}
changed = []
skipped = []
for path in sorted(templates_dir.glob('*.html')):
if path.name in skip:
skipped.append(f'{path.name}: skipped system/template')
continue
text = path.read_text()
if '{% include "_sidebar.html" %}' in text or "{% include '_sidebar.html' %}" in text:
skipped.append(f'{path.name}: already has sidebar')
continue
if '<div class="v-layout">' in text:
skipped.append(f'{path.name}: already v-layout')
continue
if '<div class="shell">' not in text:
skipped.append(f'{path.name}: no shell')
continue
text = text.replace(
'<body>\n <div class="shell">',
'<body>\n <div class="v-layout">\n {% include "_sidebar.html" %}\n <main class="v-main">\n <div class="shell v-shell-embedded">',
1,
)
# Close shell/main/layout before scripts when page has JS block.
marker = '\n\n <script>'
if marker in text:
idx = text.rfind('\n </div>', 0, text.find(marker))
if idx != -1:
text = text[:idx] + '\n </div>\n </main>\n </div>' + text[idx + len('\n </div>'):]
else:
skipped.append(f'{path.name}: close marker before script not found')
continue
else:
marker2 = '\n</body>'
idx = text.rfind('\n </div>', 0, text.find(marker2) if marker2 in text else len(text))
if idx != -1:
text = text[:idx] + '\n </div>\n </main>\n </div>' + text[idx + len('\n </div>'):]
else:
skipped.append(f'{path.name}: close marker before body not found')
continue
path.write_text(text)
changed.append(path.name)
print('sidebar layout patch applied:')
for name in changed:
print(f'- {name}: sidebar wrapped')
for item in skipped:
print(f'- {item}')
+56
Просмотреть файл
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
if 'import update_core' not in text:
text = text.replace('import network_core\n', 'import network_core\nimport update_core\n', 1)
changed.append('import update_core added')
helper = r'''
def dashboard_update_notice() -> dict[str, Any]:
try:
info = update_core.check_updates(fetch=False)
if info.get("has_update"):
return {
"has_update": True,
"current_version": info.get("current_version", "unknown"),
"latest_version": info.get("latest_version", "unknown"),
"missing_count": len(info.get("missing_versions", [])),
"commit_count": len(info.get("commits", [])),
}
except Exception:
pass
return {"has_update": False}
'''
if 'def dashboard_update_notice() -> dict[str, Any]:' not in text:
marker = '\n\ndef update_operation(operation: dict[str, Any], **changes: Any) -> None:'
if marker not in text:
raise SystemExit('update_operation marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('dashboard update notice helper added')
else:
changed.append('dashboard update notice helper already present')
old_fragment = '"operations": list_operations(5), "operation_css": operation_css})'
new_fragment = '"operations": list_operations(5), "operation_css": operation_css, "update_notice": dashboard_update_notice()})'
if old_fragment in text:
text = text.replace(old_fragment, new_fragment, 1)
changed.append('dashboard context gets update_notice')
elif '"update_notice": dashboard_update_notice()' in text:
changed.append('dashboard context already has update_notice')
else:
raise SystemExit('dashboard context marker not found')
app_path.write_text(text)
print('update badge patch applied:')
for item in changed:
print(f'- {item}')
+127
Просмотреть файл
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
if 'import update_core' not in text:
if 'import network_core\n' in text:
text = text.replace('import network_core\n', 'import network_core\nimport update_core\n', 1)
elif 'from network_core import NetworkError\n' in text:
text = text.replace('from network_core import NetworkError\n', 'from network_core import NetworkError\nimport update_core\n', 1)
else:
raise SystemExit('network_core import marker not found')
changed.append('import update_core added')
else:
changed.append('import update_core already present')
routes = r'''
@app.get("/update", response_class=HTMLResponse)
def update_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
error = None
try:
info = update_core.check_updates(fetch=False)
except Exception as exc:
error = str(exc)
info = {
"ok": False,
"source_dir": str(update_core.SOURCE_DIR),
"remote": update_core.REMOTE,
"branch": update_core.DEFAULT_BRANCH,
"fetch_ok": False,
"fetch_error": str(exc),
"current_commit": "",
"latest_commit": "",
"current_version": "unknown",
"latest_version": "unknown",
"has_update": False,
"missing_versions": [],
"commits": [],
"checked_at": utc_now(),
"state": update_core.state(),
"log_tail": update_core.update_log_tail(),
}
return templates.TemplateResponse("update.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error})
@app.get("/update/status")
def update_status(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return JSONResponse({"ok": False, "error": "auth required"}, status_code=401)
return JSONResponse({
"ok": True,
"state": update_core.state(),
"log_tail": update_core.update_log_tail(260),
"checked_at": utc_now(),
})
@app.post("/update/check")
def update_check(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
update_core.check_updates(fetch=True)
except Exception:
pass
return RedirectResponse(url="/update", status_code=303)
@app.post("/update/apply")
def update_apply(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
try:
update_core.start_update()
except Exception:
pass
return RedirectResponse(url="/update", status_code=303)
'''
if '@app.get("/update"' not in text:
marker = '\n\n@app.get("/operations", response_class=HTMLResponse)'
if marker not in text:
raise SystemExit('operations route marker not found')
text = text.replace(marker, routes + marker, 1)
changed.append('update routes added')
else:
changed.append('update route already present')
if '@app.get("/update/status"' not in text:
marker = '\n\n@app.post("/update/check")'
if marker not in text:
raise SystemExit('update/check route marker not found')
status_route = r'''
@app.get("/update/status")
def update_status(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return JSONResponse({"ok": False, "error": "auth required"}, status_code=401)
return JSONResponse({
"ok": True,
"state": update_core.state(),
"log_tail": update_core.update_log_tail(260),
"checked_at": utc_now(),
})
'''
text = text.replace(marker, status_route + marker, 1)
changed.append('update status route added')
else:
changed.append('update status route already present')
app_path.write_text(text)
print('update center patch applied:')
for item in changed:
print(f'- {item}')
+96
Просмотреть файл
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
safe_name_helper = r'''
def safe_upload_filename(filename: str, allowed_suffixes: tuple[str, ...], fallback_prefix: str) -> str | None:
original = Path(filename or "").name.strip()
suffix = Path(original).suffix.lower()
if suffix not in allowed_suffixes:
return None
stem = Path(original).stem.strip()
stem = re.sub(r"\s+", "-", stem)
stem = re.sub(r"[^a-zA-Z0-9_.-]", "_", stem)
stem = stem.strip("._-")
if not stem:
stem = fallback_prefix
stem = stem[:120]
name = f"{stem}{suffix}"
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,180}\.[a-zA-Z0-9]{2,8}", name):
name = f"{fallback_prefix}{suffix}"
return name
'''
if 'def safe_upload_filename(' not in text:
marker = '\n\ndef safe_iso_filename(filename: str) -> str | None:'
if marker not in text:
raise SystemExit('safe_iso_filename marker not found')
text = text.replace(marker, safe_name_helper + marker, 1)
changed.append('safe upload filename helper added')
else:
changed.append('safe upload filename helper already present')
old_iso = r'''def safe_iso_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip().replace(" ", "-")
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
if not name.lower().endswith(".iso"):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.iso", name):
return None
return name
'''
new_iso = r'''def safe_iso_filename(filename: str) -> str | None:
return safe_upload_filename(filename, (".iso",), "virtuality-iso")
'''
if old_iso in text:
text = text.replace(old_iso, new_iso, 1)
changed.append('ISO filename sanitizer relaxed')
elif 'return safe_upload_filename(filename, (".iso",), "virtuality-iso")' in text:
changed.append('ISO filename sanitizer already relaxed')
else:
changed.append('ISO filename sanitizer replacement skipped')
old_disk = r'''def safe_disk_image_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip().replace(" ", "-")
name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
if not name.lower().endswith((".img", ".raw", ".qcow2")):
return None
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(img|raw|qcow2)", name, re.IGNORECASE):
return None
return name
'''
new_disk = r'''def safe_disk_image_filename(filename: str) -> str | None:
return safe_upload_filename(filename, (".img", ".raw", ".qcow2"), "virtuality-disk")
'''
if old_disk in text:
text = text.replace(old_disk, new_disk, 1)
changed.append('disk image filename sanitizer relaxed')
elif 'return safe_upload_filename(filename, (".img", ".raw", ".qcow2"), "virtuality-disk")' in text:
changed.append('disk image filename sanitizer already relaxed')
else:
changed.append('disk image filename sanitizer not found yet')
# Make upload errors more explicit when temp/filesystem fails after multipart parsing.
text = text.replace(
'"error": f"Ошибка загрузки ISO: {exc}"}, status_code=500)',
'"error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500)'
)
text = text.replace(
'"error": f"Ошибка загрузки образа: {exc}"}, status_code=500)',
'"error": f"Ошибка загрузки образа: {exc}. Проверь свободное место, права на /var/lib/virtuality/disk-images и временный каталог /var/lib/virtuality/tmp."}, status_code=500)'
)
changed.append('upload error messages clarified')
app_path.write_text(text)
print('upload compatibility patch applied:')
for item in changed:
print(f'- {item}')
+73
Просмотреть файл
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_dir = Path(sys.argv[1]).resolve().parent if len(sys.argv) > 1 else Path('/opt/virtuality/web')
templates_dir = app_dir / 'templates'
changed = []
guard_script = r'''
function setUploadNavigationGuard(active) {
window.virtualityUploadActive = Boolean(active);
document.body.classList.toggle('upload-locked', window.virtualityUploadActive);
}
window.addEventListener('beforeunload', function (event) {
if (!window.virtualityUploadActive) return;
event.preventDefault();
event.returnValue = 'Идёт загрузка файла. Переход остановит передачу.';
return event.returnValue;
});
document.addEventListener('click', function (event) {
if (!window.virtualityUploadActive) return;
const target = event.target.closest('a, button, input[type="submit"]');
if (!target) return;
if (target.id === 'iso-upload-button' || target.id === 'disk-upload-button') return;
event.preventDefault();
event.stopPropagation();
alert('Идёт загрузка файла. Дождись завершения или нажми «Отменить загрузку». Переход по меню сейчас заблокирован, чтобы файл не оборвался.');
}, true);
document.addEventListener('submit', function (event) {
if (!window.virtualityUploadActive) return;
if (event.target && (event.target.id === 'iso-upload-form' || event.target.id === 'disk-upload-form')) return;
event.preventDefault();
event.stopPropagation();
alert('Идёт загрузка файла. Дождись завершения или нажми «Отменить загрузку».');
}, true);
'''
def patch_template(path: Path, active_token: str, form_id: str) -> None:
if not path.exists():
return
text = path.read_text()
original = text
if 'function setUploadNavigationGuard(active)' not in text:
marker = ' function bytesText(bytes) {'
if marker not in text:
raise SystemExit(f'bytesText marker not found in {path}')
text = text.replace(marker, guard_script + '\n' + marker, 1)
text = text.replace(' uploadActive = active;\n', ' uploadActive = active;\n setUploadNavigationGuard(active);\n')
# During server-side post-processing after upload, file transfer is done, so page navigation is safe again.
text = text.replace(" window.location.href = '/iso';", " setUploadNavigationGuard(false);\n window.location.href = '/iso';")
text = text.replace(" window.location.href = '/disk-images';", " setUploadNavigationGuard(false);\n window.location.href = '/disk-images';")
# Conversion polling is not an active browser upload anymore; allow navigation while backend operation continues.
text = text.replace(" setUploadMode(false);\n fileInput.disabled = true;", " setUploadMode(false);\n setUploadNavigationGuard(false);\n fileInput.disabled = true;")
if text != original:
path.write_text(text)
changed.append(str(path))
patch_template(templates_dir / 'iso.html', 'iso-upload-button', 'iso-upload-form')
patch_template(templates_dir / 'disk_images.html', 'disk-upload-button', 'disk-upload-form')
print('upload navigation guard patch applied:')
for item in changed or ['already applied']:
print(f'- {item}')
+147
Просмотреть файл
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helpers = r'''
def vm_arch_options(profile: dict[str, Any]) -> list[dict[str, str]]:
host_arch = str(profile.get("arch", ""))
options = [
{"value": "auto", "label": "Авто — рекомендовано для этого хоста"},
{"value": "x86_64", "label": "x86_64 / amd64 — обычные ПК и серверы"},
]
if host_arch in ("aarch64", "arm64"):
options.append({"value": "aarch64", "label": "ARM64 / aarch64 — KVM на ARM-хосте"})
else:
options.append({"value": "aarch64", "label": "ARM64 / aarch64 — QEMU-эмуляция, медленно"})
options.append({"value": "generic", "label": "Generic QEMU — экспериментально"})
return options
def resolve_vm_arch(arch_choice: str, profile: dict[str, Any]) -> str:
if arch_choice == "auto" or not arch_choice:
return str(profile.get("recommended_guest_arch") or "x86_64")
if arch_choice in ("x86_64", "aarch64", "generic"):
return arch_choice
return str(profile.get("recommended_guest_arch") or "x86_64")
def append_arch_args(cmd: list[str], guest_arch: str, profile: dict[str, Any]) -> list[str]:
host_arch = str(profile.get("arch", ""))
if guest_arch == "aarch64":
if host_arch in ("aarch64", "arm64"):
return cmd + ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
return cmd + ["--arch", "aarch64", "--machine", "virt", "--cpu", "cortex-a57", "--virt-type", "qemu", "--boot", "uefi"]
if guest_arch == "x86_64":
if host_arch in ("x86_64", "amd64"):
return cmd + ["--arch", "x86_64"]
return cmd + ["--arch", "x86_64", "--virt-type", "qemu"]
return cmd
'''
start = text.find('def vm_arch_options(profile: dict[str, Any])')
if start != -1:
end = text.find('\n\ndef vm_form_context(', start)
if end == -1:
raise SystemExit('vm_form_context end marker not found')
text = text[:start] + helpers.strip() + text[end:]
changed.append('architecture helpers replaced')
else:
marker = '\n\ndef vm_form_context('
if marker not in text:
raise SystemExit('vm_form_context marker not found')
text = text.replace(marker, helpers + marker, 1)
changed.append('architecture helpers added')
old_context = '"disk_images": list_disk_image_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
new_context = '"disk_images": list_disk_image_files(), "arch_options": vm_arch_options(profile), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
if old_context in text:
text = text.replace(old_context, new_context, 1)
changed.append('vm form context gets arch options')
elif '"arch_options": vm_arch_options(profile)' in text:
changed.append('vm form context already has arch options')
else:
old_context2 = '"isos": list_iso_files(), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
new_context2 = '"isos": list_iso_files(), "disk_images": list_disk_image_files() if "list_disk_image_files" in globals() else [], "arch_options": vm_arch_options(profile), "error": error, "profile": profile, "form": form or {"memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "guest_arch": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}'
if old_context2 in text:
text = text.replace(old_context2, new_context2, 1)
changed.append('vm form context gets arch options fallback')
else:
raise SystemExit('vm_form_context payload marker not found')
old_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig in text:
text = text.replace(old_sig, new_sig, 1)
changed.append('vm create signature gets guest_arch')
elif new_sig in text:
changed.append('vm create signature already has guest_arch')
else:
old_sig2 = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig2 = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(...), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig2 in text:
text = text.replace(old_sig2, new_sig2, 1)
changed.append('vm create signature gets guest_arch fallback')
else:
raise SystemExit('vm_create_submit signature marker not found')
old_form = '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, "network_mode": network_mode, "bridge": bridge}'
new_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
if old_form in text:
text = text.replace(old_form, new_form, 1)
changed.append('form state stores guest_arch')
elif '"guest_arch": guest_arch' in text:
changed.append('form state already stores guest_arch')
else:
old_form2 = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "network_mode": network_mode, "bridge": bridge}'
new_form2 = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
if old_form2 in text:
text = text.replace(old_form2, new_form2, 1)
changed.append('form state stores guest_arch fallback')
old_validation = 'elif source_type not in ("iso", "disk_image"):\n error = "Некорректный источник VM."'
new_validation = 'elif source_type not in ("iso", "disk_image"):\n error = "Некорректный источник VM."\n elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."'
if old_validation in text and 'Некорректная архитектура VM' not in text:
text = text.replace(old_validation, new_validation, 1)
changed.append('guest_arch validation added')
elif 'Некорректная архитектура VM' in text:
changed.append('guest_arch validation already present')
old_arch_block = ''' profile = host_profile.load_host_profile()
is_arm = profile.get("recommended_guest_arch") == "aarch64"
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
if is_arm:
cmd += ["--arch", "aarch64", "--machine", "virt", "--cpu", "host", "--virt-type", "kvm", "--boot", "uefi"]
'''
new_arch_block = ''' profile = host_profile.load_host_profile()
resolved_guest_arch = resolve_vm_arch(guest_arch, profile)
network_arg = f"network={network_core.NETWORK_NAME},model=virtio" if network_mode == "nat" else f"bridge={bridge},model=virtio"
cmd = ["virt-install", "--name", name, "--memory", str(memory), "--vcpus", str(vcpus)]
cmd = append_arch_args(cmd, resolved_guest_arch, profile)
'''
if old_arch_block in text:
text = text.replace(old_arch_block, new_arch_block, 1)
changed.append('virt-install arch args made selectable')
elif 'cmd = append_arch_args(cmd, resolved_guest_arch)' in text:
text = text.replace('cmd = append_arch_args(cmd, resolved_guest_arch)', 'cmd = append_arch_args(cmd, resolved_guest_arch, profile)')
changed.append('virt-install arch args now use host profile')
elif 'cmd = append_arch_args(cmd, resolved_guest_arch, profile)' in text:
changed.append('virt-install arch args already use host profile')
else:
raise SystemExit('arch command block marker not found')
text = text.replace('"guest_arch": profile.get("recommended_guest_arch"),', '"guest_arch": resolved_guest_arch, "guest_arch_choice": guest_arch,')
text = text.replace('"guest_arch": profile.get("recommended_guest_arch")', '"guest_arch": resolved_guest_arch, "guest_arch_choice": guest_arch')
app_path.write_text(text)
print('vm architecture patch applied:')
for item in changed:
print(f'- {item}')
+67
Просмотреть файл
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = r'''
def vm_autostart_status(name: str) -> dict[str, str | bool]:
result = run_cmd(["virsh", "dominfo", name], timeout=8)
output = result.get("stdout", "") or ""
match = re.search(r"^Autostart:\s*(.+)$", output, re.MULTILINE | re.IGNORECASE)
raw = match.group(1).strip() if match else "unknown"
enabled = raw.lower() in ("enable", "enabled", "yes", "on")
label = "enabled" if enabled else "disabled" if raw != "unknown" else "unknown"
css = "ok" if enabled else "warn"
return {"enabled": enabled, "label": label, "css": css, "raw": raw}
'''
if 'def vm_autostart_status(' not in text:
marker = '\n\ndef parse_virsh_list() -> list[dict[str, str]]:'
if marker not in text:
raise SystemExit('parse_virsh_list marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('vm_autostart_status helper added')
else:
changed.append('vm_autostart_status helper already present')
old_rows = ''' if len(parts) == 3:\n rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})\n elif len(parts) == 2:\n rows.append({"id": "-", "name": parts[0], "state": parts[1]})'''
new_rows = ''' if len(parts) == 3:\n autostart = vm_autostart_status(parts[1])\n rows.append({"id": parts[0], "name": parts[1], "state": parts[2], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})\n elif len(parts) == 2:\n autostart = vm_autostart_status(parts[0])\n rows.append({"id": "-", "name": parts[0], "state": parts[1], "autostart_enabled": autostart["enabled"], "autostart_label": autostart["label"], "autostart_css": autostart["css"]})'''
if old_rows in text:
text = text.replace(old_rows, new_rows, 1)
changed.append('parse_virsh_list enriched with autostart')
elif 'autostart_enabled' in text and 'autostart_label' in text:
changed.append('parse_virsh_list already has autostart')
else:
raise SystemExit('parse_virsh_list row marker not found')
old_details = '''def vm_details(name: str) -> dict[str, Any]:\n return {\n "name": name,\n "dominfo": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],\n "vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",\n "ip": vm_ip(name),\n "disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],\n "interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],\n "autostart": run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"],\n }'''
new_details = '''def vm_details(name: str) -> dict[str, Any]:\n dominfo = run_cmd(["virsh", "dominfo", name], timeout=10)["stdout"]\n autostart = vm_autostart_status(name)\n return {\n "name": name,\n "dominfo": dominfo,\n "vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",\n "ip": vm_ip(name),\n "disks": run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)["stdout"],\n "interfaces": run_cmd(["virsh", "domiflist", name], timeout=10)["stdout"],\n "autostart": dominfo,\n "autostart_enabled": autostart["enabled"],\n "autostart_label": autostart["label"],\n "autostart_css": autostart["css"],\n }'''
if old_details in text:
text = text.replace(old_details, new_details, 1)
changed.append('vm_details enriched with autostart')
elif '"autostart_enabled": autostart["enabled"]' in text:
changed.append('vm_details already has autostart')
else:
raise SystemExit('vm_details marker not found')
old_live = ''' vms.append({"id": vm.get("id", "-"), "name": name, "state": state, "state_css": css, "ip": ip if ip and ip != "not available" else ""})'''
new_live = ''' 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")})'''
if old_live in text:
text = text.replace(old_live, new_live, 1)
changed.append('live status enriched with autostart')
elif '"autostart_label": vm.get("autostart_label", "unknown")' in text:
changed.append('live status already has autostart')
else:
changed.append('live status marker not found, skipped')
app_path.write_text(text)
print('vm autostart patch applied:')
for item in changed:
print(f'- {item}')
+214
Просмотреть файл
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
from pathlib import Path
import re
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
app_dir = app_path.resolve().parent
template_path = app_dir / 'templates' / 'vm_create.html'
changed = []
warnings = []
text = app_path.read_text()
helpers = r'''
def vm_boot_order_options() -> list[dict[str, str]]:
return [
{"value": "auto", "label": "Auto — по источнику VM"},
{"value": "disk", "label": "Сначала диск"},
{"value": "cdrom_disk", "label": "Сначала ISO/CD-ROM, потом диск"},
{"value": "disk_cdrom", "label": "Сначала диск, потом ISO/CD-ROM"},
{"value": "network_disk", "label": "Сначала сеть/PXE, потом диск"},
]
def normalize_boot_order(value: str, source_type: str) -> str:
value = (value or "auto").strip()
if value == "auto":
return "cdrom_disk" if source_type == "iso" else "disk"
if value in ("disk", "cdrom_disk", "disk_cdrom", "network_disk"):
return value
return "cdrom_disk" if source_type == "iso" else "disk"
def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
mapping = {
"disk": "hd",
"cdrom_disk": "cdrom,hd",
"disk_cdrom": "hd,cdrom",
"network_disk": "network,hd",
}
value = mapping.get(boot_order, "hd")
if is_arm:
return "uefi," + value
return value
'''
def replace_once(source: str, old: str, new: str, label: str) -> str:
if old in source:
changed.append(label)
return source.replace(old, new, 1)
warnings.append(f'{label}: marker not found, skipped')
return source
if 'def vm_boot_order_options()' not in text:
marker = '\n\ndef vm_form_context('
if marker in text:
text = text.replace(marker, helpers + marker, 1)
changed.append('boot order helpers added')
else:
warnings.append('vm_form_context marker not found, helpers skipped')
else:
changed.append('boot order helpers already present')
if '"boot_options": vm_boot_order_options()' not in text:
if '"arch_options": vm_arch_options(),' in text:
text = text.replace('"arch_options": vm_arch_options(),', '"arch_options": vm_arch_options(), "boot_options": vm_boot_order_options(),', 1)
changed.append('vm form context gets boot options')
else:
warnings.append('arch_options marker not found, boot_options skipped')
else:
changed.append('vm form context already has boot options')
if '"boot_order": "auto"' not in text:
if '"guest_arch": "auto", "network_mode"' in text:
text = text.replace('"guest_arch": "auto", "network_mode"', '"guest_arch": "auto", "boot_order": "auto", "network_mode"', 1)
changed.append('default form boot_order added')
elif '"guest_arch": "auto"' in text:
text = text.replace('"guest_arch": "auto"', '"guest_arch": "auto", "boot_order": "auto"', 1)
changed.append('default form boot_order added near guest_arch')
else:
warnings.append('default form marker not found, boot_order default skipped')
else:
changed.append('default form boot_order already present')
if 'boot_order: str = Form("auto")' not in text:
signature_patterns = [
('guest_arch: str = Form("auto"), network_mode:', 'guest_arch: str = Form("auto"), boot_order: str = Form("auto"), network_mode:'),
('guest_arch: str = Form("auto"),\n network_mode:', 'guest_arch: str = Form("auto"),\n boot_order: str = Form("auto"),\n network_mode:'),
]
for old, new in signature_patterns:
if old in text:
text = text.replace(old, new, 1)
changed.append('vm create signature supports boot order')
break
else:
warnings.append('vm create signature marker not found, skipped')
else:
changed.append('vm create signature already supports boot order')
if '"boot_order": boot_order' not in text:
if '"guest_arch": guest_arch, "network_mode"' in text:
text = text.replace('"guest_arch": guest_arch, "network_mode"', '"guest_arch": guest_arch, "boot_order": boot_order, "network_mode"', 1)
changed.append('vm create form state supports boot order')
elif '"guest_arch": guest_arch' in text:
text = text.replace('"guest_arch": guest_arch', '"guest_arch": guest_arch, "boot_order": boot_order', 1)
changed.append('vm create form state supports boot order near guest_arch')
else:
warnings.append('vm create form state marker not found, skipped')
else:
changed.append('vm create form state already supports boot order')
if 'boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk")' not in text:
validation_old = 'elif guest_arch not in ("auto", "x86_64", "aarch64", "generic"):\n error = "Некорректная архитектура VM."'
validation_new = validation_old + '\n elif boot_order not in ("auto", "disk", "cdrom_disk", "disk_cdrom", "network_disk"):\n error = "Некорректный порядок загрузки VM."'
if validation_old in text:
text = text.replace(validation_old, validation_new, 1)
changed.append('vm create validation supports boot order')
else:
warnings.append('vm create validation marker not found, skipped')
else:
changed.append('vm create validation already supports boot order')
if 'selected_boot_order = normalize_boot_order(boot_order, source_type)' not in text:
if 'selected_arch = normalize_guest_arch(guest_arch, profile)\n' in text:
text = text.replace('selected_arch = normalize_guest_arch(guest_arch, profile)\n', 'selected_arch = normalize_guest_arch(guest_arch, profile)\n selected_boot_order = normalize_boot_order(boot_order, source_type)\n', 1)
changed.append('selected boot order added')
else:
warnings.append('selected_arch marker not found, selected boot order skipped')
else:
changed.append('selected boot order already present')
# Replace legacy ARM-specific --boot uefi in architecture block with unified --boot argument if present.
if ', "--boot", "uefi"]' in text:
text = text.replace(', "--boot", "uefi"]', ']')
changed.append('legacy ARM --boot uefi removed')
if 'cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)]' not in text:
marker = ' if source_type == "disk_image":\n'
if marker in text:
text = text.replace(marker, ' cmd += ["--boot", virt_boot_arg(selected_boot_order, is_arm)]\n\n' + marker, 1)
changed.append('virt-install boot argument added')
else:
warnings.append('source_type command marker not found, boot argument skipped')
else:
changed.append('virt-install boot argument already present')
if '"boot_order": selected_boot_order' not in text:
if '"guest_arch": selected_arch, "host_profile"' in text:
text = text.replace('"guest_arch": selected_arch, "host_profile"', '"guest_arch": selected_arch, "boot_order": selected_boot_order, "host_profile"', 1)
changed.append('operation metadata gets boot order')
elif '"guest_arch": selected_arch' in text:
text = text.replace('"guest_arch": selected_arch', '"guest_arch": selected_arch, "boot_order": selected_boot_order', 1)
changed.append('operation metadata gets boot order near guest_arch')
else:
warnings.append('operation metadata marker not found, skipped')
else:
changed.append('operation metadata already has boot order')
app_path.write_text(text)
if template_path.exists():
tpl = template_path.read_text()
original = tpl
if 'name="boot_order"' not in tpl:
insert = r'''
<label>
<span>Порядок загрузки</span>
<select name="boot_order" required>
{% for boot in boot_options %}
<option value="{{ boot.value }}" {% if form.boot_order == boot.value %}selected{% endif %}>{{ boot.label }}</option>
{% endfor %}
</select>
</label>
'''
markers = [
' <label>\n <span>Режим сети</span>',
' <label>\n <span>Режим сети</span>',
]
for marker in markers:
if marker in tpl:
tpl = tpl.replace(marker, insert + '\n' + marker, 1)
changed.append('boot order field added to vm_create.html')
break
else:
warnings.append('network mode marker not found in vm_create.html, boot field skipped')
else:
changed.append('boot order field already present in vm_create.html')
tpl = tpl.replace(
'Virtuality импортирует .img/.raw/.qcow2 в новый qcow2-диск VM. Поле “Диск, GB” не используется.',
'Virtuality импортирует .img/.raw/.qcow2 в новый qcow2-диск VM. Поле “Диск, GB” не используется. Для готовых дисков обычно выбирай порядок загрузки “Сначала диск”.',
)
tpl = tpl.replace(
'Virtuality создаст новый диск указанного размера и запустит установку с ISO.',
'Virtuality создаст новый диск указанного размера и запустит установку с ISO. Для установщика обычно выбирай “Сначала ISO/CD-ROM, потом диск”.',
)
if tpl != original:
template_path.write_text(tpl)
else:
warnings.append(f'vm_create.html not found: {template_path}')
print('VM boot order patch applied:')
for item in changed:
print(f'- {item}')
if warnings:
print('Warnings:')
for item in warnings:
print(f'- {item}')
+161
Просмотреть файл
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
app_dir = app_path.resolve().parent if app_path.exists() else Path('/opt/virtuality/web')
template_path = app_dir / 'templates' / 'vm_detail.html'
css_path = app_dir / 'static' / 'app.css'
changed = []
if not template_path.exists():
print(f'VM detail resource layout patch skipped: vm_detail.html not found: {template_path}')
raise SystemExit(0)
tpl = template_path.read_text()
original = tpl
resource_messages = r'''
{% if resource_message %}
<div class="alert success">{{ resource_message }}</div>
{% endif %}
{% if resource_error %}
<div class="alert danger">{{ resource_error }}</div>
{% endif %}
'''
resource_card = r'''
<article class="card">
<div class="card-head">
<h2>CPU / RAM / Архитектура</h2>
<span class="pill">resources</span>
</div>
<div class="resource-mini-grid">
<div><span>CPU</span><b>{{ resource_settings.vcpus }}</b></div>
<div><span>RAM</span><b>{{ resource_settings.memory_mb }} MB</b></div>
<div><span>Архитектура</span><b>{{ resource_settings.arch }}</b></div>
</div>
<form method="post" action="/vm/{{ vm.name }}/resources" class="form-grid">
<label>
<span>CPU, vCPU</span>
<input type="number" name="vcpus" min="1" max="128" value="{{ resource_settings.vcpus }}" required {% if not resource_settings.is_shutoff %}disabled{% endif %}>
</label>
<label>
<span>RAM, MB</span>
<input type="number" name="memory_mb" min="512" max="262144" step="128" value="{{ resource_settings.memory_mb }}" required {% if not resource_settings.is_shutoff %}disabled{% endif %}>
</label>
<label>
<span>Архитектура</span>
<select name="guest_arch" {% if not resource_settings.is_shutoff %}disabled{% endif %}>
<option value="keep" selected>Не менять — {{ resource_settings.arch }}</option>
<option value="x86_64">x86_64 / amd64</option>
<option value="aarch64">ARM64 / aarch64</option>
</select>
</label>
<button class="primary wide" type="submit" {% if not resource_settings.is_shutoff %}disabled{% endif %}>Применить ресурсы</button>
</form>
{% if not resource_settings.is_shutoff %}
<div class="alert danger">VM сейчас не выключена. CPU, RAM и архитектуру можно менять только после Shutdown/Power off.</div>
{% endif %}
<p class="muted small-note">CPU/RAM меняются в XML VM. Смена архитектуры требует совместимый диск и загрузчик; x86-диск не станет ARM-диском по щелчку, увы, физика всё ещё душнит.</p>
</article>
'''
if 'resource_message' not in tpl:
marker = ' {% if iso_message %}'
if marker in tpl:
tpl = tpl.replace(marker, resource_messages + '\n' + marker, 1)
changed.append('resource messages added before ISO messages')
else:
# Safe fallback: place messages after header/topbar area if exact ISO marker is not present.
marker = ' <section'
index = tpl.find(marker)
if index != -1:
tpl = tpl[:index] + resource_messages + '\n' + tpl[index:]
changed.append('resource messages added by fallback')
else:
changed.append('resource messages skipped: no safe marker')
if 'CPU / RAM / Архитектура' not in tpl:
candidate_markers = [
' <section class="grid three vm-resource-boot-iso-grid">',
' <section class="grid two vm-boot-iso-grid">',
' <section class="grid two">',
' <section class="card">',
]
inserted = False
for marker in candidate_markers:
pos = tpl.find(marker)
if pos == -1:
continue
if 'vm-boot-iso-grid' in marker:
tpl = tpl.replace(marker, ' <section class="grid three vm-resource-boot-iso-grid">', 1)
grid_pos = tpl.find('vm-resource-boot-iso-grid')
insert_at = tpl.find('\n <article class="card">', grid_pos)
if insert_at != -1:
tpl = tpl[:insert_at] + '\n' + resource_card + tpl[insert_at:]
else:
close_at = tpl.find('</section>', grid_pos)
if close_at != -1:
tpl = tpl[:close_at] + resource_card + tpl[close_at:]
changed.append('resource card inserted into existing settings grid')
inserted = True
break
elif marker == ' <section class="grid two">':
settings = '\n\n <section class="grid three vm-resource-boot-iso-grid">\n' + resource_card + ' </section>\n'
tpl = tpl.replace('\n\n' + marker, settings + '\n\n' + marker, 1)
changed.append('resource grid inserted before first two-column grid')
inserted = True
break
else:
settings = '\n\n <section class="grid three vm-resource-boot-iso-grid">\n' + resource_card + ' </section>\n'
tpl = tpl[:pos] + settings + tpl[pos:]
changed.append('resource grid inserted before first card fallback')
inserted = True
break
if not inserted:
changed.append('resource card skipped: no safe layout marker')
else:
changed.append('resource card already present')
# Upgrade older two-column settings grid when present.
tpl = tpl.replace('class="grid two vm-boot-iso-grid"', 'class="grid three vm-resource-boot-iso-grid"')
if tpl != original:
template_path.write_text(tpl)
if css_path.exists():
css = css_path.read_text()
original_css = css
if '.three {' not in css:
if '.two {' in css:
css = css.replace('.two { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 12px; }', '.two { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 12px; }\n.three { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 12px; }')
if '.three {' not in css:
css += '\n.three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }\n'
else:
css += '\n.three { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }\n'
changed.append('three-column grid CSS added')
if '.resource-mini-grid' not in css:
css += '''
.resource-mini-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin-bottom: 10px; }
.resource-mini-grid div { display: grid; gap: 3px; padding: 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); }
.resource-mini-grid span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
.resource-mini-grid b { font-size: 14px; overflow-wrap: anywhere; }
.boot-order-list { display: grid; gap: 8px; }
.boot-order-item { display: flex; align-items: center; gap: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); cursor: grab; }
.boot-order-item:active { cursor: grabbing; }
.boot-order-item.dragging { opacity: .55; border-color: var(--accent); }
.boot-order-item small { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
.drag-handle { color: var(--muted); font-weight: 900; }
@media (max-width: 1180px) { .three { grid-template-columns: 1fr; } .resource-mini-grid { grid-template-columns: 1fr; } }
'''
changed.append('resource and boot-order CSS added')
if css != original_css:
css_path.write_text(css)
else:
changed.append('CSS skipped: app.css not found')
print('VM detail resource layout patch applied:')
for item in changed or ['already applied']:
print(f'- {item}')
+74
Просмотреть файл
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = r'''
def vm_exists(name: str) -> bool:
if not name or not valid_vm_name(name):
return False
return run_cmd(["virsh", "dominfo", name], timeout=8)["ok"]
'''
if 'def vm_exists(name: str) -> bool:' not in text:
marker = '\n\ndef list_vms() -> list[dict[str, str]]:'
if marker not in text:
raise SystemExit('list_vms marker not found')
text = text.replace(marker, helper + marker, 1)
changed.append('vm_exists helper added')
else:
changed.append('vm_exists helper already present')
old_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
new_sig = 'def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(...), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), guest_arch: str = Form("auto"), replace_existing_disk: str = Form("0"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE)):'
if old_sig in text:
text = text.replace(old_sig, new_sig, 1)
changed.append('replace_existing_disk added to signature')
elif 'replace_existing_disk: str = Form("0")' in text:
changed.append('replace_existing_disk already in signature')
else:
raise SystemExit('vm_create_submit signature marker not found')
old_form = 'form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "guest_arch": guest_arch, "network_mode": network_mode, "bridge": bridge}'
new_form = '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, "replace_existing_disk": replace_existing_disk, "network_mode": network_mode, "bridge": bridge}'
if old_form in text:
text = text.replace(old_form, new_form, 1)
changed.append('replace_existing_disk stored in form')
elif '"replace_existing_disk": replace_existing_disk' in text:
changed.append('replace_existing_disk already stored in form')
old_disk_check = ''' disk_path = IMAGES_DIR / f"{name}.qcow2"
if disk_path.exists():
return vm_form_context(request, error=f"Диск уже существует: {disk_path}", form=form, status_code=400)
profile = host_profile.load_host_profile()
'''
new_disk_check = ''' disk_path = IMAGES_DIR / f"{name}.qcow2"
if vm_exists(name):
return vm_form_context(request, error=f"VM с именем {name} уже существует. Выбери другое имя или удали существующую VM.", form=form, status_code=400)
if disk_path.exists():
if replace_existing_disk == "1":
disk_path.unlink()
else:
return vm_form_context(request, error=f"Диск уже существует: {disk_path}. Это может быть остаток неудачной установки. Включи опцию «Заменить существующий диск», если VM с таким именем не нужна.", form=form, status_code=400)
profile = host_profile.load_host_profile()
'''
if old_disk_check in text:
text = text.replace(old_disk_check, new_disk_check, 1)
changed.append('disk replace logic added')
elif 'Заменить существующий диск' in text and 'replace_existing_disk == "1"' in text:
changed.append('disk replace logic already present')
else:
raise SystemExit('disk_path exists marker not found')
app_path.write_text(text)
print('vm disk replace patch applied:')
for item in changed:
print(f'- {item}')
+64
Просмотреть файл
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
changed = []
helper = '''
def bridge_exists(name: str) -> bool:
if not name or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", name):
return False
return run_cmd(["ip", "link", "show", name], timeout=5)["ok"]
'''
if 'def bridge_exists(name: str) -> bool:' not in text:
markers = [
'\n\ndef default_network_mode() -> str:',
'\n\ndef valid_vm_name(name: str) -> bool:',
'\n\ndef parse_virsh_list() -> list[dict[str, str]]:',
]
inserted = False
for marker in markers:
if marker in text:
text = text.replace(marker, helper + marker, 1)
changed.append('bridge_exists helper added')
inserted = True
break
if not inserted:
print('WARN: bridge helper marker not found, skip helper injection')
changed.append('bridge_exists helper skipped')
else:
changed.append('bridge_exists helper already present')
old_validation = ''' elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
error = "Некорректное имя bridge."
else:
iso = Path(iso_path).resolve()
'''
new_validation = ''' elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
error = "Некорректное имя bridge."
elif network_mode == "bridge" and 'bridge_exists' in globals() and not bridge_exists(bridge):
error = f"Bridge {bridge} не найден на сервере. Для VPS выбери режим NAT Router — virtuality-nat, либо сначала создай bridge {bridge}."
else:
iso = Path(iso_path).resolve()
'''
if old_validation in text:
text = text.replace(old_validation, new_validation, 1)
changed.append('bridge existence validation added')
elif 'Bridge {bridge} не найден на сервере' in text:
changed.append('bridge existence validation already present')
else:
print('WARN: bridge validation marker not found, skip validation injection')
changed.append('bridge validation skipped')
app_path.write_text(text)
print('vm network guard patch applied:')
for item in changed:
print(f'- {item}')
+101
Просмотреть файл
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
app_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/opt/virtuality/web/app.py')
if not app_path.exists():
raise SystemExit(f'app.py not found: {app_path}')
text = app_path.read_text()
safe_proxy = '''# Virtuality noVNC console patch
async def proxy_vnc_to_websocket(reader: asyncio.StreamReader, websocket: WebSocket) -> None:
while True:
data = await reader.read(65536)
if not data:
break
try:
await websocket.send_bytes(data)
except (WebSocketDisconnect, RuntimeError, ConnectionError):
break
async def proxy_websocket_to_vnc(websocket: WebSocket, writer: asyncio.StreamWriter) -> None:
while True:
try:
message = await websocket.receive()
except (WebSocketDisconnect, RuntimeError, ConnectionError):
break
if message.get("type") == "websocket.disconnect":
break
if message.get("bytes") is not None:
writer.write(message["bytes"])
elif message.get("text") is not None:
writer.write(message["text"].encode())
try:
await writer.drain()
except (RuntimeError, ConnectionError, BrokenPipeError):
break
'''
safe_gather = ''' tasks = [
asyncio.create_task(proxy_vnc_to_websocket(reader, websocket)),
asyncio.create_task(proxy_websocket_to_vnc(websocket, writer)),
]
try:
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
await asyncio.gather(*done, return_exceptions=True)
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
'''
if 'Virtuality noVNC console patch' in text:
start = text.find('# Virtuality noVNC console patch')
marker = '\n\n@app.get("/login", response_class=HTMLResponse)'
end = text.find(marker, start)
if start != -1 and end != -1:
text = text[:start] + safe_proxy + text[end:]
old_gather = ''' try:
await asyncio.gather(proxy_vnc_to_websocket(reader, websocket), proxy_websocket_to_vnc(websocket, writer))
except (WebSocketDisconnect, asyncio.CancelledError, ConnectionError):
pass
finally:
writer.close()
await writer.wait_closed()
'''
if old_gather in text:
text = text.replace(old_gather, safe_gather, 1)
app_path.write_text(text)
print(f'noVNC console patch upgraded: {app_path}')
raise SystemExit(0)
text = text.replace('import crypt\n', 'import asyncio\nimport crypt\n', 1)
text = text.replace('from fastapi import FastAPI, Request, Form, UploadFile, File\n', 'from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect\n', 1)
text = text.replace('serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session")\n', 'serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-session")\nconsole_serializer = URLSafeSerializer(SESSION_SECRET, salt="virtuality-console")\n', 1)
text = text.replace('DEFAULT_BRIDGE = "br0"\n', 'DEFAULT_BRIDGE = "br0"\nNOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None)\n', 1)
text = text.replace('app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")\n', 'app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")\nif NOVNC_DIR:\n app.mount("/novnc", StaticFiles(directory=str(NOVNC_DIR)), name="novnc")\n', 1)
old_get_user = '''def get_current_user(request: Request) -> str | None:\n token = request.cookies.get("virtuality_session")\n if not token:\n return None\n try:\n data = serializer.loads(token)\n except BadSignature:\n return None\n return AUTH_USER if data.get("user") == AUTH_USER else None\n'''
new_get_user = '''def user_from_session_token(token: str | None) -> str | None:\n if not token:\n return None\n try:\n data = serializer.loads(token)\n except BadSignature:\n return None\n return AUTH_USER if data.get("user") == AUTH_USER else None\n\n\ndef get_current_user(request: Request) -> str | None:\n return user_from_session_token(request.cookies.get("virtuality_session"))\n'''
text = text.replace(old_get_user, new_get_user, 1)
insert_after_vm_ip = '''def vm_vnc_display(name: str) -> str:\n return run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available"\n\n\ndef vnc_display_to_port(display: str) -> int | None:\n value = (display or "").strip()\n if not value or value == "not available":\n return None\n match = re.search(r":(\\d+)$", value)\n if not match:\n return None\n display_number = int(match.group(1))\n if display_number >= 5900:\n return display_number\n return 5900 + display_number\n\n\ndef console_info(name: str) -> dict[str, Any]:\n display = vm_vnc_display(name)\n port = vnc_display_to_port(display)\n has_novnc = bool(NOVNC_DIR and (NOVNC_DIR / "vnc.html").exists())\n token = None\n url = None\n if port and has_novnc:\n token = console_serializer.dumps({"vm": name, "port": port})\n url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=console/ws/{token}"\n return {"vm": name, "display": display, "port": port, "has_novnc": has_novnc, "novnc_dir": str(NOVNC_DIR) if NOVNC_DIR else "not installed", "url": url}\n\n\n'''
text = text.replace('def vm_details(name: str) -> dict[str, Any]:\n', insert_after_vm_ip + 'def vm_details(name: str) -> dict[str, Any]:\n', 1)
text = text.replace('"vnc": run_cmd(["virsh", "vncdisplay", name], timeout=8)["stdout"] or "not available",', '"vnc": vm_vnc_display(name),', 1)
text = text.replace('@app.get("/login", response_class=HTMLResponse)\n', safe_proxy + '\n@app.get("/login", response_class=HTMLResponse)\n', 1)
console_routes = '''\n\n@app.get("/vm/{name}/console", response_class=HTMLResponse)\ndef vm_console_page(request: Request, name: str):\n auth_redirect = require_auth(request)\n if auth_redirect:\n return auth_redirect\n if not valid_vm_name(name) or not vm_exists(name):\n return RedirectResponse(url="/", status_code=303)\n return templates.TemplateResponse("console.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)})\n\n\n@app.websocket("/console/ws/{token}")\nasync def console_websocket(websocket: WebSocket, token: str):\n if user_from_session_token(websocket.cookies.get("virtuality_session")) != AUTH_USER:\n await websocket.close(code=1008)\n return\n try:\n payload = console_serializer.loads(token)\n vm_name = payload.get("vm")\n target_port = int(payload.get("port"))\n except Exception:\n await websocket.close(code=1008)\n return\n if not valid_vm_name(vm_name) or not vm_exists(vm_name) or target_port < 5900 or target_port > 5999:\n await websocket.close(code=1008)\n return\n await websocket.accept()\n try:\n reader, writer = await asyncio.open_connection("127.0.0.1", target_port)\n except Exception:\n await websocket.close(code=1011)\n return\n''' + safe_gather.replace('\n', '\n')
text = text.replace('\n\n@app.get("/vm/{name}", response_class=HTMLResponse)\n', console_routes + '\n\n@app.get("/vm/{name}", response_class=HTMLResponse)\n', 1)
app_path.write_text(text)
print(f'noVNC console patch applied: {app_path}')
-38
Просмотреть файл
@@ -1,38 +0,0 @@
import sys
from pathlib import Path
import pytest
WEB_DIR = Path(__file__).resolve().parent.parent / "web"
sys.path.insert(0, str(WEB_DIR))
@pytest.fixture(autouse=True)
def isolate_dirs(tmp_path, monkeypatch):
"""Уводим все записи на диск в tmp, чтобы тесты работали без root и не трогали систему."""
import app
import backup_core
import cloud_images
monkeypatch.setattr(app, "OPERATIONS_DIR", tmp_path / "ops")
monkeypatch.setattr(app, "ISO_DIR", tmp_path / "iso")
monkeypatch.setattr(app, "IMAGES_DIR", tmp_path / "images")
monkeypatch.setattr(app, "DISK_IMAGES_DIR", tmp_path / "disk-images")
monkeypatch.setattr(app, "VM_TEMPLATES_FILE", tmp_path / "config" / "vm_templates.json")
monkeypatch.setattr(backup_core, "BACKUPS_DIR", tmp_path / "backups")
monkeypatch.setattr(backup_core, "SCHEDULE_FILE", tmp_path / "config" / "backup_schedule.json")
monkeypatch.setattr(backup_core, "LOG_FILE", tmp_path / "log" / "backup.log")
monkeypatch.setattr(cloud_images, "CLOUD_IMAGES_DIR", tmp_path / "cloud-images")
yield
@pytest.fixture()
def auth_client():
"""TestClient с валидной сессией и CSRF-токеном."""
from fastapi.testclient import TestClient
import app
client = TestClient(app.app)
token = app.serializer.dumps({"user": app.AUTH_USER, "csrf": "test-csrf-token"})
client.cookies.set("virtuality_session", token)
return client
-82
Просмотреть файл
@@ -1,82 +0,0 @@
import app
def test_login_page_renders(auth_client):
response = auth_client.get("/login", follow_redirects=False)
# авторизованный пользователь уводится на дашборд
assert response.status_code == 303
def test_unauthenticated_post_redirects_to_login():
from fastapi.testclient import TestClient
client = TestClient(app.app)
response = client.post("/host/refresh", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_post_without_csrf_rejected(auth_client):
response = auth_client.post("/iso/refresh", follow_redirects=False)
assert response.status_code == 403
def test_post_with_wrong_csrf_rejected(auth_client):
response = auth_client.post("/iso/refresh", data={"csrf_token": "wrong"}, follow_redirects=False)
assert response.status_code == 403
def test_post_with_valid_csrf_accepted(auth_client):
response = auth_client.post("/iso/refresh", data={"csrf_token": "test-csrf-token"}, follow_redirects=False)
assert response.status_code == 303
def test_dashboard_renders_with_csrf_inputs(auth_client):
response = auth_client.get("/")
assert response.status_code == 200
assert 'name="csrf_token"' in response.text
assert "cpu-spark" in response.text
def test_backups_page_renders(auth_client):
response = auth_client.get("/backups")
assert response.status_code == 200
def test_cloud_images_page_renders(auth_client):
response = auth_client.get("/cloud-images")
assert response.status_code == 200
def test_login_rate_limit():
ip = "203.0.113.7"
app.reset_login_failures(ip)
assert app.login_block_remaining(ip) == 0
for _ in range(app.LOGIN_MAX_FAILURES):
app.register_login_failure(ip)
assert app.login_block_remaining(ip) > 0
app.reset_login_failures(ip)
assert app.login_block_remaining(ip) == 0
def test_session_token_roundtrip():
token = app.serializer.dumps({"user": app.AUTH_USER, "csrf": "abc"})
assert app.user_from_session_token(token) == app.AUTH_USER
assert app.user_from_session_token("garbage") is None
assert app.user_from_session_token(None) is None
def test_template_vm_cannot_start(auth_client, monkeypatch):
monkeypatch.setattr(app, "vm_exists", lambda name: True)
app.set_vm_template("tpl-vm", True)
response = auth_client.post("/vm/tpl-vm/start", data={"csrf_token": "test-csrf-token"}, follow_redirects=False)
assert response.status_code == 303
assert "clone_error" in response.headers["location"]
def test_metrics_endpoint(auth_client):
response = auth_client.get("/live/metrics")
assert response.status_code == 200
payload = response.json()
assert payload["ok"]
assert "series" in payload
-87
Просмотреть файл
@@ -1,87 +0,0 @@
import time
from pathlib import Path
import backup_core
def test_backup_filename_validation():
assert backup_core.valid_backup_filename("vm1_20260815_010101.qcow2")
assert backup_core.valid_backup_filename("a.qcow2")
assert not backup_core.valid_backup_filename("../evil.qcow2")
assert not backup_core.valid_backup_filename("x.img")
assert not backup_core.valid_backup_filename("")
def test_backup_path_traversal_guard():
assert backup_core.backup_path("vm1", "vm1_x.qcow2") is not None
assert backup_core.backup_path("vm1", "../../etc/passwd") is None
assert backup_core.backup_path("../vm1", "x.qcow2") is None
assert backup_core.backup_path("vm1", "nested/x.qcow2") is None
def test_new_backup_path_shape():
path = backup_core.new_backup_path("vm1")
assert path is not None
assert path.name.startswith("vm1_")
assert path.suffix == ".qcow2"
assert backup_core.new_backup_path("bad name") is None
def test_rotation_keeps_newest():
vm_dir = backup_core.backup_dir_for("vm1")
vm_dir.mkdir(parents=True)
for index in range(5):
item = vm_dir / f"vm1_{index}.qcow2"
item.write_text("x")
stamp = time.time() - (5 - index) * 60
import os
os.utime(item, (stamp, stamp))
removed = backup_core.rotate_backups("vm1", keep=2)
assert len(removed) == 3
remaining = sorted(p.name for p in vm_dir.glob("*.qcow2"))
assert remaining == ["vm1_3.qcow2", "vm1_4.qcow2"]
def test_rotation_ignores_bad_input():
assert backup_core.rotate_backups("no-such-vm", keep=3) == []
assert backup_core.rotate_backups("vm1", keep=0) == []
def test_schedule_roundtrip():
config = backup_core.save_schedule(True, ["vm2", "vm1", "bad name", "vm1"], 99)
assert config == {"enabled": True, "vms": ["vm1", "vm2"], "keep": 30}
assert backup_core.load_schedule() == config
config = backup_core.save_schedule(False, [], 0)
assert backup_core.load_schedule() == {"enabled": False, "vms": [], "keep": 1}
def test_schedule_defaults_when_missing():
assert backup_core.load_schedule() == {"enabled": False, "vms": [], "keep": backup_core.DEFAULT_KEEP}
def test_backup_script_contents():
target = Path("/var/lib/virtuality/backups/vm1/vm1_x.qcow2")
script = backup_core.build_backup_script("vm1", "/var/lib/virtuality/images/vm1.qcow2", target)
assert "virsh suspend vm1" in script
assert "qemu-img convert -p -c -O qcow2" in script
assert "virsh resume vm1" in script
assert f"{target}.part" in script # атомарная запись через .part
restore = backup_core.build_restore_script("/var/lib/virtuality/images/vm1.qcow2", target)
assert "qemu-img convert -p -O qcow2" in restore
assert ".restore" in restore
def test_first_disk_of_parsing(monkeypatch):
sample = """ Type Device Target Source
------------------------------------------------
file disk vda /var/lib/virtuality/images/vm1.qcow2
file cdrom sda /var/lib/virtuality/iso/ubuntu.iso
"""
class FakeResult:
returncode = 0
stdout = sample
stderr = ""
monkeypatch.setattr(backup_core, "_run", lambda cmd, timeout=60: FakeResult())
assert backup_core.first_disk_of("vm1") == "/var/lib/virtuality/images/vm1.qcow2"
-80
Просмотреть файл
@@ -1,80 +0,0 @@
import cloud_images
def test_catalog_entries_consistent():
keys = [entry["key"] for entry in cloud_images.CATALOG]
assert len(keys) == len(set(keys))
for entry in cloud_images.CATALOG:
assert entry["arch"] in ("x86_64", "aarch64")
assert entry["url"].startswith("https://")
assert cloud_images.safe_image_filename(entry["filename"])
def test_catalog_entry_lookup():
assert cloud_images.catalog_entry("ubuntu-24.04-x86_64") is not None
assert cloud_images.catalog_entry("nope") is None
def test_catalog_for_arch_puts_native_first():
entries = cloud_images.catalog_for_arch("aarch64")
natives = [e["native"] for e in entries]
assert natives == sorted(natives, reverse=True)
assert entries[0]["arch"] == "aarch64"
entries_x86 = cloud_images.catalog_for_arch("x86_64")
assert entries_x86[0]["arch"] == "x86_64"
def test_safe_image_filename():
assert cloud_images.safe_image_filename("ubuntu-24.04-x86_64.qcow2")
assert cloud_images.safe_image_filename("img.img")
assert cloud_images.safe_image_filename("../../etc/passwd") is None
assert cloud_images.safe_image_filename("../escape.qcow2") == "escape.qcow2" # basename-санитизация
assert cloud_images.safe_image_filename("evil.iso") is None
def test_image_path_traversal_guard(tmp_path, monkeypatch):
monkeypatch.setattr(cloud_images, "CLOUD_IMAGES_DIR", tmp_path)
assert cloud_images.image_path_by_name("ok.qcow2") == tmp_path / "ok.qcow2"
# Каталоги отбрасываются до basename — результат всегда внутри CLOUD_IMAGES_DIR
assert cloud_images.image_path_by_name("../escape.qcow2") == tmp_path / "escape.qcow2"
assert cloud_images.image_path_by_name("bad name.qcow2") is None
def test_valid_cloud_username():
assert cloud_images.valid_cloud_username("admin")
assert cloud_images.valid_cloud_username("_svc-user")
assert not cloud_images.valid_cloud_username("Admin")
assert not cloud_images.valid_cloud_username("1abc")
assert not cloud_images.valid_cloud_username("")
def test_valid_ssh_key():
assert cloud_images.valid_ssh_key("") # ключ необязателен
assert cloud_images.valid_ssh_key("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample user@host")
assert cloud_images.valid_ssh_key("ssh-rsa AAAAB3NzaC1yc2E=")
assert not cloud_images.valid_ssh_key("not a key")
assert not cloud_images.valid_ssh_key("ssh-ed25519")
def test_build_user_data_with_password():
data = cloud_images.build_user_data("vm1", "admin", 'p"ss', "")
assert data.startswith("#cloud-config")
assert "hostname: vm1" in data
assert "name: admin" in data
assert "ssh_pwauth: true" in data
assert '"p\\"ss"' in data # пароль экранирован как JSON/YAML-строка
assert "ssh_authorized_keys" not in data
def test_build_user_data_with_key_only():
key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample user@host"
data = cloud_images.build_user_data("vm1", "admin", "", key)
assert "ssh_pwauth: false" in data
assert key in data
assert "chpasswd" not in data
def test_build_meta_data():
meta = cloud_images.build_meta_data("vm1")
assert "instance-id: virtuality-vm1" in meta
assert "local-hostname: vm1" in meta
-93
Просмотреть файл
@@ -1,93 +0,0 @@
import app
def test_valid_vm_name():
assert app.valid_vm_name("ubuntu-test")
assert app.valid_vm_name("vm1.local_x")
assert not app.valid_vm_name("")
assert not app.valid_vm_name("a") # минимум 2 символа
assert not app.valid_vm_name("-starts-with-dash")
assert not app.valid_vm_name("has space")
assert not app.valid_vm_name("x" * 64)
def test_valid_snapshot_name():
assert app.valid_snapshot_name("before-update")
assert app.valid_snapshot_name("s1")
assert app.valid_snapshot_name("a") # один символ допустим
assert not app.valid_snapshot_name("")
assert not app.valid_snapshot_name("bad name")
assert not app.valid_snapshot_name(".hidden")
def test_safe_iso_filename():
assert app.safe_iso_filename("ubuntu-24.04.iso") == "ubuntu-24.04.iso"
assert app.safe_iso_filename("my image.iso") == "my-image.iso"
assert app.safe_iso_filename("../../etc/passwd") is None
assert app.safe_iso_filename("evil.qcow2") is None
assert app.safe_iso_filename("") is None
def test_iso_path_traversal_guard(tmp_path, monkeypatch):
monkeypatch.setattr(app, "ISO_DIR", tmp_path)
assert app.iso_path_by_name("ok.iso") == tmp_path / "ok.iso"
# Пути с каталогами санитизируются до basename и остаются внутри ISO_DIR
assert app.iso_path_by_name("../escape.iso") == tmp_path / "escape.iso"
assert app.iso_path_by_name("nested/evil.iso") == tmp_path / "evil.iso"
assert app.iso_path_by_name("не-ascii.iso") != tmp_path / "не-ascii.iso" # кириллица заменяется
assert app.iso_path_by_name("no-extension") is None
def test_progress_parsers():
assert app.wget_progress(10, " 512000K .......... 42% 10.5M 2m30s") == 42
assert app.wget_progress(50, "no percent here") == 50
assert app.wget_progress(10, "100%") == 99 # качаем — не показываем 100 до mv
assert app.qemu_convert_progress(0, " (42.00/100%)") == 42
assert app.qemu_convert_progress(77, " (42.00/100%)") == 77 # прогресс не откатывается
assert app.progress_from_line(10, "Allocating disk...") == 30
assert app.progress_from_line(50, "Creating domain...") == 75
def test_snapshot_list_parser(monkeypatch):
sample = """ Name Creation Time State
--------------------------------------------------------
before-update 2026-08-15 12:10:33 +0300 running
clean 2026-08-14 09:01:02 +0300 shutoff
"""
monkeypatch.setattr(app, "run_cmd", lambda cmd, timeout=12: {"ok": True, "stdout": sample, "stderr": "", "code": 0, "cmd": ""})
snaps = app.list_vm_snapshots("vm1")
assert [s["name"] for s in snaps] == ["before-update", "clean"]
assert snaps[0]["state"] == "running"
assert snaps[0]["created"] == "2026-08-15 12:10:33 +0300"
def test_snapshot_action_rejects_bad_name():
ok, message = app.snapshot_action("vm1", "create", "bad name")
assert not ok
assert "Имя снапшота" in message
def test_vm_templates_flag(tmp_path, monkeypatch):
monkeypatch.setattr(app, "VM_TEMPLATES_FILE", tmp_path / "vm_templates.json")
assert app.load_vm_templates() == []
app.set_vm_template("vm1", True)
app.set_vm_template("vm2", True)
assert app.is_vm_template("vm1")
assert sorted(app.load_vm_templates()) == ["vm1", "vm2"]
app.set_vm_template("vm1", False)
assert not app.is_vm_template("vm1")
def test_ttl_cache(monkeypatch):
calls = []
def producer():
calls.append(1)
return len(calls)
app.cache_invalidate()
assert app.cached("k1", 60, producer) == 1
assert app.cached("k1", 60, producer) == 1
assert len(calls) == 1
app.cache_invalidate("k1")
assert app.cached("k1", 60, producer) == 2
Разница между файлами не показана из-за своего большого размера Загрузить разницу
-209
Просмотреть файл
@@ -1,209 +0,0 @@
#!/usr/bin/env python3
"""Бэкапы дисков VM: сжатые qcow2-копии в /var/lib/virtuality/backups.
Без внешних зависимостей — модуль используется и веб-панелью, и ночным
systemd-таймером (запуск: python3 backup_core.py --scheduled).
"""
import json
import re
import subprocess
import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import Any
BACKUPS_DIR = Path("/var/lib/virtuality/backups")
SCHEDULE_FILE = Path("/var/lib/virtuality/config/backup_schedule.json")
SCHEDULE_LOCK = threading.Lock()
LOG_FILE = Path("/var/log/virtuality/backup.log")
DEFAULT_KEEP = 5
def valid_vm_name(name: str) -> bool:
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""))
def valid_backup_filename(name: str) -> bool:
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,120}\.qcow2", name or ""))
def backup_dir_for(vm_name: str) -> Path | None:
if not valid_vm_name(vm_name):
return None
path = (BACKUPS_DIR / vm_name).resolve()
if BACKUPS_DIR.resolve() not in path.parents:
return None
return path
def backup_path(vm_name: str, filename: str) -> Path | None:
vm_dir = backup_dir_for(vm_name)
if vm_dir is None or not valid_backup_filename(filename):
return None
path = (vm_dir / filename).resolve()
if vm_dir not in path.parents:
return None
return path
def new_backup_path(vm_name: str) -> Path | None:
vm_dir = backup_dir_for(vm_name)
if vm_dir is None:
return None
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return vm_dir / f"{vm_name}_{stamp}.qcow2"
def list_backups() -> list[dict[str, Any]]:
backups = []
if not BACKUPS_DIR.exists():
return backups
for vm_dir in sorted(BACKUPS_DIR.iterdir()):
if not vm_dir.is_dir() or not valid_vm_name(vm_dir.name):
continue
for item in sorted(vm_dir.glob("*.qcow2"), reverse=True):
try:
stat = item.stat()
except OSError:
continue
backups.append({
"vm": vm_dir.name,
"name": item.name,
"path": str(item),
"size": f"{stat.st_size / 1024 / 1024:.0f} MB",
"created": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
})
return backups
def rotate_backups(vm_name: str, keep: int) -> list[str]:
"""Удаляет старые бэкапы сверх keep. Возвращает имена удалённых файлов."""
vm_dir = backup_dir_for(vm_name)
if vm_dir is None or not vm_dir.exists() or keep < 1:
return []
files = sorted(vm_dir.glob("*.qcow2"), key=lambda p: p.stat().st_mtime, reverse=True)
removed = []
for stale in files[keep:]:
try:
stale.unlink()
removed.append(stale.name)
except OSError:
pass
return removed
def load_schedule() -> dict[str, Any]:
try:
data = json.loads(SCHEDULE_FILE.read_text())
if isinstance(data, dict):
return {
"enabled": bool(data.get("enabled", False)),
"vms": [name for name in data.get("vms", []) if isinstance(name, str) and valid_vm_name(name)],
"keep": max(1, min(30, int(data.get("keep", DEFAULT_KEEP)))),
}
except Exception:
pass
return {"enabled": False, "vms": [], "keep": DEFAULT_KEEP}
def save_schedule(enabled: bool, vms: list[str], keep: int) -> dict[str, Any]:
config = {
"enabled": bool(enabled),
"vms": sorted({name for name in vms if valid_vm_name(name)}),
"keep": max(1, min(30, int(keep))),
}
with SCHEDULE_LOCK:
SCHEDULE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp_path = SCHEDULE_FILE.with_suffix(".json.tmp")
tmp_path.write_text(json.dumps(config, ensure_ascii=False, indent=2))
tmp_path.replace(SCHEDULE_FILE)
return config
def build_backup_script(vm_name: str, disk_path: str, target: Path) -> str:
"""Скрипт бэкапа: пауза работающей VM на время копии, сжатый qcow2, resume.
Для выключенной VM virsh suspend/resume тихо провалятся — это нормально.
"""
part = f"{target}.part"
return (
"set -uo pipefail; "
f"mkdir -p {target.parent}; "
f"virsh suspend {vm_name} >/dev/null 2>&1 && RESUME=1 || RESUME=0; "
f"qemu-img convert -p -c -O qcow2 {disk_path} {part}; STATUS=$?; "
f"[ \"$RESUME\" = 1 ] && virsh resume {vm_name} >/dev/null 2>&1; "
f"[ $STATUS -ne 0 ] && rm -f {part} && exit $STATUS; "
f"mv {part} {target}"
)
def build_restore_script(disk_path: str, backup_file: Path) -> str:
part = f"{disk_path}.restore"
return (
"set -euo pipefail; "
f"qemu-img convert -p -O qcow2 {backup_file} {part}; "
f"mv {part} {disk_path}"
)
# ---- CLI-режим для ночного systemd-таймера ----
def _log(message: str) -> None:
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with LOG_FILE.open("a", encoding="utf-8") as handle:
handle.write(f"[{stamp}] {message}\n")
def _run(cmd: list[str], timeout: int = 60) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
def first_disk_of(vm_name: str) -> str | None:
result = _run(["virsh", "domblklist", vm_name, "--details"])
if result.returncode != 0:
return None
for line in result.stdout.splitlines()[2:]:
parts = line.split()
if len(parts) >= 4 and parts[1] == "disk" and parts[3].endswith(".qcow2"):
return parts[3]
return None
def run_scheduled_backups() -> int:
config = load_schedule()
if not config["enabled"]:
_log("Расписание выключено — выходим.")
return 0
if not config["vms"]:
_log("Расписание включено, но список VM пуст.")
return 0
failures = 0
for vm_name in config["vms"]:
disk = first_disk_of(vm_name)
if not disk:
_log(f"{vm_name}: qcow2-диск не найден, пропуск.")
failures += 1
continue
target = new_backup_path(vm_name)
if target is None:
_log(f"{vm_name}: некорректное имя VM, пропуск.")
failures += 1
continue
_log(f"{vm_name}: бэкап {disk}{target}")
result = subprocess.run(["bash", "-c", build_backup_script(vm_name, disk, target)], capture_output=True, text=True, check=False)
if result.returncode == 0:
removed = rotate_backups(vm_name, config["keep"])
_log(f"{vm_name}: готово" + (f", ротация удалила: {', '.join(removed)}" if removed else ""))
else:
failures += 1
_log(f"{vm_name}: ошибка бэкапа (код {result.returncode}): {result.stderr.strip()[:400]}")
return 1 if failures else 0
if __name__ == "__main__":
if "--scheduled" in sys.argv:
sys.exit(run_scheduled_backups())
print("Использование: backup_core.py --scheduled", file=sys.stderr)
sys.exit(2)
-162
Просмотреть файл
@@ -1,162 +0,0 @@
#!/usr/bin/env python3
"""Каталог cloud-образов и генерация cloud-init seed для быстрых VM."""
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any
CLOUD_IMAGES_DIR = Path("/var/lib/virtuality/cloud-images")
# Официальные cloud-образы. Для каждого дистрибутива — обе архитектуры,
# чтобы Raspberry/Orange Pi качали arm64 без ручного выбора URL.
CATALOG: list[dict[str, str]] = [
{
"key": "ubuntu-24.04-x86_64",
"label": "Ubuntu Server 24.04 LTS",
"arch": "x86_64",
"filename": "ubuntu-24.04-x86_64.qcow2",
"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
"default_user": "ubuntu",
},
{
"key": "ubuntu-24.04-aarch64",
"label": "Ubuntu Server 24.04 LTS",
"arch": "aarch64",
"filename": "ubuntu-24.04-aarch64.qcow2",
"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img",
"default_user": "ubuntu",
},
{
"key": "ubuntu-22.04-x86_64",
"label": "Ubuntu Server 22.04 LTS",
"arch": "x86_64",
"filename": "ubuntu-22.04-x86_64.qcow2",
"url": "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
"default_user": "ubuntu",
},
{
"key": "ubuntu-22.04-aarch64",
"label": "Ubuntu Server 22.04 LTS",
"arch": "aarch64",
"filename": "ubuntu-22.04-aarch64.qcow2",
"url": "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img",
"default_user": "ubuntu",
},
{
"key": "debian-12-x86_64",
"label": "Debian 12 Bookworm",
"arch": "x86_64",
"filename": "debian-12-x86_64.qcow2",
"url": "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2",
"default_user": "debian",
},
{
"key": "debian-12-aarch64",
"label": "Debian 12 Bookworm",
"arch": "aarch64",
"filename": "debian-12-aarch64.qcow2",
"url": "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-arm64.qcow2",
"default_user": "debian",
},
]
def catalog_entry(key: str) -> dict[str, str] | None:
for entry in CATALOG:
if entry["key"] == key:
return entry
return None
def catalog_for_arch(host_arch: str) -> list[dict[str, Any]]:
"""Каталог с отметкой native — образы родной архитектуры хоста показываются первыми."""
native = "aarch64" if host_arch in ("aarch64", "arm64") else "x86_64"
entries = [dict(entry, native=entry["arch"] == native, downloaded=(CLOUD_IMAGES_DIR / entry["filename"]).exists()) for entry in CATALOG]
return sorted(entries, key=lambda item: (not item["native"], item["label"]))
def safe_image_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip()
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(qcow2|img)", name):
return None
return name
def image_path_by_name(name: str) -> Path | None:
safe_name = safe_image_filename(name)
if not safe_name:
return None
path = (CLOUD_IMAGES_DIR / safe_name).resolve()
if CLOUD_IMAGES_DIR.resolve() not in path.parents:
return None
return path
def list_images() -> list[dict[str, str]]:
CLOUD_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
files = []
for item in sorted(CLOUD_IMAGES_DIR.glob("*")):
if item.suffix.lower() not in (".qcow2", ".img") or item.name.endswith(".part"):
continue
try:
stat = item.stat()
size_mb = stat.st_size / 1024 / 1024
updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
except OSError:
size_mb = 0
updated = "unknown"
known = next((entry for entry in CATALOG if entry["filename"] == item.name), None)
files.append({
"name": item.name,
"path": str(item),
"size": f"{size_mb:.0f} MB",
"updated": updated,
"label": known["label"] if known else item.stem,
"arch": known["arch"] if known else ("aarch64" if "arm64" in item.name or "aarch64" in item.name else "x86_64"),
"default_user": known["default_user"] if known else "user",
})
return files
def valid_cloud_username(value: str) -> bool:
return bool(re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", value or ""))
def valid_ssh_key(value: str) -> bool:
if not value:
return True
return bool(re.fullmatch(r"(ssh-(rsa|ed25519|dss)|ecdsa-sha2-[a-z0-9-]+) [A-Za-z0-9+/=]+( [^\r\n]{0,256})?", value.strip()))
def build_user_data(hostname: str, username: str, password: str, ssh_key: str) -> str:
lines = [
"#cloud-config",
f"hostname: {hostname}",
"manage_etc_hosts: true",
f"ssh_pwauth: {'true' if password else 'false'}",
"users:",
f" - name: {username}",
" groups: [sudo]",
" shell: /bin/bash",
' sudo: "ALL=(ALL) NOPASSWD:ALL"',
" lock_passwd: false",
]
if ssh_key:
lines.append(" ssh_authorized_keys:")
lines.append(f" - {ssh_key.strip()}")
if password:
lines += [
"chpasswd:",
" expire: false",
" users:",
f" - name: {username}",
f" password: {json.dumps(password, ensure_ascii=False)}",
" type: text",
]
lines.append("package_update: false")
return "\n".join(lines) + "\n"
def build_meta_data(name: str) -> str:
return f"instance-id: virtuality-{name}\nlocal-hostname: {name}\n"
-115
Просмотреть файл
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""Лёгкий сборщик метрик хоста: кольцевой буфер в памяти, без БД и зависимостей.
Раз в METRICS_INTERVAL секунд снимаются CPU, память и load average хоста
плюс число работающих VM. Буфер хранит около часа истории для спарклайнов
на дашборде. Стоимость — одно чтение /proc и один вызов libvirt за цикл.
"""
import threading
import time
from collections import deque
from pathlib import Path
from typing import Any
import virt_backend
METRICS_INTERVAL = 15
SAMPLES = deque(maxlen=240) # ~1 час при интервале 15 секунд
_LOCK = threading.Lock()
_STARTED = threading.Event()
_prev_cpu: tuple[int, int] | None = None
def _read_cpu_times() -> tuple[int, int] | None:
"""(busy, total) джиффи из /proc/stat."""
try:
fields = Path("/proc/stat").read_text().splitlines()[0].split()[1:]
values = [int(v) for v in fields[:8]]
idle = values[3] + values[4] # idle + iowait
total = sum(values)
return total - idle, total
except Exception:
return None
def _cpu_percent() -> float:
global _prev_cpu
current = _read_cpu_times()
if current is None:
return 0.0
if _prev_cpu is None:
_prev_cpu = current
return 0.0
busy = current[0] - _prev_cpu[0]
total = current[1] - _prev_cpu[1]
_prev_cpu = current
if total <= 0:
return 0.0
return round(100.0 * busy / total, 1)
def _memory_mb() -> tuple[int, int]:
"""(used_mb, total_mb) из /proc/meminfo."""
try:
info = {}
for line in Path("/proc/meminfo").read_text().splitlines():
key, _, rest = line.partition(":")
info[key] = int(rest.split()[0])
total = info.get("MemTotal", 0) // 1024
available = info.get("MemAvailable", 0) // 1024
return max(0, total - available), total
except Exception:
return 0, 0
def _load_average() -> float:
try:
return float(Path("/proc/loadavg").read_text().split()[0])
except Exception:
return 0.0
def _running_vms() -> int:
stats = virt_backend.domain_stats()
if stats is not None:
return len(stats)
return -1 # backend недоступен — не считаем через subprocess, чтобы не грузить хост
def collect_sample() -> dict[str, Any]:
used_mb, total_mb = _memory_mb()
return {
"ts": int(time.time()),
"cpu": _cpu_percent(),
"mem_used_mb": used_mb,
"mem_total_mb": total_mb,
"mem_percent": round(100.0 * used_mb / total_mb, 1) if total_mb else 0.0,
"load1": _load_average(),
"vms_running": _running_vms(),
}
def _worker() -> None:
_cpu_percent() # первая выборка задаёт базу для дельты
while True:
sample = collect_sample()
with _LOCK:
SAMPLES.append(sample)
time.sleep(METRICS_INTERVAL)
def start() -> None:
if _STARTED.is_set():
return
_STARTED.set()
threading.Thread(target=_worker, daemon=True, name="virtuality-metrics").start()
def snapshot(limit: int = 240) -> dict[str, Any]:
with _LOCK:
series = list(SAMPLES)[-limit:]
return {
"interval": METRICS_INTERVAL,
"series": series,
"current": series[-1] if series else None,
}
+12 -66
Просмотреть файл
@@ -72,18 +72,6 @@ def iptables_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}:{int(end)}'
def iptables_dnat_port_value(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
def successful_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in results if item.get('ok')]
def failed_results(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [item for item in results if not item.get('ok')]
def port_label(start: int, end: int) -> str:
return str(int(start)) if int(start) == int(end) else f'{int(start)}-{int(end)}'
@@ -140,20 +128,6 @@ def external_interface() -> str:
return match.group(1) if match else 'eth0'
def route_interface_for_ip(ip: str) -> str:
if not valid_ip(ip):
return NAT_BRIDGE
result = run_cmd(['ip', 'route', 'get', ip], timeout=5)
if not result['ok']:
return NAT_BRIDGE
match = re.search(r'\bdev\s+([^\s]+)', result['stdout'])
return match.group(1) if match else NAT_BRIDGE
def forward_guest_interface(item: dict[str, Any]) -> str:
return route_interface_for_ip(str(item.get('guest_ip', '')))
def ip_forward_state() -> str:
path = Path('/proc/sys/net/ipv4/ip_forward')
if not path.exists():
@@ -378,13 +352,12 @@ def delete_port_forward(forward_id: str) -> None:
def render_nft_rules() -> str:
ext = external_interface()
forwards = load_port_forwards()
lines = [
'table ip virtuality {',
' chain prerouting {',
' type nat hook prerouting priority dstnat; policy accept;',
]
for item in forwards:
for item in load_port_forwards():
external_ports = nft_port_value(item['external_port_start'], item['external_port_end'])
guest_ports = nft_port_value(item['guest_port_start'], item['guest_port_end'])
lines.append(f" iifname \"{ext}\" {item['protocol']} dport {external_ports} dnat to {item['guest_ip']}:{guest_ports}")
@@ -393,22 +366,11 @@ def render_nft_rules() -> str:
' chain postrouting {',
' type nat hook postrouting priority srcnat; policy accept;',
f' ip saddr {NAT_SUBNET} oifname "{ext}" masquerade',
]
for item in forwards:
guest_iface = forward_guest_interface(item)
lines.append(f' ip daddr {item["guest_ip"]} oifname "{guest_iface}" masquerade')
lines += [
' }',
' chain forward {',
' type filter hook forward priority filter; policy accept;',
f' ip saddr {NAT_SUBNET} accept',
f' ip daddr {NAT_SUBNET} accept',
]
for item in forwards:
guest_iface = forward_guest_interface(item)
lines.append(f' iifname "{ext}" oifname "{guest_iface}" ip daddr {item["guest_ip"]} accept')
lines.append(f' iifname "{guest_iface}" oifname "{ext}" ip saddr {item["guest_ip"]} ct state established,related accept')
lines += [
' }',
'}',
]
@@ -430,7 +392,7 @@ def apply_ufw_route_rules(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
cmd = [
'ufw', 'route', 'allow',
'in', 'on', ext,
'out', 'on', forward_guest_interface(item),
'out', 'on', NAT_BRIDGE,
'to', item['guest_ip'],
'port', guest_port,
'proto', item['protocol'],
@@ -467,13 +429,10 @@ def apply_iptables_fallback(items: list[dict[str, Any]]) -> list[dict[str, Any]]
guest_ip = item['guest_ip']
external_port = iptables_port_value(item['external_port_start'], item['external_port_end'])
guest_port = iptables_port_value(item['guest_port_start'], item['guest_port_end'])
guest_to_port = iptables_dnat_port_value(item['guest_port_start'], item['guest_port_end'])
guest_to = f"{guest_ip}:{guest_to_port}"
guest_iface = forward_guest_interface(item)
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', guest_iface, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', guest_iface, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
guest_to = f"{guest_ip}:{iptables_port_value(item['guest_port_start'], item['guest_port_end'])}"
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', ext, '-o', NAT_BRIDGE, '-p', proto, '-d', guest_ip, '-m', proto, '--dport', guest_port, '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-I', 'FORWARD', '1', '-i', NAT_BRIDGE, '-o', ext, '-s', guest_ip, '-m', 'conntrack', '--ctstate', 'ESTABLISHED,RELATED', '-j', 'ACCEPT']))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'PREROUTING', '1', '-i', ext, '-p', proto, '-m', proto, '--dport', external_port, '-j', 'DNAT', '--to-destination', guest_to]))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-d', guest_ip, '-o', guest_iface, '-j', 'MASQUERADE']))
results.append(ensure_iptables_rule(['iptables', '-t', 'nat', '-I', 'POSTROUTING', '1', '-s', NAT_SUBNET, '-o', ext, '-j', 'MASQUERADE']))
return results
@@ -484,29 +443,16 @@ def apply_port_forwards() -> dict[str, Any]:
disable_rp_filter()
items = load_port_forwards()
NFT_FILE.write_text(render_nft_rules())
nft_delete = run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
nft_apply = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
run_cmd(['nft', 'delete', 'table', 'ip', 'virtuality'], timeout=8)
result = run_cmd(['nft', '-f', str(NFT_FILE)], timeout=15)
if not result['ok']:
raise NetworkError(result['stderr'] or result['stdout'] or 'Не удалось применить nftables-правила')
ufw_results = apply_ufw_route_rules(items)
iptables_results = apply_iptables_fallback(items)
iptables_ok = bool(successful_results(iptables_results)) or not items
if not nft_apply['ok'] and not iptables_ok:
details = [
nft_apply.get('stderr') or nft_apply.get('stdout') or 'nftables не применился',
*[
item.get('stderr') or item.get('stdout') or item.get('cmd', 'iptables rule failed')
for item in failed_results(iptables_results)
],
]
raise NetworkError('Не удалось применить правила проброса: ' + ' | '.join([d for d in details if d]))
return {
'ok': nft_apply['ok'] or iptables_ok,
'ok': True,
'file': str(NFT_FILE),
'rules': render_nft_rules(),
'nft_delete': nft_delete,
'nft_apply': nft_apply,
'ufw': ufw_results,
'iptables': iptables_results,
}
@@ -559,7 +505,7 @@ def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, pr
nft_has_range_rule = bool(matching_forward and vm_ip and matching_forward['external_port_label'] in nft_text and f"{vm_ip}:{matching_forward['guest_port_label']}" in nft_text)
nft_has_rule = nft_has_single_rule or nft_has_range_rule
iptables_has_prerouting = bool(vm_ip and f'--dport {int(external_port)} -j DNAT --to-destination {vm_ip}:{int(guest_port)}' in ipt_nat_text)
iptables_has_range_prerouting = bool(matching_forward and vm_ip and matching_forward['external_port_label'].replace('-', ':') in ipt_nat_text and (f"{vm_ip}:{matching_forward['guest_port_label']}" in ipt_nat_text or f"{vm_ip}:{matching_forward['guest_port_label'].replace('-', ':')}" in ipt_nat_text))
iptables_has_range_prerouting = bool(matching_forward and vm_ip and matching_forward['external_port_label'].replace('-', ':') in ipt_nat_text and f"{vm_ip}:{matching_forward['guest_port_label'].replace('-', ':')}" in ipt_nat_text)
iptables_has_prerouting = iptables_has_prerouting or iptables_has_range_prerouting
iptables_has_forward = bool(vm_ip and f'-d {vm_ip}/32' in ipt_forward_text and (f'--dport {int(guest_port)}' in ipt_forward_text or (matching_forward and matching_forward['guest_port_label'].replace('-', ':') in ipt_forward_text)))
ufw_has_route = bool(vm_ip and vm_ip in ufw_text and (str(int(guest_port)) in ufw_text or (matching_forward and matching_forward['guest_port_label'].replace('-', ':') in ufw_text)))
@@ -599,7 +545,7 @@ def diagnose_public_access(vm_name: str, external_port: int, guest_port: int, pr
'checks': checks,
'commands': {
'watch_external': f"sudo tcpdump -ni {ext} '{protocol} port {int(external_port)}'",
'watch_internal': f"sudo tcpdump -ni {route_interface_for_ip(vm_ip) if vm_ip else NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'",
'watch_internal': f"sudo tcpdump -ni {NAT_BRIDGE} 'host {vm_ip or '<VM_IP>'} and {protocol} port {int(guest_port)}'",
},
'raw': {
'domifaddr': domifaddr['stdout'] or domifaddr['stderr'],
-1
Просмотреть файл
@@ -3,4 +3,3 @@ uvicorn[standard]==0.34.0
jinja2==3.1.5
python-multipart==0.0.20
itsdangerous==2.2.0
python-pam==2.0.2
-26
Просмотреть файл
@@ -229,9 +229,6 @@ input[type="file"] { padding: 12px; }
.notice { color: #bae6fd; background: rgba(56,189,248,.09); border: 1px solid rgba(56,189,248,.24); }
.alert.danger { color: #fecaca; background: rgba(239,68,68,.10); border: 1px solid rgba(239,68,68,.28); }
.alert.success { color: #bbf7d0; background: rgba(34,197,94,.10); border: 1px solid rgba(34,197,94,.26); }
.alert.warn { color: #fde68a; background: rgba(245,158,11,.10); border: 1px solid rgba(245,158,11,.28); }
.sparkline { display: block; width: 100%; height: 60px; }
.sparkline polyline { stroke: var(--accent, #38bdf8); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.services, .quick-actions, .knowledge-list { display: grid; gap: 10px; }
.service-row, .big-action, .knowledge-list div, .meta-grid div {
@@ -298,26 +295,3 @@ body.upload-active .v-nav a, body.upload-active .top-meta a, body.upload-active
table { display: block; overflow-x: auto; border-spacing: 0 8px; }
.icon-action em { display: none; }
}
.resource-mini-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin-bottom: 10px; }
.resource-mini-grid div { display: grid; gap: 3px; padding: 8px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); }
.resource-mini-grid span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
.resource-mini-grid b { font-size: 14px; overflow-wrap: anywhere; }
.boot-order-list { display: grid; gap: 8px; }
.boot-order-item { display: flex; align-items: center; gap: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 7px; background: var(--panel-2); cursor: grab; }
.boot-order-item:active { cursor: grabbing; }
.boot-order-item.dragging { opacity: .55; border-color: var(--accent); }
.boot-order-item small { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
.drag-handle { color: var(--muted); font-weight: 900; }
@media (max-width: 1180px) { .three { grid-template-columns: 1fr; } .resource-mini-grid { grid-template-columns: 1fr; } }
/* Virtuality live status + toast layer */
#v-toast-wrap { position: fixed; top: 14px; right: 14px; z-index: 10000; display: grid; gap: 8px; width: min(360px, calc(100vw - 28px)); pointer-events: none; }
.v-toast { transform: translateY(-8px); opacity: 0; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line); background: #fff; color: var(--text); box-shadow: 0 12px 32px rgba(15,23,42,.18); font-size: 13px; font-weight: 800; transition: .22s ease; }
.v-toast.show { transform: translateY(0); opacity: 1; }
.v-toast.ok { border-color: rgba(22,163,74,.28); box-shadow: 0 12px 32px rgba(22,163,74,.16); }
.v-toast.warn { border-color: rgba(217,119,6,.32); box-shadow: 0 12px 32px rgba(217,119,6,.16); }
.v-toast.err { border-color: rgba(220,38,38,.32); box-shadow: 0 12px 32px rgba(220,38,38,.16); }
.live-badge { margin-left: 10px; vertical-align: middle; }
+2 -5
Просмотреть файл
@@ -6,16 +6,13 @@
</div>
<nav class="v-nav">
<a data-icon="⌂" class="{{ 'active' if path == '/' else '' }}" href="/">Обзор</a>
<a data-icon="" class="{{ 'active' if path == '/vm/create' else '' }}" href="/vm/create">Создать VM</a>
<a data-icon="☁" class="{{ 'active' if path == '/vm/create-cloud' else '' }}" href="/vm/create-cloud">VM из облака</a>
<div class="v-nav-group {{ 'open' if path.startswith('/iso') or path.startswith('/disk-images') or path.startswith('/cloud-images') else '' }}">
<a data-icon="" class="{{ 'active' if path.startswith('/vm/create') else '' }}" href="/vm/create">Создать VM</a>
<div class="v-nav-group {{ 'open' if path.startswith('/iso') or path.startswith('/disk-images') else '' }}">
<button class="v-nav-group-title" type="button">Образы</button>
<a data-icon="◎" class="{{ 'active' if path.startswith('/iso') else '' }}" href="/iso">ISO</a>
<a data-icon="▣" class="{{ 'active' if path.startswith('/disk-images') else '' }}" href="/disk-images">Диски</a>
<a data-icon="☁" class="{{ 'active' if path.startswith('/cloud-images') else '' }}" href="/cloud-images">Cloud</a>
</div>
<a data-icon="⇄" class="{{ 'active' if path.startswith('/network') else '' }}" href="/network">Сеть</a>
<a data-icon="⛁" class="{{ 'active' if path.startswith('/backups') else '' }}" href="/backups">Бэкапы</a>
<a data-icon="⚙" class="{{ 'active' if path.startswith('/operations') else '' }}" href="/operations">Операции</a>
<a data-icon="≡" class="{{ 'active' if path.startswith('/logs') else '' }}" href="/logs">Журналы</a>
<a data-icon="↻" class="{{ 'active' if path.startswith('/update') else '' }}" href="/update">Обновления</a>
-119
Просмотреть файл
@@ -1,119 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Бэкапы — {{ app_name }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="v-layout">
{% include "_sidebar.html" %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">Бэкапы</div>
<div class="v-subtitle">Сжатые копии дисков VM в /var/lib/virtuality/backups с ротацией и ночным расписанием</div>
</div>
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
</div>
</header>
{% if error %}<div class="alert danger">{{ error }}</div>{% endif %}
{% if message %}<div class="alert success">{{ message }}</div>{% endif %}
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Сделать бэкап сейчас</h2>
<span class="pill">qemu-img convert -c</span>
</div>
<form method="post" action="/backups/create" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Виртуальная машина</span>
<select name="vm_name" {% if vms %}required{% endif %}>
{% for vm in vms %}
<option value="{{ vm.name }}">{{ vm.name }} — {{ vm.state }}</option>
{% endfor %}
</select>
</label>
<button class="primary wide" {% if not vms %}disabled{% endif %}>Создать бэкап</button>
{% if not vms %}<div class="alert danger">VM пока нет.</div>{% endif %}
</form>
<p class="muted small-note">Работающая VM ставится на паузу на время копирования диска и продолжает работу автоматически. Бэкап сжимается — занимает заметно меньше исходного диска.</p>
</article>
<article class="card">
<div class="card-head">
<h2>Ночное расписание</h2>
<span class="pill">{{ 'включено' if schedule.enabled else 'выключено' }}</span>
</div>
<form method="post" action="/backups/schedule" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="check-row">
<input type="checkbox" name="enabled" value="1" {% if schedule.enabled %}checked{% endif %}>
<span>Делать бэкапы каждую ночь (04:30 по Москве)</span>
</label>
<label>
<span>Хранить копий на VM</span>
<input type="number" name="keep" min="1" max="30" value="{{ schedule.keep }}" required>
</label>
<div class="label">Какие VM бэкапить</div>
{% for vm in vms %}
<label class="check-row">
<input type="checkbox" name="vms" value="{{ vm.name }}" {% if vm.name in schedule.vms %}checked{% endif %}>
<span>{{ vm.name }}</span>
</label>
{% else %}
<div class="muted small-note">VM пока нет — расписание можно настроить после их создания.</div>
{% endfor %}
<button class="primary wide">Сохранить расписание</button>
</form>
<p class="muted small-note">Старые копии сверх лимита удаляются автоматически после каждого успешного бэкапа. Журнал ночных бэкапов: /var/log/virtuality/backup.log.</p>
</article>
</section>
<section class="card">
<div class="card-head">
<h2>Сохранённые бэкапы</h2>
<span class="pill">{{ backups|length }} файлов</span>
</div>
<table>
<thead>
<tr><th>VM</th><th>Файл</th><th>Размер</th><th>Создан</th><th>Действия</th></tr>
</thead>
<tbody>
{% for backup in backups %}
<tr>
<td class="strong">{{ backup.vm }}</td>
<td>{{ backup.name }}</td>
<td>{{ backup.size }}</td>
<td>{{ backup.created }}</td>
<td class="actions">
<form method="post" action="/backups/{{ backup.vm }}/{{ backup.name }}/restore" onsubmit="return confirm('Восстановить VM {{ backup.vm }} из бэкапа {{ backup.name }}? Текущий диск VM будет перезаписан.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="ghost">Восстановить</button>
</form>
<form method="post" action="/backups/{{ backup.vm }}/{{ backup.name }}/delete" onsubmit="return confirm('Удалить бэкап {{ backup.name }}?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="danger">Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="muted">Бэкапов пока нет. Сделай первый бэкап кнопкой слева или включи ночное расписание.</td></tr>
{% endfor %}
</tbody>
</table>
<p class="muted small-note">Восстановление перезаписывает первый диск VM содержимым бэкапа — VM должна быть выключена.</p>
</section>
</main>
</div>
<script src="/static/panel.js" defer></script>
</body>
</html>
-116
Просмотреть файл
@@ -1,116 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloud-образы — {{ app_name }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="v-layout">
{% include "_sidebar.html" %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">Cloud-образы</div>
<div class="v-subtitle">Официальные cloud-образы Ubuntu и Debian для быстрого создания VM без установки с ISO</div>
</div>
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
</div>
</header>
{% if error %}
<div class="alert danger">{{ error }}</div>
{% endif %}
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Каталог образов</h2>
<span class="pill">официальные зеркала</span>
</div>
<table>
<thead>
<tr><th>Система</th><th>Архитектура</th><th>Статус</th><th></th></tr>
</thead>
<tbody>
{% for entry in catalog %}
<tr>
<td class="strong">{{ entry.label }}</td>
<td><span class="status {{ 'ok' if entry.native else 'warn' }}">{{ entry.arch }}{% if entry.native %} · родная{% endif %}</span></td>
<td>{% if entry.downloaded %}<span class="status ok">скачан</span>{% else %}<span class="muted">не скачан</span>{% endif %}</td>
<td class="actions">
<form method="post" action="/cloud-images/download"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="key" value="{{ entry.key }}">
<button class="{{ 'ghost' if entry.downloaded else 'primary' }}">{{ 'Скачать заново' if entry.downloaded else 'Скачать' }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="muted small-note">Образы качаются с cloud-images.ubuntu.com и cloud.debian.org в фоне — прогресс виден в разделе «Операции». Для Raspberry / Orange Pi выбирай aarch64.</p>
</article>
<article class="card">
<div class="card-head">
<h2>Зачем это нужно</h2>
<span class="pill">VM за минуту</span>
</div>
<div class="quick-actions">
<div class="big-action static">
<strong>Быстрее, чем ISO</strong>
<span>Cloud-образ — уже установленная система. VM создаётся копированием диска и стартует сразу, без установщика.</span>
</div>
<div class="big-action static">
<strong>Логин и SSH-ключ сразу</strong>
<span>При создании VM задаёшь пользователя, пароль или SSH-ключ — cloud-init настроит их при первом старте.</span>
</div>
<div class="big-action static">
<strong>ARM64 без боли</strong>
<span>Для Raspberry Pi и Orange Pi это основной способ создавать гостевые VM.</span>
</div>
</div>
<a class="link-pill" href="/vm/create-cloud">→ Создать VM из cloud-образа</a>
</article>
</section>
<section class="card">
<div class="card-head">
<h2>Скачанные образы</h2>
<span class="pill">{{ images|length }} файлов</span>
</div>
<table>
<thead>
<tr><th>Файл</th><th>Система</th><th>Архитектура</th><th>Размер</th><th>Обновлён</th><th>Действия</th></tr>
</thead>
<tbody>
{% for image in images %}
<tr>
<td class="strong">{{ image.name }}</td>
<td>{{ image.label }}</td>
<td><span class="status ok">{{ image.arch }}</span></td>
<td>{{ image.size }}</td>
<td>{{ image.updated }}</td>
<td class="actions">
<a class="link-pill" href="/vm/create-cloud">Создать VM</a>
<form method="post" action="/cloud-images/{{ image.name }}/delete" onsubmit="return confirm('Удалить образ {{ image.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="danger">Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">Образов пока нет. Скачай Ubuntu или Debian из каталога выше.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
</main>
</div>
<script src="/static/panel.js" defer></script>
</body>
</html>
+8 -72
Просмотреть файл
@@ -18,7 +18,7 @@
</div>
<div class="top-meta">
<span>user: {{ user }}</span>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
@@ -54,29 +54,6 @@
</article>
</section>
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>CPU хоста</h2>
<span class="pill" id="cpu-now"></span>
</div>
<svg id="cpu-spark" class="sparkline" viewBox="0 0 300 60" preserveAspectRatio="none" aria-label="График CPU за час">
<polyline points="" fill="none"></polyline>
</svg>
<p class="muted small-note">Загрузка процессора за последний час, обновляется каждые 15 секунд.</p>
</article>
<article class="card">
<div class="card-head">
<h2>Память хоста</h2>
<span class="pill" id="mem-now"></span>
</div>
<svg id="mem-spark" class="sparkline" viewBox="0 0 300 60" preserveAspectRatio="none" aria-label="График памяти за час">
<polyline points="" fill="none"></polyline>
</svg>
<p class="muted small-note">Использование RAM за последний час. Хвост графика заполняется по мере работы панели.</p>
</article>
</section>
<section class="card">
<div class="card-head">
<h2>Виртуальные машины</h2>
@@ -99,11 +76,11 @@
<td><span class="status {{ 'ok' if is_running else 'err' if 'shut' in vm.state else 'warn' }}">{{ vm.state }}</span></td>
<td class="autostart-cell">
{% if vm.autostart_enabled %}
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="inline-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="inline-form">
<button class="status status-button ok" title="Отключить автозапуск">{{ vm.autostart_label|default('enabled') }}</button>
</form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/autostart" class="inline-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/vm/{{ vm.name }}/autostart" class="inline-form">
<button class="status status-button err" title="Включить автозапуск">{{ vm.autostart_label|default('disabled') }}</button>
</form>
{% endif %}
@@ -113,12 +90,12 @@
<a class="icon-action" href="/vm/{{ vm.name }}" title="Подробнее"><span></span><em>Детали</em></a>
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Консоль"><span></span><em>Консоль</em></a>
{% if is_running %}
<form method="post" action="/vm/{{ vm.name }}/reboot"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Перезагрузить"><span></span><em>Reboot</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/shutdown"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Мягко выключить"><span></span><em>Shutdown</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/destroy"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Принудительно выключить"><span></span><em>Power</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/reboot"><button class="icon-action" title="Перезагрузить"><span></span><em>Reboot</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/shutdown"><button class="icon-action" title="Мягко выключить"><span></span><em>Shutdown</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/destroy"><button class="icon-action danger" title="Принудительно выключить"><span></span><em>Power</em></button></form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/start"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action success" title="Запустить"><span></span><em>Start</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Удалить VM"><span>🗑</span><em>Delete</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/start"><button class="icon-action success" title="Запустить"><span></span><em>Start</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><button class="icon-action danger" title="Удалить VM"><span>🗑</span><em>Delete</em></button></form>
{% endif %}
</div>
</td>
@@ -208,47 +185,6 @@
</section>
</main>
</div>
<script>
(function () {
function drawSpark(svgId, values, max) {
const svg = document.getElementById(svgId);
if (!svg) return;
const line = svg.querySelector('polyline');
if (!values.length) { line.setAttribute('points', ''); return; }
const width = 300, height = 60, pad = 3;
const step = values.length > 1 ? (width - pad * 2) / (values.length - 1) : 0;
const points = values.map((value, index) => {
const x = pad + index * step;
const y = height - pad - Math.max(0, Math.min(1, value / max)) * (height - pad * 2);
return x.toFixed(1) + ',' + y.toFixed(1);
});
line.setAttribute('points', points.join(' '));
}
async function refreshMetrics() {
try {
const response = await fetch('/live/metrics', { cache: 'no-store' });
if (!response.ok) return;
const payload = await response.json();
if (!payload.ok || !payload.series) return;
const cpu = payload.series.map(sample => sample.cpu);
const mem = payload.series.map(sample => sample.mem_percent);
drawSpark('cpu-spark', cpu, 100);
drawSpark('mem-spark', mem, 100);
const current = payload.current;
if (current) {
const cpuNow = document.getElementById('cpu-now');
const memNow = document.getElementById('mem-now');
if (cpuNow) cpuNow.textContent = current.cpu.toFixed(1) + '% · load ' + current.load1.toFixed(2);
if (memNow) memNow.textContent = current.mem_used_mb + ' / ' + current.mem_total_mb + ' MB · ' + current.mem_percent.toFixed(0) + '%';
}
} catch (error) { /* сеть моргнула — попробуем в следующий цикл */ }
}
refreshMetrics();
setInterval(refreshMetrics, 15000);
})();
</script>
<script src="/static/panel.js" defer></script>
</body>
</html>
+3 -3
Просмотреть файл
@@ -19,7 +19,7 @@
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
@@ -33,7 +33,7 @@
<h2>Загрузить образ установленной системы</h2>
<span class="pill">.img .raw .qcow2 .img.xz .zip .tar.gz</span>
</div>
<form id="disk-upload-form" method="post" action="/disk-images/upload" enctype="multipart/form-data" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form id="disk-upload-form" method="post" action="/disk-images/upload" enctype="multipart/form-data" class="form-grid">
<label class="upload-box">
<span>Выбери файл готового диска или архив с диском</span>
<input id="disk-file-input" type="file" name="image_file" accept=".img,.raw,.qcow2,.img.xz,.zip,.tar.gz,.tgz" required>
@@ -93,7 +93,7 @@
<td>{{ image.updated }}</td>
<td class="actions">
<a class="link-pill" href="/vm/create">Создать VM</a>
<form method="post" action="/disk-images/{{ image.name }}/delete" onsubmit="return confirm('Удалить образ {{ image.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/disk-images/{{ image.name }}/delete" onsubmit="return confirm('Удалить образ {{ image.name }}?');">
<button class="danger">Удалить</button>
</form>
</td>
+2 -2
Просмотреть файл
@@ -19,7 +19,7 @@
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
@@ -65,7 +65,7 @@
<article class="card">
<div class="card-head">
<h2>Проверки</h2>
<form method="post" action="/host/refresh"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button>Переопределить профиль</button></form>
<form method="post" action="/host/refresh"><button>Переопределить профиль</button></form>
</div>
<div class="services">
{% for check in profile.checks %}
+4 -4
Просмотреть файл
@@ -19,7 +19,7 @@
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
@@ -33,7 +33,7 @@
<h2>Загрузить установочный ISO</h2>
<span class="pill">только .iso</span>
</div>
<form id="iso-upload-form" method="post" action="/iso/upload" enctype="multipart/form-data" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form id="iso-upload-form" method="post" action="/iso/upload" enctype="multipart/form-data" class="form-grid">
<label class="upload-box">
<span>Выбери ISO-файл установочного диска</span>
<input id="iso-file-input" type="file" name="iso_file" accept=".iso" required>
@@ -62,7 +62,7 @@
<span class="pill">pool</span>
</div>
<div class="actions big-actions">
<form method="post" action="/iso/refresh"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button>Обновить ISO pool</button></form>
<form method="post" action="/iso/refresh"><button>Обновить ISO pool</button></form>
<a class="link-pill accent" href="/vm/create">Создать VM</a>
</div>
<pre>sudo virsh pool-refresh virtuality-iso
@@ -86,7 +86,7 @@ ls -lh /var/lib/virtuality/iso</pre>
<td>{{ iso.size }}</td>
<td>{{ iso.updated }}</td>
<td class="actions">
<form method="post" action="/iso/{{ iso.name }}/delete" onsubmit="return confirm('Удалить ISO {{ iso.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/iso/{{ iso.name }}/delete" onsubmit="return confirm('Удалить ISO {{ iso.name }}?');">
<button class="danger">Удалить</button>
</form>
</td>
+1 -1
Просмотреть файл
@@ -24,7 +24,7 @@
<div class="alert danger">{{ error }}</div>
{% endif %}
<form method="post" action="/login" class="auth-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/login" class="auth-form">
<label>
<span>Linux-пользователь</span>
<input type="text" name="username" value="{{ auth_user }}" autocomplete="username" required>
+1 -1
Просмотреть файл
@@ -38,7 +38,7 @@
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
+7 -7
Просмотреть файл
@@ -19,7 +19,7 @@
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
@@ -58,7 +58,7 @@
<div><span>DHCP</span><strong>{{ ctx.nat.dhcp }}</strong></div>
<div><span>Bridge</span><strong>{{ ctx.nat.bridge }}</strong></div>
</div>
<form method="post" action="/network/nat/setup" class="inline-form top-space"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/network/nat/setup" class="inline-form top-space">
<button class="primary">Создать / починить NAT-сеть</button>
</form>
<pre>{{ ctx.nat.info }}</pre>
@@ -80,7 +80,7 @@
<h2>Добавить проброс порта</h2>
<span class="pill">DNAT auto/manual IP · ranges</span>
</div>
<form method="post" action="/network/forward/add" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/network/forward/add" class="form-grid">
<label>
<span>Описание</span>
<input type="text" name="note" placeholder="Например: сайт, SSH, SIP, RTP MikoPBX">
@@ -155,7 +155,7 @@
<h2>Диагностика публичного доступа</h2>
<span class="pill">VM → Internet</span>
</div>
<form method="post" action="/network/diagnose" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/network/diagnose" class="form-grid">
<label>
<span>Виртуальная машина</span>
<select name="vm_name" required>
@@ -220,7 +220,7 @@
<section class="card">
<div class="card-head">
<h2>Пробросы портов</h2>
<form method="post" action="/network/apply"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button>Применить правила</button></form>
<form method="post" action="/network/apply"><button>Применить правила</button></form>
</div>
<table>
<thead>
@@ -239,14 +239,14 @@
<td>{{ item.note }}</td>
<td>
<div class="actions">
<form method="post" action="/network/diagnose"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/network/diagnose">
<input type="hidden" name="vm_name" value="{{ item.vm_name }}">
<input type="hidden" name="external_port" value="{{ item.external_port }}">
<input type="hidden" name="guest_port" value="{{ item.guest_port }}">
<input type="hidden" name="protocol" value="{{ item.protocol }}">
<button>Диагностика</button>
</form>
<form method="post" action="/network/forward/{{ item.id }}/delete"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="danger">Удалить</button></form>
<form method="post" action="/network/forward/{{ item.id }}/delete"><button class="danger">Удалить</button></form>
</div>
</td>
</tr>
+1 -1
Просмотреть файл
@@ -17,7 +17,7 @@
<span>user: {{ user }}</span>
<a class="link-pill" href="/operations">Операции</a>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
+1 -1
Просмотреть файл
@@ -19,7 +19,7 @@
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
+3 -3
Просмотреть файл
@@ -38,7 +38,7 @@
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
@@ -82,11 +82,11 @@
{% endif %}
<div class="actions top-space update-actions">
{% if info.has_update %}
<form method="post" action="/update/apply" id="update-apply-form" onsubmit="return confirm('Запустить обновление Virtuality из GitHub? Панель перезапустится.');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/update/apply" id="update-apply-form" onsubmit="return confirm('Запустить обновление Virtuality из GitHub? Панель перезапустится.');">
<button class="primary" id="update-apply-button" type="submit">Установить обновления</button>
</form>
{% else %}
<form method="post" action="/update/check" id="update-check-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/update/check" id="update-check-form">
<button class="primary" id="update-main-button" type="submit">Проверить обновления</button>
</form>
{% endif %}
+2 -2
Просмотреть файл
@@ -21,7 +21,7 @@
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
@@ -36,7 +36,7 @@
<span class="pill">VM wizard</span>
</div>
<form method="post" action="/vm/create" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/vm/create" class="form-grid">
<label>
<span>Имя VM</span>
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="ubuntu-test" required>
-137
Просмотреть файл
@@ -1,137 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VM из cloud-образа — {{ app_name }}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="v-layout">
{% include "_sidebar.html" %}
<main class="v-main">
<header class="v-topbar">
<div>
<div class="v-title">VM из cloud-образа</div>
<div class="v-subtitle">Готовая система с пользователем и SSH за минуту — без установки с ISO</div>
</div>
<div class="top-meta">
<span>user: {{ user }}</span>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
</div>
</header>
{% if error %}
<div class="alert danger">{{ error }}</div>
{% endif %}
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Параметры</h2>
<span class="pill">cloud-init</span>
</div>
<form method="post" action="/vm/create-cloud" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Имя VM</span>
<input type="text" name="name" value="{{ form.name or '' }}" placeholder="ubuntu-web" required>
</label>
<label>
<span>Cloud-образ</span>
<select name="image_name" {% if images %}required{% endif %}>
{% for image in images %}
<option value="{{ image.name }}" {% if form.image_name == image.name %}selected{% endif %}>{{ image.label }} — {{ image.arch }} — {{ image.size }}</option>
{% endfor %}
</select>
</label>
<div class="form-row">
<label>
<span>RAM, MB</span>
<input type="number" name="memory" min="512" max="262144" step="512" value="{{ form.memory }}" required>
</label>
<label>
<span>CPU</span>
<input type="number" name="vcpus" min="1" max="128" value="{{ form.vcpus }}" required>
</label>
</div>
<label>
<span>Диск, GB</span>
<input type="number" name="disk_size" min="4" max="4096" value="{{ form.disk_size }}" required>
</label>
<div class="form-row">
<label>
<span>Пользователь в VM</span>
<input type="text" name="username" value="{{ form.username or 'admin' }}" placeholder="admin" required>
</label>
<label>
<span>Пароль (необязательно, если есть SSH-ключ)</span>
<input type="password" name="password" value="" placeholder="пароль для входа">
</label>
</div>
<label>
<span>Публичный SSH-ключ (необязательно)</span>
<input type="text" name="ssh_key" value="{{ form.ssh_key or '' }}" placeholder="ssh-ed25519 AAAA…">
</label>
<label>
<span>Режим сети</span>
<select name="network_mode" required>
<option value="nat" {% if form.network_mode != 'bridge' %}selected{% endif %}>VPS NAT Router — virtuality-nat</option>
<option value="bridge" {% if form.network_mode == 'bridge' %}selected{% endif %}>Bridge — br0 / локальная сеть</option>
</select>
</label>
<label>
<span>Bridge для режима Bridge</span>
<input type="text" name="bridge" value="{{ form.bridge }}" placeholder="br0">
</label>
<div class="notice">
Пользователь и пароль/ключ будут настроены cloud-init при первом старте VM. Вход: <b>ssh пользователь@IP</b> или через консоль на странице VM.
</div>
<button type="submit" class="primary wide" {% if not images %}disabled{% endif %}>Создать VM из cloud-образа</button>
{% if not images %}
<div class="alert danger">Сначала скачай cloud-образ на странице <a href="/cloud-images">Cloud-образы</a>.</div>
{% endif %}
</form>
</article>
<article class="card">
<div class="card-head">
<h2>Как это работает</h2>
<a class="link-pill" href="/cloud-images">Открыть cloud-образы</a>
</div>
<div class="quick-actions">
<div class="big-action static">
<strong>1. Копия образа</strong>
<span>Скачанный cloud-образ копируется в новый qcow2-диск VM и расширяется до выбранного размера.</span>
</div>
<div class="big-action static">
<strong>2. Seed-диск cloud-init</strong>
<span>Из имени, пользователя и пароля/ключа собирается маленький seed-ISO, который VM читает при первом старте.</span>
</div>
<div class="big-action static">
<strong>3. Импорт и запуск</strong>
<span>virt-install импортирует диск и запускает VM. Прогресс — в разделе «Операции».</span>
</div>
</div>
<pre>qemu-img convert -O qcow2 cloud.qcow2 vm.qcow2
qemu-img resize vm.qcow2 20G
cloud-localds seed.iso user-data meta-data
virt-install --import --disk vm.qcow2 …</pre>
</article>
</section>
</main>
</div>
<script src="/static/panel.js" defer></script>
</body>
</html>
+12 -97
Просмотреть файл
@@ -65,7 +65,7 @@
<span class="status {{ 'ok' if is_autostart else 'err' }}">autostart: {{ autostart_label }}</span>
<a class="link-pill accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">Консоль ↗</a>
<a class="link-pill" href="/">← Дашборд</a>
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
<form method="post" action="/logout" class="logout-form"><button class="ghost">Выйти</button></form>
</div>
</header>
@@ -75,11 +75,6 @@
{% if iso_error %}<div class="alert danger">{{ iso_error }}</div>{% endif %}
{% if boot_message %}<div class="alert success">{{ boot_message }}</div>{% endif %}
{% if boot_error %}<div class="alert danger">{{ boot_error }}</div>{% endif %}
{% if clone_message %}<div class="alert success">{{ clone_message }}</div>{% endif %}
{% if clone_error %}<div class="alert danger">{{ clone_error }}</div>{% endif %}
{% if snap_message %}<div class="alert success">{{ snap_message }}</div>{% endif %}
{% if snap_error %}<div class="alert danger">{{ snap_error }}</div>{% endif %}
{% if is_template %}<div class="alert warn">Эта VM помечена как шаблон: запуск заблокирован. Клонируй её для создания новых машин.</div>{% endif %}
<section class="grid vm-four-grid">
<article class="card">
@@ -93,10 +88,10 @@
<div class="vm-actions compact-actions big-actions">
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Открыть консоль"><span></span><em>Консоль</em></a>
{% if is_running %}
<form method="post" action="/vm/{{ vm.name }}/reboot"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Перезагрузить"><span></span><em>Reboot</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/shutdown"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Мягко выключить"><span></span><em>Shutdown</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/reboot"><button class="icon-action" title="Перезагрузить"><span></span><em>Reboot</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/shutdown"><button class="icon-action" title="Мягко выключить"><span></span><em>Shutdown</em></button></form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/start"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action success" title="Запустить"><span></span><em>Start</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/start"><button class="icon-action success" title="Запустить"><span></span><em>Start</em></button></form>
{% endif %}
</div>
</div>
@@ -104,11 +99,11 @@
<div class="label">Автозапуск</div>
<div class="vm-actions compact-actions big-actions">
{% if is_autostart %}
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="autostart-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="autostart-form">
<button class="autostart-indicator on" title="Автозапуск включён. Нажми, чтобы отключить"><span></span><strong>Включён</strong><small>Нажми для отключения</small></button>
</form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/autostart" class="autostart-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/vm/{{ vm.name }}/autostart" class="autostart-form">
<button class="autostart-indicator off" title="Автозапуск выключен. Нажми, чтобы включить"><span></span><strong>Выключен</strong><small>Нажми для включения</small></button>
</form>
{% endif %}
@@ -119,10 +114,10 @@
<div class="label">Опасные действия</div>
<div class="vm-actions compact-actions big-actions">
{% if is_running %}
<form method="post" action="/vm/{{ vm.name }}/destroy"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Принудительно выключить"><span></span><em>Power off</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/destroy"><button class="icon-action danger" title="Принудительно выключить"><span></span><em>Power off</em></button></form>
{% endif %}
{% if not is_running %}
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Удалить VM вместе с дисками"><span>🗑</span><em>Delete</em></button></form>
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><button class="icon-action danger" title="Удалить VM вместе с дисками"><span>🗑</span><em>Delete</em></button></form>
{% else %}
<span class="muted small-note">Удаление доступно после выключения VM.</span>
{% endif %}
@@ -136,7 +131,7 @@
<h2>CPU / RAM / Архитектура</h2>
<span class="pill">resources</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/resources" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/vm/{{ vm.name }}/resources" class="form-grid">
<div class="form-row">
<label>
<span>CPU</span>
@@ -169,7 +164,7 @@
<h2>Порядок загрузки</h2>
<span class="pill">boot</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid boot-order-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/vm/{{ vm.name }}/boot-order" class="form-grid boot-order-form">
<input type="hidden" name="boot_order" id="boot-order-value" value="{{ current_boot_order|default('auto') }}">
<div class="boot-order-list" id="boot-order-list" data-current="{{ current_boot_order|default('auto') }}">
<div class="boot-order-item" draggable="true" data-device="cdrom"><span class="drag-handle"></span><div><b>ISO / CD-ROM</b><small>Установщик или rescue-образ</small></div></div>
@@ -191,7 +186,7 @@
{% else %}
<div class="muted small-note">ISO сейчас не подключён.</div>
{% endif %}
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/vm/{{ vm.name }}/iso/mount" class="form-grid">
<label>
<span>ISO образ</span>
<select name="iso_path" required>
@@ -202,7 +197,7 @@
</label>
<button class="primary wide" type="submit" {% if not isos %}disabled{% endif %}>Смонтировать ISO</button>
</form>
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" class="form-grid" onsubmit="return confirm('Отмонтировать ISO из VM {{ vm.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<form method="post" action="/vm/{{ vm.name }}/iso/unmount" class="form-grid" onsubmit="return confirm('Отмонтировать ISO из VM {{ vm.name }}?');">
<button type="submit" class="ghost wide" {% if not current_iso %}disabled{% endif %}>Отмонтировать ISO</button>
</form>
{% if not isos %}
@@ -249,86 +244,6 @@
</article>
</section>
<section class="card">
<div class="card-head">
<h2>Снапшоты</h2>
<span class="pill">{{ snapshots|length }} шт.</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/snapshot/create" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-row">
<label>
<span>Имя снапшота</span>
<input type="text" name="snapshot_name" placeholder="before-update" required>
</label>
<label>
<span>Комментарий (необязательно)</span>
<input type="text" name="description" placeholder="перед обновлением nginx">
</label>
</div>
<button class="primary">Создать снапшот</button>
</form>
<table>
<thead>
<tr><th>Имя</th><th>Создан</th><th>Состояние VM</th><th>Действия</th></tr>
</thead>
<tbody>
{% for snap in snapshots %}
<tr>
<td class="strong">{{ snap.name }}</td>
<td>{{ snap.created }}</td>
<td><span class="status {{ snap.state_css }}">{{ snap.state }}</span></td>
<td class="actions">
<form method="post" action="/vm/{{ vm.name }}/snapshot/revert" onsubmit="return confirm('Откатить VM {{ vm.name }} к снапшоту {{ snap.name }}? Текущее состояние диска будет потеряно.');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="snapshot_name" value="{{ snap.name }}">
<button class="ghost">Откатиться</button>
</form>
<form method="post" action="/vm/{{ vm.name }}/snapshot/delete" onsubmit="return confirm('Удалить снапшот {{ snap.name }}?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="snapshot_name" value="{{ snap.name }}">
<button class="danger">Удалить</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">Снапшотов пока нет. Создай снапшот перед рискованными изменениями — к нему можно будет откатиться одной кнопкой.</td></tr>
{% endfor %}
</tbody>
</table>
<p class="muted small-note">Снапшот работающей VM сохраняет и память — VM продолжит работу с того же места после отката. Снапшот выключенной VM сохраняет только диск.</p>
</section>
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Клонирование</h2>
<span class="pill">virt-clone</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/clone" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Имя новой VM</span>
<input type="text" name="new_name" placeholder="{{ vm.name }}-copy" required>
</label>
<button class="primary wide" {% if is_running %}disabled{% endif %}>Клонировать VM</button>
{% if is_running %}
<p class="muted small-note">Клонирование доступно только для выключенной VM — сначала выключи её.</p>
{% else %}
<p class="muted small-note">Диски копируются целиком, MAC-адрес нового клона генерируется автоматически. Прогресс — в «Операциях».</p>
{% endif %}
</form>
</article>
<article class="card">
<div class="card-head">
<h2>Шаблон</h2>
<span class="pill">{{ 'template' if is_template else 'обычная VM' }}</span>
</div>
<form method="post" action="/vm/{{ vm.name }}/template" class="form-grid"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="enabled" value="{{ '0' if is_template else '1' }}">
<button class="{{ 'ghost' if is_template else 'primary' }} wide">{{ 'Снять флаг шаблона' if is_template else 'Пометить как шаблон' }}</button>
</form>
<p class="muted small-note">Шаблон — эталонная VM для клонирования: запуск заблокирован, чтобы случайно не изменить эталонный диск. Подготовь VM, выключи её и пометь как шаблон.</p>
</article>
</section>
<section class="card">
<div class="card-head">
<h2>Консоль</h2>
-114
Просмотреть файл
@@ -1,114 +0,0 @@
#!/usr/bin/env python3
"""Быстрый доступ к libvirt через python-байндинги.
Одно постоянное соединение вместо десятков subprocess-вызовов virsh на
каждую загрузку страницы — критично для Raspberry Pi и слабых VPS.
Если libvirt-python не установлен или libvirtd недоступен, модуль честно
сообщает об этом через available(), и приложение работает через virsh.
"""
import threading
from typing import Any
try:
import libvirt # python3-libvirt / libvirt-python
# Отключаем печать ошибок libvirt в stderr — ошибки обрабатываются кодом.
libvirt.registerErrorHandler(lambda ctx, err: None, None)
except ImportError:
libvirt = None
_CONN_LOCK = threading.Lock()
_conn = None
# Коды состояний libvirt → строки в стиле virsh, которые уже ждёт UI.
_STATE_NAMES = {
0: "no state",
1: "running",
2: "blocked",
3: "paused",
4: "in shutdown",
5: "shut off",
6: "crashed",
7: "pmsuspended",
}
def _connection():
global _conn
if libvirt is None:
return None
with _CONN_LOCK:
if _conn is not None:
try:
if _conn.isAlive():
return _conn
except Exception:
pass
try:
_conn.close()
except Exception:
pass
_conn = None
try:
_conn = libvirt.open("qemu:///system")
except Exception:
_conn = None
return _conn
def available() -> bool:
return _connection() is not None
def list_domains() -> list[dict[str, Any]] | None:
"""Список всех доменов с состоянием и автозапуском. None — backend недоступен."""
conn = _connection()
if conn is None:
return None
rows = []
try:
for dom in conn.listAllDomains(0):
try:
state_code = dom.state()[0]
dom_id = dom.ID()
rows.append({
"id": str(dom_id) if dom_id > 0 else "-",
"name": dom.name(),
"state": _STATE_NAMES.get(state_code, "unknown"),
"autostart_enabled": bool(dom.autostart()),
})
except Exception:
continue
except Exception:
return None
rows.sort(key=lambda row: row["name"])
return rows
def domain_stats() -> dict[str, dict[str, int]] | None:
"""cpu_time (нс) и память (KiB) работающих доменов для сбора метрик."""
conn = _connection()
if conn is None:
return None
stats: dict[str, dict[str, int]] = {}
try:
for dom in conn.listAllDomains(libvirt.VIR_CONNECT_LIST_DOMAINS_ACTIVE):
try:
info = dom.info() # [state, maxMem, memory, nrVirtCpu, cpuTime]
stats[dom.name()] = {"cpu_time_ns": int(info[4]), "memory_kb": int(info[2]), "max_memory_kb": int(info[1]), "vcpus": int(info[3])}
except Exception:
continue
except Exception:
return None
return stats
def node_memory_mb() -> dict[str, int] | None:
"""Память хоста по данным libvirt (fallback для метрик)."""
conn = _connection()
if conn is None:
return None
try:
info = conn.getInfo() # [model, memory_mb, cpus, ...]
return {"total_mb": int(info[1]), "cpus": int(info[2])}
except Exception:
return None