28 Коммитов
Автор SHA1 Сообщение Дата
viktor a840e0c828 Версия была обновлена до 0.12.8
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 22:42:23 +09:00
viktor c19d8db6e7 Выбор образа при создании машины карточками: название системы, размер, дата загрузки и поиск, когда образов много 2026-09-27 22:42:23 +09:00
viktor c04e5285e7 Версия была обновлена до 0.12.7
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 21:24:19 +09:00
viktor 5d02cc81ff Установщик Windows узнаётся по метке ISO и имени файла: форма создания машины сама выбирает тип Windows, иначе диск был virtio и установщик его не видел 2026-09-27 21:24:19 +09:00
viktor ff3b09aba4 Версия была обновлена до 0.12.6
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 21:08:17 +09:00
viktor 2bf4ef35c2 Удаление машины больше не стирает вставленный установочный ISO: вместо --remove-all-storage удаляются только диски машины из папки машин 2026-09-27 21:08:17 +09:00
viktor ca5d49266c Версия была обновлена до 0.12.5
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 17:58:07 +09:00
viktor ec9428c2eb Удалить машину можно в любом состоянии: работающая выключается, пока идёт задача удаление ждёт, пробросы портов машины удаляются вместе с ней 2026-09-27 17:58:07 +09:00
viktor f31b4ade1d Версия была обновлена до 0.12.4
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 17:45:03 +09:00
viktor 2474ad3dc2 Машина с ISO после установки перезагружается сама: создаётся без отдельной фазы установки (no_install), диск первым в порядке загрузки 2026-09-27 17:45:03 +09:00
viktor daeec06ec6 Версия была обновлена до 0.12.3
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 17:32:45 +09:00
viktor 209e83044a Экран машины снова подключается: noVNC 1.6 строил адрес WebSocket от своей страницы и уходил в /novnc/console/ws, путь теперь работает и с 1.6, и с 1.3 2026-09-27 17:32:45 +09:00
viktor 5d4525f78d Версия была обновлена до 0.12.2
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 17:24:01 +09:00
viktor 984eb85a5a Неудачное создание машины: панель больше не предлагает экран несуществующей машины, даёт «Создать заново» с тем же образом и удаляет недосозданный диск 2026-09-27 17:24:01 +09:00
viktor 48ac535c3a Версия была обновлена до 0.12.1
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 16:43:13 +09:00
viktor cc3abe3f4a Создание машины: диск больше свободного места создаётся только с подтверждением, в шаге «Мощность» видно свободное место; раньше установка падала с ошибкой virt-install 2026-09-27 16:43:13 +09:00
viktor 2a4bffc7df Версия была обновлена до 0.12.0
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 16:23:20 +09:00
viktor af9cae85de Загрузка файлов с компьютера снова работает: форма собиралась уже после блокировки поля файла, и браузер отправлял пустой запрос (ошибка 422) 2026-09-27 16:20:58 +09:00
viktor b7c87867fb Старт панели переведён с устаревшего on_event на lifespan, шаблоны вызываются как TemplateResponse(request, name): ушла сотня предупреждений FastAPI 2026-09-27 16:02:10 +09:00
viktor fc76f3f1fa Резервные копии по расписанию: каждый день или раз в неделю, все машины или отмеченные, хранятся последние N автокопий; копии делает служба virtuality-backup, а не панель 2026-09-27 15:58:31 +09:00
viktor 1ad557d1ab Задачи помнят процесс-владельца: при старте панель больше не объявляет прерванными задачи, которые ведёт другой живой процесс 2026-09-27 15:58:31 +09:00
viktor d50a4314aa Сборка ISO не принимает системные имена администратора (admin, root, kvm): установщик Ubuntu их отвергает, и автоматическая установка падала на первом шаге
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
Build ISO image / ISO ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }} (push) Canceled after 0s
2026-09-27 15:54:01 +09:00
viktor 40978d1c2b Версия была обновлена до 0.11.1
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
2026-09-27 15:31:43 +09:00
viktor 440d8fc562 Проект переехал в Gitea StackWorks: обновления, установка одной командой и ISO берут код с git.stackworks.ru, запасной ZIP скачивается оттуда же, ноды со старым адресом GitHub переключаются сами 2026-09-27 15:31:43 +09:00
viktor 83511d6b09 История копии из Gitea (0.9.6 и разбор старого README) была присоединена, содержимое остаётся от GitHub-версии 0.11
CI / Tests (Python 3.11) (push) Canceled after 0s
CI / Tests (Python 3.12) (push) Canceled after 0s
CI / Tests (Python 3.13) (push) Canceled after 0s
CI / Tests (Python 3.14) (push) Canceled after 0s
CI / Shell scripts (push) Canceled after 0s
2026-09-27 14:47:52 +09:00
viktor 83300b6c5a Резервные копии: наблюдатель позднего выключения перепроверяет отмену после опроса машины и не запускает её, если «Выключить» нажали в этот момент 2026-09-26 09:34:57 +09:00
viktor 657d54fc75 Тесты vmops больше не зависят от свободного места во временной папке (на tmpfs падали 8 тестов) 2026-09-26 09:34:57 +09:00
viktor 5f2bdd921d docs: подробный README (разбор проекта при переносе в Gitea)
Старый README сохранён как README.upstream.md.
2026-08-20 04:18:36 +09:00
50 изменённых файлов: 1860 добавлений и 192 удалений
+7 -4
Просмотреть файл
@@ -7,6 +7,9 @@ Virtuality — простая замена Proxmox для дома и малог
**Общайся с владельцем проекта по-русски.** Весь текст интерфейса — на простом русском, без жаргона
(термины вроде qcow2, libvirt, nvram — только в раскрывающихся блоках «Для специалистов»).
Репозиторий: https://git.stackworks.ru/viktor/virtuality (Gitea StackWorks). С GitHub проект переехал в сентябре 2026 года;
ноды со старым адресом переключаются сами (`update_core.migrate_remote`, `migrate_update_remote` в `scripts/update_target.sh`).
## Устройство репозитория
- `web/app.py` — основное приложение FastAPI (машины, ISO, образы дисков, сеть, журналы, обновления, консоль).
@@ -14,8 +17,8 @@ Virtuality — простая замена Proxmox для дома и малог
`finish_operation`, `active_operations_for`, `interrupt_orphaned_operations`), `render`, `require_auth`,
`valid_vm_name`, шаблоны Jinja. Импорт `core` выставляет `LC_ALL=C.UTF-8` (virsh иначе переводится).
- `web/features/*.py` — модули с `router = APIRouter()`, подключаются в конце `app.py`:
`setup` (мастер `/setup`), `settings` (`/settings`), `snapshots`, `backups`, `downloads` (скачивание
по ссылке и каталог), `vmops` (клон, увеличение диска, пауза/спящий режим, заметки, нагрузка).
`setup` (мастер `/setup`), `settings` (`/settings`), `snapshots`, `backups`, `backup_schedule` (копии
по расписанию; запускает их `virtuality-backup.timer`, а не панель), `downloads` (скачивание по ссылке и каталог), `vmops` (клон, увеличение диска, пауза/спящий режим, заметки, нагрузка).
POST-маршруты модулей — минимум 4 сегмента (`/vm/{name}/<модуль>/<действие>`), иначе их перехватит
`@app.post("/vm/{name}/{action}")`.
- `web/nodectl.py` — факты об узле и действия через `scripts/virtuality-ctl` (единая точка для
@@ -55,8 +58,8 @@ make iso # ISO в dist/ (нужны xorriso, git, curl, p
- Работаем прямо в `main` (так решил владелец). Перед каждым пушем — `make check`; не пушить
недоделанное: ноды на канале «Ранний доступ» (`main`) подтягивают main каждую ночь.
- Узлы на канале `stable` (по умолчанию) обновляются только до тегов `vX.Y.Z`. Релиз: поднять `VERSION`,
добавить запись в `updates/versions.json`, запушить, поставить тег `vX.Y.Z` на main — тег же запускает
сборку ISO в GitHub Actions (artifacts `virtuality-iso-amd64/arm64`).
добавить запись в `updates/versions.json`, запушить, поставить тег `vX.Y.Z` на main. Раннера Actions нет:
ISO собирается вручную `make iso`, CI заменяет `make check` перед пушем.
- Секреты и пароли не логировать; пароль cloud-init хранится только как SHA-512 crypt.
- Не возвращать удалённое: Cockpit, консольный dashboard, эмуляцию ARM64 на x86, PXE, смену
архитектуры машины, скрипты bootstrap/clean_install/setup_bridge_br0/create_test_vm.
+13 -13
Просмотреть файл
@@ -2,7 +2,7 @@
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
Текущая версия: **0.11.0** (файл `VERSION`, журнал изменений — `updates/versions.json`).
Текущая версия: **0.12.8** (файл `VERSION`, журнал изменений — `updates/versions.json`).
Что умеет:
@@ -70,14 +70,13 @@
Где взять образ:
- **GitHub Actions.** Откройте вкладку Actions репозитория → workflow **Build ISO image** → последний успешный запуск → раздел Artifacts → `virtuality-iso-amd64` (или `virtuality-iso-arm64`). Внутри архива — `.iso` и файл `.sha256`. Артефакты хранятся 30 дней; новый образ собирается при каждом выпуске (тег `vX.Y.Z`), запустить сборку вручную можно кнопкой Run workflow.
- **Собрать самому** на любой Linux-машине (root не нужен):
```bash
sudo apt install -y xorriso git curl python3-pip
git clone https://github.com/viktor138irk/virtuality.git
git clone https://git.stackworks.ru/viktor/virtuality.git
cd virtuality
make iso # dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso
make iso # dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso
```
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
@@ -87,7 +86,7 @@
Подходит для уже установленного Ubuntu Server 24.04 / 26.04 или Debian 13. Работает и под `root`, и под обычным пользователем с `sudo`:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | bash
curl -fsSL https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | bash
```
Скрипт проверит требования, поставит пакеты KVM/QEMU/libvirt, команду `vhealth` и панель, а в конце покажет адрес панели. Логин — тот пользователь, от которого запущена установка (через `sudo` — ваш пользователь, под `root` — `root`); пароль — его пароль Linux. Если пароль ещё не задан: `sudo passwd <пользователь>`.
@@ -105,7 +104,7 @@ VIRTUALITY_INSTALL_VERBOSE=1 # показывать вывод команд вм
Пример:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=admin bash
curl -fsSL https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | VIRTUALITY_USER=admin bash
```
При первом входе в панель откроется мастер настройки — тот же, что после установки с ISO.
@@ -116,7 +115,7 @@ curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/instal
```bash
sudo mkdir -p /opt/virtuality
sudo git clone https://github.com/viktor138irk/virtuality.git /opt/virtuality/source
sudo git clone https://git.stackworks.ru/viktor/virtuality.git /opt/virtuality/source
cd /opt/virtuality/source
sudo bash install_virtuality_node.sh # пакеты KVM/QEMU/libvirt, пулы, firewall
sudo bash scripts/install_healthcheck_command.sh # команда sudo vhealth
@@ -167,9 +166,9 @@ sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панел
**Машины**
- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть.
- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны.
- Тип системы для ISO (установщик Windows узнаётся по образу и выбирается сам): Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны.
- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками.
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками и пробросами портов (работающая машина сначала выключается, установочные ISO остаются).
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
@@ -178,7 +177,8 @@ sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панел
**Снимки и копии**
- **Снимки** — точки восстановления внутри диска машины, до 8 на машину. У работающей машины в снимок попадает и память. Вернуться или удалить — одной кнопкой.
- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием; копию можно делать и по расписанию из командной строки.
- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием.
- **Копии по расписанию** — каждый день или раз в неделю в выбранное время, для всех машин или только отмеченных. Сервер хранит последние N автоматических копий каждой машины и удаляет старые только после удачной новой; ручные копии не трогает.
**Образы ОС**
@@ -265,7 +265,7 @@ sudo virtuality-ctl uninstall [--purge] [--yes] # удалить Virtuality
- **Из панели**: раздел **Обновления** → «Проверить сейчас» → «Обновить». Машины во время обновления продолжают работать. Что изменилось — видно на той же странице.
- **Автоматически**: таймер `virtuality-auto-update.timer` проверяет обновления каждую ночь около 4:00 по времени сервера. Включается и выключается в «Настройках» или командой `sudo virtuality-ctl set VIRTUALITY_AUTO_UPDATE=0`.
- **Каналы**: `stable` — только выпущенные версии (последний тег `vX.Y.Z`; пока тегов нет, стабильный канал следует ветке `main`), `main` — все изменения ветки `main`. Нода никогда не откатывается на более старую версию сама.
- **Вручную из терминала**: `sudo bash /opt/virtuality/source/scripts/apply_github_update.sh` — тот же сценарий, что и в панели (git fetch, при недоступности git — ZIP с GitHub, затем `scripts/install_web_panel.sh` и перезапуск панели). Журнал: `/var/log/virtuality/update.log`.
- **Вручную из терминала**: `sudo bash /opt/virtuality/source/scripts/apply_github_update.sh` — тот же сценарий, что и в панели (git fetch, при недоступности git — ZIP-архив с git.stackworks.ru, затем `scripts/install_web_panel.sh` и перезапуск панели). Журнал: `/var/log/virtuality/update.log`.
- **Откат**: перед каждым обновлением предыдущая версия панели сохраняется в `/opt/virtuality/web.prev`. Если новая версия не запустилась: `sudo virtuality-ctl rollback`.
---
@@ -318,7 +318,7 @@ sudo virtuality-ctl uninstall --purge # удаляет ВСЁ: машины,
/var/lib/virtuality/disk-images # готовые образы систем (каталог, ссылки, загрузки)
/var/lib/virtuality/images # диски машин (qcow2)
/var/lib/virtuality/backups # резервные копии машин и архивы настроек
/var/lib/virtuality/config # web.env, setup.json, wizard.json, host_profile.json, tls/
/var/lib/virtuality/config # web.env, setup.json, wizard.json, host_profile.json, backup_schedule.json, tls/
/var/lib/virtuality/network # port_forwards.json, virtuality-nat.xml
/var/lib/virtuality/tmp # временные файлы загрузок и cloud-init
/var/log/virtuality # firstboot.log, update.log, virtuality-ctl.log, install_*.log
@@ -353,7 +353,7 @@ VIRTUALITY_DEV_SETUP=finished python3 tests/dev_server.py # настроен
Устройство кода описано в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Интерфейс: шаблоны `web/templates` (каркас `base.html`, компоненты `_ui.html`), стили `web/static/app.css`, поведение `web/static/panel.js`; шрифт и иконки лежат в `web/static`. Скриншоты страниц: `node tests/screenshots.mjs` (нужен Playwright).
GitHub Actions: `ci.yml` запускает pyflakes, shellcheck и тесты на Python 3.11–3.14; `image.yml` собирает ISO по тегу `v*` и вручную. Ветка `main` — рабочая ветка нод на канале `main`; выпуск для стабильного канала — тег `vX.Y.Z`.
Репозиторий живёт в Gitea StackWorks: https://git.stackworks.ru/viktor/virtuality (до сентября 2026 года был на GitHub; ноды со старым адресом переключаются на новый сами при первой проверке обновлений). Ветка `main` — рабочая ветка нод на канале `main`; выпуск для стабильного канала — тег `vX.Y.Z`. Раннер Actions не подключён: перед каждым пушем запускается `make check`, ISO собирается вручную `make iso`. Файлы `.github/workflows` (`ci.yml` — проверки на Python 3.11–3.14, `image.yml` — сборка ISO) Gitea тоже понимает, они заработают, если раннер появится.
---
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.11.0
0.12.8
+9 -5
Просмотреть файл
@@ -26,7 +26,8 @@ virtuality-web.service — uvicorn, web/app.py (FastAPI, Jinja2)
| `features/setup.py` | Мастер первичной настройки `/setup`: шаги install → welcome → account → access → network → storage → updates → finish, страница «Применяем настройки», ожидание перестройки сети в мост. |
| `features/settings.py` | Страница «Настройки»: доступ (HTTPS/порт), пароль, часовой пояс, обновления, сеть машин, хранилище, перезагрузка/выключение, архив настроек, отчёт для поддержки. |
| `features/snapshots.py` | Снимки: внутренние снимки libvirt (`virsh snapshot-*`), до 8 на машину, создание и возврат — фоновые задачи. |
| `features/backups.py` | Резервные копии: `vm.xml` + копии дисков + `meta.json` в `backups/<машина>/<ГГГГММДД-ЧЧММ>/`; восстановление под тем же или новым именем; запуск как скрипта для расписания. |
| `features/backups.py` | Резервные копии: `vm.xml` + копии дисков + `meta.json` в `backups/<машина>/<ГГГГММДД-ЧЧММ>/`; восстановление под тем же или новым именем; автокопии помечены `auto: true`. |
| `features/backup_schedule.py` | Копии по расписанию: настройка `config/backup_schedule.json` (каждый день или по дню недели, время, машины, сколько хранить), итог запуска `config/backup_schedule_state.json`. Копирует служба `virtuality-backup.service` (таймер раз в 15 минут), а не панель, поэтому перезапуск панели копию не обрывает. Пропущенное время догоняется только в течение 2 часов; после удачной копии у машины остаются последние N автокопий, ручные не удаляются. |
| `features/downloads.py` | Скачивание ISO и образов по ссылке и из каталога — фоновые задачи типа `download` с прогрессом, проверкой контрольной суммы и отменой. |
| `features/vmops.py` | Увеличение диска, клонирование (`virt-clone`), пауза, спящий режим (`virsh managedsave`), заметки (title/description машины), живая нагрузка (`virsh domstats`). |
| `nodectl.py` | Факты о ноде (сеть, диски, оборудование, часовые пояса, состояние первой загрузки, выбор мастера) и действия через `virtuality-ctl`. Действия, которые перезапускают панель, запускаются отложенно через `systemd-run`. |
@@ -44,7 +45,7 @@ virtuality-web.service — uvicorn, web/app.py (FastAPI, Jinja2)
### Фоновые задачи
Долгие операции (создание машины, конвертация образа, скачивание, снимок, копия, клон, спящий режим) выполняются в потоках и записываются в `/var/log/virtuality/operations/<id>.json` (статус, прогресс, сообщение, метаданные) и `<id>.log` (журнал). Раздел «Задачи» и страницы машин читают эти файлы; `active_operations_for(vm, kinds)` защищает машину от двух одновременных операций. При старте панели `interrupt_orphaned_operations()` закрывает задачи, оставшиеся в состоянии «выполняется» после перезапуска. `virtuality-ctl maintenance` удаляет задачи старше 30 дней и оставляет не больше 300.
Долгие операции (создание машины, конвертация образа, скачивание, снимок, копия, клон, спящий режим) выполняются в потоках и записываются в `/var/log/virtuality/operations/<id>.json` (статус, прогресс, сообщение, метаданные) и `<id>.log` (журнал). Раздел «Задачи» и страницы машин читают эти файлы; `active_operations_for(vm, kinds)` защищает машину от двух одновременных операций. У каждой задачи записан процесс-владелец (`owner`: pid, время его запуска и boot id). При старте панели `interrupt_orphaned_operations()` закрывает задачи, оставшиеся в состоянии «выполняется», если их процесс уже не жив; задачи живого чужого процесса (копии по расписанию из `virtuality-backup.service`) не трогает. `virtuality-ctl maintenance` удаляет задачи старше 30 дней и оставляет не больше 300.
---
@@ -73,6 +74,7 @@ virtuality-web.service — uvicorn, web/app.py (FastAPI, Jinja2)
| `virtuality-redirect.service` | Только при TLS: HTTP → HTTPS. |
| `virtuality-network.service` | При загрузке заново применяет пробросы портов (`restore_network.py`). |
| `virtuality-auto-update.timer` / `.service` | Ночная проверка обновлений (`scripts/auto_update_check.sh`), 04:00 ± 30 минут; включён при `VIRTUALITY_AUTO_UPDATE=1`. |
| `virtuality-backup.timer` / `.service` | Раз в 15 минут запускает `features/backup_schedule.py`: если подошло время по расписанию, копирует машины по очереди и удаляет лишние автокопии. Итог пишется в журнал службы и показывается на странице «Резервные копии». |
| `virtuality-maintenance.timer` / `.service` | Ежедневное обслуживание: старые задачи, временные файлы, недокачанные загрузки, `journalctl --vacuum-size=300M`, запись занятости диска в `config/health.json`. |
| `libvirt-guests.service` | При выключении сервера машины выключаются корректно (`ON_SHUTDOWN=shutdown`, до 180 секунд), при загрузке не запускаются сами — только те, у кого включён автозапуск. |
@@ -120,7 +122,7 @@ libvirt-сеть с мостом `virbr100`, подсеть `192.168.100.0/24`,
├── images/ диски машин <имя>.qcow2; libvirt-пул virtuality-images
├── disk-images/ готовые образы систем (каталог, ссылки, загрузки с компьютера)
├── backups/ резервные копии машин (<машина>/<ГГГГММДД-ЧЧММ>/) и архивы настроек config-*.tar.gz
├── config/ web.env, setup.json, setup_done, wizard.json, host_profile.json,
├── config/ web.env, setup.json, setup_done, wizard.json, host_profile.json, backup_schedule*.json,
│ session_secret, ports.state, health.json, tls/, netplan-backup/
├── network/ port_forwards.json, ufw_rules.json, virtuality-nat.xml
├── update/ state.json, last_check.json, временные файлы обновления
@@ -146,9 +148,11 @@ libvirt-сеть с мостом `virbr100`, подсеть `192.168.100.0/24`,
## Обновления
Ноды обновляются из `origin` своего `/opt/virtuality/source` — по умолчанию https://git.stackworks.ru/viktor/virtuality.git. Проект переехал туда с GitHub в сентябре 2026 года; ноду со старым адресом GitHub `update_core.migrate_remote()` и `migrate_update_remote` в `scripts/update_target.sh` переключают на новый перед `git fetch`.
1. **Канал** (`VIRTUALITY_UPDATE_CHANNEL` в `web.env`): `stable` — последний тег `vX.Y.Z` (пока тегов нет — ветка `main`); `main` — ветка `main`. Логика одна и та же в `web/update_core.py` и `scripts/update_target.sh`.
2. **Проверка** (`/update/check`, ночной `scripts/auto_update_check.sh`): `git fetch --tags`, сравнение текущего коммита с целевым; обновление предлагается, только если целевой коммит — потомок текущего (нода никогда не откатывается сама). Список изменений берётся из `updates/versions.json` целевой версии.
3. **Применение** (`scripts/apply_github_update.sh`, запускается через `systemd-run`): `git checkout -B main <цель>` и `git clean`; если git недоступен — скачивание ZIP-архива с GitHub и `rsync`; затем `scripts/install_web_panel.sh` (панель копируется в `web.new`, старая переименовывается в `web.prev`, venv обновляется) и `systemctl restart virtuality-web`. Состояние — `/var/lib/virtuality/update/state.json`, журнал — `/var/log/virtuality/update.log`.
3. **Применение** (`scripts/apply_github_update.sh`, запускается через `systemd-run`): `git checkout -B main <цель>` и `git clean`; если git недоступен — скачивание ZIP-архива того же репозитория (`<адрес>/archive/<ветка или тег>.zip`, так отдают и Gitea, и GitHub) и `rsync`; затем `scripts/install_web_panel.sh` (панель копируется в `web.new`, старая переименовывается в `web.prev`, venv обновляется) и `systemctl restart virtuality-web`. Состояние — `/var/lib/virtuality/update/state.json`, журнал — `/var/log/virtuality/update.log`.
4. **Откат**: `virtuality-ctl rollback` меняет местами `web` и `web.prev`.
Установщики и скрипты вне `web/`:
@@ -169,4 +173,4 @@ image/build-iso.sh, autoinstall.yaml, files/, grub/ сборка ISO
## Тесты и CI
`tests/conftest.py` подменяет `run_cmd`, так что `virsh`, `systemctl`, `qemu-img` и остальные команды имитируются — тесты идут на любой машине без libvirt. `tests/dev_server.py` поднимает демо-панель с теми же подменами. `make check` = pyflakes + shellcheck + pytest; `.github/workflows/ci.yml` запускает это на Python 3.11–3.14, `.github/workflows/image.yml` собирает ISO.
`tests/conftest.py` подменяет `run_cmd`, так что `virsh`, `systemctl`, `qemu-img` и остальные команды имитируются — тесты идут на любой машине без libvirt. `tests/dev_server.py` поднимает демо-панель с теми же подменами. `make check` = pyflakes + shellcheck + pytest, запускается перед каждым пушем. `.github/workflows/ci.yml` (то же на Python 3.11–3.14) и `.github/workflows/image.yml` (сборка ISO) заработают в Gitea Actions, когда будет подключён раннер.
+4 -2
Просмотреть файл
@@ -60,7 +60,7 @@
1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова.
2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком.
3. После установки на странице машины откройте вкладку **Диски и загрузка** → **Привод CD/DVD** и извлеките установочный образ, чтобы машина больше не предлагала загрузку с него.
3. После установки машина перезагрузится уже с диска: для машин из ISO панель ставит диск первым в порядке загрузки, пока он пустой, машина загружается с образа. Сам образ можно извлечь во вкладке **Диски и загрузка** → **Привод CD/DVD**.
Для других систем выбирайте тип **Linux** (Ubuntu, Debian, Fedora и другие), **Windows 10 / Server 2019–2022** (классическая загрузка) или **Другая система**.
@@ -121,7 +121,9 @@
3. **Восстановить**: выберите копию → укажите название — то же (тогда текущую машину придётся заменить, панель попросит подтверждение) или новое (получите вторую машину рядом с первой). Восстановленная машина создаётся выключенной. Файлы копии остаются на месте, восстанавливать из неё можно сколько угодно раз.
4. Перед восстановлением панель проверяет, хватит ли места в `/var/lib/virtuality`.
Копию можно делать и по расписанию из терминала сервера — например, задачей cron:
Копии можно делать и автоматически: **Резервные копии** → **Настроить** в карточке «Автоматические копии». Выберите, как часто (каждый день или раз в неделю), время, машины и сколько копий хранить. Сервер будет сам копировать машины в это время и удалять старые автоматические копии, а ручные оставит.
Одну копию можно сделать и из терминала сервера:
```bash
sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия"
+12 -16
Просмотреть файл
@@ -20,13 +20,7 @@
## Где взять готовый образ
Workflow `.github/workflows/image.yml` собирает ISO в GitHub Actions:
- автоматически при выпуске — пуш тега `v*` (amd64 и arm64);
- при pull request, который меняет `image/**`, `scripts/virtuality_firstboot.sh`, `web/requirements.txt` или сам workflow;
- вручную: Actions → **Build ISO image** → Run workflow → архитектура `amd64`, `arm64` или `both`.
После сборки workflow проверяет, что в образе есть загрузочная запись UEFI, `autoinstall.yaml` разбирается как YAML, а в `grub.cfg` есть пункт Virtuality. Образы Ubuntu Server больше лимита GitHub Releases (2 ГБ на файл), поэтому ISO публикуются как **artifacts** запуска (`virtuality-iso-amd64`, `virtuality-iso-arm64`) и хранятся 30 дней. В артефакте — `.iso` и `.sha256`.
Образ собирается вручную командой `make iso` (см. ниже): раннер Actions в Gitea не подключён. Workflow `.github/workflows/image.yml` остаётся в репозитории и заработает, как только раннер появится: он собирает ISO по тегу `v*` и вручную, проверяет загрузочную запись UEFI, `autoinstall.yaml` и пункт Virtuality в `grub.cfg` и сохраняет `.iso` и `.sha256` как artifacts запуска.
---
@@ -41,7 +35,7 @@ sudo apt install -y xorriso git curl python3-pip
Сборка:
```bash
git clone https://github.com/viktor138irk/virtuality.git
git clone https://git.stackworks.ru/viktor/virtuality.git
cd virtuality
make iso # amd64
make iso ARCH=arm64 # ARM64-серверы с UEFI
@@ -50,8 +44,8 @@ make iso ARCH=arm64 # ARM64-серверы с UEFI
`image/build-iso.sh` сам находит последний Ubuntu 26.04.x live-server ISO, скачивает его (около 3 ГБ), проверяет SHA256 и кладёт результат в `dist/`:
```text
dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso
dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso.sha256
dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso
dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso.sha256
```
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
@@ -104,16 +98,18 @@ make iso ISO_ARGS="--web-port 8089 --no-auto-update --timezone Europe/Moscow"
Для массовой раскатки:
```bash
make iso ISO_ARGS="--unattended --username admin --password-hash '$(openssl passwd -6)' --hostname node01"
make iso ISO_ARGS="--unattended --username sysadmin --password-hash '$(openssl passwd -6)' --hostname node01"
```
Системные имена вроде `admin`, `root` или `kvm` установщик Ubuntu не принимает, и установка остановится на первом шаге. Поэтому `build-iso.sh` сразу отказывается собирать образ с таким `--username`. Полный список лежит в `image/reserved-usernames`, это копия списка из установщика Ubuntu.
> ⚠️ Такой образ сам уходит в установку через 10 секунд в меню и **без подтверждения стирает самый большой диск** машины. Файл получает суффикс `-unattended`, пункты меню — пометку «автоматически, диск будет стёрт». Не оставляйте такую флешку в серверах.
---
## Что спрашивает установщик
Меню загрузки: **Установить Virtuality 0.11.0** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
Меню загрузки: **Установить Virtuality 0.12.8** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
@@ -165,14 +161,14 @@ cat /var/log/virtuality/firstboot.log
Сверьте контрольную сумму скачанного или собранного образа:
```bash
sha256sum -c virtuality-0.11.0-ubuntu-26.04-amd64.iso.sha256
sha256sum -c virtuality-0.12.8-ubuntu-26.04-amd64.iso.sha256
```
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
```bash
xorriso -indev virtuality-0.11.0-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
xorriso -osirrox on -indev virtuality-0.11.0-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
xorriso -indev virtuality-0.12.8-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
xorriso -osirrox on -indev virtuality-0.12.8-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
```
---
@@ -186,7 +182,7 @@ xorriso -osirrox on -indev virtuality-0.11.0-ubuntu-26.04-amd64.iso -extract /au
- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
```bash
sudo dd if=dist/virtuality-0.11.0-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
sudo dd if=dist/virtuality-0.12.8-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
```
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
+1 -1
Просмотреть файл
@@ -59,7 +59,7 @@ VIRTUALITY_SKIP_REQUIREMENTS=1 # отключить проверки совс
Например, для тестового стенда с маленьким диском:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_MIN_VAR_FREE_MB=8192 bash
curl -fsSL https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | VIRTUALITY_MIN_VAR_FREE_MB=8192 bash
```
Установка с ISO эти пороги не применяет: диск и память проверяются мастером настройки, который покажет предупреждение, но не остановит установку.
+5 -2
Просмотреть файл
@@ -6,7 +6,7 @@ Virtuality — панель для одного домашнего сервер
**Установка и настройка**
- Установочный ISO на базе Ubuntu Server 26.04 LTS (amd64, arm64), сборка `make iso` и в GitHub Actions, полностью автоматический режим.
- Установочный ISO на базе Ubuntu Server 26.04 LTS (amd64, arm64), сборка `make iso`, полностью автоматический режим.
- Первая загрузка: панель ставится первой и показывает ход установки в браузере, при ошибке продолжает сама.
- Мастер настройки: часовой пояс, пароль, HTTPS, сеть машин (NAT или домашняя сеть с автооткатом), отдельный диск под машины, обновления.
- Страница «Настройки», команда `virtuality-ctl`, установка одной командой на Ubuntu 24.04 / 26.04 и Debian 13.
@@ -24,9 +24,12 @@ Virtuality — панель для одного домашнего сервер
- HTTPS для панели, стабильный канал обновлений и ранний доступ, откат, ежедневное обслуживание, отчёт для поддержки, архив настроек.
- Убрано: Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE, старые вспомогательные скрипты.
## Сделано в 0.12
- Резервные копии по расписанию: каждый день или раз в неделю, все машины или отмеченные, хранение последних N автокопий. Копии делает отдельная служба сервера, перезапуск панели их не прерывает.
## Планируется
- **Резервные копии по расписанию из панели** — сейчас копия по расписанию делается только командой в терминале; нужен график и хранение N последних копий.
- **Копии на внешний диск или сетевую папку** — сейчас копии лежат только в `/var/lib/virtuality/backups`.
- **Контейнеры LXC** — лёгкие Linux-контейнеры рядом с машинами, как в Proxmox.
- **Проброс устройств (PCI/USB passthrough)** — видеокарты, USB-ключи, диски целиком в машину.
Двоичные данные
Двоичный файл не отображается.

До

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

После

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

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

До

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

После

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

+3 -2
Просмотреть файл
@@ -21,7 +21,7 @@ UBUNTU_ISO=""
CACHE_DIR="${VIRTUALITY_ISO_CACHE:-${REPO_ROOT}/.cache/iso}"
OUTPUT_DIR="${REPO_ROOT}/dist"
GIT_REF="HEAD"
REPO_URL="https://github.com/viktor138irk/virtuality.git"
REPO_URL="https://git.stackworks.ru/viktor/virtuality.git"
WEB_PORT="8088"
AUTO_UPDATE="1"
UNATTENDED="0"
@@ -47,7 +47,7 @@ Options:
--ref GIT_REF Git ref of Virtuality to bundle (default: ${GIT_REF})
--repo-url URL Update remote configured on installed nodes (default: ${REPO_URL})
--web-port PORT Web panel port on installed nodes (default: ${WEB_PORT})
--no-auto-update Disable the nightly GitHub auto-update on installed nodes
--no-auto-update Disable the nightly auto-update on installed nodes
--locale LOCALE Installer/system locale (default: ${LOCALE})
--keyboard LAYOUT Keyboard layout (default: ${KEYBOARD})
--timezone TZ System timezone (default: ${TIMEZONE} — detected by the installer; the wizard can change it)
@@ -106,6 +106,7 @@ esac
[[ "$WEB_PORT" =~ ^[0-9]+$ ]] && (( WEB_PORT >= 1 && WEB_PORT <= 65535 )) || die "invalid --web-port: $WEB_PORT"
if [[ "$UNATTENDED" == "1" ]]; then
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--unattended needs a valid --username"
! grep -qxF "$USERNAME" "${IMAGE_DIR}/reserved-usernames" || die "--username ${USERNAME} is reserved by the Ubuntu installer (like admin, root, kvm); pick another name"
[[ "$PASSWORD_HASH" == \$* ]] || die "--unattended needs --password-hash (generate: openssl passwd -6)"
[[ "$HOSTNAME_VALUE" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}$ ]] || die "invalid --hostname"
fi
+1 -1
Просмотреть файл
@@ -1,6 +1,6 @@
[Unit]
Description=Virtuality first boot setup
Documentation=https://github.com/viktor138irk/virtuality/blob/main/docs/IMAGE.md
Documentation=https://git.stackworks.ru/viktor/virtuality/src/branch/main/docs/IMAGE.md
Wants=network-online.target
After=network-online.target
ConditionPathExists=!/var/lib/virtuality/.firstboot-done
+127
Просмотреть файл
@@ -0,0 +1,127 @@
# Имена, которые установщик Ubuntu (subiquity) не даёт выбрать для первого пользователя.
# Копия https://github.com/canonical/subiquity/blob/main/reserved-usernames (27.09.2026).
# build-iso.sh проверяет по ней --username, иначе автоматическая установка падает на первом шаге.
# Static users from base-passwd/passwd.master (3.5.41).
root
daemon
bin
sys
sync
games
man
lp
mail
news
uucp
proxy
www-data
backup
list
irc
gnats
nobody
# Other static groups from base-passwd/group.master (3.5.41).
adm
tty
disk
kmem
dialout
fax
voice
cdrom
floppy
tape
sudo
audio
dip
operator
src
shadow
utmp
video
sasl
plugdev
staff
users
nogroup
# Reserved usernames listed in base-passwd/README (3.5.41).
netplan
ftn
mysql
tac-plus
alias
qmail
qmaild
qmails
qmailr
qmailq
qmaill
qmailp
asterisk
vpopmail
vchkpw
slurm
hacluster
haclient
grsec-tpe
grsec-sock-all
grsec-sock-clt
grsec-sock-srv
grsec-proc
ceph
opensrf
libvirt-qemu
# Ubuntu creates the admin group and adds the first user to it in order to
# grant them sudo privileges.
admin
# Other miscellaneous system users/groups created by common packages. While
# it's useful to add things here that people might run into, it's not
# absolutely critical; the worst that will happen is that the installation
# will fail at some later point.
Debian-exim
bind
crontab
cupsys
dcc
dhcp
dictd
dnsmasq
dovecot
fetchmail
firebird
ftp
fuse
gdm
haldaemon
hplilp
identd
input
jwhois
klog
kvm
lpadmin
maas
messagebus
mythtv
netdev
powerdev
radvd
render
saned
sbuild
scanner
sgx
slocate
ssh
sshd
ssl-cert
sslwrap
statd
syslog
telnetd
tftpd
+2 -2
Просмотреть файл
@@ -6,7 +6,7 @@ set -euo pipefail
# Public install entrypoint for clean Ubuntu/Debian servers.
# ==========================================================
SELF_INSTALL_URL="${VIRTUALITY_INSTALL_URL:-https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh}"
SELF_INSTALL_URL="${VIRTUALITY_INSTALL_URL:-https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh}"
if [[ "${EUID}" -ne 0 ]]; then
if command -v sudo >/dev/null 2>&1; then
@@ -27,7 +27,7 @@ if [[ "${EUID}" -ne 0 ]]; then
exit 1
fi
REPO_URL="${VIRTUALITY_REPO_URL:-https://github.com/viktor138irk/virtuality.git}"
REPO_URL="${VIRTUALITY_REPO_URL:-https://git.stackworks.ru/viktor/virtuality.git}"
DEFAULT_INSTALL_USER="${SUDO_USER:-root}"
INSTALL_USER="${VIRTUALITY_USER:-$DEFAULT_INSTALL_USER}"
PROJECT_BASE_DIR="${VIRTUALITY_PROJECT_BASE_DIR:-/opt/virtuality}"
+9 -8
Просмотреть файл
@@ -10,11 +10,11 @@ BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}"
TMP_DIR="${STATE_DIR}/tmp"
# shellcheck source=scripts/update_target.sh
. "$(dirname "${BASH_SOURCE[0]}")/update_target.sh"
mkdir -p "$STATE_DIR" "$TMP_DIR" "$(dirname "$LOG_FILE")"
MOVED_REMOTE="$(migrate_update_remote)"
resolve_update_target
REPO_ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-$TARGET_ZIP_URL}"
mkdir -p "$STATE_DIR" "$TMP_DIR" "$(dirname "$LOG_FILE")"
now() { date '+%Y-%m-%d %H:%M:%S'; }
json_escape() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])'; }
write_state() {
@@ -58,7 +58,7 @@ fetch_zip_update() {
log "Fallback update via ZIP started"
log "ZIP URL: $REPO_ZIP_URL"
write_state "running" "GitHub git fetch не сработал, пробуем ZIP fallback"
write_state "running" "git fetch не сработал, пробуем скачать ZIP-архив"
if command -v curl >/dev/null 2>&1; then
if ! try_run curl -L --connect-timeout 20 --max-time 240 --retry 3 --retry-delay 3 -o "$zip_file" "$REPO_ZIP_URL"; then
@@ -111,7 +111,8 @@ fetch_zip_update() {
STARTED_AT="$(now)"
write_state "running" "Обновление запущено"
log "============================================================"
log "Virtuality GitHub update started"
log "Virtuality update started"
[ -n "$MOVED_REMOTE" ] && log "$MOVED_REMOTE"
log "source: $SOURCE_DIR"
log "remote: $REMOTE"
log "branch: $BRANCH"
@@ -129,7 +130,7 @@ log "before version: $BEFORE_VERSION"
GIT_UPDATED=0
if [ -d "$SOURCE_DIR/.git" ]; then
cd "$SOURCE_DIR" || exit 1
write_state "running" "Получаем изменения из GitHub через git"
write_state "running" "Получаем изменения через git"
if try_run git fetch --tags --force "$REMOTE" "$BRANCH"; then
resolve_update_target
log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}"
@@ -140,7 +141,7 @@ if [ -d "$SOURCE_DIR/.git" ]; then
log "Local changes before update:"
printf '%s\n' "$LOCAL_STATUS" >> "$LOG_FILE"
fi
write_state "running" "Синхронизируем исходники с GitHub через git reset"
write_state "running" "Синхронизируем исходники через git reset"
log "Deploy mode: git checkout -B ${BRANCH} ${TARGET_LABEL}"
if try_run git checkout -q -f -B "$BRANCH" "$TARGET_COMMIT" && try_run git clean -fd; then
GIT_UPDATED=1
@@ -159,7 +160,7 @@ fi
if [ "$GIT_UPDATED" != "1" ]; then
if ! fetch_zip_update; then
write_state "error" "Не удалось обновиться ни через git, ни через ZIP fallback. Проверь доступ к github.com, curl/wget/unzip и DNS."
write_state "error" "Не удалось обновиться ни через git, ни через ZIP fallback. Проверьте доступ сервера к ${REPO_ZIP_URL%%/archive/*}, curl/wget/unzip и DNS."
exit 1
fi
fi
@@ -180,6 +181,6 @@ run_step bash scripts/install_web_panel.sh
write_state "running" "Перезапускаем virtuality-web"
run_step systemctl restart virtuality-web
log "Virtuality GitHub update finished successfully"
log "Virtuality update finished successfully"
write_state "success" "Обновление завершено: ${BEFORE_VERSION} → ${AFTER_VERSION}"
exit 0
+9 -2
Просмотреть файл
@@ -35,10 +35,14 @@ JSON
}
diag_network() {
local web repo_host
web="$(repo_web_url)"
repo_host="${web#https://}"
repo_host="${repo_host%%/*}"
log "diagnostics: host=$(hostname 2>/dev/null || echo unknown)"
log "diagnostics: date=$(date -Is 2>/dev/null || date)"
if command -v getent >/dev/null 2>&1; then
getent hosts github.com >> "$LOG_FILE" 2>&1 || log "diagnostics: DNS github.com failed"
getent hosts "$repo_host" >> "$LOG_FILE" 2>&1 || log "diagnostics: DNS $repo_host failed"
fi
if command -v git >/dev/null 2>&1; then
git --version >> "$LOG_FILE" 2>&1 || true
@@ -47,7 +51,7 @@ diag_network() {
log "diagnostics: git not found"
fi
if command -v curl >/dev/null 2>&1; then
curl -I -L --connect-timeout 10 --max-time 25 https://github.com/ >> "$LOG_FILE" 2>&1 || log "diagnostics: curl github.com failed"
curl -I -L --connect-timeout 10 --max-time 25 "$web" >> "$LOG_FILE" 2>&1 || log "diagnostics: curl $repo_host failed"
else
log "diagnostics: curl not found"
fi
@@ -89,6 +93,9 @@ diag_network() {
current="unknown"
fi
moved="$(migrate_update_remote)"
[ -n "$moved" ] && log "$moved"
if [ -d "$SOURCE_DIR/.git" ]; then
if git fetch --quiet --tags --force "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then
resolve_update_target
+33 -3
Просмотреть файл
@@ -4,6 +4,10 @@
# main — every change on the main branch
# Needs SOURCE_DIR, REMOTE and BRANCH; sets UPDATE_CHANNEL, TARGET_REF, TARGET_LABEL, TARGET_ZIP_URL.
# Where the project lives. Nodes installed before the move from GitHub are switched over by migrate_update_remote.
DEFAULT_REPO_URL="https://git.stackworks.ru/viktor/virtuality.git"
LEGACY_REPO_URLS=("https://github.com/viktor138irk/virtuality.git" "https://github.com/viktor138irk/virtuality")
update_channel() {
local value="${VIRTUALITY_UPDATE_CHANNEL:-}"
if [[ -z "$value" && -f /var/lib/virtuality/config/web.env ]]; then
@@ -12,18 +16,44 @@ update_channel() {
[[ "$value" == "main" ]] && echo main || echo stable
}
remote_url() {
git -C "$SOURCE_DIR" remote get-url "$REMOTE" 2>/dev/null || true
}
migrate_update_remote() {
[[ -d "${SOURCE_DIR}/.git" ]] || return 0
local url legacy
url="$(remote_url)"
for legacy in "${LEGACY_REPO_URLS[@]}"; do
if [[ "$url" == "$legacy" ]]; then
git -C "$SOURCE_DIR" remote set-url "$REMOTE" "$DEFAULT_REPO_URL" && echo "update remote moved: $url -> $DEFAULT_REPO_URL"
return 0
fi
done
}
# Web address of the repository for ZIP downloads: the remote itself when it is https, otherwise the default.
# Gitea and GitHub both serve <repo>/archive/<branch or tag>.zip.
repo_web_url() {
local url=""
[[ -d "${SOURCE_DIR}/.git" ]] && url="$(remote_url)"
[[ "$url" == https://* ]] || url="$DEFAULT_REPO_URL"
echo "${url%.git}"
}
resolve_update_target() {
local tag=""
local tag="" web
web="$(repo_web_url)"
UPDATE_CHANNEL="$(update_channel)"
TARGET_REF="${REMOTE}/${BRANCH}"
TARGET_LABEL="${BRANCH}"
TARGET_ZIP_URL="https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip"
TARGET_ZIP_URL="${web}/archive/${BRANCH}.zip"
if [[ "$UPDATE_CHANNEL" == "stable" && -d "${SOURCE_DIR}/.git" ]]; then
tag="$(git -C "$SOURCE_DIR" tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+(\.[0-9]+)*$' | head -n1 || true)"
if [[ -n "$tag" ]]; then
TARGET_REF="refs/tags/${tag}"
TARGET_LABEL="$tag"
TARGET_ZIP_URL="https://github.com/viktor138irk/virtuality/archive/refs/tags/${tag}.zip"
TARGET_ZIP_URL="${web}/archive/${tag}.zip"
fi
fi
}
+35 -3
Просмотреть файл
@@ -235,6 +235,38 @@ RandomizedDelaySec=1h
Persistent=true
Unit=virtuality-maintenance.service
[Install]
WantedBy=timers.target
EOF
# Scheduled VM backups run in their own unit: a panel restart (nightly update) must not cut a copy short.
cat > "${UNIT_DIR}/virtuality-backup.service" <<EOF
[Unit]
Description=Virtuality scheduled VM backups
After=libvirtd.service
[Service]
Type=oneshot
TimeoutStartSec=infinity
User=root
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/features/backup_schedule.py
Environment=PYTHONUNBUFFERED=1
Environment=LC_ALL=C.UTF-8
Environment=VIRTUALITY_AUTH_USER=${auth_user}
Environment=VIRTUALITY_SOURCE_DIR=${SOURCE_DIR}
Environment=VIRTUALITY_NODE_ENV=${NODE_ENV}
Environment=TMPDIR=${DATA_DIR}/tmp
EOF
cat > "${UNIT_DIR}/virtuality-backup.timer" <<EOF
[Unit]
Description=Virtuality scheduled VM backups (checks the schedule every 15 minutes)
[Timer]
OnCalendar=*:0/15
AccuracySec=1min
Unit=virtuality-backup.service
[Install]
WantedBy=timers.target
EOF
@@ -265,7 +297,7 @@ EOF
fi
systemctl daemon-reload
systemctl enable virtuality-web.service virtuality-network.service virtuality-maintenance.timer >/dev/null 2>&1 || true
systemctl enable virtuality-web.service virtuality-network.service virtuality-maintenance.timer virtuality-backup.timer >/dev/null 2>&1 || true
systemctl enable libvirt-guests.service >/dev/null 2>&1 || true
if [[ "$tls" == "1" ]]; then
systemctl enable virtuality-redirect.service >/dev/null 2>&1 || true
@@ -305,7 +337,7 @@ restart_panel() {
if [[ "$(env_get VIRTUALITY_TLS 0)" == "1" ]]; then
systemctl restart virtuality-redirect.service || true
fi
systemctl enable --now virtuality-maintenance.timer >/dev/null 2>&1 || true
systemctl enable --now virtuality-maintenance.timer virtuality-backup.timer >/dev/null 2>&1 || true
if [[ "$(env_get VIRTUALITY_AUTO_UPDATE 1)" == "1" ]]; then
systemctl enable --now virtuality-auto-update.timer >/dev/null 2>&1 || true
fi
@@ -689,7 +721,7 @@ cmd_uninstall() {
[[ "$answer" == "YES" ]] || { echo "Отменено."; exit 0; }
fi
local unit
for unit in virtuality-web.service virtuality-redirect.service virtuality-network.service virtuality-auto-update.timer virtuality-auto-update.service virtuality-maintenance.timer virtuality-maintenance.service virtuality-console-dashboard.service virtuality-firstboot.service; do
for unit in virtuality-web.service virtuality-redirect.service virtuality-network.service virtuality-auto-update.timer virtuality-auto-update.service virtuality-maintenance.timer virtuality-maintenance.service virtuality-backup.timer virtuality-backup.service virtuality-console-dashboard.service virtuality-firstboot.service; do
systemctl disable --now "$unit" >/dev/null 2>&1 || true
rm -f "${UNIT_DIR}/${unit}"
done
+1
Просмотреть файл
@@ -171,6 +171,7 @@ def data_dirs(tmp_path, monkeypatch):
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
monkeypatch.setattr(app, "images_free_bytes", lambda: 500 * 1024 ** 3) # временная папка тестов бывает маленькой (tmpfs)
for module in (app, core, network_core, host_profile, *feature_modules()):
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
return dirs
+10 -5
Просмотреть файл
@@ -106,11 +106,11 @@ def demo_run_cmd(cmd, timeout=12, **kwargs):
def seed_backups(backups_dir: Path) -> None:
"""Несколько копий, как после пары недель работы: web01 — три, db01 — одна."""
"""Несколько копий, как после пары недель работы: web01 — три (две по расписанию), db01 — одна."""
vm_xml = conftest.DUMPXML_MIGRATABLE
demo = [
("web01", "20260924-0300", 2_684_354_560, "", [("vda", 6_443_499_520)]),
("web01", "20260917-0300", 2_598_000_000, "", [("vda", 6_300_000_000)]),
("web01", "20260924-0300", 2_684_354_560, "По расписанию", [("vda", 6_443_499_520)]),
("web01", "20260917-0300", 2_598_000_000, "По расписанию", [("vda", 6_300_000_000)]),
("web01", "20260912-1842", 2_412_000_000, "Перед обновлением сайта", [("vda", 6_100_000_000)]),
("db01", "20260921-2215", 9_126_805_504, "После переноса базы", [("vda", 12_884_901_888), ("vdb", 8_589_934_592)]),
]
@@ -121,7 +121,7 @@ def seed_backups(backups_dir: Path) -> None:
for target, _ in disks:
with (path / f"{target}.qcow2").open("wb") as handle:
handle.truncate(1024 * 1024)
meta = {"vm": vm, "created_at": f"{backup_id[:4]}-{backup_id[4:6]}-{backup_id[6:8]} {backup_id[9:11]}:{backup_id[11:]}:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": s} for t, s in disks], "size_bytes": size, "version": "0.11.0", "note": note}
meta = {"vm": vm, "created_at": f"{backup_id[:4]}-{backup_id[4:6]}-{backup_id[6:8]} {backup_id[9:11]}:{backup_id[11:]}:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": s} for t, s in disks], "size_bytes": size, "version": "0.11.0", "note": note, "auto": note == "По расписанию"}
(path / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2))
@@ -133,6 +133,11 @@ def seed(root: Path) -> None:
core.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR, core.STORAGE_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"], root
core.BACKUPS_DIR, core.CONFIG_DIR = dirs["backups"], dirs["config"]
seed_backups(dirs["backups"])
from features import backup_schedule
backup_schedule.write_json(backup_schedule.schedule_path(), {**backup_schedule.DEFAULT, "enabled": True, "frequency": "weekly", "weekday": 3})
results = [{"vm": "db01", "ok": True}, {"vm": "web01", "ok": True, "removed": []}, {"vm": "win11", "ok": True}]
backup_schedule.write_json(backup_schedule.state_path(), {"slot": "2026-09-24 03:00", "status": "done", "started_at": "2026-09-24 03:00:04", "finished_at": "2026-09-24 03:41:10", "results": results})
for name in DEMO_VMS: # диски машин лежат в хранилище, иначе форма увеличения не появится
with (dirs["images"] / f"{name}.qcow2").open("wb") as handle:
handle.truncate(4 * 1024 * 1024)
@@ -217,5 +222,5 @@ if __name__ == "__main__":
seed(Path(tempfile.mkdtemp(prefix="virtuality-dev-")))
# The demo operations are seeded as "running": keep them instead of closing them as orphaned on startup.
app.app.router.on_startup.clear()
app.run_startup_tasks = lambda: None
uvicorn.run(app.app, host="127.0.0.1", port=int(sys.argv[1]) if len(sys.argv) > 1 else 8765, log_level="warning")
+90 -2
Просмотреть файл
@@ -1,3 +1,5 @@
import uuid
from pathlib import Path
from urllib.parse import unquote
import pytest
@@ -100,7 +102,7 @@ def test_dashboard_uses_plain_language(logged_in):
def test_vm_create_preselects_image(logged_in, data_dirs):
(data_dirs["iso"] / "debian.iso").write_bytes(b"iso")
html = logged_in.get("/vm/create?iso=debian.iso").text
assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" selected' in html
assert f'value="{data_dirs["iso"] / "debian.iso"}" data-label="debian.iso" data-os="" data-login="" checked' in html
def test_vm_create_disk_image_without_disk_size(logged_in, data_dirs, monkeypatch):
@@ -140,7 +142,10 @@ def test_vm_create_iso_builds_virt_install(logged_in, data_dirs, monkeypatch):
assert cmd[0] == "virt-install"
assert "--cdrom" in cmd and str(iso) in cmd
assert any(part.startswith("network=virtuality-nat,model=virtio,mac=52:54:00:") for part in cmd)
assert captured["operation"]["boot_order"] == "cdrom_disk"
assert captured["operation"]["boot_order"] == "disk_cdrom"
# После установки гость перезагружается с диска: без особого режима установки и с диском первым в очереди.
assert cmd[cmd.index("--boot") + 1] == "hd,cdrom"
assert cmd[cmd.index("--install") + 1] == "no_install=yes"
def test_vm_create_validation_error(logged_in):
@@ -186,3 +191,86 @@ def test_live_status(logged_in):
data = logged_in.get("/live/status").json()
assert data["ok"] is True
assert {vm["name"] for vm in data["vms"]} == {"web01", "db01"}
def test_uploader_collects_file_before_disabling_input():
"""Заблокированное поле браузер не отправляет: загрузка уходила пустой (44 байта) и получала 422."""
script = (Path(__file__).resolve().parents[1] / "web" / "static" / "panel.js").read_text()
uploader = script[script.index("function wireUploader"):script.index("/* ---------------------------------------------------- operation page */")]
assert "input.disabled = active" in uploader
assert uploader.index("new FormData(form)") < uploader.index("setActive(true)")
assert "xhr.send(data)" in uploader
def test_vm_create_disk_bigger_than_free_space_needs_confirmation(logged_in, data_dirs, monkeypatch):
"""virt-install отказывал с английской ошибкой; теперь панель предупреждает сама и создаёт только с подтверждением."""
iso = data_dirs["iso"] / "smk.iso"
iso.write_bytes(b"iso")
started = []
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: started.append(cmd))
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
monkeypatch.setattr(app, "images_free_bytes", lambda: 25 * 1024 ** 3)
form = {"name": "smk", "memory": 4096, "vcpus": 2, "disk_size": 40, "iso_path": str(iso), "source_type": "iso", "network_mode": "nat"}
page = logged_in.get("/vm/create").text
assert "Свободно на сервере: 25" in page and 'name="allow_overcommit"' not in page
response = logged_in.post("/vm/create", data=form)
assert response.status_code == 400 and started == []
assert "Диск на 40 ГБ больше, чем свободно на сервере" in response.text and 'name="allow_overcommit"' in response.text
response = logged_in.post("/vm/create", data={**form, "allow_overcommit": "1"}, follow_redirects=False)
assert response.status_code == 303, response.text[:300]
assert started[0][-2:] == ["--check", "disk_size=off"]
response = logged_in.post("/vm/create", data={**form, "name": "small", "disk_size": 20}, follow_redirects=False)
assert response.status_code == 303 and "--check" not in started[1]
def failed_create(data_dirs, name, disk=True, **extra):
"""Операция создания машины, у которой virt-install завершился с ошибкой."""
disk_path = data_dirs["images"] / f"{name}.qcow2"
if disk:
disk_path.write_bytes(b"QFI\xfb")
operation = {"id": str(uuid.uuid4()), "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "", "created_at": app.utc_now(), "updated_at": app.utc_now(), "vm_name": name, "disk_path": str(disk_path), "network_mode": "bridge", **extra}
app.write_operation(operation)
app.run_operation_worker(operation["id"], ["bash", "-c", "echo 'ERROR The requested volume capacity will exceed the available pool space'; exit 1"])
return app.read_operation(operation["id"]), disk_path
def test_failed_vm_create_cleans_up_and_offers_retry(logged_in, data_dirs):
"""Страница упавшей задачи предлагала «Открыть экран машины», хотя машины нет; недосозданный диск мешал повтору."""
operation, disk_path = failed_create(data_dirs, "smk", iso_path=str(data_dirs["iso"] / "smk-server-_1.iso"))
assert operation["status"] == "error" and "ничего не осталось" in operation["message"]
assert not disk_path.exists() and "Недосозданный диск smk.qcow2 удалён" in operation["log_tail"]
html = logged_in.get(f"/operations/{operation['id']}").text
assert 'data-op-done="success" hidden' in html
assert 'href="/vm/create?iso=smk-server-_1.iso"' in html and "Создать заново" in html
def test_failed_create_of_existing_vm_keeps_its_disk(logged_in, data_dirs):
operation, disk_path = failed_create(data_dirs, "web01") # машина определена в libvirt: её диск не трогаем
assert disk_path.exists() and "ничего не осталось" not in operation["message"]
html = logged_in.get(f"/operations/{operation['id']}").text
assert "Создать заново" not in html and 'href="/vm/web01">Перейти к машине' in html
def test_operation_page_reveals_only_buttons_for_its_result():
script = (Path(__file__).resolve().parents[1] / "web" / "static" / "panel.js").read_text()
assert '$$(`[data-op-done="${status}"]`)' in script and "$$('[data-op-done]')" not in script
def test_console_websocket_path_works_with_old_and_new_novnc(tmp_path, monkeypatch):
"""noVNC 1.6 (Ubuntu 26.04) считает path от /novnc/vnc.html: без "../" экран стучался в /novnc/console/ws/… и не подключался."""
import posixpath
from urllib.parse import parse_qs, urljoin, urlsplit
(tmp_path / "vnc.html").write_text("<html></html>")
monkeypatch.setattr(app, "NOVNC_DIR", tmp_path)
monkeypatch.setattr(app, "vm_vnc_display", lambda name: "127.0.0.1:0")
info = app.console_info("web01")
page = "https://panel.test:8443" + info["url"]
path = parse_qs(urlsplit(page).query)["path"][0]
token = path.rsplit("/", 1)[1]
assert app.console_serializer.loads(token) == {"vm": "web01", "port": 5900}
assert urlsplit(urljoin(page, path)).path == f"/console/ws/{token}" # noVNC 1.6: new URL(path, location.href)
assert posixpath.normpath("/" + path) == f"/console/ws/{token}" # noVNC 1.3: "wss://host:port/" + path
+108 -4
Просмотреть файл
@@ -1,7 +1,7 @@
"""Regressions for the defects found by the 0.11 audit."""
import json
from pathlib import Path
from urllib.parse import quote
from urllib.parse import quote, unquote
import app
import conftest
@@ -55,20 +55,88 @@ def test_vm_action_success_redirects_without_error(logged_in, data_dirs, monkeyp
def test_vm_delete_undefines_uefi_machines(logged_in, data_dirs, monkeypatch):
import core
calls = recording(monkeypatch, app)
core_calls = recording(monkeypatch, core)
disk = data_dirs["images"] / "web01.qcow2"
disk.write_bytes(b"QFI\xfb")
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
assert response.headers["location"].startswith("/?message=")
assert find(calls, "virsh", "destroy", "web01")
undefine = find(calls, "virsh", "undefine", "web01")[0]
assert "--nvram" in undefine and "--remove-all-storage" in undefine and "--snapshots-metadata" in undefine
undefine = find(core_calls, "virsh", "undefine", "web01")[0]
assert "--nvram" in undefine and "--snapshots-metadata" in undefine
assert not disk.exists() and find(core_calls, "virsh", "pool-refresh", "virtuality-images")
def test_vm_delete_keeps_installer_iso_and_foreign_disks(logged_in, data_dirs, monkeypatch):
"""--remove-all-storage удалял вставленный ISO: загруженные образы пропадали вместе с машиной."""
import core
iso = data_dirs["iso"] / "smk-server-_1.iso"
iso.write_bytes(b"ISO")
disk = data_dirs["images"] / "web01.qcow2"
disk.write_bytes(b"QFI\xfb")
foreign = data_dirs["disk_images"] / "shared.raw"
foreign.write_bytes(b"raw")
listing = f" Type Device Target Source\n----\n file disk vda {disk}\n file disk vdb {foreign}\n file cdrom sda {iso}\n"
recording(monkeypatch, app)
core_calls = recording(monkeypatch, core, [(["virsh", "domblklist"], conftest.fake_result(listing))])
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
assert not disk.exists() and iso.exists() and foreign.exists()
assert "--remove-all-storage" not in find(core_calls, "virsh", "undefine", "web01")[0]
assert str(foreign) in unquote(response.headers["location"])
def test_vm_delete_failure_is_reported(logged_in, data_dirs, monkeypatch):
recording(monkeypatch, app, [(["virsh", "undefine"], conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: cannot undefine domain with nvram"))])
import core
recording(monkeypatch, app)
recording(monkeypatch, core, [(["virsh", "undefine"], conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: cannot undefine domain with nvram"))])
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
assert response.headers["location"].startswith("/vm/web01?error=")
def test_vm_delete_stops_paused_machine_first(logged_in, data_dirs, monkeypatch):
import core
calls = recording(monkeypatch, app)
core_calls = recording(monkeypatch, core, [(["virsh", "domstate"], conftest.fake_result("paused"))])
logged_in.post("/vm/web01/delete", follow_redirects=False)
assert find(calls, "virsh", "destroy", "web01") and find(core_calls, "virsh", "undefine", "web01")
def test_vm_delete_waits_for_running_task(logged_in, data_dirs, monkeypatch):
import core
calls = recording(monkeypatch, app)
backup = core.new_operation("backup", "Резервная копия web01", vm_name="web01")
core.update_operation(backup, status="running")
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
assert "Резервная копия web01" in unquote(response.headers["location"])
assert not find(calls, "virsh", "undefine") and not find(calls, "virsh", "destroy")
def test_vm_delete_removes_its_port_forwards(logged_in, data_dirs, monkeypatch):
recording(monkeypatch, app)
rule = {"guest_ip": "192.168.100.51", "external_port_start": 2222, "external_port_end": 2222, "guest_port_start": 22, "guest_port_end": 22, "protocol": "tcp"}
data_dirs["network"].joinpath("port_forwards.json").write_text(json.dumps([
{"id": "1", "vm_name": "web01", **rule},
{"id": "2", "vm_name": "db01", **rule, "external_port_start": 2223, "external_port_end": 2223, "guest_ip": "192.168.100.52"},
]))
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
assert "убраны пробросы портов: 1" in unquote(response.headers["location"])
assert [item["vm_name"] for item in network_core.load_port_forwards()] == ["db01"]
assert "2222" not in (data_dirs["nft"] / "virtuality.nft").read_text()
def test_delete_is_offered_for_running_machine(logged_in):
html = logged_in.get("/vm/web01").text # в тестах web01 работает
assert 'action="/vm/web01/delete"' in html and "она будет сразу выключена" in html
dashboard = logged_in.get("/").text
assert 'action="/vm/web01/delete"' in dashboard and 'action="/vm/db01/delete"' in dashboard
# ---------------------------------------------------------------- ISO in the CD-ROM drive (app.py:1150, 1158)
def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch):
iso = data_dirs["iso"] / "debian.iso"
@@ -139,6 +207,42 @@ def test_orphaned_operations_are_closed_on_startup(data_dirs):
assert core.active_operations_for("web01") == []
def test_panel_startup_closes_orphaned_operations(data_dirs):
"""Стартовые задачи панели запускает lifespan (FastAPI on_event устарел)."""
import core
from fastapi.testclient import TestClient
running = core.new_operation("snapshot_create", "Снимок web01", vm_name="web01")
core.update_operation(running, status="running")
with TestClient(app.app, base_url="http://panel.test"):
assert core.read_operation(running["id"])["interrupted"] is True
def test_interrupt_leaves_operations_of_other_live_processes(data_dirs):
"""Копии по расписанию идут в отдельной службе: перезапуск панели не должен объявлять их прерванными."""
import subprocess
import core
sleeper = subprocess.Popen(["sleep", "30"])
finished = subprocess.Popen(["sleep", "0.1"])
finished_owner = core.process_owner(finished.pid)
finished.wait()
try:
scheduled = core.new_operation("backup", "Резервная копия web01 по расписанию", vm_name="web01")
core.update_operation(scheduled, status="running", owner=core.process_owner(sleeper.pid))
dead = core.new_operation("backup", "Резервная копия db01", vm_name="db01")
core.update_operation(dead, status="running", owner=finished_owner)
legacy = core.new_operation("backup", "Задача старой версии без владельца", vm_name="db01")
core.update_operation(legacy, status="running", owner=None)
assert core.interrupt_orphaned_operations() == 2
assert core.read_operation(scheduled["id"])["status"] == "running"
assert core.read_operation(dead["id"])["interrupted"] and core.read_operation(legacy["id"])["interrupted"]
finally:
sleeper.kill()
sleeper.wait()
def test_snapshot_names_with_spaces_are_listed():
from features import snapshots
+209
Просмотреть файл
@@ -0,0 +1,209 @@
"""Резервные копии по расписанию: когда запускать, какие машины копировать, сколько хранить, форма и страницы."""
import fcntl
import json
from datetime import datetime
from urllib.parse import unquote
import pytest
import core
from features import backup_schedule as bs
from features import backups
from test_backups import make_backup
def schedule(**changes):
return bs.clean_schedule({**bs.DEFAULT, "enabled": True, **changes})
# ---------------------------------------------------------------- настройка и время
def test_clean_schedule_replaces_broken_values():
assert bs.clean_schedule({}) == bs.DEFAULT
broken = {"enabled": "yes", "frequency": "hourly", "weekday": True, "time": "25:00", "vms": ["web01", "bad name", 5, "web01"], "keep": 0}
assert bs.clean_schedule(broken) == {**bs.DEFAULT, "vms": ["web01"]}
assert bs.clean_schedule({"all_vms": False, "keep": 61})["all_vms"] is False
assert bs.clean_schedule({"keep": 61})["keep"] == bs.DEFAULT["keep"]
def test_slots_daily():
daily = schedule(time="03:00")
assert bs.last_slot(daily, datetime(2026, 9, 27, 2, 59)) == datetime(2026, 9, 26, 3, 0)
assert bs.last_slot(daily, datetime(2026, 9, 27, 3, 0)) == datetime(2026, 9, 27, 3, 0)
assert bs.next_slot(daily, datetime(2026, 9, 27, 3, 0)) == datetime(2026, 9, 28, 3, 0)
def test_slots_weekly():
sunday = schedule(frequency="weekly", weekday=6, time="04:30") # 27.09.2026 — воскресенье
assert bs.last_slot(sunday, datetime(2026, 9, 30, 12, 0)) == datetime(2026, 9, 27, 4, 30)
assert bs.last_slot(sunday, datetime(2026, 9, 27, 4, 0)) == datetime(2026, 9, 20, 4, 30)
assert bs.next_slot(sunday, datetime(2026, 9, 27, 4, 0)) == datetime(2026, 9, 27, 4, 30)
def test_due_runs_once_and_does_not_catch_up_hours_later():
daily = schedule(time="03:00")
assert bs.due(daily, {}, datetime(2026, 9, 27, 3, 10)) == ("run", datetime(2026, 9, 27, 3, 0))
assert bs.due(daily, {"slot": "2026-09-27 03:00"}, datetime(2026, 9, 27, 3, 25)) is None
assert bs.due(daily, {}, datetime(2026, 9, 27, 5, 0)) == ("run", datetime(2026, 9, 27, 3, 0))
assert bs.due(daily, {}, datetime(2026, 9, 27, 5, 1)) == ("missed", datetime(2026, 9, 27, 3, 0))
assert bs.due({**daily, "enabled": False}, {}, datetime(2026, 9, 27, 3, 10)) is None
# ---------------------------------------------------------------- запуск
@pytest.fixture()
def fake_copies(data_dirs, monkeypatch):
"""create_backup сразу кладёт готовую копию; для машин из failing — отказ с текстом."""
calls = []
failing = {}
minutes = iter(range(1, 60))
def create(vm, note="", wait=False, auto=False):
calls.append((vm, note, wait, auto))
if vm in failing:
raise backups.BackupError(failing[vm])
backup_id = f"20260927-03{next(minutes):02d}"
make_backup(data_dirs, vm=vm, backup_id=backup_id, note=note, auto=auto)
return {"id": f"op-{backup_id}", "status": "success", "message": "Резервная копия создана: 2.5 ГБ"}
monkeypatch.setattr(backups, "create_backup", create)
monkeypatch.setattr(backups, "existing_vm_names", lambda: {"web01", "db01", "test"})
create.calls, create.failing = calls, failing
return create
def test_run_copies_every_machine_and_keeps_last_n_auto_copies(data_dirs, fake_copies):
bs.write_json(bs.schedule_path(), schedule(time="03:00", keep=2))
for backup_id in ("20260920-0300", "20260921-0300", "20260922-0300"):
make_backup(data_dirs, vm="web01", backup_id=backup_id, auto=True)
make_backup(data_dirs, vm="web01", backup_id="20260901-1200", note="перед обновлением")
state = bs.run_due(datetime(2026, 9, 27, 3, 5))
assert [call[0] for call in fake_copies.calls] == ["db01", "test", "web01"]
assert {call[1:] for call in fake_copies.calls} == {("По расписанию", True, True)}
assert state["status"] == "done" and state["slot"] == "2026-09-27 03:00" and all(item["ok"] for item in state["results"])
web01 = state["results"][-1]
assert web01["removed"] == ["20260921-0300", "20260920-0300"]
# Ручная копия осталась, из автоматических — две последние.
assert [item["id"] for item in backups.list_backups("web01")] == ["20260927-0303", "20260922-0300", "20260901-1200"]
assert bs.read_json(bs.state_path()) == state
assert bs.run_due(datetime(2026, 9, 27, 3, 20)) is None # этот момент уже отработан
assert len(fake_copies.calls) == 3
def test_failed_copy_keeps_old_copies_and_others_continue(data_dirs, fake_copies):
bs.write_json(bs.schedule_path(), schedule(all_vms=False, vms=["web01", "db01", "gone"], keep=1))
make_backup(data_dirs, vm="web01", backup_id="20260920-0300", auto=True)
make_backup(data_dirs, vm="web01", backup_id="20260921-0300", auto=True)
fake_copies.failing["web01"] = "Недостаточно места для копии"
state = bs.run_due(datetime(2026, 9, 27, 3, 5))
assert [call[0] for call in fake_copies.calls] == ["db01", "web01"] # машины gone на сервере нет
results = {item["vm"]: item for item in state["results"]}
assert results["db01"]["ok"] and not results["web01"]["ok"] and "места" in results["web01"]["message"]
assert len(backups.list_backups("web01")) == 2
def test_missed_slot_is_recorded_not_copied(data_dirs, fake_copies):
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
assert bs.run_due(datetime(2026, 9, 27, 9, 0)) is None
assert fake_copies.calls == []
state = bs.read_json(bs.state_path())
assert state["slot"] == state["missed"] == "2026-09-27 03:00"
assert bs.overview(datetime(2026, 9, 27, 9, 0))["missed"] == "27 сентября, 03:00"
def test_run_waits_for_previous_run(data_dirs, fake_copies):
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
with bs.lock_path().open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
assert bs.run_due(datetime(2026, 9, 27, 3, 5)) is None
assert fake_copies.calls == []
assert bs.run_due(datetime(2026, 9, 27, 3, 20))["status"] == "done"
def test_prune_leaves_copy_that_is_being_restored(data_dirs):
make_backup(data_dirs, vm="web01", backup_id="20260920-0300", auto=True)
make_backup(data_dirs, vm="web01", backup_id="20260921-0300", auto=True)
core.new_operation("restore", "Восстановление web01", vm_name="web01-copy", source_vm="web01", backup_id="20260920-0300")
assert bs.prune("web01", 1) == []
assert len(backups.list_backups("web01")) == 2
# ---------------------------------------------------------------- форма и страницы
FORM = {"enabled": "1", "frequency": "daily", "weekday": "6", "time": "03:00", "machines": "all", "keep": "7"}
def test_schedule_form_saves_and_does_not_start_right_away(logged_in, data_dirs, monkeypatch):
monkeypatch.setattr(backups, "existing_vm_names", lambda: {"web01", "db01"})
page = logged_in.get("/backups/schedule")
assert page.status_code == 200 and "Расписание копий" in page.text and 'value="db01"' in page.text
data = {**FORM, "frequency": "weekly", "time": "04:30", "machines": "chosen", "vms": ["web01"], "keep": "4"}
response = logged_in.post("/backups/schedule", data=data, follow_redirects=False)
assert response.status_code == 303 and "по воскресеньям в 04:30" in unquote(response.headers["location"])
saved = bs.load_schedule()
assert saved == {"enabled": True, "frequency": "weekly", "weekday": 6, "time": "04:30", "all_vms": False, "vms": ["web01"], "keep": 4}
assert bs.due(saved, bs.read_json(bs.state_path()), datetime.now()) is None # прошедший момент не догоняется
response = logged_in.post("/backups/schedule", data={**FORM, "enabled": ""}, follow_redirects=False)
assert "выключены" in unquote(response.headers["location"]) and bs.load_schedule()["enabled"] is False
def test_saving_same_time_keeps_pending_copy(logged_in, data_dirs):
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
bs.write_json(bs.state_path(), {"slot": "2026-09-20 03:00"})
logged_in.post("/backups/schedule", data={**FORM, "keep": "3"}, follow_redirects=False)
assert bs.load_schedule()["keep"] == 3
assert bs.read_json(bs.state_path())["slot"] == "2026-09-20 03:00"
@pytest.mark.parametrize("changes, error", [
({"time": "3 часа"}, "ЧЧ:ММ"),
({"keep": "0"}, "от 1 до 60"),
({"keep": "много"}, "от 1 до 60"),
({"machines": "chosen"}, "хотя бы одну машину"),
])
def test_schedule_form_errors(logged_in, data_dirs, changes, error):
response = logged_in.post("/backups/schedule", data={**FORM, **changes})
assert response.status_code == 400 and error in response.text
assert not bs.schedule_path().exists()
def test_schedule_requires_login(client):
assert client.get("/backups/schedule", follow_redirects=False).status_code in (302, 303)
assert client.post("/backups/schedule", data=FORM, follow_redirects=False).status_code in (302, 303, 403)
assert not bs.schedule_path().exists()
def test_pages_show_schedule_and_last_run(logged_in, data_dirs):
page = logged_in.get("/backups")
assert "Автоматические копии" in page.text and "Настроить" in page.text
bs.write_json(bs.schedule_path(), schedule(time="03:00", keep=5))
results = [{"vm": "web01", "ok": True}, {"vm": "db01", "ok": False, "message": "Недостаточно места для копии"}]
bs.write_json(bs.state_path(), {"slot": "2026-09-27 03:00", "status": "done", "started_at": "2026-09-27 03:00:05", "results": results})
page = logged_in.get("/backups")
assert "Каждый день в 03:00" in page.text and "хранить 5 последних копий" in page.text
assert "скопировано 1 из 2 машин" in page.text and "Недостаточно места для копии" in page.text
assert "Машина копируется каждый день в 03:00" in logged_in.get("/vm/web01/backups").text
bs.write_json(bs.schedule_path(), schedule(all_vms=False, vms=["db01"]))
assert "не копируется автоматически" in logged_in.get("/vm/web01/backups").text
def test_main_prints_summary(data_dirs, fake_copies, monkeypatch, capsys):
bs.write_json(bs.schedule_path(), schedule(time="03:00"))
fake_copies.failing["test"] = "Машина не выключилась"
class Night(datetime):
@classmethod
def now(cls, tz=None):
return cls(2026, 9, 27, 3, 5)
monkeypatch.setattr(bs, "datetime", Night)
bs.main()
out = capsys.readouterr().out
assert "2 из 3 успешно" in out and "test: Машина не выключилась" in out
assert json.loads(bs.state_path().read_text())["status"] == "done"
bs.main() # второй запуск в тот же момент молчит
assert capsys.readouterr().out == ""
+35 -8
Просмотреть файл
@@ -12,14 +12,14 @@ from conftest import DOMBLKLIST_DETAILS, DUMPXML_MIGRATABLE, QEMU_IMG_INFO, fake
FIXTURES = Path(__file__).resolve().parent / "fixtures"
def make_backup(dirs, vm="web01", backup_id="20260924-0300", size=2_684_354_560, note="", disks=("vda",), complete=True) -> Path:
def make_backup(dirs, vm="web01", backup_id="20260924-0300", size=2_684_354_560, note="", disks=("vda",), complete=True, auto=False) -> Path:
path = dirs["backups"] / vm / backup_id
path.mkdir(parents=True)
(path / "vm.xml").write_text(DUMPXML_MIGRATABLE)
for target in disks:
if complete:
(path / f"{target}.qcow2").write_bytes(b"QFI\xfb" + b"\0" * 60)
meta = {"vm": vm, "created_at": "2026-09-24 03:00:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": 6_443_499_520} for t in disks], "size_bytes": size, "version": "0.11.0", "note": note}
meta = {"vm": vm, "created_at": "2026-09-24 03:00:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": 6_443_499_520} for t in disks], "size_bytes": size, "version": "0.11.0", "note": note, "auto": auto}
(path / "meta.json").write_text(json.dumps(meta))
return path
@@ -291,6 +291,16 @@ def test_create_backup_wait_returns_finished_operation(fast_worker, monkeypatch)
assert operation["status"] == "success" and operation["type"] == "backup"
def test_scheduled_backup_is_marked_auto(fast_worker, monkeypatch):
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
operation = backups.create_backup("db01", "По расписанию", wait=True, auto=True)
assert operation["status"] == "success" and operation["title"] == "Резервная копия db01 по расписанию"
assert backups.list_backups("db01")[0]["auto"] is True
backups.create_backup("web01", wait=True)
assert backups.list_backups("web01")[0]["auto"] is False
def test_backup_fails_when_vm_does_not_shut_down(fast_worker, monkeypatch):
virsh = FakeVirsh(running=True, shuts_down=False)
monkeypatch.setattr(core, "run_cmd", virsh)
@@ -410,7 +420,7 @@ def test_restore_requires_replace_confirmation(fast_worker):
assert exc.value.code == "exists"
UNDEFINE = ["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"]
UNDEFINE = ["virsh", "undefine", "web01", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"]
def test_restore_replaces_existing_vm_only_after_disks_are_ready(fast_worker, monkeypatch):
@@ -454,23 +464,23 @@ def test_restore_refuses_when_space_is_tight_even_if_old_vm_would_free_it(fast_w
assert exc.value.code == "space" and "рядом со старой машиной" not in str(exc.value)
def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch):
def test_restore_keeps_inserted_iso(fast_worker, monkeypatch):
make_backup(fast_worker)
iso = fast_worker["iso"] / "debian.iso"
iso.write_bytes(b"ISO")
virsh = FakeVirsh(running=False)
base = virsh.__call__
def with_iso(cmd, timeout=12, **kw):
if cmd[:2] == ["virsh", "domblklist"]:
virsh.calls.append(list(cmd))
return fake_result(DOMBLKLIST_DETAILS.replace("sda -", "sda /var/lib/virtuality/iso/debian.iso"))
return fake_result(DOMBLKLIST_DETAILS.replace("/var/lib/virtuality/images", str(core.IMAGES_DIR)).replace("sda -", f"sda {iso}"))
return base(cmd, timeout, **kw)
monkeypatch.setattr(core, "run_cmd", with_iso)
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
backups.start_restore("web01", "20260924-0300", "web01", True)
eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"]
assert eject in virsh.calls
assert virsh.calls.index(eject) < virsh.calls.index(UNDEFINE)
assert UNDEFINE in virsh.calls and iso.exists()
def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker):
@@ -643,3 +653,20 @@ def test_late_restart_watch_is_cancelled_by_user_action(fast_worker, monkeypatch
watchers[0].join(timeout=5)
assert not watchers[0].is_alive() and ["virsh", "start", "web01"] not in virsh.calls
assert "Наблюдение снято" in core.read_operation(operation["id"])["log_tail"]
def test_late_restart_rechecks_cancel_after_shutdown(fast_worker, monkeypatch):
virsh = FakeVirsh(running=True, shuts_down=False)
monkeypatch.setattr(core, "run_cmd", virsh)
monkeypatch.setattr(backups, "LATE_SHUTDOWN_WATCH", 5.0)
operation = core.new_operation("backup", "Резервная копия web01", vm_name="web01")
def shut_down_by_user(vm):
backups.cancel_late_restart(vm) # «Выключить» нажали, пока наблюдатель спрашивал состояние машины
return False
monkeypatch.setattr(core, "vm_is_running", shut_down_by_user)
backups.restart_after_late_shutdown(operation, "web01")
assert ["virsh", "start", "web01"] not in virsh.calls
assert "Наблюдение снято" in core.read_operation(operation["id"])["log_tail"]
assert "web01" not in backups.LATE_WATCHES
+64
Просмотреть файл
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest
import network_core
from conftest import ROOT
import restore_network
import update_core
@@ -104,6 +105,59 @@ def test_update_not_offered_when_local_is_newer(tmp_path, monkeypatch, data_dirs
assert update_core.check_updates(fetch=True)["has_update"] is True
GITEA = "https://git.stackworks.ru/viktor/virtuality"
def make_source(path):
import subprocess
def git(*args):
return subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=path, check=True, capture_output=True, text=True).stdout.strip()
path.mkdir()
git("init", "-q", "-b", "main")
git("commit", "-q", "--allow-empty", "-m", "base")
git("remote", "add", "origin", "https://github.com/viktor138irk/virtuality.git")
return git
def test_migrate_remote_moves_only_the_old_github_address(tmp_path, monkeypatch, data_dirs):
git = make_source(tmp_path / "source")
monkeypatch.setattr(update_core, "SOURCE_DIR", tmp_path / "source")
assert update_core.migrate_remote() is True
assert git("remote", "get-url", "origin") == GITEA + ".git"
assert update_core.migrate_remote() is False
git("remote", "set-url", "origin", "https://example.org/me/fork.git")
assert update_core.migrate_remote() is False
assert git("remote", "get-url", "origin") == "https://example.org/me/fork.git"
def test_update_target_script_downloads_from_gitea(tmp_path):
import os
import subprocess
source = tmp_path / "source"
git = make_source(source)
def target(channel, script="resolve_update_target"):
env = {**os.environ, "VIRTUALITY_UPDATE_CHANNEL": channel}
code = f'SOURCE_DIR="{source}"; REMOTE=origin; BRANCH=main; . "{ROOT}/scripts/update_target.sh"; {script}; echo "$TARGET_LABEL $TARGET_ZIP_URL"'
return subprocess.run(["bash", "-c", code], env=env, check=True, capture_output=True, text=True).stdout.strip().splitlines()
moved, result = target("main", "migrate_update_remote; resolve_update_target")
assert moved == f"update remote moved: https://github.com/viktor138irk/virtuality.git -> {GITEA}.git"
assert result == f"main {GITEA}/archive/main.zip"
assert git("remote", "get-url", "origin") == GITEA + ".git"
git("tag", "v0.11.1")
assert target("stable") == [f"v0.11.1 {GITEA}/archive/v0.11.1.zip"]
assert target("main") == [f"main {GITEA}/archive/main.zip"]
# A fork downloads from itself; a non-https remote falls back to the project's address.
git("remote", "set-url", "origin", "https://example.org/me/fork.git")
assert target("stable", "migrate_update_remote; resolve_update_target") == ["v0.11.1 https://example.org/me/fork/archive/v0.11.1.zip"]
git("remote", "set-url", "origin", "git@example.org:me/fork.git")
assert target("stable") == [f"v0.11.1 {GITEA}/archive/v0.11.1.zip"]
def test_stable_channel_follows_release_tags(tmp_path, monkeypatch, data_dirs):
import subprocess
@@ -140,3 +194,13 @@ def test_stable_channel_follows_release_tags(tmp_path, monkeypatch, data_dirs):
(tmp_path / "web.env").write_text("VIRTUALITY_UPDATE_CHANNEL=main\n")
data = update_core.check_updates(fetch=True)
assert data["channel"] == "main" and data["target"] == "main" and data["has_update"]
@pytest.mark.parametrize("username", ["admin", "root", "kvm", "libvirt-qemu"])
def test_build_iso_refuses_names_the_ubuntu_installer_rejects(username):
"""Установщик Ubuntu не принимает системные имена: автоматический образ с таким именем падал на первом шаге."""
import subprocess
result = subprocess.run(["bash", str(ROOT / "image" / "build-iso.sh"), "--unattended", "--username", username, "--password-hash", "$6$salt$hash"], capture_output=True, text=True)
assert result.returncode != 0 and "reserved by the Ubuntu installer" in result.stderr
+54 -3
Просмотреть файл
@@ -1,7 +1,10 @@
"""Quick machines from cloud images (cloud-init), OS types for installers, fixed NAT addresses."""
import shlex
import pytest
import app
import catalog
import cloudinit
import conftest
import network_core
@@ -118,7 +121,7 @@ def test_windows11_gets_uefi_tpm_sata_and_e1000e(logged_in, data_dirs, monkeypat
assert response.status_code == 303
cmd = captured["cmd"]
assert "--osinfo" in cmd and cmd[cmd.index("--osinfo") + 1] == "win11"
assert cmd[cmd.index("--boot") + 1] == "uefi,cdrom,hd"
assert cmd[cmd.index("--boot") + 1] == "uefi,hd,cdrom"
assert "--tpm" in cmd and "backend.version=2.0" in cmd[cmd.index("--tpm") + 1]
assert "bus=sata" in cmd[cmd.index("--disk") + 1] and "model=e1000e" in cmd[cmd.index("--network") + 1]
assert "--channel" not in cmd # Windows has no guest agent without extra drivers
@@ -129,7 +132,7 @@ def test_linux_installer_detects_os_and_uses_virtio(logged_in, data_dirs, monkey
iso.write_bytes(b"iso")
response, captured = create(logged_in, monkeypatch, iso_form(iso))
cmd = captured["cmd"]
assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "cdrom,hd"
assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "hd,cdrom"
assert "bus=virtio" in cmd[cmd.index("--disk") + 1] and "model=virtio" in cmd[cmd.index("--network") + 1] and "--tpm" not in cmd
assert cmd[cmd.index("--channel") + 1] == "unix,target.type=virtio,target.name=org.qemu.guest_agent.0"
assert create(logged_in, monkeypatch, iso_form(iso, os_type="bsd"))[0].status_code == 400
@@ -146,7 +149,55 @@ def test_reservation_is_reused_and_released(data_dirs, monkeypatch):
def test_vm_delete_releases_address(logged_in, data_dirs, monkeypatch):
calls = recording(monkeypatch, app, [])
import core
recording(monkeypatch, app, [])
calls = recording(monkeypatch, core, [])
net_calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
assert logged_in.post("/vm/web01/delete", follow_redirects=False).status_code == 303
assert find(calls, "virsh", "undefine") and find(net_calls, "virsh", "net-update", "virtuality-nat", "delete")
# ---------------------------------------------------------------- тип системы по установочному ISO
def fake_iso(path, label):
"""Минимальный ISO 9660: первичный дескриптор тома с меткой по смещению 0x8028."""
descriptor = bytearray(0x800)
descriptor[0], descriptor[1:6], descriptor[6] = 1, b"CD001", 1
descriptor[0x28:0x48] = label.encode().ljust(32)
path.write_bytes(bytes(0x8000) + bytes(descriptor))
return path
@pytest.mark.parametrize("label, name, expected", [
("19044_4046___WIN10_IOT_LTSC", "19044.4046---Win10-IoT-LTSC.iso", "windows10"),
("CCCOMA_X64FRE_RU-RU_DV9", "Win11_24H2_Russian_x64.iso", "windows11"),
("CCCOMA_X64FRE_EN-US_DV9", "windows.iso", "windows11"), # версия неизвестна: UEFI + TPM подходят и для 10
("SSS_X64FREE_EN-US_DV9", "20348.169.210806-2348.fe_release_SERVER_EVAL_x64FRE_en-us.iso", "windows10"),
("SSS_X64FREV_EN-US_DV9", "26100.1742.240906-0331.ge_release_SERVER_EVAL_x64FRE_en-us.iso", "windows11"),
("Ubuntu-Server 26.04 LTS amd64", "ubuntu-26.04-live-server-amd64.iso", ""),
("", "debian-13.1.0-amd64-netinst.iso", ""),
("smk-server", "smk-server-_1.iso", ""),
])
def test_guess_os_type_from_iso(label, name, expected):
assert catalog.guess_os_type(label, name) == expected
def test_windows_iso_preselects_windows_type(logged_in, data_dirs):
"""Машина с Windows 10 создалась как Linux: диск virtio, установщик Windows его не видел."""
fake_iso(data_dirs["iso"] / "19044.4046---Win10-IoT-LTSC.iso", "19044_4046___WIN10_IOT_LTSC")
fake_iso(data_dirs["iso"] / "ubuntu.iso", "Ubuntu-Server 26.04 LTS amd64")
assert app.iso_volume_label(data_dirs["iso"] / "ubuntu.iso") == "Ubuntu-Server 26.04 LTS amd64"
html = logged_in.get("/vm/create?iso=19044.4046---Win10-IoT-LTSC.iso").text
assert 'data-os="windows10" data-login="" checked' in html and 'data-label="ubuntu.iso" data-os="" data-login="" >' in html
assert 'value="windows10" checked' in html and 'value="linux" checked' not in html
def test_images_are_offered_as_cards_with_system_size_and_date(logged_in, data_dirs):
fake_iso(data_dirs["iso"] / "19044.4046---Win10-IoT-LTSC.iso", "19044_4046___WIN10_IOT_LTSC")
fake_iso(data_dirs["iso"] / "ubuntu-24.04.1-live-server-amd64.iso", "Ubuntu-Server 24.04.1 LTS amd64")
html = logged_in.get("/vm/create").text
assert html.count('name="iso_path"') == 2 and '<span class="tag">Windows 10</span>' in html and '<span class="tag">Ubuntu</span>' in html
assert "34 КБ · загружен" in html and "data-image-search" not in html # поиск только когда образов больше шести
for number in range(5):
fake_iso(data_dirs["iso"] / f"extra-{number}.iso", "")
assert "data-image-search" in logged_in.get("/vm/create").text
+5 -1
Просмотреть файл
@@ -35,13 +35,17 @@ class Recorder:
@pytest.fixture()
def record(data_dirs, monkeypatch):
"""Подменяет run_cmd и в core (vmops), и в app (обзор, страница машины)."""
"""Подменяет run_cmd и в core (vmops), и в app (обзор, страница машины).
Свободное место тоже подменено: временная папка тестов бывает маленькой (tmpfs),
а тесты про нехватку места задают его сами."""
import app
recorder = Recorder()
for module in (core, app):
monkeypatch.setattr(module, "run_cmd", recorder)
monkeypatch.setattr(vmops, "_samples", {})
monkeypatch.setattr(vmops, "free_space", lambda path: 500 * GIB)
return recorder
+122
Просмотреть файл
@@ -1,5 +1,127 @@
{
"versions": [
{
"version": "0.12.8",
"date": "2026-09-27",
"title": "Удобный выбор образа при создании машины",
"summary": "Установочные образы и готовые системы в форме создания машины теперь показаны карточками: какая система внутри, размер и когда образ загружен. Когда образов больше шести, появляется поиск по названию.",
"features": [
"Образы в форме создания машины показаны карточками вместо выпадающего списка",
"На карточке видно систему (Windows 10, Windows 11, Ubuntu, Debian и другие), размер и дату загрузки",
"Поиск по названию, когда образов больше шести",
"Версия обновлена до 0.12.8"
]
},
{
"version": "0.12.7",
"date": "2026-09-27",
"title": "Тип Windows выбирается сам по установочному образу",
"summary": "Если в форме создания машины выбран установщик Windows, панель узнаёт его по метке образа и имени файла и сама ставит тип Windows. Раньше по умолчанию оставался Linux, диск подключался как virtio, и установщик Windows его не видел.",
"features": [
"Установщик Windows 10, 11 и Server узнаётся по установочному образу, тип системы выбирается сам",
"Выбор можно поменять вручную, панель его больше не перебивает",
"Версия обновлена до 0.12.7"
]
},
{
"version": "0.12.6",
"date": "2026-09-27",
"title": "Установочные образы больше не пропадают при удалении машины",
"summary": "При удалении машины вместе с ней удалялся и вставленный в её привод установочный ISO, поэтому загруженные образы исчезали. Теперь удаляются только диски самой машины, а образы остаются для следующих установок, как в Proxmox.",
"features": [
"Удаление машины больше не удаляет установочный ISO из её привода",
"Удаляются только диски машины из папки машин, диски в других местах остаются, панель называет их в сообщении",
"То же при восстановлении из копии с заменой машины",
"Версия обновлена до 0.12.6"
]
},
{
"version": "0.12.5",
"date": "2026-09-27",
"title": "Удаление машины в любом состоянии",
"summary": "Пункт «Удалить машину» теперь есть всегда, а не только у выключенной машины. Работающую или приостановленную машину панель сначала выключит, а подтверждение честно об этом предупреждает. Вместе с машиной удаляются её пробросы портов.",
"features": [
"«Удалить машину» доступно в меню машины и в списке машин при любом состоянии",
"Работающая или приостановленная машина перед удалением выключается",
"Пока с машиной идёт копия, клон или другая задача, удалить её нельзя: панель просит дождаться завершения",
"Вместе с машиной удаляются её пробросы портов, чтобы они не вели на адрес, который достанется другой машине",
"Версия обновлена до 0.12.5"
]
},
{
"version": "0.12.4",
"date": "2026-09-27",
"title": "Машина с установочного образа после установки перезагружается сама",
"summary": "Раньше машина, созданная с ISO, после установки системы выключалась вместо перезагрузки, а при следующем запуске снова загружалась с установочного образа. Теперь она создаётся сразу в рабочем виде, диск стоит первым в порядке загрузки, и после установки машина перезагружается уже в установленную систему.",
"features": [
"После установки с ISO машина перезагружается сама, а не выключается",
"Для машин с ISO диск стоит первым в порядке загрузки: пока он пустой, машина загружается с образа, а после установки с диска и повторно в установщик не попадает",
"Версия обновлена до 0.12.4"
]
},
{
"version": "0.12.3",
"date": "2026-09-27",
"title": "Экран машины снова подключается",
"summary": "На Ubuntu 26.04 экран машины в браузере не подключался: новая версия noVNC обращалась не по тому адресу. Теперь экран работает и с новой, и со старой версией noVNC.",
"features": [
"Экран машины в браузере подключается на Ubuntu 26.04 и Debian 13 (noVNC 1.6), как и на Ubuntu 24.04 (noVNC 1.3)",
"Версия обновлена до 0.12.3"
]
},
{
"version": "0.12.2",
"date": "2026-09-27",
"title": "Понятный итог, если машину создать не удалось",
"summary": "Если создание машины сорвалось, панель больше не предлагает открыть экран несуществующей машины. Она пишет, что на сервере от неё ничего не осталось, и даёт кнопку «Создать заново» с тем же образом. Недосозданный диск удаляется сам, поэтому то же название можно использовать сразу.",
"features": [
"Кнопки «Открыть экран машины» и «Перейти к машине» появляются только после успешного создания",
"После неудачного создания: кнопка «Создать заново» с тем же установочным или готовым образом",
"Недосозданный диск удаляется сам, повторное создание под тем же названием больше не упирается в «Диск уже существует»",
"Версия обновлена до 0.12.2"
]
},
{
"version": "0.12.1",
"date": "2026-09-27",
"title": "Понятное предупреждение, когда диск машины больше свободного места",
"summary": "Если диск новой машины больше, чем свободно на сервере, панель больше не запускает установку, которая падала с ошибкой на английском. Она объясняет, в чём риск, и создаёт такую машину только после подтверждения: диск занимает место постепенно, по мере заполнения.",
"features": [
"В шаге «Мощность» видно, сколько места свободно на сервере",
"Диск больше свободного места создаётся только с подтверждением, раньше установка падала с ошибкой virt-install",
"Версия обновлена до 0.12.1"
]
},
{
"version": "0.12.0",
"date": "2026-09-27",
"title": "Резервные копии по расписанию",
"summary": "Сервер сам копирует машины каждый день или раз в неделю в выбранное время и хранит только последние копии, чтобы они не заняли весь диск. Копии делает отдельная служба сервера, поэтому ночное обновление панели их не прерывает. Исправлена загрузка ISO и образов дисков с компьютера.",
"features": [
"Загрузка ISO и образов дисков с компьютера снова работает: браузер отправлял пустой запрос, и сервер отвечал ошибкой 422",
"Расписание копий: каждый день или в выбранный день недели, время, все машины или только отмеченные",
"Сервер хранит последние N копий по расписанию для каждой машины и удаляет старые только после удачной новой",
"Копии, сделанные вручную, расписание не удаляет",
"Если сервер был выключен в назначенное время, копия делается в течение двух часов после включения, иначе в следующий раз",
"На странице «Резервные копии» видно следующую копию и итог последнего запуска по каждой машине",
"Перезапуск панели больше не объявляет прерванными задачи, которые ведёт служба копий",
"Установочный образ не собирается с именем администратора, которое запрещает установщик Ubuntu (admin, root, kvm)",
"Версия обновлена до 0.12.0"
]
},
{
"version": "0.11.1",
"date": "2026-09-27",
"title": "Переезд обновлений на git.stackworks.ru",
"summary": "Проект переехал с GitHub на собственный сервер git.stackworks.ru, и обновления теперь приходят оттуда. Сервер, который до сих пор получал обновления с GitHub, переключится на новый адрес сам при следующей проверке. Заодно исправлена редкая ошибка резервных копий: машина могла снова запуститься, хотя её только что выключили вручную.",
"features": [
"Обновления, установка одной командой и установочный ISO берут код с git.stackworks.ru",
"Сервер со старым адресом GitHub сам переключается на новый при проверке обновлений",
"Запасное обновление архивом ZIP скачивается с того же адреса, откуда сервер получает обновления",
"Резервные копии: если машину выключили вручную, пока панель ждала её выключения, панель больше не запускает её снова",
"Версия обновлена до 0.11.1"
]
},
{
"version": "0.11.0",
"date": "2026-09-25",
+140 -52
Просмотреть файл
@@ -18,6 +18,7 @@ from pathlib import Path
from typing import Any
from urllib.parse import quote, urlsplit
import xml.etree.ElementTree as ET
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Form, UploadFile, File, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
@@ -45,7 +46,22 @@ OPERATIONS_DIR = Path("/var/log/virtuality/operations")
DEFAULT_BRIDGE = "br0"
NOVNC_DIR = next((p for p in [Path("/usr/share/novnc"), Path("/usr/share/novnc/app")] if p.exists()), None)
app = FastAPI(title="Virtuality Panel")
def run_startup_tasks() -> None:
"""После перезапуска панели: закрыть задачи, оборванные перезапуском, и убрать недокачанные загрузки."""
import core
from features import downloads
core.interrupt_orphaned_operations()
downloads.close_stale_downloads()
@asynccontextmanager
async def lifespan(_app: FastAPI):
run_startup_tasks()
yield
app = FastAPI(title="Virtuality Panel", lifespan=lifespan)
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
static_dir = BASE_DIR / "static"
static_dir.mkdir(exist_ok=True)
@@ -278,6 +294,17 @@ def progress_from_line(current: int, line: str) -> int:
return current
def remove_unfinished_disk(operation_id: str, disk_path: str) -> None:
"""Машина не появилась, а её диск (из готового образа он создаётся до virt-install) остался:
удаляем его, иначе повторное создание под тем же названием упрётся в «Диск уже существует»."""
disk = Path(disk_path)
if not disk_path or disk.parent.resolve() != IMAGES_DIR.resolve() or not disk.is_file():
return
disk.unlink(missing_ok=True)
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
append_operation_log(operation_id, f"Недосозданный диск {disk.name} удалён.")
def run_operation_worker(operation_id: str, cmd: list[str]) -> None:
operation = read_operation(operation_id)
if not operation:
@@ -306,7 +333,13 @@ def run_operation_worker(operation_id: str, cmd: list[str]) -> None:
append_operation_log(operation_id, f"virt-install завершился с ошибкой. Exit code: {exit_code}")
if fresh.get("vm_name") and fresh.get("network_mode") == "nat":
network_core.release_nat_address(fresh["vm_name"])
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=f"virt-install завершился с ошибкой: {exit_code}", finished_at=utc_now())
vm_left = bool(fresh.get("vm_name")) and vm_exists(fresh["vm_name"])
if not vm_left:
remove_unfinished_disk(operation_id, fresh.get("disk_path") or "")
message = "Машину создать не удалось, причина в журнале ниже."
if not vm_left:
message += " На сервере от неё ничего не осталось, её можно создать заново под тем же названием."
update_operation(fresh, status="error", progress=100, exit_code=exit_code, message=message, finished_at=utc_now())
except Exception as exc:
fresh = read_operation(operation_id) or operation
append_operation_log(operation_id, f"Ошибка запуска операции: {exc}")
@@ -520,6 +553,19 @@ def parse_pool_list() -> list[dict[str, str]]:
return rows
def iso_volume_label(path: Path) -> str:
"""Метка тома ISO 9660: 32 байта в первичном дескрипторе по смещению 0x8028."""
try:
with path.open("rb") as handle:
handle.seek(0x8000)
descriptor = handle.read(0x48)
except OSError:
return ""
if descriptor[1:6] != b"CD001":
return ""
return descriptor[0x28:0x48].decode("ascii", "replace").strip()
def list_iso_files() -> list[dict[str, str]]:
ISO_DIR.mkdir(parents=True, exist_ok=True)
files = []
@@ -531,7 +577,12 @@ def list_iso_files() -> list[dict[str, str]]:
except OSError:
size_mb = 0
updated = "unknown"
files.append({"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated})
label = iso_volume_label(item)
files.append({
"name": item.name, "path": str(item), "size": f"{size_mb:.1f} MB", "updated": updated,
"os_type": catalog.guess_os_type(label, item.name), "system": catalog.iso_system_name(label, item.name),
"size_label": presenters.format_bytes(stat.st_size) if size_mb else "", "date_label": presenters.short_date(stat.st_mtime) if size_mb else "",
})
return files
@@ -589,7 +640,8 @@ def list_disk_image_files() -> list[dict[str, str]]:
size_gb = 0
updated = "unknown"
entry = catalog.entry_for_filename(item.name)
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated, "login": cloudinit.default_user(item.name), "title": entry["title"] if entry else "", "catalog_id": entry["id"] if entry else ""})
files.append({"name": item.name, "path": str(item), "format": item.suffix.lower().lstrip('.'), "size": f"{size_gb:.2f} GB", "updated": updated, "login": cloudinit.default_user(item.name), "title": entry["title"] if entry else "", "catalog_id": entry["id"] if entry else "",
"size_label": presenters.format_bytes(stat.st_size) if size_gb else "", "date_label": presenters.short_date(stat.st_mtime) if size_gb else ""})
return files
@@ -906,7 +958,9 @@ def console_info(name: str) -> dict[str, Any]:
url = None
if port and has_novnc:
token = console_serializer.dumps({"vm": name, "port": port})
url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=console/ws/{token}"
# "../": noVNC 1.6 (Ubuntu 26.04) строит адрес от своей страницы /novnc/vnc.html, а 1.3 (Ubuntu 24.04)
# приклеивает path к корню сайта; в обоих случаях получается /console/ws/<токен>.
url = f"/novnc/vnc.html?autoconnect=1&resize=scale&path=../console/ws/{token}"
return {"vm": name, "display": display, "port": port, "has_novnc": has_novnc, "novnc_dir": str(NOVNC_DIR) if NOVNC_DIR else "not installed", "url": url}
@@ -1237,12 +1291,12 @@ def vm_boot_order_options() -> list[dict[str, str]]:
def normalize_boot_order(value: str, source_type: str) -> str:
"""auto для ISO — сначала диск, потом CD, как в Proxmox: пустой диск не загрузочный, и машина стартует
с установщика, а после установки — уже с диска, и повторно в установщик не попадает."""
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"):
return value
return "cdrom_disk" if source_type == "iso" else "disk"
return "disk_cdrom" if source_type == "iso" else "disk"
def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
@@ -1257,10 +1311,20 @@ def virt_boot_arg(boot_order: str, is_arm: bool) -> str:
return value
def images_free_bytes() -> int:
"""Свободное место там, где лежат диски машин."""
for path in (IMAGES_DIR, STORAGE_DIR, Path("/")):
try:
return shutil.disk_usage(path).free
except OSError:
continue
return 0
def vm_form_context(request: Request, error: str | None = None, form: dict[str, Any] | None = None, status_code: int = 200):
profile = host_profile.load_host_profile()
default_mode = profile.get("recommended_network", "nat")
return templates.TemplateResponse("vm_create.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "disk_images": list_disk_image_files(), "os_types": catalog.OS_TYPES, "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
return templates.TemplateResponse(request, "vm_create.html", {"app_name": APP_NAME, "user": AUTH_USER, "images_free": presenters.format_bytes(images_free_bytes()), "isos": list_iso_files(), "disk_images": list_disk_image_files(), "os_types": catalog.OS_TYPES, "boot_options": vm_boot_order_options(), "error": error, "profile": profile, "form": form or {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "iso", "boot_order": "auto", "network_mode": default_mode, "bridge": DEFAULT_BRIDGE}}, status_code=status_code)
# Virtuality noVNC console patch
@@ -1296,20 +1360,20 @@ async def proxy_websocket_to_vnc(websocket: WebSocket, writer: asyncio.StreamWri
def login_page(request: Request):
if get_current_user(request):
return RedirectResponse(url="/", status_code=303)
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": None, "configured": is_configured(), "auth_user": AUTH_USER})
return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": None, "configured": is_configured(), "auth_user": AUTH_USER})
@app.post("/login", response_class=HTMLResponse)
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
if not is_configured():
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Панель ещё не настроена. Запусти установщик веб-панели повторно.", "configured": False, "auth_user": AUTH_USER}, status_code=500)
return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": "Панель ещё не настроена. Запусти установщик веб-панели повторно.", "configured": False, "auth_user": AUTH_USER}, status_code=500)
key = client_key(request)
retry_after = login_throttle.retry_after(key)
if retry_after:
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": f"Слишком много неудачных попыток входа. Повтори через {retry_after} сек.", "configured": True, "auth_user": AUTH_USER}, status_code=429, headers={"Retry-After": str(retry_after)})
return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": f"Слишком много неудачных попыток входа. Повтори через {retry_after} сек.", "configured": True, "auth_user": AUTH_USER}, status_code=429, headers={"Retry-After": str(retry_after)})
if not verify_linux_password(username, password):
login_throttle.record_failure(key)
return templates.TemplateResponse("login.html", {"request": request, "app_name": APP_NAME, "error": "Неверный логин или пароль Linux-пользователя", "configured": True, "auth_user": AUTH_USER}, status_code=401)
return templates.TemplateResponse(request, "login.html", {"app_name": APP_NAME, "error": "Неверный логин или пароль Linux-пользователя", "configured": True, "auth_user": AUTH_USER}, status_code=401)
login_throttle.record_success(key)
token = serializer.dumps({"user": AUTH_USER})
response = RedirectResponse(url="/", status_code=303)
@@ -1333,7 +1397,7 @@ def dashboard(request: Request):
return RedirectResponse(url="/setup", status_code=303)
services = {"libvirtd": service_state("libvirtd.service"), "virtlogd": service_state("virtlogd.service"), "web": service_state("virtuality-web.service")}
service_rows = [{"key": key, "name": presenters.SERVICE_LABELS.get(key, key), "state": state, **presenters.service_state(state)} for key, state in services.items()]
return templates.TemplateResponse("dashboard.html", {"request": request, "app_name": APP_NAME, "system": system_summary(), "services": services, "service_rows": service_rows, "services_ok": all(row["tone"] == "success" for row in service_rows if row["key"] in ("libvirtd", "web")), "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css, "host": presenters.host_stats(STORAGE_DIR), "greeting": presenters.greeting()})
return templates.TemplateResponse(request, "dashboard.html", {"app_name": APP_NAME, "system": system_summary(), "services": services, "service_rows": service_rows, "services_ok": all(row["tone"] == "success" for row in service_rows if row["key"] in ("libvirtd", "web")), "vms": parse_virsh_list(), "pools": parse_pool_list(), "network": network_summary(), "user": AUTH_USER, "profile": host_profile.load_host_profile(), "operations": list_operations(5), "operation_css": operation_css, "host": presenters.host_stats(STORAGE_DIR), "greeting": presenters.greeting()})
@app.get("/help", response_class=HTMLResponse)
@@ -1341,7 +1405,7 @@ def help_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("help.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "host_ip": system_summary()["ip"]})
return templates.TemplateResponse(request, "help.html", {"app_name": APP_NAME, "user": AUTH_USER, "host_ip": system_summary()["ip"]})
@app.get("/host", response_class=HTMLResponse)
@@ -1350,7 +1414,7 @@ def host_page(request: Request):
if auth_redirect:
return auth_redirect
profile = host_profile.load_host_profile()
return templates.TemplateResponse("host.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "profile": profile, "profile_json": json.dumps(profile, ensure_ascii=False, indent=2)})
return templates.TemplateResponse(request, "host.html", {"app_name": APP_NAME, "user": AUTH_USER, "profile": profile, "profile_json": json.dumps(profile, ensure_ascii=False, indent=2)})
@app.post("/host/refresh")
@@ -1368,7 +1432,7 @@ def iso_page(request: Request, error: str | None = None):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error})
return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": error})
@app.post("/iso/upload", response_class=HTMLResponse)
@@ -1378,11 +1442,11 @@ def iso_upload(request: Request, iso_file: UploadFile = File(...)):
return auth_redirect
safe_name = safe_iso_filename(iso_file.filename or "")
if not safe_name:
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400)
return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": "Можно загружать только .iso файлы с безопасным именем."}, status_code=400)
ISO_DIR.mkdir(parents=True, exist_ok=True)
target = ISO_DIR / safe_name
if target.exists():
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"ISO уже существует: {safe_name}"}, status_code=400)
return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"ISO уже существует: {safe_name}"}, status_code=400)
tmp_target = ISO_DIR / f".{safe_name}.uploading"
try:
with tmp_target.open("wb") as out:
@@ -1391,7 +1455,7 @@ def iso_upload(request: Request, iso_file: UploadFile = File(...)):
refresh_iso_pool()
except Exception as exc:
tmp_target.unlink(missing_ok=True)
return templates.TemplateResponse("iso.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500)
return templates.TemplateResponse(request, "iso.html", {"app_name": APP_NAME, "user": AUTH_USER, "isos": list_iso_files(), "error": f"Ошибка загрузки ISO: {exc}. Проверь свободное место, права на /var/lib/virtuality/iso и временный каталог /var/lib/virtuality/tmp."}, status_code=500)
return RedirectResponse(url="/iso", status_code=303)
@@ -1424,7 +1488,7 @@ 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})
return templates.TemplateResponse(request, "disk_images.html", {"app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": error})
@app.post("/disk-images/upload", response_class=HTMLResponse)
@@ -1434,7 +1498,7 @@ def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
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)
return templates.TemplateResponse(request, "disk_images.html", {"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] = []
@@ -1455,7 +1519,7 @@ def disk_image_upload(request: Request, image_file: UploadFile = File(...)):
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)
return templates.TemplateResponse(request, "disk_images.html", {"app_name": APP_NAME, "user": AUTH_USER, "images": list_disk_image_files(), "error": f"Ошибка загрузки/распаковки образа: {exc}"}, status_code=500)
operations = []
for path in saved_paths:
@@ -1507,8 +1571,7 @@ def safe_network_template(request: Request, error: str | None = None, status_cod
"nft_rules": "",
}
error = error or f"Ошибка чтения сетевого состояния: {exc}"
return templates.TemplateResponse("network.html", {
"request": request,
return templates.TemplateResponse(request, "network.html", {
"app_name": APP_NAME,
"user": AUTH_USER,
"vms": parse_virsh_list(),
@@ -1535,7 +1598,7 @@ def network_vm_ip_save(request: Request, vm_name: str = Form(...), manual_ip: st
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)
return templates.TemplateResponse(request, "network.html", {"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:
@@ -1616,8 +1679,7 @@ def network_diagnose(request: Request, vm_name: str = Form(...), external_port:
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,
return templates.TemplateResponse(request, "network.html", {
"app_name": APP_NAME,
"user": AUTH_USER,
"vms": parse_virsh_list(),
@@ -1657,7 +1719,7 @@ def update_page(request: Request):
}
current = str(info.get("current_version", ""))
current_release = next((item for item in update_core.load_manifest().get("versions", []) if str(item.get("version")) == current), None)
return templates.TemplateResponse("update.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error, "current_release": current_release})
return templates.TemplateResponse(request, "update.html", {"app_name": APP_NAME, "user": AUTH_USER, "info": info, "error": error, "current_release": current_release})
@app.get("/update/status")
@@ -1702,7 +1764,7 @@ def operations_page(request: Request):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
return templates.TemplateResponse("operations.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "operations": list_operations(50), "operation_css": operation_css})
return templates.TemplateResponse(request, "operations.html", {"app_name": APP_NAME, "user": AUTH_USER, "operations": list_operations(50), "operation_css": operation_css})
@app.get("/operations/{operation_id}", response_class=HTMLResponse)
@@ -1713,7 +1775,18 @@ def operation_detail_page(request: Request, operation_id: str):
operation = read_operation(operation_id)
if not operation:
return RedirectResponse(url="/operations", status_code=303)
return templates.TemplateResponse("operation_detail.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css})
vm_name = str(operation.get("vm_name") or "")
vm_present = valid_vm_name(vm_name) and vm_exists(vm_name)
return templates.TemplateResponse(request, "operation_detail.html", {"app_name": APP_NAME, "user": AUTH_USER, "operation": operation, "operation_css": operation_css, "vm_present": vm_present, "retry_url": vm_create_retry_url(operation)})
def vm_create_retry_url(operation: dict[str, Any]) -> str:
"""Форма создания машины с тем же установочным образом или готовым образом, что у неудачной попытки."""
if operation.get("iso_path"):
return "/vm/create?iso=" + quote(Path(operation["iso_path"]).name)
if operation.get("disk_image_path"):
return "/vm/create?image=" + quote(Path(operation["disk_image_path"]).name)
return "/vm/create"
@app.get("/api/operations")
@@ -1739,8 +1812,7 @@ def logs_page(request: Request, source: str = "web", lines: int = 220):
if auth_redirect:
return auth_redirect
selected = read_log_source(source, lines)
return templates.TemplateResponse("logs.html", {
"request": request,
return templates.TemplateResponse(request, "logs.html", {
"app_name": APP_NAME,
"user": AUTH_USER,
"sources": LOG_SOURCES,
@@ -1766,18 +1838,23 @@ def vm_create_page(request: Request, iso: str = "", image: str = ""):
form = {"memory": 4096, "vcpus": 2, "disk_size": 40, "source_type": "disk_image" if image else "iso", "boot_order": "auto", "network_mode": profile.get("recommended_network", "nat"), "bridge": DEFAULT_BRIDGE}
if iso:
form["iso_path"] = str(ISO_DIR / Path(iso).name)
form["os_type"] = catalog.guess_os_type(iso_volume_label(ISO_DIR / Path(iso).name), Path(iso).name) or "linux"
if image:
form["disk_image_path"] = str(DISK_IMAGES_DIR / Path(image).name)
return vm_form_context(request, form=form)
@app.post("/vm/create", response_class=HTMLResponse)
def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(20), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE), os_type: str = Form("linux"), cloud_init: str = Form("0"), ci_user: str = Form(""), ci_password: str = Form(""), ci_ssh_key: str = Form("")):
def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form(...), vcpus: int = Form(...), disk_size: int = Form(20), iso_path: str = Form(""), disk_image_path: str = Form(""), source_type: str = Form("iso"), boot_order: str = Form("auto"), network_mode: str = Form("nat"), bridge: str = Form(DEFAULT_BRIDGE), os_type: str = Form("linux"), cloud_init: str = Form("0"), ci_user: str = Form(""), ci_password: str = Form(""), ci_ssh_key: str = Form(""), allow_overcommit: str = Form("")):
auth_redirect = require_auth(request)
if auth_redirect:
return auth_redirect
ci_user = ci_user.strip()
form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "boot_order": boot_order, "network_mode": network_mode, "bridge": bridge, "os_type": os_type, "cloud_init": cloud_init, "ci_user": ci_user, "ci_ssh_key": ci_ssh_key}
# Диск qcow2 занимает место по мере заполнения, поэтому больше свободного места его создать можно,
# но только осознанно: если машина его заполнит, место на сервере кончится у всех машин.
free = images_free_bytes()
too_big = disk_size * 1024 ** 3 > free
form = {"name": name, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "iso_path": iso_path, "disk_image_path": disk_image_path, "source_type": source_type, "boot_order": boot_order, "network_mode": network_mode, "bridge": bridge, "os_type": os_type, "cloud_init": cloud_init, "ci_user": ci_user, "ci_ssh_key": ci_ssh_key, "overcommit": too_big, "allow_overcommit": allow_overcommit == "1"}
error = None
use_cloud_init = source_type == "disk_image" and cloud_init == "1"
ssh_keys = cloudinit.parse_ssh_keys(ci_ssh_key) if use_cloud_init else []
@@ -1791,6 +1868,8 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
error = "CPU должен быть от 1 до 128 vCPU."
elif disk_size < 4 or disk_size > 4096:
error = "Диск должен быть от 4 GB до 4096 GB."
elif too_big and allow_overcommit != "1":
error = f"Диск на {disk_size} ГБ больше, чем свободно на сервере ({presenters.format_bytes(free)}). Уменьшите диск или подтвердите создание в шаге «Мощность»."
elif network_mode not in ("nat", "bridge"):
error = "Некорректный режим сети."
elif network_mode == "bridge" and (not bridge or not re.fullmatch(r"[a-zA-Z0-9_.:-]+", bridge)):
@@ -1889,11 +1968,16 @@ def vm_create_submit(request: Request, name: str = Form(...), memory: int = Form
steps.append(" ".join(shlex.quote(part) for part in virt))
cmd = ["bash", "-lc", "set -euo pipefail; " + "; ".join(steps)]
else:
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics]
# no_install: машина сразу создаётся в рабочем виде, с образом в приводе. Без этого virt-install ставит
# на время установки «перезагрузка = выключение» и сам должен перезапустить машину, а он не ждёт
# (--noautoconsole) — после установки машина просто выключалась.
cmd += ["--disk", f"path={disk_path},size={disk_size},format=qcow2,bus={disk_bus}", "--cdrom", iso_path, "--install", "no_install=yes", "--osinfo", os_info["osinfo"], "--network", network_arg, *graphics]
if too_big:
cmd += ["--check", "disk_size=off"] # подтверждено пользователем: virt-install иначе откажется
success_message = "Машина создана" + (f". {login_hint}" if login_hint else "") + (f". Адрес в сети машин: {nat_ip}" if nat_ip else "")
operation_id = str(uuid.uuid4())
operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "host_profile": profile.get("profile"), "guest_arch": selected_arch, "boot_order": selected_boot_order, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "os_type": os_type, "cloud_init_user": ci_user if use_cloud_init else "", "nat_ip": nat_ip or "", "seed_file": seed_file, "success_message": success_message, "cmd": " ".join(cmd)}
operation = {"id": operation_id, "type": "vm_create", "title": f"Создание VM {name}", "status": "queued", "progress": 0, "message": "Операция поставлена в очередь", "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "vm_name": name, "disk_path": str(disk_path), "iso_path": iso_path, "disk_image_path": disk_image_path if source_type == "disk_image" else "", "host_profile": profile.get("profile"), "guest_arch": selected_arch, "boot_order": selected_boot_order, "network_mode": network_mode, "network": network_arg, "bridge": bridge, "memory": memory, "vcpus": vcpus, "disk_size": disk_size, "os_type": os_type, "cloud_init_user": ci_user if use_cloud_init else "", "nat_ip": nat_ip or "", "seed_file": seed_file, "success_message": success_message, "cmd": " ".join(cmd)}
start_background_operation(operation, cmd)
return RedirectResponse(url=f"/operations/{operation_id}", status_code=303)
@@ -1905,7 +1989,7 @@ def vm_console_page(request: Request, name: str):
return auth_redirect
if not valid_vm_name(name) or not vm_exists(name):
return RedirectResponse(url="/", status_code=303)
return templates.TemplateResponse("console.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)})
return templates.TemplateResponse(request, "console.html", {"app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "console": console_info(name)})
@app.websocket("/console/ws/{token}")
@@ -1955,7 +2039,7 @@ def vm_detail_page(request: Request, name: str):
return auth_redirect
if not valid_vm_name(name) or not vm_exists(name):
return RedirectResponse(url="/", status_code=303)
return templates.TemplateResponse("vm_detail.html", {"request": request, "app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "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", ""), "resource_settings": vm_resource_settings(name), "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", "")})
return templates.TemplateResponse(request, "vm_detail.html", {"app_name": APP_NAME, "user": AUTH_USER, "vm": vm_details(name), "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", ""), "resource_settings": vm_resource_settings(name), "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", "")})
@app.post("/vm/{name}/resources")
@@ -2030,14 +2114,23 @@ def vm_action(request: Request, name: str, action: str):
if action in ("shutdown", "destroy", "delete"):
backups.cancel_late_restart(name)
if action == "delete":
if vm_state(name) == "running":
import core
busy = core.active_operations_for(name)
if busy:
return redirect_with_message(f"/vm/{name}", "error", f"С машиной сейчас идёт задача «{busy[0].get('title') or 'операция'}». Дождитесь её завершения и удалите машину снова.")
if core.vm_is_running(name): # и на паузе тоже: undefine активной машины оставил бы её работать без настроек
run_cmd(["virsh", "destroy", name], timeout=20)
# --nvram: UEFI machines keep firmware variables that block a plain undefine.
result = run_cmd(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120)
if not result["ok"]:
return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {virsh_error(result)}")
# Установочные ISO и образы систем остаются: удаляются только диски самой машины.
ok, error, _removed, kept = core.undefine_vm(name)
if not ok:
return redirect_with_message(f"/vm/{name}", "error", f"Не удалось удалить машину: {error}")
network_core.release_nat_address(name)
return redirect_with_message("/", "message", f"Машина {name} удалена")
forwards = network_core.remove_vm_forwards(name)
message = f"Машина {name} удалена" + (f", вместе с ней убраны пробросы портов: {forwards}" if forwards else "")
if kept:
message += ". Не удалены диски вне папки машин: " + ", ".join(str(path) for path in kept)
return redirect_with_message("/", "message", message)
if action not in allowed:
return JSONResponse({"ok": False, "error": "Unsupported action"}, status_code=400)
result = run_cmd(allowed[action], timeout=30)
@@ -2083,18 +2176,13 @@ def api_health(request: Request):
# Feature modules register template globals into app.templates, so they are imported once it exists.
from features import backups, downloads, settings, setup, snapshots, vmops # noqa: E402
@app.on_event("startup")
def close_orphaned_operations() -> None:
import core
core.interrupt_orphaned_operations()
from features import backup_schedule, backups, downloads, settings, setup, snapshots, vmops # noqa: E402
app.include_router(setup.router)
app.include_router(settings.router)
app.include_router(snapshots.router)
app.include_router(backup_schedule.router)
app.include_router(backups.router)
app.include_router(downloads.router)
app.include_router(vmops.router)
+38
Просмотреть файл
@@ -3,6 +3,7 @@
Each entry points to the vendor's official cloud image. Images boot in seconds
and are configured through cloud-init (user, password, SSH key) — no installer.
"""
import re
from typing import Any
# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13.
@@ -69,6 +70,43 @@ OS_TYPES: list[dict[str, Any]] = [
]
# Установщик Windows узнаём по метке тома ISO (у Microsoft вида CCCOMA_X64FRE_RU-RU_DV9) или по имени файла.
WINDOWS_RE = re.compile(r"\bWIN(?:DOWS)?\s?(?:1[01]|7|8|SERVER)\b|\bWINDOWS\b|\b(?:X64|X86|A64|ARM64)FRE[EV]?\b")
WINDOWS11_RE = re.compile(r"\bWIN(?:DOWS)?\s?11\b|\bSERVER\s?2025\b|\b(?:22000|22621|22631|26100|26200)\b")
WINDOWS10_RE = re.compile(r"\bWIN(?:DOWS)?\s?(?:10|7|8)\b|\bLTSC\b|\bSERVER\s?20(?:16|19|22)\b|\b1\d{4}\b|\b20348\b")
def guess_os_type(volume_label: str, filename: str) -> str:
"""Тип системы для установочного ISO: windows11 / windows10, если это установщик Windows, иначе "".
Windows неизвестной версии — windows11: UEFI и TPM подходят и для Windows 10, а без них не встанет 11."""
text = re.sub(r"[^A-Z0-9]+", " ", f"{volume_label} {filename}".upper())
if not WINDOWS_RE.search(text):
return ""
if WINDOWS11_RE.search(text):
return "windows11"
return "windows10" if WINDOWS10_RE.search(text) else "windows11"
# Название системы для карточки образа в форме создания машины.
DISTROS = (
("UBUNTU", "Ubuntu"), ("DEBIAN", "Debian"), ("FEDORA", "Fedora"), ("ALPINE", "Alpine"), ("CENTOS", "CentOS"),
("ROCKY", "Rocky Linux"), ("ALMALINUX", "AlmaLinux"), ("OPENSUSE", "openSUSE"), ("ARCHLINUX", "Arch Linux"),
("LINUXMINT", "Linux Mint"), ("KALI", "Kali Linux"), ("ASTRA", "Astra Linux"), ("REDOS", "РЕД ОС"),
("PROXMOX", "Proxmox"), ("TRUENAS", "TrueNAS"), ("FREEBSD", "FreeBSD"), ("PFSENSE", "pfSense"),
("OPNSENSE", "OPNsense"), ("OPENWRT", "OpenWrt"),
)
def iso_system_name(volume_label: str, filename: str) -> str:
"""«Windows 10», «Ubuntu»… или "", если систему по образу не узнать."""
guess = guess_os_type(volume_label, filename)
if guess:
return "Windows 11" if guess == "windows11" else "Windows 10"
words = set(re.sub(r"[^A-Z0-9]+", " ", f"{volume_label} {filename}".upper()).split())
return next((title for key, title in DISTROS if key in words), "")
def cloud_image(image_id: str) -> dict[str, Any] | None:
return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None)
+61 -3
Просмотреть файл
@@ -112,7 +112,7 @@ def render(request: Request, template: str, context: dict[str, Any] | None = Non
data = {"request": request, "app_name": APP_NAME, "user": AUTH_USER}
data.update(context or {})
data.update(extra)
return templates.TemplateResponse(template, data, status_code=status_code)
return templates.TemplateResponse(request, template, data, status_code=status_code)
# ---------------------------------------------------------------- auth
@@ -227,6 +227,43 @@ def vm_is_running(name: str) -> bool:
return vm_state(name) in ("running", "idle", "blocked", "paused", "pmsuspended", "in shutdown")
def vm_disk_files(name: str) -> tuple[list[Path], list[Path]]:
"""Файлы дисков машины: (свои — в IMAGES_DIR, чужие — где-то ещё). Приводы CD/DVD сюда не входят."""
own: list[Path] = []
foreign: list[Path] = []
result = run_cmd(["virsh", "domblklist", name, "--details"], timeout=10)
for disk in presenters.parse_domblklist(result.get("stdout") or ""):
if disk["device"] == "disk" and disk["source"]:
path = Path(disk["source"])
(own if IMAGES_DIR.resolve() in path.resolve().parents else foreign).append(path)
return own, foreign
def undefine_vm(name: str) -> tuple[bool, str, list[Path], list[Path]]:
"""Удалить машину и файлы её дисков из IMAGES_DIR. Возвращает (ok, ошибка, удалённые, оставленные).
Без `--remove-all-storage`: он удаляет все тома libvirt, подключённые к машине, в том числе
вставленный установочный ISO из пула virtuality-iso. Диски вне IMAGES_DIR не трогаем."""
own, foreign = vm_disk_files(name) # после undefine libvirt о дисках машины уже не расскажет
# --nvram: UEFI machines keep firmware variables that block a plain undefine.
result = run_cmd(["virsh", "undefine", name, "--nvram", "--managed-save", "--snapshots-metadata", "--checkpoints-metadata"], timeout=120)
if not result["ok"]:
return False, cmd_error(result, "virsh undefine"), [], []
removed = []
for disk in own:
try:
disk.unlink()
except FileNotFoundError:
continue
except OSError:
foreign.append(disk)
continue
removed.append(disk)
if removed:
run_cmd(["virsh", "pool-refresh", "virtuality-images"], timeout=20)
return True, "", removed, foreign
# ---------------------------------------------------------------- operations
def operation_meta_path(operation_id: str) -> Path:
return OPERATIONS_DIR / f"{operation_id}.json"
@@ -278,9 +315,27 @@ def list_operations(limit: int = 25) -> list[dict[str, Any]]:
return operations
def process_owner(pid: int | None = None) -> dict[str, Any] | None:
"""Who runs an operation: pid plus its start time and boot id, so a reused pid is not mistaken for it."""
pid = pid or os.getpid()
try:
stat = Path(f"/proc/{pid}/stat").read_text()
boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip()
except OSError:
return None
return {"pid": pid, "start": stat.rsplit(")", 1)[1].split()[19], "boot": boot}
def owner_alive(owner: Any) -> bool:
if not isinstance(owner, dict) or not isinstance(owner.get("pid"), int):
return False
return process_owner(owner["pid"]) == owner
def interrupt_orphaned_operations() -> int:
"""Operations run in threads of the panel process: after a restart the ones still marked
queued/running will never finish. Close them so they do not block the next attempt."""
queued/running will never finish. Close them so they do not block the next attempt.
Operations of another live process (scheduled backups run from a timer) are left alone."""
if not OPERATIONS_DIR.exists():
return 0
count = 0
@@ -291,6 +346,9 @@ def interrupt_orphaned_operations() -> int:
continue
if data.get("status") not in ("queued", "running"):
continue
owner = data.get("owner")
if owner_alive(owner) and owner["pid"] != os.getpid():
continue
data.pop("log_tail", None)
message = "Прервано: панель перезапускалась во время операции. Проверьте состояние машины и запустите действие снова."
update_operation(data, status="error", progress=100, message=message, finished_at=utc_now(), interrupted=True)
@@ -334,7 +392,7 @@ def update_operation(operation: dict[str, Any], **changes: Any) -> None:
def new_operation(kind: str, title: str, message: str = "Операция поставлена в очередь", **meta: Any) -> dict[str, Any]:
operation = {"id": str(uuid.uuid4()), "type": kind, "title": title, "status": "queued", "progress": 0, "message": message, "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER}
operation = {"id": str(uuid.uuid4()), "type": kind, "title": title, "status": "queued", "progress": 0, "message": message, "created_at": utc_now(), "updated_at": utc_now(), "created_by": AUTH_USER, "owner": process_owner()}
operation.update(meta)
write_operation(operation)
append_operation_log(operation["id"], "Операция поставлена в очередь.")
+316
Просмотреть файл
@@ -0,0 +1,316 @@
"""Резервные копии по расписанию.
Настройка лежит в CONFIG_DIR/backup_schedule.json, итог последнего запуска —
в CONFIG_DIR/backup_schedule_state.json. Копии делает не панель, а таймер
virtuality-backup.timer раз в 15 минут:
`/opt/virtuality/venv/bin/python /opt/virtuality/web/features/backup_schedule.py`.
Так ночной перезапуск панели при обновлении не обрывает копию на середине
и не оставляет машину выключенной.
Автокопии помечены в meta.json (auto: true); после каждой удачной копии
у машины остаются последние N автокопий. Ручные копии расписание не трогает.
"""
import fcntl
import json
import re
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
if __name__ == "__main__": # запуск как скрипта: нужен web/ в sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from fastapi import APIRouter, Form, Request # noqa: E402
import core # noqa: E402
from features import backups # noqa: E402
router = APIRouter()
TIME_RE = re.compile(r"([01]\d|2[0-3]):([0-5]\d)")
CATCH_UP = timedelta(hours=2) # сервер был выключен в назначенное время: догоняем не позже чем через 2 часа, иначе ждём следующего раза
KEEP_MIN, KEEP_MAX = 1, 60
WEEKDAYS = ("по понедельникам", "по вторникам", "по средам", "по четвергам", "по пятницам", "по субботам", "по воскресеньям")
WEEKDAY_NAMES = ("Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье")
DEFAULT: dict[str, Any] = {"enabled": False, "frequency": "daily", "weekday": 6, "time": "03:00", "all_vms": True, "vms": [], "keep": 7}
NOTE = "По расписанию"
# ---------------------------------------------------------------- хранение
def schedule_path() -> Path:
return core.CONFIG_DIR / "backup_schedule.json"
def state_path() -> Path:
return core.CONFIG_DIR / "backup_schedule_state.json"
def lock_path() -> Path:
return core.CONFIG_DIR / "backup_schedule.lock"
def read_json(path: Path) -> dict[str, Any]:
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(".tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2))
tmp_path.replace(path)
def is_int(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def clean_schedule(raw: dict[str, Any]) -> dict[str, Any]:
"""Настройка всегда полная и корректная: испорченные значения заменяются значениями по умолчанию."""
schedule = dict(DEFAULT)
schedule["enabled"] = raw.get("enabled") is True
if raw.get("frequency") in ("daily", "weekly"):
schedule["frequency"] = raw["frequency"]
if is_int(raw.get("weekday")) and 0 <= raw["weekday"] <= 6:
schedule["weekday"] = raw["weekday"]
if isinstance(raw.get("time"), str) and TIME_RE.fullmatch(raw["time"]):
schedule["time"] = raw["time"]
schedule["all_vms"] = raw.get("all_vms") is not False
vms = raw.get("vms")
schedule["vms"] = sorted({vm for vm in vms if isinstance(vm, str) and core.valid_vm_name(vm)}) if isinstance(vms, list) else []
if is_int(raw.get("keep")) and KEEP_MIN <= raw["keep"] <= KEEP_MAX:
schedule["keep"] = raw["keep"]
return schedule
def load_schedule() -> dict[str, Any]:
return clean_schedule(read_json(schedule_path()))
# ---------------------------------------------------------------- когда копировать
def step(schedule: dict[str, Any]) -> timedelta:
return timedelta(days=7 if schedule["frequency"] == "weekly" else 1)
def last_slot(schedule: dict[str, Any], now: datetime) -> datetime:
"""Последний назначенный момент копирования, не позже now (время сервера)."""
hour, minute = map(int, schedule["time"].split(":"))
slot = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if schedule["frequency"] == "weekly":
slot -= timedelta(days=(slot.weekday() - schedule["weekday"]) % 7)
if slot > now:
slot -= step(schedule)
return slot
def next_slot(schedule: dict[str, Any], now: datetime) -> datetime:
return last_slot(schedule, now) + step(schedule)
def slot_key(slot: datetime) -> str:
return slot.strftime("%Y-%m-%d %H:%M")
def due(schedule: dict[str, Any], state: dict[str, Any], now: datetime) -> tuple[str, datetime] | None:
"""("run", момент) — пора копировать; ("missed", момент) — время прошло, пока сервер был выключен; None — нечего делать."""
if not schedule["enabled"]:
return None
slot = last_slot(schedule, now)
if state.get("slot") == slot_key(slot):
return None
return ("run" if now - slot <= CATCH_UP else "missed"), slot
def selected_vms(schedule: dict[str, Any]) -> list[str]:
existing = backups.existing_vm_names()
return sorted(existing if schedule["all_vms"] else existing & set(schedule["vms"]))
# ---------------------------------------------------------------- запуск
def prune(vm: str, keep: int) -> list[str]:
"""Удалить автокопии машины сверх последних keep. Ручные копии и копии, с которыми идёт работа, остаются."""
autos = [item for item in backups.list_backups(vm) if item["auto"] and item["complete"]] # новые сверху
removed = []
for item in autos[keep:]:
try:
backups.delete_backup(vm, item["id"])
except backups.BackupError:
continue
removed.append(item["id"])
return removed
def run_due(now: datetime | None = None) -> dict[str, Any] | None:
"""Сделать копии, если подошло время. Возвращает итог запуска или None, если делать нечего."""
lock_path().parent.mkdir(parents=True, exist_ok=True)
with lock_path().open("w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
return None # предыдущий запуск ещё копирует
now = now or datetime.now()
schedule = load_schedule()
state = read_json(state_path())
found = due(schedule, state, now)
if not found:
return None
kind, slot = found
if kind == "missed":
state.update(slot=slot_key(slot), missed=slot_key(slot))
write_json(state_path(), state)
return None
state = {"slot": slot_key(slot), "status": "running", "started_at": core.utc_now(), "results": []}
write_json(state_path(), state)
for vm in selected_vms(schedule):
result: dict[str, Any] = {"vm": vm}
try:
operation = backups.create_backup(vm, NOTE, wait=True, auto=True)
except Exception as exc: # noqa: BLE001 — одна машина не должна сорвать копии остальных
result.update(ok=False, message=str(exc)[:240])
else:
result.update(ok=operation.get("status") == "success", message=str(operation.get("message") or "")[:240], operation_id=operation["id"])
if result["ok"]:
result["removed"] = prune(vm, schedule["keep"])
state["results"].append(result)
write_json(state_path(), state)
state.update(status="done", finished_at=core.utc_now())
write_json(state_path(), state)
return state
# ---------------------------------------------------------------- как показать
def format_slot(slot: datetime) -> str:
return f"{slot.day} {backups.MONTHS[slot.month - 1]}, {slot:%H:%M}"
def format_key(key: str) -> str:
try:
return format_slot(datetime.strptime(key, "%Y-%m-%d %H:%M"))
except (TypeError, ValueError):
return ""
def when_text(schedule: dict[str, Any]) -> str:
day = "каждый день" if schedule["frequency"] == "daily" else WEEKDAYS[schedule["weekday"]]
return f"{day} в {schedule['time']}"
def overview(now: datetime | None = None) -> dict[str, Any]:
"""Всё для карточки на странице «Резервные копии» и на странице машины."""
now = now or datetime.now()
schedule = load_schedule()
state = read_json(state_path())
results = [item for item in state.get("results") or [] if isinstance(item, dict)]
failed = [item for item in results if not item.get("ok")]
last = None
if state.get("started_at"):
last = {
"when": format_key(str(state.get("slot") or "")),
"running": state.get("status") == "running",
"count": len(results),
"ok": len(results) - len(failed),
"failed": failed,
}
return {
**schedule,
"when": when_text(schedule),
"next": format_slot(next_slot(schedule, now)) if schedule["enabled"] else "",
"last": last,
"missed": format_key(str(state.get("missed") or "")) if state.get("missed") and state.get("missed") == state.get("slot") else "",
}
def covers(schedule: dict[str, Any], vm: str) -> bool:
return schedule["enabled"] and (schedule["all_vms"] or vm in schedule["vms"])
# ---------------------------------------------------------------- страницы
def form_page(request: Request, form: dict[str, Any], error: str = "", status_code: int = 200):
names = sorted(backups.existing_vm_names() | set(form["vms"]))
context = {"page_title": "Расписание копий", "form": form, "vm_names": names, "weekdays": WEEKDAY_NAMES, "keep_max": KEEP_MAX, "error": error}
return core.render(request, "backup_schedule.html", context, status_code=status_code)
@router.get("/backups/schedule")
def schedule_page(request: Request):
auth = core.require_auth(request)
if auth:
return auth
return form_page(request, load_schedule())
def read_form(enabled: str, frequency: str, weekday: str, time: str, machines: str, vms: list[str], keep: str) -> dict[str, Any]:
return {
"enabled": enabled == "1",
"frequency": frequency if frequency in ("daily", "weekly") else "daily",
"weekday": int(weekday) if weekday.isdigit() and int(weekday) <= 6 else DEFAULT["weekday"],
"time": time.strip(),
"all_vms": machines != "chosen",
"vms": sorted({vm for vm in vms if core.valid_vm_name(vm)}),
"keep": int(keep) if keep.strip().isdigit() else keep.strip(),
}
def form_error(form: dict[str, Any]) -> str:
if not TIME_RE.fullmatch(form["time"]):
return "Укажите время в виде ЧЧ:ММ, например 03:00"
if not is_int(form["keep"]) or not KEEP_MIN <= form["keep"] <= KEEP_MAX:
return f"Сколько копий хранить: число от {KEEP_MIN} до {KEEP_MAX}"
if form["enabled"] and not form["all_vms"] and not form["vms"]:
return "Отметьте хотя бы одну машину или выберите «Все машины»"
return ""
def same_timing(old: dict[str, Any], new: dict[str, Any]) -> bool:
return old["enabled"] and new["enabled"] and all(old[key] == new[key] for key in ("frequency", "weekday", "time"))
@router.post("/backups/schedule")
def schedule_save(
request: Request,
enabled: str = Form(""),
frequency: str = Form("daily"),
weekday: str = Form("6"),
time: str = Form("03:00"),
machines: str = Form("all"),
vms: list[str] = Form([]),
keep: str = Form("7"),
):
auth = core.require_auth(request)
if auth:
return auth
form = read_form(enabled, frequency, weekday, time, machines, vms, keep)
error = form_error(form)
if error:
return form_page(request, form, error, status_code=400)
old, schedule = load_schedule(), clean_schedule(form)
write_json(schedule_path(), schedule)
now = datetime.now()
if not same_timing(old, schedule):
# Уже прошедший момент нового расписания не догоняем: первая копия — в следующее назначенное время.
state = read_json(state_path())
state["slot"] = slot_key(last_slot(schedule, now))
write_json(state_path(), state)
if not schedule["enabled"]:
return core.redirect_with_message("/backups", "backup_message", "Автоматические копии выключены")
message = f"Расписание сохранено: {when_text(schedule)}, следующая копия — {format_slot(next_slot(schedule, now))}"
return core.redirect_with_message("/backups", "backup_message", message)
def main() -> None:
"""Точка входа virtuality-backup.service: итог пишется в журнал службы."""
outcome = run_due()
if outcome:
done = sum(1 for item in outcome["results"] if item.get("ok"))
print(f"Копии по расписанию {outcome['slot']}: {done} из {len(outcome['results'])} успешно")
for item in outcome["results"]:
if not item.get("ok"):
print(f" {item['vm']}: {item.get('message')}")
if __name__ == "__main__": # запускает virtuality-backup.timer
main()
+22 -21
Просмотреть файл
@@ -140,13 +140,6 @@ def vm_disks(name: str) -> list[dict[str, str]]:
return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "disk" and disk["source"]]
def vm_cdroms_with_media(name: str) -> list[dict[str, str]]:
result = run(["virsh", "domblklist", name, "--details"], timeout=10)
if not result["ok"]:
return []
return [disk for disk in presenters.parse_domblklist(result["stdout"]) if disk["device"] == "cdrom" and disk["source"]]
def existing_parent(path: Path) -> Path:
"""Сам путь или ближайший существующий родитель — чтобы спросить у ФС о разделе."""
target = path
@@ -227,6 +220,7 @@ def describe_backup(vm: str, backup_id: str, path: Path, meta: dict[str, Any]) -
"size_bytes": size,
"size": format_bytes(size),
"version": str(meta.get("version") or ""),
"auto": meta.get("auto") is True,
"complete": complete,
}
@@ -358,12 +352,13 @@ def reserve_backup_dir(vm_name: str) -> str:
# ---------------------------------------------------------------- создание копии
def create_backup(vm_name: str, note: str = "", wait: bool = False) -> dict[str, Any]:
def create_backup(vm_name: str, note: str = "", wait: bool = False, auto: bool = False) -> dict[str, Any]:
"""Поставить создание копии в очередь и вернуть операцию.
Проверки (машина есть, диски есть, места хватает) выполняются сразу и
поднимают BackupError с понятным текстом. С wait=True копия делается
в текущем потоке — удобно для расписания."""
в текущем потоке — так её делает расписание (auto=True: такие копии
расписание само удаляет, оставляя последние N)."""
cancel_late_restart(vm_name)
if not core.valid_vm_name(vm_name):
raise BackupError("Некорректное имя машины")
@@ -387,7 +382,8 @@ def create_backup(vm_name: str, note: str = "", wait: bool = False) -> dict[str,
raise BackupError(f"Недостаточно места для копии: нужно около {format_bytes(need)}, свободно {format_bytes(free)}. Удалите старые копии или освободите место на сервере.", "space")
backup_id = reserve_backup_dir(vm_name)
try:
operation = core.new_operation("backup", f"Резервная копия {vm_name}", "Ожидает начала", vm_name=vm_name, backup_id=backup_id, note=note, disks=plan, size_bytes=need)
title = f"Резервная копия {vm_name}" + (" по расписанию" if auto else "")
operation = core.new_operation("backup", title, "Ожидает начала", vm_name=vm_name, backup_id=backup_id, note=note, disks=plan, size_bytes=need, auto=auto)
except Exception:
remove_backup_dir(backup_root() / vm_name / backup_id)
raise
@@ -473,6 +469,9 @@ def restart_after_late_shutdown(operation: dict[str, Any], vm: str) -> None:
core.append_operation_log(operation["id"], "Наблюдение снято: машиной занялись вручную или новой задачей.")
return
if not core.vm_is_running(vm):
if cancelled.is_set(): # «Выключить» нажали, пока virsh отвечал на вопрос о состоянии
core.append_operation_log(operation["id"], "Наблюдение снято: машиной занялись вручную или новой задачей.")
return
if any(other["id"] != operation["id"] for other in core.active_operations_for(vm, OPERATION_TYPES)):
core.append_operation_log(operation["id"], "Машина выключилась, но с ней уже работает другая задача — не запускаем.")
return
@@ -552,7 +551,7 @@ def backup_worker(operation: dict[str, Any]) -> None:
log(f"qemu-img завершился с кодом {code}.")
raise BackupError(f"Не удалось скопировать диск {disk['target']} — подробности в журнале задачи")
size = sum(item.stat().st_size for item in dest.iterdir() if item.is_file())
meta = {"vm": vm, "created_at": core.utc_now(), "disks": plan, "size_bytes": size, "version": core.APP_VERSION, "note": operation.get("note", "")}
meta = {"vm": vm, "created_at": core.utc_now(), "disks": plan, "size_bytes": size, "version": core.APP_VERSION, "note": operation.get("note", ""), "auto": operation.get("auto") is True}
(dest / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
log(f"Копия готова: {format_bytes(size)} (сжатие включено).")
except Exception as exc: # noqa: BLE001 — любая ошибка = операция с понятным текстом
@@ -685,16 +684,12 @@ def remove_vm(operation: dict[str, Any], name: str) -> None:
result = run(["virsh", "destroy", name], timeout=30)
if not result["ok"]:
raise BackupError("Не удалось выключить старую машину: " + core.cmd_error(result, "virsh destroy"))
for cdrom in vm_cdroms_with_media(name):
# Иначе `undefine --remove-all-storage` удалит и вставленный ISO-образ.
result = run(["virsh", "change-media", name, cdrom["target"], "--eject", "--config"], timeout=20)
if not result["ok"]:
raise BackupError(f"Не удалось извлечь образ из привода {cdrom['target']}: " + core.cmd_error(result, "virsh change-media"))
log(f"Образ извлечён из привода {cdrom['target']}, чтобы он не был удалён вместе с машиной.")
result = run(["virsh", "undefine", name, "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"], timeout=180)
if not result["ok"]:
raise BackupError("Не удалось удалить старую машину: " + core.cmd_error(result, "virsh undefine"))
ok, error, _removed, kept = core.undefine_vm(name) # вставленный ISO остаётся на месте
if not ok:
raise BackupError("Не удалось удалить старую машину: " + error)
log(f"Старая машина {name} удалена вместе с дисками.")
if kept:
log("Диски вне папки машин не удалены: " + ", ".join(str(path) for path in kept))
def restore_worker(operation: dict[str, Any]) -> None:
@@ -774,8 +769,11 @@ def backups_page(request: Request):
auth = core.require_auth(request)
if auth:
return auth
from features import backup_schedule # расписание само импортирует этот модуль
items = list_backups()
context = {
"schedule": backup_schedule.overview(),
"groups": group_by_vm(items, existing_vm_names()),
"storage": storage_summary(items),
"running": [op for op in core.running_operations() if op.get("type") in ("backup", "restore")],
@@ -838,8 +836,11 @@ def vm_backups_page(request: Request, name: str):
return auth
if not core.valid_vm_name(name) or not core.vm_exists(name):
return RedirectResponse(url="/", status_code=303)
from features import backup_schedule
items = list_backups(name)
context = {"vm": vm_summary(name), "backups": items, "storage": storage_summary(), "active": active_operation(name), **page_messages(request)}
schedule = backup_schedule.overview()
context = {"vm": vm_summary(name), "backups": items, "storage": storage_summary(), "active": active_operation(name), "schedule": schedule, "scheduled": backup_schedule.covers(schedule, name), **page_messages(request)}
return core.render(request, "vm_backups.html", context)
+2 -2
Просмотреть файл
@@ -783,9 +783,9 @@ def api_cancel_operation(request: Request, operation_id: str):
return {"ok": True, "operation": read_operation(operation_id) or operation}
@router.on_event("startup")
def close_stale_downloads() -> None:
"""После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part."""
"""После перезапуска панели потоков загрузки нет: закрываем такие операции и убираем их .part.
Вызывается из app.run_startup_tasks() при старте панели."""
reap_stale_downloads()
+11
Просмотреть файл
@@ -436,6 +436,17 @@ def add_port_forward(vm_name: str, guest_ip: str, external_port: Any, guest_port
return normalize_forward(forward)
def remove_vm_forwards(vm_name: str) -> int:
"""Машина удалена: её пробросы вели бы на адрес, который потом может достаться другой машине."""
items = load_port_forwards()
kept = [item for item in items if item.get('vm_name') != vm_name]
if len(kept) == len(items):
return 0
save_port_forwards(kept)
apply_port_forwards()
return len(items) - len(kept)
def delete_port_forward(forward_id: str) -> None:
items = [item for item in load_port_forwards() if item.get('id') != forward_id]
save_port_forwards(items)
+11
Просмотреть файл
@@ -242,6 +242,17 @@ def port_service(port: int) -> str:
return WELL_KNOWN_PORTS.get(int(port or 0), "")
MONTHS_GENITIVE = ("января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря")
def short_date(timestamp: float, now: datetime | None = None) -> str:
"""«27 сентября», для прошлых лет — «27 сентября 2025»."""
moment = datetime.fromtimestamp(timestamp)
now = now or datetime.now()
text = f"{moment.day} {MONTHS_GENITIVE[moment.month - 1]}"
return text if moment.year == now.year else f"{text} {moment.year}"
def format_bytes(value: int | float) -> str:
value = float(value or 0)
for unit in ("Б", "КБ", "МБ", "ГБ", "ТБ"):
+22
Просмотреть файл
@@ -333,6 +333,28 @@ hr { border: 0; border-top: 1px solid var(--border); margin: 20px 0; }
.option input:checked + .option-body .option-check { border: 5px solid var(--brand); }
.option-title { font-weight: 650; font-size: 14px; }
.option-text { color: var(--text-2); font-size: 12.5px; }
/* Выбор образа в форме создания машины */
.image-picker { display: grid; gap: 10px; }
.image-list { display: grid; gap: 8px; max-height: 360px; overflow-y: auto; padding: 2px; }
.image-option { position: relative; display: block; cursor: pointer; }
.image-option[hidden] { display: none; }
.image-option input { position: absolute; opacity: 0; pointer-events: none; }
.image-option-body { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border: 1.5px solid var(--border); border-radius: var(--radius); background: var(--surface); transition: border-color .15s, background .15s; }
.image-option:hover .image-option-body { border-color: var(--border-strong); }
.image-option input:checked + .image-option-body { border-color: var(--brand); background: color-mix(in srgb, var(--brand-soft) 55%, var(--surface)); box-shadow: 0 0 0 4px var(--focus); }
.image-option input:focus-visible + .image-option-body { box-shadow: 0 0 0 4px var(--focus); }
.image-option input:checked + .image-option-body .option-check { border: 5px solid var(--brand); }
.image-option .grow { flex: 1; display: grid; gap: 2px; min-width: 0; }
.image-title { font-weight: 650; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.image-meta { color: var(--text-3); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 560px) {
.image-option .list-icon { display: none; }
.image-title, .image-meta { white-space: normal; overflow-wrap: anywhere; }
}
/* Расписание копий: день недели и список машин видны, только когда нужны (без :has — видны всегда). */
.schedule-form:has(input[name=frequency][value=daily]:checked) [data-when=weekly],
.schedule-form:has(input[name=machines][value=all]:checked) [data-when=chosen] { display: none; }
.option-specs { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
/* segmented */
+2
Просмотреть файл
@@ -61,4 +61,6 @@
<symbol id="i-history" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" /></symbol>
<symbol id="i-notebook-pen" viewBox="0 0 24 24"><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4" /><path d="M2 6h4" /><path d="M2 10h4" /><path d="M2 14h4" /><path d="M2 18h4" /><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z" /></symbol>
<symbol id="i-expand" viewBox="0 0 24 24"><path d="m15 15 6 6" /><path d="m15 9 6-6" /><path d="M21 16v5h-5" /><path d="M21 8V3h-5" /><path d="M3 16v5h5" /><path d="m3 21 6-6" /><path d="M3 8V3h5" /><path d="M9 9 3 3" /></symbol>
<symbol id="i-calendar" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
<symbol id="i-calendar-days" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /><path d="M8 13h.01" /><path d="M12 13h.01" /><path d="M16 13h.01" /><path d="M8 17h.01" /><path d="M12 17h.01" /><path d="M16 17h.01" /></symbol>
</svg>

До

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

После

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

+34 -8
Просмотреть файл
@@ -300,6 +300,8 @@
const file = input.files[0];
if (!valid(file)) { setError('Выберите файл'); return; }
const started = Date.now();
// FormData собираем до setActive: заблокированное поле файла браузер в отправку не включает.
const data = new FormData(form);
xhr = new XMLHttpRequest();
setActive(true); progress.hidden = false; progress.classList.remove('success', 'danger');
statusEl.textContent = 'Загрузка на сервер…';
@@ -337,7 +339,7 @@
xhr.open('POST', form.action);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.send(new FormData(form));
xhr.send(data);
});
showFile();
}
@@ -381,7 +383,7 @@
messageEl.textContent = op.message || '';
if (logEl) { const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40; logEl.textContent = op.log_tail || ''; if (atBottom) logEl.scrollTop = logEl.scrollHeight; }
render();
if (done()) { clearInterval(timer); clearInterval(tick); if (!wasDone) { toast(status === 'success' ? 'Операция завершена' : 'Операция завершилась с ошибкой', status === 'success' ? 'success' : 'danger'); $$('[data-op-done]').forEach((el) => { el.hidden = false; }); } }
if (done()) { clearInterval(timer); clearInterval(tick); if (!wasDone) { toast(status === 'success' ? 'Операция завершена' : 'Операция завершилась с ошибкой', status === 'success' ? 'success' : 'danger'); $$(`[data-op-done="${status}"]`).forEach((el) => { el.hidden = false; }); if (status === 'error' && $('[data-op-done="error"][data-op-reload]')) setTimeout(() => location.reload(), 1500); } }
} catch (_) {}
};
const timer = setInterval(poll, 1500);
@@ -433,7 +435,8 @@
const field = (name) => form.elements[name];
const value = (name) => { const el = field(name); if (!el) return ''; if (el instanceof RadioNodeList) return el.value; return el.value; };
const setSummary = (key, text) => $$(`[data-summary="${key}"]`).forEach((el) => { el.textContent = text || '—'; });
const optionText = (select) => select && select.selectedIndex >= 0 ? select.options[select.selectedIndex].dataset.label || select.options[select.selectedIndex].text : '';
// Образы выбираются карточками (радиокнопки): берём отмеченную.
const picked = (name) => $(`input[name="${name}"]:checked`, form);
function sync() {
const source = value('source_type');
@@ -452,9 +455,7 @@
// Default login of the chosen cloud image (ubuntu, debian, …) until the user types a name.
const login = field('ci_user');
if (login && login.dataset.autoLogin === '1') {
const image = field('disk_image_path');
const option = image && image.selectedIndex >= 0 ? image.options[image.selectedIndex] : null;
login.value = option?.dataset.login || 'admin';
login.value = picked('disk_image_path')?.dataset.login || 'admin';
}
const preset = value('preset');
const custom = preset === 'custom';
@@ -467,7 +468,7 @@
$$('[data-custom-resources] input', form).forEach((input) => { input.readOnly = !custom; });
const memory = Number(value('memory')) || 0;
setSummary('name', value('name'));
setSummary('source', source === 'disk_image' ? optionText(field('disk_image_path')) : optionText(field('iso_path')));
setSummary('source', picked(source === 'disk_image' ? 'disk_image_path' : 'iso_path')?.dataset.label || '');
setSummary('cpu', `${value('vcpus')} ${Number(value('vcpus')) === 1 ? 'ядро' : 'ядра'}`);
setSummary('memory', memory >= 1024 ? `${+(memory / 1024).toFixed(1)} ГБ` : `${memory} МБ`);
setSummary('disk', `${value('disk_size')} ГБ`);
@@ -476,8 +477,33 @@
$$('[data-create-button]').forEach((btn) => { btn.disabled = !hasSource || !value('name'); });
$$('[data-missing-source]').forEach((el) => { el.hidden = hasSource || el.dataset.missingSource !== source; });
}
// Установщик Windows панель узнаёт по ISO (data-os) и сама выбирает тип, пока пользователь не выбрал его руками.
const guessOs = () => {
const guess = picked('iso_path')?.dataset.os || '';
const hint = $('[data-os-hint]', form);
if (form.dataset.osManual) return;
const radio = $(`input[name=os_type][value="${guess || 'linux'}"]`, form);
if (radio) radio.checked = true;
if (hint) hint.hidden = !guess;
};
form.addEventListener('input', (event) => { if (event.target.name === 'ci_user') event.target.dataset.autoLogin = event.target.value ? '0' : '1'; sync(); });
form.addEventListener('change', sync);
form.addEventListener('change', (event) => {
if (event.target.name === 'os_type') { form.dataset.osManual = '1'; const hint = $('[data-os-hint]', form); if (hint) hint.hidden = true; }
if (event.target.name === 'iso_path') guessOs();
sync();
});
// Поиск по образам, когда их много.
$$('[data-image-picker]', form).forEach((picker) => {
const search = $('[data-image-search]', picker);
if (!search) return;
search.addEventListener('input', () => {
const query = search.value.trim().toLowerCase();
let shown = 0;
$$('.image-option', picker).forEach((item) => { item.hidden = Boolean(query) && !item.dataset.search.includes(query); shown += item.hidden ? 0 : 1; });
$('[data-image-empty]', picker).hidden = shown > 0;
});
});
guessOs();
sync();
}
+26
Просмотреть файл
@@ -0,0 +1,26 @@
{% from "_ui.html" import icon %}
{#- Выбор образа карточками. Ожидает: picker_name (имя поля), picker_items (list_iso_files или list_disk_image_files),
picker_selected (путь выбранного), picker_icon. Поиск появляется, когда образов больше шести. -#}
<div class="image-picker" data-image-picker>
{% if picker_items|length > 6 %}
<input class="input" type="search" placeholder="Найти образ по названию" aria-label="Найти образ" data-image-search>
{% endif %}
<div class="image-list" role="radiogroup">
{% for item in picker_items %}
{% set title = item.title or item.name %}
<label class="image-option" data-search="{{ (title ~ ' ' ~ item.name ~ ' ' ~ (item.system or ''))|lower }}">
<input type="radio" name="{{ picker_name }}" value="{{ item.path }}" data-label="{{ title }}" data-os="{{ item.os_type or '' }}" data-login="{{ item.login or '' }}" {% if picker_selected == item.path or (not picker_selected and loop.first) %}checked{% endif %}>
<span class="image-option-body">
<span class="list-icon brand">{{ icon(picker_icon) }}</span>
<span class="grow">
<span class="image-title">{{ title }}</span>
<span class="image-meta">{% if item.title %}{{ item.name }} · {% endif %}{{ item.size_label or item.size }}{% if item.date_label %} · загружен {{ item.date_label }}{% endif %}</span>
</span>
{% if item.system %}<span class="tag">{{ item.system }}</span>{% endif %}
<span class="option-check"></span>
</span>
</label>
{% endfor %}
</div>
<p class="subtle small" data-image-empty hidden>Образов с таким названием нет.</p>
</div>
+109
Просмотреть файл
@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% from "_ui.html" import icon, badge, alert %}
{% block page_header %}
<div class="page-header">
<div>
<div class="eyebrow"><a href="/backups">{{ icon('arrow-left', 'icon-sm') }} Резервные копии</a></div>
<h1>Расписание копий</h1>
<p class="lead">Сервер сам делает копии машин в выбранное время и хранит только последние, чтобы копии не заняли весь диск.</p>
</div>
</div>
{% endblock %}
{% block alerts %}
{% if error %}{{ alert('danger', error) }}{% endif %}
{% endblock %}
{% block content %}
<section class="grid grid-main">
<article class="card">
<form method="post" action="/backups/schedule" class="schedule-form">
<div class="card-body form-grid">
<label class="switch-row" style="cursor:pointer">
<span class="switch-text"><strong>Делать копии автоматически</strong><span>Выключите, чтобы приостановить расписание. Уже сделанные копии останутся.</span></span>
<input type="checkbox" name="enabled" value="1" {% if form.enabled %}checked{% endif %} style="width:20px;height:20px;accent-color:var(--brand)">
</label>
<div class="field">
<span class="label">Как часто</span>
<div class="options" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr))">
<label class="option">
<input type="radio" name="frequency" value="daily" {% if form.frequency == 'daily' %}checked{% endif %}>
<span class="option-body"><span class="option-top"><span class="option-icon">{{ icon('calendar') }}</span></span><span class="option-title">Каждый день</span><span class="option-text">Для машин, в которых что-то меняется каждый день: сайты, базы, файлы.</span></span>
</label>
<label class="option">
<input type="radio" name="frequency" value="weekly" {% if form.frequency == 'weekly' %}checked{% endif %}>
<span class="option-body"><span class="option-top"><span class="option-icon">{{ icon('calendar-days') }}</span></span><span class="option-title">Раз в неделю</span><span class="option-text">Если машины меняются редко или места под копии немного.</span></span>
</label>
</div>
</div>
<div class="grid grid-2" style="align-items:start">
<div class="field" data-when="weekly">
<label for="weekday">День недели</label>
<select class="select" id="weekday" name="weekday">
{% for day in weekdays %}<option value="{{ loop.index0 }}" {% if form.weekday == loop.index0 %}selected{% endif %}>{{ day }}</option>{% endfor %}
</select>
</div>
<div class="field">
<label for="time">Время</label>
<input class="input" id="time" type="time" name="time" value="{{ form.time }}" required>
<span class="hint">По часам сервера. Лучше ночью, когда машинами никто не пользуется.</span>
</div>
</div>
<div class="field">
<span class="label">Какие машины копировать</span>
<div class="options" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr))">
<label class="option">
<input type="radio" name="machines" value="all" {% if form.all_vms %}checked{% endif %}>
<span class="option-body"><span class="option-title">Все машины</span><span class="option-text">Новые машины тоже попадут в копии сами.</span></span>
</label>
<label class="option">
<input type="radio" name="machines" value="chosen" {% if not form.all_vms %}checked{% endif %}>
<span class="option-body"><span class="option-title">Только отмеченные</span><span class="option-text">Например, без тестовых машин, которые не жалко.</span></span>
</label>
</div>
<div class="stack-sm" data-when="chosen">
{% for name in vm_names %}
<label class="check"><input type="checkbox" name="vms" value="{{ name }}" {% if name in form.vms %}checked{% endif %}><span><strong>{{ name }}</strong></span></label>
{% else %}
<p class="subtle small">Машин на сервере пока нет.</p>
{% endfor %}
</div>
</div>
<div class="field" style="max-width:280px">
<label for="keep">Сколько копий хранить</label>
<input class="input" id="keep" type="number" name="keep" value="{{ form.keep }}" min="1" max="{{ keep_max }}" required>
<span class="hint">Для каждой машины. Более старые автоматические копии удаляются сами.</span>
</div>
{{ alert('info', 'На время копирования работающая машина аккуратно выключается, а потом запускается снова. Машины копируются по очереди, так что каждая стоит только пока копируется она сама.') }}
</div>
<div class="card-footer">
<a class="btn" href="/backups">Отмена</a>
<button type="submit" class="btn btn-primary">{{ icon('check') }}Сохранить</button>
</div>
</form>
</article>
<aside class="stack">
<article class="card">
<div class="card-header"><h2>Как это работает</h2></div>
<div class="checklist">
{% set steps = [
('Старые копии удаляются сами', 'После удачной новой копии у машины остаются последние копии по расписанию, сколько вы указали. Копии, сделанные вручную, не удаляются.'),
('Неудачная копия ничего не удаляет', 'Если места не хватило или машина не выключилась, прежние копии остаются. Итог виден на странице «Резервные копии».'),
('Если сервер был выключен', 'Пропущенную копию сервер сделает в течение двух часов после включения. Если включили позже, копия будет в следующий раз.'),
('Обновления не мешают', 'Копии делает отдельная служба сервера, а не панель. Перезапуск панели их не прерывает.'),
] %}
{% for title, text in steps %}
<div class="check-item success">{{ icon('circle-check') }}<div><strong>{{ title }}</strong><span>{{ text }}</span></div></div>
{% endfor %}
</div>
</article>
</aside>
</section>
{% endblock %}
+48 -1
Просмотреть файл
@@ -39,6 +39,53 @@
</article>
</section>
<article class="card mt-3" id="schedule">
<div class="card-header">
<div class="row">
<span class="vm-avatar">{{ icon('clock') }}</span>
<div>
<h2>Автоматические копии {{ badge('включены', 'success', cls='hide-sm') if schedule.enabled else badge('выключены', 'neutral', False, cls='hide-sm') }}</h2>
{% if schedule.enabled %}
<p>{{ schedule.when|capitalize }} · {{ 'все машины' if schedule.all_vms else schedule.vms|join(', ') }} · хранить {{ schedule.keep }} {{ plural(schedule.keep, 'последнюю копию', 'последние копии', 'последних копий') }}</p>
{% else %}
<p>Сервер может сам копировать машины каждую ночь или раз в неделю и удалять старые копии.</p>
{% endif %}
</div>
</div>
<a class="btn btn-sm" href="/backups/schedule">{{ icon('settings', 'icon-sm') }}{{ 'Изменить' if schedule.enabled else 'Настроить' }}</a>
</div>
{% if schedule.enabled or schedule.last %}
<div class="list">
{% if schedule.enabled %}
<div class="list-item">
<span class="list-icon brand">{{ icon('clock') }}</span>
<div class="grow"><div class="title">Следующая копия: {{ schedule.next }}</div><div class="meta">Работающие машины на время копирования выключаются и потом запускаются снова</div></div>
</div>
{% endif %}
{% if schedule.missed %}
<div class="list-item">
<span class="list-icon info">{{ icon('info') }}</span>
<div class="grow"><div class="title">Копия {{ schedule.missed }} пропущена</div><div class="meta">В это время сервер был выключен, копия будет сделана в следующий раз</div></div>
</div>
{% endif %}
{% if schedule.last %}
{% set last = schedule.last %}
<div class="list-item">
<span class="list-icon {{ 'info' if last.running else 'danger' if last.failed else 'success' }}">{{ icon('activity' if last.running else 'triangle-alert' if last.failed else 'circle-check') }}</span>
<div class="grow">
<div class="title">
{% if last.running %}Идёт копирование по расписанию {{ last.when }}: готово {{ last.count }}
{% elif not last.count %}Запуск {{ last.when }}: машин для копирования не было
{% else %}Запуск {{ last.when }}: скопировано {{ last.ok }} из {{ last.count }} {{ plural(last.count, 'машины', 'машин', 'машин') }}{% endif %}
</div>
{% for item in last.failed %}<div class="meta"><strong>{{ item.vm }}</strong>: {{ item.message }}</div>{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
</article>
{% if groups %}
<div class="stack mt-3">
{% for g in groups %}
@@ -75,7 +122,7 @@
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('info') }}<h3>Как это работает</h3></div>
<p class="muted small">Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной: под новым названием — с новыми сетевыми адресами, чтобы не мешать оригиналу; при замене — с прежними. Копии хранятся в папке <code>/var/lib/virtuality/backups</code>.</p>
<p class="muted small">Работающая машина на время копирования аккуратно выключается и потом запускается снова. При восстановлении машина создаётся выключенной: под новым названием — с новыми сетевыми адресами, чтобы не мешать оригиналу; при замене — с прежними. Копии хранятся в папке <code>/var/lib/virtuality/backups</code>. Копии по расписанию сервер удаляет сам, оставляя последние. Копии, сделанные вручную, лежат, пока вы их не удалите.</p>
</div>
</article>
</section>
+2 -2
Просмотреть файл
@@ -101,9 +101,9 @@
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить {{ vm.name }} принудительно?" data-confirm-button="Выключить" data-toast="Машина {{ vm.name }} выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}Выключить принудительно</button></form>
{% elif vm.state != 'paused' %}
<form method="post" action="/vm/{{ vm.name }}/start" data-toast="Машина {{ vm.name }} запускается"><button class="menu-item">{{ icon('play') }}Запустить</button></form>
<div class="menu-sep"></div>
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="Машина и все её диски будут удалены без возможности восстановления." data-confirm-title="Удалить {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}Удалить машину</button></form>
{% endif %}
<div class="menu-sep"></div>
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="{% if vm.state != 'shut off' %}Машина {{ vm.name }} работает: она будет сразу выключена, как при отключении питания, а затем удалена вместе со всеми дисками.{% else %}Машина {{ vm.name }} будет удалена вместе со всеми дисками.{% endif %} Отменить это нельзя. Резервные копии машины останутся, из них её можно восстановить." data-confirm-title="Удалить {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}Удалить машину</button></form>
</div>
</details>
</div>
+1 -1
Просмотреть файл
@@ -38,7 +38,7 @@
<ul>
<li>Экран открывается в новой вкладке браузера — это как монитор, подключённый к машине.</li>
<li>Если курсор «застрял» внутри экрана, нажмите <code>Ctrl + Alt</code>.</li>
<li>После установки системы извлеките установочный диск на странице машины → «Диски и загрузка».</li>
<li>После установки машина перезагрузится уже с диска: он стоит первым в порядке загрузки. Установочный образ можно извлечь на странице машины → «Диски и загрузка».</li>
</ul>
</div></article>
+8 -1
Просмотреть файл
@@ -25,10 +25,17 @@
<div class="progress lg {{ 'success' if operation.status == 'success' else 'danger' if operation.status == 'error' else '' }}" data-op="progress" style="height:10px"><span data-op="bar" style="width:{{ operation.progress|default(0) }}%"></span></div>
<p class="muted" data-op="message">{{ operation.message }}</p>
{% if operation.vm_name %}
<div class="row" data-op-done {% if operation.status != 'success' %}hidden{% endif %}>
<div class="row" data-op-done="success" {% if operation.status != 'success' %}hidden{% endif %}>
<a class="btn btn-primary" href="/vm/{{ operation.vm_name }}/console" target="_blank" rel="noopener">{{ icon('monitor') }}Открыть экран машины</a>
<a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a>
</div>
<div class="row" data-op-done="error" {% if operation.type == 'vm_create' %}data-op-reload{% endif %} {% if operation.status != 'error' %}hidden{% endif %}>
{% if operation.type == 'vm_create' and not vm_present %}
<a class="btn btn-primary" href="{{ retry_url }}">{{ icon('rotate-cw') }}Создать заново</a>
{% elif vm_present %}
<a class="btn" href="/vm/{{ operation.vm_name }}">Перейти к машине</a>
{% endif %}
</div>
{% endif %}
</div>
</article>
+1 -1
Просмотреть файл
@@ -41,7 +41,7 @@
</article>
<div class="alert info" data-update-running hidden>{{ icon('refresh-cw') }}<div><strong>Идёт обновление</strong><span data-update-state></span></div></div>
{% if not info.fetch_ok %}{{ alert('warning', 'Не удалось связаться с GitHub: ' ~ (info.fetch_error or 'нет ответа') ~ '. Проверьте доступ сервера в интернет.', 'Проверка не удалась') }}{% endif %}
{% if not info.fetch_ok %}{{ alert('warning', 'Не удалось связаться с сервером обновлений: ' ~ (info.fetch_error or 'нет ответа') ~ '. Проверьте доступ сервера в интернет.', 'Проверка не удалась') }}{% endif %}
<article class="card">
<div class="card-header"><div><h2>Что нового</h2><p>{{ 'Изменения, которые вы получите' if info.missing_versions else 'Последние изменения' }}</p></div></div>
+11
Просмотреть файл
@@ -70,6 +70,17 @@
</div>
</form>
</article>
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('history') }}<h3>Автоматические копии</h3></div>
{% if scheduled %}
<p class="muted small">Машина копируется {{ schedule.when }}, сервер хранит {{ schedule.keep }} {{ plural(schedule.keep, 'последнюю копию', 'последние копии', 'последних копий') }}. Следующая: {{ schedule.next }}.</p>
{% else %}
<p class="muted small">Эта машина не копируется автоматически.</p>
{% endif %}
<a class="btn btn-sm" href="/backups/schedule">{{ icon('settings', 'icon-sm') }}Расписание копий</a>
</div>
</article>
<article class="card">
<div class="card-body stack-sm">
<div class="row">{{ icon('clock') }}<h3>Сколько это займёт</h3></div>
+14 -10
Просмотреть файл
@@ -16,7 +16,7 @@
{% endblock %}
{% block content %}
<form method="post" action="/vm/create" class="grid grid-main" data-wizard data-has-isos="{{ '1' if isos else '0' }}" data-has-disks="{{ '1' if disk_images else '0' }}">
<form method="post" action="/vm/create" class="grid grid-main" data-wizard {% if form.name %}data-os-manual="1"{% endif %} data-has-isos="{{ '1' if isos else '0' }}" data-has-disks="{{ '1' if disk_images else '0' }}">
<div class="stack">
<article class="card step-card">
<div class="step-head"><span class="step-num">1</span><div><h2>Название и система</h2><p>Как назовём машину и что на неё установить</p></div></div>
@@ -47,12 +47,10 @@
</div>
<div class="field" data-show-when="source_type=iso">
<label for="iso_path">Установочный образ</label>
<span class="label">Установочный образ</span>
{% if isos %}
<select class="select" id="iso_path" name="iso_path">
{% for iso in isos %}<option value="{{ iso.path }}" data-label="{{ iso.name }}" {% if form.iso_path == iso.path %}selected{% endif %}>{{ iso.name }} · {{ iso.size }}</option>{% endfor %}
</select>
<span class="hint">Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
{% with picker_name = 'iso_path', picker_items = isos, picker_selected = form.iso_path, picker_icon = 'disc-3' %}{% include "_image_picker.html" %}{% endwith %}
<span class="hint">Образы хранятся на сервере отдельно от машин, один образ можно ставить сколько угодно раз. Нужен другой? <a href="/iso">Загрузите образ</a>.</span>
{% endif %}
</div>
<div class="field" data-show-when="source_type=iso">
@@ -65,14 +63,13 @@
</label>
{% endfor %}
</div>
<span class="hint" data-os-hint hidden>{{ icon('sparkles', 'icon-sm') }} Это установщик Windows, тип выбран сам. Если ошиблись, выберите другой.</span>
<span class="hint">От этого зависят виртуальные устройства: Windows получит совместимые диск и сеть, Windows 11 — UEFI и TPM.</span>
</div>
<div class="field" data-show-when="source_type=disk_image">
<label for="disk_image_path">Образ системы</label>
<span class="label">Образ системы</span>
{% if disk_images %}
<select class="select" id="disk_image_path" name="disk_image_path">
{% for image in disk_images %}<option value="{{ image.path }}" data-label="{{ image.title or image.name }}" data-login="{{ image.login }}" {% if form.disk_image_path == image.path %}selected{% endif %}>{{ image.title or image.name }} · {{ image.size }}</option>{% endfor %}
</select>
{% with picker_name = 'disk_image_path', picker_items = disk_images, picker_selected = form.disk_image_path, picker_icon = 'hard-drive' %}{% include "_image_picker.html" %}{% endwith %}
<span class="hint">Образ будет скопирован — оригинал останется для других машин.</span>
{% endif %}
</div>
@@ -122,6 +119,13 @@
<div class="field"><label for="memory">Память, МБ</label><input class="input" id="memory" type="number" name="memory" min="512" max="262144" step="256" value="{{ form.memory }}" required></div>
<div class="field"><label for="disk_size">Диск, ГБ</label><input class="input" id="disk_size" type="number" name="disk_size" min="4" max="4096" value="{{ form.disk_size }}" required></div>
</div>
<p class="subtle small">Свободно на сервере: {{ images_free }}. Диск машины занимает место постепенно, по мере заполнения.</p>
{% if form.overcommit %}
<label class="check danger">
<input type="checkbox" name="allow_overcommit" value="1" {% if form.allow_overcommit %}checked{% endif %}>
<span><strong>Всё равно создать диск на {{ form.disk_size }} ГБ</strong><span>На сервере свободно {{ images_free }}. Сразу столько места диск не займёт, поэтому создать его можно. Но если система внутри машины заполнит больше, чем есть на сервере, место закончится у всех машин и они остановятся. Надёжнее уменьшить диск или освободить место.</span></span>
</label>
{% endif %}
{% if profile.virtualization_warning %}{{ alert('warning', 'На этом сервере нет аппаратного ускорения — машины будут заметно медленнее.') }}{% endif %}
</div>
</article>
+1 -2
Просмотреть файл
@@ -33,9 +33,8 @@
{% if s.running %}
<form method="post" action="/vm/{{ vm.name }}/reboot" data-toast="Машина перезагружается"><button class="menu-item">{{ icon('rotate-cw') }}<span>Перезагрузить<small>Мягкая перезагрузка системы</small></span></button></form>
<form method="post" action="/vm/{{ vm.name }}/destroy" data-confirm="Машина будет выключена сразу, как при отключении питания. Несохранённые данные внутри неё пропадут." data-confirm-title="Выключить принудительно?" data-confirm-button="Выключить" data-toast="Машина выключена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('zap') }}<span>Выключить принудительно<small>Если система зависла</small></span></button></form>
{% else %}
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="Машина {{ vm.name }} и все её диски будут удалены без возможности восстановления." data-confirm-title="Удалить машину?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}<span>Удалить машину<small>Вместе с дисками</small></span></button></form>
{% endif %}
<form method="post" action="/vm/{{ vm.name }}/delete" data-confirm="{% if vm.state != 'shut off' %}Машина {{ vm.name }} работает: она будет сразу выключена, как при отключении питания, а затем удалена вместе со всеми дисками.{% else %}Машина {{ vm.name }} будет удалена вместе со всеми дисками.{% endif %} Отменить это нельзя. Резервные копии машины останутся, из них её можно восстановить." data-confirm-title="Удалить машину {{ vm.name }}?" data-confirm-button="Удалить навсегда" data-toast="Машина {{ vm.name }} удалена" data-toast-tone="warning"><button class="menu-item danger">{{ icon('trash-2') }}<span>Удалить машину<small>Вместе с дисками</small></span></button></form>
</div>
</details>
</div>
+11
Просмотреть файл
@@ -14,6 +14,9 @@ DEFAULT_BRANCH = os.environ.get('VIRTUALITY_UPDATE_BRANCH', 'main')
REMOTE = os.environ.get('VIRTUALITY_UPDATE_REMOTE', 'origin')
NODE_ENV = Path(os.environ.get('VIRTUALITY_NODE_ENV', '/var/lib/virtuality/config/web.env'))
CHANNELS = {'stable': 'Стабильный — выпущенные версии', 'main': 'Ранний доступ — все изменения'}
# Проект переехал с GitHub в Gitea StackWorks; ноды со старым адресом переключаются сами (как и scripts/update_target.sh).
DEFAULT_REPO_URL = 'https://git.stackworks.ru/viktor/virtuality.git'
LEGACY_REPO_URLS = ('https://github.com/viktor138irk/virtuality.git', 'https://github.com/viktor138irk/virtuality')
class UpdateError(Exception):
@@ -193,6 +196,13 @@ def git_log_between(base: str, head: str, limit: int = 50) -> list[dict[str, str
return rows
def migrate_remote() -> bool:
result = run_cmd(['git', 'remote', 'get-url', REMOTE], cwd=SOURCE_DIR)
if not result['ok'] or result['stdout'] not in LEGACY_REPO_URLS:
return False
return run_cmd(['git', 'remote', 'set-url', REMOTE, DEFAULT_REPO_URL], cwd=SOURCE_DIR)['ok']
def check_updates(fetch: bool = True) -> dict[str, Any]:
ensure_state_dir()
if not source_ready():
@@ -200,6 +210,7 @@ def check_updates(fetch: bool = True) -> dict[str, Any]:
fetch_result = {'ok': True, 'stdout': '', 'stderr': '', 'cmd': 'skip'}
if fetch:
migrate_remote()
fetch_result = run_cmd(['git', 'fetch', '--quiet', '--tags', '--force', REMOTE, DEFAULT_BRANCH], cwd=SOURCE_DIR, timeout=60)
channel = update_channel()