Автор SHA1 Сообщение Дата
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
Claude 526ef3effd CLAUDE.md: работа прямо в main, релизы — теги vX.Y.Z
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 18:13:49 +00:00
Виктор 3613c02b7c Virtuality 0.11.0: мастер настройки, снимки, резервные копии, быстрые машины из готовых образов (#2)
Virtuality 0.11.0: мастер настройки, снимки, резервные копии, быстрые машины из готовых образов
2026-09-26 03:12:31 +09:00
Claude e13887ad76 Тест успешного скачивания тоже ждёт снятия отметки потока
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 18:10:58 +00:00
Claude fb6fd2bc2d Тест отмены скачивания ждёт, пока поток снимет свои отметки (гонка в CI на Python 3.13)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 18:10:22 +00:00
Claude 03ca04073f Резервные копии: копию нельзя удалить, пока из неё идёт любое восстановление; наблюдатель позднего выключения отменяется
- защита от удаления проверяет все незавершённые восстановления этой копии (под любым именем машины)
- наблюдатель, который запускает машину после позднего выключения, снимается при «Выключить»/«Удалить»
  в панели и при новой копии, и не мешает другим задачам с этой машиной

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 18:07:20 +00:00
Claude 3ad50faa46 Первая загрузка образа была исправлена: libvirt больше не останавливается на вопросе о конфиге
Найдено полной установкой ISO в QEMU: панель ставится первой, и virtuality-ctl заранее создавал
/etc/default/libvirt-guests; при установке libvirt dpkg спрашивал, какую версию оставить, и падал.
- virtuality-ctl пишет этот файл только после установки libvirt
- apt ставит пакеты с --force-confdef/--force-confold, перед установкой доделывает прерванную (dpkg --configure -a)
- спиннер установщика не засоряет журнал первой загрузки
- проверка ISO в CI ищет русский пункт меню

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 18:05:11 +00:00
Claude d51b620d02 Merge branch 'fix/docs' into claude/production-system-image-smcxyr 2026-09-25 15:42:50 +00:00
Claude 6b4d4f899c cloud-init: временные файлы берут путь хранилища из core на момент вызова
Тесты без прав root падали: путь /var/lib/virtuality фиксировался при импорте модуля.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:42:02 +00:00
Claude 5465000f42 Документация была переписана под версию 0.11: README, образ, первая машина, архитектура, требования, планы
README описывает продукт как простую замену Proxmox для дома: три способа установки
(ISO из GitHub Actions или make iso, одна команда, вручную), первую загрузку и мастер
настройки, возможности, страницу «Настройки», команды virtuality-ctl, обновления и откат,
удаление, диагностику и разработку. Упоминания Cockpit, консольного dashboard, тестовых
скриптов, эмуляции ARM64 на x86 и PXE убраны; таблица скриншотов сохранена и дополнена
существующими снимками.

docs/IMAGE.md — сборка ISO и все параметры image/build-iso.sh, вопросы установщика,
этапы первой загрузки, тема GRUB, артефакты CI, проверка и запись на флешку.
docs/FIRST_VM.md — первая машина только через панель: Ubuntu 26.04 из каталога
с cloud-init, Windows 11 с ISO, экран, проброс портов, снимки, резервные копии.
docs/ARCHITECTURE.md — текущее устройство: модули web/, virtuality-ctl, задачи,
службы systemd, TLS, firewall, сеть NAT с резервациями, хранилище, обновления.
docs/REQUIREMENTS.md — оборудование, ОС, ARM64, сеть, браузер.
docs/ROADMAP.md — что сделано в 0.11 и что планируется.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:41:03 +00:00
Claude ee6a615df7 Демо-сервер не закрывает показательные скачивания при старте
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:39:41 +00:00
Claude a74e620192 Merge branch 'claude/downloads-review-fixes-smcxyr' into claude/production-system-image-smcxyr 2026-09-25 15:38:25 +00:00
Claude 01970f3071 Merge branch 'claude/backups-review-fixes' into claude/production-system-image-smcxyr 2026-09-25 15:38:25 +00:00
Claude c2dc32947f Скачивание образов было доработано по итогам ревью: зависшие операции, гонка запусков, отмена при проверке и распаковке
- Проверка «уже скачивается» идёт по всем операциям каталога (glob), а не по последним 50;
  скачивания без живого потока закрываются при старте (reap_stale_downloads), их .part удаляются,
  а отмена такой операции завершает её сразу, не дожидаясь потока.
- Два запуска одного файла больше не пишут в один .part: проверки и создание операции под замком,
  файл создаётся монопольно (O_EXCL), вторая копия завершается ошибкой, не трогая чужой файл;
  осиротевший .part без владельца убирается перед новым запуском.
- Отмена во время проверки контрольной суммы и распаковки .img.xz останавливает работу:
  операция завершается как отменённая, недокачанный файл, архив и недораспакованный образ удаляются.
- Битый .img.xz после неудачной распаковки удаляется с понятным сообщением, повторное скачивание разрешено;
  проверка «файл уже есть» учитывает распакованный .img и qcow2 после конвертации.
- Ссылки с кириллицей (домен IDNA, путь и параметры в процентах) открываются, некорректный порт
  и непонятные символы отклоняются сразу; ошибки сети описаны словами, исходный текст — в журнале.
- Прогресс раз в секунду перечитывает JSON операции, чтобы не затирать cancel_requested и замечать
  отмену из другого процесса; операции читаются один раз на страницу; отмена в карточке — не ошибка.
- Формулировки карточек и сообщения о недоступной архитектуре упрощены; добавлены тесты на каждое поведение.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:31:12 +00:00
Claude cb485b1d23 Резервные копии были доработаны по замечаниям ревью: своя папка у каждой задачи, замена машины только после распаковки дисков
- Папка копии занимается атомарно ещё в create_backup; вторая копия в ту же
  минуту получает суффикс -2, поэтому неудачная задача удаляет только свою папку.
- qemu-img info вызывается с -U (диск работающей машины занят QEMU), а ошибка
  теперь поднимается пользователю вместо тихих нулей в расчёте места.
- Проверки «с машиной уже идёт задача» переведены на core.active_operations_for;
  удаление копии, которую читает восстановление под другим именем, блокирует
  метка .restoring с id задачи (устаревшая метка после перезапуска игнорируется).
- Зависший qemu-img убивается по общему таймауту stream_cmd.
- Восстановление с заменой сначала распаковывает все диски во временные файлы
  и только потом удаляет старую машину; при ошибке она остаётся на месте.
  Освобождаемое место старой машины больше не учитывается в проверке.
- Отклонённая команда выключения сообщается как есть, машина на паузе
  сохраняется через managedsave и продолжает с того же места, а если машина
  выключилась уже после отказа задачи — панель запускает её снова.
- rewrite_xml при замене сохраняет uuid и mac, убирает <backingStore> и
  вынимает из привода образ, которого больше нет на сервере.
- Имена дисков из meta.json проверяются, процент занятого места считается от
  всего диска, убран двойной отклик при удалении, сообщения об ошибках
  копирования без жаргона, путь запуска из расписания указан через venv панели.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:27:46 +00:00
Claude 1d39029472 Гостевой агент был добавлен: машины из готовых образов ставят qemu-guest-agent, панель видит их IP и в домашней сети
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:27:25 +00:00
Claude 024fe5a267 Мастер настройки не навязывается серверам, обновлённым с прошлой версии
Он открывается сам только после новой установки; уже настроенные серверы запускают его из «Настроек».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:26:02 +00:00
Claude 43b7ea386f Обновление с 0.10 убирает старый экран-дашборд на tty1 и возвращает обычный вход
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:25:43 +00:00
Claude 554270fcb9 Контрастность светлой темы была исправлена: вторичный текст, бейджи состояний, выключенный переключатель
Цвета проверены по WCAG: текст не ниже 4.5:1, переключатель не ниже 3:1; тёмная тема не изменилась.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:25:09 +00:00
Claude c3912ca4e9 Заметки для Claude Code (CLAUDE.md) были добавлены
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:23:53 +00:00
Claude 0d34fe8c0b Скриншоты документации обновлены: мастер настройки, настройки, снимки, копии, каталог
Демо-сервер больше не закрывает показательные операции при старте.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:23:24 +00:00
Claude 90993a4115 Версия 0.11.0: запись в журнале изменений была добавлена
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:21:10 +00:00
Claude c5b576dbe7 Стабильный канал обновлений был добавлен; клон и увеличение диска защищены от спящих машин
- stable (по умолчанию): обновление до последнего тега vX.Y.Z; пока релизов нет — ветка main
- main: ранний доступ, все изменения; канал берётся из web.env (Настройки → Обновления)
- scripts/update_target.sh общий для автообновления и ручного обновления, ZIP-запасной путь для тегов
- страница «Обновления» показывает канал и целевую версию
- клонирование и увеличение диска недоступны, пока машина в спящем режиме

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:19:19 +00:00
Claude 7ec3dc6082 Исправлены ошибки второго раунда аудита: локаль virsh, имена машин, IP, ISO в приводе, консоль
- все команды запускаются с LC_ALL=C.UTF-8: русский перевод virsh ломал разбор состояния машин
- имя машины не может быть числом или UUID (virsh принял бы его за ID другой машины)
- IP без MAC машины не угадывается по чужой аренде DHCP; у выключенных машин нет устаревшего IP
- вставленный ISO определяется по устройству CD-ROM, а не по «.iso» в пути
- ISO, вставленный в привод машины, нельзя удалить — машина перестала бы запускаться
- на ARM-платах нет ложного предупреждения про флаги vmx/svm
- проверка машины в консоли не блокирует остальные запросы

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:17:23 +00:00
Claude e5ce9c4d41 Быстрое создание машин: cloud-init, тип системы для установщика, постоянный адрес в сети NAT
- готовые образы: пользователь, пароль (хеш SHA-512) и SSH-ключ через virt-install --cloud-init,
  диск увеличивается до выбранного размера, osinfo из каталога
- установка с ISO: выбор Linux / Windows 11 (UEFI + TPM) / Windows 10 / другая;
  Windows получает SATA-диск и сетевую карту e1000e
- каждая машина в NAT получает фиксированный MAC и резервацию DHCP — адрес не меняется,
  пробросы портов не ломаются; при удалении резервация снимается
- xorriso в пакетах ноды (для seed-образа cloud-init)
- tests/test_quickvm.py; тест каталога скачиваний сделан детерминированным

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:14:03 +00:00
Claude eb7b71f478 Операции, прерванные перезапуском панели, закрываются при старте; снимки: исправления по ревью
- core.interrupt_orphaned_operations() при запуске панели, active_operations_for() по всем задачам
- снимки: имена с пробелами, снимки вне панели без кнопок, --force только на «revert requires force»,
  память на паузе учитывается, защита от двойного нажатия, надпись «Создаём снимок…»

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:05:49 +00:00
Claude 2b7223f311 Merge branch 'feature/vmops' into claude/production-system-image-smcxyr
# Conflicts:
#	tests/conftest.py
#	tests/dev_server.py
#	web/app.py
#	web/static/app.css
#	web/static/icons.svg
2026-09-25 15:03:48 +00:00
Claude a43f96883e Merge branch 'feature/downloads' into claude/production-system-image-smcxyr
# Conflicts:
#	tests/dev_server.py
#	web/app.py
#	web/static/app.css
2026-09-25 15:02:19 +00:00
Claude 0e4b1783ec Исправлены ошибки по итогам аудита: формат образов, CD-ROM, действия с машинами, firewall, VNC
- формат образа диска определяется через qemu-img info (облачные .img — это qcow2)
- ISO подключается/извлекается через virsh change-media, без угадывания устройств
- действия start/shutdown/delete сообщают об ошибке virsh; удаление UEFI-машин (--nvram)
- правила iptables/ufw для удалённых пробросов портов теперь удаляются
- VNC машин слушает только 127.0.0.1; virtuality-ctl reconfigure чинит старые машины
- зелёное уведомление не показывается, если действие вернуло ошибку
- убраны смена архитектуры машины, эмуляция ARM64 на x86 и загрузка по PXE
- tests/test_audit_fixes.py

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:01:32 +00:00
Claude 34684634af Повседневные операции с машиной были добавлены: диск, клон, пауза, заметки, нагрузка
Модуль web/features/vmops.py (router подключён двумя строками в app.py):

- Увеличение диска на вкладке «Диски и загрузка»: размер из qemu-img info,
  только рост, проверка свободного места; выключенная машина — qemu-img resize,
  работающая — virsh blockresize. Диски вне /var/lib/virtuality не трогаются.
- Клонирование: страница /vm/{name}/clone с проверками (машина выключена,
  хватает места, имя свободно), фоновая операция virt-clone с явными --file
  в хранилище образов, прогресс по росту файлов копии, pool-refresh после.
- Пауза, продолжение, спящий режим (managedsave как операция) и удаление
  сохранённого состояния — /vm/{name}/ops/{action}; пункты меню на обзоре
  и странице машины, кнопка «Продолжить» для приостановленной машины.
- Заметки: название и описание через virsh desc (--new-desc=ТЕКСТ одним
  аргументом, чтобы текст вида «--edit» не стал опцией); название видно
  под именем машины на обзоре (virsh list --all --title, парсер в presenters).
- Живая нагрузка: /live/stats по virsh domstats для всех активных машин
  одним вызовом, CPU % по разнице cpu.time между опросами; шкалы в строках
  обзора и карточка «Нагрузка сейчас» на странице машины (panel.js, 5 с).

Тесты для всех парсеров и маршрутов (tests/test_vmops.py), демо-данные
в dev_server.py (три машины в разных состояниях, живые шкалы), значки
notebook-pen и expand в спрайте.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 15:00:41 +00:00
Claude fd7e53f8f4 Merge branch 'feature/backups' into claude/production-system-image-smcxyr
# Conflicts:
#	tests/conftest.py
#	tests/dev_server.py
#	web/app.py
#	web/templates/vm_detail.html
2026-09-25 14:56:15 +00:00
Claude 42ac092272 Merge branch 'feature/snapshots' into claude/production-system-image-smcxyr
# Conflicts:
#	web/app.py
2026-09-25 14:55:49 +00:00
Claude 2745a1280f Страница «Настройки» была добавлена: доступ, пароль, сеть машин, хранилище, время, обновления, сервер
- web/features/settings.py + settings.html: всё, что спрашивал мастер, меняется в одном месте
- перезагрузка/выключение с ожиданием возврата панели, архив настроек и отчёт для поддержки
- сообщения после редиректов показываются на любой странице (?message=, ?error=)
- virtuality-ctl set: настройки обновлений применяются без перезапуска панели
- tests/test_settings.py

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 14:55:31 +00:00
Claude 55f7d971f6 Первая загрузка образа переписана: панель ставится первой, прогресс виден в мастере
- virtuality_firstboot.sh: шаги panel → network → virtualization → tools → finish,
  прогресс в config/setup.json (в т.ч. имена пакетов apt), идемпотентный повтор
  после ошибки, /etc/issue с адресом панели
- install_web_panel.sh: поэтапная замена web/ с сохранением web.prev, офлайн
  virtualenv с pip из wheel, юниты/TLS/firewall через virtuality-ctl reconfigure,
  мастер настройки и для ручной установки
- virtuality-ctl url; путь к исходникам хранится в web.env
- build-iso.sh: wheel pip в образе, часовой пояс по GeoIP
- Cockpit, консольный dashboard, тестовая VM, clean_install, setup_bridge_br0,
  bootstrap.sh и setup_github_sync.sh были убраны (заменены мастером и virtuality-ctl)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 14:50:16 +00:00
Claude 4fd04be588 Скачивание образов по ссылке и из каталога готовых систем было добавлено
- web/features/downloads.py: фоновая операция типа «download» — urllib по 1 МиБ,
  таймаут 30 с, User-Agent Virtuality/<версия>, запись в .<имя>.part и переименование
  в конце; в сообщении скорость и остаток («245 МБ из 3,1 ГБ · 12 МБ/с · осталось 4 мин»);
  отмена между кусками (POST /api/operations/{id}/cancel и /downloads/{id}/cancel);
  проверка схемы, имени файла, занятости имени и свободного места (Content-Length + 512 МиБ);
  для каталога — проверка контрольной суммы (SHA256SUMS/SHA512SUMS/одиночный хеш),
  при несовпадении файл удаляется; после ISO — virsh pool-refresh virtuality-iso,
  после .img.xz — распаковка и запуск конвертации в qcow2 как при загрузке с компьютера.
- /iso и /disk-images: карточка «Скачать по ссылке», на /disk-images — «Каталог готовых
  систем» для процессора сервера с пометками «Рекомендуем», «Загружен», «Скачивается»;
  идущие скачивания вверху страниц с живым прогрессом (опрос раз в 2 с) и «Отменить».
- Тесты: парсер контрольных сумм, проверка ссылок и имён, загрузка с локального
  http-сервера, редирект, отмена, несовпадение суммы, нехватка места, .img.xz, маршруты.
- В ветку скопированы web/core.py, web/catalog.py, presenters.format_bytes и tests/fixtures
  из общей рабочей копии (без изменений), conftest подменяет run_cmd и каталоги и в core.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 14:49:43 +00:00
Claude 9cf8c3e4b7 Случайно добавленные рабочие копии .claude/worktrees были убраны из репозитория
Каталог .claude/ добавлен в .gitignore.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 14:37:12 +00:00
Claude 8a4474a305 Мастер первичной настройки и nodectl: добавлены пошаговый /setup, управление узлом через virtuality-ctl и тесты
- web/nodectl.py: факты об узле (сеть, диски, часовые пояса, KVM) и действия через virtuality-ctl
- web/features/setup.py + шаблоны setup*: мастер из 8 шагов (установка, часовой пояс, пароль, HTTPS, сеть, хранилище, обновления, итог)
- безопасное переключение на мост br0 с авто-откатом и подтверждением из браузера
- virtuality-ctl storage-use: перенос данных на отдельный диск
- панель перенаправляет на мастер до его завершения; демо-режим VIRTUALITY_DEV_SETUP
- tests/test_setup.py: 8 тестов на маршруты мастера

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 14:36:32 +00:00
Claude 2450278d93 Резервные копии машин были добавлены: создание, восстановление, удаление
Первый модуль-фича на общем core.py — web/features/backups.py,
подключён в app.py одной строкой include_router.

- копия = папка /var/lib/virtuality/backups/<машина>/<ГГГГММДД-ЧЧММ>/
  с vm.xml (virsh dumpxml --migratable), <диск>.qcow2 на каждый диск
  и meta.json (машина, дата, диски, размер, версия, заметка);
- создание — фоновая операция: свободное место проверяется заранее по
  qemu-img info, работающая машина мягко выключается (ждём до 3 минут,
  без принудительного выключения), диски копируются qemu-img convert -c
  с прогрессом из строк «(NN.NN/100%)», после чего машина запускается;
- восстановление — операция: диски распаковываются в images/<имя>[-<диск>]
  .qcow2, в XML меняется имя, убираются uuid, MAC и nvram, затем
  virsh define; восстановление под занятым именем требует галочку
  «заменить существующую» — старая машина удаляется с дисками, а ISO
  перед этим извлекается из привода, чтобы --remove-all-storage его не
  стёр;
- страницы: «Резервные копии» в меню (все копии по машинам, занятое и
  свободное место), вкладка «Резервные копии» на странице машины с
  формой «Создать копию» и заметкой, страница восстановления с
  понятным списком шагов;
- create_backup(vm, note, wait=True) можно вызывать без HTTP —
  задел под расписание (python3 web/features/backups.py web01 «заметка»);
- tests/conftest.py: каталог backups, патчи core.*, вывод virsh
  domblklist --details, dumpxml --migratable и qemu-img info из fixtures;
  tests/test_backups.py — 33 теста парсеров, воркеров и маршрутов;
  tests/dev_server.py показывает демо-копии.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 14:32:05 +00:00
Claude 4198c18ff4 Снимки машин (точки восстановления) были добавлены
Новая страница /vm/{name}/snapshots и вкладка «Снимки» на странице машины:
список внутренних снимков libvirt с описанием, датой, состоянием и меткой
«Текущий», создание снимка с автоматическим именем snap-ГГГГММДД-ЧЧММ,
возврат к снимку и удаление. Создание и возврат идут как фоновые задачи
с журналом; страница сама обновляется, когда задача завершится.

Ограничения и ошибки объясняются по-русски: не больше 8 снимков на машину,
только диски qcow2, предупреждение для машин с UEFI; известные ошибки libvirt
переводятся, а исходный текст показывается рядом. Возврат повторяется
с --force, если libvirt этого требует.

Модуль web/features/snapshots.py, парсеры с тестами на образцах вывода virsh,
fake_run_cmd в тестах расширен командами snapshot-*, demo-сервер показывает
снимки и идущую задачу. В спрайт добавлены иконки camera и history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 14:30:25 +00:00
Claude 7bf7a0f6b7 Утилита virtuality-ctl была добавлена
Единая точка управления нодой: пересборка systemd-юнитов из web.env
(порт, HTTPS с самоподписанным сертификатом и redirector HTTP→HTTPS,
автообновление, ежедневное обслуживание, libvirt-guests), смена пароля
и часового пояса, перезагрузка/выключение, архив настроек ноды и его
восстановление, откат панели после неудачного обновления, безопасное
подключение моста br0 с автооткатом через 2 минуты, отчёт для
поддержки, удаление. Установщик и панель будут использовать её вместо
собственных копий этой логики.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 14:26:03 +00:00
Claude e52967fba1 Загрузочное меню ISO было оформлено в стиле Virtuality
- графическая тема GRUB (фон, подсветка выбранного пункта, полоса
  автозапуска) в image/grub, подключается в build-iso.sh; при отсутствии
  модулей GRUB меню остаётся текстовым;
- пункты меню переведены: «Установить Virtuality X.Y.Z», «Загрузить
  с другого диска», «Настройки UEFI», «Проверить память»;
- tests/render_html.mjs рендерит HTML в PNG (для фона темы).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 14:22:11 +00:00
Claude 1e29134fb3 Подготовка к 0.11: общий модуль core.py, каталог облачных образов, образцы вывода virsh
- web/core.py: настройки, авторизация, run_cmd, хранилище фоновых операций
  и шаблоны вынесены в общий модуль, чтобы новые функции жили в отдельных
  модулях; app.py пока не переключён;
- web/catalog.py: официальные облачные образы Ubuntu 26.04/24.04,
  Debian 13, Alpine 3.23 (amd64/arm64) и типы ОС для установки с ISO;
- tests/fixtures: реалистичный вывод virsh для тестов будущих парсеров;
- presenters.format_bytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 14:04:11 +00:00
Виктор 873b32c517 Virtuality 0.10.0: новый интерфейс, установочный ISO на Ubuntu 26.04 LTS, подготовка к продакшену (#1)
Virtuality 0.10.0: новый интерфейс, установочный ISO на Ubuntu 26.04 LTS, подготовка к продакшену
2026-09-25 16:16:46 +09:00
Claude f20782c087 Сборка ISO была подключена к Pull Request в GitHub Actions
- образ собирается на каждом PR, который меняет image/, first boot
  или зависимости панели, а не только по тегу v*;
- после сборки проверяются загрузочные записи BIOS/UEFI, autoinstall.yaml
  и меню GRUB;
- перед сборкой освобождается место на раннере, устаревшие запуски
  отменяются; CI можно запустить вручную.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 07:08:38 +00:00
Claude 4098c270a4 Интерфейс панели был полностью перерисован
Новый дизайн в стиле коммерческих продуктов, рассчитанный на обычного
пользователя:
- светлая и тёмная темы (следует системной), шрифт Inter и иконки
  Lucide вшиты в панель — работает без интернета;
- общий каркас base.html и компоненты _ui.html вместо 14 независимых
  страниц; адаптивная вёрстка для телефона;
- простой язык: «Работает / Выключена», «Автозапуск», «Доступ из
  интернета» вместо running, autostart, DNAT; технические данные
  убраны во вкладку «Для специалистов»;
- главная: загрузка процессора, памяти и хранилища, карточки машин с
  главным действием и меню остальных, приветственный сценарий для
  первой машины;
- мастер создания машины из трёх шагов с готовыми конфигурациями;
- страница машины с вкладками, переключателем автозапуска и порядком
  загрузки кнопками;
- «Сеть и доступ»: открытие SSH, сайта, RDP и т. д. из списка,
  ручные IP машин, понятная диагностика;
- загрузка образов перетаскиванием с прогрессом и оставшимся временем;
- подтверждения опасных действий в диалоге и уведомления;
- раздел «Помощь»;
- web/presenters.py переводит вывод virsh и /proc в данные для
  страниц; tests/dev_server.py запускает панель с имитацией virsh.

Также: исправлено определение модели CPU на x86 («207» вместо
названия процессора), история версии 0.10.0 и документация обновлены
под Ubuntu 26.04 LTS, в README добавлены скриншоты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 07:01:41 +00:00
Claude c4cebd916b Центр обновлений больше не предлагает откат на старую версию
Раньше обновление считалось доступным при любом расхождении локального
коммита и origin/main. Нода со сборкой новее main (например, из
ISO-образа ветки) получала предложение «обновиться» на старую версию,
а ночное автообновление откатывало её само. Теперь обновление
предлагается и ставится, только если локальный коммит — предок
удалённого.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 07:01:30 +00:00
Claude 967f4c1a7e ISO был переведён на Ubuntu 26.04 LTS, установщик ноды стал переносимым
- build-iso.sh по умолчанию собирает образ на Ubuntu Server 26.04 LTS
  (Python 3.14), 24.04 остаётся через --ubuntu 24.04;
- install_virtuality_node.sh больше не падает на Ubuntu 26.04 и Debian 13:
  qemu-kvm стал виртуальным пакетом, поэтому QEMU выбирается по
  архитектуре, dnsutils заменён на bind9-dnsutils, а недоступные пакеты
  пропускаются с предупреждением;
- CI проверяет панель и на Python 3.14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 06:41:43 +00:00
Claude cadc17a6df Требования к reverse proxy были описаны в README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 06:37:58 +00:00
Claude 110a37f435 Версия проекта была обновлена до 0.10.0: тесты, CI и документация образа
- tests/: 55 тестов web-панели, авторизации, сети и обновлений,
  все страницы рендерятся на замоканных virsh/systemctl;
- GitHub Actions: pyflakes + pytest на Python 3.11–3.13, shellcheck,
  сборка ISO amd64/arm64 по тегу v* или вручную;
- Makefile: make check / make test / make iso;
- docs/IMAGE.md и README: установка с ISO, продакшен-настройки,
  разработка;
- updates/versions.json: описание версии 0.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 06:36:26 +00:00
Claude 85648739dc Все пункты загрузочного меню ISO были переименованы в установку Virtuality
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 06:33:17 +00:00
Claude d92b1b9bda Сборка установочного ISO-образа Virtuality была добавлена
image/build-iso.sh пересобирает официальный Ubuntu Server 24.04
live-server ISO (amd64/arm64) в установщик Virtuality:
- /autoinstall.yaml: установщик спрашивает только сеть, диск и
  пользователя, остальное автоматически; есть полностью
  автоматический режим --unattended;
- исходники Virtuality (git-репозиторий, чтобы работал центр
  обновлений) и Python-колёса для офлайн-установки панели;
- virtuality-firstboot.service при первой загрузке ставит KVM,
  libvirt, Cockpit, web-панель, vhealth и консольный dashboard,
  повторяя попытку до успеха.

Также: зависимости панели закреплены полностью, установщики работают
неинтерактивно и учитывают VIRTUALITY_USER, bootstrap.sh стал обёрткой
над install.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 06:31:55 +00:00
Claude 975ab4d1a0 Web-панель была подготовлена к продакшену
- исправлен NameError при создании VM: не был импортирован platform;
- на странице VM автозапуск всегда показывался выключенным;
- сообщения в redirect-URL теперь кодируются (кириллица, пробелы, &);
- проверка пароля вынесена в web/auth.py и работает на Python 3.13+,
  где модули crypt и spwd удалены (fallback на libcrypt и /etc/shadow);
- добавлены ограничение попыток входа, срок жизни сессии, флаг Secure
  для cookie, защита от cross-origin POST и заголовки безопасности;
- добавлен публичный /healthz с версией панели;
- правила проброса портов восстанавливаются после перезагрузки
  (virtuality-network.service);
- обновление из панели запускается через systemd-run и больше не
  убивается перезапуском virtuality-web;
- install_web_panel.sh сохраняет порт, пользователя и режим
  автообновления в /var/lib/virtuality/config/web.env, поэтому
  обновление больше не сбрасывает порт на 8088 и пользователя на viktor;
- поддержка офлайн-установки Python-зависимостей из wheels/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 06:26:51 +00:00
Claude 10a780d381 Патчи установки были встроены в исходники web-панели
Раньше web/app.py, web/network_core.py и web/static/app.css собирались
на каждой ноде цепочкой из ~25 patch-скриптов во время установки.
Теперь результат этой цепочки хранится прямо в репозитории, а
install_web_panel.sh просто копирует web/ без модификаций. Поведение
установленной панели не меняется. Неиспользуемые patch-скрипты удалены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 06:22:28 +00:00
viktor 5f2bdd921d docs: подробный README (разбор проекта при переносе в Gitea)
Старый README сохранён как README.upstream.md.
2026-08-20 04:18:36 +09:00
169 изменённых файлов: 15737 добавлений и 7961 удалений
+28 -19
Просмотреть файл
@@ -2,30 +2,39 @@ name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-latest
test:
name: Tests (Python ${{ matrix.python }})
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
# 3.12: Ubuntu 24.04, 3.13: Debian 13, 3.14: Ubuntu 26.04 (no crypt/spwd modules).
python: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: web/requirements.txt
- run: pip install -r web/requirements.txt -r requirements-dev.txt
- run: make lint-python test
- name: Проверка синтаксиса Python
run: python -m compileall -q web scripts
- name: Проверка синтаксиса bash-скриптов
run: |
bash -n install.sh bootstrap.sh install_virtuality_node.sh setup_github_sync.sh
for script in scripts/*.sh; do bash -n "$script"; done
- name: Установка зависимостей
run: |
python -m pip install --upgrade pip
pip install -r web/requirements.txt pytest httpx
- name: Тесты
run: pytest -q
shell:
name: Shell scripts
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- run: make lint-shell
+64
Просмотреть файл
@@ -0,0 +1,64 @@
name: Build ISO image
on:
push:
tags: ["v*"]
pull_request:
paths:
- "image/**"
- "scripts/virtuality_firstboot.sh"
- "web/requirements.txt"
- ".github/workflows/image.yml"
workflow_dispatch:
inputs:
arch:
description: "Architecture"
type: choice
options: [amd64, arm64, both]
default: amd64
permissions:
contents: read
concurrency:
group: image-${{ github.ref }}
cancel-in-progress: true
jobs:
iso:
name: ISO ${{ matrix.arch }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
arch: ${{ fromJSON(github.event_name == 'workflow_dispatch' && (inputs.arch == 'both' && '["amd64","arm64"]' || format('["{0}"]', inputs.arch)) || '["amd64","arm64"]') }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: sudo apt-get update && sudo apt-get install -y xorriso python3-yaml
- uses: actions/cache@v4
with:
path: .cache/iso
key: ubuntu-iso-${{ matrix.arch }}-${{ hashFiles('image/build-iso.sh') }}
restore-keys: ubuntu-iso-${{ matrix.arch }}-
- name: Free disk space
run: sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL && df -h /
- run: make iso ARCH=${{ matrix.arch }}
- name: Check ISO boot records
run: |
iso=$(ls dist/*.iso)
xorriso -indev "$iso" -report_el_torito plain 2>/dev/null | tee /dev/stderr | grep -q "UEFI"
xorriso -osirrox on -indev "$iso" -extract /autoinstall.yaml autoinstall.yaml -extract /boot/grub/grub.cfg grub.cfg
python3 -c "import yaml; d = yaml.safe_load(open('autoinstall.yaml')); assert d['autoinstall']['version'] == 1"
grep -q "Установить Virtuality" grub.cfg
# Ubuntu server ISOs are larger than the 2 GB GitHub release asset limit,
# so images are published as workflow artifacts.
- uses: actions/upload-artifact@v4
with:
name: virtuality-iso-${{ matrix.arch }}
path: |
dist/*.iso
dist/*.sha256
compression-level: 0
retention-days: 30
+6
Просмотреть файл
@@ -91,3 +91,9 @@ composer.lock
# System files
*.pid
*.sock
# Offline Python wheels bundled into system images
wheels/
# рабочие копии агентов и локальные настройки Claude Code
.claude/
+65
Просмотреть файл
@@ -0,0 +1,65 @@
# Virtuality — заметки для Claude Code
Virtuality — простая замена Proxmox для дома и малого офиса: KVM/QEMU/libvirt + собственная
веб-панель на FastAPI, установочный ISO на базе чистого Ubuntu Server 26.04 LTS и мастер
первичной настройки в браузере. Пользователь — обычный человек, не админ.
**Общайся с владельцем проекта по-русски.** Весь текст интерфейса — на простом русском, без жаргона
(термины вроде 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, образы дисков, сеть, журналы, обновления, консоль).
- `web/core.py` — общие примитивы: `run_cmd`, хранилище операций (`new_operation`, `run_operation`,
`finish_operation`, `active_operations_for`, `interrupt_orphaned_operations`), `render`, `require_auth`,
`valid_vm_name`, шаблоны Jinja. Импорт `core` выставляет `LC_ALL=C.UTF-8` (virsh иначе переводится).
- `web/features/*.py` — модули с `router = APIRouter()`, подключаются в конце `app.py`:
`setup` (мастер `/setup`), `settings` (`/settings`), `snapshots`, `backups`, `downloads` (скачивание
по ссылке и каталог), `vmops` (клон, увеличение диска, пауза/спящий режим, заметки, нагрузка).
POST-маршруты модулей — минимум 4 сегмента (`/vm/{name}/<модуль>/<действие>`), иначе их перехватит
`@app.post("/vm/{name}/{action}")`.
- `web/nodectl.py` — факты об узле и действия через `scripts/virtuality-ctl` (единая точка для
systemd-юнитов, TLS, firewall, моста br0 с автооткатом, диска под хранилище, пароля, часового пояса).
- `web/network_core.py` — NAT-сеть `virtuality-nat` (192.168.100.0/24), резервации DHCP (фиксированный
MAC по имени машины), проброс портов (nftables + ufw + iptables-fallback с меткой `virtuality-forward`).
- `web/catalog.py` — каталог облачных образов и типы ОС; `web/cloudinit.py` — seed cloud-init.
- `web/update_core.py` + `scripts/auto_update_check.sh`, `scripts/apply_github_update.sh`,
`scripts/update_target.sh` — обновления; канал `stable` = последний тег `vX.Y.Z` (пока тегов нет — main),
канал `main` = ветка main.
- `scripts/virtuality_firstboot.sh` — первая загрузка установленной системы: panel → network →
virtualization → tools → finish, прогресс в `/var/lib/virtuality/config/setup.json`.
- `scripts/install_web_panel.sh` — установка/обновление панели (web.prev для отката, офлайн-wheels).
- `install.sh` (установка одной командой), `install_virtuality_node.sh` (пакеты KVM/libvirt).
- `image/build-iso.sh`, `image/autoinstall.yaml`, `image/grub/` — сборка установочного ISO.
- `updates/versions.json` — журнал изменений для центра обновлений; `VERSION` — текущая версия.
## Команды
```bash
make check # pyflakes + shellcheck + pytest
python3 -m pytest -q # тесты (fake virsh в tests/conftest.py)
python3 tests/dev_server.py 8765 # демо-панель, вход: tester / любой пароль
VIRTUALITY_DEV_SETUP=installing python3 tests/dev_server.py # мастер на шаге установки
VIRTUALITY_DEV_SETUP=done|finished ... # мастер после установки / настроенный сервер
make iso # ISO в dist/ (нужны xorriso, git, curl, python3-pip; ~3 ГБ загрузки)
./image/build-iso.sh --help # все параметры сборки (в т.ч. --unattended)
```
Скриншоты: Playwright и Chromium доступны в облачной среде, локально — `npx playwright`.
## Правила
- Код как вокруг: плотность комментариев, имена, идиомы модуля. Не переформатировать чужой код.
- Каждое изменение поведения — с тестом. Перед коммитом: `make check`.
- Коммиты по-русски в стиле «<что> было сделано».
- Работаем прямо в `main` (так решил владелец). Перед каждым пушем — `make check`; не пушить
недоделанное: ноды на канале «Ранний доступ» (`main`) подтягивают main каждую ночь.
- Узлы на канале `stable` (по умолчанию) обновляются только до тегов `vX.Y.Z`. Релиз: поднять `VERSION`,
добавить запись в `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.
+34
Просмотреть файл
@@ -0,0 +1,34 @@
PYTHON ?= python3
ARCH ?= amd64
ISO_ARGS ?=
SHELL_SCRIPTS := $(wildcard *.sh scripts/*.sh image/*.sh)
.PHONY: help test lint lint-python lint-shell check iso clean
help:
@echo "make test - run the web panel test suite"
@echo "make lint - pyflakes + shellcheck"
@echo "make check - lint + test"
@echo "make iso - build the installer ISO (ARCH=amd64|arm64, ISO_ARGS='--unattended ...')"
@echo "make clean - remove build output"
test:
$(PYTHON) -m pytest -q
lint-python:
$(PYTHON) -m pyflakes web tests
lint-shell:
@for f in $(SHELL_SCRIPTS); do bash -n "$$f" || exit 1; done
shellcheck -S error $(SHELL_SCRIPTS)
lint: lint-python lint-shell
check: lint test
iso:
./image/build-iso.sh --arch $(ARCH) $(ISO_ARGS)
clean:
rm -rf dist .pytest_cache
find . -name __pycache__ -type d -prune -exec rm -rf {} +
+291 -378
Просмотреть файл
@@ -1,448 +1,361 @@
# Virtuality
**Virtuality** — лёгкая серверная платформа виртуализации на базе **KVM**, **QEMU**, **libvirt**, **Cockpit** и собственной web-панели.
**Virtuality** — простая замена Proxmox для дома и малого офиса. Один сервер, виртуальные машины и понятная панель в браузере на русском языке. Внутри — проверенные KVM, QEMU и libvirt, снаружи — интерфейс, в котором не нужна командная строка.
Цель проекта — собрать понятную, компактную и расширяемую систему для управления виртуальными машинами, ISO-образами, NAT/bridge-сетью, пробросом портов, диагностикой, web-console/noVNC, backup/snapshot-функциями и будущей кластеризацией.
Текущая версия: **0.11.1** (файл `VERSION`, журнал изменений — `updates/versions.json`).
> Текущий статус: ранняя стадия разработки. Уже есть one-command установка, автоопределение профиля хоста, web-панель, авторизация через Linux-пользователя, ISO-менеджер, создание VM из интерфейса, журнал операций, NAT Router для VPS/ARM edge nodes и port forwarding.
Что умеет:
- ставится с готового ISO-образа, как Proxmox: три экрана установщика — и через несколько минут панель открыта в браузере;
- создаёт машины из установочных ISO (в том числе Windows 11) и за минуту — из готовых облачных образов Ubuntu, Debian и Alpine;
- снимки, резервные копии, клонирование, увеличение диска, спящий режим;
- сеть машин «из коробки»: отдельная сеть NAT с постоянными адресами и пробросом портов или ваша домашняя сеть через мост;
- экран машины прямо в браузере, HTTPS для панели, автообновления со стабильным каналом.
![Обзор](docs/screenshots/dashboard.png)
| Мастер создания машины | Страница машины |
|---|---|
| ![Создание машины](docs/screenshots/create.png) | ![Машина](docs/screenshots/vm.png) |
| Сеть и доступ | Тёмная тема |
|---|---|
| ![Сеть](docs/screenshots/network.png) | ![Тёмная тема](docs/screenshots/dashboard-dark.png) |
| Мастер настройки: установка | Мастер настройки: сеть машин |
|---|---|
| ![Мастер, установка](docs/screenshots/wizard-install.png) | ![Мастер, сеть](docs/screenshots/wizard-network.png) |
| Каталог готовых систем | Настройки |
|---|---|
| ![Каталог](docs/screenshots/catalog.png) | ![Настройки](docs/screenshots/settings.png) |
| Снимки | Резервные копии |
|---|---|
| ![Снимки](docs/screenshots/snapshots.png) | ![Резервные копии](docs/screenshots/backups.png) |
| Вход | На телефоне |
|---|---|
| ![Вход](docs/screenshots/login.png) | ![Мобильная версия](docs/screenshots/dashboard-mobile.png) |
---
## Поддерживаемые профили хоста
## Содержание
Virtuality автоматически определяет профиль сборки во время установки:
- [Установка](#установка)
- [Способ 1: установочный ISO](#способ-1-установочный-iso)
- [Способ 2: одна команда на готовый сервер](#способ-2-одна-команда-на-готовый-сервер)
- [Способ 3: вручную](#способ-3-вручную)
- [Первая загрузка и мастер настройки](#первая-загрузка-и-мастер-настройки)
- [Возможности](#возможности)
- [Настройки](#настройки)
- [Команда virtuality-ctl](#команда-virtuality-ctl)
- [Обновления и откат](#обновления-и-откат)
- [Удаление](#удаление)
- [Если что-то не работает](#если-что-то-не-работает)
- [Где что лежит](#где-что-лежит)
- [Разработка](#разработка)
```text
x86_64 # обычный сервер, домашний сервер, VPS
raspberry-arm64 # Raspberry Pi ARM64 edge node
orangepi5-arm64 # Orange Pi 5 / RK3588 ARM64 edge node
generic-arm64 # другая ARM64-плата
```
Профиль сохраняется здесь:
```text
/var/lib/virtuality/config/host_profile.json
```
В web-панели профиль виден на странице:
```text
/host
```
Для ARM64-плат правильный сценарий — **ARM64-гости**, NAT-сеть и позже cloud-image/cloud-init шаблоны. x86_64 ISO на Raspberry/Orange Pi не являются целевым режимом.
Подробные документы: [требования](docs/REQUIREMENTS.md) · [установочный образ](docs/IMAGE.md) · [первая машина](docs/FIRST_VM.md) · [архитектура](docs/ARCHITECTURE.md) · [планы](docs/ROADMAP.md).
---
## Быстрая установка одной командой
## Установка
Одна команда работает и под `root`, и под обычным пользователем с `sudo`:
Перед установкой проверьте [требования](docs/REQUIREMENTS.md): процессор x86_64 с включённой виртуализацией (Intel VT-x / AMD-V), от 4 ГБ памяти, проводная сеть.
### Способ 1: установочный ISO
Самый простой путь для «голого» сервера или старого компьютера. Образ собирается из чистого Ubuntu Server 26.04 LTS; установщик спрашивает только сеть, диск и пользователя.
Где взять образ:
- **Собрать самому** на любой Linux-машине (root не нужен):
```bash
sudo apt install -y xorriso git curl python3-pip
git clone https://git.stackworks.ru/viktor/virtuality.git
cd virtuality
make iso # dist/virtuality-0.11.1-ubuntu-26.04-amd64.iso
```
Дальше: записать образ на флешку (Rufus, balenaEtcher или `dd`), загрузиться с неё, выбрать **Установить Virtuality**, ответить на три экрана установщика и дождаться перезагрузки. Через минуту после неё панель уже открывается в браузере — адрес показан на экране сервера. Все подробности, параметры сборки и полностью автоматическая установка — в [docs/IMAGE.md](docs/IMAGE.md).
### Способ 2: одна команда на готовый сервер
Подходит для уже установленного Ubuntu Server 24.04 / 26.04 или Debian 13. Работает и под `root`, и под обычным пользователем с `sudo`:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | bash
curl -fsSL https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | bash
```
Как выбирается пользователь для входа в web-панель:
Скрипт проверит требования, поставит пакеты KVM/QEMU/libvirt, команду `vhealth` и панель, а в конце покажет адрес панели. Логин — тот пользователь, от которого запущена установка (через `sudo` — ваш пользователь, под `root` — `root`); пароль — его пароль Linux. Если пароль ещё не задан: `sudo passwd <пользователь>`.
Переменные окружения, которые понимает `install.sh`:
```text
Если запуск под root → login: root
Если запуск через sudo → login: текущий sudo-пользователь
Если задан VIRTUALITY_USER → login: указанный Linux-пользователь
```
Принудительно выбрать пользователя:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=<linux_user> bash
VIRTUALITY_USER=admin # какой Linux-пользователь входит в панель (будет создан, если его нет)
VIRTUALITY_WEB_PORT=8089 # порт панели (по умолчанию 8088)
VIRTUALITY_AUTO_UPDATE=0 # не включать ночное автообновление
VIRTUALITY_BRANCH=main # ветка репозитория
VIRTUALITY_INSTALL_VERBOSE=1 # показывать вывод команд вместо спиннера
```
Пример:
```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
```
Установка с авторизацией под `root`:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_USER=root bash
```
Если пароль выбранного Linux-пользователя не задан:
```bash
sudo passwd <linux_user>
```
Для root:
```bash
passwd root
```
---
## Что уже есть
- универсальная one-command установка через `install.sh`;
- автоопределение профиля хоста: x86_64, Raspberry Pi ARM64, Orange Pi 5 ARM64, generic ARM64;
- установка разных пакетных наборов под x86 и ARM64;
- красивый пошаговый installer с live-status и логами;
- preflight-проверка системных требований;
- установка KVM/QEMU/libvirt;
- установка Cockpit и Cockpit Machines;
- рабочая директория `/opt/virtuality/source`;
- libvirt storage pools `virtuality-images` и `virtuality-iso`;
- firewall-правила для SSH, Cockpit, VNC и web-панели;
- безопасная ручная настройка bridge `br0` через `netplan try`;
- NAT Router `virtuality-nat` для VPS/ARM edge nodes;
- port forwarding через nftables;
- автоматическое определение IP VM для проброса портов;
- healthcheck одной командой `vhealth`;
- консольный dashboard для физического монитора;
- web-панель Virtuality на FastAPI;
- авторизация web-панели через системного Linux-пользователя;
- `/host` — профиль хоста и проверки готовности;
- `/network` — NAT, DHCP leases, port forwarding, nftables preview;
- `/iso` — ISO-менеджер;
- `/operations` — журнал фоновых операций;
- список VM;
- управление VM: Start, Shutdown, Reboot, Power off, Autostart on/off, Delete VM with disks;
- создание VM из web-интерфейса;
- выбор сетевого режима при создании VM: `virtuality-nat` или `br0`;
- прогресс создания VM и live-лог `virt-install`;
- страница деталей VM: `dominfo`, VNC display, IP, диски, сетевые интерфейсы;
- web-консоль noVNC прямо в браузере;
- cloud-образы Ubuntu/Debian (x86_64 и ARM64) и создание VM из образа с cloud-init за минуту;
- клонирование VM и шаблоны (эталонные VM с блокировкой запуска);
- снапшоты VM: создание, откат, удаление;
- бэкапы дисков VM со сжатием, ротацией и ночным расписанием;
- HTTPS из коробки (самоподписанный сертификат), CSRF-защита форм, PAM-аутентификация, защита от перебора пароля;
- графики CPU/RAM хоста на дашборде;
- быстрый libvirt-backend с fallback на virsh для слабого железа;
- тесты pytest и CI на GitHub Actions.
---
## Системные требования
Минимально для x86_64 тестового стенда:
```text
CPU: 2 ядра с Intel VT-x / AMD-V
RAM: 4 GB
/: минимум 8 GB свободно
/var/lib: минимум 20 GB свободно
OS: Ubuntu Server 24.04 LTS / Debian-like с apt
Network: один проводной интерфейс
```
Рекомендуемо для x86_64:
```text
CPU: 4+ ядра
RAM: 16+ GB
Storage: 100+ GB SSD/NVMe под /var/lib/virtuality
Network: 1 Gbit/s+
OS: Ubuntu Server 24.04 LTS
```
ARM64 edge nodes:
```text
Raspberry Pi 4/5 ARM64: желательно 8 GB RAM
Orange Pi 5 ARM64: желательно 8/16/32 GB RAM и NVMe
Гости: ARM64 Linux VM
Сеть: virtuality-nat по умолчанию
```
Подробнее: [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md)
---
## Порты
```text
Cockpit: https://SERVER_IP:9090
Virtuality UI: https://SERVER_IP:8088
VNC: 5900-5999/tcp
SSH: 22/tcp
```
Панель работает по HTTPS с самоподписанным сертификатом — браузер один раз попросит подтвердить исключение. Отключить TLS: `VIRTUALITY_TLS=0` при установке.
Изменить порт web-панели:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/install.sh | VIRTUALITY_WEB_PORT=8089 bash
```
Для VPS/ARM NAT-сценария входящие сервисы VM открываются через port forwarding в разделе `/network`.
---
## Директории
```text
/opt/virtuality/source # исходники проекта из GitHub
/opt/virtuality/web # установленная web-панель
/opt/virtuality/venv # Python virtualenv web-панели
/opt/virtuality/virtuality.env # базовый env ноды
/var/lib/virtuality/config # конфиги Virtuality
/var/lib/virtuality/config/host_profile.json
/var/lib/virtuality/iso # ISO-образы
/var/lib/virtuality/images # qcow2-диски VM
/var/lib/virtuality/network # port_forwards.json
/var/lib/virtuality/backups # backups
/var/log/virtuality # логи установки и диагностики
/var/log/virtuality/operations # JSON/log фоновых операций
/etc/virtuality/nftables/virtuality.nft # nftables-правила Virtuality
```
---
## Диагностика
```bash
sudo vhealth
```
Дополнительно:
```bash
cat /var/lib/virtuality/config/host_profile.json
systemctl status virtuality-web --no-pager
journalctl -u virtuality-web -n 120 --no-pager
virsh list --all
virsh pool-list --all
virsh net-list --all
virsh net-dhcp-leases virtuality-nat
ip -br a
ip route
```
---
## Web-панель
URL:
```text
http://SERVER_IP:8088
```
Основные разделы:
```text
/ # дашборд с графиками CPU/RAM
/host # профиль хоста и проверки
/iso # ISO-менеджер
/disk-images # готовые образы дисков
/cloud-images # cloud-образы Ubuntu/Debian
/vm/create # создание VM из ISO или готового диска
/vm/create-cloud # создание VM из cloud-образа с cloud-init
/backups # бэкапы VM и ночное расписание
/network # NAT Router и проброс портов
/operations # журнал операций
/logs # центр журналов
/update # центр обновлений
```
Переустановка web-панели:
При первом входе в панель откроется мастер настройки — тот же, что после установки с ISO.
### Способ 3: вручную
Если хочется видеть каждый шаг. Всё делается от `root` или через `sudo`:
```bash
sudo mkdir -p /opt/virtuality
sudo git clone https://git.stackworks.ru/viktor/virtuality.git /opt/virtuality/source
cd /opt/virtuality/source
sudo bash scripts/install_web_panel.sh
sudo bash install_virtuality_node.sh # пакеты KVM/QEMU/libvirt, пулы, firewall
sudo bash scripts/install_healthcheck_command.sh # команда sudo vhealth
sudo VIRTUALITY_AUTH_USER=admin bash scripts/install_web_panel.sh # панель и virtuality-ctl
```
Статус:
`install_web_panel.sh` копирует панель в `/opt/virtuality/web`, создаёт virtualenv, ставит `virtuality-ctl` и через него — systemd-юниты и правила firewall. Переменные: `VIRTUALITY_AUTH_USER` (пользователь для входа), `VIRTUALITY_WEB_PORT` (порт), `VIRTUALITY_AUTO_UPDATE` (0/1). Повторный запуск скрипта обновляет панель, сохраняя настройки.
```bash
systemctl status virtuality-web --no-pager
```
---
Логи:
## Первая загрузка и мастер настройки
```bash
journalctl -u virtuality-web -f
```
### Что происходит после установки с ISO
Вход:
После перезагрузки запускается `virtuality-firstboot.service` (`scripts/virtuality_firstboot.sh`). Порядок шагов выбран так, чтобы вы как можно раньше увидели, что происходит:
1. **Панель управления** — ставится офлайн из Python-колёс, упакованных в образ, и сразу запускается. Открывайте `http://адрес-сервера:8088` — мастер покажет ход остальных шагов прямо в браузере.
2. **Подключение к интернету** — ожидание сети. Если кабель не подключён, мастер напишет об этом и продолжит сам, как только интернет появится.
3. **KVM, QEMU и libvirt** — самый долгий шаг, обычно 5–15 минут: пакеты ставятся из репозиториев Ubuntu, мастер показывает, какой пакет скачивается или настраивается.
4. **Инструменты диагностики** — команда `vhealth`.
5. **Завершение** — `virtuality-ctl reconfigure`: юниты, firewall, перезапуск панели.
Ход установки пишется в `/var/lib/virtuality/config/setup.json` и в `/var/log/virtuality/firstboot.log`. Если какой-то шаг упал (например, отвалилась сеть), systemd перезапускает скрипт через минуту, и он продолжает с первого незавершённого шага. На экране сервера (tty) всё это время показана подсказка, а после завершения — адрес панели и логин.
Логин в панель — пользователь, созданный в установщике Ubuntu; пароль — его пароль.
### Мастер настройки
Мастер (`/setup`) открывается при первом входе в панель — и после ISO, и после `install.sh`. Каждый шаг можно пропустить, а весь мастер — запустить заново из раздела «Настройки».
| Шаг | Что делает |
|---|---|
| **Установка** | Показывает ход первой загрузки (только после ISO). |
| **Сервер** | Проверяет процессор, память, диск и доступность KVM; задаёт часовой пояс. |
| **Администратор** | Смена пароля пользователя панели (он же пароль SSH). |
| **Доступ** | HTTPS с сертификатом сервера (панель на порту 8443, с 8088 — перенаправление) или обычный HTTP; порт. |
| **Сеть машин** | **Отдельная сеть (NAT)** — машины получают адреса 192.168.100.x, работает везде. **Домашняя сеть (мост)** — машины становятся устройствами вашей сети; сервер сам перестроит сеть в мост `br0`, а если панель не ответит за 2 минуты — вернёт всё как было. |
| **Хранилище** | Отдать пустой диск (от 16 ГБ) под машины: он форматируется и монтируется в `/var/lib/virtuality`. Или оставить системный диск. |
| **Обновления** | Автоматически ночью (только выпущенные версии) или вручную; канал: стабильный или ранний доступ. |
| **Готово** | Сводка; настройки применяются, при смене адреса панель перезапускается и открывается по новому. |
---
## Возможности
Разделы панели: **Обзор**, **Создать машину**, **Образы ОС**, **Сеть и доступ**, **Резервные копии**, **Задачи**, **Журналы**, **Обновления**, **Сервер**, **Настройки**, **Помощь**.
**Машины**
- Создание из установочного ISO: три шага — название и система, мощность (готовые профили «Мини», «Стандарт», «Мощная» или свои значения), сеть.
- Тип системы для ISO: Linux, **Windows 11 / Server 2025** (UEFI и TPM 2.0 включаются сами), Windows 10 / Server 2019–2022, другая. Для Windows подбираются диск SATA и сетевая карта Intel — драйверы не нужны.
- Создание из готового образа за минуту: образ конвертируется в qcow2, диск увеличивается до нужного размера, а cloud-init настраивает первый вход — пользователя, пароль и/или SSH-ключ. Пароль хранится только как хеш. При первой загрузке в машину ставится гостевой агент, поэтому панель видит её адрес в любой сети и выключает её корректно.
- Запуск, выключение, перезагрузка, принудительное выключение, автозапуск, удаление вместе с дисками.
- Пауза и **спящий режим** (память сохраняется на диск сервера, после запуска машина продолжает с того же места).
- Изменение процессора и памяти, порядок загрузки, привод CD/DVD (вставить или извлечь образ).
- **Увеличение диска**, **клонирование** (точная копия с новым именем и своим адресом), заметки (название и описание), живая нагрузка процессора и памяти.
- **Экран машины в браузере** (noVNC). VNC наружу не открыт: консоль доступна только через панель.
**Снимки и копии**
- **Снимки** — точки восстановления внутри диска машины, до 8 на машину. У работающей машины в снимок попадает и память. Вернуться или удалить — одной кнопкой.
- **Резервные копии** — полная копия настроек и всех дисков в `/var/lib/virtuality/backups/<машина>/<дата>/`. Восстановление под тем же или новым названием; копию можно делать и по расписанию из командной строки.
**Образы ОС**
- Загрузка ISO и образов дисков перетаскиванием (с прогрессом и скоростью), поддержка `.qcow2`, `.img`, `.raw`, сжатых `.img.xz` и архивов.
- **Скачивание по ссылке** — сервер качает файл сам, в фоне.
- **Каталог готовых систем** — официальные облачные образы Ubuntu Server 26.04 LTS и 24.04 LTS, Debian 13, Alpine Linux 3.23 с проверкой контрольных сумм.
**Сеть**
- Сеть **NAT** `virtuality-nat` (192.168.100.0/24): каждая машина получает **постоянный адрес** по имени, поэтому пробросы портов не ломаются после перезагрузок.
- **Проброс портов** из списка готовых сервисов (SSH, сайт, RDP, Minecraft, VPN и другие) или любой порт и диапазон; проверка правила одной кнопкой; правила переживают перезагрузку сервера.
- **Домашняя сеть через мост** `br0` с автооткатом, если сеть пропала.
**Сервер**
- Мастер настройки и страница «Настройки».
- **HTTPS** для панели с сертификатом сервера и перенаправлением с HTTP.
- **Обновления** из панели или автоматически ночью, **стабильный канал** (только выпущенные версии) и ранний доступ, откат на предыдущую версию.
- Задачи с прогрессом и журналом, журналы служб, диагностика `vhealth`, отчёт для поддержки, архив настроек.
- Светлая и тёмная темы, работает на телефоне, не требует доступа в интернет для самой панели.
Как сделать первую машину — в [docs/FIRST_VM.md](docs/FIRST_VM.md).
---
## Настройки
Страница **Настройки** (`/settings`) — всё то же, что в мастере, плюс управление сервером:
- **Доступ к панели** — HTTPS или HTTP, порт. После применения панель перезапускается и открывается по новому адресу.
- **Пароль** — смена пароля пользователя панели (он же пароль Linux и SSH).
- **Сеть машин** — переключение между NAT и домашней сетью (мост `br0`), возврат прежней сети, проверка сети NAT.
- **Хранилище** — подключить пустой диск под машины.
- **Время** — часовой пояс сервера (по нему показываются журналы и час ночного обновления).
- **Обновления** — автообновление и канал (стабильный / ранний доступ).
- **Сервер** — скачать архив настроек, собрать отчёт для поддержки, перезагрузить или выключить сервер (машины выключаются корректно).
- **Мастер настройки** — пройти все шаги заново.
Настройки ноды хранятся в `/var/lib/virtuality/config/web.env` и не сбрасываются обновлениями:
```text
Login: выбранный Linux-пользователь
Pass: пароль этого Linux-пользователя
VIRTUALITY_WEB_PORT=8088 # порт HTTP
VIRTUALITY_TLS=1 # 1 — HTTPS включён
VIRTUALITY_TLS_PORT=8443 # порт HTTPS
VIRTUALITY_WEB_HOST=0.0.0.0 # 127.0.0.1, если панель стоит за reverse proxy
VIRTUALITY_AUTH_USER=admin # Linux-пользователь для входа
VIRTUALITY_AUTO_UPDATE=1 # 0 — без ночного автообновления
VIRTUALITY_UPDATE_CHANNEL=stable # stable — выпущенные версии, main — все изменения
VIRTUALITY_SOURCE_DIR=/opt/virtuality/source
```
Менять их руками не нужно: `sudo virtuality-ctl set КЛЮЧ=ЗНАЧЕНИЕ` сохраняет значение и сразу применяет его.
---
## Команда virtuality-ctl
`virtuality-ctl` ставится в `/usr/local/bin` вместе с панелью; панель сама вызывает его для всех действий с сервером, так что из терминала можно сделать то же самое. Запускается от `root`:
```text
sudo virtuality-ctl status # что установлено, порты, адрес панели
sudo virtuality-ctl url # адрес панели
sudo virtuality-ctl reconfigure # пересоздать systemd-юниты из web.env и перезапустить панель
sudo virtuality-ctl set KEY=VALUE ... # изменить настройку в web.env и применить (например set VIRTUALITY_TLS=1)
sudo virtuality-ctl passwd [USER] # сменить пароль пользователя панели
sudo virtuality-ctl timezone Europe/Moscow # часовой пояс сервера
sudo virtuality-ctl power reboot|poweroff # перезагрузить или выключить сервер (машины выключаются корректно)
sudo virtuality-ctl backup-config [FILE] # архив настроек ноды и описаний машин
sudo virtuality-ctl restore-config FILE # восстановить настройки из архива
sudo virtuality-ctl rollback # вернуть предыдущую версию панели
sudo virtuality-ctl maintenance # ежедневное обслуживание (запускается таймером)
sudo virtuality-ctl bridge <iface> [dhcp|static] # мост br0 с автооткатом через 2 минуты
sudo virtuality-ctl bridge-confirm # подтвердить новую сеть
sudo virtuality-ctl bridge-revert # вернуть прежнюю сеть
sudo virtuality-ctl storage-use /dev/sdX # отдать пустой диск под /var/lib/virtuality
sudo virtuality-ctl support-bundle [FILE] # отчёт для поддержки
sudo virtuality-ctl uninstall [--purge] [--yes] # удалить Virtuality
```
---
## Сеть: Bridge и NAT Router
## Обновления и откат
### Bridge br0
Подходит для домашнего/офисного сервера, где VM должны получать IP из локальной сети.
По умолчанию one-command установщик **не включает `br0` автоматически**, чтобы не уронить SSH-сессию.
Сначала нужно посмотреть сетевой интерфейс:
```bash
ip -br a
ip route
```
Затем запустить bridge setup, например для `enp2s0`:
```bash
cd /opt/virtuality/source
sudo bash scripts/setup_bridge_br0.sh enp2s0 static
```
Ожидаемый результат:
```text
enp2s0 UP
br0 UP SERVER_IP/24
default via GATEWAY dev br0
```
### Virtuality NAT Router
Подходит для VPS, Raspberry Pi, Orange Pi 5 и других ARM64 edge nodes.
Схема:
```text
Интернет / LAN
↓
Хост Virtuality
↓
virtuality-nat / virbr100
↓
VM 192.168.100.x
```
Создать или починить NAT-сеть можно в web-панели:
```text
/network → Создать / починить NAT-сеть
```
Параметры по умолчанию:
```text
Network: virtuality-nat
Bridge: virbr100
Subnet: 192.168.100.0/24
Gateway: 192.168.100.1
DHCP: 192.168.100.50–192.168.100.200
```
Проброс порта добавляется в `/network`. IP VM определяется автоматически через `virsh domifaddr`, а если не получилось — через DHCP leases и MAC-адрес VM.
Пример SSH-проброса:
```text
VM: ubuntu-test
Внешний порт сервера: 2222
Порт внутри VM: 22
Протокол: tcp
```
Подключение:
```bash
ssh user@SERVER_IP -p 2222
```
- **Из панели**: раздел **Обновления** → «Проверить сейчас» → «Обновить». Машины во время обновления продолжают работать. Что изменилось — видно на той же странице.
- **Автоматически**: таймер `virtuality-auto-update.timer` проверяет обновления каждую ночь около 4:00 по времени сервера. Включается и выключается в «Настройках» или командой `sudo virtuality-ctl set VIRTUALITY_AUTO_UPDATE=0`.
- **Каналы**: `stable` — только выпущенные версии (последний тег `vX.Y.Z`; пока тегов нет, стабильный канал следует ветке `main`), `main` — все изменения ветки `main`. Нода никогда не откатывается на более старую версию сама.
- **Вручную из терминала**: `sudo bash /opt/virtuality/source/scripts/apply_github_update.sh` — тот же сценарий, что и в панели (git fetch, при недоступности git — ZIP-архив с git.stackworks.ru, затем `scripts/install_web_panel.sh` и перезапуск панели). Журнал: `/var/log/virtuality/update.log`.
- **Откат**: перед каждым обновлением предыдущая версия панели сохраняется в `/opt/virtuality/web.prev`. Если новая версия не запустилась: `sudo virtuality-ctl rollback`.
---
## ISO и создание VM
Загрузка ISO через web-панель:
```text
/iso
```
Создание VM:
```text
/vm/create
```
При создании VM можно выбрать сеть:
```text
VPS NAT Router — virtuality-nat
Bridge — br0 / локальная сеть
```
Для x86_64 можно использовать обычные x86_64 ISO. Для Raspberry Pi / Orange Pi 5 нужны ARM64 ISO или, в будущем, ARM64 cloud images.
Пример загрузки Alpine x86_64 ISO:
## Удаление
```bash
sudo wget -O /var/lib/virtuality/iso/alpine-standard-x86_64.iso https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.3-x86_64.iso
sudo virsh pool-refresh virtuality-iso
sudo virtuality-ctl uninstall # убирает панель, службы и настройки; машины, образы и копии остаются
sudo virtuality-ctl uninstall --purge # удаляет ВСЁ: машины, диски, образы, резервные копии, сеть NAT
```
Команда спрашивает подтверждение (`YES`); `--yes` пропускает вопрос. Пакеты KVM/QEMU/libvirt остаются в системе.
---
## Обновление
## Если что-то не работает
```bash
cd /opt/virtuality/source
sudo git pull
sudo bash scripts/install_web_panel.sh
sudo systemctl restart virtuality-web
```
1. Раздел **Сервер** в панели показывает, чего не хватает (KVM, пакеты, мост). Раздел **Задачи** — журнал каждой операции. Раздел **Журналы** — панель, обновления, установка, виртуализация.
2. Полная диагностика в терминале:
Полная повторная установка компонентов ноды:
```bash
sudo vhealth # проверка процессора, служб, сети, firewall, libvirt, дисков
sudo virtuality-ctl status # версия, адрес панели, HTTPS, автообновление, хранилище
```
```bash
cd /opt/virtuality/source
sudo bash install_virtuality_node.sh
sudo bash scripts/install_web_panel.sh
```
3. Отчёт для поддержки — кнопка в «Настройках» или `sudo virtuality-ctl support-bundle`: архив с настройками (без секретов), журналами служб и последними задачами.
4. Журналы:
```bash
journalctl -u virtuality-web -f # панель
sudo journalctl -fu virtuality-firstboot # первая загрузка после ISO
cat /var/log/virtuality/firstboot.log # то же, файлом
cat /var/log/virtuality/update.log # обновления
ls /var/log/virtuality/operations/ # задачи: JSON + журнал каждой
```
5. Для мониторинга: `GET /healthz` без авторизации отвечает `{"ok": true, "version": "..."}`.
Полезные команды libvirt: `virsh list --all`, `virsh net-list --all`, `virsh net-dhcp-leases virtuality-nat`, `virsh pool-list --all`.
---
## Полезные команды
## Где что лежит
```bash
sudo vhealth
virsh list --all
virsh pool-list --all
virsh net-list --all
virsh net-dhcp-leases virtuality-nat
ip -br a
ip route
sudo nft list ruleset
sudo ufw status
systemctl status libvirtd --no-pager
systemctl status cockpit.socket --no-pager
systemctl status virtuality-web --no-pager
journalctl -u virtuality-web -f
```text
/opt/virtuality/source # исходники (git), отсюда обновления
/opt/virtuality/web # установленная панель (web.prev — предыдущая версия)
/opt/virtuality/venv # Python virtualenv панели
/var/lib/virtuality/iso # установочные ISO
/var/lib/virtuality/disk-images # готовые образы систем (каталог, ссылки, загрузки)
/var/lib/virtuality/images # диски машин (qcow2)
/var/lib/virtuality/backups # резервные копии машин и архивы настроек
/var/lib/virtuality/config # web.env, setup.json, wizard.json, host_profile.json, tls/
/var/lib/virtuality/network # port_forwards.json, virtuality-nat.xml
/var/lib/virtuality/tmp # временные файлы загрузок и cloud-init
/var/log/virtuality # firstboot.log, update.log, virtuality-ctl.log, install_*.log
/var/log/virtuality/operations # задачи панели: JSON и журнал каждой
/etc/virtuality/nftables/virtuality.nft # правила проброса портов
/usr/local/bin/virtuality-ctl # управление нодой
/usr/local/bin/vhealth # диагностика
```
Порты: панель `8088` (HTTP) и `8443` (HTTPS), SSH `22`. VNC-порты машин наружу не открываются.
---
## Roadmap
## Разработка
- управление storage pools;
- сетевой менеджер bridge/VLAN;
- журнал событий.
```bash
pip install -r web/requirements.txt -r requirements-dev.txt
make test # pytest (virsh и systemctl имитируются в tests/conftest.py)
make lint # pyflakes + shellcheck
make check # lint + test — запускать перед коммитом
make iso # установочный ISO в dist/ (см. docs/IMAGE.md)
```
Сделано из прошлого roadmap: cloud-image шаблоны (x86_64 и ARM64), cloud-init,
web-console/noVNC, снапшоты и бэкапы, автообновление.
Демо-панель без сервера виртуализации — с тремя машинами, задачами и загрузками:
Сознательно не планируется: кластеризация, HA, Ceph/ZFS и роли пользователей —
Virtuality остаётся лёгкой панелью для одиночных нод.
```bash
python3 tests/dev_server.py 8765 # http://127.0.0.1:8765, вход: tester / любой пароль
VIRTUALITY_DEV_SETUP=installing python3 tests/dev_server.py # мастер на шаге «Установка»
VIRTUALITY_DEV_SETUP=done python3 tests/dev_server.py # мастер после установки
VIRTUALITY_DEV_SETUP=finished python3 tests/dev_server.py # настроенный сервер, страница «Настройки»
```
Устройство кода описано в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Интерфейс: шаблоны `web/templates` (каркас `base.html`, компоненты `_ui.html`), стили `web/static/app.css`, поведение `web/static/panel.js`; шрифт и иконки лежат в `web/static`. Скриншоты страниц: `node tests/screenshots.mjs` (нужен Playwright).
Репозиторий живёт в 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 тоже понимает, они заработают, если раннер появится.
---
## Лицензия
**MIT License** — см. [`LICENSE`](LICENSE).
[MIT License](LICENSE).
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.9.13
0.11.1
-296
Просмотреть файл
@@ -1,296 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# ==========================================================
# Virtuality One-command Bootstrap Installer
# Public install entrypoint for clean Ubuntu/Debian servers.
# ==========================================================
REPO_URL="${VIRTUALITY_REPO_URL:-https://github.com/viktor138irk/virtuality.git}"
DEFAULT_INSTALL_USER="${SUDO_USER:-root}"
INSTALL_USER="${VIRTUALITY_USER:-$DEFAULT_INSTALL_USER}"
PROJECT_BASE_DIR="${VIRTUALITY_PROJECT_BASE_DIR:-/opt/virtuality}"
PROJECT_DIR="${VIRTUALITY_PROJECT_DIR:-${PROJECT_BASE_DIR}/source}"
RUN_BRIDGE="${VIRTUALITY_SETUP_BRIDGE:-0}"
BRIDGE_IFACE="${VIRTUALITY_BRIDGE_IFACE:-}"
RUN_TEST_VM="${VIRTUALITY_CREATE_TEST_VM:-0}"
WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
LOG_DIR="/var/log/virtuality"
LOG_FILE="${LOG_DIR}/bootstrap_$(date +%Y%m%d_%H%M%S).log"
MIN_ROOT_FREE_MB="${VIRTUALITY_MIN_ROOT_FREE_MB:-8192}"
MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}"
MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}"
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
ESC="\033"
RESET="${ESC}[0m"
BOLD="${ESC}[1m"
GREEN="${ESC}[32m"
YELLOW="${ESC}[33m"
RED="${ESC}[31m"
CYAN="${ESC}[36m"
BLUE="${ESC}[34m"
GRAY="${ESC}[90m"
mkdir -p "$LOG_DIR"
timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
log() { echo "[$(timestamp)] $*" >> "$LOG_FILE"; }
ok() { echo -e " ${GREEN}✓${RESET} $*"; log "OK: $*"; }
warn() { echo -e " ${YELLOW}!${RESET} $*"; log "WARN: $*"; }
fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Bootstrap остановлен.${RESET} Лог: ${LOG_FILE}"; exit 1; }
step() { echo; echo -e "${BLUE}${BOLD}▶${RESET} ${BOLD}$*${RESET}"; log "STEP: $*"; }
run_logged() {
local desc="$1"
shift
local start_ts pid code spinner elapsed symbol i
log "RUN: $*"
start_ts="$(date +%s)"
"$@" >> "$LOG_FILE" 2>&1 &
pid=$!
spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
i=0
while kill -0 "$pid" 2>/dev/null; do
elapsed=$(( $(date +%s) - start_ts ))
symbol="${spinner[$((i % ${#spinner[@]}))]}"
printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$desc" "$elapsed" "$LOG_FILE"
sleep 1
i=$((i + 1))
done
wait "$pid"
code=$?
printf "\r%*s\r" 120 ""
if [[ "$code" -eq 0 ]]; then
ok "$desc"
else
fail "$desc"
fi
}
header() {
clear 2>/dev/null || true
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Bootstrap Installer${RESET} ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} One-command KVM / libvirt / Cockpit / Web Panel setup ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo
echo -e "${GRAY}Repository:${RESET} ${REPO_URL}"
echo -e "${GRAY}Auth/install user:${RESET} ${INSTALL_USER}"
echo -e "${GRAY}Project dir:${RESET} ${PROJECT_DIR}"
echo -e "${GRAY}Log:${RESET} ${LOG_FILE}"
echo
}
require_root() {
[[ "$EUID" -eq 0 ]] || fail "Запусти от root: curl ... | sudo bash"
}
free_mb_for_path() {
local path="$1"
mkdir -p "$path" 2>/dev/null || true
df -Pm "$path" | awk 'NR==2 {print $4}'
}
check_password_state() {
local user="$1"
local hash
hash="$(getent shadow "$user" | cut -d: -f2 || true)"
if [[ -z "$hash" || "$hash" == "!" || "$hash" == "*" || "$hash" == "!!" ]]; then
warn "У пользователя ${user} пароль не задан или заблокирован. Для входа в панель выполни: passwd ${user}"
else
ok "У пользователя ${user} есть пароль для входа в панель"
fi
}
check_requirements() {
step "Проверяем минимальные системные требования"
if [[ "$SKIP_REQUIREMENTS" == "1" ]]; then
warn "Проверка требований отключена через VIRTUALITY_SKIP_REQUIREMENTS=1"
return 0
fi
local root_free var_free ram_mb cpu_cores
root_free="$(free_mb_for_path /)"
var_free="$(free_mb_for_path /var/lib 2>/dev/null || free_mb_for_path /)"
ram_mb="$(awk '/MemTotal/ {printf "%d", $2/1024}' /proc/meminfo)"
cpu_cores="$(nproc 2>/dev/null || echo 1)"
if (( root_free >= MIN_ROOT_FREE_MB )); then
ok "Свободно на /: ${root_free} MB минимум ${MIN_ROOT_FREE_MB} MB"
else
fail "Недостаточно места на /: ${root_free} MB. Нужно минимум ${MIN_ROOT_FREE_MB} MB. Очисти диск: apt clean; journalctl --vacuum-time=3d"
fi
if (( var_free >= MIN_VAR_FREE_MB )); then
ok "Свободно для /var/lib: ${var_free} MB минимум ${MIN_VAR_FREE_MB} MB"
else
fail "Недостаточно места для VM-хранилища /var/lib: ${var_free} MB. Нужно минимум ${MIN_VAR_FREE_MB} MB"
fi
if (( ram_mb >= MIN_RAM_MB )); then
ok "RAM: ${ram_mb} MB минимум ${MIN_RAM_MB} MB"
else
warn "RAM: ${ram_mb} MB меньше рекомендуемых ${MIN_RAM_MB} MB. Установка возможна, но VM будут ограничены"
fi
if (( cpu_cores >= MIN_CPU_CORES )); then
ok "CPU cores: ${cpu_cores} минимум ${MIN_CPU_CORES}"
else
warn "CPU cores: ${cpu_cores} меньше рекомендуемых ${MIN_CPU_CORES}"
fi
if grep -E -q '(vmx|svm)' /proc/cpuinfo; then
ok "CPU virtualization vmx/svm найдена"
else
warn "CPU virtualization vmx/svm не найдена. Проверь BIOS/UEFI или настройки VPS"
fi
}
ensure_user() {
if [[ "$INSTALL_USER" == "root" ]]; then
ok "Пользователь авторизации: root"
check_password_state root
return 0
fi
if id "$INSTALL_USER" >/dev/null 2>&1; then
ok "Пользователь уже существует: ${INSTALL_USER}"
else
run_logged "Создан пользователь ${INSTALL_USER}" adduser --disabled-password --gecos "" "$INSTALL_USER"
warn "Пароль для ${INSTALL_USER} не задан. После установки выполни: sudo passwd ${INSTALL_USER}"
fi
usermod -aG sudo "$INSTALL_USER" >> "$LOG_FILE" 2>&1 || true
ok "Пользователь ${INSTALL_USER} добавлен в sudo"
check_password_state "$INSTALL_USER"
}
run_as_user() {
if [[ "$INSTALL_USER" == "root" ]]; then
bash -lc "$*"
else
sudo -H -u "$INSTALL_USER" bash -lc "$*"
fi
}
prepare_project_dir() {
mkdir -p "$PROJECT_BASE_DIR"
if [[ "$INSTALL_USER" == "root" ]]; then
chown root:root "$PROJECT_BASE_DIR"
else
chown "$INSTALL_USER:$INSTALL_USER" "$PROJECT_BASE_DIR"
fi
chmod 755 "$PROJECT_BASE_DIR"
ok "Рабочая директория подготовлена: ${PROJECT_BASE_DIR}"
}
header
step "Проверяем root и apt"
require_root
command -v apt >/dev/null 2>&1 || fail "apt не найден. Нужен Debian/Ubuntu"
ok "root подтверждён"
ok "apt найден"
check_requirements
step "Устанавливаем базовые пакеты"
run_logged "apt update выполнен" apt update
run_logged "Установлены sudo/git/curl/ca-certificates" apt install -y sudo git curl ca-certificates openssh-client nano htop
step "Создаём или проверяем пользователя"
ensure_user
step "Готовим рабочую директорию в /opt"
prepare_project_dir
step "Проверяем место после подготовки пользователя"
root_free_after="$(free_mb_for_path /)"
if (( root_free_after < MIN_ROOT_FREE_MB )); then
fail "После подготовки пользователя свободного места стало мало: ${root_free_after} MB на /. Очисти диск и повтори установку"
fi
ok "Место после подготовки пользователя: ${root_free_after} MB на /"
step "Клонируем или обновляем репозиторий"
if [[ -d "$PROJECT_DIR/.git" ]]; then
run_logged "Репозиторий обновлён" run_as_user "cd '$PROJECT_DIR' && git reset --hard origin/main && git pull"
else
run_logged "Репозиторий склонирован" run_as_user "cd '$PROJECT_BASE_DIR' && git clone '$REPO_URL' source"
fi
step "Запускаем основной установщик ноды"
run_logged "Virtuality Node установлен" bash "$PROJECT_DIR/install_virtuality_node.sh"
step "Устанавливаем healthcheck-команду"
run_logged "Healthcheck установлен" bash "$PROJECT_DIR/scripts/install_healthcheck_command.sh"
step "Устанавливаем консольный dashboard"
run_logged "Console dashboard установлен" bash "$PROJECT_DIR/scripts/install_console_dashboard.sh"
step "Устанавливаем web-панель"
VIRTUALITY_AUTH_USER="$INSTALL_USER" VIRTUALITY_WEB_PORT="$WEB_PORT" bash "$PROJECT_DIR/scripts/install_web_panel.sh" >> "$LOG_FILE" 2>&1 &
web_pid=$!
web_start="$(date +%s)"
web_i=0
web_spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
while kill -0 "$web_pid" 2>/dev/null; do
web_elapsed=$(( $(date +%s) - web_start ))
printf "\r ${CYAN}%s${RESET} Web-панель устанавливается... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "${web_spinner[$((web_i % ${#web_spinner[@]}))]}" "$web_elapsed" "$LOG_FILE"
sleep 1
web_i=$((web_i + 1))
done
wait "$web_pid"
web_code=$?
printf "\r%*s\r" 120 ""
[[ "$web_code" -eq 0 ]] && ok "Web-панель установлена" || fail "Web-панель не установлена"
if [[ "$RUN_BRIDGE" == "1" ]]; then
step "Настраиваем bridge br0"
if [[ -z "$BRIDGE_IFACE" ]]; then
BRIDGE_IFACE="$(ip route | awk '/default/ {print $5; exit}')"
fi
if [[ -z "$BRIDGE_IFACE" ]]; then
warn "Не удалось определить интерфейс для br0. Пропускаю bridge setup"
else
warn "Bridge меняет сеть. Если SSH зависнет, нужен физический доступ или консоль провайдера."
bash "$PROJECT_DIR/scripts/setup_bridge_br0.sh" "$BRIDGE_IFACE" static >> "$LOG_FILE" 2>&1 && ok "Bridge br0 настроен на ${BRIDGE_IFACE}" || warn "Bridge setup завершился с предупреждением. Проверь лог"
fi
else
warn "Bridge br0 не настраивался автоматически. Запуск вручную: sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
fi
if [[ "$RUN_TEST_VM" == "1" ]]; then
step "Создаём тестовую VM"
run_logged "Тестовая VM создана" bash "$PROJECT_DIR/scripts/create_test_vm.sh" || warn "Тестовая VM не создана. Проверь ISO/bridge/log"
fi
step "Финальная диагностика"
if command -v vhealth >/dev/null 2>&1; then
vhealth >> "$LOG_FILE" 2>&1 && ok "vhealth: OK" || warn "vhealth вернул warnings/errors. Проверь: sudo vhealth"
else
warn "Команда vhealth не найдена"
fi
SERVER_IP="$(hostname -I | awk '{print $1}')"
echo
echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Virtuality Bootstrap завершён${RESET} ${GREEN}${BOLD}│${RESET}"
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo
echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:9090"
echo -e "${BOLD}Web Panel:${RESET} http://${SERVER_IP}:${WEB_PORT}"
echo -e "${BOLD}Login:${RESET} ${INSTALL_USER} / пароль Linux-пользователя"
echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
echo -e "${BOLD}Log:${RESET} ${LOG_FILE}"
echo
echo -e "${BOLD}После установки:${RESET}"
echo " passwd ${INSTALL_USER} # если пароль ещё не задан или заблокирован"
echo " sudo vhealth"
echo " cd ${PROJECT_DIR}"
echo " ip -br a"
echo
echo -e "${YELLOW}Важно:${RESET} bridge br0 по умолчанию не включается в one-command режиме, чтобы не уронить SSH."
echo "Для bridge: cd ${PROJECT_DIR} && sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
echo
+139 -87
Просмотреть файл
@@ -1,122 +1,174 @@
# Архитектура Virtuality
Virtuality — это платформа виртуализации в стиле Proxmox, но с более компактной и контролируемой архитектурой.
## Цель
Создать собственную систему управления виртуальными машинами на базе стабильных Linux-компонентов:
- KVM;
- QEMU;
- libvirt;
- Cockpit на раннем этапе;
- собственная веб-панель на следующих этапах.
## Базовая схема
Virtuality — одна нода: сервер с KVM/QEMU/libvirt, поверх которого работает панель на FastAPI. Панель не хранит собственную базу данных: источник правды о машинах — libvirt (`virsh`), о ноде — файлы в `/var/lib/virtuality/config`, о задачах — файлы в `/var/log/virtuality/operations`. Всё, что меняет сам сервер (службы, firewall, сеть, диски), делает один скрипт — `virtuality-ctl`, и панель вызывает его же.
```text
Веб-интерфейс
↓
Backend API
↓
Node services
↓
libvirt / QEMU / KVM
↓
Host OS / Storage / Network
Браузер
│ HTTP 8088 / HTTPS 8443
▼
virtuality-web.service — uvicorn, web/app.py (FastAPI, Jinja2)
├── web/features/* мастер, настройки, снимки, копии, загрузки, операции с машиной
├── web/core.py общие примитивы, хранилище задач
├── web/nodectl.py ───────► /usr/local/bin/virtuality-ctl (systemd, TLS, ufw, br0, диск, пароль)
├── web/network_core.py ──► virsh net-*, nft, ufw, iptables (сеть NAT и пробросы)
└── virsh / virt-install / virt-clone / qemu-img ──► libvirtd ──► QEMU/KVM
```
## Слои системы
---
### 1. Web UI
## Код панели: `web/`
Будущая пользовательская панель Virtuality.
| Модуль | Назначение |
|---|---|
| `app.py` | Основное приложение: вход, обзор, машины (создание, действия, ресурсы, порядок загрузки, привод), ISO и образы дисков (загрузка, конвертация, распаковка), сеть и пробросы, обновления, задачи, журналы, экран машины (WebSocket-прокси к VNC), `/healthz`. В конце подключает роутеры из `features/`. |
| `core.py` | Общие примитивы: настройки из `.env`, сессии и авторизация (`require_auth`, `require_api_auth`, защита от cross-origin POST, ограничение попыток входа), `run_cmd`, `render` и окружение шаблонов, `valid_vm_name`, хранилище задач (`new_operation`, `run_operation`, `update_operation`, `finish_operation`, `active_operations_for`, `running_operations`, `interrupt_orphaned_operations`). Импорт `core` выставляет `LC_ALL=C.UTF-8`: вывод `virsh` разбирается панелью и не должен переводиться. |
| `auth.py` | Проверка пароля Linux-пользователя по `/etc/shadow`: через `crypt`/`spwd`, а на Python 3.13+ — через `libcrypt` (ctypes). Ограничение попыток входа. |
| `features/setup.py` | Мастер первичной настройки `/setup`: шаги install → welcome → account → access → network → storage → updates → finish, страница «Применяем настройки», ожидание перестройки сети в мост. |
| `features/settings.py` | Страница «Настройки»: доступ (HTTPS/порт), пароль, часовой пояс, обновления, сеть машин, хранилище, перезагрузка/выключение, архив настроек, отчёт для поддержки. |
| `features/snapshots.py` | Снимки: внутренние снимки libvirt (`virsh snapshot-*`), до 8 на машину, создание и возврат — фоновые задачи. |
| `features/backups.py` | Резервные копии: `vm.xml` + копии дисков + `meta.json` в `backups/<машина>/<ГГГГММДД-ЧЧММ>/`; восстановление под тем же или новым именем; запуск как скрипта для расписания. |
| `features/downloads.py` | Скачивание ISO и образов по ссылке и из каталога — фоновые задачи типа `download` с прогрессом, проверкой контрольной суммы и отменой. |
| `features/vmops.py` | Увеличение диска, клонирование (`virt-clone`), пауза, спящий режим (`virsh managedsave`), заметки (title/description машины), живая нагрузка (`virsh domstats`). |
| `nodectl.py` | Факты о ноде (сеть, диски, оборудование, часовые пояса, состояние первой загрузки, выбор мастера) и действия через `virtuality-ctl`. Действия, которые перезапускают панель, запускаются отложенно через `systemd-run`. |
| `network_core.py` | Сеть NAT `virtuality-nat`, резервации DHCP, пробросы портов и их применение (nftables, ufw, iptables). |
| `catalog.py` | Каталог облачных образов (Ubuntu 26.04/24.04, Debian 13, Alpine 3.23 для x86_64 и aarch64) и типы ОС для установки с ISO (`osinfo` для `virt-install`). |
| `cloudinit.py` | Seed cloud-init для машин из готовых образов: `user-data` (пользователь, хеш пароля SHA-512, SSH-ключи, рост диска, гостевой агент) и `meta-data`; подключается к `virt-install --cloud-init`. |
| `host_profile.py` | Профиль сервера (`x86_64`, `raspberry-arm64`, `orangepi5-arm64`, `generic-arm64`), проверки готовности; хранится в `config/host_profile.json`. |
| `update_core.py` | Проверка обновлений: канал, целевой ref, список пропущенных версий из `updates/versions.json`, запуск `scripts/apply_github_update.sh` через `systemd-run`. |
| `presenters.py` | Человеческие названия состояний, профили ресурсов машин, список типовых портов, форматирование размеров. |
| `restore_network.py` | Повторное применение пробросов после загрузки (`virtuality-network.service`). |
| `redirect_http.py` | Перенаправление HTTP → HTTPS на порту 8088, когда включён TLS (`virtuality-redirect.service`). |
| `templates/`, `static/` | Jinja2-шаблоны (`base.html` — каркас, `_ui.html` — компоненты), `app.css` (светлая и тёмная темы), `panel.js`, шрифт и иконки. Панель не ходит в интернет. |
Задачи:
Правило для роутов модулей: POST-маршруты вида `/vm/{name}/<модуль>/<действие>` — минимум четыре сегмента, иначе их перехватит общий `@app.post("/vm/{name}/{action}")`.
- список виртуальных машин;
- создание VM;
- управление питанием;
- консоль;
- хранилища;
- сеть;
- бэкапы;
- пользователи и роли.
### Фоновые задачи
### 2. Backend API
Долгие операции (создание машины, конвертация образа, скачивание, снимок, копия, клон, спящий режим) выполняются в потоках и записываются в `/var/log/virtuality/operations/<id>.json` (статус, прогресс, сообщение, метаданные) и `<id>.log` (журнал). Раздел «Задачи» и страницы машин читают эти файлы; `active_operations_for(vm, kinds)` защищает машину от двух одновременных операций. При старте панели `interrupt_orphaned_operations()` закрывает задачи, оставшиеся в состоянии «выполняется» после перезапуска. `virtuality-ctl maintenance` удаляет задачи старше 30 дней и оставляет не больше 300.
Единая точка управления нодой.
---
Задачи:
## Управление нодой: `scripts/virtuality-ctl`
- принимать команды от Web UI;
- валидировать действия;
- работать с libvirt;
- вести журнал событий;
- отдавать состояние VM, storage и сети.
Один bash-скрипт, устанавливается в `/usr/local/bin`. Панель (через `nodectl.py`) и администратор в терминале делают одно и то же:
### 3. Node services
- **`reconfigure`** — читает `/var/lib/virtuality/config/web.env` и пересоздаёт systemd-юниты, logrotate, `/etc/default/libvirt-guests`, правила ufw; переводит VNC старых машин на `127.0.0.1`; перезапускает панель.
- **`set KEY=VALUE`** — проверяет и сохраняет настройку в `web.env`, затем `reconfigure` (для настроек обновления — только перезапись таймера).
- **TLS** — при `VIRTUALITY_TLS=1` создаёт самоподписанный сертификат EC P-256 на 10 лет в `config/tls/panel.{key,crt}` (SAN: имя сервера, localhost, адрес), uvicorn слушает `VIRTUALITY_TLS_PORT` (8443), а `virtuality-redirect.service` перенаправляет с HTTP-порта.
- **Мост `br0`** — `bridge <iface> [dhcp|static]` делает копию `/etc/netplan`, пишет `60-virtuality-br0.yaml`, проверяет `netplan generate`, взводит таймер `virtuality-netplan-revert` на 120 секунд и применяет; `bridge-confirm` снимает таймер, `bridge-revert` возвращает копию. Панель после перестройки сети сама вызывает подтверждение, когда снова получает ответ от сервера.
- **`storage-use /dev/sdX`** — только пустой диск без разделов: `mkfs.ext4`, перенос текущего содержимого `/var/lib/virtuality`, запись в `/etc/fstab` (`nofail`), монтирование.
- **`passwd`, `timezone`, `power`, `backup-config`, `restore-config`, `rollback`, `maintenance`, `support-bundle`, `uninstall`** — см. README.
Системные сервисы Virtuality.
Журнал действий: `/var/log/virtuality/virtuality-ctl.log`.
Задачи:
---
- диагностика ноды;
- обновления;
- резервное копирование;
- мониторинг;
- фоновые задачи.
## Службы systemd
### 4. libvirt / QEMU / KVM
Создаются `virtuality-ctl reconfigure`:
Нижний слой виртуализации.
| Юнит | Что делает |
|---|---|
| `virtuality-web.service` | Панель: `uvicorn app:app` от root из `/opt/virtuality/web` с venv `/opt/virtuality/venv`; `Restart=always`. |
| `virtuality-redirect.service` | Только при TLS: HTTP → HTTPS. |
| `virtuality-network.service` | При загрузке заново применяет пробросы портов (`restore_network.py`). |
| `virtuality-auto-update.timer` / `.service` | Ночная проверка обновлений (`scripts/auto_update_check.sh`), 04:00 ± 30 минут; включён при `VIRTUALITY_AUTO_UPDATE=1`. |
| `virtuality-maintenance.timer` / `.service` | Ежедневное обслуживание: старые задачи, временные файлы, недокачанные загрузки, `journalctl --vacuum-size=300M`, запись занятости диска в `config/health.json`. |
| `libvirt-guests.service` | При выключении сервера машины выключаются корректно (`ON_SHUTDOWN=shutdown`, до 180 секунд), при загрузке не запускаются сами — только те, у кого включён автозапуск. |
Задачи:
Отдельно, из образа: `virtuality-firstboot.service` — первая загрузка (см. [IMAGE.md](IMAGE.md)); временные юниты `virtuality-netplan-revert` (автооткат моста), `virtuality-apply-*` (отложенное применение настроек) и `virtuality-update-*` (обновление вне cgroup панели, чтобы перезапуск панели не убил сам процесс обновления).
- запуск виртуальных машин;
- управление дисками;
- управление сетевыми интерфейсами;
- снапшоты;
- консольный доступ.
---
### 5. Host OS
## Безопасность панели
Операционная система сервера.
- Вход — по имени и паролю системного пользователя (`VIRTUALITY_AUTH_USER`); проверяется по `/etc/shadow`, поэтому панель работает от root. После 5 неверных паролей адрес блокируется на 5 минут; сессия (подписанная cookie, ключ в `config/session_secret`) живёт 12 часов.
- POST-запросы принимаются только с того же origin; за reverse proxy нужно передавать исходный `Host` (и `Upgrade`/`Connection` для экрана машины).
- Экран машины: VNC у всех машин слушает `127.0.0.1`, панель проксирует его в WebSocket по подписанному токену и только при действующей сессии. Порты 5900–5999 в ufw закрыты.
- Пароль cloud-init хранится только как хеш SHA-512; секреты не попадают в журналы и отчёт для поддержки.
- Firewall: `install_virtuality_node.sh` включает ufw и разрешает SSH; `virtuality-ctl` открывает только порт панели (и HTTPS-порт), список открытых портов хранит в `config/ports.state`.
Целевые системы:
---
- Debian 12;
- Ubuntu Server 22.04;
- Ubuntu Server 24.04.
## Сеть машин
## Пути
### NAT: `virtuality-nat`
libvirt-сеть с мостом `virbr100`, подсеть `192.168.100.0/24`, шлюз `192.168.100.1`, DHCP `192.168.100.50–200`. Описание сохраняется в `/var/lib/virtuality/network/virtuality-nat.xml`. Создаётся мастером, страницей «Сеть и доступ» или автоматически при создании первой машины в режиме NAT. Включается `net.ipv4.ip_forward` (`/etc/sysctl.d/99-virtuality-forward.conf`) и ослабляется rp_filter (`98-virtuality-rpfilter.conf`).
**Постоянные адреса.** Для каждой машины MAC-адрес вычисляется из её имени (`stable_mac`), и в сети создаётся резервация DHCP (`virsh net-update add ip-dhcp-host`) — машина всегда получает один и тот же адрес, панель знает его ещё до первого запуска. При удалении машины резервация освобождается.
**Пробросы портов** хранятся в `/var/lib/virtuality/network/port_forwards.json` и применяются тремя способами сразу, чтобы работать на любом дистрибутиве:
1. nftables — таблица `ip virtuality` в `/etc/virtuality/nftables/virtuality.nft` (DNAT, forward, masquerade);
2. ufw — правила `route allow`, список сохранённых правил в `network/ufw_rules.json`;
3. iptables — запасной вариант, все правила помечены комментарием `virtuality-forward`, чтобы их можно было найти и удалить.
Адрес машины определяется через гостевой агент, `virsh domifaddr`, аренды DHCP по MAC машины, либо задаётся вручную. «Проверить» рядом с правилом делает TCP-подключение к машине и показывает команды для диагностики.
### Домашняя сеть: мост `br0`
Сервер объединяет свой проводной интерфейс в мост `br0` (netplan), машины подключаются к нему и получают адреса от роутера. Wi-Fi в мост объединить нельзя — тогда доступен только NAT. Как устроен автооткат — выше, в разделе про `virtuality-ctl`.
---
## Хранилище
```text
/opt/virtuality # код и конфигурация проекта
/var/lib/virtuality # runtime-хранилище
/var/lib/virtuality/iso # ISO-образы
/var/lib/virtuality/images # диски VM
/var/lib/virtuality/backups # резервные копии
/var/log/virtuality # логи
/var/lib/virtuality/
├── iso/ установочные образы; libvirt-пул virtuality-iso
├── images/ диски машин <имя>.qcow2; libvirt-пул virtuality-images
├── disk-images/ готовые образы систем (каталог, ссылки, загрузки с компьютера)
├── backups/ резервные копии машин (<машина>/<ГГГГММДД-ЧЧММ>/) и архивы настроек config-*.tar.gz
├── config/ web.env, setup.json, setup_done, wizard.json, host_profile.json,
│ session_secret, ports.state, health.json, tls/, netplan-backup/
├── network/ port_forwards.json, ufw_rules.json, virtuality-nat.xml
├── update/ state.json, last_check.json, временные файлы обновления
└── tmp/ загрузки (.<имя>.part, .<имя>.uploading), seed cloud-init; TMPDIR панели
```
## Принципы
Мастер и «Настройки» умеют отдать под всё это отдельный пустой диск — он монтируется в `/var/lib/virtuality` целиком. Память «спящих» машин libvirt хранит в `/var/lib/libvirt/qemu/save`.
- исходный код отдельно от runtime-данных;
- диски VM и ISO не хранятся в Git;
- конфиги должны быть прозрачными;
- установщик должен быть повторяемым;
- сетевые изменения делаются осторожно, чтобы не уронить SSH;
- архитектура документируется до активной разработки.
Код и служебные файлы:
## Этап v0.1
```text
/opt/virtuality/source git-репозиторий, из него ставится панель и берутся обновления
/opt/virtuality/web установленная панель (+ .env с пользователем и ключом сессий)
/opt/virtuality/web.prev предыдущая версия для virtuality-ctl rollback
/opt/virtuality/venv virtualenv
/opt/virtuality/virtuality.env пути ноды (пишет install_virtuality_node.sh)
/etc/virtuality/image.env параметры образа (только после установки с ISO)
/etc/virtuality/nftables/ правила пробросов
/var/log/virtuality/ firstboot.log, update.log, virtuality-ctl.log, install_*.log, operations/
```
На этапе v0.1 создаётся фундамент:
---
- установщик ноды;
- установка KVM/QEMU/libvirt;
- установка Cockpit;
- storage pools;
- структура проекта;
- GitHub-синхронизация;
- базовая диагностика.
## Обновления
Ноды обновляются из `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-архива того же репозитория (`<адрес>/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/`:
```text
install.sh установка одной командой (проверки, пакеты, пользователь, клон, три установщика ниже)
install_virtuality_node.sh пакеты KVM/QEMU/libvirt (x86_64 или ARM64), пулы, ufw, группы пользователя
scripts/install_web_panel.sh панель, venv (офлайн из wheels/ при наличии), virtuality-ctl, юниты
scripts/install_healthcheck_command.sh команда vhealth (scripts/virtuality_healthcheck.sh)
scripts/detect_host_profile.sh профиль сервера для установщика
scripts/virtuality_firstboot.sh первая загрузка после ISO
scripts/auto_update_check.sh, apply_github_update.sh, update_target.sh обновления
scripts/bump_patch_version.py поднять patch-версию в VERSION
image/build-iso.sh, autoinstall.yaml, files/, grub/ сборка ISO
```
---
## Тесты и CI
`tests/conftest.py` подменяет `run_cmd`, так что `virsh`, `systemctl`, `qemu-img` и остальные команды имитируются — тесты идут на любой машине без libvirt. `tests/dev_server.py` поднимает демо-панель с теми же подменами. `make check` = pyflakes + shellcheck + pytest, запускается перед каждым пушем. `.github/workflows/ci.yml` (то же на Python 3.11–3.14) и `.github/workflows/image.yml` (сборка ISO) заработают в Gitea Actions, когда будет подключён раннер.
+127 -93
Просмотреть файл
@@ -1,104 +1,138 @@
# Первая тестовая виртуальная машина
# Первая виртуальная машина
Этот документ фиксирует проверку первого рабочего сценария Virtuality: сеть `br0`, libvirt storage pools и запуск тестовой VM.
Всё в этом документе делается через панель в браузере — терминал не понадобится. Считаем, что Virtuality установлена и мастер настройки пройден (см. README).
## Требования
Сначала — самый быстрый путь: готовая Ubuntu за минуту. Потом — установка Windows 11 с ISO, экран машины, доступ снаружи, снимки и резервные копии.
Перед созданием VM должны быть готовы:
---
- KVM/QEMU;
- libvirt;
- Cockpit;
- storage pools `virtuality-images` и `virtuality-iso`;
- bridge `br0`;
- default route через `br0`.
## 1. Быстрая машина из каталога: Ubuntu Server 26.04
Проверка:
Готовые (облачные) образы — это уже установленная система без пароля. Панель скачивает образ, делает из него диск нужного размера и через cloud-init настраивает первый вход: пользователя, пароль, SSH-ключ.
### Скачать образ
1. Откройте **Образы ОС** → вкладка **Готовые системы**.
2. В карточке **Каталог готовых систем** найдите **Ubuntu Server 26.04 LTS** (помечена «Рекомендуем») и нажмите **Скачать**. Сервер скачает образ с сайта Ubuntu сам и проверит контрольную сумму; ход виден в **Задачах** и на этой же странице.
3. Когда образ отмечен «Загружен», рядом появится кнопка **Создать машину** — она открывает мастер с уже выбранным образом.
В каталоге также Ubuntu Server 24.04 LTS, Debian 13 и Alpine Linux 3.23. Свой образ (`.qcow2`, `.img`, `.raw`, `.img.xz`) можно загрузить с компьютера перетаскиванием или скачать по ссылке в карточке **Скачать по ссылке**.
### Создать машину
Шаг **1. Название и система**:
- **Название машины** — латиница, цифры, точка, дефис, подчёркивание (например, `ubuntu-1`). Это же имя станет именем компьютера внутри системы.
- Источник **Готовая система** уже выбран, образ подставлен.
- Переключатель **Настроить первый вход** должен быть включён: у готовых образов нет пароля, без этого шага в машину не войти.
- **Пользователь** — панель подставляет стандартного пользователя образа (`ubuntu`, у Debian — `debian`, у Alpine — `alpine`); можно указать свой. Он получит права администратора (`sudo`).
- **Пароль** — не короче 6 символов.
- **SSH-ключ** — необязательно; вставьте содержимое `~/.ssh/id_ed25519.pub`. Если указать только ключ без пароля, вход по SSH будет только по ключу.
Шаг **2. Мощность**: выберите профиль — **Мини** (1 ядро, 1 ГБ, 10 ГБ), **Стандарт** (2 ядра, 4 ГБ, 40 ГБ), **Мощная** (4 ядра, 8 ГБ, 80 ГБ) — или задайте ядра, память и диск сами. Диск образа увеличивается до выбранного размера, а система при первой загрузке сама расширит файловую систему.
Шаг **3. Сеть**: **Автоматически** — машина получит постоянный адрес `192.168.100.x` в отдельной сети и выйдет в интернет через сервер; снаружи её не видно, пока вы не откроете доступ. **В локальной сети** (если в мастере настройки выбран мост) — машина станет устройством вашей домашней сети и получит адрес от роутера.
Справа в **Итого** проверьте сводку и нажмите **Создать машину**. Панель откроет страницу задачи с живым журналом. Обычно в пределах минуты появится «Машина создана. Вход: ubuntu по паролю. Адрес в сети машин: 192.168.100.x».
### Войти
Откройте страницу машины (из **Обзора**) → **Открыть экран** и войдите с указанными пользователем и паролем. В машину сразу ставится гостевой агент, поэтому её адрес виден на странице машины и в **Сети и доступе** — по нему можно зайти по SSH из локальной сети (если выбран мост) или после проброса порта (см. ниже).
---
## 2. Windows 11 с установочного ISO
### Загрузить ISO
1. Скачайте ISO Windows 11 с сайта Microsoft на свой компьютер.
2. **Образы ОС** → вкладка **Установочные образы** → перетащите файл в окно загрузки. Или вставьте прямую ссылку на `.iso` в карточку **Скачать по ссылке** — сервер скачает его сам.
### Создать машину
1. **Создать машину** → шаг 1: название (например, `win11`), источник **Установить с диска**, выберите ISO.
2. **Что будете устанавливать** → **Windows 11 / Server 2025**. Панель сама включит UEFI и виртуальный TPM 2.0 (без него Windows 11 отказывается ставиться), подберёт диск SATA и сетевую карту Intel — драйверы во время установки не нужны.
3. Шаг 2: профиль **Мощная** (4 ядра, 8 ГБ, 80 ГБ) — Windows 11 требует минимум 4 ГБ памяти и 64 ГБ диска.
4. Шаг 3: сеть — как и для Linux.
5. **Создать машину**. Задача завершится за несколько секунд — машина создана и запущена с установочного диска.
### Установить систему
1. На странице машины нажмите **Открыть экран**. Сразу после запуска Windows показывает «Press any key to boot from CD or DVD» — нажмите любую клавишу в окне экрана, иначе загрузка с диска пропустится. Если пропустили — **Перезагрузить** на странице машины и попробовать снова.
2. Дальше установка идёт как на обычном компьютере. Диск в установщике будет пустым — выберите его целиком.
3. После установки на странице машины откройте вкладку **Диски и загрузка** → **Привод CD/DVD** и извлеките установочный образ, чтобы машина больше не предлагала загрузку с него.
Для других систем выбирайте тип **Linux** (Ubuntu, Debian, Fedora и другие), **Windows 10 / Server 2019–2022** (классическая загрузка) или **Другая система**.
---
## 3. Экран машины
- Кнопка **Открыть экран** на странице машины открывает экран в новой вкладке браузера — это как монитор, подключённый к машине. Работает и для установки системы, и когда сеть внутри машины ещё не настроена.
- Если курсор «застрял» внутри экрана, нажмите **Ctrl + Alt**.
- Экран доступен только через панель: VNC-порты машин наружу не открываются, соединение идёт через сервер по вашей сессии.
- Для повседневной работы удобнее подключаться по SSH (Linux) или по удалённому рабочему столу (Windows) — как открыть к ним доступ, ниже.
---
## 4. Доступ к машине снаружи (проброс портов)
В сети **Автоматически (NAT)** машины не видны ни из домашней сети, ни из интернета, пока вы не откроете нужный порт на сервере.
1. Откройте **Сеть и доступ** → карточка **Открыть доступ**.
2. **Машина** — выберите машину (рядом показан её адрес).
3. **Что открыть** — сервис из списка: **SSH — удалённый доступ к Linux**, **Сайт (HTTP)**, **Сайт (HTTPS)**, **Удалённый рабочий стол Windows (RDP)**, **Minecraft**, MySQL, PostgreSQL, OpenVPN, WireGuard и другие — или **Другой порт**.
4. **Порт в машине** заполнится сам (для SSH — `22`), **Порт на сервере** — например, `2222`. Протокол TCP или UDP подставляется по сервису. Можно указать диапазон вида `10000-20000`.
5. Нажмите **Открыть доступ**. Теперь `адрес-сервера:2222` ведёт в машину:
```bash
ssh ubuntu@адрес-сервера -p 2222
```
Для Windows выберите **RDP** и подключайтесь приложением «Подключение к удалённому рабочему столу» по адресу `адрес-сервера:3389` (или другому порту сервера, который вы указали).
Кнопка **Проверить** рядом с правилом покажет, доходит ли трафик до машины и слушает ли она порт. Правила восстанавливаются после перезагрузки сервера сами. Чтобы сервис был доступен из интернета, дополнительно пробросьте тот же порт на роутере на адрес сервера.
Адреса в сети NAT закреплены за машинами по имени, поэтому пробросы не ломаются после перезагрузок. Если адрес машины не определился (например, система внутри ещё не запущена), в карточке **Адреса машин** его можно указать вручную.
Машинам в режиме **В локальной сети** проброс не нужен: у них свой адрес от роутера, как у ноутбука или телефона.
---
## 5. Снимок перед рискованными изменениями
Снимок — точка восстановления машины: перед обновлением системы, экспериментом с настройками или установкой сомнительной программы.
1. Страница машины → вкладка **Снимки** → карточка **Создать снимок**, при желании впишите описание («перед обновлением») → **Создать снимок**.
2. У работающей машины в снимок попадает и память — она на несколько секунд замрёт, зато вернётся ровно в то же состояние с открытыми программами. У выключенной сохраняется только диск.
3. Если что-то пошло не так — **Вернуться** рядом с нужным снимком. Машина откатится к тому моменту; всё, что сделано после снимка, пропадёт.
4. Ненужные снимки удаляйте: они занимают место внутри диска машины. На одну машину — до 8 снимков.
Ограничения: снимки работают только с дисками qcow2 (все машины, созданные панелью, такие). Для машин с UEFI-прошивкой (Windows 11, образы Alpine, все машины на ARM64) внутренние снимки в текущей версии libvirt недоступны — панель предупредит; для них используйте резервные копии.
---
## 6. Резервная копия
Резервная копия — полная копия настроек машины и всех её дисков в отдельной папке на сервере. В отличие от снимка, она не зависит от диска машины и переживёт его повреждение.
1. Страница машины → вкладка **Резервные копии** → **Создать копию** (заметка по желанию). Работающая машина будет корректно выключена, скопирована и запущена снова — в списке машин в это время видно, что происходит.
2. Копии всех машин собраны в разделе **Резервные копии**; они лежат в `/var/lib/virtuality/backups/<машина>/<дата-время>/`. Ход создания виден в **Задачах**, страницу можно закрыть.
3. **Восстановить**: выберите копию → укажите название — то же (тогда текущую машину придётся заменить, панель попросит подтверждение) или новое (получите вторую машину рядом с первой). Восстановленная машина создаётся выключенной. Файлы копии остаются на месте, восстанавливать из неё можно сколько угодно раз.
4. Перед восстановлением панель проверяет, хватит ли места в `/var/lib/virtuality`.
Копию можно делать и по расписанию из терминала сервера — например, задачей cron:
```bash
sudo vhealth
ip -br a
ip route
virsh pool-list --all
sudo /opt/virtuality/venv/bin/python /opt/virtuality/web/features/backups.py web01 "ночная копия"
```
Нормальная сеть:
---
```text
enp2s0 UP
br0 UP 10.0.0.200/24
default via 10.0.0.1 dev br0
```
## Что дальше
## Создание тестовой VM
```bash
cd ~/virtuality
git pull
sudo bash scripts/create_test_vm.sh
```
По умолчанию создаётся VM:
```text
Name: test-alpine
RAM: 1024 MB
CPU: 1 vCPU
Disk: 8 GB qcow2
ISO: Alpine Linux
Network: br0
Graphics: VNC
```
Можно задать имя:
```bash
sudo bash scripts/create_test_vm.sh test-alpine-2
```
## Проверка
```bash
virsh list --all
sudo virsh vncdisplay test-alpine
sudo vhealth
```
Если `vncdisplay` показывает `:0`, значит порт VNC — `5900`.
Если показывает `:1`, значит порт VNC — `5901`.
## Доступ через Cockpit
Открыть:
```text
https://SERVER_IP:9090
```
Дальше:
```text
Virtual machines → test-alpine → Console
```
## Что считается успешным результатом
Этап считается пройденным, если:
- VM появилась в `virsh list --all`;
- VM запущена;
- консоль открывается в Cockpit или через VNC;
- VM подключена к bridge `br0`;
- `sudo vhealth` не показывает критических ошибок по Virtuality.
## Следующий этап
После первой тестовой VM можно переходить к разработке собственной панели управления Virtuality:
- список VM;
- статусы VM;
- запуск/остановка;
- просмотр storage pools;
- создание VM через UI;
- базовый журнал событий.
- **Клонировать** (меню на странице машины) — точная копия с новым именем и своим адресом, удобно для тестов.
- **Спящий режим** — сохранить память на диск и выключить; после запуска машина продолжит с того же места.
- **Автозапуск** — переключатель на странице машины: машина включится сама после перезагрузки сервера.
- **Ресурсы** — процессор и память можно поменять, когда машина выключена; диск — увеличить в любой момент во вкладке **Диски и загрузка**.
- Раздел **Помощь** в панели повторяет главное из этого документа.
+196
Просмотреть файл
@@ -0,0 +1,196 @@
# Установочный образ Virtuality
Загрузочный ISO ставит Virtuality на чистый сервер «с нуля», как Proxmox: записали на флешку, загрузились, ответили на три экрана — после перезагрузки панель уже открыта в браузере, а мастер настройки показывает, как ставятся остальные компоненты.
Образ собирается из официального, ничем не изменённого **Ubuntu Server 26.04 LTS live-server ISO** (amd64 или arm64) и дополняется:
```text
/autoinstall.yaml # сценарий установщика Ubuntu (subiquity autoinstall)
/virtuality/source # исходники Virtuality нужной версии (git-репозиторий)
/virtuality/source/wheels # Python-колёса панели и pip — для установки без интернета
/virtuality/image.env # параметры сборки: версия, порт панели, автообновление
/virtuality/virtuality-firstboot.service # служба первой загрузки
/boot/grub/grub.cfg # меню «Установить Virtuality X.Y.Z» с фирменной темой
/boot/grub/themes/virtuality/ # тема GRUB
```
Загрузочные записи BIOS и UEFI берутся из оригинального ISO без изменений.
---
## Где взять готовый образ
Образ собирается вручную командой `make iso` (см. ниже): раннер Actions в Gitea не подключён. Workflow `.github/workflows/image.yml` остаётся в репозитории и заработает, как только раннер появится: он собирает ISO по тегу `v*` и вручную, проверяет загрузочную запись UEFI, `autoinstall.yaml` и пункт Virtuality в `grub.cfg` и сохраняет `.iso` и `.sha256` как artifacts запуска.
---
## Сборка своими руками
Нужна Linux-машина (root не нужен), около 10 ГБ свободного места и пакеты:
```bash
sudo apt install -y xorriso git curl python3-pip
```
Сборка:
```bash
git clone https://git.stackworks.ru/viktor/virtuality.git
cd virtuality
make iso # amd64
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.1-ubuntu-26.04-amd64.iso
dist/virtuality-0.11.1-ubuntu-26.04-amd64.iso.sha256
```
Скачанный ISO Ubuntu кешируется в `.cache/iso` (или в `VIRTUALITY_ISO_CACHE`). Уже скачанный образ можно передать явно:
```bash
./image/build-iso.sh --ubuntu-iso ~/Downloads/ubuntu-26.04.1-live-server-amd64.iso
```
### Параметры `image/build-iso.sh`
```text
--arch amd64|arm64 архитектура (по умолчанию amd64)
--ubuntu 26.04|24.04 база образа (по умолчанию 26.04 LTS)
--ubuntu-iso PATH использовать локальный live-server ISO вместо скачивания
--cache-dir DIR где хранить скачанные ISO Ubuntu (по умолчанию .cache/iso)
--output DIR куда класть результат (по умолчанию dist)
--ref GIT_REF какую версию Virtuality положить в образ (по умолчанию HEAD)
--repo-url URL откуда установленная нода будет получать обновления
--web-port PORT порт панели на установленной ноде (по умолчанию 8088)
--no-auto-update не включать ночное автообновление
--locale LOCALE локаль установщика и системы (по умолчанию en_US.UTF-8)
--keyboard LAYOUT раскладка (по умолчанию us)
--timezone TZ часовой пояс (по умолчанию geoip — установщик определит по сети; мастер настройки может сменить)
--no-wheels не класть Python-колёса (тогда первая загрузка скачает их из PyPI)
```
Полностью автоматический режим (вопросов нет, **стирается самый большой диск**):
```text
--unattended автоматическая установка
--username NAME администратор (логин в панель), обязателен с --unattended
--password-hash HASH хеш crypt(3), например от: openssl passwd -6
--hostname NAME имя сервера (по умолчанию virtuality)
```
`./image/build-iso.sh --help` печатает тот же список. Через `make` параметры передаются так:
```bash
make iso ISO_ARGS="--web-port 8089 --no-auto-update --timezone Europe/Moscow"
```
### Что кладётся в образ
- **Исходники**: `git clone` репозитория на указанный `--ref`; у клона `origin` переключается на `--repo-url`, чтобы нода обновлялась оттуда.
- **Колёса**: `pip download` для Python целевого выпуска (3.14 для Ubuntu 26.04, 3.12 для 24.04) и архитектуры, только бинарные `manylinux`-колёса из `web/requirements.txt`. Сам `pip` тоже кладётся: в Ubuntu Server нет `python3-venv`, и установщик панели поднимает pip из этого колеса в virtualenv, созданный `--without-pip`. Благодаря этому панель ставится до появления сети.
- **`image.env`**: `VIRTUALITY_IMAGE_VERSION`, `VIRTUALITY_IMAGE_BUILT`, `VIRTUALITY_WEB_PORT`, `VIRTUALITY_AUTO_UPDATE` и, в автоматическом режиме, `VIRTUALITY_USER`. Установщик копирует файл в `/etc/virtuality/image.env`, его читает первая загрузка.
### Полностью автоматическая установка
Для массовой раскатки:
```bash
make iso ISO_ARGS="--unattended --username 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.1** (выбирается сам через 10 секунд), тот же пункт с новым ядром HWE, загрузка с другого диска, настройки UEFI, проверка памяти.
Сценарий `image/autoinstall.yaml` оставляет интерактивными только три экрана установщика Ubuntu:
1. **Сеть** — обычно достаточно подтвердить адрес по DHCP.
2. **Диск** — какой диск использовать. Разметка: LVM на весь диск.
3. **Пользователь** — имя сервера, имя пользователя и пароль. Этот пользователь становится администратором панели (и входит по SSH).
Язык, раскладка, часовой пояс (по умолчанию определяется по сети), SSH-сервер с входом по паролю и всё остальное заданы заранее. В конце установщик копирует исходники в `/opt/virtuality/source`, `image.env` в `/etc/virtuality/` и включает `virtuality-firstboot.service`. Затем — перезагрузка.
---
## Первая загрузка
`scripts/virtuality_firstboot.sh` запускается службой `virtuality-firstboot.service` один раз. Порядок шагов сделан так, чтобы панель появилась первой и показывала прогресс всего остального:
| Шаг | Что происходит | Что видно |
|---|---|---|
| `panel` | `scripts/install_web_panel.sh` с `VIRTUALITY_PANEL_ONLY=1`: панель ставится офлайн из колёс и запускается. | Через минуту после загрузки панель отвечает на `http://адрес:8088`; на экране сервера — подсказка `Virtuality настраивается`. |
| `network` | Ожидание маршрута по умолчанию и DNS. | Если через 20 секунд сети нет, мастер пишет «Нет доступа в интернет. Подключите кабель или настройте сеть — установка продолжится сама». |
| `virtualization` | `install_virtuality_node.sh`: пакеты KVM/QEMU/libvirt, пулы, firewall. Самый долгий шаг, 5–15 минут. | Мастер показывает, какой пакет скачивается, распаковывается или настраивается. |
| `tools` | `scripts/install_healthcheck_command.sh`: команда `vhealth`. | |
| `finish` | `virtuality-ctl reconfigure`: юниты, firewall, перезапуск панели; на экран сервера выводится адрес панели и логин. | Мастер переходит к шагу «Сервер». |
Прогресс пишется в `/var/lib/virtuality/config/setup.json` (`stage`, `message`, список шагов со статусами), мастер опрашивает его через `/api/setup/state`. Каждый шаг идемпотентен и отмечается флагом в `/var/lib/virtuality/.firstboot-steps/`; если что-то упало, systemd перезапускает скрипт через 60 секунд, и он продолжает с первого незавершённого шага — мастер в это время показывает ошибку и хвост журнала. По завершении создаётся `/var/lib/virtuality/.firstboot-done`, и служба больше не запускается.
Если образ собран с `--no-wheels`, офлайн-установка панели не удастся; скрипт дождётся сети и поставит зависимости из PyPI.
Смотреть ход установки с сервера:
```bash
sudo journalctl -fu virtuality-firstboot
cat /var/log/virtuality/firstboot.log
```
Логин в панель — пользователь из установщика, пароль — его пароль. Дальше вас ведёт мастер настройки (см. README).
---
## Тема загрузочного меню
`image/grub/theme.txt` — тема GRUB (gfxmenu), `background.png` — фон 1024×768, `select_*.png` — подсветка выбранного пункта. `build-iso.sh` дописывает в `grub.cfg` подключение темы (`gfxterm`, шрифт `unicode.pf2`, `gfxmode=1024x768`); если модуль или шрифт недоступны, GRUB показывает обычное текстовое меню. Пункты меню переводятся на русский, таймаут — 10 секунд. Контрольная сумма изменённого `grub.cfg` обновляется в `md5sum.txt`, чтобы проверка целостности ISO проходила.
Фон рендерится из `image/grub/background.html` (тот же стиль, что у страницы входа панели), как описано в `image/grub/README.md`.
---
## Проверка образа
Сверьте контрольную сумму скачанного или собранного образа:
```bash
sha256sum -c virtuality-0.11.1-ubuntu-26.04-amd64.iso.sha256
```
Убедиться, что образ загрузочный и содержит нужные файлы (то же делает CI):
```bash
xorriso -indev virtuality-0.11.1-ubuntu-26.04-amd64.iso -report_el_torito plain 2>/dev/null | grep UEFI
xorriso -osirrox on -indev virtuality-0.11.1-ubuntu-26.04-amd64.iso -extract /autoinstall.yaml autoinstall.yaml
```
---
## Запись на флешку
Нужна флешка от 4 ГБ. Все данные на ней будут стёрты.
- **Windows**: [Rufus](https://rufus.ie) (режим записи DD) или [balenaEtcher](https://etcher.balena.io).
- **macOS**: balenaEtcher.
- **Linux**: balenaEtcher или `dd`, где `/dev/sdX` — устройство флешки (проверьте через `lsblk`):
```bash
sudo dd if=dist/virtuality-0.11.1-ubuntu-26.04-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
```
Загрузитесь с флешки (меню загрузки — обычно F8, F11, F12 или Esc при включении), выберите **Установить Virtuality**.
---
## Ограничения
- База — Ubuntu Server 26.04 LTS или 24.04 LTS (`--ubuntu 24.04`).
- Для первой загрузки нужен интернет: пакеты KVM/libvirt ставятся из репозиториев Ubuntu. Панель и её Python-зависимости уже в образе.
- ARM64-образ рассчитан на серверы с UEFI. Raspberry Pi и Orange Pi 5 загружаются иначе — для них используйте штатный Ubuntu Server для платы и `install.sh`.
+46 -64
Просмотреть файл
@@ -1,95 +1,77 @@
# Системные требования Virtuality
# Требования
Virtuality — это серверная система управления виртуализацией на базе KVM/QEMU/libvirt/Cockpit и собственной web-панели.
## Коротко
## Минимальные требования
Подойдёт почти любой компьютер или мини-ПК не старше 10 лет с проводной сетью и включённой в BIOS/UEFI виртуализацией. Для одной-двух небольших машин достаточно 4 ГБ памяти; для Windows и нескольких машин сразу — 16 ГБ и SSD.
Подходят только для тестового стенда и одной-двух лёгких VM.
## Операционная система
```text
CPU: 2 ядра с аппаратной виртуализацией Intel VT-x / AMD-V
RAM: 4 GB
Диск /: минимум 8 GB свободно
Диск под /var/lib/virtuality: минимум 20 GB свободно
OS: Ubuntu Server 24.04 LTS / Debian-like с apt
Network: один проводной интерфейс
```
Virtuality работает на:
## Рекомендуемые требования
- **Ubuntu Server 26.04 LTS** — основа установочного ISO, рекомендуется;
- **Ubuntu Server 24.04 LTS**;
- **Debian 13**.
Для нормальной работы нескольких VM.
Другие системы на базе Debian/Ubuntu с `apt` скорее всего заработают, но не проверяются. Нужен systemd и netplan (для режима «домашняя сеть»). Установка с ISO делает всё сама; на готовую систему Virtuality ставится скриптом `install.sh` — ставьте её на чистый сервер, а не на рабочий компьютер.
```text
CPU: 4+ ядра с Intel VT-x / AMD-V
RAM: 16+ GB
Диск: 100+ GB SSD/NVMe под /var/lib/virtuality
Network: проводной интерфейс 1 Gbit/s+
OS: Ubuntu Server 24.04 LTS
```
## Процессор и память
## Проверки установщика
| | Минимум | Рекомендуется |
|---|---|---|
| Процессор | 2 ядра x86_64 с Intel VT-x / AMD-V | 4+ ядер |
| Память | 4 ГБ | 16 ГБ и больше |
| Системный диск | 8 ГБ свободно на `/` | SSD |
| Диск под машины | 20 ГБ свободно в `/var/lib` | 100+ ГБ SSD/NVMe, лучше отдельный диск |
| Сеть | один проводной интерфейс | 1 Гбит/с |
`bootstrap.sh` и `install_virtuality_node.sh` проверяют:
Как считать память: сервер и панель занимают около 1 ГБ, остальное делят машины. Ubuntu Server внутри машины — от 1 ГБ, Windows 11 — от 4 ГБ (лучше 8). Диск машины занимает место постепенно, по мере заполнения, но резервные копии и снимки требуют запаса: планируйте хотя бы вдвое больше, чем суммарный размер дисков машин.
- наличие root-прав;
- наличие `apt`;
- свободное место на `/`;
- свободное место для `/var/lib`;
- объём RAM;
- количество CPU cores;
- наличие флага аппаратной виртуализации `vmx` или `svm`.
**Виртуализация должна быть включена в BIOS/UEFI** (Intel VT-x / Virtualization Technology, AMD-V / SVM Mode). Без неё машины будут работать через медленную программную эмуляцию; панель предупредит об этом в разделе «Сервер», а `sudo vhealth` покажет ошибку `/dev/kvm missing`.
## Значения по умолчанию
Внутри другой виртуальной машины или на VPS Virtuality работает, только если включена вложенная виртуализация (nested virtualization).
## ARM64
Платы и серверы на ARM64 (Raspberry Pi 4/5, Orange Pi 5, серверы с UEFI) поддерживаются: панель определяет профиль, ставит `qemu-system-arm` и прошивку UEFI для ARM. Гости на таких серверах — **только ARM64** (в каталоге готовых систем есть образы aarch64 для Ubuntu, Debian и Alpine); образы x86_64 на них не запускаются. Установка Windows на ARM64 не проверялась.
Для Raspberry Pi и Orange Pi желательно 8 ГБ памяти и загрузка с SSD/NVMe. Установочный ISO для них не подходит (они грузятся иначе) — используйте штатный Ubuntu Server для платы и `install.sh`. ARM64-ISO предназначен для серверов с UEFI.
## Сеть
- Проводное подключение к роутеру. По Wi-Fi сервер работать может, но режим «домашняя сеть» (мост) недоступен — только NAT.
- Доступ в интернет при установке: первая загрузка после ISO и `install.sh` скачивают пакеты из репозиториев Ubuntu/Debian (сотни мегабайт). Панель после установки в интернет не ходит, кроме проверки обновлений и скачивания образов по вашей команде.
- Порты на сервере: `8088` (панель по HTTP), `8443` (панель по HTTPS), `22` (SSH). Открывать их на роутере наружу не обязательно и без HTTPS не рекомендуется.
- Для сети NAT машинам выделяется подсеть `192.168.100.0/24` — она не должна совпадать с вашей домашней сетью. Если роутер раздаёт именно `192.168.100.x`, выбирайте режим «домашняя сеть» или смените подсеть роутера.
- Если хотите, чтобы сервисы машин были доступны из интернета, потребуется проброс портов на роутере на адрес сервера (и «белый» адрес от провайдера).
## Что проверяет установщик
`install.sh` и `install_virtuality_node.sh` перед установкой проверяют права root, наличие `apt`, свободное место на `/` и в `/var/lib`, память, количество ядер и флаг виртуализации `vmx`/`svm`. Пороги можно изменить переменными окружения:
```text
VIRTUALITY_MIN_ROOT_FREE_MB=8192
VIRTUALITY_MIN_VAR_FREE_MB=20480
VIRTUALITY_MIN_RAM_MB=4096
VIRTUALITY_MIN_CPU_CORES=2
VIRTUALITY_SKIP_REQUIREMENTS=1 # отключить проверки совсем — только для тестов
```
## Переопределение требований
Для тестового стенда можно временно уменьшить требования:
Например, для тестового стенда с маленьким диском:
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/bootstrap.sh | sudo VIRTUALITY_MIN_VAR_FREE_MB=8192 bash
curl -fsSL https://git.stackworks.ru/viktor/virtuality/raw/branch/main/install.sh | VIRTUALITY_MIN_VAR_FREE_MB=8192 bash
```
Полностью отключить preflight-проверки можно так:
Установка с ISO эти пороги не применяет: диск и память проверяются мастером настройки, который покажет предупреждение, но не остановит установку.
```bash
curl -fsSL https://raw.githubusercontent.com/viktor138irk/virtuality/main/bootstrap.sh | sudo VIRTUALITY_SKIP_REQUIREMENTS=1 bash
```
Использовать отключение проверок стоит только для тестов. Для реального сервера это плохая идея: VM быстро съедают диск.
## Как быстро проверить место
Освободить место перед установкой на существующей системе:
```bash
df -h
df -i
du -xh / --max-depth=1 2>/dev/null | sort -h
```
## Очистка перед установкой
```bash
sudo apt clean
sudo apt autoclean
sudo apt autoremove -y
sudo rm -rf /var/lib/apt/lists/*
sudo rm -rf /tmp/* /var/tmp/*
sudo apt clean && sudo apt autoremove -y
sudo journalctl --vacuum-time=3d
```
## Где Virtuality хранит данные
## Браузер
```text
/opt/virtuality # системные конфиги и установленная web-панель
/home/<user>/virtuality # GitHub-репозиторий проекта
/var/lib/virtuality/iso # ISO-образы
/var/lib/virtuality/images # qcow2-диски VM
/var/lib/virtuality/backups # будущие backup VM
/var/log/virtuality # логи установки и диагностики
```
Панель работает в актуальных Chrome, Firefox, Safari и Edge, в том числе на телефоне. Экран машины (noVNC) требует поддержки WebSocket — она есть во всех перечисленных. При включённом HTTPS сертификат выпускается самим сервером, поэтому браузер один раз спросит, доверять ли ему.
+34 -103
Просмотреть файл
@@ -1,115 +1,46 @@
# Roadmap Virtuality
# Планы
Документ фиксирует направление разработки, чтобы проект не превратился в хаотичный комбайн.
Virtuality — панель для одного домашнего сервера. Этот документ честно фиксирует, что уже есть в версии 0.11 и что планируется дальше. Порядок в списке планов — примерный приоритет, сроков нет. Подробный журнал изменений по версиям — в `updates/versions.json` и в разделе «Обновления» панели.
## v0.1 — Фундамент ноды
## Сделано в 0.11
Цель: получить рабочую базу для запуска виртуализации.
**Установка и настройка**
Задачи:
- Установочный ISO на базе Ubuntu Server 26.04 LTS (amd64, arm64), сборка `make iso`, полностью автоматический режим.
- Первая загрузка: панель ставится первой и показывает ход установки в браузере, при ошибке продолжает сама.
- Мастер настройки: часовой пояс, пароль, HTTPS, сеть машин (NAT или домашняя сеть с автооткатом), отдельный диск под машины, обновления.
- Страница «Настройки», команда `virtuality-ctl`, установка одной командой на Ubuntu 24.04 / 26.04 и Debian 13.
- базовый установщик `install_virtuality_node.sh`;
- установка KVM/QEMU/libvirt;
- установка Cockpit и Cockpit Machines;
- создание директорий хранения;
- создание libvirt storage pools;
- базовая настройка firewall;
- подготовка GitHub-синхронизации;
- документация проекта.
**Машины**
## v0.2 — Сеть и диагностика
- Создание с ISO с выбором типа системы: Linux, Windows 11 / Server 2025 (UEFI + TPM), Windows 10, другая.
- Машина из готового образа за минуту: каталог (Ubuntu 26.04/24.04, Debian 13, Alpine), скачивание по ссылке, cloud-init с пользователем, паролем и SSH-ключом, гостевой агент.
- Снимки (до 8 на машину), резервные копии с восстановлением, клонирование, увеличение диска, пауза и спящий режим, заметки, живая нагрузка.
- Экран машины в браузере только через панель (VNC закрыт снаружи).
Цель: безопасно подготовить сетевую основу.
**Сеть и сервер**
Задачи:
- Сеть NAT с постоянными адресами, пробросы портов с проверкой и восстановлением после перезагрузки, домашняя сеть через мост `br0`.
- HTTPS для панели, стабильный канал обновлений и ранний доступ, откат, ежедневное обслуживание, отчёт для поддержки, архив настроек.
- Убрано: Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE, старые вспомогательные скрипты.
- диагностический скрипт ноды;
- определение активного сетевого интерфейса;
- безопасная настройка bridge `br0`;
- резервная копия сетевых конфигов перед изменением;
- откат сетевой конфигурации;
- улучшение логов установщика.
## Планируется
## v0.3 — Прототип панели
- **Резервные копии по расписанию из панели** — сейчас копия по расписанию делается только командой в терминале; нужен график и хранение N последних копий.
- **Копии на внешний диск или сетевую папку** — сейчас копии лежат только в `/var/lib/virtuality/backups`.
- **Контейнеры LXC** — лёгкие Linux-контейнеры рядом с машинами, как в Proxmox.
- **Проброс устройств (PCI/USB passthrough)** — видеокарты, USB-ключи, диски целиком в машину.
- **Второй сервер (кластер из двух нод)** — общий список машин, перенос машины между серверами, копии на соседний сервер.
- **Пользователи и роли** — сейчас в панель входит один администратор; нужны дополнительные пользователи с ограниченными правами.
- **Уведомления** — о завершении задач, ошибках, нехватке места и доступных обновлениях (почта, Telegram).
- **Шаблоны машин** — сохранить настроенную машину как шаблон и создавать из него новые.
- **Дополнительные диски и сети у машины** — второй диск, вторая сетевая карта, изменение сети существующей машины.
- **Сертификат Let's Encrypt** — вместо самоподписанного, для доступа к панели по доменному имени.
- **История нагрузки** — графики процессора, памяти и диска за день и неделю по серверу и машинам.
- **Проверка обновлений образов каталога** — предлагать обновить скачанный облачный образ, когда вышел новый.
Цель: начать собственную панель управления.
## Не планируется
Задачи:
- лёгкая веб-панель;
- авторизация;
- список виртуальных машин;
- статус VM;
- старт/стоп/перезагрузка VM через libvirt;
- отображение storage pools;
- базовый журнал событий.
## v0.4 — Создание VM
Цель: добавить мастер создания виртуальных машин.
Задачи:
- менеджер ISO;
- форма создания VM;
- выбор CPU/RAM/disk;
- выбор ISO;
- выбор сети;
- создание qcow2-диска;
- запуск установки ОС;
- базовая консоль.
## v0.5 — Обновления и бэкапы
Цель: сделать платформу обслуживаемой.
Задачи:
- автообновлятор;
- резервная копия конфигов перед обновлением;
- история обновлений;
- ручной backup VM;
- список бэкапов;
- восстановление из backup.
## v0.6 — Пользователи и роли
Цель: подготовить систему к реальному использованию несколькими пользователями.
Задачи:
- роли: admin, operator, viewer;
- ограничения действий по ролям;
- журнал действий пользователей;
- базовые настройки безопасности.
## v0.7 — Мониторинг
Цель: видеть состояние ноды без терминального шаманства.
Задачи:
- CPU/RAM/disk usage;
- статус libvirt;
- статус Cockpit;
- количество VM;
- состояние storage pools;
- алерты;
- Telegram/email-уведомления в будущем.
## v1.0 — Первый стабильный релиз
Цель: рабочая одиночная нода Virtuality.
Минимальный состав:
- установка одной командой;
- управление VM;
- создание VM;
- сеть bridge;
- ISO manager;
- backup/restore;
- web dashboard;
- базовая безопасность;
- документация;
- обновлятор.
- Многосерверные кластеры с общим хранилищем, Ceph, живая миграция — это уровень Proxmox для дата-центров, не для дома.
- Поддержка других гипервизоров, кроме KVM/QEMU/libvirt.
- Гости x86_64 на ARM64-серверах через эмуляцию — слишком медленно, чтобы этим пользоваться.
Двоичные данные
Двоичный файл не отображается.

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

+31
Просмотреть файл
@@ -0,0 +1,31 @@
#cloud-config
# Virtuality installer configuration for Ubuntu Server 24.04 (subiquity autoinstall).
# build-iso.sh puts this file at /autoinstall.yaml on the ISO.
# @@VARIABLES@@ are substituted at build time.
autoinstall:
version: 1
# The installer asks only for these screens; everything else is automated.
interactive-sections:
@@INTERACTIVE_SECTIONS@@
locale: @@LOCALE@@
keyboard:
layout: @@KEYBOARD@@
timezone: @@TIMEZONE@@
source:
id: ubuntu-server
ssh:
install-server: true
allow-pw: true
@@IDENTITY@@
storage:
layout:
name: lvm
sizing-policy: all
late-commands:
- mkdir -p /target/opt/virtuality /target/etc/virtuality
- cp -a /cdrom/virtuality/source /target/opt/virtuality/source
- chown -R root:root /target/opt/virtuality
- chmod -R u+w /target/opt/virtuality/source
- cp /cdrom/virtuality/image.env /target/etc/virtuality/image.env
- cp /cdrom/virtuality/virtuality-firstboot.service /target/etc/systemd/system/virtuality-firstboot.service
- curtin in-target --target=/target -- systemctl enable virtuality-firstboot.service
+275
Просмотреть файл
@@ -0,0 +1,275 @@
#!/usr/bin/env bash
set -euo pipefail
# ==========================================================
# Virtuality ISO builder
# Remasters the official Ubuntu Server LTS live ISO (26.04 by default) into a
# Virtuality installer: Ubuntu autoinstall + bundled Virtuality
# source and Python wheels + first boot setup service.
#
# Usage: image/build-iso.sh [options] (see --help)
# Needs: xorriso, git, curl, python3 with pip, sha256sum.
# Does not need root.
# ==========================================================
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE_DIR="${REPO_ROOT}/image"
ARCH="amd64"
UBUNTU_SERIES="26.04"
UBUNTU_ISO=""
CACHE_DIR="${VIRTUALITY_ISO_CACHE:-${REPO_ROOT}/.cache/iso}"
OUTPUT_DIR="${REPO_ROOT}/dist"
GIT_REF="HEAD"
REPO_URL="https://git.stackworks.ru/viktor/virtuality.git"
WEB_PORT="8088"
AUTO_UPDATE="1"
UNATTENDED="0"
USERNAME=""
PASSWORD_HASH=""
HOSTNAME_VALUE="virtuality"
LOCALE="en_US.UTF-8"
KEYBOARD="us"
TIMEZONE="geoip"
WITH_WHEELS="1"
TARGET_PYTHON=""
usage() {
cat <<EOF
Usage: $(basename "$0") [options]
Options:
--arch amd64|arm64 Target architecture (default: ${ARCH})
--ubuntu 26.04|24.04 Ubuntu Server LTS release to base on (default: ${UBUNTU_SERIES})
--ubuntu-iso PATH Use a local Ubuntu live-server ISO of that release instead of downloading
--cache-dir DIR Where downloaded Ubuntu ISOs are kept (default: ${CACHE_DIR})
--output DIR Output directory (default: ${OUTPUT_DIR})
--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 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)
--no-wheels Do not bundle Python wheels (first boot downloads them from PyPI)
Unattended mode (no questions at all, ERASES THE LARGEST DISK):
--unattended Fully automatic install
--username NAME Admin user (panel login), required with --unattended
--password-hash HASH crypt(3) hash, e.g. from: openssl passwd -6
--hostname NAME Hostname (default: ${HOSTNAME_VALUE})
Without --unattended the installer asks for network, disk and user
like a regular Ubuntu Server install; everything else is automated.
EOF
}
die() { echo "ERROR: $*" >&2; exit 1; }
say() { echo "==> $*"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--ubuntu) UBUNTU_SERIES="$2"; shift 2 ;;
--ubuntu-iso) UBUNTU_ISO="$2"; shift 2 ;;
--cache-dir) CACHE_DIR="$2"; shift 2 ;;
--output) OUTPUT_DIR="$2"; shift 2 ;;
--ref) GIT_REF="$2"; shift 2 ;;
--repo-url) REPO_URL="$2"; shift 2 ;;
--web-port) WEB_PORT="$2"; shift 2 ;;
--no-auto-update) AUTO_UPDATE="0"; shift ;;
--locale) LOCALE="$2"; shift 2 ;;
--keyboard) KEYBOARD="$2"; shift 2 ;;
--timezone) TIMEZONE="$2"; shift 2 ;;
--no-wheels) WITH_WHEELS="0"; shift ;;
--unattended) UNATTENDED="1"; shift ;;
--username) USERNAME="$2"; shift 2 ;;
--password-hash) PASSWORD_HASH="$2"; shift 2 ;;
--hostname) HOSTNAME_VALUE="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; die "unknown option: $1" ;;
esac
done
# Python of the target release decides which wheels are bundled.
case "$UBUNTU_SERIES" in
26.04) TARGET_PYTHON="3.14" ;;
24.04) TARGET_PYTHON="3.12" ;;
*) die "unsupported --ubuntu ${UBUNTU_SERIES} (26.04 or 24.04)" ;;
esac
case "$ARCH" in
amd64) PY_PLATFORM_ARCH="x86_64"; MIRROR="https://releases.ubuntu.com/${UBUNTU_SERIES}" ;;
arm64) PY_PLATFORM_ARCH="aarch64"; MIRROR="https://cdimage.ubuntu.com/releases/${UBUNTU_SERIES}/release" ;;
*) die "unsupported arch: $ARCH (amd64 or arm64)" ;;
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
for cmd in xorriso git curl python3 sha256sum md5sum; do
command -v "$cmd" >/dev/null 2>&1 || die "$cmd not found (Ubuntu: sudo apt install xorriso git curl python3-pip)"
done
VERSION="$(git -C "$REPO_ROOT" show "${GIT_REF}:VERSION" | tr -d '[:space:]')"
[[ -n "$VERSION" ]] || die "cannot read VERSION at ${GIT_REF}"
SUFFIX=""
[[ "$UNATTENDED" == "1" ]] && SUFFIX="-unattended"
OUTPUT_ISO="${OUTPUT_DIR}/virtuality-${VERSION}-ubuntu-${UBUNTU_SERIES}-${ARCH}${SUFFIX}.iso"
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/virtuality-iso.XXXXXX")"
trap 'rm -rf "$WORK_DIR"' EXIT
mkdir -p "$OUTPUT_DIR" "$CACHE_DIR"
# ---------------------------------------------------------- Ubuntu ISO
if [[ -z "$UBUNTU_ISO" ]]; then
say "Resolving the latest Ubuntu ${UBUNTU_SERIES} live-server ISO for ${ARCH}"
curl -fsSL --retry 3 "${MIRROR}/SHA256SUMS" -o "${WORK_DIR}/SHA256SUMS"
iso_name="$(grep -oE "ubuntu-${UBUNTU_SERIES}(\.[0-9]+)*-live-server-${ARCH}\.iso" "${WORK_DIR}/SHA256SUMS" | sort -uV | tail -n1)"
[[ -n "$iso_name" ]] || die "live-server ISO for ${ARCH} not found in ${MIRROR}/SHA256SUMS"
expected_sha="$(awk -v n="*${iso_name}" '$2 == n || $2 == substr(n, 2) {print $1}' "${WORK_DIR}/SHA256SUMS")"
UBUNTU_ISO="${CACHE_DIR}/${iso_name}"
if [[ -f "$UBUNTU_ISO" ]] && echo "${expected_sha} ${UBUNTU_ISO}" | sha256sum -c --status; then
say "Using cached ${iso_name}"
else
say "Downloading ${iso_name}"
curl -fL --retry 5 -C - -o "${UBUNTU_ISO}.part" "${MIRROR}/${iso_name}"
mv "${UBUNTU_ISO}.part" "$UBUNTU_ISO"
echo "${expected_sha} ${UBUNTU_ISO}" | sha256sum -c --status || die "checksum mismatch for ${iso_name}"
fi
say "Checksum OK: ${expected_sha}"
fi
[[ -f "$UBUNTU_ISO" ]] || die "Ubuntu ISO not found: $UBUNTU_ISO"
# ---------------------------------------------------------- payload
PAYLOAD="${WORK_DIR}/virtuality"
mkdir -p "$PAYLOAD"
say "Bundling Virtuality ${VERSION} (${GIT_REF})"
git clone --quiet --no-hardlinks "$REPO_ROOT" "${PAYLOAD}/source"
git -C "${PAYLOAD}/source" checkout --quiet --detach "$(git -C "$REPO_ROOT" rev-parse "${GIT_REF}^{commit}")"
git -C "${PAYLOAD}/source" remote set-url origin "$REPO_URL"
if [[ "$WITH_WHEELS" == "1" ]]; then
say "Downloading Python ${TARGET_PYTHON} wheels for ${PY_PLATFORM_ARCH}"
platform_args=(--platform "manylinux2014_${PY_PLATFORM_ARCH}")
for glibc_minor in 17 28 31 34 35 39; do
platform_args+=(--platform "manylinux_2_${glibc_minor}_${PY_PLATFORM_ARCH}")
done
# pip itself is bundled as well: Ubuntu Server has no python3-venv, the panel
# installer bootstraps pip from this wheel into a --without-pip virtualenv.
python3 -m pip download --quiet --disable-pip-version-check \
-r "${PAYLOAD}/source/web/requirements.txt" pip -d "${PAYLOAD}/source/wheels" \
--only-binary=:all: --implementation cp --python-version "$TARGET_PYTHON" \
--abi "cp${TARGET_PYTHON/./}" --abi abi3 --abi none "${platform_args[@]}"
fi
cat > "${PAYLOAD}/image.env" <<EOF
# Written by image/build-iso.sh, read by scripts/virtuality_firstboot.sh
VIRTUALITY_IMAGE_VERSION=${VERSION}
VIRTUALITY_IMAGE_BUILT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
VIRTUALITY_WEB_PORT=${WEB_PORT}
VIRTUALITY_AUTO_UPDATE=${AUTO_UPDATE}
EOF
[[ "$UNATTENDED" == "1" ]] && echo "VIRTUALITY_USER=${USERNAME}" >> "${PAYLOAD}/image.env"
cp "${IMAGE_DIR}/files/virtuality-firstboot.service" "${PAYLOAD}/virtuality-firstboot.service"
# ---------------------------------------------------------- autoinstall
if [[ "$UNATTENDED" == "1" ]]; then
interactive_sections=" []"
identity=" identity:
hostname: ${HOSTNAME_VALUE}
username: ${USERNAME}
realname: Virtuality Admin
password: '${PASSWORD_HASH}'"
else
interactive_sections=" - network
- storage
- identity"
identity=""
fi
python3 - "${IMAGE_DIR}/autoinstall.yaml" "${WORK_DIR}/autoinstall.yaml" <<PYEOF
import sys
text = open(sys.argv[1]).read()
values = {
"@@INTERACTIVE_SECTIONS@@": """${interactive_sections}""",
"@@IDENTITY@@": """${identity}""",
"@@LOCALE@@": "${LOCALE}",
"@@KEYBOARD@@": "${KEYBOARD}",
"@@TIMEZONE@@": "${TIMEZONE}",
}
for key, value in values.items():
text = text.replace(key, value)
open(sys.argv[2], "w").write(text)
PYEOF
python3 -c 'import sys, yaml; data = yaml.safe_load(open(sys.argv[1])); assert data["autoinstall"]["version"] == 1' "${WORK_DIR}/autoinstall.yaml" 2>/dev/null \
|| python3 -c 'import sys, json; print("PyYAML not available, skipping YAML validation")'
# ---------------------------------------------------------- boot menu
xorriso -osirrox on -indev "$UBUNTU_ISO" \
-extract /boot/grub/grub.cfg "${WORK_DIR}/grub.cfg" \
-extract /md5sum.txt "${WORK_DIR}/md5sum.txt" >/dev/null 2>&1 || die "cannot read grub.cfg from $UBUNTU_ISO"
chmod u+w "${WORK_DIR}/grub.cfg" "${WORK_DIR}/md5sum.txt"
python3 - "${WORK_DIR}/grub.cfg" "$UNATTENDED" "$VERSION" <<'PYEOF'
import re
import sys
path, unattended, version = sys.argv[1], sys.argv[2] == "1", sys.argv[3]
text = open(path).read()
# /autoinstall.yaml applies to every entry, so all Ubuntu entries become Virtuality entries.
suffix = " — автоматически, диск будет стёрт" if unattended else ""
text = text.replace('menuentry "Try or Install Ubuntu Server"', 'menuentry "Установить Virtuality %s%s"' % (version, suffix), 1)
text = text.replace('menuentry "Ubuntu Server with the HWE kernel"', 'menuentry "Установить Virtuality %s (новое ядро HWE)%s"' % (version, suffix), 1)
text = text.replace("menuentry 'Boot from next volume'", "menuentry 'Загрузить с другого диска'", 1)
text = text.replace("menuentry 'UEFI Firmware Settings'", "menuentry 'Настройки UEFI'", 1)
text = text.replace("menuentry 'Test memory'", "menuentry 'Проверить память'", 1)
if "Установить Virtuality" not in text:
raise SystemExit("unexpected grub.cfg menu titles")
if unattended:
text = re.sub(r"^(\s*linux\s+\S+\s+)---", r"\1autoinstall ---", text, flags=re.M)
text = re.sub(r"^set timeout=\d+\n", "", text, count=1, flags=re.M)
text = text.replace("loadfont unicode\n", "", 1)
# Branded graphical menu; if a module or the font is missing GRUB falls back to the plain text menu.
theme = """insmod all_video
insmod gfxterm
insmod png
insmod gfxmenu
set gfxmode=1024x768,auto
if loadfont /boot/grub/fonts/unicode.pf2; then
terminal_output gfxterm
set theme=/boot/grub/themes/virtuality/theme.txt
export theme
fi
set default=0
set timeout=10
"""
open(path, "w").write(theme + text)
PYEOF
new_md5="$(md5sum "${WORK_DIR}/grub.cfg" | cut -d' ' -f1)"
sed -i "s|^[0-9a-f]\{32\} \./boot/grub/grub.cfg$|${new_md5} ./boot/grub/grub.cfg|" "${WORK_DIR}/md5sum.txt"
# ---------------------------------------------------------- repack
THEME_DIR="${WORK_DIR}/theme"
mkdir -p "$THEME_DIR"
cp "${IMAGE_DIR}"/grub/theme.txt "${IMAGE_DIR}"/grub/*.png "$THEME_DIR"/
say "Writing ${OUTPUT_ISO}"
rm -f "$OUTPUT_ISO"
chmod -R a+rX,go-w "$PAYLOAD"
if ! xorriso -indev "$UBUNTU_ISO" -outdev "$OUTPUT_ISO" \
-uid 0 -gid 0 \
-map "$PAYLOAD" /virtuality \
-map "${WORK_DIR}/autoinstall.yaml" /autoinstall.yaml \
-map "${WORK_DIR}/grub.cfg" /boot/grub/grub.cfg \
-map "${THEME_DIR}" /boot/grub/themes/virtuality \
-map "${WORK_DIR}/md5sum.txt" /md5sum.txt \
-boot_image any replay > "${WORK_DIR}/xorriso.log" 2>&1; then
tail -n 30 "${WORK_DIR}/xorriso.log" >&2
die "xorriso failed"
fi
[[ -s "$OUTPUT_ISO" ]] || die "xorriso did not produce $OUTPUT_ISO"
(cd "$OUTPUT_DIR" && sha256sum "$(basename "$OUTPUT_ISO")" > "$(basename "$OUTPUT_ISO").sha256")
say "Done: ${OUTPUT_ISO} ($(du -h "$OUTPUT_ISO" | cut -f1))"
say "SHA256: $(cut -d' ' -f1 "${OUTPUT_ISO}.sha256")"
+19
Просмотреть файл
@@ -0,0 +1,19 @@
[Unit]
Description=Virtuality first boot setup
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
StartLimitIntervalSec=0
[Service]
# simple, not oneshot: boot must not wait for a multi-minute package install.
Type=simple
ExecStart=/bin/bash /opt/virtuality/source/scripts/virtuality_firstboot.sh
Restart=on-failure
RestartSec=60
StandardOutput=journal+console
StandardError=journal+console
[Install]
WantedBy=multi-user.target
+12
Просмотреть файл
@@ -0,0 +1,12 @@
# Тема загрузочного меню
`theme.txt` — тема GRUB (gfxmenu) для установочного ISO, `background.png` — фон 1024×768,
`select_*.png` — подсветка выбранного пункта.
Фон рендерится из `background.html` (тот же стиль, что у страницы входа панели):
```bash
NODE_PATH=$(npm root -g) node tests/render_html.mjs image/grub/background.html image/grub/background.png 1024 768
```
Пиксмапы подсветки — однотонные RGBA 6×6 (110, 96, 255, 150).
+19
Просмотреть файл
@@ -0,0 +1,19 @@
<!doctype html><html><head><meta charset="utf-8"><style>
html,body{margin:0;width:1024px;height:768px;overflow:hidden;font-family:Inter,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
body{background:radial-gradient(900px 520px at 8% 0%, rgba(123,107,255,.55), transparent 60%), radial-gradient(760px 520px at 100% 100%, rgba(47,115,240,.45), transparent 55%), #0b0a1f;color:#fff;position:relative}
.grid{position:absolute;inset:0;background-image:linear-gradient(rgba(255,255,255,.045) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.045) 1px,transparent 1px);background-size:48px 48px;-webkit-mask-image:radial-gradient(ellipse at 30% 30%,#000 15%,transparent 72%)}
.brand{position:absolute;left:72px;top:64px;display:flex;align-items:center;gap:18px}
.mark{width:64px;height:64px;border-radius:18px;background:linear-gradient(135deg,#7b6bff 0%,#5a48f5 45%,#2f73f0 100%);box-shadow:0 14px 40px -10px rgba(90,72,245,.9), inset 0 1px 0 rgba(255,255,255,.3);display:grid;place-items:center}
.mark svg{width:38px;height:38px}
.name{font-size:38px;font-weight:700;letter-spacing:-.02em;line-height:1}
.name small{display:block;margin-top:8px;font-size:16px;font-weight:500;color:rgba(255,255,255,.62);letter-spacing:0}
.tag{position:absolute;left:72px;top:176px;font-size:30px;font-weight:600;letter-spacing:-.02em;line-height:1.25;max-width:620px}
.tag span{background:linear-gradient(90deg,#c4bcff,#8fb8ff);-webkit-background-clip:text;background-clip:text;color:transparent}
.foot{position:absolute;left:72px;bottom:56px;color:rgba(255,255,255,.5);font-size:15px}
.hint{position:absolute;right:72px;bottom:56px;color:rgba(255,255,255,.5);font-size:15px;text-align:right}
</style></head><body><div class="grid"></div>
<div class="brand"><div class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M4.5 5.5 12 19l7.5-13.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M9 5.5h6" stroke="#fff" stroke-opacity=".55" stroke-width="2.6" stroke-linecap="round"/></svg></div><div class="name">Virtuality<small>Платформа виртуализации</small></div></div>
<div class="tag">Ваши серверы.<br><span>Под полным контролем.</span></div>
<div class="foot">Установщик · KVM · QEMU · libvirt</div>
<div class="hint">↑ ↓ — выбор пункта, Enter — запуск</div>
</body></html>
Двоичные данные
Двоичный файл не отображается.

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

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

После

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

+41
Просмотреть файл
@@ -0,0 +1,41 @@
desktop-image: "background.png"
desktop-image-scale-method: "stretch"
desktop-color: "#0b0a1f"
title-text: ""
terminal-font: "Unicode Regular"
terminal-left: "7%"
terminal-top: "36%"
terminal-width: "86%"
terminal-height: "50%"
terminal-border: "0"
+ boot_menu {
left = 7%
top = 36%
width = 60%
height = 42%
item_font = "Unicode Regular"
item_color = "#b9b8cf"
selected_item_color = "#ffffff"
item_height = 36
item_padding = 10
item_spacing = 6
icon_width = 0
icon_height = 0
scrollbar = false
selected_item_pixmap_style = "select_*.png"
}
+ progress_bar {
id = "__timeout__"
left = 7%
top = 86%
width = 60%
height = 24
font = "Unicode Regular"
text_color = "#ffffff"
fg_color = "#7b6bff"
bg_color = "#1c1a36"
border_color = "#2a2748"
text = "Автоматический запуск через %d с"
}
+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
+16 -58
Просмотреть файл
@@ -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
@@ -21,20 +21,18 @@ if [[ "${EUID}" -ne 0 ]]; then
exit 1
fi
chmod +x "$tmp_installer"
exec sudo --preserve-env=VIRTUALITY_INSTALL_URL,VIRTUALITY_USER,VIRTUALITY_WEB_PORT,VIRTUALITY_MIN_ROOT_FREE_MB,VIRTUALITY_MIN_VAR_FREE_MB,VIRTUALITY_MIN_RAM_MB,VIRTUALITY_MIN_CPU_CORES,VIRTUALITY_SKIP_REQUIREMENTS,VIRTUALITY_SETUP_BRIDGE,VIRTUALITY_BRIDGE_IFACE,VIRTUALITY_CREATE_TEST_VM,VIRTUALITY_REPO_URL,VIRTUALITY_PROJECT_BASE_DIR,VIRTUALITY_PROJECT_DIR,VIRTUALITY_INSTALL_VERBOSE,VIRTUALITY_CLEAN_BEFORE_INSTALL bash "$tmp_installer" "$@"
exec sudo --preserve-env=VIRTUALITY_INSTALL_URL,VIRTUALITY_USER,VIRTUALITY_WEB_PORT,VIRTUALITY_MIN_ROOT_FREE_MB,VIRTUALITY_MIN_VAR_FREE_MB,VIRTUALITY_MIN_RAM_MB,VIRTUALITY_MIN_CPU_CORES,VIRTUALITY_SKIP_REQUIREMENTS,VIRTUALITY_REPO_URL,VIRTUALITY_PROJECT_BASE_DIR,VIRTUALITY_PROJECT_DIR,VIRTUALITY_INSTALL_VERBOSE,VIRTUALITY_AUTO_UPDATE,VIRTUALITY_BRANCH bash "$tmp_installer" "$@"
fi
echo "Root or sudo is required. Run: curl -fsSL $SELF_INSTALL_URL | sudo bash" >&2
exit 1
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}"
PROJECT_DIR="${VIRTUALITY_PROJECT_DIR:-${PROJECT_BASE_DIR}/source}"
RUN_BRIDGE="${VIRTUALITY_SETUP_BRIDGE:-0}"
BRIDGE_IFACE="${VIRTUALITY_BRIDGE_IFACE:-}"
RUN_TEST_VM="${VIRTUALITY_CREATE_TEST_VM:-0}"
BRANCH="${VIRTUALITY_BRANCH:-main}"
WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
LOG_DIR="/var/log/virtuality"
LOG_FILE="${LOG_DIR}/install_$(date +%Y%m%d_%H%M%S).log"
@@ -44,7 +42,7 @@ MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}"
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
INSTALL_VERBOSE="${VIRTUALITY_INSTALL_VERBOSE:-0}"
CLEAN_BEFORE_INSTALL="${VIRTUALITY_CLEAN_BEFORE_INSTALL:-0}"
export DEBIAN_FRONTEND=noninteractive
ESC="\033"
RESET="${ESC}[0m"
@@ -117,7 +115,7 @@ header() {
clear 2>/dev/null || true
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Installer${RESET} ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} One-command KVM / libvirt / Cockpit / Web Panel setup ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} One-command KVM / libvirt / web panel setup ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo
echo -e "${GRAY}Repository:${RESET} ${REPO_URL}"
@@ -250,13 +248,6 @@ ensure_user
step "Готовим рабочую директорию в /opt"
prepare_project_dir
if [[ "$CLEAN_BEFORE_INSTALL" == "1" && -x "$PROJECT_DIR/scripts/clean_install.sh" ]]; then
step "Очищаем старую кривую установку перед установкой"
run_logged "Старая установка очищена" bash "$PROJECT_DIR/scripts/clean_install.sh" --yes
elif [[ "$CLEAN_BEFORE_INSTALL" == "1" ]]; then
warn "Очистка запрошена, но clean_install.sh пока недоступен. Продолжаю установку."
fi
step "Проверяем место после подготовки пользователя"
root_free_after="$(free_mb_for_path /)"
if (( root_free_after < MIN_ROOT_FREE_MB )); then
@@ -266,25 +257,17 @@ ok "Место после подготовки пользователя: ${root_
step "Клонируем или обновляем репозиторий"
if [[ -d "$PROJECT_DIR/.git" ]]; then
run_logged "Репозиторий обновлён" run_as_user "cd '$PROJECT_DIR' && git fetch origin main && git reset --hard origin/main && git pull --ff-only"
run_logged "Репозиторий обновлён (${BRANCH})" run_as_user "cd '$PROJECT_DIR' && git fetch origin '$BRANCH' && git checkout -q -B '$BRANCH' 'origin/$BRANCH'"
else
run_logged "Репозиторий склонирован" run_as_user "cd '$PROJECT_BASE_DIR' && git clone '$REPO_URL' source"
fi
if [[ "$CLEAN_BEFORE_INSTALL" == "1" && -x "$PROJECT_DIR/scripts/clean_install.sh" ]]; then
step "Очищаем старую кривую установку после обновления исходников"
run_logged "Старая установка очищена" bash "$PROJECT_DIR/scripts/clean_install.sh" --yes
run_logged "Репозиторий склонирован (${BRANCH})" run_as_user "cd '$PROJECT_BASE_DIR' && git clone --branch '$BRANCH' '$REPO_URL' source"
fi
step "Запускаем основной установщик ноды"
run_logged "Virtuality Node установлен" bash "$PROJECT_DIR/install_virtuality_node.sh"
run_logged "Virtuality Node установлен" env VIRTUALITY_USER="$INSTALL_USER" bash "$PROJECT_DIR/install_virtuality_node.sh"
step "Устанавливаем healthcheck-команду"
run_logged "Healthcheck установлен" bash "$PROJECT_DIR/scripts/install_healthcheck_command.sh"
step "Устанавливаем консольный dashboard"
run_logged "Console dashboard установлен" bash "$PROJECT_DIR/scripts/install_console_dashboard.sh"
step "Устанавливаем web-панель"
if [[ "$INSTALL_VERBOSE" == "1" ]]; then
VIRTUALITY_AUTH_USER="$INSTALL_USER" VIRTUALITY_WEB_PORT="$WEB_PORT" bash "$PROJECT_DIR/scripts/install_web_panel.sh" 2>&1 | tee -a "$LOG_FILE"
@@ -312,25 +295,6 @@ else
[[ "$web_code" -eq 0 ]] && ok "Web-панель установлена" || fail "Web-панель не установлена"
fi
if [[ "$RUN_BRIDGE" == "1" ]]; then
step "Настраиваем bridge br0"
if [[ -z "$BRIDGE_IFACE" ]]; then
BRIDGE_IFACE="$(ip route | awk '/default/ {print $5; exit}')"
fi
if [[ -z "$BRIDGE_IFACE" ]]; then
warn "Не удалось определить интерфейс для br0. Пропускаю bridge setup"
else
warn "Bridge меняет сеть. Если SSH зависнет, нужен физический доступ или консоль провайдера."
bash "$PROJECT_DIR/scripts/setup_bridge_br0.sh" "$BRIDGE_IFACE" static >> "$LOG_FILE" 2>&1 && ok "Bridge br0 настроен на ${BRIDGE_IFACE}" || warn "Bridge setup завершился с предупреждением. Проверь лог"
fi
else
warn "Bridge br0 не настраивался автоматически. Запуск вручную: sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
fi
if [[ "$RUN_TEST_VM" == "1" ]]; then
step "Создаём тестовую VM"
run_logged "Тестовая VM создана" bash "$PROJECT_DIR/scripts/create_test_vm.sh" || warn "Тестовая VM не создана. Проверь ISO/bridge/log"
fi
step "Финальная диагностика"
if command -v vhealth >/dev/null 2>&1; then
@@ -346,18 +310,12 @@ echo -e "${GREEN}${BOLD}╭─────────────────
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Virtuality установка завершена${RESET} ${GREEN}${BOLD}│${RESET}"
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo
echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:9090"
echo -e "${BOLD}Web Panel:${RESET} http://${SERVER_IP}:${WEB_PORT}"
echo -e "${BOLD}Login:${RESET} ${INSTALL_USER} / пароль Linux-пользователя"
echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
echo -e "${BOLD}Log:${RESET} ${LOG_FILE}"
echo -e "${BOLD}Панель:${RESET} http://${SERVER_IP}:${WEB_PORT}"
echo -e "${BOLD}Логин:${RESET} ${INSTALL_USER} / пароль пользователя Linux"
echo -e "${BOLD}Исходники:${RESET} ${PROJECT_DIR}"
echo -e "${BOLD}Лог:${RESET} ${LOG_FILE}"
echo
echo -e "${BOLD}После установки:${RESET}"
echo " passwd ${INSTALL_USER} # если пароль ещё не задан или заблокирован"
echo " sudo vhealth"
echo " cd ${PROJECT_DIR}"
echo " ip -br a"
echo
echo -e "${YELLOW}Важно:${RESET} bridge br0 по умолчанию не включается в one-command режиме, чтобы не уронить SSH."
echo "Для bridge: cd ${PROJECT_DIR} && sudo bash scripts/setup_bridge_br0.sh INTERFACE static"
echo -e "${BOLD}Дальше:${RESET} откройте панель в браузере — мастер настройки поможет включить HTTPS,"
echo "выбрать сеть для машин (NAT или ваша локальная сеть) и хранилище."
echo "Если пароль ещё не задан: passwd ${INSTALL_USER}"
echo
+51 -36
Просмотреть файл
@@ -3,7 +3,7 @@ set -euo pipefail
# ==========================================================
# Virtuality Node Installer
# Clean step-by-step installer for KVM/QEMU/libvirt/Cockpit
# Clean step-by-step installer for KVM/QEMU/libvirt
# ==========================================================
PROJECT_NAME="Virtuality"
@@ -13,15 +13,17 @@ ISO_DIR="${STORAGE_DIR}/iso"
IMAGES_DIR="${STORAGE_DIR}/images"
BACKUP_DIR="${STORAGE_DIR}/backups"
LOG_DIR="/var/log/virtuality"
COCKPIT_PORT="9090"
LOG_FILE="${LOG_DIR}/install_node_$(date +%Y%m%d_%H%M%S).log"
TOTAL_STEPS=13
TOTAL_STEPS=12
CURRENT_STEP=0
MIN_ROOT_FREE_MB="${VIRTUALITY_MIN_ROOT_FREE_MB:-8192}"
MIN_VAR_FREE_MB="${VIRTUALITY_MIN_VAR_FREE_MB:-20480}"
MIN_RAM_MB="${VIRTUALITY_MIN_RAM_MB:-4096}"
MIN_CPU_CORES="${VIRTUALITY_MIN_CPU_CORES:-2}"
SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-0}"
export DEBIAN_FRONTEND=noninteractive
# Keep existing configuration files without asking: there is nobody to answer on first boot.
APT_OPTS=(-o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold)
ESC="\033"
RESET="${ESC}[0m"
@@ -44,12 +46,11 @@ print_header() {
clear 2>/dev/null || true
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Node Installer${RESET} ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} KVM / QEMU / libvirt / Cockpit virtualization node ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} KVM / QEMU / libvirt virtualization node ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo
echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}"
echo -e "${GRAY}Storage:${RESET} ${STORAGE_DIR}"
echo -e "${GRAY}Cockpit:${RESET} ${COCKPIT_PORT}/tcp"
echo
}
@@ -75,15 +76,18 @@ run_logged() {
spinner=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏")
i=0
while kill -0 "$pid" 2>/dev/null; do
elapsed=$(( $(date +%s) - start_ts ))
symbol="${spinner[$((i % ${#spinner[@]}))]}"
printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$description" "$elapsed" "$LOG_FILE"
# The spinner only makes sense on a terminal; first boot writes this output to a log.
if [[ -t 1 ]]; then
elapsed=$(( $(date +%s) - start_ts ))
symbol="${spinner[$((i % ${#spinner[@]}))]}"
printf "\r ${CYAN}%s${RESET} %s... ${GRAY}%ss${RESET} ${GRAY}(лог: %s)${RESET}" "$symbol" "$description" "$elapsed" "$LOG_FILE"
fi
sleep 1
i=$((i + 1))
done
wait "$pid"
code=$?
printf "\r%*s\r" 120 ""
code=0
wait "$pid" || code=$?
[[ -t 1 ]] && printf "\r%*s\r" 120 ""
if [[ "$code" -eq 0 ]]; then
ok "$description"
else
@@ -91,6 +95,22 @@ run_logged() {
fi
}
# Installs the packages this distribution has; package names differ between
# Ubuntu 24.04/26.04 and Debian 13 (e.g. qemu-kvm became a virtual package).
install_available() {
local description="$1"
shift
local pkg available=()
for pkg in "$@"; do
if apt-cache show "$pkg" >/dev/null 2>&1; then
available+=("$pkg")
else
warn "Пакет ${pkg} недоступен в этом дистрибутиве, пропускаю"
fi
done
run_logged "$description" apt-get install -y "${APT_OPTS[@]}" "${available[@]}"
}
service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
service_enabled() { systemctl is-enabled "$1" 2>/dev/null || echo "disabled"; }
free_mb_for_path() { local path="$1"; mkdir -p "$path" 2>/dev/null || true; df -Pm "$path" | awk 'NR==2 {print $4}'; }
@@ -149,21 +169,27 @@ else
fi
step "Обновляем apt cache"
run_logged "apt update выполнен" apt update
run_logged "apt update выполнен" apt-get update
# A previous attempt may have stopped half way (power loss, a question from dpkg): finish it first.
run_logged "Незавершённые установки пакетов доделаны" dpkg --configure -a --force-confdef --force-confold
step "Устанавливаем базовые утилиты и зависимости диагностики"
run_logged "Базовые пакеты и диагностические утилиты установлены" apt install -y \
install_available "Базовые пакеты и диагностические утилиты установлены" \
curl wget git nano htop btop tree ncdu jq unzip xz-utils tar gzip ca-certificates gnupg lsb-release \
software-properties-common apt-transport-https ufw rsync iproute2 iptables nftables dnsutils net-tools openssh-client
software-properties-common apt-transport-https ufw rsync iproute2 iptables nftables bind9-dnsutils net-tools openssh-client
step "Устанавливаем KVM/QEMU/libvirt"
run_logged "Пакеты виртуализации установлены" apt install -y \
qemu-kvm qemu-utils libvirt-daemon-system libvirt-clients virtinst \
bridge-utils dnsmasq-base ovmf swtpm cloud-image-utils
case "$(uname -m)" in
aarch64|arm64) QEMU_SYSTEM_PACKAGES=(qemu-system-arm qemu-efi-aarch64) ;;
*) QEMU_SYSTEM_PACKAGES=(qemu-system-x86 ovmf) ;;
esac
install_available "Пакеты виртуализации установлены" \
"${QEMU_SYSTEM_PACKAGES[@]}" qemu-utils libvirt-daemon-system libvirt-clients virtinst \
bridge-utils dnsmasq-base swtpm cloud-image-utils xorriso
step "Устанавливаем Cockpit и модули"
run_logged "Cockpit установлен" apt install -y \
cockpit cockpit-machines cockpit-networkmanager cockpit-storaged cockpit-packagekit
step "Устанавливаем зависимости панели управления"
install_available "Пакеты панели установлены" \
python3 python3-venv rsync openssl nftables novnc python3-websockify
step "Создаём структуру Virtuality"
run_logged "Директории Virtuality созданы" mkdir -p "$PROJECT_DIR" "$STORAGE_DIR" "$ISO_DIR" "$IMAGES_DIR" "$BACKUP_DIR" "$LOG_DIR"
@@ -174,28 +200,26 @@ ok "Images: ${IMAGES_DIR}"
ok "Backups: ${BACKUP_DIR}"
step "Добавляем пользователя в группы libvirt/kvm"
REAL_USER="${SUDO_USER:-root}"
REAL_USER="${VIRTUALITY_USER:-${SUDO_USER:-root}}"
if [[ "$REAL_USER" != "root" ]]; then
usermod -aG libvirt "$REAL_USER" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить ${REAL_USER} в libvirt"
usermod -aG kvm "$REAL_USER" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить ${REAL_USER} в kvm"
ok "Пользователь ${REAL_USER} добавлен в группы libvirt/kvm"
warn "Чтобы группы применились, нужно выйти из SSH и зайти снова"
else
warn "Запуск под root без SUDO_USER; пользователь не добавлен в группы"
warn "Запуск под root без SUDO_USER/VIRTUALITY_USER; пользователь не добавлен в группы"
fi
step "Включаем systemd-сервисы"
run_logged "libvirtd включён и запущен" systemctl enable --now libvirtd
run_logged "virtlogd включён и запущен" systemctl enable --now virtlogd
run_logged "cockpit.socket включён и запущен" systemctl enable --now cockpit.socket
ok "libvirtd: $(service_state libvirtd) / $(service_enabled libvirtd)"
ok "virtlogd: $(service_state virtlogd) / $(service_enabled virtlogd)"
ok "cockpit.socket: $(service_state cockpit.socket) / $(service_enabled cockpit.socket)"
step "Настраиваем firewall"
run_logged "Разрешён OpenSSH" ufw allow OpenSSH
run_logged "Разрешён Cockpit ${COCKPIT_PORT}/tcp" ufw allow "${COCKPIT_PORT}/tcp"
run_logged "Разрешены VNC-порты 5900:5999/tcp" ufw allow 5900:5999/tcp
# Consoles are proxied by the panel; VNC ports stay closed from outside.
ufw delete allow 5900:5999/tcp >> "$LOG_FILE" 2>&1 || true
if ufw status | grep -qi inactive; then
echo "y" | ufw enable >> "$LOG_FILE" 2>&1 && ok "UFW включён" || warn "Не удалось включить UFW"
else
@@ -222,7 +246,6 @@ ISO_DIR="${ISO_DIR}"
IMAGES_DIR="${IMAGES_DIR}"
BACKUP_DIR="${BACKUP_DIR}"
LOG_DIR="${LOG_DIR}"
COCKPIT_PORT="${COCKPIT_PORT}"
EOF
ok "Создан ${PROJECT_DIR}/virtuality.env"
@@ -233,11 +256,6 @@ if command -v virsh >/dev/null 2>&1; then
else
warn "virsh не найден после установки"
fi
if ss -tulpn 2>/dev/null | grep -q ':9090'; then
ok "Cockpit слушает порт 9090"
else
warn "Порт 9090 не виден в ss; Cockpit может быть socket-activated"
fi
if [[ -e /dev/kvm ]]; then
ok "/dev/kvm доступен"
else
@@ -251,7 +269,6 @@ echo -e "${GREEN}${BOLD}╭─────────────────
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Node завершена${RESET} ${GREEN}${BOLD}│${RESET}"
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo
echo -e "${BOLD}Cockpit:${RESET} https://${SERVER_IP}:${COCKPIT_PORT}"
echo -e "${BOLD}Project:${RESET} ${PROJECT_DIR}"
echo -e "${BOLD}ISO:${RESET} ${ISO_DIR}"
echo -e "${BOLD}Images:${RESET} ${IMAGES_DIR}"
@@ -262,9 +279,7 @@ echo -e "${BOLD}Проверка:${RESET}"
echo " virsh list --all"
echo " virsh pool-list --all"
echo " systemctl status libvirtd --no-pager"
echo " systemctl status cockpit.socket --no-pager"
echo
line
echo -e "${DIM}Следующий шаг: sudo bash scripts/setup_bridge_br0.sh enp2s0 static${RESET}"
echo -e "${DIM}После bridge: sudo bash scripts/create_test_vm.sh${RESET}"
echo -e "${DIM}Дальше: sudo bash scripts/install_web_panel.sh — панель управления и мастер настройки${RESET}"
line
+3
Просмотреть файл
@@ -0,0 +1,3 @@
[pytest]
testpaths = tests
addopts = -p no:cacheprovider
+4
Просмотреть файл
@@ -0,0 +1,4 @@
# Development and CI tools
pytest==8.3.4
httpx==0.28.1
pyflakes==3.2.0
+19 -14
Просмотреть файл
@@ -7,10 +7,13 @@ STATE_FILE="${STATE_DIR}/state.json"
LOG_FILE="/var/log/virtuality/update.log"
REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}"
BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}"
REPO_ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip}"
TMP_DIR="${STATE_DIR}/tmp"
# shellcheck source=scripts/update_target.sh
. "$(dirname "${BASH_SOURCE[0]}")/update_target.sh"
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}"
now() { date '+%Y-%m-%d %H:%M:%S'; }
json_escape() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read())[1:-1])'; }
@@ -48,14 +51,14 @@ try_run() {
fetch_zip_update() {
local zip_file extract_dir extracted_root
zip_file="${TMP_DIR}/virtuality-${BRANCH}.zip"
zip_file="${TMP_DIR}/virtuality-${TARGET_LABEL}.zip"
extract_dir="${TMP_DIR}/zip-update"
rm -rf "$zip_file" "$extract_dir"
mkdir -p "$extract_dir"
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
@@ -108,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"
@@ -126,19 +130,20 @@ log "before version: $BEFORE_VERSION"
GIT_UPDATED=0
if [ -d "$SOURCE_DIR/.git" ]; then
cd "$SOURCE_DIR" || exit 1
write_state "running" "Получаем изменения из GitHub через git"
if try_run git fetch "$REMOTE" "$BRANCH"; then
TARGET_REF="${REMOTE}/${BRANCH}"
TARGET_COMMIT="$(git rev-parse "$TARGET_REF" 2>/dev/null || true)"
write_state "running" "Получаем изменения через git"
if try_run git fetch --tags --force "$REMOTE" "$BRANCH"; then
resolve_update_target
log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}"
TARGET_COMMIT="$(git rev-parse "${TARGET_REF}^{commit}" 2>/dev/null || true)"
if [ -n "$TARGET_COMMIT" ]; then
LOCAL_STATUS="$(git status --porcelain 2>/dev/null || true)"
if [ -n "$LOCAL_STATUS" ]; then
log "Local changes before update:"
printf '%s\n' "$LOCAL_STATUS" >> "$LOG_FILE"
fi
write_state "running" "Синхронизируем исходники с GitHub через git reset"
log "Deploy mode: git reset --hard $TARGET_REF"
if try_run git reset --hard "$TARGET_REF" && try_run git clean -fd; then
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
else
log "git reset/clean failed, ZIP fallback will be used"
@@ -155,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
@@ -176,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
+22 -7
Просмотреть файл
@@ -9,7 +9,8 @@ LOG_FILE="/var/log/virtuality/update.log"
REMOTE="${VIRTUALITY_UPDATE_REMOTE:-origin}"
BRANCH="${VIRTUALITY_UPDATE_BRANCH:-main}"
APPLY_SCRIPT="${SOURCE_DIR}/scripts/apply_github_update.sh"
ZIP_URL="${VIRTUALITY_UPDATE_ZIP_URL:-https://github.com/viktor138irk/virtuality/archive/refs/heads/${BRANCH}.zip}"
# shellcheck source=scripts/update_target.sh
. "${SOURCE_DIR}/scripts/update_target.sh"
mkdir -p "$STATE_DIR" "$(dirname "$LOG_FILE")"
@@ -34,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
@@ -46,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
@@ -88,14 +93,24 @@ diag_network() {
current="unknown"
fi
moved="$(migrate_update_remote)"
[ -n "$moved" ] && log "$moved"
if [ -d "$SOURCE_DIR/.git" ]; then
if git fetch --quiet "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then
latest="$(git rev-parse "${REMOTE}/${BRANCH}" 2>/dev/null || true)"
if git fetch --quiet --tags --force "$REMOTE" "$BRANCH" >> "$LOG_FILE" 2>&1; then
resolve_update_target
log "channel: ${UPDATE_CHANNEL}, target: ${TARGET_LABEL}"
latest="$(git rev-parse "${TARGET_REF}^{commit}" 2>/dev/null || true)"
if [ -n "$latest" ] && [ "$current" = "$latest" ]; then
log "no updates: ${current:0:12} / version $current_version"
write_state "idle" "Автообновление: новых обновлений нет"
exit 0
fi
if [ -n "$latest" ] && [ "$current" != "unknown" ] && ! git merge-base --is-ancestor "$current" "$latest" 2>/dev/null; then
log "local build ${current:0:12} is not behind ${TARGET_LABEL} (${latest:0:12}); skip to avoid a downgrade"
write_state "idle" "Автообновление: установленная версия новее ${TARGET_LABEL}, обновление пропущено"
exit 0
fi
log "update may be available or current commit unknown: ${current:0:12} -> ${latest:0:12}"
else
log "git fetch failed; diagnostics and apply ZIP fallback will be used"
@@ -112,6 +127,6 @@ diag_network() {
exit 0
fi
write_state "running" "Автообновление: проверяем и устанавливаем обновление через устойчивый механизм"
VIRTUALITY_SOURCE_DIR="$SOURCE_DIR" VIRTUALITY_UPDATE_REMOTE="$REMOTE" VIRTUALITY_UPDATE_BRANCH="$BRANCH" VIRTUALITY_UPDATE_ZIP_URL="$ZIP_URL" bash "$APPLY_SCRIPT"
write_state "running" "Автообновление: устанавливаем обновление"
VIRTUALITY_SOURCE_DIR="$SOURCE_DIR" VIRTUALITY_UPDATE_REMOTE="$REMOTE" VIRTUALITY_UPDATE_BRANCH="$BRANCH" bash "$APPLY_SCRIPT"
) 9>"$LOCK_FILE"
-90
Просмотреть файл
@@ -1,90 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "=================================================="
echo " Virtuality Node Diagnostics"
echo "=================================================="
echo
echo "[System]"
if [[ -f /etc/os-release ]]; then
. /etc/os-release
echo "OS: ${PRETTY_NAME}"
fi
uname -a
echo
echo "[CPU virtualization]"
if grep -E -q '(vmx|svm)' /proc/cpuinfo; then
echo "OK: hardware virtualization flag found"
else
echo "WARNING: vmx/svm flag not found"
fi
echo
echo "[Services]"
for service in libvirtd virtlogd cockpit.socket; do
if systemctl list-unit-files | grep -q "^${service}"; then
echo -n "${service}: "
systemctl is-active "$service" || true
else
echo "${service}: not installed"
fi
done
echo
echo "[Packages]"
for bin in virsh qemu-system-x86_64 virt-install cockpit-bridge; do
if command -v "$bin" >/dev/null 2>&1; then
echo "OK: $bin -> $(command -v "$bin")"
else
echo "MISSING: $bin"
fi
done
echo
echo "[libvirt VMs]"
if command -v virsh >/dev/null 2>&1; then
virsh list --all || true
else
echo "virsh not found"
fi
echo
echo "[libvirt storage pools]"
if command -v virsh >/dev/null 2>&1; then
virsh pool-list --all || true
else
echo "virsh not found"
fi
echo
echo "[Network interfaces]"
ip -br a || true
echo
echo "[Routes]"
ip route || true
echo
echo "[Virtuality paths]"
for dir in /opt/virtuality /var/lib/virtuality /var/lib/virtuality/iso /var/lib/virtuality/images /var/lib/virtuality/backups /var/log/virtuality; do
if [[ -d "$dir" ]]; then
echo "OK: $dir"
else
echo "MISSING: $dir"
fi
done
echo
echo "[Firewall]"
if command -v ufw >/dev/null 2>&1; then
ufw status || true
else
echo "ufw not installed"
fi
echo
echo "=================================================="
echo " Diagnostics completed"
echo "=================================================="
-111
Просмотреть файл
@@ -1,111 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Virtuality clean install helper.
# Default mode preserves user data: VM disks, ISO files, uploaded disk images and backups.
# Use --purge-data only when you intentionally want to remove Virtuality data directories too.
PURGE_DATA=0
YES=0
for arg in "$@"; do
case "$arg" in
--purge-data) PURGE_DATA=1 ;;
--yes|-y) YES=1 ;;
*) echo "Unknown argument: $arg" >&2; exit 2 ;;
esac
done
if [[ "$EUID" -ne 0 ]]; then
exec sudo bash "$0" "$@"
fi
RED="\033[31m"
GREEN="\033[32m"
YELLOW="\033[33m"
BOLD="\033[1m"
RESET="\033[0m"
say() { echo -e "${GREEN}✓${RESET} $*"; }
warn() { echo -e "${YELLOW}!${RESET} $*"; }
remove_path() {
local path="$1"
if [[ -e "$path" || -L "$path" ]]; then
rm -rf "$path"
say "removed: $path"
else
warn "not found: $path"
fi
}
cat <<EOF
${BOLD}Virtuality clean install${RESET}
Will remove:
- /opt/virtuality/web
- /opt/virtuality/venv
- /etc/systemd/system/virtuality-web.service
- /var/lib/virtuality/config
- /var/lib/virtuality/update
- /var/log/virtuality/update.log and install logs
Will preserve by default:
- /opt/virtuality/source
- /var/lib/virtuality/iso
- /var/lib/virtuality/images
- /var/lib/virtuality/disk-images
- /var/lib/virtuality/backups
- existing libvirt VMs and storage pools
EOF
if [[ "$PURGE_DATA" -eq 1 ]]; then
cat <<EOF
${RED}${BOLD}PURGE DATA MODE ENABLED${RESET}
Will also remove:
- /var/lib/virtuality/iso
- /var/lib/virtuality/images
- /var/lib/virtuality/disk-images
- /var/lib/virtuality/backups
- /var/lib/virtuality/network
Existing libvirt VM definitions are not deleted automatically.
EOF
fi
if [[ "$YES" -ne 1 ]]; then
echo
read -r -p "Continue? Type YES: " answer
if [[ "$answer" != "YES" ]]; then
echo "Cancelled."
exit 0
fi
fi
systemctl stop virtuality-web.service 2>/dev/null || true
systemctl disable virtuality-web.service 2>/dev/null || true
remove_path /etc/systemd/system/virtuality-web.service
systemctl daemon-reload || true
remove_path /opt/virtuality/web
remove_path /opt/virtuality/venv
remove_path /var/lib/virtuality/config
remove_path /var/lib/virtuality/update
remove_path /var/log/virtuality/update.log
find /var/log/virtuality -maxdepth 1 -type f -name 'install_web_panel_*.log' -delete 2>/dev/null || true
say "old installer logs removed"
if [[ "$PURGE_DATA" -eq 1 ]]; then
remove_path /var/lib/virtuality/iso
remove_path /var/lib/virtuality/images
remove_path /var/lib/virtuality/disk-images
remove_path /var/lib/virtuality/backups
remove_path /var/lib/virtuality/network
fi
mkdir -p /opt/virtuality /var/log/virtuality /var/lib/virtuality
say "base directories recreated"
echo
say "Clean install cleanup finished. Run: sudo bash scripts/install_web_panel.sh"
-71
Просмотреть файл
@@ -1,71 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
VM_NAME="${1:-test-alpine}"
ISO_URL="${VIRTUALITY_TEST_ISO_URL:-https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-standard-3.20.3-x86_64.iso}"
ISO_PATH="/var/lib/virtuality/iso/alpine-standard.iso"
DISK_PATH="/var/lib/virtuality/images/${VM_NAME}.qcow2"
BRIDGE="${VIRTUALITY_BRIDGE:-br0}"
MEMORY="${VIRTUALITY_TEST_VM_MEMORY:-1024}"
VCPUS="${VIRTUALITY_TEST_VM_VCPUS:-1}"
DISK_SIZE="${VIRTUALITY_TEST_VM_DISK_SIZE:-8}"
if [[ "$EUID" -ne 0 ]]; then
echo "Ошибка: запусти через sudo: sudo bash scripts/create_test_vm.sh"
exit 1
fi
if ! command -v virt-install >/dev/null 2>&1; then
echo "Ошибка: virt-install не найден. Запусти install_virtuality_node.sh"
exit 1
fi
if ! command -v virsh >/dev/null 2>&1; then
echo "Ошибка: virsh не найден. Запусти install_virtuality_node.sh"
exit 1
fi
if ! ip link show "$BRIDGE" >/dev/null 2>&1; then
echo "Ошибка: bridge $BRIDGE не найден. Сначала настрой br0."
ip -br a
exit 1
fi
mkdir -p /var/lib/virtuality/iso /var/lib/virtuality/images
if virsh dominfo "$VM_NAME" >/dev/null 2>&1; then
echo "Виртуальная машина $VM_NAME уже существует."
virsh list --all
exit 0
fi
if [[ ! -f "$ISO_PATH" ]]; then
echo "Скачиваю тестовый ISO: $ISO_URL"
wget -O "$ISO_PATH" "$ISO_URL"
fi
virsh pool-refresh virtuality-iso >/dev/null 2>&1 || true
virsh pool-refresh virtuality-images >/dev/null 2>&1 || true
echo "Создаю тестовую VM: $VM_NAME"
virt-install \
--name "$VM_NAME" \
--memory "$MEMORY" \
--vcpus "$VCPUS" \
--disk "path=${DISK_PATH},size=${DISK_SIZE},format=qcow2,bus=virtio" \
--cdrom "$ISO_PATH" \
--os-variant generic \
--network "bridge=${BRIDGE},model=virtio" \
--graphics vnc,listen=0.0.0.0 \
--noautoconsole
echo
echo "VM создана."
virsh list --all
echo
echo "VNC display:"
virsh vncdisplay "$VM_NAME" || true
echo
echo "Открыть в Cockpit: https://SERVER_IP:9090 → Virtual machines → ${VM_NAME}"
-104
Просмотреть файл
@@ -1,104 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Virtuality Cockpit auth helper
# Checks Linux user password state, Cockpit disallowed-users and required groups.
USER_NAME="${1:-${VIRTUALITY_USER:-${SUDO_USER:-root}}}"
DISALLOWED_FILE="/etc/cockpit/disallowed-users"
ESC="\033"
RESET="${ESC}[0m"
BOLD="${ESC}[1m"
GREEN="${ESC}[32m"
YELLOW="${ESC}[33m"
RED="${ESC}[31m"
CYAN="${ESC}[36m"
GRAY="${ESC}[90m"
ok() { echo -e "${GREEN}✓${RESET} $*"; }
warn() { echo -e "${YELLOW}!${RESET} $*"; }
fail() { echo -e "${RED}✗${RESET} $*"; exit 1; }
if [[ "$EUID" -ne 0 ]]; then
fail "Запусти от root: sudo bash scripts/fix_cockpit_auth.sh [user]"
fi
clear 2>/dev/null || true
cat <<EOF
${CYAN}${BOLD}Virtuality Cockpit auth check${RESET}
${GRAY}User:${RESET} ${USER_NAME}
EOF
echo
if ! id "$USER_NAME" >/dev/null 2>&1; then
fail "Пользователь ${USER_NAME} не найден"
fi
ok "Пользователь найден: ${USER_NAME}"
HASH="$(getent shadow "$USER_NAME" | cut -d: -f2 || true)"
if [[ -z "$HASH" || "$HASH" == "!" || "$HASH" == "*" || "$HASH" == "!!" ]]; then
warn "У пользователя ${USER_NAME} пароль не задан или заблокирован"
echo
echo -e "${BOLD}Для входа в Cockpit задай пароль:${RESET}"
if [[ "$USER_NAME" == "root" ]]; then
echo " passwd root"
else
echo " sudo passwd ${USER_NAME}"
fi
else
ok "У пользователя ${USER_NAME} есть пароль"
fi
if [[ -f "$DISALLOWED_FILE" ]]; then
if grep -qx "$USER_NAME" "$DISALLOWED_FILE"; then
cp "$DISALLOWED_FILE" "${DISALLOWED_FILE}.bak.$(date +%Y%m%d_%H%M%S)"
sed -i "/^${USER_NAME}$/d" "$DISALLOWED_FILE"
ok "Пользователь ${USER_NAME} удалён из ${DISALLOWED_FILE}"
else
ok "Пользователь ${USER_NAME} не запрещён в ${DISALLOWED_FILE}"
fi
else
warn "Файл ${DISALLOWED_FILE} не найден, пропускаю проверку запрета пользователей"
fi
if [[ "$USER_NAME" != "root" ]]; then
usermod -aG sudo "$USER_NAME" || true
usermod -aG libvirt "$USER_NAME" || true
usermod -aG kvm "$USER_NAME" || true
ok "Пользователь ${USER_NAME} добавлен в группы sudo/libvirt/kvm"
warn "Для применения групп нужно выйти из SSH и войти снова"
else
ok "root не требует добавления в группы"
fi
if command -v apt >/dev/null 2>&1; then
apt install -y cockpit cockpit-machines sscg >/dev/null 2>&1 || true
ok "Пакеты cockpit/cockpit-machines/sscg проверены"
fi
systemctl enable --now cockpit.socket >/dev/null 2>&1 || fail "Не удалось запустить cockpit.socket"
ok "cockpit.socket включён и запущен"
if command -v ufw >/dev/null 2>&1; then
ufw allow 9090/tcp >/dev/null 2>&1 || true
ok "UFW правило 9090/tcp добавлено/проверено"
fi
systemctl restart cockpit.socket >/dev/null 2>&1 || true
systemctl restart cockpit.service >/dev/null 2>&1 || true
ok "Cockpit перезапущен"
SERVER_IP="$(hostname -I | awk '{print $1}')"
PUBLIC_IP="$(ip route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") {print $(i+1); exit}}')"
echo
echo -e "${GREEN}${BOLD}Готово.${RESET}"
echo -e "${BOLD}Cockpit local/private:${RESET} https://${SERVER_IP}:9090"
if [[ -n "$PUBLIC_IP" && "$PUBLIC_IP" != "$SERVER_IP" ]]; then
echo -e "${BOLD}Cockpit route IP:${RESET} https://${PUBLIC_IP}:9090"
fi
echo -e "${BOLD}Login:${RESET} ${USER_NAME}"
echo -e "${BOLD}Password:${RESET} пароль Linux-пользователя ${USER_NAME}"
echo
-81
Просмотреть файл
@@ -1,81 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DASH_SRC="${REPO_DIR}/scripts/virtuality_console_dashboard.sh"
DASH_DST="/usr/local/bin/virtuality-console-dashboard"
SERVICE_FILE="/etc/systemd/system/virtuality-console-dashboard.service"
TARGET_USER="${SUDO_USER:-${USER:-root}}"
if [[ "$EUID" -ne 0 ]]; then
echo "Ошибка: запусти через sudo: sudo bash scripts/install_console_dashboard.sh"
exit 1
fi
if [[ ! -f "$DASH_SRC" ]]; then
echo "Ошибка: не найден файл $DASH_SRC"
exit 1
fi
apt update
apt install -y btop procps iproute2 util-linux coreutils
install -m 0755 "$DASH_SRC" "$DASH_DST"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=Virtuality console dashboard on tty1
After=multi-user.target network-online.target libvirtd.service cockpit.socket
Wants=network-online.target
Conflicts=getty@tty1.service
[Service]
Type=simple
ExecStart=/usr/local/bin/virtuality-console-dashboard
Restart=always
RestartSec=2
StandardInput=tty
StandardOutput=tty
TTYPath=/dev/tty1
TTYReset=yes
TTYVHangup=yes
TTYVTDisallocate=yes
Environment=TERM=linux
Environment=VIRTUALITY_DASHBOARD_INTERVAL=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl disable --now getty@tty1.service >/dev/null 2>&1 || true
systemctl enable --now virtuality-console-dashboard.service
USER_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6 || true)"
if [[ -n "$USER_HOME" && -d "$USER_HOME" ]]; then
BASHRC="$USER_HOME/.bashrc"
touch "$BASHRC"
grep -q "alias bt=" "$BASHRC" || cat >> "$BASHRC" <<'EOF'
# Virtuality quick aliases
alias bt='btop'
alias dash='sudo systemctl restart virtuality-console-dashboard && sudo chvt 1'
alias dash-stop='sudo systemctl stop virtuality-console-dashboard'
alias dash-start='sudo systemctl start virtuality-console-dashboard'
alias dash-status='systemctl status virtuality-console-dashboard --no-pager'
alias vcheck='cd ~/virtuality && sudo bash scripts/check_node.sh'
EOF
chown "$TARGET_USER:$TARGET_USER" "$BASHRC" || true
fi
echo "============================================================"
echo "Virtuality console dashboard installed"
echo "============================================================"
echo "Физический монитор: tty1"
echo "Перейти на дашборд: sudo chvt 1"
echo "Команда btop: bt"
echo "Перезапустить дашборд: dash"
echo "Остановить дашборд: dash-stop"
echo "Запустить дашборд: dash-start"
echo "Статус: dash-status"
echo "============================================================"
-11
Просмотреть файл
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
DISK_IMAGES_DIR="/var/lib/virtuality/disk-images"
mkdir -p "$DISK_IMAGES_DIR"
chmod 755 "$DISK_IMAGES_DIR"
systemctl restart virtuality-web.service
echo "Disk image support installed. Open: /disk-images"
+1 -1
Просмотреть файл
@@ -4,7 +4,7 @@ set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="${REPO_DIR}/scripts/virtuality_healthcheck.sh"
DST="/usr/local/bin/vhealth"
TARGET_USER="${SUDO_USER:-${USER:-root}}"
TARGET_USER="${VIRTUALITY_USER:-${SUDO_USER:-${USER:-root}}}"
if [[ "$EUID" -ne 0 ]]; then
echo "Ошибка: запусти через sudo: sudo bash scripts/install_healthcheck_command.sh"
+151 -272
Просмотреть файл
@@ -1,32 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
# Virtuality Web Panel Installer
# Auto-detects x86_64 / Raspberry Pi ARM64 / Orange Pi 5 ARM64 / generic ARM64 profile.
# ==========================================================
# Virtuality web panel installer
# Copies web/ to /opt/virtuality/web (keeping the previous
# version for rollback), builds the virtualenv (offline from
# bundled wheels when available), installs virtuality-ctl and
# lets it create the systemd units, TLS and firewall rules.
#
# VIRTUALITY_PANEL_ONLY=1 skip apt entirely (first boot from the image,
# before the network is up)
# VIRTUALITY_AUTH_USER Linux user that logs in to the panel
# VIRTUALITY_WEB_PORT HTTP port (default 8088)
# ==========================================================
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WEB_DIR="${REPO_DIR}/web"
APP_DIR="/opt/virtuality/web"
VENV_DIR="/opt/virtuality/venv"
SERVICE_FILE="/etc/systemd/system/virtuality-web.service"
AUTO_UPDATE_SERVICE_FILE="/etc/systemd/system/virtuality-auto-update.service"
AUTO_UPDATE_TIMER_FILE="/etc/systemd/system/virtuality-auto-update.timer"
BACKUP_SERVICE_FILE="/etc/systemd/system/virtuality-backup.service"
BACKUP_TIMER_FILE="/etc/systemd/system/virtuality-backup.timer"
PORT="${VIRTUALITY_WEB_PORT:-8088}"
AUTH_USER="${VIRTUALITY_AUTH_USER:-${SUDO_USER:-viktor}}"
CTL_SRC="${REPO_DIR}/scripts/virtuality-ctl"
CTL_DST="/usr/local/bin/virtuality-ctl"
LOG_DIR="/var/log/virtuality"
LOG_FILE="${LOG_DIR}/install_web_panel_$(date +%Y%m%d_%H%M%S).log"
PROFILE_DIR="/var/lib/virtuality/config"
PROFILE_FILE="${PROFILE_DIR}/host_profile.json"
SESSION_SECRET_FILE="${PROFILE_DIR}/session_secret"
TLS_DIR="${PROFILE_DIR}/tls"
TLS_CERT="${TLS_DIR}/virtuality.crt"
TLS_KEY="${TLS_DIR}/virtuality.key"
TLS_ENABLED="${VIRTUALITY_TLS:-1}"
CONFIG_DIR="/var/lib/virtuality/config"
PROFILE_FILE="${CONFIG_DIR}/host_profile.json"
SESSION_SECRET_FILE="${CONFIG_DIR}/session_secret"
NODE_CONFIG_FILE="${CONFIG_DIR}/web.env"
SETUP_STATE_FILE="${CONFIG_DIR}/setup.json"
UPLOAD_TMP_DIR="/var/lib/virtuality/tmp"
TOTAL_STEPS=13
WHEELS_DIR="${VIRTUALITY_WHEELS_DIR:-${REPO_DIR}/wheels}"
PANEL_ONLY="${VIRTUALITY_PANEL_ONLY:-0}"
export DEBIAN_FRONTEND=noninteractive
# Settings survive updates: explicit env > saved node config > defaults.
saved_setting() {
local key="$1"
[[ -f "$NODE_CONFIG_FILE" ]] || return 0
sed -n "s/^${key}=//p" "$NODE_CONFIG_FILE" | tail -n1 | tr -d "\"'"
}
first_human_user() {
getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $7 !~ /(nologin|false)$/ {print $1; exit}'
}
env_set() {
local key="$1" value="$2"
if grep -q "^${key}=" "$NODE_CONFIG_FILE" 2>/dev/null; then
sed -i "s|^${key}=.*|${key}=${value}|" "$NODE_CONFIG_FILE"
else
printf '%s=%s\n' "$key" "$value" >> "$NODE_CONFIG_FILE"
fi
}
PORT="${VIRTUALITY_WEB_PORT:-$(saved_setting VIRTUALITY_WEB_PORT)}"
PORT="${PORT:-8088}"
AUTH_USER="${VIRTUALITY_AUTH_USER:-$(saved_setting VIRTUALITY_AUTH_USER)}"
AUTH_USER="${AUTH_USER:-${SUDO_USER:-}}"
AUTH_USER="${AUTH_USER:-$(first_human_user)}"
AUTH_USER="${AUTH_USER:-root}"
AUTO_UPDATE="${VIRTUALITY_AUTO_UPDATE:-$(saved_setting VIRTUALITY_AUTO_UPDATE)}"
AUTO_UPDATE="${AUTO_UPDATE:-1}"
CURRENT_STEP=0
TOTAL_STEPS=9
ESC="\033"
RESET="${ESC}[0m"
@@ -39,36 +72,22 @@ CYAN="${ESC}[36m"
BLUE="${ESC}[34m"
GRAY="${ESC}[90m"
mkdir -p "$LOG_DIR" "$PROFILE_DIR" "$UPLOAD_TMP_DIR"
mkdir -p "$LOG_DIR" "$CONFIG_DIR" "$UPLOAD_TMP_DIR"
chmod 1777 "$UPLOAD_TMP_DIR"
timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
line() { printf '%*s\n' 72 '' | tr ' ' '─'; }
log() { echo "[$(timestamp)] $*" >> "$LOG_FILE"; }
print_header() {
clear 2>/dev/null || true
echo -e "${CYAN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} ${BOLD}Virtuality Web Panel Installer${RESET} ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}│${RESET} Auto profile: x86 / VPS / Raspberry / Orange Pi 5 ${CYAN}${BOLD}│${RESET}"
echo -e "${CYAN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo
echo -e "${GRAY}Лог установки:${RESET} ${LOG_FILE}"
echo -e "${GRAY}Порт панели:${RESET} ${PORT}"
echo -e "${GRAY}Linux auth user:${RESET} ${AUTH_USER}"
echo
}
step() {
CURRENT_STEP=$((CURRENT_STEP + 1))
echo
echo -e "${BLUE}${BOLD}[${CURRENT_STEP}/${TOTAL_STEPS}]${RESET} ${BOLD}$*${RESET}"
log "STEP ${CURRENT_STEP}/${TOTAL_STEPS}: $*"
}
ok() { echo -e " ${GREEN}✓${RESET} $*"; log "OK: $*"; }
warn() { echo -e " ${YELLOW}!${RESET} $*"; log "WARN: $*"; }
fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Установка остановлена.${RESET} Подробности в логе: ${LOG_FILE}"; exit 1; }
fail() { echo -e " ${RED}✗${RESET} $*"; log "ERROR: $*"; echo; echo -e "${RED}${BOLD}Установка остановлена.${RESET} Подробности в логе: ${LOG_FILE}"; tail -n 20 "$LOG_FILE" 2>/dev/null || true; exit 1; }
run_logged() {
local description="$1"
@@ -81,289 +100,149 @@ run_logged() {
fi
}
service_state() { systemctl is-active "$1" 2>/dev/null || echo "inactive"; }
require_root() { [[ "$EUID" -eq 0 ]] || fail "Запусти через sudo: sudo bash scripts/install_web_panel.sh"; }
json_value() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$PROFILE_FILE" "$1" 2>/dev/null || true; }
print_header
echo -e "${CYAN}${BOLD}Virtuality — установка панели управления${RESET}"
echo -e "${GRAY}Лог: ${LOG_FILE}${RESET}"
step "Проверяем права и структуру проекта"
require_root
ok "Права root подтверждены"
[[ -d "$WEB_DIR" ]] || fail "Не найдена директория web: $WEB_DIR"
ok "Исходники web найдены: $WEB_DIR"
[[ -f "$WEB_DIR/app.py" ]] || fail "Не найден web/app.py"
ok "FastAPI entrypoint найден: web/app.py"
[[ -f "$WEB_DIR/requirements.txt" ]] || fail "Не найден web/requirements.txt"
ok "requirements.txt найден"
step "Проверяем права и исходники"
[[ "$EUID" -eq 0 ]] || fail "Запустите через sudo: sudo bash scripts/install_web_panel.sh"
[[ -f "$WEB_DIR/app.py" && -f "$WEB_DIR/requirements.txt" ]] || fail "Не найдены исходники панели в ${WEB_DIR}"
[[ -f "$CTL_SRC" ]] || fail "Не найден ${CTL_SRC}"
for cmd in systemctl rsync python3 hostname openssl; do
command -v "$cmd" >/dev/null 2>&1 || fail "Не найдена команда ${cmd}"
done
ok "Исходники: ${WEB_DIR}"
if id "$AUTH_USER" >/dev/null 2>&1; then
ok "Пользователь для входа: ${AUTH_USER}"
else
fail "Пользователь ${AUTH_USER} не найден. Создайте его или передайте VIRTUALITY_AUTH_USER=имя"
fi
if getent shadow "$AUTH_USER" | cut -d: -f2 | grep -Eq '^(!|\*|!!)?$'; then
warn "У пользователя ${AUTH_USER} нет пароля — задайте его: sudo passwd ${AUTH_USER}"
fi
step "Автоматически определяем профиль сборки"
step "Определяем профиль сервера"
if [[ -x "${REPO_DIR}/scripts/detect_host_profile.sh" ]]; then
PROFILE="$(${REPO_DIR}/scripts/detect_host_profile.sh 2>>"$LOG_FILE" | tail -n1)"
PROFILE="$("${REPO_DIR}/scripts/detect_host_profile.sh" 2>>"$LOG_FILE" | tail -n1 || true)"
else
bash "${REPO_DIR}/scripts/detect_host_profile.sh" >>"$LOG_FILE" 2>&1 || true
PROFILE="$(json_value profile)"
fi
[[ -n "$PROFILE" ]] || PROFILE="x86_64"
ARCH="$(json_value arch)"
MODEL="$(json_value model)"
LABEL="$(json_value label)"
PACKAGE_PROFILE="$(json_value package_profile)"
ok "Профиль: ${LABEL:-$PROFILE}"
ok "Архитектура: ${ARCH:-unknown}"
ok "Модель: ${MODEL:-unknown}"
ok "Конфиг профиля: $PROFILE_FILE"
ok "Профиль: $(json_value label)${PROFILE:+ (${PROFILE})}"
step "Проверяем Linux-пользователя для входа"
if id "$AUTH_USER" >/dev/null 2>&1; then
ok "Пользователь найден: $AUTH_USER"
step "Системные пакеты"
if [[ "$PANEL_ONLY" == "1" ]]; then
ok "Пропущено (VIRTUALITY_PANEL_ONLY=1): пакеты поставит установщик ноды"
elif command -v apt-get >/dev/null 2>&1; then
run_logged "apt update выполнен" apt-get update
run_logged "Пакеты панели установлены" apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold python3 python3-venv rsync openssl curl nftables novnc python3-websockify qemu-utils virtinst libvirt-clients cloud-image-utils
else
fail "Пользователь $AUTH_USER не найден. Создай его или передай VIRTUALITY_AUTH_USER=username"
fi
if getent shadow "$AUTH_USER" | cut -d: -f2 | grep -Eq '^(!|\*|!!)?$'; then
warn "У пользователя $AUTH_USER может быть заблокирован пароль. Установи пароль: sudo passwd $AUTH_USER"
else
ok "У пользователя $AUTH_USER есть пароль для Linux-auth"
warn "apt не найден — пакеты не устанавливались"
fi
step "Проверяем базовые команды"
for cmd in apt systemctl rsync python3 hostname openssl; do
if command -v "$cmd" >/dev/null 2>&1; then
ok "$cmd найден: $(command -v "$cmd")"
else
fail "$cmd не найден"
fi
done
step "Копируем панель в ${APP_DIR}"
run_logged "Новая версия скопирована" rsync -a --delete --exclude='__pycache__/' --exclude='.env' "$WEB_DIR/" "${APP_DIR}.new/"
install -m 0644 "${REPO_DIR}/VERSION" "${APP_DIR}.new/VERSION"
FRESH_INSTALL=1
if [[ -d "$APP_DIR" ]]; then
FRESH_INSTALL=0
rm -rf "${APP_DIR}.prev"
mv "$APP_DIR" "${APP_DIR}.prev"
ok "Предыдущая версия сохранена в ${APP_DIR}.prev (virtuality-ctl rollback)"
fi
mv "${APP_DIR}.new" "$APP_DIR"
ok "Версия панели: $(cat "${APP_DIR}/VERSION")"
step "Обновляем apt cache"
run_logged "apt update выполнен" apt update
step "Устанавливаем системные зависимости под выбранную сборку"
COMMON_PACKAGES=(python3 python3-venv python3-pip python3-dev rsync openssl curl wget unzip nftables novnc python3-websockify libvirt-dev pkg-config gcc)
X86_PACKAGES=(qemu-system-x86 qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
ARM_PACKAGES=(qemu-system-arm qemu-efi-aarch64 virtinst libvirt-daemon-system libvirt-clients bridge-utils cloud-image-utils)
PACKAGES=("${COMMON_PACKAGES[@]}")
case "$PACKAGE_PROFILE" in
raspberry|orangepi5|arm64)
PACKAGES+=("${ARM_PACKAGES[@]}")
;;
*)
PACKAGES+=("${X86_PACKAGES[@]}")
;;
esac
run_logged "Системные пакеты установлены для профиля ${PACKAGE_PROFILE:-x86}" apt install -y "${PACKAGES[@]}"
if [[ "${PACKAGE_PROFILE:-x86}" != "raspberry" && "${PACKAGE_PROFILE:-x86}" != "orangepi5" && "${PACKAGE_PROFILE:-x86}" != "arm64" ]]; then
if command -v qemu-system-aarch64 >/dev/null 2>&1; then
ok "ARM64 QEMU эмулятор найден: $(command -v qemu-system-aarch64)"
step "Python-окружение"
if [[ -x "$VENV_DIR/bin/python" ]] && "$VENV_DIR/bin/python" -c 'import sys' >/dev/null 2>&1; then
ok "Virtualenv уже есть: ${VENV_DIR}"
else
[[ -d "$VENV_DIR" ]] && { warn "Virtualenv повреждён (например, после обновления Python) — пересоздаём"; rm -rf -- "${VENV_DIR:?}"; }
if python3 -c 'import ensurepip' >/dev/null 2>&1; then
run_logged "Virtualenv создан" python3 -m venv "$VENV_DIR"
else
fail "Не найден qemu-system-aarch64. Установи пакет: sudo apt install -y qemu-system-arm qemu-efi-aarch64"
# Ubuntu Server without python3-venv: pip is bootstrapped from the bundled wheel.
run_logged "Virtualenv создан (без pip)" python3 -m venv --without-pip "$VENV_DIR"
fi
fi
step "Копируем web-панель в /opt/virtuality"
run_logged "Создана директория /opt/virtuality" mkdir -p /opt/virtuality
run_logged "Файлы панели синхронизированы в $APP_DIR" rsync -a --delete "$WEB_DIR/" "$APP_DIR/"
run_logged "Конфиг профиля доступен web-панели" mkdir -p "$PROFILE_DIR"
if [[ -f "$PROFILE_FILE" ]]; then
ok "Профиль уже сохранён: $PROFILE_FILE"
else
warn "Профиль не найден после детекта, будет создан web-панелью при старте"
if ! "$VENV_DIR/bin/python" -m pip --version >/dev/null 2>&1; then
PIP_WHEEL="$(ls "${WHEELS_DIR}"/pip-*.whl 2>/dev/null | head -n1 || true)"
[[ -n "$PIP_WHEEL" ]] || fail "В virtualenv нет pip и нет wheel pip в ${WHEELS_DIR}. Установите python3-venv: sudo apt install python3-venv"
run_logged "pip установлен из ${PIP_WHEEL##*/}" "$VENV_DIR/bin/python" "${PIP_WHEEL}/pip" install --no-index --find-links "$WHEELS_DIR" pip
fi
if compgen -G "${WHEELS_DIR}/*.whl" >/dev/null; then
run_logged "Зависимости установлены офлайн из ${WHEELS_DIR}" "$VENV_DIR/bin/python" -m pip install --no-index --find-links "$WHEELS_DIR" -r "$APP_DIR/requirements.txt"
else
run_logged "Зависимости установлены из PyPI" "$VENV_DIR/bin/python" -m pip install --disable-pip-version-check -r "$APP_DIR/requirements.txt"
fi
run_logged "Панель импортируется без ошибок" bash -c "cd '$APP_DIR' && '$VENV_DIR/bin/python' -c 'import app'"
step "Создаём конфиг авторизации"
step "Настройки ноды"
touch "$NODE_CONFIG_FILE"
grep -q '^#' "$NODE_CONFIG_FILE" || sed -i '1i # Настройки ноды Virtuality. Менять: sudo virtuality-ctl set KEY=VALUE' "$NODE_CONFIG_FILE"
env_set VIRTUALITY_WEB_PORT "$PORT"
env_set VIRTUALITY_AUTH_USER "$AUTH_USER"
env_set VIRTUALITY_AUTO_UPDATE "$AUTO_UPDATE"
env_set VIRTUALITY_SOURCE_DIR "$REPO_DIR"
[[ -n "$(saved_setting VIRTUALITY_WEB_HOST)" ]] || env_set VIRTUALITY_WEB_HOST 0.0.0.0
[[ -n "$(saved_setting VIRTUALITY_TLS)" ]] || env_set VIRTUALITY_TLS 0
[[ -n "$(saved_setting VIRTUALITY_UPDATE_CHANNEL)" ]] || env_set VIRTUALITY_UPDATE_CHANNEL stable
chmod 644 "$NODE_CONFIG_FILE"
ok "Сохранено в ${NODE_CONFIG_FILE}"
if [[ -s "$SESSION_SECRET_FILE" ]]; then
SESSION_SECRET="$(cat "$SESSION_SECRET_FILE")"
ok "Используем существующий session secret: $SESSION_SECRET_FILE"
else
SESSION_SECRET="$(openssl rand -hex 32)"
printf '%s\n' "$SESSION_SECRET" > "$SESSION_SECRET_FILE"
chmod 600 "$SESSION_SECRET_FILE"
ok "Создан постоянный session secret: $SESSION_SECRET_FILE"
ok "Создан ключ сессий: ${SESSION_SECRET_FILE}"
fi
cat > "${APP_DIR}/.env" <<EOF
VIRTUALITY_AUTH_USER=${AUTH_USER}
VIRTUALITY_SESSION_SECRET=${SESSION_SECRET}
VIRTUALITY_COOKIE_SECURE=$(saved_setting VIRTUALITY_COOKIE_SECURE)
TMPDIR=${UPLOAD_TMP_DIR}
TEMP=${UPLOAD_TMP_DIR}
TMP=${UPLOAD_TMP_DIR}
EOF
chmod 600 "${APP_DIR}/.env"
ok "Создан ${APP_DIR}/.env"
if [[ "$TLS_ENABLED" == "1" ]]; then
if [[ -s "$TLS_CERT" && -s "$TLS_KEY" ]]; then
ok "Используем существующий TLS-сертификат: $TLS_CERT"
else
mkdir -p "$TLS_DIR"
run_logged "Самоподписанный TLS-сертификат создан на 10 лет" openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes -keyout "$TLS_KEY" -out "$TLS_CERT" -subj "/CN=virtuality" -addext "subjectAltName=DNS:virtuality,IP:127.0.0.1"
chmod 600 "$TLS_KEY"
fi
ok "Панель будет работать по HTTPS (отключить: VIRTUALITY_TLS=0)"
else
warn "TLS отключён через VIRTUALITY_TLS=0 — панель будет работать по HTTP"
fi
ok "Временный каталог загрузок: ${UPLOAD_TMP_DIR}"
ok "Вход будет по Linux-пользователю: ${AUTH_USER}"
step "Создаём Python virtualenv"
if [[ -d "$VENV_DIR" ]]; then
warn "Virtualenv уже существует, будет переиспользован: $VENV_DIR"
else
run_logged "Virtualenv создан: $VENV_DIR" python3 -m venv "$VENV_DIR"
fi
step "Устанавливаем virtuality-ctl"
install -m 0755 "$CTL_SRC" "$CTL_DST"
ok "${CTL_DST}"
step "Устанавливаем Python-зависимости"
run_logged "pip обновлён" "$VENV_DIR/bin/pip" install --upgrade pip
run_logged "Python-зависимости установлены" "$VENV_DIR/bin/pip" install -r "$APP_DIR/requirements.txt"
if "$VENV_DIR/bin/pip" install libvirt-python >> "$LOG_FILE" 2>&1; then
ok "libvirt-python установлен — панель работает через быстрый libvirt-backend"
else
warn "libvirt-python не установился — панель будет работать через virsh (медленнее, но полностью функционально)"
fi
step "Создаём systemd service"
UVICORN_TLS_ARGS=""
if [[ "$TLS_ENABLED" == "1" ]]; then
UVICORN_TLS_ARGS=" --ssl-certfile ${TLS_CERT} --ssl-keyfile ${TLS_KEY}"
fi
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=Virtuality Web Panel
After=network-online.target libvirtd.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host 0.0.0.0 --port ${PORT}${UVICORN_TLS_ARGS}
Restart=always
RestartSec=3
User=root
Group=root
Environment=PYTHONUNBUFFERED=1
Environment=VIRTUALITY_AUTH_USER=${AUTH_USER}
Environment=VIRTUALITY_SOURCE_DIR=${REPO_DIR}
Environment=TMPDIR=${UPLOAD_TMP_DIR}
Environment=TEMP=${UPLOAD_TMP_DIR}
Environment=TMP=${UPLOAD_TMP_DIR}
[Install]
WantedBy=multi-user.target
EOF
ok "Создан service: $SERVICE_FILE"
run_logged "systemd daemon-reload выполнен" systemctl daemon-reload
step "Настраиваем автообновление"
if [[ -f "${REPO_DIR}/scripts/auto_update_check.sh" ]]; then
chmod +x "${REPO_DIR}/scripts/auto_update_check.sh" || true
cat > "$AUTO_UPDATE_SERVICE_FILE" <<EOF
[Unit]
Description=Virtuality automatic GitHub update check
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
Group=root
Environment=VIRTUALITY_SOURCE_DIR=${REPO_DIR}
Environment=TMPDIR=${UPLOAD_TMP_DIR}
ExecStart=/bin/bash ${REPO_DIR}/scripts/auto_update_check.sh
EOF
cat > "$AUTO_UPDATE_TIMER_FILE" <<EOF
[Unit]
Description=Run Virtuality automatic update check daily at 00:00 Moscow time
[Timer]
OnCalendar=*-*-* 21:00:00 UTC
AccuracySec=1min
Persistent=true
Unit=virtuality-auto-update.service
[Install]
WantedBy=timers.target
EOF
run_logged "systemd daemon-reload выполнен для автообновлений" systemctl daemon-reload
run_logged "virtuality-auto-update.timer включён" systemctl enable --now virtuality-auto-update.timer
ok "Автообновление будет проверять GitHub 1 раз в сутки в 00:00 по Москве"
else
warn "auto_update_check.sh не найден, автообновление пропущено"
fi
cat > "$BACKUP_SERVICE_FILE" <<EOF
[Unit]
Description=Virtuality scheduled VM backups
After=libvirtd.service
[Service]
Type=oneshot
User=root
Group=root
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/backup_core.py --scheduled
EOF
cat > "$BACKUP_TIMER_FILE" <<EOF
[Unit]
Description=Run Virtuality VM backups nightly at 04:30 Moscow time
[Timer]
OnCalendar=*-*-* 01:30:00 UTC
AccuracySec=5min
Persistent=true
Unit=virtuality-backup.service
[Install]
WantedBy=timers.target
EOF
run_logged "systemd daemon-reload выполнен для бэкапов" systemctl daemon-reload
run_logged "virtuality-backup.timer включён" systemctl enable --now virtuality-backup.timer
ok "Ночные бэкапы VM: расписание настраивается на странице /backups"
step "Запускаем Virtuality Web Panel"
run_logged "virtuality-web.service включён и запущен" systemctl enable --now virtuality-web.service
step "Службы, firewall и запуск"
run_logged "Юниты созданы, панель запущена" env VIRTUALITY_SOURCE_DIR="$REPO_DIR" "$CTL_DST" reconfigure
sleep 2
STATE="$(service_state virtuality-web.service)"
if [[ "$STATE" == "active" ]]; then
ok "virtuality-web.service active"
if systemctl is-active --quiet virtuality-web.service; then
ok "virtuality-web.service работает"
else
warn "virtuality-web.service состояние: $STATE"
journalctl -u virtuality-web.service -n 40 --no-pager >> "$LOG_FILE" 2>&1 || true
fail "virtuality-web.service не запустился"
fi
step "Настраиваем firewall и проверяем порт"
if command -v ufw >/dev/null 2>&1; then
ufw allow "${PORT}/tcp" >> "$LOG_FILE" 2>&1 || warn "Не удалось добавить UFW правило для ${PORT}/tcp"
ok "UFW правило добавлено/проверено: ${PORT}/tcp"
step "Мастер настройки"
if [[ -f "$SETUP_STATE_FILE" ]]; then
ok "Состояние установки уже ведётся"
elif [[ "${VIRTUALITY_FIRSTBOOT:-0}" == "1" ]]; then
ok "Прогресс пишет первая загрузка"
elif [[ "$FRESH_INSTALL" != "1" ]]; then
ok "Обновление уже настроенного сервера — мастер не нужен (его можно запустить в Настройках)"
else
warn "ufw не установлен, firewall-правило пропущено"
fi
if command -v ss >/dev/null 2>&1 && ss -tulpn | grep -q ":${PORT}"; then
ok "Порт ${PORT} слушается"
else
warn "Порт ${PORT} пока не найден в ss; проверь service logs"
# A node installed by hand gets the same first-run wizard as one installed from the image.
printf '{"stage": "done", "message": "", "steps": []}\n' > "$SETUP_STATE_FILE"
ok "Мастер настройки откроется при первом входе в панель"
fi
SERVER_IP="$(hostname -I | awk '{print $1}')"
PANEL_SCHEME="http"
[[ "$TLS_ENABLED" == "1" ]] && PANEL_SCHEME="https"
echo
echo -e "${GREEN}${BOLD}╭────────────────────────────────────────────────────────────╮${RESET}"
echo -e "${GREEN}${BOLD}│${RESET} ${BOLD}Установка Virtuality Web Panel завершена${RESET} ${GREEN}${BOLD}│${RESET}"
echo -e "${GREEN}${BOLD}╰────────────────────────────────────────────────────────────╯${RESET}"
echo
echo -e "${BOLD}URL:${RESET} ${PANEL_SCHEME}://${SERVER_IP}:${PORT}"
if [[ "$TLS_ENABLED" == "1" ]]; then
echo -e "${DIM}Сертификат самоподписанный — браузер один раз спросит подтверждение.${RESET}"
fi
echo -e "${BOLD}Login:${RESET} ${AUTH_USER} / пароль Linux-пользователя"
echo -e "${BOLD}Profile:${RESET} ${LABEL:-$PROFILE}"
echo -e "${BOLD}Arch:${RESET} ${ARCH:-unknown}"
echo -e "${BOLD}Service:${RESET} virtuality-web.service"
echo -e "${BOLD}Auto update:${RESET} virtuality-auto-update.timer / ежедневно в 00:00 по Москве"
echo -e "${BOLD}Upload tmp:${RESET} ${UPLOAD_TMP_DIR}"
echo -e "${BOLD}Session key:${RESET} ${SESSION_SECRET_FILE}"
echo -e "${BOLD}Status:${RESET} systemctl status virtuality-web --no-pager"
echo -e "${BOLD}Logs:${RESET} journalctl -u virtuality-web -f"
echo -e "${BOLD}Install log:${RESET} ${LOG_FILE}"
PANEL_URL="$("$CTL_DST" url 2>/dev/null || echo "http://$(hostname -I | awk '{print $1}'):${PORT}")"
echo
echo -e "${GREEN}${BOLD}Панель управления установлена${RESET}"
line
echo -e "${DIM}Следующий шаг: /host, /network, /logs, /update, /vm/create, /vm/NAME/console.${RESET}"
echo -e "${BOLD}Адрес:${RESET} ${PANEL_URL}"
echo -e "${BOLD}Логин:${RESET} ${AUTH_USER} (пароль пользователя Linux)"
echo -e "${BOLD}Служба:${RESET} systemctl status virtuality-web --no-pager"
echo -e "${BOLD}Журнал:${RESET} journalctl -u virtuality-web -f"
echo -e "${BOLD}Настройки:${RESET} sudo virtuality-ctl status"
line
echo -e "${DIM}Автообновление: $([[ "$AUTO_UPDATE" == "1" ]] && echo "включено (ночью)" || echo "отключено")${RESET}"
-169
Просмотреть файл
@@ -1,169 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
IFACE="${1:-enp2s0}"
BRIDGE="br0"
NETPLAN_DIR="/etc/netplan"
BACKUP_DIR="/root/virtuality-netplan-backups/$(date +%Y%m%d_%H%M%S)"
BRIDGE_FILE="${NETPLAN_DIR}/60-virtuality-br0.yaml"
ROLLBACK_SCRIPT="/root/virtuality-rollback-netplan.sh"
MODE="${2:-dhcp}"
if [[ "$EUID" -ne 0 ]]; then
echo "Ошибка: запусти через sudo: sudo bash scripts/setup_bridge_br0.sh enp2s0"
exit 1
fi
if ! command -v netplan >/dev/null 2>&1; then
echo "Ошибка: netplan не найден."
exit 1
fi
if ! ip link show "$IFACE" >/dev/null 2>&1; then
echo "Ошибка: интерфейс $IFACE не найден."
ip -br a
exit 1
fi
CURRENT_IP="$(ip -4 addr show "$IFACE" | awk '/inet / {print $2; exit}')"
CURRENT_GW="$(ip route | awk '/default/ {print $3; exit}')"
# Use stable IPv4 DNS by default. Link-local IPv6 DNS can break YAML/routes without scope id.
DNS_1="${VIRTUALITY_DNS_1:-10.0.0.1}"
DNS_2="${VIRTUALITY_DNS_2:-1.1.1.1}"
echo "============================================================"
echo "Virtuality safe bridge setup"
echo "============================================================"
echo "Interface: $IFACE"
echo "Bridge: $BRIDGE"
echo "Mode: $MODE"
echo "Current IP: ${CURRENT_IP:-unknown}"
echo "Current gateway: ${CURRENT_GW:-unknown}"
echo "DNS: ${DNS_1}, ${DNS_2}"
echo "Backup: $BACKUP_DIR"
echo
echo "ВАЖНО: скрипт использует netplan try."
echo "Если сеть пропадёт, netplan должен автоматически откатить изменения."
echo "Если видишь подтверждение — нажми Enter, только если SSH/сеть не отвалилась."
echo
mkdir -p "$BACKUP_DIR"
cp -a "$NETPLAN_DIR"/*.yaml "$BACKUP_DIR"/ 2>/dev/null || true
cat > "$ROLLBACK_SCRIPT" <<EOF
#!/usr/bin/env bash
set -euo pipefail
rm -f "$BRIDGE_FILE"
cp -a "$BACKUP_DIR"/*.yaml "$NETPLAN_DIR"/
chmod 600 "$NETPLAN_DIR"/*.yaml 2>/dev/null || true
netplan generate
netplan apply
echo "Netplan rollback applied from $BACKUP_DIR"
EOF
chmod +x "$ROLLBACK_SCRIPT"
# Keep existing netplan files neutral for this interface to avoid duplicate DHCP/IP.
for file in "$NETPLAN_DIR"/*.yaml; do
[[ "$file" == "$BRIDGE_FILE" ]] && continue
if grep -q "${IFACE}:" "$file"; then
cp -a "$file" "${file}.virtuality-before-br0"
cat > "$file" <<EOF
network:
version: 2
ethernets:
${IFACE}:
dhcp4: false
dhcp6: false
EOF
chmod 600 "$file"
fi
done
if [[ "$MODE" == "static" ]]; then
if [[ -z "${CURRENT_IP:-}" || -z "${CURRENT_GW:-}" ]]; then
echo "Ошибка: не удалось определить текущий IP/gateway для static mode."
echo "Откат: sudo $ROLLBACK_SCRIPT"
exit 1
fi
cat > "$BRIDGE_FILE" <<EOF
network:
version: 2
ethernets:
${IFACE}:
dhcp4: false
dhcp6: false
bridges:
${BRIDGE}:
interfaces:
- ${IFACE}
addresses:
- ${CURRENT_IP}
routes:
- to: default
via: ${CURRENT_GW}
nameservers:
addresses:
- ${DNS_1}
- ${DNS_2}
dhcp4: false
dhcp6: false
parameters:
stp: false
forward-delay: 0
EOF
else
cat > "$BRIDGE_FILE" <<EOF
network:
version: 2
ethernets:
${IFACE}:
dhcp4: false
dhcp6: false
bridges:
${BRIDGE}:
interfaces:
- ${IFACE}
dhcp4: true
dhcp6: false
parameters:
stp: false
forward-delay: 0
EOF
fi
chmod 600 "$BRIDGE_FILE"
chmod 600 "$NETPLAN_DIR"/*.yaml 2>/dev/null || true
echo "[1/5] Generated config:"
cat "$BRIDGE_FILE"
echo
echo "[2/5] Validating netplan..."
netplan generate
echo
echo "[3/5] Testing config with netplan try..."
echo "Если после применения сеть работает — подтверди netplan try клавишей Enter."
echo "Если SSH зависнет — жди автооткат или используй физический монитор: sudo $ROLLBACK_SCRIPT"
netplan try --timeout 30
echo
echo "[4/5] Applying final netplan..."
netplan apply
sleep 3
echo
echo "[5/5] Result:"
ip -br a
ip route
echo
echo "============================================================"
echo "Bridge setup completed"
echo "============================================================"
echo "Rollback command: sudo $ROLLBACK_SCRIPT"
echo "If DHCP mode failed, try static mode:"
echo "sudo bash scripts/setup_bridge_br0.sh ${IFACE} static"
echo "============================================================"
+59
Просмотреть файл
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Sourced by auto_update_check.sh and apply_github_update.sh: which git ref a node updates to.
# stable (default) — the latest release tag vX.Y.Z; main until the first release is tagged
# main — every change on the main branch
# Needs SOURCE_DIR, REMOTE and BRANCH; sets UPDATE_CHANNEL, TARGET_REF, TARGET_LABEL, TARGET_ZIP_URL.
# 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
value="$(sed -n 's/^VIRTUALITY_UPDATE_CHANNEL=//p' /var/lib/virtuality/config/web.env | tail -n1 | tr -d "\"'")"
fi
[[ "$value" == "main" ]] && echo main || echo stable
}
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="" web
web="$(repo_web_url)"
UPDATE_CHANNEL="$(update_channel)"
TARGET_REF="${REMOTE}/${BRANCH}"
TARGET_LABEL="${BRANCH}"
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="${web}/archive/${tag}.zip"
fi
fi
}
+744
Просмотреть файл
@@ -0,0 +1,744 @@
#!/usr/bin/env bash
# virtuality-ctl — node management for Virtuality.
# Installed to /usr/local/bin by scripts/install_web_panel.sh. Run as root.
set -euo pipefail
CONFIG_DIR="/var/lib/virtuality/config"
NODE_ENV="${CONFIG_DIR}/web.env"
TLS_DIR="${CONFIG_DIR}/tls"
STATE_FILE="${CONFIG_DIR}/ports.state"
DATA_DIR="/var/lib/virtuality"
LOG_DIR="/var/log/virtuality"
APP_DIR="/opt/virtuality/web"
VENV_DIR="/opt/virtuality/venv"
UNIT_DIR="/etc/systemd/system"
NETPLAN_DIR="/etc/netplan"
NETPLAN_BACKUP="${CONFIG_DIR}/netplan-backup"
BRIDGE_FILE="${NETPLAN_DIR}/60-virtuality-br0.yaml"
REVERT_UNIT="virtuality-netplan-revert"
usage() {
cat <<EOF
Usage: virtuality-ctl <command> [options]
status Что установлено, порты, адреса панели
url Адрес панели управления
reconfigure Пересоздать systemd-юниты из ${NODE_ENV} и перезапустить панель
set KEY=VALUE ... Изменить настройку в web.env (например set VIRTUALITY_TLS=1) и применить
passwd [USER] Сменить пароль пользователя панели (пароль читается со стандартного ввода)
timezone <Area/City> Установить часовой пояс сервера
power reboot|poweroff Перезагрузить или выключить сервер (машины выключаются корректно)
backup-config [FILE] Сохранить настройки ноды и описания машин в архив
restore-config FILE Восстановить настройки ноды из архива
rollback Вернуть предыдущую версию панели после неудачного обновления
maintenance Ежедневное обслуживание: старые задачи, временные файлы, свободное место
bridge <iface> [dhcp|static] Подключить машины к локальной сети через мост br0 с автооткатом
bridge-confirm Подтвердить новую сеть (отменяет автооткат)
bridge-revert Вернуть прежнюю сеть
storage-use /dev/sdX Отдать пустой диск под хранилище машин (${DATA_DIR})
support-bundle [FILE] Собрать отчёт для поддержки
uninstall [--purge] [--yes] Удалить Virtuality (по умолчанию машины и образы остаются)
EOF
}
die() { echo "virtuality-ctl: $*" >&2; exit 1; }
need_root() { [[ "$EUID" -eq 0 ]] || die "нужны права root: sudo virtuality-ctl $*"; }
now() { date '+%Y-%m-%d %H:%M:%S'; }
log() { mkdir -p "$LOG_DIR"; echo "[$(now)] [virtuality-ctl] $*" >> "${LOG_DIR}/virtuality-ctl.log"; }
# ---------------------------------------------------------------- settings
env_get() {
local key="$1" default="${2:-}"
local value=""
if [[ -f "$NODE_ENV" ]]; then
value="$(sed -n "s/^${key}=//p" "$NODE_ENV" | tail -n1 | tr -d '"' | tr -d "'")"
fi
printf '%s' "${value:-$default}"
}
env_set() {
local key="$1" value="$2"
mkdir -p "$CONFIG_DIR"
touch "$NODE_ENV"
if grep -q "^${key}=" "$NODE_ENV"; then
sed -i "s|^${key}=.*|${key}=${value}|" "$NODE_ENV"
else
printf '%s=%s\n' "$key" "$value" >> "$NODE_ENV"
fi
chmod 644 "$NODE_ENV"
}
primary_ip() { hostname -I 2>/dev/null | awk '{print $1}'; }
# Source checkout used by updates; the installer records it in web.env.
SOURCE_DIR="${VIRTUALITY_SOURCE_DIR:-$(env_get VIRTUALITY_SOURCE_DIR /opt/virtuality/source)}"
panel_url() {
local tls port
tls="$(env_get VIRTUALITY_TLS 0)"
if [[ "$tls" == "1" ]]; then
port="$(env_get VIRTUALITY_TLS_PORT 8443)"
printf 'https://%s:%s' "$(primary_ip)" "$port"
else
port="$(env_get VIRTUALITY_WEB_PORT 8088)"
printf 'http://%s:%s' "$(primary_ip)" "$port"
fi
}
# ---------------------------------------------------------------- tls
ensure_cert() {
local ip host
mkdir -p "$TLS_DIR"
chmod 700 "$TLS_DIR"
if [[ -s "${TLS_DIR}/panel.key" && -s "${TLS_DIR}/panel.crt" ]]; then
return 0
fi
ip="$(primary_ip)"
host="$(hostname)"
local san="DNS:${host},DNS:localhost,IP:127.0.0.1"
[[ -n "$ip" ]] && san="${san},IP:${ip}"
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 3650 \
-subj "/CN=Virtuality on ${host}" -addext "subjectAltName=${san}" \
-keyout "${TLS_DIR}/panel.key" -out "${TLS_DIR}/panel.crt" >/dev/null 2>&1 \
|| die "не удалось создать TLS-сертификат (нужен openssl)"
chmod 600 "${TLS_DIR}/panel.key"
log "TLS certificate created for ${host} ${ip}"
}
# ---------------------------------------------------------------- units
write_units() {
local port host tls tls_port auth_user auto_update
port="$(env_get VIRTUALITY_WEB_PORT 8088)"
host="$(env_get VIRTUALITY_WEB_HOST 0.0.0.0)"
tls="$(env_get VIRTUALITY_TLS 0)"
tls_port="$(env_get VIRTUALITY_TLS_PORT 8443)"
auth_user="$(env_get VIRTUALITY_AUTH_USER root)"
auto_update="$(env_get VIRTUALITY_AUTO_UPDATE 1)"
local listen_port="$port" ssl_args=""
if [[ "$tls" == "1" ]]; then
ensure_cert
listen_port="$tls_port"
ssl_args="--ssl-keyfile ${TLS_DIR}/panel.key --ssl-certfile ${TLS_DIR}/panel.crt"
env_set VIRTUALITY_COOKIE_SECURE 1
else
env_set VIRTUALITY_COOKIE_SECURE 0
fi
cat > "${UNIT_DIR}/virtuality-web.service" <<EOF
[Unit]
Description=Virtuality Web Panel
After=network-online.target libvirtd.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/uvicorn app:app --host ${host} --port ${listen_port} --workers 1 --no-server-header ${ssl_args}
Restart=always
RestartSec=3
TimeoutStopSec=20
LimitNOFILE=65536
User=root
Group=root
Environment=PYTHONUNBUFFERED=1
Environment=LC_ALL=C.UTF-8
Environment=VIRTUALITY_AUTH_USER=${auth_user}
Environment=VIRTUALITY_SOURCE_DIR=${SOURCE_DIR}
Environment=VIRTUALITY_NODE_ENV=${NODE_ENV}
Environment=TMPDIR=${DATA_DIR}/tmp
Environment=TEMP=${DATA_DIR}/tmp
Environment=TMP=${DATA_DIR}/tmp
[Install]
WantedBy=multi-user.target
EOF
if [[ "$tls" == "1" ]]; then
cat > "${UNIT_DIR}/virtuality-redirect.service" <<EOF
[Unit]
Description=Virtuality HTTP to HTTPS redirect
After=network-online.target
[Service]
Type=simple
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/redirect_http.py --port ${port} --target-port ${tls_port}
Restart=always
RestartSec=3
User=root
[Install]
WantedBy=multi-user.target
EOF
else
rm -f "${UNIT_DIR}/virtuality-redirect.service"
fi
cat > "${UNIT_DIR}/virtuality-network.service" <<EOF
[Unit]
Description=Virtuality NAT port forwarding restore
After=network-online.target libvirtd.service nftables.service ufw.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=${APP_DIR}
ExecStart=${VENV_DIR}/bin/python ${APP_DIR}/restore_network.py
[Install]
WantedBy=multi-user.target
EOF
cat > "${UNIT_DIR}/virtuality-auto-update.service" <<EOF
[Unit]
Description=Virtuality automatic update check
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
Environment=VIRTUALITY_SOURCE_DIR=${SOURCE_DIR}
Environment=TMPDIR=${DATA_DIR}/tmp
ExecStart=/bin/bash ${SOURCE_DIR}/scripts/auto_update_check.sh
EOF
cat > "${UNIT_DIR}/virtuality-auto-update.timer" <<EOF
[Unit]
Description=Virtuality nightly update check
[Timer]
OnCalendar=*-*-* 04:00:00
RandomizedDelaySec=30min
AccuracySec=1min
Persistent=true
Unit=virtuality-auto-update.service
[Install]
WantedBy=timers.target
EOF
cat > "${UNIT_DIR}/virtuality-maintenance.service" <<EOF
[Unit]
Description=Virtuality daily maintenance
[Service]
Type=oneshot
User=root
ExecStart=/usr/local/bin/virtuality-ctl maintenance
EOF
cat > "${UNIT_DIR}/virtuality-maintenance.timer" <<EOF
[Unit]
Description=Virtuality daily maintenance
[Timer]
OnCalendar=daily
RandomizedDelaySec=1h
Persistent=true
Unit=virtuality-maintenance.service
[Install]
WantedBy=timers.target
EOF
mkdir -p /etc/logrotate.d
cat > /etc/logrotate.d/virtuality <<EOF
${LOG_DIR}/*.log {
weekly
rotate 4
compress
delaycompress
missingok
notifempty
copytruncate
}
EOF
# Guests shut down gracefully with the host instead of being killed (Proxmox behaviour).
# Only once libvirt is installed: the file is a dpkg conffile, and creating it earlier (the panel is
# installed first on the image) makes the libvirt package stop at a "keep your version?" question.
if [[ -f /usr/lib/libvirt/libvirt-guests.sh ]]; then
cat > /etc/default/libvirt-guests <<EOF
ON_BOOT=ignore
ON_SHUTDOWN=shutdown
SHUTDOWN_TIMEOUT=180
PARALLEL_SHUTDOWN=5
EOF
fi
systemctl daemon-reload
systemctl enable virtuality-web.service virtuality-network.service virtuality-maintenance.timer >/dev/null 2>&1 || true
systemctl enable libvirt-guests.service >/dev/null 2>&1 || true
if [[ "$tls" == "1" ]]; then
systemctl enable virtuality-redirect.service >/dev/null 2>&1 || true
else
systemctl disable --now virtuality-redirect.service >/dev/null 2>&1 || true
fi
if [[ "$auto_update" == "1" ]]; then
systemctl enable virtuality-auto-update.timer >/dev/null 2>&1 || true
else
systemctl disable --now virtuality-auto-update.timer >/dev/null 2>&1 || true
fi
}
apply_firewall() {
command -v ufw >/dev/null 2>&1 || return 0
local port tls tls_port old
port="$(env_get VIRTUALITY_WEB_PORT 8088)"
tls="$(env_get VIRTUALITY_TLS 0)"
tls_port="$(env_get VIRTUALITY_TLS_PORT 8443)"
if [[ -f "$STATE_FILE" ]]; then
while read -r old; do
[[ -n "$old" ]] && ufw delete allow "${old}/tcp" >/dev/null 2>&1 || true
done < "$STATE_FILE"
fi
# VM consoles are proxied by the panel over 127.0.0.1; VNC must never be reachable directly.
ufw delete allow 5900:5999/tcp >/dev/null 2>&1 || true
ufw allow "${port}/tcp" >/dev/null 2>&1 || true
printf '%s\n' "$port" > "$STATE_FILE"
if [[ "$tls" == "1" ]]; then
ufw allow "${tls_port}/tcp" >/dev/null 2>&1 || true
printf '%s\n' "$tls_port" >> "$STATE_FILE"
fi
}
restart_panel() {
systemctl restart virtuality-web.service
if [[ "$(env_get VIRTUALITY_TLS 0)" == "1" ]]; then
systemctl restart virtuality-redirect.service || true
fi
systemctl enable --now virtuality-maintenance.timer >/dev/null 2>&1 || true
if [[ "$(env_get VIRTUALITY_AUTO_UPDATE 1)" == "1" ]]; then
systemctl enable --now virtuality-auto-update.timer >/dev/null 2>&1 || true
fi
}
# VMs created by older versions listened for VNC on every address; the panel proxies consoles over localhost.
harden_vnc() {
command -v virsh >/dev/null 2>&1 && command -v virt-xml >/dev/null 2>&1 || return 0
local name
while read -r name; do
[[ -n "$name" ]] || continue
if virsh dumpxml "$name" 2>/dev/null | grep -q "listen='0.0.0.0'"; then
virt-xml "$name" --edit --graphics listen=127.0.0.1 >/dev/null 2>&1 && log "vnc of ${name} now listens on 127.0.0.1 (applies after restart)" || true
fi
done < <(virsh list --all --name 2>/dev/null)
}
# Nodes installed before 0.11 had a text dashboard on tty1 instead of the login prompt.
cleanup_legacy() {
if [[ -f "${UNIT_DIR}/virtuality-console-dashboard.service" ]]; then
systemctl disable --now virtuality-console-dashboard.service >/dev/null 2>&1 || true
rm -f "${UNIT_DIR}/virtuality-console-dashboard.service" /usr/local/bin/virtuality-console-dashboard
systemctl daemon-reload
systemctl enable --now getty@tty1.service >/dev/null 2>&1 || true
log "legacy tty1 console dashboard removed"
fi
}
cmd_reconfigure() {
need_root reconfigure
[[ -d "$APP_DIR" ]] || die "панель не установлена: ${APP_DIR} не найден"
cleanup_legacy
write_units
apply_firewall
harden_vnc
restart_panel
log "reconfigured: $(panel_url)"
echo "Панель перезапущена: $(panel_url)"
}
cmd_set() {
need_root set
[[ $# -gt 0 ]] || die "usage: virtuality-ctl set KEY=VALUE [KEY=VALUE ...]"
local pair key value restart_needed=0
for pair in "$@"; do
key="${pair%%=*}"
value="${pair#*=}"
[[ "$key" =~ ^VIRTUALITY_[A-Z_]+$ ]] || die "недопустимый ключ: $key"
case "$key" in
VIRTUALITY_WEB_PORT|VIRTUALITY_TLS_PORT) [[ "$value" =~ ^[0-9]+$ ]] && (( value >= 1 && value <= 65535 )) || die "порт должен быть числом 1–65535" ;;
VIRTUALITY_TLS|VIRTUALITY_AUTO_UPDATE) [[ "$value" == "0" || "$value" == "1" ]] || die "$key принимает 0 или 1" ;;
VIRTUALITY_UPDATE_CHANNEL) [[ "$value" == "stable" || "$value" == "main" ]] || die "канал: stable или main" ;;
VIRTUALITY_WEB_HOST) [[ "$value" =~ ^[0-9a-fA-F.:]+$ ]] || die "адрес должен быть IP" ;;
esac
env_set "$key" "$value"
[[ "$key" == VIRTUALITY_AUTO_UPDATE || "$key" == VIRTUALITY_UPDATE_CHANNEL ]] || restart_needed=1
done
if [[ "$restart_needed" == "1" ]]; then
cmd_reconfigure
else
# Update settings only touch the timer: no need to restart the panel.
need_root set
write_units
log "settings updated without restart: $*"
echo "Настройки сохранены"
fi
}
# ---------------------------------------------------------------- account
cmd_passwd() {
need_root passwd
local user="${1:-$(env_get VIRTUALITY_AUTH_USER root)}"
id "$user" >/dev/null 2>&1 || die "пользователь $user не найден"
local password
if [[ -t 0 ]]; then
read -r -s -p "Новый пароль для ${user}: " password; echo
else
IFS= read -r password
fi
[[ ${#password} -ge 6 ]] || die "пароль должен быть не короче 6 символов"
printf '%s:%s\n' "$user" "$password" | chpasswd || die "не удалось сменить пароль"
passwd -u "$user" >/dev/null 2>&1 || true
log "password changed for ${user}"
echo "Пароль пользователя ${user} обновлён"
}
cmd_timezone() {
need_root timezone
local tz="${1:-}"
[[ "$tz" =~ ^[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+){0,2}$ ]] || die "usage: virtuality-ctl timezone Europe/Moscow"
timedatectl set-timezone "$tz" || die "часовой пояс $tz не найден"
timedatectl set-ntp true >/dev/null 2>&1 || true
log "timezone set to ${tz}"
echo "Часовой пояс: $(timedatectl show -p Timezone --value)"
}
cmd_power() {
need_root power
case "${1:-}" in
reboot) log "reboot requested"; systemctl reboot ;;
poweroff|shutdown) log "poweroff requested"; systemctl poweroff ;;
*) die "usage: virtuality-ctl power reboot|poweroff" ;;
esac
}
# ---------------------------------------------------------------- config backup
cmd_backup_config() {
need_root backup-config
local out="${1:-${DATA_DIR}/backups/config-$(date +%Y%m%d-%H%M).tar.gz}"
local work
work="$(mktemp -d)"
mkdir -p "$work/node" "$work/vms" "$work/networks" "$(dirname "$out")"
cp -a "$CONFIG_DIR" "$work/node/config" 2>/dev/null || true
cp -a "${DATA_DIR}/network" "$work/node/network" 2>/dev/null || true
cp -a /etc/virtuality "$work/node/etc-virtuality" 2>/dev/null || true
cp -a "$BRIDGE_FILE" "$work/node/" 2>/dev/null || true
cp -a /etc/default/libvirt-guests "$work/node/" 2>/dev/null || true
cat "${SOURCE_DIR}/VERSION" > "$work/VERSION" 2>/dev/null || true
if command -v virsh >/dev/null 2>&1; then
local name
while read -r name; do
[[ -n "$name" ]] && virsh dumpxml --migratable "$name" > "$work/vms/${name}.xml" 2>/dev/null || true
done < <(virsh list --all --name 2>/dev/null)
while read -r name; do
[[ -n "$name" ]] && virsh net-dumpxml "$name" > "$work/networks/${name}.xml" 2>/dev/null || true
done < <(virsh net-list --all --name 2>/dev/null)
fi
tar -czf "$out" -C "$work" .
rm -rf "$work"
chmod 600 "$out"
log "config backup written to ${out}"
echo "$out"
}
cmd_restore_config() {
need_root restore-config
local file="${1:-}"
[[ -f "$file" ]] || die "usage: virtuality-ctl restore-config FILE.tar.gz"
local work
work="$(mktemp -d)"
tar -xzf "$file" -C "$work" || die "архив повреждён"
[[ -d "$work/node/config" ]] || die "в архиве нет настроек Virtuality"
mkdir -p "$CONFIG_DIR" "${DATA_DIR}/network" /etc/virtuality
cp -a "$work/node/config/." "$CONFIG_DIR/"
[[ -d "$work/node/network" ]] && cp -a "$work/node/network/." "${DATA_DIR}/network/"
[[ -d "$work/node/etc-virtuality" ]] && cp -a "$work/node/etc-virtuality/." /etc/virtuality/
[[ -f "$work/node/libvirt-guests" ]] && cp -a "$work/node/libvirt-guests" /etc/default/libvirt-guests
if command -v virsh >/dev/null 2>&1; then
local xml
for xml in "$work"/networks/*.xml; do
[[ -f "$xml" ]] || continue
virsh net-define "$xml" >/dev/null 2>&1 || true
virsh net-autostart "$(basename "$xml" .xml)" >/dev/null 2>&1 || true
virsh net-start "$(basename "$xml" .xml)" >/dev/null 2>&1 || true
done
for xml in "$work"/vms/*.xml; do
[[ -f "$xml" ]] || continue
virsh define "$xml" >/dev/null 2>&1 || echo "Машина $(basename "$xml" .xml): описание восстановлено с ошибкой (диски отсутствуют?)"
done
fi
rm -rf "$work"
log "config restored from ${file}"
if [[ -d "$APP_DIR" ]]; then
cmd_reconfigure
fi
echo "Настройки восстановлены"
}
# ---------------------------------------------------------------- updates
cmd_rollback() {
need_root rollback
[[ -d "${APP_DIR}.prev" ]] || die "предыдущей версии панели нет"
rm -rf "${APP_DIR}.broken"
mv "$APP_DIR" "${APP_DIR}.broken"
mv "${APP_DIR}.prev" "$APP_DIR"
systemctl restart virtuality-web.service
log "rolled back to previous panel version"
echo "Возвращена предыдущая версия панели. Сломанная сохранена в ${APP_DIR}.broken"
}
# ---------------------------------------------------------------- maintenance
cmd_maintenance() {
need_root maintenance
local ops="${LOG_DIR}/operations"
if [[ -d "$ops" ]]; then
find "$ops" -type f -mtime +30 -delete 2>/dev/null || true
# keep at most the 300 newest operations
ls -t "$ops"/*.json 2>/dev/null | tail -n +301 | while read -r f; do rm -f "$f" "${f%.json}.log"; done
fi
find "${DATA_DIR}/tmp" -mindepth 1 -mtime +1 -delete 2>/dev/null || true
find "${DATA_DIR}/iso" "${DATA_DIR}/disk-images" -maxdepth 1 -name '.*.uploading' -mtime +1 -delete 2>/dev/null || true
find "${DATA_DIR}/iso" "${DATA_DIR}/disk-images" -maxdepth 1 -name '.*.part' -mtime +1 -delete 2>/dev/null || true
find "$LOG_DIR" -maxdepth 1 -name 'install_*.log' -mtime +30 -delete 2>/dev/null || true
journalctl --vacuum-size=300M >/dev/null 2>&1 || true
local used
used="$(df -P "$DATA_DIR" | awk 'NR==2 {gsub("%","",$5); print $5}')"
mkdir -p "$CONFIG_DIR"
printf '{"disk_used_pct": %s, "checked_at": "%s"}\n' "${used:-0}" "$(now)" > "${CONFIG_DIR}/health.json"
log "maintenance done, disk used ${used}%"
}
# ---------------------------------------------------------------- network bridge
cmd_bridge() {
need_root bridge
local iface="${1:-}" mode="${2:-dhcp}"
[[ -n "$iface" ]] || die "usage: virtuality-ctl bridge <iface> [dhcp|static]"
ip link show "$iface" >/dev/null 2>&1 || die "интерфейс $iface не найден"
[[ -d "/sys/class/net/${iface}/wireless" ]] && die "Wi-Fi нельзя объединить в мост; используйте режим NAT"
command -v netplan >/dev/null 2>&1 || die "netplan не найден"
local mac ip gw
mac="$(cat "/sys/class/net/${iface}/address")"
ip="$(ip -4 addr show "$iface" | awk '/inet / {print $2; exit}')"
gw="$(ip route show default | awk '/default/ {print $3; exit}')"
rm -rf "$NETPLAN_BACKUP"
mkdir -p "$NETPLAN_BACKUP"
cp -a "$NETPLAN_DIR"/*.yaml "$NETPLAN_BACKUP"/ 2>/dev/null || true
# Neutralise the interface in existing files so it is not configured twice.
local file
for file in "$NETPLAN_DIR"/*.yaml; do
[[ -f "$file" && "$file" != "$BRIDGE_FILE" ]] || continue
if grep -q "^\s*${iface}:" "$file"; then
cat > "$file" <<EOF
network:
version: 2
ethernets:
${iface}:
dhcp4: false
dhcp6: false
EOF
fi
done
{
echo "network:"
echo " version: 2"
echo " ethernets:"
echo " ${iface}:"
echo " dhcp4: false"
echo " dhcp6: false"
echo " bridges:"
echo " br0:"
echo " interfaces: [${iface}]"
echo " macaddress: \"${mac}\""
echo " parameters:"
echo " stp: false"
echo " forward-delay: 0"
if [[ "$mode" == "static" ]]; then
[[ -n "$ip" && -n "$gw" ]] || die "не удалось определить текущий адрес и шлюз"
echo " addresses: [${ip}]"
echo " routes:"
echo " - to: default"
echo " via: ${gw}"
echo " nameservers:"
echo " addresses: [${gw}, 1.1.1.1]"
else
echo " dhcp4: true"
echo " dhcp-identifier: mac"
echo " dhcp6: false"
fi
} > "$BRIDGE_FILE"
chmod 600 "$NETPLAN_DIR"/*.yaml 2>/dev/null || true
netplan generate || { cmd_bridge_revert; die "конфигурация сети не прошла проверку, изменения отменены"; }
# Auto-revert unless confirmed from the panel within two minutes.
systemctl stop "${REVERT_UNIT}.timer" >/dev/null 2>&1 || true
systemd-run --unit="$REVERT_UNIT" --on-active=120 --quiet /usr/local/bin/virtuality-ctl bridge-revert
netplan apply
log "bridge br0 on ${iface} applied (${mode}); auto-revert armed"
echo "Мост br0 включён. Подтвердите в течение 2 минут: virtuality-ctl bridge-confirm"
}
cmd_bridge_confirm() {
need_root bridge-confirm
systemctl stop "${REVERT_UNIT}.timer" >/dev/null 2>&1 || true
systemctl stop "${REVERT_UNIT}.service" >/dev/null 2>&1 || true
rm -rf "$NETPLAN_BACKUP"
log "bridge confirmed"
echo "Сеть подтверждена"
}
cmd_bridge_revert() {
need_root bridge-revert
systemctl stop "${REVERT_UNIT}.timer" >/dev/null 2>&1 || true
[[ -d "$NETPLAN_BACKUP" ]] || die "нечего откатывать"
rm -f "$NETPLAN_DIR"/*.yaml
cp -a "$NETPLAN_BACKUP"/*.yaml "$NETPLAN_DIR"/ 2>/dev/null || true
chmod 600 "$NETPLAN_DIR"/*.yaml 2>/dev/null || true
netplan generate && netplan apply
ip link delete br0 >/dev/null 2>&1 || true
rm -rf "$NETPLAN_BACKUP"
log "bridge reverted"
echo "Прежняя сеть восстановлена"
}
# ---------------------------------------------------------------- storage
cmd_storage_use() {
need_root storage-use
local dev="${1:-}"
[[ "$dev" =~ ^/dev/[a-zA-Z0-9]+$ ]] || die "usage: virtuality-ctl storage-use /dev/sdX"
[[ -b "$dev" ]] || die "$dev не является блочным устройством"
local children fstype mounted
children="$(lsblk -n -o NAME "$dev" | tail -n +2 | wc -l)"
fstype="$(lsblk -n -d -o FSTYPE "$dev")"
mounted="$(lsblk -n -o MOUNTPOINT "$dev" | tr -d ' \n')"
[[ "$children" -eq 0 && -z "$fstype" && -z "$mounted" ]] || die "на $dev уже есть разделы или данные — используйте только пустой диск"
findmnt -n --target "$DATA_DIR" -o SOURCE | grep -q "^${dev}" && die "$dev уже используется"
log "storage-use ${dev}: formatting"
wipefs -a "$dev" >/dev/null
mkfs.ext4 -F -q -L virtuality "$dev" >/dev/null || die "не удалось создать файловую систему"
local uuid tmp
uuid="$(blkid -s UUID -o value "$dev")"
[[ -n "$uuid" ]] || die "не удалось прочитать UUID"
tmp="$(mktemp -d)"
mount "$dev" "$tmp" || die "не удалось смонтировать диск"
mkdir -p "$DATA_DIR"
rsync -a "$DATA_DIR"/ "$tmp"/ || { umount "$tmp"; die "не удалось перенести данные"; }
umount "$tmp"; rmdir "$tmp"
sed -i "\#[[:space:]]${DATA_DIR}[[:space:]]#d" /etc/fstab
printf 'UUID=%s %s ext4 defaults,nofail 0 2\n' "$uuid" "$DATA_DIR" >> /etc/fstab
systemctl daemon-reload
mount "$DATA_DIR" || die "не удалось смонтировать ${DATA_DIR}"
chmod 755 "$DATA_DIR"
mkdir -p "$DATA_DIR"/{iso,images,disk-images,backups,tmp,network,config}
chmod 1777 "$DATA_DIR/tmp"
command -v virsh >/dev/null 2>&1 && { virsh pool-refresh virtuality-images >/dev/null 2>&1 || true; virsh pool-refresh virtuality-iso >/dev/null 2>&1 || true; }
log "storage-use ${dev}: mounted at ${DATA_DIR}"
echo "Диск ${dev} подключён как ${DATA_DIR}"
}
# ---------------------------------------------------------------- support
cmd_support_bundle() {
need_root support-bundle
local out="${1:-${DATA_DIR}/tmp/virtuality-support-$(date +%Y%m%d-%H%M).tar.gz}"
local work
work="$(mktemp -d)"
mkdir -p "$(dirname "$out")"
{
echo "Virtuality $(cat "${SOURCE_DIR}/VERSION" 2>/dev/null || echo unknown)"
echo "Host: $(hostname) $(uname -r)"; date
echo; echo "== web.env"; sed 's/SECRET=.*/SECRET=***/' "$NODE_ENV" 2>/dev/null
echo; echo "== services"; systemctl --no-pager --plain list-units 'virtuality-*' 'libvirtd*' 2>/dev/null
echo; echo "== virsh"; virsh list --all 2>&1; virsh net-list --all 2>&1; virsh pool-list --all 2>&1
echo; echo "== network"; ip -br a 2>&1; ip route 2>&1
echo; echo "== firewall"; ufw status 2>&1; nft list ruleset 2>&1 | head -200
echo; echo "== disk"; df -h / "$DATA_DIR" 2>&1
} > "$work/summary.txt"
journalctl -u virtuality-web -n 500 --no-pager > "$work/virtuality-web.log" 2>&1 || true
journalctl -u libvirtd -n 200 --no-pager > "$work/libvirtd.log" 2>&1 || true
cp "${LOG_DIR}"/update.log "${LOG_DIR}"/firstboot.log "${LOG_DIR}"/virtuality-ctl.log "$work"/ 2>/dev/null || true
mkdir -p "$work/operations"
ls -t "${LOG_DIR}/operations"/*.json 2>/dev/null | head -20 | while read -r f; do cp "$f" "${f%.json}.log" "$work/operations/" 2>/dev/null || true; done
tar -czf "$out" -C "$work" .
rm -rf "$work"
echo "$out"
}
cmd_status() {
echo "Virtuality $(cat "${SOURCE_DIR}/VERSION" 2>/dev/null || echo unknown)"
echo "Панель: $(panel_url) ($(systemctl is-active virtuality-web.service 2>/dev/null || echo unknown))"
echo "Пользователь: $(env_get VIRTUALITY_AUTH_USER root)"
echo "HTTPS: $([[ "$(env_get VIRTUALITY_TLS 0)" == "1" ]] && echo включён || echo выключен)"
echo "Автообновление: $([[ "$(env_get VIRTUALITY_AUTO_UPDATE 1)" == "1" ]] && echo "включено ($(env_get VIRTUALITY_UPDATE_CHANNEL stable))" || echo выключено)"
echo "Часовой пояс: $(timedatectl show -p Timezone --value 2>/dev/null || echo unknown)"
echo "Хранилище: $(df -h "$DATA_DIR" 2>/dev/null | awk 'NR==2 {print $4" свободно из "$2}')"
}
# ---------------------------------------------------------------- uninstall
cmd_uninstall() {
need_root uninstall
local purge=0 yes=0 arg
for arg in "$@"; do
case "$arg" in
--purge) purge=1 ;;
--yes|-y) yes=1 ;;
*) die "unknown option: $arg" ;;
esac
done
if [[ "$yes" -ne 1 ]]; then
echo "Будут удалены панель, службы и настройки Virtuality."
[[ "$purge" -eq 1 ]] && echo "Режим --purge: также удаляются ВСЕ машины, диски, образы и резервные копии."
read -r -p "Продолжить? Введите YES: " answer
[[ "$answer" == "YES" ]] || { echo "Отменено."; exit 0; }
fi
local unit
for unit in virtuality-web.service virtuality-redirect.service virtuality-network.service virtuality-auto-update.timer virtuality-auto-update.service virtuality-maintenance.timer virtuality-maintenance.service virtuality-console-dashboard.service virtuality-firstboot.service; do
systemctl disable --now "$unit" >/dev/null 2>&1 || true
rm -f "${UNIT_DIR}/${unit}"
done
systemctl enable --now getty@tty1.service >/dev/null 2>&1 || true
systemctl daemon-reload
nft delete table ip virtuality >/dev/null 2>&1 || true
if command -v ufw >/dev/null 2>&1 && [[ -f "$STATE_FILE" ]]; then
while read -r old; do [[ -n "$old" ]] && ufw delete allow "${old}/tcp" >/dev/null 2>&1 || true; done < "$STATE_FILE"
fi
rm -rf /opt/virtuality /etc/virtuality /etc/logrotate.d/virtuality /etc/sysctl.d/98-virtuality-rpfilter.conf /etc/sysctl.d/99-virtuality-forward.conf
rm -f /usr/local/bin/vhealth /usr/local/bin/virtuality-console-dashboard /etc/issue.d/virtuality.issue
if [[ "$purge" -eq 1 ]]; then
if command -v virsh >/dev/null 2>&1; then
local name
while read -r name; do
[[ -n "$name" ]] || continue
virsh destroy "$name" >/dev/null 2>&1 || true
virsh undefine "$name" --remove-all-storage --nvram --managed-save --snapshots-metadata >/dev/null 2>&1 || true
done < <(virsh list --all --name 2>/dev/null)
virsh net-destroy virtuality-nat >/dev/null 2>&1 || true
virsh net-undefine virtuality-nat >/dev/null 2>&1 || true
fi
rm -rf "$DATA_DIR" "$LOG_DIR"
else
rm -rf "$CONFIG_DIR" "${DATA_DIR}/update" "${DATA_DIR}/tmp"
echo "Машины, образы и резервные копии сохранены в ${DATA_DIR}"
fi
rm -f /usr/local/bin/virtuality-ctl
echo "Virtuality удалена"
}
case "${1:-}" in
status) cmd_status ;;
url) panel_url; echo ;;
reconfigure) cmd_reconfigure ;;
set) shift; cmd_set "$@" ;;
passwd) shift; cmd_passwd "$@" ;;
timezone) shift; cmd_timezone "$@" ;;
power) shift; cmd_power "$@" ;;
backup-config) shift; cmd_backup_config "$@" ;;
restore-config) shift; cmd_restore_config "$@" ;;
rollback) cmd_rollback ;;
maintenance) cmd_maintenance ;;
bridge) shift; cmd_bridge "$@" ;;
bridge-confirm) cmd_bridge_confirm ;;
bridge-revert) cmd_bridge_revert ;;
storage-use) shift; cmd_storage_use "$@" ;;
support-bundle) shift; cmd_support_bundle "$@" ;;
uninstall) shift; cmd_uninstall "$@" ;;
-h|--help|help|"") usage ;;
*) usage >&2; die "неизвестная команда: $1" ;;
esac
-224
Просмотреть файл
@@ -1,224 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Virtuality Console Dashboard
# btop-like compact TUI for physical server console tty1
refresh_interval="${VIRTUALITY_DASHBOARD_INTERVAL:-2}"
has_cmd() { command -v "$1" >/dev/null 2>&1; }
ESC="\033"
RESET="${ESC}[0m"
BOLD="${ESC}[1m"
DIM="${ESC}[2m"
FG_CYAN="${ESC}[38;5;51m"
FG_BLUE="${ESC}[38;5;39m"
FG_GREEN="${ESC}[38;5;46m"
FG_YELLOW="${ESC}[38;5;226m"
FG_RED="${ESC}[38;5;196m"
FG_MAGENTA="${ESC}[38;5;201m"
FG_GRAY="${ESC}[38;5;245m"
FG_WHITE="${ESC}[38;5;255m"
BG_PANEL="${ESC}[48;5;235m"
BG_HEADER="${ESC}[48;5;24m"
move() { printf "${ESC}[%s;%sH" "$1" "$2"; }
clear_screen() { printf "${ESC}[2J${ESC}[H"; }
hide_cursor() { printf "${ESC}[?25l"; }
show_cursor() { printf "${ESC}[?25h${RESET}"; }
trap show_cursor EXIT
term_cols() { tput cols 2>/dev/null || echo 120; }
term_rows() { tput lines 2>/dev/null || echo 40; }
repeat_char() {
local char="$1" count="$2"
printf '%*s' "$count" '' | tr ' ' "$char"
}
strip_ansi() {
sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g'
}
print_fit() {
local text="$1" width="$2"
printf "%-${width}.${width}s" "$text"
}
box() {
local row="$1" col="$2" height="$3" width="$4" title="$5"
local inner=$((width - 2))
move "$row" "$col"; printf "${FG_BLUE}╭%s╮${RESET}" "$(repeat_char '─' "$inner")"
move "$row" $((col + 2)); printf "${BG_PANEL}${FG_CYAN}${BOLD} %s ${RESET}" "$title"
for ((i=1; i<height-1; i++)); do
move $((row+i)) "$col"; printf "${FG_BLUE}│${RESET}"
printf "${BG_PANEL}%s${RESET}" "$(repeat_char ' ' "$inner")"
printf "${FG_BLUE}│${RESET}"
done
move $((row+height-1)) "$col"; printf "${FG_BLUE}╰%s╯${RESET}" "$(repeat_char '─' "$inner")"
}
put() {
local row="$1" col="$2" width="$3" text="$4"
move "$row" "$col"
printf "${BG_PANEL}"
printf "%b" "$text" | cut -c 1-"$width"
printf "${RESET}"
}
bar() {
local percent="$1" width="$2"
local filled=$(( percent * width / 100 ))
local empty=$(( width - filled ))
local color="$FG_GREEN"
if (( percent >= 85 )); then color="$FG_RED"; elif (( percent >= 65 )); then color="$FG_YELLOW"; fi
printf "%b%s%b%s" "$color" "$(repeat_char '█' "$filled")" "$DIM" "$(repeat_char '░' "$empty")"
printf "%b" "$RESET"
}
service_state() {
local unit="$1"
if systemctl list-unit-files "$unit" >/dev/null 2>&1; then
systemctl is-active "$unit" 2>/dev/null || echo "inactive"
else
echo "missing"
fi
}
state_badge() {
local state="$1"
case "$state" in
active) printf "${FG_GREEN}● active${RESET}" ;;
inactive) printf "${FG_YELLOW}● inactive${RESET}" ;;
failed) printf "${FG_RED}● failed${RESET}" ;;
missing) printf "${FG_GRAY}● missing${RESET}" ;;
*) printf "${FG_YELLOW}● %s${RESET}" "$state" ;;
esac
}
get_cpu_usage() {
local a b idle_a total_a idle_b total_b diff_idle diff_total usage
read -r _ a b c idle_a rest < /proc/stat
total_a=$((a+b+c+idle_a))
sleep 0.15
read -r _ a b c idle_b rest < /proc/stat
total_b=$((a+b+c+idle_b))
diff_idle=$((idle_b-idle_a))
diff_total=$((total_b-total_a))
if (( diff_total <= 0 )); then echo 0; else echo $(( (100 * (diff_total - diff_idle)) / diff_total )); fi
}
get_mem_percent() {
free | awk '/Mem:/ {printf "%d", ($3/$2)*100}'
}
get_disk_percent() {
df / | awk 'NR==2 {gsub("%", "", $5); print $5}'
}
while true; do
cols="$(term_cols)"
rows="$(term_rows)"
[[ "$cols" -lt 100 ]] && cols=100
[[ "$rows" -lt 32 ]] && rows=32
cpu="$(get_cpu_usage)"
mem="$(get_mem_percent)"
disk="$(get_disk_percent)"
load="$(awk '{print $1" "$2" "$3}' /proc/loadavg)"
uptime_text="$(uptime -p 2>/dev/null | sed 's/up //' || true)"
host="$(hostname)"
now="$(date '+%Y-%m-%d %H:%M:%S')"
ip_main="$(hostname -I 2>/dev/null | awk '{print $1}')"
gateway="$(ip route | awk '/default/ {print $3" via "$5; exit}')"
clear_screen
hide_cursor
move 1 1
printf "${BG_HEADER}${FG_WHITE}${BOLD} %-20s ${FG_CYAN}%-30s ${FG_GRAY}%s ${RESET}" "VIRTUALITY" "$host" "$now"
printf "%s" "$(repeat_char ' ' $((cols-70 > 0 ? cols-70 : 1)))"
# Layout
left_w=48
right_w=$((cols - left_w - 5))
[[ "$right_w" -lt 48 ]] && right_w=48
box 3 2 10 "$left_w" "SYSTEM"
box 3 $((left_w + 4)) 10 "$right_w" "SERVICES"
box 14 2 8 "$left_w" "NETWORK"
box 14 $((left_w + 4)) 8 "$right_w" "VIRTUAL MACHINES"
box 23 2 8 "$left_w" "STORAGE"
box 23 $((left_w + 4)) 8 "$right_w" "LIBVIRT POOLS"
# System panel
put 5 4 $((left_w-4)) "${FG_GRAY}CPU ${FG_WHITE}${cpu}% $(bar "$cpu" 26)"
put 6 4 $((left_w-4)) "${FG_GRAY}RAM ${FG_WHITE}${mem}% $(bar "$mem" 26)"
put 7 4 $((left_w-4)) "${FG_GRAY}DSK ${FG_WHITE}${disk}% $(bar "$disk" 26)"
put 8 4 $((left_w-4)) "${FG_GRAY}Load:${RESET} ${FG_WHITE}${load}${RESET}"
put 9 4 $((left_w-4)) "${FG_GRAY}Uptime:${RESET} ${FG_WHITE}${uptime_text}${RESET}"
put 10 4 $((left_w-4)) "${FG_GRAY}Kernel:${RESET} ${FG_WHITE}$(uname -r)${RESET}"
# Services panel
svc_col=$((left_w + 6))
svc_w=$((right_w - 4))
put 5 "$svc_col" "$svc_w" "libvirtd.service $(state_badge "$(service_state libvirtd.service)")"
put 6 "$svc_col" "$svc_w" "virtlogd.service $(state_badge "$(service_state virtlogd.service)")"
put 7 "$svc_col" "$svc_w" "cockpit.socket $(state_badge "$(service_state cockpit.socket)")"
put 8 "$svc_col" "$svc_w" "libvirt-guests.service $(state_badge "$(service_state libvirt-guests.service)")"
put 10 "$svc_col" "$svc_w" "${FG_GRAY}Cockpit:${RESET} ${FG_WHITE}https://${ip_main}:9090${RESET}"
# Network panel
put 16 4 $((left_w-4)) "${FG_GRAY}Main IP:${RESET} ${FG_GREEN}${ip_main}${RESET}"
put 17 4 $((left_w-4)) "${FG_GRAY}Gateway:${RESET} ${FG_WHITE}${gateway}${RESET}"
net_lines="$(ip -br a 2>/dev/null | head -n 3)"
n=0
while IFS= read -r line; do
put $((18+n)) 4 $((left_w-4)) "${FG_WHITE}${line}${RESET}"
n=$((n+1))
done <<< "$net_lines"
# VM panel
vm_col=$((left_w + 6))
vm_lines="$(virsh list --all 2>/dev/null | tail -n +3 | sed '/^$/d' | head -n 4 || true)"
if [[ -z "$vm_lines" ]]; then
put 16 "$vm_col" "$svc_w" "${FG_GRAY}VM пока нет. Следующий этап — создать тестовую VM.${RESET}"
else
n=0
while IFS= read -r line; do
put $((16+n)) "$vm_col" "$svc_w" "${FG_WHITE}${line}${RESET}"
n=$((n+1))
done <<< "$vm_lines"
fi
# Storage panel
srow=25
for dir in /var/lib/virtuality/iso /var/lib/virtuality/images /var/lib/virtuality/backups /var/log/virtuality; do
if [[ -d "$dir" ]]; then
used="$(du -sh "$dir" 2>/dev/null | awk '{print $1}')"
put "$srow" 4 $((left_w-4)) "${FG_GRAY}${dir}:${RESET} ${FG_WHITE}${used}${RESET}"
else
put "$srow" 4 $((left_w-4)) "${FG_GRAY}${dir}:${RESET} ${FG_RED}missing${RESET}"
fi
srow=$((srow+1))
done
# Pools panel
pool_lines="$(virsh pool-list --all 2>/dev/null | tail -n +3 | sed '/^$/d' | head -n 4 || true)"
if [[ -z "$pool_lines" ]]; then
put 25 "$vm_col" "$svc_w" "${FG_GRAY}Storage pools unavailable${RESET}"
else
n=0
while IFS= read -r line; do
put $((25+n)) "$vm_col" "$svc_w" "${FG_WHITE}${line}${RESET}"
n=$((n+1))
done <<< "$pool_lines"
fi
move $((rows-2)) 2
printf "${FG_GRAY}Refresh:${RESET} ${FG_WHITE}${refresh_interval}s${RESET} ${FG_GRAY}|${RESET} ${FG_CYAN}Ctrl+Alt+F2${RESET} login ${FG_GRAY}|${RESET} ${FG_GREEN}bt${RESET}=btop ${FG_GRAY}|${RESET} stop: ${FG_YELLOW}sudo systemctl stop virtuality-console-dashboard${RESET}"
move $((rows-1)) 1
printf "${RESET}"
sleep "$refresh_interval"
done
+239
Просмотреть файл
@@ -0,0 +1,239 @@
#!/usr/bin/env bash
set -euo pipefail
# ==========================================================
# Virtuality first boot
# Runs once on a system installed from the Virtuality image.
# The web panel is installed first (offline, from bundled
# wheels) so the setup wizard in the browser can show the
# progress of everything that follows: network, KVM/libvirt,
# tools. Progress is written to config/setup.json, which the
# wizard polls. Every step is idempotent: on failure systemd
# restarts the script after a minute and it continues from
# the first unfinished step.
# ==========================================================
SOURCE_DIR="${VIRTUALITY_SOURCE_DIR:-/opt/virtuality/source}"
IMAGE_ENV="${VIRTUALITY_IMAGE_ENV:-/etc/virtuality/image.env}"
STATE_DIR="${VIRTUALITY_STATE_DIR:-/var/lib/virtuality}"
CONFIG_DIR="${STATE_DIR}/config"
STATE_FILE="${CONFIG_DIR}/setup.json"
DONE_FLAG="${STATE_DIR}/.firstboot-done"
STEP_FLAG_DIR="${STATE_DIR}/.firstboot-steps"
LOG_DIR="${VIRTUALITY_LOG_DIR:-/var/log/virtuality}"
LOG_FILE="${LOG_DIR}/firstboot.log"
APT_LOCK_CONF="${VIRTUALITY_APT_LOCK_CONF:-/etc/apt/apt.conf.d/90virtuality-lock-timeout}"
ISSUE_FILE="${VIRTUALITY_ISSUE_FILE:-/etc/issue.d/virtuality.issue}"
CTL="${VIRTUALITY_CTL:-/usr/local/bin/virtuality-ctl}"
NETWORK_WAIT_SECONDS="${VIRTUALITY_NETWORK_WAIT_SECONDS:-0}" # 0 = wait forever
STEP_IDS=(panel network virtualization tools finish)
declare -A STEP_TITLE=(
[panel]="Панель управления"
[network]="Подключение к интернету"
[virtualization]="KVM, QEMU и libvirt"
[tools]="Инструменты диагностики"
[finish]="Завершение"
)
declare -A STEP_STATUS=()
CURRENT_STEP=""
WATCHER_PID=""
mkdir -p "$LOG_DIR" "$STATE_DIR" "$CONFIG_DIR" "$STEP_FLAG_DIR" "$(dirname "$ISSUE_FILE")"
exec > >(tee -a "$LOG_FILE") 2>&1
now() { date '+%Y-%m-%d %H:%M:%S'; }
say() { echo "[$(now)] [virtuality-firstboot] $*"; }
# ---------------------------------------------------------------- progress for the wizard
json_escape() {
local text="$1"
text="${text//\\/\\\\}"
text="${text//\"/\\\"}"
text="${text//$'\n'/ }"
printf '%s' "$text"
}
write_state() {
local stage="$1" message="$2" id first=1
{
printf '{"stage": "%s", "message": "%s", "updated_at": "%s", "steps": [' "$stage" "$(json_escape "$message")" "$(now)"
for id in "${STEP_IDS[@]}"; do
(( first )) || printf ', '
first=0
printf '{"id": "%s", "title": "%s", "status": "%s"}' "$id" "${STEP_TITLE[$id]}" "${STEP_STATUS[$id]:-pending}"
done
printf ']}\n'
} > "${STATE_FILE}.tmp"
mv -f "${STATE_FILE}.tmp" "$STATE_FILE"
chmod 644 "$STATE_FILE"
}
step_done_before() { [[ -f "${STEP_FLAG_DIR}/$1" ]]; }
step_start() {
CURRENT_STEP="$1"
STEP_STATUS[$1]="running"
say "step ${1}: ${STEP_TITLE[$1]}"
write_state installing "$2"
}
step_finish() {
STEP_STATUS[$1]="done"
touch "${STEP_FLAG_DIR}/$1"
CURRENT_STEP=""
write_state installing "${2:-}"
}
# apt writes its progress to the installer logs; turn the last line into a friendly message.
watch_progress() {
local latest line pkg
while true; do
latest="$(ls -t "${LOG_DIR}"/install_*.log 2>/dev/null | head -n1 || true)"
if [[ -n "$latest" ]]; then
line="$(grep -aE '^(Get:[0-9]+ |Unpacking |Setting up |Preparing to unpack )' "$latest" 2>/dev/null | tail -n1 || true)"
pkg=""
case "$line" in
Get:*) pkg="$(printf '%s' "$line" | awk '{print $5}')" ; [[ -n "$pkg" ]] && write_state installing "Скачиваем ${pkg}…" ;;
"Unpacking "*|"Preparing to unpack "*) pkg="$(printf '%s' "$line" | sed -E 's/^(Preparing to unpack \.\.\.\/|Preparing to unpack |Unpacking )//; s/[ :].*//')"; [[ -n "$pkg" ]] && write_state installing "Распаковываем ${pkg}…" ;;
"Setting up "*) pkg="$(printf '%s' "$line" | sed -E 's/^Setting up //; s/[ :].*//')"; [[ -n "$pkg" ]] && write_state installing "Настраиваем ${pkg}…" ;;
esac
fi
sleep 3
done
}
watch_start() { watch_progress & WATCHER_PID=$!; }
watch_stop() { [[ -n "$WATCHER_PID" ]] && { kill "$WATCHER_PID" 2>/dev/null || true; wait "$WATCHER_PID" 2>/dev/null || true; }; WATCHER_PID=""; }
on_error() {
local code=$?
watch_stop
if [[ -n "$CURRENT_STEP" ]]; then
STEP_STATUS[$CURRENT_STEP]="error"
fi
write_state error "Шаг «${STEP_TITLE[${CURRENT_STEP:-finish}]}» завершился с ошибкой (код ${code}). Повторим автоматически через минуту; подробности ниже в журнале."
say "ERROR: step ${CURRENT_STEP:-?} failed with code ${code}"
exit "$code"
}
trap on_error ERR
# ---------------------------------------------------------------- environment
if [[ -f "$DONE_FLAG" ]]; then
say "already completed, nothing to do"
exit 0
fi
if [[ -f "$IMAGE_ENV" ]]; then
set -a
# shellcheck disable=SC1090
. "$IMAGE_ENV"
set +a
fi
first_human_user() {
getent passwd | awk -F: '$3 >= 1000 && $3 < 60000 && $7 !~ /(nologin|false)$/ {print $1; exit}'
}
AUTH_USER="${VIRTUALITY_USER:-$(first_human_user)}"
AUTH_USER="${AUTH_USER:-root}"
export VIRTUALITY_USER="$AUTH_USER"
export VIRTUALITY_AUTH_USER="$AUTH_USER"
export VIRTUALITY_WEB_PORT="${VIRTUALITY_WEB_PORT:-8088}"
export VIRTUALITY_AUTO_UPDATE="${VIRTUALITY_AUTO_UPDATE:-1}"
# Disk/RAM limits are reported in the panel; they must not block an image install.
export VIRTUALITY_SKIP_REQUIREMENTS="${VIRTUALITY_SKIP_REQUIREMENTS:-1}"
export VIRTUALITY_FIRSTBOOT=1
export DEBIAN_FRONTEND=noninteractive
export TERM="${TERM:-dumb}"
for id in "${STEP_IDS[@]}"; do
step_done_before "$id" && STEP_STATUS[$id]="done"
done
say "source: ${SOURCE_DIR}, panel user: ${AUTH_USER}, port: ${VIRTUALITY_WEB_PORT}"
[[ -f "${SOURCE_DIR}/install_virtuality_node.sh" ]] || { write_state error "Исходники Virtuality не найдены в ${SOURCE_DIR}"; say "ERROR: source not found"; exit 1; }
cd "$SOURCE_DIR"
# unattended-upgrades often holds the dpkg lock right after the first boot.
printf 'DPkg::Lock::Timeout "900";\nDPkg::Options { "--force-confdef"; "--force-confold"; };\n' > "$APT_LOCK_CONF"
cat > "$ISSUE_FILE" <<'EOF'
Virtuality настраивается. Ход установки: sudo journalctl -fu virtuality-firstboot
EOF
network_ready() {
[[ -n "${VIRTUALITY_NETWORK_CHECK:-}" ]] && { "$VIRTUALITY_NETWORK_CHECK"; return; }
ip route show default 2>/dev/null | grep -q . && getent hosts archive.ubuntu.com >/dev/null 2>&1
}
wait_for_network() {
local started elapsed
started="$(date +%s)"
until network_ready; do
elapsed=$(( $(date +%s) - started ))
if (( NETWORK_WAIT_SECONDS > 0 && elapsed >= NETWORK_WAIT_SECONDS )); then
say "ERROR: no network with DNS after ${elapsed}s"
return 1
fi
if (( elapsed >= 20 )); then
write_state waiting-network "Нет доступа в интернет. Подключите кабель или настройте сеть — установка продолжится сама."
fi
sleep 5
done
write_state installing "Интернет доступен"
}
# ---------------------------------------------------------------- 1. panel (offline)
if ! step_done_before panel; then
step_start panel "Устанавливаем панель управления…"
if ! VIRTUALITY_PANEL_ONLY=1 bash ./scripts/install_web_panel.sh; then
# No bundled wheels (image built with --no-wheels): install online instead.
say "offline panel install failed, retrying with network"
wait_for_network
bash ./scripts/install_web_panel.sh
fi
step_finish panel "Панель управления запущена"
fi
# ---------------------------------------------------------------- 2. network
if ! step_done_before network; then
step_start network "Проверяем подключение к интернету…"
wait_for_network
step_finish network "Интернет доступен"
fi
# ---------------------------------------------------------------- 3. virtualization
if ! step_done_before virtualization; then
step_start virtualization "Устанавливаем пакеты виртуализации (это самый долгий шаг)…"
watch_start
bash ./install_virtuality_node.sh
watch_stop
step_finish virtualization "Виртуализация установлена"
fi
# ---------------------------------------------------------------- 4. tools
if ! step_done_before tools; then
step_start tools "Устанавливаем инструменты диагностики…"
bash ./scripts/install_healthcheck_command.sh
step_finish tools "Инструменты установлены"
fi
# ---------------------------------------------------------------- 5. finish
step_start finish "Завершаем настройку…"
# The panel was installed before libvirt: refresh units/firewall and restart it with everything in place.
"$CTL" reconfigure
PANEL_URL="$("$CTL" url 2>/dev/null || echo "http://$(hostname -I | awk '{print $1}'):${VIRTUALITY_WEB_PORT}")"
cat > "$ISSUE_FILE" <<EOF
Virtuality: откройте панель управления в браузере — ${PANEL_URL}
Логин: ${AUTH_USER} (пароль как у пользователя Linux)
EOF
touch "$DONE_FLAG"
STEP_STATUS[finish]="done"
CURRENT_STEP=""
write_state done "Сервер готов"
systemctl disable virtuality-firstboot.service >/dev/null 2>&1 || true
rm -f "$APT_LOCK_CONF"
say "completed. Panel: ${PANEL_URL} login: ${AUTH_USER}"
-72
Просмотреть файл
@@ -1,72 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="/opt/virtuality"
DEFAULT_BRANCH="main"
if [[ "$EUID" -ne 0 ]]; then
echo "Ошибка: запусти от root: sudo bash setup_github_sync.sh"
exit 1
fi
apt update
apt install -y git openssh-client
if [[ ! -d "$PROJECT_DIR" ]]; then
echo "Ошибка: папка проекта не найдена: $PROJECT_DIR"
echo "Сначала запусти install_virtuality_node.sh"
exit 1
fi
cd "$PROJECT_DIR"
read -rp "GitHub repo URL [git@github.com:viktor138irk/virtuality.git]: " GITHUB_REPO
GITHUB_REPO="${GITHUB_REPO:-git@github.com:viktor138irk/virtuality.git}"
read -rp "Git author name [Virtuality Node]: " GIT_NAME
GIT_NAME="${GIT_NAME:-Virtuality Node}"
read -rp "Git author email [virtuality@local]: " GIT_EMAIL
GIT_EMAIL="${GIT_EMAIL:-virtuality@local}"
git config --global user.name "$GIT_NAME"
git config --global user.email "$GIT_EMAIL"
cat > "$PROJECT_DIR/git_sync.sh" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="/opt/virtuality"
cd "$PROJECT_DIR"
MESSAGE="${1:-Auto sync $(date '+%Y-%m-%d %H:%M:%S')}"
git add .
git commit -m "$MESSAGE" || {
echo "Нет изменений для commit."
exit 0
}
git push origin main
EOF
chmod +x "$PROJECT_DIR/git_sync.sh"
if [[ ! -d "$PROJECT_DIR/.git" ]]; then
git init
fi
git branch -M "$DEFAULT_BRANCH"
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "$GITHUB_REPO"
else
git remote add origin "$GITHUB_REPO"
fi
git add .
git commit -m "Initial Virtuality node base" || true
git push -u origin "$DEFAULT_BRANCH"
echo "GitHub sync готов."
echo "Ручная синхронизация: sudo /opt/virtuality/git_sync.sh \"Описание изменений\""
+180 -22
Просмотреть файл
@@ -1,38 +1,196 @@
import os
import sys
from pathlib import Path
import pytest
WEB_DIR = Path(__file__).resolve().parent.parent / "web"
ROOT = Path(__file__).resolve().parents[1]
WEB_DIR = ROOT / "web"
sys.path.insert(0, str(WEB_DIR))
os.environ.setdefault("VIRTUALITY_AUTH_USER", "tester")
os.environ.setdefault("VIRTUALITY_SESSION_SECRET", "test-secret-" + "0" * 32)
@pytest.fixture(autouse=True)
def isolate_dirs(tmp_path, monkeypatch):
"""Уводим все записи на диск в tmp, чтобы тесты работали без root и не трогали систему."""
import app
import backup_core
import cloud_images
DOMINFO = """Id: 1
Name: web01
UUID: 6c1b3c1e-0000-0000-0000-000000000001
OS Type: hvm
State: running
CPU(s): 2
Max memory: 2097152 KiB
Used memory: 2097152 KiB
Persistent: yes
Autostart: enable
"""
monkeypatch.setattr(app, "OPERATIONS_DIR", tmp_path / "ops")
monkeypatch.setattr(app, "ISO_DIR", tmp_path / "iso")
monkeypatch.setattr(app, "IMAGES_DIR", tmp_path / "images")
monkeypatch.setattr(app, "DISK_IMAGES_DIR", tmp_path / "disk-images")
monkeypatch.setattr(app, "VM_TEMPLATES_FILE", tmp_path / "config" / "vm_templates.json")
monkeypatch.setattr(backup_core, "BACKUPS_DIR", tmp_path / "backups")
monkeypatch.setattr(backup_core, "SCHEDULE_FILE", tmp_path / "config" / "backup_schedule.json")
monkeypatch.setattr(backup_core, "LOG_FILE", tmp_path / "log" / "backup.log")
monkeypatch.setattr(cloud_images, "CLOUD_IMAGES_DIR", tmp_path / "cloud-images")
yield
DUMPXML = """<domain type='kvm'>
<name>web01</name>
<memory unit='KiB'>2097152</memory>
<vcpu placement='static'>2</vcpu>
<os><type arch='x86_64' machine='pc-q35'>hvm</type><boot dev='hd'/></os>
<devices>
<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk>
</devices>
</domain>
"""
FIXTURES = ROOT / "tests" / "fixtures"
DOMBLKLIST_DETAILS = (FIXTURES / "domblklist-details.txt").read_text()
DUMPXML_MIGRATABLE = (FIXTURES / "dumpxml-web01.xml").read_text()
QEMU_IMG_INFO = (FIXTURES / "qemu-img-info.json").read_text()
# Снимки web01: имя → (состояние, описание); список берётся из fixtures/snapshot-list.txt.
SNAPSHOTS = {"before-upd": ("shutoff", "Перед обновлением ядра"), "clean": ("running", "Чистая система после установки")}
# Заметки машин для `virsh desc`: имя → (название, описание).
NOTES = {"web01": ("Сайт компании", "Nginx + база. Бэкап по пятницам.")}
def fake_result(stdout: str = "", ok: bool = True, stderr: str = "") -> dict:
return {"ok": ok, "code": 0 if ok else 1, "stdout": stdout, "stderr": stderr, "cmd": ""}
def option(cmd, name):
"""Значение опции virsh вида --name VALUE (или '')."""
return cmd[cmd.index(name) + 1] if name in cmd and cmd.index(name) + 1 < len(cmd) else ""
def fake_snapshot_xml(snap: str) -> str:
state, description = SNAPSHOTS.get(snap, ("shutoff", ""))
memory = "<memory snapshot='internal'/>" if state == "running" else "<memory snapshot='no'/>"
return f"<domainsnapshot><name>{snap}</name><description>{description}</description><state>{state}</state>{memory}<creationTime>1758794400</creationTime></domainsnapshot>"
def fake_run_cmd(cmd, timeout=12, **_kwargs):
joined = " ".join(cmd)
if cmd[:2] == ["virsh", "snapshot-list"]:
return fake_result((FIXTURES / "snapshot-list.txt").read_text())
if cmd[:2] == ["virsh", "snapshot-current"]:
return fake_result("clean")
if cmd[:2] == ["virsh", "snapshot-dumpxml"]:
return fake_result(fake_snapshot_xml(option(cmd, "--snapshotname")))
if cmd[:2] in (["virsh", "snapshot-create-as"], ["virsh", "snapshot-revert"], ["virsh", "snapshot-delete"]):
return fake_result("Domain snapshot %s created" % option(cmd, "--name"))
if cmd[:2] == ["virsh", "list"]:
if "--title" in cmd:
return fake_result((FIXTURES / "list-title.txt").read_text())
if "--name" in cmd:
return fake_result("web01\ndb01\n" if "--all" in cmd else "web01\n")
return fake_result(" Id Name State\n-----------------------\n 1 web01 running\n - db01 shut off")
if cmd[:2] == ["virsh", "dominfo"]:
return fake_result(DOMINFO) if cmd[-1] in ("web01", "db01") else fake_result(ok=False, stderr="failed to get domain")
if cmd[:2] == ["virsh", "dumpxml"]:
return fake_result(DUMPXML_MIGRATABLE if "--migratable" in cmd else DUMPXML)
if cmd[:2] == ["virsh", "domstate"]:
return fake_result("running")
if cmd[:2] == ["virsh", "domblklist"]:
import core # диск лежит в подменённом data_dirs хранилище — иначе панель откажется его увеличивать
return fake_result((FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images", str(core.IMAGES_DIR)))
if cmd[:2] == ["virsh", "domstats"]:
return fake_result((FIXTURES / "domstats.txt").read_text())
if cmd[:1] == ["virsh"] and "desc" in cmd[:3]:
if any(arg.startswith("--new-desc") for arg in cmd):
return fake_result("Domain description updated successfully")
vm = cmd[cmd.index("desc") + 1]
title, description = NOTES.get(vm, ("", ""))
return fake_result(title if "--title" in cmd else description)
if cmd[:2] == ["qemu-img", "info"]:
return fake_result(QEMU_IMG_INFO)
if cmd[:2] == ["virsh", "domifaddr"]:
return fake_result(" Name MAC address Protocol Address\n vnet0 52:54:00:aa:bb:cc ipv4 192.168.100.51/24")
if cmd[:2] == ["virsh", "domiflist"]:
return fake_result(" Interface Type Source Model MAC\n vnet0 network virtuality-nat virtio 52:54:00:aa:bb:cc")
if cmd[:2] == ["virsh", "vncdisplay"]:
return fake_result(":0")
if cmd[:2] == ["virsh", "pool-list"]:
return fake_result(" Name State Autostart\n---\n virtuality-images active yes")
if cmd[:2] == ["virsh", "pool-refresh"]:
return fake_result(f"Pool {cmd[-1]} refreshed")
if cmd[:2] == ["virsh", "net-list"]:
return fake_result(" Name State Autostart Persistent\n---\n virtuality-nat active yes yes")
if cmd[:2] == ["systemctl", "is-active"]:
return fake_result("active")
if cmd[:1] == ["hostname"]:
return fake_result("192.168.1.10" if "-I" in cmd else "virtuality-test")
if cmd[:3] == ["ip", "route", "show"]:
return fake_result("default via 192.168.1.1 dev eth0")
if cmd[:1] in (["uptime"], ["uname"]):
return fake_result("up 1 hour" if cmd[0] == "uptime" else "6.8.0")
if "nft" in joined or "iptables" in joined or "ufw" in joined or "sysctl" in joined:
return fake_result()
return fake_result()
def feature_modules():
"""Загруженные модули web/features/* — им тоже подменяем run_cmd."""
return [module for name, module in sys.modules.items() if name.startswith("features.") and hasattr(module, "run_cmd")]
@pytest.fixture()
def auth_client():
"""TestClient с валидной сессией и CSRF-токеном."""
def data_dirs(tmp_path, monkeypatch):
import app
import core
import host_profile
import network_core
import update_core
dirs = {
"iso": tmp_path / "iso",
"images": tmp_path / "images",
"disk_images": tmp_path / "disk-images",
"backups": tmp_path / "backups",
"operations": tmp_path / "operations",
"network": tmp_path / "network",
"config": tmp_path / "config",
"nft": tmp_path / "nft",
"update": tmp_path / "update",
}
for path in dirs.values():
path.mkdir()
for module in (app, core):
monkeypatch.setattr(module, "ISO_DIR", dirs["iso"])
monkeypatch.setattr(module, "IMAGES_DIR", dirs["images"])
monkeypatch.setattr(module, "DISK_IMAGES_DIR", dirs["disk_images"])
monkeypatch.setattr(module, "OPERATIONS_DIR", dirs["operations"])
monkeypatch.setattr(core, "BACKUPS_DIR", dirs["backups"])
monkeypatch.setattr(core, "CONFIG_DIR", dirs["config"])
monkeypatch.setattr(core, "STORAGE_DIR", tmp_path)
monkeypatch.setattr(network_core, "CONFIG_DIR", dirs["config"])
monkeypatch.setattr(network_core, "NETWORK_DIR", dirs["network"])
monkeypatch.setattr(network_core, "NFT_DIR", dirs["nft"])
monkeypatch.setattr(network_core, "PORT_FORWARDS_FILE", dirs["network"] / "port_forwards.json")
monkeypatch.setattr(network_core, "UFW_STATE_FILE", dirs["network"] / "ufw_rules.json")
monkeypatch.setattr(network_core, "NAT_XML_FILE", dirs["network"] / "virtuality-nat.xml")
monkeypatch.setattr(network_core, "NFT_FILE", dirs["nft"] / "virtuality.nft")
monkeypatch.setattr(network_core, "enable_ip_forward", lambda: None)
monkeypatch.setattr(network_core, "disable_rp_filter", lambda: None)
monkeypatch.setattr(host_profile, "PROFILE_FILE", dirs["config"] / "host_profile.json")
monkeypatch.setattr(update_core, "STATE_DIR", dirs["update"])
monkeypatch.setattr(update_core, "STATE_FILE", dirs["update"] / "state.json")
monkeypatch.setattr(update_core, "LOG_FILE", dirs["update"] / "update.log")
monkeypatch.setattr(update_core, "SOURCE_DIR", ROOT)
for module in (app, core, network_core, host_profile, *feature_modules()):
monkeypatch.setattr(module, "run_cmd", fake_run_cmd)
return dirs
@pytest.fixture()
def client(data_dirs):
from fastapi.testclient import TestClient
import app
client = TestClient(app.app)
token = app.serializer.dumps({"user": app.AUTH_USER, "csrf": "test-csrf-token"})
client.cookies.set("virtuality_session", token)
app.login_throttle = app.auth.LoginThrottle()
return TestClient(app.app, base_url="http://panel.test")
@pytest.fixture()
def logged_in(client, monkeypatch):
import app
monkeypatch.setattr(app.auth, "verify_password", lambda user, password: password == "correct")
response = client.post("/login", data={"username": "tester", "password": "correct"}, follow_redirects=False)
assert response.status_code == 303
return client
+221
Просмотреть файл
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Run the panel locally with fake virsh/systemctl for UI work.
python3 tests/dev_server.py [port] # login: tester / any password
"""
import json
import os
import sys
import tempfile
import time
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import conftest # noqa: E402 (sets sys.path and env)
import app # noqa: E402
import core # noqa: E402
import host_profile # noqa: E402
import network_core # noqa: E402
import update_core # noqa: E402
# Машины демо-сервера: имя → состояние virsh (web01 работает, db01 выключена, win11 на паузе).
DEMO_VMS = {"web01": "running", "db01": "shut off", "win11": "paused"}
DEMO_NOTES = {"web01": ("Сайт компании", "Nginx + база. Бэкап по пятницам.\nЗа сервер отвечает Иван."), "win11": ("Бухгалтерия 1С", "")}
DEMO_DOMSTATS = """Domain: 'web01'
cpu.time={cpu_web}
balloon.current=2097152
balloon.maximum=2097152
balloon.rss=1534208
vcpu.current=2
vcpu.maximum=2
Domain: 'win11'
cpu.time={cpu_win}
balloon.current=4194304
balloon.maximum=4194304
balloon.rss=3102720
vcpu.current=4
vcpu.maximum=4
"""
_cpu_clock = {"web": 48_123_456_789, "win": 90_000_000_000, "at": time.monotonic()}
def demo_vm_run_cmd(cmd, timeout=12, **kwargs):
"""Три машины в разных состояниях и живая нагрузка; остальное — conftest.fake_run_cmd."""
if cmd[:2] == ["virsh", "list"]:
if "--title" in cmd:
rows = "\n".join(f" {'1' if state == 'running' else '2' if state == 'paused' else '-':<4} {name:<7} {state:<10} {DEMO_NOTES.get(name, ('', ''))[0]}" for name, state in DEMO_VMS.items())
return conftest.fake_result(" Id Name State Title\n" + "-" * 44 + "\n" + rows + "\n")
if "--name" in cmd:
names = [name for name, state in DEMO_VMS.items() if "--all" in cmd or state != "shut off"]
return conftest.fake_result("\n".join(names) + "\n")
rows = "\n".join(f" {'1' if state == 'running' else '2' if state == 'paused' else '-':<4} {name:<7} {state}" for name, state in DEMO_VMS.items())
return conftest.fake_result(" Id Name State\n" + "-" * 25 + "\n" + rows)
if cmd[:2] in (["virsh", "dominfo"], ["virsh", "domstate"]):
name = cmd[-1]
if name not in DEMO_VMS:
return conftest.fake_result(ok=False, stderr=f"error: failed to get domain '{name}'")
if cmd[1] == "domstate":
return conftest.fake_result(DEMO_VMS[name])
info = conftest.DOMINFO.replace("web01", name).replace("State: running", f"State: {DEMO_VMS[name]}")
if name == "db01":
info += "Managed save: yes\n"
return conftest.fake_result(info)
if cmd[:2] == ["virsh", "domstats"]:
# Процессорное время растёт вместе с реальным: web01 нагружена на ~37 % (2 ядра), win11 на ~8 % (4 ядра).
now = time.monotonic()
elapsed_ns = (now - _cpu_clock["at"]) * 1e9
_cpu_clock.update(web=_cpu_clock["web"] + int(elapsed_ns * 2 * 0.37), win=_cpu_clock["win"] + int(elapsed_ns * 4 * 0.08), at=now)
return conftest.fake_result(DEMO_DOMSTATS.format(cpu_web=_cpu_clock["web"], cpu_win=_cpu_clock["win"]))
if cmd[:2] == ["virsh", "domblklist"]:
return conftest.fake_result((conftest.FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images/web01", str(core.IMAGES_DIR / cmd[2])))
if cmd[:1] == ["virsh"] and "desc" in cmd[:3] and not any(arg.startswith("--new-desc") for arg in cmd):
title, description = DEMO_NOTES.get(cmd[cmd.index("desc") + 1], ("", ""))
return conftest.fake_result(title if "--title" in cmd else description)
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
# Снимки для демо: имя, дата, состояние, описание.
DEMO_SNAPSHOTS = [
("snap-20260925-1012", "2026-09-25 10:12:03 +0000", "shutoff", "Перед обновлением ядра"),
("snap-20260924-0900", "2026-09-24 09:00:41 +0000", "running", "Настроен nginx и сертификаты"),
("snap-20260918-1830", "2026-09-18 18:30:12 +0000", "running", "Чистая система после установки"),
("clean", "2026-09-10 12:05:00 +0000", "shutoff", ""),
]
def demo_run_cmd(cmd, timeout=12, **kwargs):
"""Как conftest.fake_run_cmd, но со списком снимков побогаче."""
if cmd[:2] == ["virsh", "snapshot-list"]:
rows = "\n".join(f" {name:<20} {when} {state}" for name, when, state, _ in DEMO_SNAPSHOTS)
return conftest.fake_result(" Name Creation Time State\n" + "-" * 62 + "\n" + rows)
if cmd[:2] == ["virsh", "snapshot-current"]:
return conftest.fake_result("snap-20260924-0900")
if cmd[:2] == ["virsh", "snapshot-create-as"] and "uefi" in conftest.option(cmd, "--description").lower():
# Описание со словом «uefi» показывает, как выглядит ошибка libvirt.
return conftest.fake_result(ok=False, stderr="error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
if cmd[:2] == ["virsh", "snapshot-dumpxml"]:
snap = conftest.option(cmd, "--snapshotname")
state, description = next(((s, d) for n, _, s, d in DEMO_SNAPSHOTS if n == snap), ("shutoff", ""))
memory = "internal" if state == "running" else "no"
return conftest.fake_result(f"<domainsnapshot><name>{snap}</name><description>{description}</description><state>{state}</state><memory snapshot='{memory}'/></domainsnapshot>")
return demo_vm_run_cmd(cmd, timeout, **kwargs)
def seed_backups(backups_dir: Path) -> None:
"""Несколько копий, как после пары недель работы: web01 — три, db01 — одна."""
vm_xml = conftest.DUMPXML_MIGRATABLE
demo = [
("web01", "20260924-0300", 2_684_354_560, "", [("vda", 6_443_499_520)]),
("web01", "20260917-0300", 2_598_000_000, "", [("vda", 6_300_000_000)]),
("web01", "20260912-1842", 2_412_000_000, "Перед обновлением сайта", [("vda", 6_100_000_000)]),
("db01", "20260921-2215", 9_126_805_504, "После переноса базы", [("vda", 12_884_901_888), ("vdb", 8_589_934_592)]),
]
for vm, backup_id, size, note, disks in demo:
path = backups_dir / vm / backup_id
path.mkdir(parents=True, exist_ok=True)
(path / "vm.xml").write_text(vm_xml.replace("web01", vm))
for target, _ in disks:
with (path / f"{target}.qcow2").open("wb") as handle:
handle.truncate(1024 * 1024)
meta = {"vm": vm, "created_at": f"{backup_id[:4]}-{backup_id[4:6]}-{backup_id[6:8]} {backup_id[9:11]}:{backup_id[11:]}:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": s} for t, s in disks], "size_bytes": size, "version": "0.11.0", "note": note}
(path / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2))
def seed(root: Path) -> None:
dirs = {name: root / name for name in ("iso", "images", "disk-images", "backups", "operations", "network", "config", "nft", "update")}
for path in dirs.values():
path.mkdir(parents=True, exist_ok=True)
app.ISO_DIR, app.IMAGES_DIR, app.DISK_IMAGES_DIR, app.OPERATIONS_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"]
core.ISO_DIR, core.IMAGES_DIR, core.DISK_IMAGES_DIR, core.OPERATIONS_DIR, core.STORAGE_DIR = dirs["iso"], dirs["images"], dirs["disk-images"], dirs["operations"], root
core.BACKUPS_DIR, core.CONFIG_DIR = dirs["backups"], dirs["config"]
seed_backups(dirs["backups"])
for name in DEMO_VMS: # диски машин лежат в хранилище, иначе форма увеличения не появится
with (dirs["images"] / f"{name}.qcow2").open("wb") as handle:
handle.truncate(4 * 1024 * 1024)
network_core.CONFIG_DIR, network_core.NETWORK_DIR, network_core.NFT_DIR = dirs["config"], dirs["network"], dirs["nft"]
network_core.PORT_FORWARDS_FILE = dirs["network"] / "port_forwards.json"
network_core.UFW_STATE_FILE = dirs["network"] / "ufw_rules.json"
network_core.NAT_XML_FILE = dirs["network"] / "virtuality-nat.xml"
network_core.NFT_FILE = dirs["nft"] / "virtuality.nft"
network_core.enable_ip_forward = lambda: None
network_core.disable_rp_filter = lambda: None
host_profile.PROFILE_FILE = dirs["config"] / "host_profile.json"
update_core.STATE_DIR, update_core.STATE_FILE, update_core.LOG_FILE = dirs["update"], dirs["update"] / "state.json", dirs["update"] / "update.log"
update_core.SOURCE_DIR = Path(__file__).resolve().parents[1]
for module in (app, core, network_core, host_profile, *conftest.feature_modules()):
module.run_cmd = demo_run_cmd
app.auth.verify_password = lambda user, password: True
import nodectl
nodectl.CONFIG_DIR = dirs["config"]
nodectl.SETUP_STATE = dirs["config"] / "setup.json"
nodectl.SETUP_DONE = dirs["config"] / "setup_done"
nodectl.WIZARD_FILE = dirs["config"] / "wizard.json"
stage = os.environ.get("VIRTUALITY_DEV_SETUP", "")
if stage:
# VIRTUALITY_DEV_SETUP=installing|done shows the setup wizard in the demo; finished = wizard completed (Settings page).
steps = [("panel", "Панель управления", "done"), ("network-wait", "Подключение к интернету", "done"), ("virtualization", "KVM, QEMU и libvirt", "running" if stage == "installing" else "done"), ("tools", "Инструменты диагностики", "pending" if stage == "installing" else "done"), ("finish", "Завершение", "pending" if stage == "installing" else "done")]
nodectl.SETUP_STATE.write_text(json.dumps({"stage": "installing" if stage == "installing" else "done", "message": "Устанавливаем qemu-system-x86 (14 из 61)…", "steps": [{"id": i, "title": t, "status": s} for i, t, s in steps]}))
nodectl.FIRSTBOOT_LOG = dirs["config"] / "firstboot.log"
nodectl.FIRSTBOOT_LOG.write_text("[2026-09-25 14:02:11] [virtuality-firstboot] step 1/4: web panel\n[2026-09-25 14:02:40] [virtuality-firstboot] step 2/4: virtualization node (KVM, libvirt)\nGet:14 http://archive.ubuntu.com/ubuntu resolute/main amd64 qemu-system-x86 amd64 1:10.2.1+ds-1ubuntu3 [9 812 kB]\n")
if not nodectl.ctl_available():
nodectl.spare_disks = lambda: [{"path": "/dev/sdb", "name": "sdb", "size": 2 * 1024 ** 4, "model": "WD Red 2TB", "tran": "sata"}]
nodectl.network_facts = lambda: {"interface": "enp3s0", "gateway": "192.168.1.1", "address": "192.168.1.10", "prefix": 24, "mac": "aa:bb:cc:dd:ee:ff", "wireless": False, "private": True, "is_vps": False, "virt": "none", "bridge_present": False, "on_bridge": False, "recommended": "bridge", "revert_armed": False, "bridge_possible": True}
nodectl.hardware_summary = lambda: {"hostname": "home-server", "cpu_model": "Intel Core i5-12400", "cpu_count": 6, "mem_total": 32 * 1024 ** 3, "disk_total": 480 * 1024 ** 3, "disk_free": 401 * 1024 ** 3, "kvm": True}
nodectl.set_timezone = lambda tz: (True, tz)
nodectl.timezones = lambda: {"current": "Europe/Moscow", "popular": nodectl.POPULAR_TIMEZONES, "all": nodectl.POPULAR_TIMEZONES}
if stage == "finished":
nodectl.mark_wizard_done()
nodectl.NODE_ENV = dirs["config"] / "web.env"
nodectl.NODE_ENV.write_text("VIRTUALITY_WEB_PORT=8088\nVIRTUALITY_TLS=1\nVIRTUALITY_AUTO_UPDATE=1\nVIRTUALITY_UPDATE_CHANNEL=stable\n")
for name, size in (("ubuntu-26.04-live-server-amd64.iso", 3), ("debian-13.7.0-amd64-netinst.iso", 1)):
with (dirs["iso"] / name).open("wb") as handle:
handle.truncate(size * 1024 * 1024)
with (dirs["disk-images"] / "debian-13-genericcloud-amd64.qcow2").open("wb") as handle:
handle.truncate(2 * 1024 * 1024)
network_core.PORT_FORWARDS_FILE.write_text(json.dumps([
{"id": "f1", "vm_name": "web01", "guest_ip": "192.168.100.51", "external_port_start": 2222, "external_port_end": 2222, "guest_port_start": 22, "guest_port_end": 22, "protocol": "tcp", "note": "SSH"},
{"id": "f2", "vm_name": "web01", "guest_ip": "192.168.100.51", "external_port_start": 443, "external_port_end": 443, "guest_port_start": 443, "guest_port_end": 443, "protocol": "tcp", "note": "HTTPS"},
]))
for status, title, progress in (("success", "Создание VM web01", 100), ("running", "Конвертация debian-13.img", 64), ("error", "Создание VM test", 100)):
op_id = str(uuid.uuid4())
app.write_operation({"id": op_id, "type": "vm_create", "title": title, "status": status, "progress": progress, "message": "Готово" if status == "success" else "qemu-img: 64%" if status == "running" else "virt-install завершился с ошибкой: 1", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester"})
app.append_operation_log(op_id, "virt-install --name web01 --memory 2048 ...")
# Идущий снимок машины db01 — видно на /vm/db01/snapshots.
snap_op = core.new_operation("snapshot_create", "Снимок машины db01", vm_name="db01", snapshot="snap-20260925-1500", vm_running=True)
core.update_operation(snap_op, status="running", progress=15, message="Сохраняем состояние машины и памяти — она на несколько секунд замрёт…")
op_id = str(uuid.uuid4())
app.write_operation({"id": op_id, "type": "backup", "title": "Резервная копия web01", "status": "success", "progress": 100, "message": "Резервная копия создана: 2.5 ГБ", "created_at": app.utc_now(), "updated_at": app.utc_now(), "created_by": "tester", "vm_name": "web01", "backup_id": "20260924-0300"})
for line in ("Машина работает — отправляем команду выключения.", "Машина выключена.", "Настройки машины сохранены в vm.xml.", "Копируем диск vda (1 из 1): 100%", "Машина запущена снова."):
app.append_operation_log(op_id, line)
# Идущие скачивания — видны на /iso и /disk-images (в каталоге Ubuntu 26.04 помечена «Скачивается»).
for kind, url, catalog_id, title, progress, message, total, done in (
("iso", "https://software-download.microsoft.com/download/pr/windows-server-2025-eval-x64.iso", None, "Скачивание windows-server-2025-eval-x64.iso", 37, "1,9 ГБ из 5,2 ГБ · 24 МБ/с · осталось 2 мин", 5_583_457_280, 2_065_879_193),
("disk", "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img", "ubuntu-26.04", "Скачивание Ubuntu Server 26.04 LTS", 62, "412 МБ из 663 МБ · 31 МБ/с · осталось 8 с", 695_205_888, 431_027_650),
):
filename = url.rsplit("/", 1)[-1]
target = (dirs["iso"] if kind == "iso" else dirs["disk-images"]) / filename
op = core.new_operation("download", title, download_kind=kind, url=url, filename=filename, target_path=str(target), page="/iso" if kind == "iso" else "/disk-images", catalog_id=catalog_id, total_bytes=total, downloaded_bytes=done, cancel_requested=False)
core.update_operation(op, status="running", progress=progress, message=message, started_at=core.utc_now())
core.append_operation_log(op["id"], f"Ссылка: {url}")
# Завершённое клонирование web01 → web01-copy: видно на обзоре и на странице /operations/{id}.
clone_op = core.new_operation("vm_clone", "Клонирование web01 → web01-copy", vm_name="web01-copy", source_vm="web01", targets=[str(dirs["images"] / "web01-copy.qcow2")], expected_bytes=6443499520)
core.append_operation_log(clone_op["id"], "virt-clone --original web01 --name web01-copy --file /var/lib/virtuality/images/web01-copy.qcow2")
core.append_operation_log(clone_op["id"], "Allocating 'web01-copy.qcow2' | 6.0 GB 00:01:12")
core.update_operation(clone_op, cmd="virt-clone --original web01 --name web01-copy --file " + str(dirs["images"] / "web01-copy.qcow2"))
core.finish_operation(clone_op, True, "Машина web01-copy создана — это копия web01. Запустите её и смените имя компьютера внутри системы.")
if __name__ == "__main__":
import uvicorn
seed(Path(tempfile.mkdtemp(prefix="virtuality-dev-")))
# The demo operations are seeded as "running": keep them instead of closing them as orphaned on startup.
app.app.router.on_startup.clear()
uvicorn.run(app.app, host="127.0.0.1", port=int(sys.argv[1]) if len(sys.argv) > 1 else 8765, log_level="warning")
+3
Просмотреть файл
@@ -0,0 +1,3 @@
5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51 *resolute-server-cloudimg-amd64.img
f69162950f235e3cdbbad33f1f912d1a504be90d8a37d002c735d6f3e3882265 *resolute-server-cloudimg-arm64.img
e0361263a9f568c21a59e75c3b7810bc5867866e5bd48f499f8b2e6ab723f059 *resolute-server-cloudimg-amd64.vmdk
+3
Просмотреть файл
@@ -0,0 +1,3 @@
# generated by debian cloud team
1412d62c2d55ae2432df6d22c36b9edf4512a59a43bb4eadb1bd3a5ef201154a3052d83ffae704d7079b50388e61992013348e4bb4dee45b3c2a266e2b296135 debian-13-genericcloud-amd64.qcow2
27c03aab0b5ee1f5918bfaa94821d2a1500fc171ffeb0ae159e7d18662f49171551f397683004434096e4006380fa607577b3c90fafa1eceb3a92702ebd49a15 debian-13-genericcloud-arm64.qcow2
+3
Просмотреть файл
@@ -0,0 +1,3 @@
Capacity: 42949672960
Allocation: 6442450944
Physical: 6443499520
+4
Просмотреть файл
@@ -0,0 +1,4 @@
Type Device Target Source
------------------------------------------------------------
file disk vda /var/lib/virtuality/images/web01.qcow2
file cdrom sda -
+16
Просмотреть файл
@@ -0,0 +1,16 @@
Domain: 'web01'
cpu.time=48123456789
cpu.user=30000000000
cpu.system=18123456789
balloon.current=2097152
balloon.maximum=2097152
balloon.rss=1534208
vcpu.current=2
vcpu.maximum=2
vcpu.0.state=1
vcpu.0.time=24000000000
vcpu.0.wait=0
vcpu.1.state=1
vcpu.1.time=24123456789
vcpu.1.wait=0
+37
Просмотреть файл
@@ -0,0 +1,37 @@
<domain type='kvm' id='1'>
<name>web01</name>
<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>
<title>Сайт компании</title>
<description>Nginx + база. Бэкап по пятницам.</description>
<memory unit='KiB'>2097152</memory>
<currentMemory unit='KiB'>2097152</currentMemory>
<vcpu placement='static'>2</vcpu>
<os>
<type arch='x86_64' machine='pc-q35-10.2'>hvm</type>
<boot dev='hd'/>
</os>
<devices>
<emulator>/usr/bin/qemu-system-x86_64</emulator>
<disk type='file' device='disk'>
<driver name='qemu' type='qcow2'/>
<source file='/var/lib/virtuality/images/web01.qcow2'/>
<target dev='vda' bus='virtio'/>
</disk>
<disk type='file' device='cdrom'>
<driver name='qemu' type='raw'/>
<target dev='sda' bus='sata'/>
<readonly/>
</disk>
<interface type='network'>
<mac address='52:54:00:aa:bb:cc'/>
<source network='virtuality-nat'/>
<model type='virtio'/>
</interface>
<channel type='unix'>
<target type='virtio' name='org.qemu.guest_agent.0'/>
</channel>
<graphics type='vnc' port='5900' autoport='yes' listen='127.0.0.1'>
<listen type='address' address='127.0.0.1'/>
</graphics>
</devices>
</domain>
+6
Просмотреть файл
@@ -0,0 +1,6 @@
Id Name State Title
--------------------------------------------
1 web01 running Сайт компании
- db01 shut off
- win11 shut off Бухгалтерия 1С
+17
Просмотреть файл
@@ -0,0 +1,17 @@
<network connections='2'>
<name>virtuality-nat</name>
<uuid>6c1b3c1e-1111-2222-3333-444444444444</uuid>
<forward mode='nat'>
<nat>
<port start='1024' end='65535'/>
</nat>
</forward>
<bridge name='virbr100' stp='on' delay='0'/>
<mac address='52:54:00:12:34:56'/>
<ip address='192.168.100.1' netmask='255.255.255.0'>
<dhcp>
<range start='192.168.100.50' end='192.168.100.200'/>
<host mac='52:54:00:aa:bb:cc' name='web01' ip='192.168.100.51'/>
</dhcp>
</ip>
</network>
+1
Просмотреть файл
@@ -0,0 +1 @@
2ae30006299896d225c9d4de36e9a7c3d047c9b4a5793efe3a6422ec5040e8ee5f7d71326aa9922ffa446fbd11ed6cd1a8d0af4659b3d00233d3fea3550d2d78
+10
Просмотреть файл
@@ -0,0 +1,10 @@
{
"children": [],
"virtual-size": 42949672960,
"filename": "/var/lib/virtuality/images/web01.qcow2",
"cluster-size": 65536,
"format": "qcow2",
"actual-size": 6443499520,
"format-specific": {"type": "qcow2", "data": {"compat": "1.1", "lazy-refcounts": false, "refcount-bits": 16, "corrupt": false}},
"dirty-flag": false
}
+9
Просмотреть файл
@@ -0,0 +1,9 @@
Name: clean
Domain: web01
Current: yes
State: running
Location: internal
Parent: -
Children: 1
Descendants: 1
Metadata: yes
+4
Просмотреть файл
@@ -0,0 +1,4 @@
Name Creation Time State
------------------------------------------------------
before-upd 2026-09-25 10:12:03 +0000 shutoff
clean 2026-09-24 09:00:41 +0000 running
+10
Просмотреть файл
@@ -0,0 +1,10 @@
import { createRequire } from 'node:module';
const { chromium } = createRequire(import.meta.url)('playwright');
// Render an HTML file to PNG. Usage: node tests/render_html.mjs in.html out.png [width] [height]
const [,, html, out, width = '1024', height = '768'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: +width, height: +height }, deviceScaleFactor: 1 });
await page.goto('file://' + (html.startsWith('/') ? html : process.cwd() + '/' + html));
await page.waitForTimeout(300);
await page.screenshot({ path: out, type: 'png' });
await browser.close();
+38
Просмотреть файл
@@ -0,0 +1,38 @@
// Screenshots of every panel page for design review.
// Usage: node tests/screenshots.mjs [baseUrl] [outDir] [theme]
import { createRequire } from 'node:module';
const { chromium } = createRequire(import.meta.url)('playwright'); // NODE_PATH=$(npm root -g) for a global install
import { mkdirSync } from 'node:fs';
const base = process.argv[2] || 'http://127.0.0.1:8765';
const out = process.argv[3] || 'screenshots';
const theme = process.argv[4] || '';
const pages = [
['login', '/login'], ['dashboard', '/'], ['vm', '/vm/web01'], ['create', '/vm/create'], ['help', '/help'], ['operation', '/operations'],
['iso', '/iso'], ['disks', '/disk-images'], ['network', '/network'], ['operations', '/operations'],
['logs', '/logs'], ['update', '/update'], ['host', '/host'], ['console', '/vm/web01/console'],
];
mkdirSync(out, { recursive: true });
const browser = await chromium.launch();
for (const [width, suffix] of [[1440, ''], [390, '-mobile']]) {
const context = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: 1 });
const page = await context.newPage();
const errors = [];
page.on('pageerror', (err) => errors.push(err.message));
if (theme) await page.addInitScript((t) => localStorage.setItem('virtualityTheme', t), theme);
await page.goto(base + '/login');
if (suffix === '') await page.screenshot({ path: `${out}/login${suffix}.png`, fullPage: true });
await page.fill('input[name=password]', 'x');
await page.click('button[type=submit]');
await page.waitForURL(base + '/');
for (const [name, path] of pages.slice(1)) {
if (suffix && !['dashboard', 'vm', 'create'].includes(name)) continue;
await page.goto(base + path);
await page.waitForTimeout(400);
await page.screenshot({ path: `${out}/${name}${suffix}.png`, fullPage: true });
}
if (errors.length) console.log('JS errors:', errors.join(' | '));
await context.close();
}
await browser.close();
console.log('saved to', out);
+188
Просмотреть файл
@@ -0,0 +1,188 @@
from urllib.parse import unquote
import pytest
import app
PAGES = ["/", "/help", "/host", "/iso", "/disk-images", "/network", "/operations", "/logs", "/update", "/vm/create", "/vm/web01", "/vm/web01/console"]
def test_healthz_is_public(client):
response = client.get("/healthz")
assert response.status_code == 200
assert response.json()["ok"] is True
assert response.json()["version"] == (app.BASE_DIR.parent / "VERSION").read_text().strip()
def test_security_headers(client):
response = client.get("/login")
assert response.headers["x-frame-options"] == "SAMEORIGIN"
assert response.headers["x-content-type-options"] == "nosniff"
@pytest.mark.parametrize("path", PAGES)
def test_pages_require_login(client, path):
response = client.get(path, follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
@pytest.mark.parametrize("path", PAGES)
def test_pages_render(logged_in, path):
response = logged_in.get(path)
assert response.status_code == 200, response.text[:500]
def test_wrong_user_is_rejected(client, monkeypatch):
monkeypatch.setattr(app.auth, "verify_password", lambda user, password: True)
response = client.post("/login", data={"username": "intruder", "password": "x"}, follow_redirects=False)
assert response.status_code == 401
def test_login_throttle(client, monkeypatch):
monkeypatch.setattr(app.auth, "verify_password", lambda user, password: False)
for _ in range(5):
assert client.post("/login", data={"username": "tester", "password": "bad"}).status_code == 401
response = client.post("/login", data={"username": "tester", "password": "bad"})
assert response.status_code == 429
assert int(response.headers["retry-after"]) > 0
def test_session_cookie_flags(client, monkeypatch):
monkeypatch.setattr(app.auth, "verify_password", lambda user, password: True)
response = client.post("/login", data={"username": "tester", "password": "x"}, follow_redirects=False)
cookie = response.headers["set-cookie"].lower()
assert "httponly" in cookie and "samesite=lax" in cookie
def test_forged_cookie_is_rejected(client):
client.cookies.set("virtuality_session", "forged.token.value")
assert client.get("/", follow_redirects=False).status_code == 303
def test_cross_origin_post_blocked(logged_in):
response = logged_in.post("/vm/web01/start", headers={"Origin": "http://evil.example"}, follow_redirects=False)
assert response.status_code == 403
def test_same_origin_post_allowed(logged_in):
response = logged_in.post("/vm/web01/start", headers={"Origin": "http://panel.test"}, follow_redirects=False)
assert response.status_code == 303
def test_vm_action_rejects_bad_name(logged_in):
assert logged_in.post("/vm/-bad/start", follow_redirects=False).status_code == 400
def test_vm_action_unknown(logged_in):
assert logged_in.post("/vm/web01/format-disk", follow_redirects=False).status_code == 400
def test_vm_details_include_autostart(data_dirs):
details = app.vm_details("web01")
assert details["autostart_enabled"] is True
assert details["autostart_label"] == "enabled"
def test_vm_detail_shows_autostart_enabled(logged_in):
html = logged_in.get("/vm/web01").text
assert 'action="/vm/web01/autostart-disable"' in html
assert 'role="switch" aria-checked="true"' in html
def test_dashboard_uses_plain_language(logged_in):
html = logged_in.get("/").text
assert "Работает" in html and "Выключена" in html
assert "2 ядра" in html and "2 ГБ" in html
assert "shut off" not in html
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
def test_vm_create_disk_image_without_disk_size(logged_in, data_dirs, monkeypatch):
image = data_dirs["disk_images"] / "cloud.qcow2"
image.write_bytes(b"qcow")
captured = {}
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: captured.update(cmd=cmd))
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
form = {"name": "cloud-vm", "memory": 1024, "vcpus": 1, "disk_image_path": str(image), "source_type": "disk_image", "network_mode": "nat"}
response = logged_in.post("/vm/create", data=form, follow_redirects=False)
assert response.status_code == 303, response.text[:300]
assert "--import" in captured["cmd"][-1]
def test_help_page(logged_in):
assert "Как создать машину" in logged_in.get("/help").text
def test_redirect_message_is_url_encoded(logged_in, monkeypatch):
monkeypatch.setattr(app, "apply_vm_boot_order", lambda name, order: (True, "Готово: диск & ISO?"))
response = logged_in.post("/vm/web01/boot-order", data={"boot_order": "disk"}, follow_redirects=False)
location = response.headers["location"]
assert " " not in location and "&" not in location.split("?", 1)[1]
assert unquote(location.split("boot_message=", 1)[1]) == "Готово: диск & ISO?"
def test_vm_create_iso_builds_virt_install(logged_in, data_dirs, monkeypatch):
iso = data_dirs["iso"] / "debian.iso"
iso.write_bytes(b"iso")
captured = {}
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: captured.update(operation=operation, cmd=cmd))
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
form = {"name": "new-vm", "memory": 2048, "vcpus": 2, "disk_size": 20, "iso_path": str(iso), "source_type": "iso", "guest_arch": "x86_64", "boot_order": "auto", "network_mode": "nat"}
response = logged_in.post("/vm/create", data=form, follow_redirects=False)
assert response.status_code == 303, response.text[:500]
cmd = captured["cmd"]
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"
def test_vm_create_validation_error(logged_in):
form = {"name": "x", "memory": 2048, "vcpus": 2, "disk_size": 20, "source_type": "iso", "network_mode": "nat"}
response = logged_in.post("/vm/create", data=form)
assert response.status_code == 400
def test_iso_upload_and_delete(logged_in, data_dirs, monkeypatch):
response = logged_in.post("/iso/upload", files={"iso_file": ("My Distro 1.0.iso", b"data", "application/octet-stream")}, follow_redirects=False)
assert response.status_code == 303
assert (data_dirs["iso"] / "My-Distro-1.0.iso").read_bytes() == b"data"
logged_in.post("/iso/My-Distro-1.0.iso/delete", follow_redirects=False)
assert not (data_dirs["iso"] / "My-Distro-1.0.iso").exists()
def test_iso_upload_rejects_other_types(logged_in):
response = logged_in.post("/iso/upload", files={"iso_file": ("evil.sh", b"#!/bin/sh", "text/plain")})
assert response.status_code == 400
def test_iso_delete_path_traversal(logged_in, data_dirs, tmp_path):
victim = tmp_path / "victim.iso"
victim.write_bytes(b"keep")
logged_in.post("/iso/..%2Fvictim.iso/delete", follow_redirects=False)
assert victim.exists()
def test_port_forward_add(logged_in, data_dirs):
form = {"vm_name": "web01", "guest_ip": "192.168.100.51", "external_port": "2222", "guest_port": "22", "protocol": "tcp"}
response = logged_in.post("/network/forward/add", data=form, follow_redirects=False)
assert response.status_code == 303
rules = (data_dirs["nft"] / "virtuality.nft").read_text()
assert "tcp dport 2222 dnat to 192.168.100.51:22" in rules
def test_api_requires_auth(client):
for path in ("/api/health", "/api/operations", "/live/status", "/api/logs"):
assert client.get(path).status_code == 401
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"}
-82
Просмотреть файл
@@ -1,82 +0,0 @@
import app
def test_login_page_renders(auth_client):
response = auth_client.get("/login", follow_redirects=False)
# авторизованный пользователь уводится на дашборд
assert response.status_code == 303
def test_unauthenticated_post_redirects_to_login():
from fastapi.testclient import TestClient
client = TestClient(app.app)
response = client.post("/host/refresh", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_post_without_csrf_rejected(auth_client):
response = auth_client.post("/iso/refresh", follow_redirects=False)
assert response.status_code == 403
def test_post_with_wrong_csrf_rejected(auth_client):
response = auth_client.post("/iso/refresh", data={"csrf_token": "wrong"}, follow_redirects=False)
assert response.status_code == 403
def test_post_with_valid_csrf_accepted(auth_client):
response = auth_client.post("/iso/refresh", data={"csrf_token": "test-csrf-token"}, follow_redirects=False)
assert response.status_code == 303
def test_dashboard_renders_with_csrf_inputs(auth_client):
response = auth_client.get("/")
assert response.status_code == 200
assert 'name="csrf_token"' in response.text
assert "cpu-spark" in response.text
def test_backups_page_renders(auth_client):
response = auth_client.get("/backups")
assert response.status_code == 200
def test_cloud_images_page_renders(auth_client):
response = auth_client.get("/cloud-images")
assert response.status_code == 200
def test_login_rate_limit():
ip = "203.0.113.7"
app.reset_login_failures(ip)
assert app.login_block_remaining(ip) == 0
for _ in range(app.LOGIN_MAX_FAILURES):
app.register_login_failure(ip)
assert app.login_block_remaining(ip) > 0
app.reset_login_failures(ip)
assert app.login_block_remaining(ip) == 0
def test_session_token_roundtrip():
token = app.serializer.dumps({"user": app.AUTH_USER, "csrf": "abc"})
assert app.user_from_session_token(token) == app.AUTH_USER
assert app.user_from_session_token("garbage") is None
assert app.user_from_session_token(None) is None
def test_template_vm_cannot_start(auth_client, monkeypatch):
monkeypatch.setattr(app, "vm_exists", lambda name: True)
app.set_vm_template("tpl-vm", True)
response = auth_client.post("/vm/tpl-vm/start", data={"csrf_token": "test-csrf-token"}, follow_redirects=False)
assert response.status_code == 303
assert "clone_error" in response.headers["location"]
def test_metrics_endpoint(auth_client):
response = auth_client.get("/live/metrics")
assert response.status_code == 200
payload = response.json()
assert payload["ok"]
assert "series" in payload
+203
Просмотреть файл
@@ -0,0 +1,203 @@
"""Regressions for the defects found by the 0.11 audit."""
import json
from pathlib import Path
from urllib.parse import quote
import app
import conftest
import network_core
CDROM_XML = conftest.DUMPXML.replace(
"<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk>",
"<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk><disk type='file' device='cdrom'><source file='/var/lib/virtuality/iso/old.iso'/><target dev='sda' bus='sata'/></disk>",
)
def recording(monkeypatch, module, overrides=()):
calls: list[list[str]] = []
def run(cmd, timeout=12, **kwargs):
calls.append(list(cmd))
for prefix, result in overrides:
if cmd[: len(prefix)] == list(prefix):
return result
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
monkeypatch.setattr(module, "run_cmd", run)
return calls
def find(calls, *prefix):
return [cmd for cmd in calls if cmd[: len(prefix)] == list(prefix)]
# ---------------------------------------------------------------- disk image format (app.py:620)
def test_disk_image_format_asks_qemu_img(data_dirs, monkeypatch):
# Ubuntu cloud images are qcow2 files named *.img: the suffix must not decide the format.
assert app.disk_image_format(Path("/tmp/noble-server-cloudimg-amd64.img")) == "qcow2"
recording(monkeypatch, app, [(["qemu-img", "info"], conftest.fake_result(ok=False, stderr="no such file"))])
assert app.disk_image_format(Path("/tmp/disk.img")) == "raw"
assert app.disk_image_format(Path("/tmp/disk.qcow2")) == "qcow2"
# ---------------------------------------------------------------- VM actions (app.py:1977, 1981)
def test_vm_action_reports_virsh_error(logged_in, data_dirs, monkeypatch):
recording(monkeypatch, app, [(["virsh", "start"], conftest.fake_result(ok=False, stderr="error: Domain is already active"))])
response = logged_in.post("/vm/web01/start", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/vm/web01?error=" + quote("Машина уже запущена")
assert "Машина уже запущена" in logged_in.get("/vm/web01?error=" + quote("Машина уже запущена")).text
def test_vm_action_success_redirects_without_error(logged_in, data_dirs, monkeypatch):
recording(monkeypatch, app)
assert logged_in.post("/vm/web01/shutdown", follow_redirects=False).headers["location"] == "/vm/web01"
def test_vm_delete_undefines_uefi_machines(logged_in, data_dirs, monkeypatch):
calls = recording(monkeypatch, app)
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
assert response.headers["location"].startswith("/?message=")
assert find(calls, "virsh", "destroy", "web01")
undefine = find(calls, "virsh", "undefine", "web01")[0]
assert "--nvram" in undefine and "--remove-all-storage" in undefine and "--snapshots-metadata" in undefine
def test_vm_delete_failure_is_reported(logged_in, data_dirs, monkeypatch):
recording(monkeypatch, app, [(["virsh", "undefine"], conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: cannot undefine domain with nvram"))])
response = logged_in.post("/vm/web01/delete", follow_redirects=False)
assert response.headers["location"].startswith("/vm/web01?error=")
# ---------------------------------------------------------------- ISO in the CD-ROM drive (app.py:1150, 1158)
def test_iso_mount_uses_change_media_for_existing_drive(logged_in, data_dirs, monkeypatch):
iso = data_dirs["iso"] / "debian.iso"
iso.write_bytes(b"iso")
calls = recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(CDROM_XML))])
ok, message = app.mount_vm_iso("web01", str(iso))
assert ok, message
assert find(calls, "virsh", "change-media", "web01", "sda", str(iso), "--update", "--config", "--live")
assert not find(calls, "virsh", "detach-disk") and not find(calls, "virsh", "attach-disk")
ok, message = app.detach_vm_iso("web01")
assert ok and find(calls, "virsh", "change-media", "web01", "sda", "--eject", "--config", "--live")
def test_iso_unmount_never_guesses_targets(data_dirs, monkeypatch):
# web01 has only a virtio data disk: nothing may be detached blindly.
calls = recording(monkeypatch, app)
ok, message = app.detach_vm_iso("web01")
assert not ok and message == "Подключенный ISO не найден."
assert not find(calls, "virsh", "detach-disk") and not find(calls, "virsh", "change-media")
def test_iso_mount_adds_drive_to_config_when_missing(data_dirs, monkeypatch):
iso = data_dirs["iso"] / "debian.iso"
iso.write_bytes(b"iso")
calls = recording(monkeypatch, app)
ok, message = app.mount_vm_iso("web01", str(iso))
assert ok and "перезапуска" in message
attach = find(calls, "virsh", "attach-disk", "web01")[0]
assert "--config" in attach and "--live" not in attach
# ---------------------------------------------------------------- port forwards leave no stale firewall rules (network_core.py:459)
def test_iptables_fallback_removes_stale_rules(data_dirs, monkeypatch):
listing = "\n".join([
"-A FORWARD -i eth0 -o virbr1 -p tcp -d 192.168.100.51 -m tcp --dport 22 -m comment --comment virtuality-forward -j ACCEPT",
"-A FORWARD -j SOMETHING_ELSE",
])
calls = recording(monkeypatch, network_core, [(["iptables", "-t", "filter", "-S", "FORWARD"], conftest.fake_result(listing))])
network_core.apply_iptables_fallback([])
deleted = find(calls, "iptables", "-t", "filter", "-D", "FORWARD")
assert deleted == [["iptables", "-t", "filter", "-D", "FORWARD", "-i", "eth0", "-o", "virbr1", "-p", "tcp", "-d", "192.168.100.51", "-m", "tcp", "--dport", "22", "-m", "comment", "--comment", "virtuality-forward", "-j", "ACCEPT"]]
checked = find(calls, "iptables", "-t", "nat", "-C", "POSTROUTING")
assert checked and "virtuality-forward" in checked[0]
def test_ufw_rules_follow_forwards(data_dirs, monkeypatch):
old_rule = ["allow", "2222/tcp"]
network_core.UFW_STATE_FILE.write_text(json.dumps([old_rule, ["route", "allow", "in", "on", "eth0", "out", "on", "virbr1", "to", "192.168.100.51", "port", "22", "proto", "tcp"]]))
calls = recording(monkeypatch, network_core, [(["ufw", "status"], conftest.fake_result("Status: active"))])
network_core.apply_ufw_route_rules([])
assert find(calls, "ufw", "delete", "allow", "2222/tcp")
assert find(calls, "ufw", "route", "delete", "allow", "in", "on", "eth0")
assert json.loads(network_core.UFW_STATE_FILE.read_text()) == []
# ---------------------------------------------------------------- operations orphaned by a restart
def test_orphaned_operations_are_closed_on_startup(data_dirs):
import core
running = core.new_operation("snapshot_create", "Снимок web01", vm_name="web01")
core.update_operation(running, status="running")
done = core.new_operation("backup", "Копия web01", vm_name="web01")
core.finish_operation(done, True, "Готово")
assert core.active_operations_for("web01")
assert core.interrupt_orphaned_operations() == 1
assert core.read_operation(running["id"])["status"] == "error" and core.read_operation(running["id"])["interrupted"]
assert core.read_operation(done["id"])["status"] == "success"
assert core.active_operations_for("web01") == []
def test_snapshot_names_with_spaces_are_listed():
from features import snapshots
rows = snapshots.parse_snapshot_list(" Name Creation Time State\n------\n before update 2026-09-25 10:12:03 +0000 shutoff\n clean 2026-09-10 12:05:00 +0000 running\n")
assert [row["name"] for row in rows] == ["before update", "clean"]
# ---------------------------------------------------------------- second audit round
def test_vm_names_that_virsh_reads_as_id_or_uuid_are_rejected():
import core
for module in (app, core, network_core):
assert module.valid_vm_name("web01") and module.valid_vm_name("1c-server")
assert not module.valid_vm_name("10") and not module.valid_vm_name("6c1b3c1e-0000-0000-0000-000000000001")
def test_subprocesses_use_c_locale():
import os
import core
assert core.STORAGE_DIR and os.environ["LC_ALL"] == "C.UTF-8" # set when core is imported
def test_lease_is_not_guessed_without_mac(data_dirs, monkeypatch):
recording(monkeypatch, network_core, [
(["virsh", "domifaddr"], conftest.fake_result(ok=False, stderr="domain is not running")),
(["virsh", "domiflist"], conftest.fake_result(ok=False, stderr="failed to get domain")),
(["virsh", "net-dhcp-leases"], conftest.fake_result(" 2026-09-26 10:00:00 52:54:00:11:22:33 ipv4 192.168.100.77/24 other -")),
])
assert network_core.resolve_vm_ip("gone") is None
def test_stopped_machines_have_no_stale_ip(data_dirs, monkeypatch):
calls = recording(monkeypatch, app)
rows = {row["name"]: row for row in app.parse_virsh_list()}
assert rows["db01"]["ip"] == "—" and rows["web01"]["ip"] == "192.168.100.51"
assert not [cmd for cmd in calls if cmd[:2] == ["virsh", "domifaddr"] and cmd[-1] == "db01"]
def test_system_disk_named_like_iso_is_not_shown_as_iso(data_dirs, monkeypatch):
xml = conftest.DUMPXML.replace("<disk type='file' device='disk'><target dev='vda' bus='virtio'/></disk>", "<disk type='file' device='disk'><source file='/var/lib/virtuality/images/web.iso.qcow2'/><target dev='vda' bus='virtio'/></disk><disk type='file' device='cdrom'><target dev='sda' bus='sata'/></disk>")
recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(xml))])
assert app.current_vm_iso("web01") == ""
def test_iso_in_use_is_not_deleted(logged_in, data_dirs, monkeypatch):
iso = data_dirs["iso"] / "old.iso"
iso.write_bytes(b"iso")
xml = CDROM_XML.replace("/var/lib/virtuality/iso/old.iso", str(iso))
recording(monkeypatch, app, [(["virsh", "dumpxml"], conftest.fake_result(xml)), (["virsh", "list", "--all", "--name"], conftest.fake_result("web01\n"))])
response = logged_in.post("/iso/old.iso/delete", follow_redirects=False)
assert "error=" in response.headers["location"] and iso.exists()
def test_arm_board_is_ready_without_vmx_flags(monkeypatch):
import host_profile
monkeypatch.setattr(host_profile.platform, "machine", lambda: "aarch64")
monkeypatch.setattr(host_profile, "cpu_virtualization_flags_count", lambda: 0)
profile = host_profile.detect_host_profile()
assert not [check for check in profile["checks"] if check["name"] == "Поддержка виртуализации процессором"]
+49
Просмотреть файл
@@ -0,0 +1,49 @@
import pytest
import auth
# SHA-512 crypt hash of "s3cret" with salt "virtualitysalt".
SHA512_HASH = "$6$virtualitysalt$"
@pytest.fixture()
def password_hash():
computed = auth.crypt_hash("s3cret", SHA512_HASH)
assert computed and computed.startswith(SHA512_HASH)
return computed
def write_shadow(tmp_path, monkeypatch, entries):
shadow = tmp_path / "shadow"
shadow.write_text("".join(f"{user}:{pw}:19000:0:99999:7:::\n" for user, pw in entries))
monkeypatch.setattr(auth, "SHADOW_FILE", shadow)
monkeypatch.setattr(auth, "_stdlib_spwd", None)
def test_libcrypt_fallback_matches(password_hash, monkeypatch):
monkeypatch.setattr(auth, "_stdlib_crypt", None)
assert auth.crypt_hash("s3cret", password_hash) == password_hash
assert auth.crypt_hash("wrong", password_hash) != password_hash
def test_verify_password_with_shadow_file(password_hash, tmp_path, monkeypatch):
write_shadow(tmp_path, monkeypatch, [("root", "*"), ("admin", password_hash), ("locked", "!" + password_hash)])
monkeypatch.setattr(auth, "_stdlib_crypt", None)
assert auth.verify_password("admin", "s3cret")
assert not auth.verify_password("admin", "wrong")
assert not auth.verify_password("admin", "")
assert not auth.verify_password("root", "anything")
assert not auth.verify_password("locked", "s3cret")
assert not auth.verify_password("missing", "s3cret")
def test_throttle_locks_and_resets():
throttle = auth.LoginThrottle(max_failures=3, window_seconds=60, lockout_seconds=60)
for _ in range(2):
throttle.record_failure("1.2.3.4")
assert throttle.retry_after("1.2.3.4") == 0
throttle.record_failure("1.2.3.4")
assert throttle.retry_after("1.2.3.4") > 0
assert throttle.retry_after("5.6.7.8") == 0
throttle.record_success("1.2.3.4")
assert throttle.retry_after("1.2.3.4") == 0
-87
Просмотреть файл
@@ -1,87 +0,0 @@
import time
from pathlib import Path
import backup_core
def test_backup_filename_validation():
assert backup_core.valid_backup_filename("vm1_20260815_010101.qcow2")
assert backup_core.valid_backup_filename("a.qcow2")
assert not backup_core.valid_backup_filename("../evil.qcow2")
assert not backup_core.valid_backup_filename("x.img")
assert not backup_core.valid_backup_filename("")
def test_backup_path_traversal_guard():
assert backup_core.backup_path("vm1", "vm1_x.qcow2") is not None
assert backup_core.backup_path("vm1", "../../etc/passwd") is None
assert backup_core.backup_path("../vm1", "x.qcow2") is None
assert backup_core.backup_path("vm1", "nested/x.qcow2") is None
def test_new_backup_path_shape():
path = backup_core.new_backup_path("vm1")
assert path is not None
assert path.name.startswith("vm1_")
assert path.suffix == ".qcow2"
assert backup_core.new_backup_path("bad name") is None
def test_rotation_keeps_newest():
vm_dir = backup_core.backup_dir_for("vm1")
vm_dir.mkdir(parents=True)
for index in range(5):
item = vm_dir / f"vm1_{index}.qcow2"
item.write_text("x")
stamp = time.time() - (5 - index) * 60
import os
os.utime(item, (stamp, stamp))
removed = backup_core.rotate_backups("vm1", keep=2)
assert len(removed) == 3
remaining = sorted(p.name for p in vm_dir.glob("*.qcow2"))
assert remaining == ["vm1_3.qcow2", "vm1_4.qcow2"]
def test_rotation_ignores_bad_input():
assert backup_core.rotate_backups("no-such-vm", keep=3) == []
assert backup_core.rotate_backups("vm1", keep=0) == []
def test_schedule_roundtrip():
config = backup_core.save_schedule(True, ["vm2", "vm1", "bad name", "vm1"], 99)
assert config == {"enabled": True, "vms": ["vm1", "vm2"], "keep": 30}
assert backup_core.load_schedule() == config
config = backup_core.save_schedule(False, [], 0)
assert backup_core.load_schedule() == {"enabled": False, "vms": [], "keep": 1}
def test_schedule_defaults_when_missing():
assert backup_core.load_schedule() == {"enabled": False, "vms": [], "keep": backup_core.DEFAULT_KEEP}
def test_backup_script_contents():
target = Path("/var/lib/virtuality/backups/vm1/vm1_x.qcow2")
script = backup_core.build_backup_script("vm1", "/var/lib/virtuality/images/vm1.qcow2", target)
assert "virsh suspend vm1" in script
assert "qemu-img convert -p -c -O qcow2" in script
assert "virsh resume vm1" in script
assert f"{target}.part" in script # атомарная запись через .part
restore = backup_core.build_restore_script("/var/lib/virtuality/images/vm1.qcow2", target)
assert "qemu-img convert -p -O qcow2" in restore
assert ".restore" in restore
def test_first_disk_of_parsing(monkeypatch):
sample = """ Type Device Target Source
------------------------------------------------
file disk vda /var/lib/virtuality/images/vm1.qcow2
file cdrom sda /var/lib/virtuality/iso/ubuntu.iso
"""
class FakeResult:
returncode = 0
stdout = sample
stderr = ""
monkeypatch.setattr(backup_core, "_run", lambda cmd, timeout=60: FakeResult())
assert backup_core.first_disk_of("vm1") == "/var/lib/virtuality/images/vm1.qcow2"
+662
Просмотреть файл
@@ -0,0 +1,662 @@
import json
from datetime import datetime
from pathlib import Path
from urllib.parse import unquote
import pytest
import core
import features.backups as backups
from conftest import DOMBLKLIST_DETAILS, DUMPXML_MIGRATABLE, QEMU_IMG_INFO, fake_result, fake_run_cmd
FIXTURES = Path(__file__).resolve().parent / "fixtures"
def make_backup(dirs, vm="web01", backup_id="20260924-0300", size=2_684_354_560, note="", disks=("vda",), complete=True) -> Path:
path = dirs["backups"] / vm / backup_id
path.mkdir(parents=True)
(path / "vm.xml").write_text(DUMPXML_MIGRATABLE)
for target in disks:
if complete:
(path / f"{target}.qcow2").write_bytes(b"QFI\xfb" + b"\0" * 60)
meta = {"vm": vm, "created_at": "2026-09-24 03:00:00", "disks": [{"target": t, "source": f"/var/lib/virtuality/images/{vm}.qcow2", "size": 6_443_499_520} for t in disks], "size_bytes": size, "version": "0.11.0", "note": note}
(path / "meta.json").write_text(json.dumps(meta))
return path
class FakeVirsh:
"""Имитация virsh с состоянием: машина выключается после `virsh shutdown`."""
def __init__(self, running=True, shuts_down=True, exists=("web01", "db01"), paused=False, rejects_shutdown=False):
self.running = running
self.paused = paused
self.shuts_down = shuts_down
self.rejects_shutdown = rejects_shutdown
self.exists = set(exists)
self.calls: list[list[str]] = []
self.defined_xml = ""
def __call__(self, cmd, timeout=12, **_kwargs):
self.calls.append(list(cmd))
if cmd[:2] == ["virsh", "domstate"]:
return fake_result("paused" if self.running and self.paused else "running" if self.running else "shut off")
if cmd[:2] == ["virsh", "shutdown"]:
if self.rejects_shutdown:
return fake_result(ok=False, stderr="error: Requested operation is not valid: domain is not running")
if self.shuts_down:
self.running = False
return fake_result()
if cmd[:2] == ["virsh", "managedsave"]:
self.running = False
return fake_result(f"Domain '{cmd[2]}' state saved by libvirt")
if cmd[:2] == ["virsh", "destroy"]:
self.running = False
return fake_result()
if cmd[:2] == ["virsh", "start"]:
self.running = True
return fake_result()
if cmd[:2] == ["virsh", "dominfo"]:
return fake_result(f"Name: {cmd[-1]}\nState: running\n") if cmd[-1] in self.exists else fake_result(ok=False, stderr="error: failed to get domain")
if cmd[:2] == ["virsh", "define"]:
self.defined_xml = Path(cmd[2]).read_text()
return fake_result(f"Domain defined from {cmd[2]}")
if cmd[:2] == ["virsh", "undefine"]:
self.exists.discard(cmd[2])
return fake_result()
return fake_run_cmd(cmd, timeout)
def fake_stream(progress_lines=("(0.00/100%)", "(48.50/100%)", "(100.00/100%)"), code=0, calls=None):
"""Имитация qemu-img convert. calls — общий список с FakeVirsh, если важен порядок команд."""
calls = [] if calls is None else calls
def stream(cmd, on_line, timeout=None):
calls.append(list(cmd))
Path(cmd[-1]).write_bytes(b"QFI\xfb" + b"\0" * 1000)
for line in progress_lines:
on_line(line)
return code
stream.calls = calls
return stream
class FixedDatetime(datetime):
"""datetime.now() всегда 24 сентября 2026, 03:00 — чтобы id копий были предсказуемы."""
@classmethod
def now(cls, tz=None):
return cls(2026, 9, 24, 3, 0, 0)
@pytest.fixture()
def fast_worker(monkeypatch, data_dirs):
"""Операции и фоновые потоки выполняются синхронно, ожидание выключения — мгновенно."""
monkeypatch.setattr(core, "run_operation", lambda operation, worker: worker(operation))
monkeypatch.setattr(backups, "start_thread", lambda target, *args: target(*args))
monkeypatch.setattr(backups, "POLL_INTERVAL", 0.001)
monkeypatch.setattr(backups, "SHUTDOWN_TIMEOUT", 0.05)
monkeypatch.setattr(backups, "LATE_SHUTDOWN_WATCH", 0.05)
monkeypatch.setattr(backups, "free_bytes", lambda path: 500 * 1024 ** 3)
return data_dirs
# ---------------------------------------------------------------- парсеры
def test_parse_progress():
assert backups.parse_progress(" (12.34/100%)") == 12
assert backups.parse_progress("(100.00/100%)") == 100
assert backups.parse_progress("qemu-img: Could not open") is None
def test_parse_qemu_img_info_fixture():
info = backups.parse_qemu_img_info(QEMU_IMG_INFO)
assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"}
assert backups.parse_qemu_img_info("not json")["actual_size"] == 0
assert backups.parse_qemu_img_info("[1, 2]")["format"] == ""
def test_vm_disks_skips_cdrom(data_dirs):
disks = backups.vm_disks("web01")
assert [(d["target"], d["source"]) for d in disks] == [("vda", str(core.IMAGES_DIR / "web01.qcow2"))]
assert DOMBLKLIST_DETAILS.count("cdrom") == 1
def test_stream_cmd_splits_carriage_returns():
lines: list[str] = []
code = backups.stream_cmd(["python3", "-c", "import sys; sys.stdout.write('a\\r (10.00/100%)\\r(55.50/100%)\\nend')"], lines.append)
assert code == 0
assert lines == ["a", "(10.00/100%)", "(55.50/100%)", "end"]
def test_stream_cmd_stops_hung_command():
lines: list[str] = []
code = backups.stream_cmd(["python3", "-c", "import time; time.sleep(30)"], lines.append, timeout=0.2)
assert code != 0
assert any("остановлена" in line for line in lines)
def test_disk_info_uses_shared_lock_and_raises_on_failure(monkeypatch):
calls: list[list[str]] = []
def info_ok(cmd, timeout=12, **_kw):
calls.append(list(cmd))
return fake_result(QEMU_IMG_INFO)
monkeypatch.setattr(core, "run_cmd", info_ok)
assert backups.disk_info("/x/web01.qcow2")["actual_size"] == 6443499520
assert calls == [["qemu-img", "info", "-U", "--output=json", "/x/web01.qcow2"]] # -U: диск занят работающей машиной
monkeypatch.setattr(core, "run_cmd", lambda cmd, timeout=12, **_kw: fake_result(ok=False, stderr='qemu-img: Could not open: Failed to get shared "write" lock'))
with pytest.raises(backups.BackupError) as exc:
backups.disk_info("/x/web01.qcow2")
assert "размер диска" in str(exc.value) and "write" in str(exc.value)
def test_rewrite_xml_renames_and_detaches_identity():
xml = backups.rewrite_xml((FIXTURES / "dumpxml-web01.xml").read_text(), "web02", {"vda": "/var/lib/virtuality/images/web02.qcow2"})
assert "<name>web02</name>" in xml
assert "<uuid>" not in xml and "id=" not in xml.split(">", 1)[0]
assert "<mac" not in xml
assert "file=\"/var/lib/virtuality/images/web02.qcow2\"" in xml
assert "Сайт компании" in xml # заголовок и описание сохраняются
assert "<readonly />" in xml or "<readonly/>" in xml # привод CD остаётся как был
def test_rewrite_xml_keeps_identity_on_replace_and_drops_stale_chain(tmp_path):
present = tmp_path / "debian.iso"
present.write_bytes(b"iso")
source = f"""<domain type='kvm' id='7'><name>web01</name><uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>
<devices>
<disk type='file' device='disk'><driver name='qemu' type='qcow2'/><source file='/var/lib/virtuality/images/web01.snap.qcow2'/>
<backingStore type='file'><format type='qcow2'/><source file='/var/lib/virtuality/images/web01.qcow2'/></backingStore>
<target dev='vda' bus='virtio'/></disk>
<disk type='file' device='cdrom'><driver name='qemu' type='raw'/><source file='/var/lib/virtuality/iso/gone.iso'/><target dev='sda' bus='sata'/><readonly/></disk>
<disk type='file' device='cdrom'><driver name='qemu' type='raw'/><source file='{present}'/><target dev='sdb' bus='sata'/><readonly/></disk>
<interface type='network'><mac address='52:54:00:aa:bb:cc'/><source network='virtuality-nat'/></interface>
</devices></domain>"""
replaced = backups.rewrite_xml(source, "web01", {"vda": "/var/lib/virtuality/images/web01.qcow2"}, keep_identity=True)
assert "<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>" in replaced and 'mac address="52:54:00:aa:bb:cc"' in replaced
assert "backingStore" not in replaced and "web01.snap.qcow2" not in replaced
assert "gone.iso" not in replaced and str(present) in replaced # пропавший образ вынут, существующий остаётся
assert 'id="7"' not in replaced
fresh = backups.rewrite_xml(source, "web02", {"vda": "/var/lib/virtuality/images/web02.qcow2"})
assert "<uuid>" not in fresh and "<mac" not in fresh and "backingStore" not in fresh
def test_rewrite_xml_converts_block_disk_and_drops_nvram():
source = """<domain type='kvm'><name>old</name><os><type>hvm</type><loader type='pflash'>/usr/share/OVMF/OVMF_CODE.fd</loader><nvram>/var/lib/libvirt/qemu/nvram/old_VARS.fd</nvram></os>
<devices><disk type='block' device='disk'><driver name='qemu' type='raw'/><source dev='/dev/vg/old'/><target dev='vda' bus='virtio'/></disk></devices></domain>"""
xml = backups.rewrite_xml(source, "new", {"vda": "/var/lib/virtuality/images/new.qcow2"})
assert "<nvram>" not in xml
assert 'type="file"' in xml and 'file="/var/lib/virtuality/images/new.qcow2"' in xml
assert 'type="qcow2"' in xml and "source dev=" not in xml and 'type="raw"' not in xml
def test_format_backup_date():
assert backups.format_backup_date("20260924-0300") == "24 сентября 2026, 03:00"
assert backups.format_backup_date("20260924-0300-2") == "24 сентября 2026, 03:00" # вторая копия в ту же минуту
assert backups.format_backup_date("garbage") == "garbage"
def test_clean_note_limits_length_and_control_chars():
assert backups.clean_note(" до\x00обновления\n ") == "до обновления"
assert len(backups.clean_note("x" * 500)) == backups.NOTE_MAX
# ---------------------------------------------------------------- хранилище
def test_list_and_group_backups(data_dirs):
make_backup(data_dirs, "web01", "20260917-0300", size=100)
make_backup(data_dirs, "web01", "20260924-0300", size=200, note="перед обновлением")
make_backup(data_dirs, "old-vm", "20260901-1200", size=50)
(data_dirs["backups"] / "web01" / "junk").mkdir()
(data_dirs["backups"] / "..hidden").mkdir()
items = backups.list_backups()
assert [(i["vm"], i["id"]) for i in items] == [("web01", "20260924-0300"), ("web01", "20260917-0300"), ("old-vm", "20260901-1200")]
assert items[0]["note"] == "перед обновлением" and items[0]["complete"] is True
groups = backups.group_by_vm(items, {"web01"})
assert [(g["vm"], g["count"], g["size_bytes"], g["exists"]) for g in groups] == [("old-vm", 1, 50, False), ("web01", 2, 300, True)]
assert backups.list_backups("web01")[0]["id"] == "20260924-0300"
assert backups.storage_summary(items)["used_bytes"] == 350
def test_backup_path_rejects_traversal(data_dirs):
assert backups.backup_path("../etc", "20260924-0300") is None
assert backups.backup_path("web01", "../../x") is None
assert backups.backup_path("web01", "20260924-0300") == data_dirs["backups"] / "web01" / "20260924-0300"
def test_incomplete_backup_is_flagged(data_dirs):
make_backup(data_dirs, complete=False)
assert backups.read_backup("web01", "20260924-0300")["complete"] is False
def test_backup_with_unsafe_disk_target_is_incomplete(data_dirs):
path = make_backup(data_dirs)
meta = json.loads((path / "meta.json").read_text())
meta["disks"].append({"target": "../../etc/shadow", "source": "/x", "size": 1})
(path / "meta.json").write_text(json.dumps(meta))
backup = backups.read_backup("web01", "20260924-0300")
assert [disk["target"] for disk in backup["disks"]] == ["vda"] and backup["complete"] is False
def test_storage_summary_percent_is_share_of_whole_disk(data_dirs, monkeypatch):
monkeypatch.setattr(backups, "total_bytes", lambda path: 1000)
monkeypatch.setattr(backups, "free_bytes", lambda path: 400)
summary = backups.storage_summary([{"size_bytes": 350}])
assert summary["used_pct"] == 35 and summary["free_bytes"] == 400
# ---------------------------------------------------------------- создание копии
def test_create_backup_refuses_without_space(data_dirs, monkeypatch):
monkeypatch.setattr(backups, "free_bytes", lambda path: 1024)
with pytest.raises(backups.BackupError) as exc:
backups.create_backup("web01")
assert "Недостаточно места" in str(exc.value) and "6.0 ГБ" in str(exc.value)
def test_create_backup_rejects_unknown_vm(fast_worker):
with pytest.raises(backups.BackupError):
backups.create_backup("nope")
with pytest.raises(backups.BackupError):
backups.create_backup("bad name")
def test_create_backup_shuts_down_copies_and_restarts(fast_worker, monkeypatch):
virsh = FakeVirsh(running=True)
monkeypatch.setattr(core, "run_cmd", virsh)
stream = fake_stream()
monkeypatch.setattr(backups, "stream_cmd", stream)
queued = backups.create_backup("web01", " ночная\tкопия ")
operation = core.read_operation(queued["id"])
assert operation["status"] == "success", operation["message"]
assert operation["progress"] == 100 and "Резервная копия создана" in operation["message"]
assert ["virsh", "shutdown", "web01"] in virsh.calls
assert ["virsh", "dumpxml", "web01", "--migratable"] in virsh.calls
assert ["qemu-img", "info", "-U", "--output=json", str(core.IMAGES_DIR / "web01.qcow2")] in virsh.calls
assert virsh.calls[-1] == ["virsh", "start", "web01"]
assert ["virsh", "destroy", "web01"] not in virsh.calls
assert stream.calls == [["qemu-img", "convert", "-p", "-O", "qcow2", "-c", str(core.IMAGES_DIR / "web01.qcow2"), str(fast_worker["backups"] / "web01" / operation["backup_id"] / "vda.qcow2")]]
path = fast_worker["backups"] / "web01" / operation["backup_id"]
meta = json.loads((path / "meta.json").read_text())
assert meta["vm"] == "web01" and meta["note"] == "ночная копия" and meta["disks"][0]["target"] == "vda"
assert meta["size_bytes"] == sum(f.stat().st_size for f in path.iterdir() if f.name != "meta.json")
assert "Сайт компании" in (path / "vm.xml").read_text()
log = core.read_operation(operation["id"])["log_tail"]
assert "48%" in log and "Машина запущена снова" in log
def test_create_backup_wait_returns_finished_operation(fast_worker, monkeypatch):
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
operation = backups.create_backup("db01", wait=True)
assert operation["status"] == "success" and operation["type"] == "backup"
def test_backup_fails_when_vm_does_not_shut_down(fast_worker, monkeypatch):
virsh = FakeVirsh(running=True, shuts_down=False)
monkeypatch.setattr(core, "run_cmd", virsh)
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
operation = backups.create_backup("web01")
fresh = core.read_operation(operation["id"])
assert fresh["status"] == "error" and "не выключилась" in fresh["message"] and "запустит её снова" in fresh["message"]
assert ["virsh", "destroy", "web01"] not in virsh.calls
assert ["virsh", "start", "web01"] not in virsh.calls # так и не выключилась — запускать нечего
assert "так и не выключилась" in fresh["log_tail"]
assert not (fast_worker["backups"] / "web01").exists()
def test_backup_restarts_vm_that_shuts_down_late(fast_worker, monkeypatch):
virsh = FakeVirsh(running=True, shuts_down=False)
monkeypatch.setattr(core, "run_cmd", virsh)
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
def shut_down_after_giving_up(target, *args):
virsh.running = False # ACPI-выключение дошло уже после того, как задача сдалась
target(*args)
monkeypatch.setattr(backups, "start_thread", shut_down_after_giving_up)
operation = backups.create_backup("web01")
fresh = core.read_operation(operation["id"])
assert fresh["status"] == "error" and "не выключилась" in fresh["message"]
assert virsh.calls[-1] == ["virsh", "start", "web01"] and virsh.running
assert "запускаем её снова" in fresh["log_tail"]
def test_backup_reports_rejected_shutdown_command(fast_worker, monkeypatch):
virsh = FakeVirsh(running=True, rejects_shutdown=True)
monkeypatch.setattr(core, "run_cmd", virsh)
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
monkeypatch.setattr(backups, "start_thread", lambda target, *args: pytest.fail("нечего ждать — команда не принята"))
operation = backups.create_backup("web01")
fresh = core.read_operation(operation["id"])
assert fresh["status"] == "error"
assert "не приняла команду выключения" in fresh["message"] and "domain is not running" in fresh["message"]
assert "не выключилась" not in fresh["message"]
assert ["virsh", "start", "web01"] not in virsh.calls
assert not (fast_worker["backups"] / "web01").exists()
def test_backup_of_paused_vm_saves_state_instead_of_shutdown(fast_worker, monkeypatch):
virsh = FakeVirsh(running=True, paused=True)
monkeypatch.setattr(core, "run_cmd", virsh)
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
operation = backups.create_backup("web01")
fresh = core.read_operation(operation["id"])
assert fresh["status"] == "success", fresh["message"]
assert ["virsh", "managedsave", "web01"] in virsh.calls and ["virsh", "shutdown", "web01"] not in virsh.calls
assert virsh.calls.index(["virsh", "managedsave", "web01"]) < virsh.calls.index(["virsh", "dumpxml", "web01", "--migratable"])
assert virsh.calls[-1] == ["virsh", "start", "web01"]
assert "на паузе" in fresh["log_tail"]
def test_backup_cleans_up_and_restarts_after_copy_error(fast_worker, monkeypatch):
virsh = FakeVirsh(running=True)
monkeypatch.setattr(core, "run_cmd", virsh)
monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1))
operation = backups.create_backup("web01")
fresh = core.read_operation(operation["id"])
assert fresh["status"] == "error" and "vda" in fresh["message"]
assert not (fast_worker["backups"] / "web01").exists()
assert virsh.calls[-1] == ["virsh", "start", "web01"]
def test_create_backup_refuses_while_another_runs(fast_worker, monkeypatch):
monkeypatch.setattr(core, "run_operation", lambda operation, worker: None) # остаётся в очереди
backups.create_backup("web01")
with pytest.raises(backups.BackupError) as exc:
backups.create_backup("web01")
assert "уже идёт" in str(exc.value)
def test_second_backup_in_same_minute_gets_own_folder_and_keeps_first(fast_worker, monkeypatch):
monkeypatch.setattr(backups, "datetime", FixedDatetime)
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
first = core.read_operation(backups.create_backup("web01", "первая")["id"])
assert first["status"] == "success" and first["backup_id"] == "20260924-0300"
monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1))
second = core.read_operation(backups.create_backup("web01", "вторая")["id"])
assert second["status"] == "error" and second["backup_id"] == "20260924-0300-2"
first_dir = fast_worker["backups"] / "web01" / "20260924-0300"
assert (first_dir / "meta.json").exists() and (first_dir / "vda.qcow2").exists() # чужую папку неудачная задача не трогает
assert not (fast_worker["backups"] / "web01" / "20260924-0300-2").exists()
assert [item["id"] for item in backups.list_backups("web01")] == ["20260924-0300"]
assert backups.backup_path("web01", "20260924-0300-2") == fast_worker["backups"] / "web01" / "20260924-0300-2"
# ---------------------------------------------------------------- восстановление
def test_restore_under_new_name(fast_worker, monkeypatch):
make_backup(fast_worker)
virsh = FakeVirsh(running=False)
monkeypatch.setattr(core, "run_cmd", virsh)
stream = fake_stream()
monkeypatch.setattr(backups, "stream_cmd", stream)
operation = backups.start_restore("web01", "20260924-0300", "web02", False)
fresh = core.read_operation(operation["id"])
assert fresh["status"] == "success", fresh["message"]
assert fresh["vm_name"] == "web02"
target = fast_worker["images"] / "web02.qcow2"
assert stream.calls[0][:6] == ["qemu-img", "convert", "-p", "-O", "qcow2", str(fast_worker["backups"] / "web01" / "20260924-0300" / "vda.qcow2")]
assert stream.calls[0][6].startswith(str(fast_worker["images"] / "web02.restore-")) # сначала во временный файл
assert target.exists() and not list(fast_worker["images"].glob("*.restore-*"))
assert "<name>web02</name>" in virsh.defined_xml and str(target) in virsh.defined_xml and "<mac" not in virsh.defined_xml
assert not any(cmd[:2] == ["virsh", "undefine"] for cmd in virsh.calls)
assert not list((fast_worker["backups"] / "web01" / "20260924-0300").glob("restore-*.xml"))
def test_restore_requires_replace_confirmation(fast_worker):
make_backup(fast_worker)
with pytest.raises(backups.BackupError) as exc:
backups.start_restore("web01", "20260924-0300", "web01", False)
assert exc.value.code == "exists"
UNDEFINE = ["virsh", "undefine", "web01", "--remove-all-storage", "--nvram", "--managed-save", "--snapshots-metadata"]
def test_restore_replaces_existing_vm_only_after_disks_are_ready(fast_worker, monkeypatch):
make_backup(fast_worker)
virsh = FakeVirsh(running=True)
monkeypatch.setattr(core, "run_cmd", virsh)
monkeypatch.setattr(backups, "stream_cmd", fake_stream(calls=virsh.calls))
operation = backups.start_restore("web01", "20260924-0300", "", True)
fresh = core.read_operation(operation["id"])
assert fresh["status"] == "success", fresh["message"]
convert = next(index for index, cmd in enumerate(virsh.calls) if cmd[:2] == ["qemu-img", "convert"])
assert convert < virsh.calls.index(["virsh", "destroy", "web01"]) < virsh.calls.index(UNDEFINE)
assert virsh.calls.index(UNDEFINE) < next(index for index, cmd in enumerate(virsh.calls) if cmd[:2] == ["virsh", "define"])
assert (fast_worker["images"] / "web01.qcow2").exists() and not list(fast_worker["images"].glob("*.restore-*"))
assert "<name>web01</name>" in virsh.defined_xml
# при замене машина остаётся «той же»: UUID и MAC из копии сохраняются
assert "<uuid>6c1b3c1e-0000-0000-0000-000000000001</uuid>" in virsh.defined_xml and "52:54:00:aa:bb:cc" in virsh.defined_xml
def test_replace_restore_keeps_old_vm_when_copy_fails(fast_worker, monkeypatch):
make_backup(fast_worker)
virsh = FakeVirsh(running=True)
monkeypatch.setattr(core, "run_cmd", virsh)
monkeypatch.setattr(backups, "stream_cmd", fake_stream(code=1))
operation = backups.start_restore("web01", "20260924-0300", "web01", True)
fresh = core.read_operation(operation["id"])
assert fresh["status"] == "error" and "Не удалось восстановить диск vda" in fresh["message"] and "qemu-img" not in fresh["message"]
assert "qemu-img завершился с кодом 1" in fresh["log_tail"]
assert ["virsh", "destroy", "web01"] not in virsh.calls and UNDEFINE not in virsh.calls and virsh.running
assert not list(fast_worker["images"].iterdir())
def test_restore_refuses_when_space_is_tight_even_if_old_vm_would_free_it(fast_worker, monkeypatch):
make_backup(fast_worker)
monkeypatch.setattr(backups, "free_bytes", lambda path: 1024)
with pytest.raises(backups.BackupError) as exc:
backups.start_restore("web01", "20260924-0300", "web01", True)
assert exc.value.code == "space" and "рядом со старой машиной" in str(exc.value)
with pytest.raises(backups.BackupError) as exc:
backups.start_restore("web01", "20260924-0300", "web02", False)
assert exc.value.code == "space" and "рядом со старой машиной" not in str(exc.value)
def test_restore_ejects_iso_before_undefine(fast_worker, monkeypatch):
make_backup(fast_worker)
virsh = FakeVirsh(running=False)
base = virsh.__call__
def with_iso(cmd, timeout=12, **kw):
if cmd[:2] == ["virsh", "domblklist"]:
virsh.calls.append(list(cmd))
return fake_result(DOMBLKLIST_DETAILS.replace("sda -", "sda /var/lib/virtuality/iso/debian.iso"))
return base(cmd, timeout, **kw)
monkeypatch.setattr(core, "run_cmd", with_iso)
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
backups.start_restore("web01", "20260924-0300", "web01", True)
eject = ["virsh", "change-media", "web01", "sda", "--eject", "--config"]
assert eject in virsh.calls
assert virsh.calls.index(eject) < virsh.calls.index(UNDEFINE)
def test_restore_rejects_bad_name_and_incomplete_backup(fast_worker):
make_backup(fast_worker)
with pytest.raises(backups.BackupError) as exc:
backups.start_restore("web01", "20260924-0300", "-bad", False)
assert exc.value.code == "name"
make_backup(fast_worker, backup_id="20260901-0000", complete=False)
with pytest.raises(backups.BackupError) as exc:
backups.start_restore("web01", "20260901-0000", "web02", False)
assert "неполная" in str(exc.value)
def test_restore_cleans_up_on_define_failure(fast_worker, monkeypatch):
make_backup(fast_worker)
virsh = FakeVirsh(running=False)
base = virsh.__call__
def failing_define(cmd, timeout=12, **kw):
if cmd[:2] == ["virsh", "define"]:
return fake_result(ok=False, stderr="error: XML error: bad thing")
return base(cmd, timeout, **kw)
monkeypatch.setattr(core, "run_cmd", failing_define)
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
operation = backups.start_restore("web01", "20260924-0300", "web02", False)
fresh = core.read_operation(operation["id"])
assert fresh["status"] == "error" and "bad thing" in fresh["message"]
assert not (fast_worker["images"] / "web02.qcow2").exists() and not list(fast_worker["images"].glob("*.restore-*"))
def test_restore_disk_path_avoids_collisions(data_dirs):
(data_dirs["images"] / "web02.qcow2").write_bytes(b"x")
assert backups.restore_disk_path("web02", "vda", 0) == data_dirs["images"] / "web02-vda.qcow2"
assert backups.restore_disk_path("web02", "vdb", 1) == data_dirs["images"] / "web02-vdb.qcow2"
(data_dirs["images"] / "web02-vda.qcow2").write_bytes(b"x")
assert backups.restore_disk_path("web02", "vda", 0) == data_dirs["images"] / "web02-vda-2.qcow2"
# ---------------------------------------------------------------- страницы
def test_backup_pages_require_login(client):
for path in ("/backups", "/vm/web01/backups", "/backups/web01/20260924-0300/restore"):
response = client.get(path, follow_redirects=False)
assert response.status_code == 303 and response.headers["location"] == "/login"
def test_backups_page_lists_groups(logged_in, data_dirs):
make_backup(data_dirs, "web01", "20260924-0300", size=2_684_354_560, note="перед обновлением")
make_backup(data_dirs, "gone-vm", "20260901-1200", size=1024 ** 3)
html = logged_in.get("/backups").text
assert "24 сентября 2026, 03:00" in html and "2.5 ГБ" in html and "перед обновлением" in html
assert 'href="/backups/web01/20260924-0300/restore"' in html
assert "машина удалена" in html and 'href="/vm/web01/backups"' in html
assert "Резервные копии" in html
def test_backups_page_empty_state(logged_in):
html = logged_in.get("/backups").text
assert "Копий пока нет" in html
def test_vm_backups_page_and_tab_link(logged_in, data_dirs):
make_backup(data_dirs)
html = logged_in.get("/vm/web01/backups").text
assert 'action="/vm/web01/backups/create"' in html and "Создать копию" in html
assert "24 сентября 2026, 03:00" in html
assert 'href="/vm/web01/backups"' in logged_in.get("/vm/web01").text
assert logged_in.get("/vm/nope/backups", follow_redirects=False).headers["location"] == "/"
def test_vm_backup_create_route(logged_in, fast_worker, monkeypatch):
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
response = logged_in.post("/vm/web01/backups/create", data={"note": "перед обновлением"}, follow_redirects=False)
assert response.status_code == 303 and response.headers["location"].startswith("/operations/")
operation_id = response.headers["location"].rsplit("/", 1)[1]
assert core.read_operation(operation_id)["status"] == "success"
assert logged_in.get(f"/operations/{operation_id}").status_code == 200
def test_vm_backup_create_route_shows_error(logged_in, data_dirs, monkeypatch):
monkeypatch.setattr(backups, "free_bytes", lambda path: 0)
response = logged_in.post("/vm/web01/backups/create", data={"note": ""}, follow_redirects=False)
assert response.status_code == 303
assert "backup_error=" in response.headers["location"] and "Недостаточно" in unquote(response.headers["location"])
assert "Недостаточно места" in logged_in.get(response.headers["location"]).text
def test_restore_page_and_submit(logged_in, fast_worker, monkeypatch):
make_backup(fast_worker)
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
html = logged_in.get("/backups/web01/20260924-0300/restore").text
assert 'name="replace"' in html and "Заменить существующую машину web01" in html
assert "Старая машина удаляется только после распаковки" in html and "Сетевые адреса сохраняются" in html
response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web01", "replace": ""})
assert response.status_code == 400 and "уже есть на сервере" in response.text
response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "web02", "replace": ""}, follow_redirects=False)
assert response.status_code == 303 and response.headers["location"].startswith("/operations/")
response = logged_in.post("/backups/web01/20260924-0300/restore", data={"name": "-bad"})
assert response.status_code == 400
assert logged_in.get("/backups/web01/20990101-0000/restore", follow_redirects=False).headers["location"].startswith("/backups?backup_error=")
def test_delete_backup_route(logged_in, data_dirs):
path = make_backup(data_dirs)
response = logged_in.post("/backups/web01/20260924-0300/delete", data={"next": "/vm/web01/backups"}, follow_redirects=False)
assert response.status_code == 303 and response.headers["location"].startswith("/vm/web01/backups?backup_message=")
assert not path.exists() and not path.parent.exists()
response = logged_in.post("/backups/web01/20260924-0300/delete", data={"next": "http://evil"}, follow_redirects=False)
assert response.headers["location"].startswith("/backups?backup_error=")
def test_delete_backup_path_traversal(logged_in, data_dirs, tmp_path):
victim = tmp_path / "victim"
victim.mkdir()
(victim / "20260924-0300").mkdir()
# %2F раскрывается до маршрутизации: лишние сегменты не подходят ни под один маршрут
assert logged_in.post("/backups/..%2Fvictim/20260924-0300/delete", follow_redirects=False).status_code == 404
assert logged_in.post("/backups/web01/..%2F..%2Fvictim/delete", follow_redirects=False).status_code == 404
# а сама проверка путей — в delete_backup, независимо от маршрутизации
with pytest.raises(backups.BackupError):
backups.delete_backup("../victim", "20260924-0300")
with pytest.raises(backups.BackupError):
backups.delete_backup("web01", "../x")
assert (victim / "20260924-0300").exists()
def test_delete_backup_refused_while_copy_is_in_use(fast_worker, monkeypatch):
monkeypatch.setattr(core, "run_operation", lambda operation, worker: None) # задачи остаются в очереди
monkeypatch.setattr(core, "run_cmd", FakeVirsh(running=False))
queued = backups.create_backup("web01")
with pytest.raises(backups.BackupError) as exc:
backups.delete_backup("web01", queued["backup_id"])
assert "идёт работа" in str(exc.value)
core.finish_operation(queued, False, "прервано")
backups.delete_backup("web01", queued["backup_id"])
assert not (fast_worker["backups"] / "web01").exists()
path = make_backup(fast_worker)
restore = backups.start_restore("web01", "20260924-0300", "web02", False) # под другим именем: задача записана на web02
second = backups.start_restore("web01", "20260924-0300", "web03", False) # две распаковки одной копии сразу
with pytest.raises(backups.BackupError) as exc:
backups.delete_backup("web01", "20260924-0300")
assert "идёт работа" in str(exc.value)
core.finish_operation(second, False, "прервано") # первая ещё идёт — копию по-прежнему нельзя удалить
with pytest.raises(backups.BackupError):
backups.delete_backup("web01", "20260924-0300")
core.finish_operation(restore, False, "прервано")
backups.delete_backup("web01", "20260924-0300")
assert not path.exists()
def test_late_restart_watch_is_cancelled_by_user_action(fast_worker, monkeypatch):
import threading
virsh = FakeVirsh(running=True, shuts_down=False)
monkeypatch.setattr(core, "run_cmd", virsh)
monkeypatch.setattr(backups, "stream_cmd", fake_stream())
monkeypatch.setattr(backups, "LATE_SHUTDOWN_WATCH", 5.0)
watchers = []
monkeypatch.setattr(backups, "start_thread", lambda target, *args: watchers.append(threading.Thread(target=target, args=args)) or watchers[-1].start())
operation = backups.create_backup("web01")
for _ in range(200): # наблюдатель запущен и ждёт
if "web01" in backups.LATE_WATCHES:
break
__import__("time").sleep(0.01)
backups.cancel_late_restart("web01") # пользователь нажал «Выключить» в панели
virsh.running = False
watchers[0].join(timeout=5)
assert not watchers[0].is_alive() and ["virsh", "start", "web01"] not in virsh.calls
assert "Наблюдение снято" in core.read_operation(operation["id"])["log_tail"]
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
-80
Просмотреть файл
@@ -1,80 +0,0 @@
import cloud_images
def test_catalog_entries_consistent():
keys = [entry["key"] for entry in cloud_images.CATALOG]
assert len(keys) == len(set(keys))
for entry in cloud_images.CATALOG:
assert entry["arch"] in ("x86_64", "aarch64")
assert entry["url"].startswith("https://")
assert cloud_images.safe_image_filename(entry["filename"])
def test_catalog_entry_lookup():
assert cloud_images.catalog_entry("ubuntu-24.04-x86_64") is not None
assert cloud_images.catalog_entry("nope") is None
def test_catalog_for_arch_puts_native_first():
entries = cloud_images.catalog_for_arch("aarch64")
natives = [e["native"] for e in entries]
assert natives == sorted(natives, reverse=True)
assert entries[0]["arch"] == "aarch64"
entries_x86 = cloud_images.catalog_for_arch("x86_64")
assert entries_x86[0]["arch"] == "x86_64"
def test_safe_image_filename():
assert cloud_images.safe_image_filename("ubuntu-24.04-x86_64.qcow2")
assert cloud_images.safe_image_filename("img.img")
assert cloud_images.safe_image_filename("../../etc/passwd") is None
assert cloud_images.safe_image_filename("../escape.qcow2") == "escape.qcow2" # basename-санитизация
assert cloud_images.safe_image_filename("evil.iso") is None
def test_image_path_traversal_guard(tmp_path, monkeypatch):
monkeypatch.setattr(cloud_images, "CLOUD_IMAGES_DIR", tmp_path)
assert cloud_images.image_path_by_name("ok.qcow2") == tmp_path / "ok.qcow2"
# Каталоги отбрасываются до basename — результат всегда внутри CLOUD_IMAGES_DIR
assert cloud_images.image_path_by_name("../escape.qcow2") == tmp_path / "escape.qcow2"
assert cloud_images.image_path_by_name("bad name.qcow2") is None
def test_valid_cloud_username():
assert cloud_images.valid_cloud_username("admin")
assert cloud_images.valid_cloud_username("_svc-user")
assert not cloud_images.valid_cloud_username("Admin")
assert not cloud_images.valid_cloud_username("1abc")
assert not cloud_images.valid_cloud_username("")
def test_valid_ssh_key():
assert cloud_images.valid_ssh_key("") # ключ необязателен
assert cloud_images.valid_ssh_key("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample user@host")
assert cloud_images.valid_ssh_key("ssh-rsa AAAAB3NzaC1yc2E=")
assert not cloud_images.valid_ssh_key("not a key")
assert not cloud_images.valid_ssh_key("ssh-ed25519")
def test_build_user_data_with_password():
data = cloud_images.build_user_data("vm1", "admin", 'p"ss', "")
assert data.startswith("#cloud-config")
assert "hostname: vm1" in data
assert "name: admin" in data
assert "ssh_pwauth: true" in data
assert '"p\\"ss"' in data # пароль экранирован как JSON/YAML-строка
assert "ssh_authorized_keys" not in data
def test_build_user_data_with_key_only():
key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample user@host"
data = cloud_images.build_user_data("vm1", "admin", "", key)
assert "ssh_pwauth: false" in data
assert key in data
assert "chpasswd" not in data
def test_build_meta_data():
meta = cloud_images.build_meta_data("vm1")
assert "instance-id: virtuality-vm1" in meta
assert "local-hostname: vm1" in meta
+206
Просмотреть файл
@@ -0,0 +1,206 @@
import json
from pathlib import Path
import pytest
import network_core
from conftest import ROOT
import restore_network
import update_core
def test_parse_port_range():
assert network_core.parse_port_range("80") == (80, 80)
assert network_core.parse_port_range("10000-10010") == (10000, 10010)
assert network_core.parse_port_range("10000:10010") == (10000, 10010)
for bad in ("", "0", "70000", "20-10", "abc", "80;rm"):
with pytest.raises(network_core.NetworkError):
network_core.parse_port_range(bad)
def test_add_port_forward_rejects_overlap(data_dirs):
network_core.add_port_forward("web01", "192.168.100.51", "8000-8010", "8000-8010", "tcp")
with pytest.raises(network_core.NetworkError):
network_core.add_port_forward("web01", "192.168.100.52", "8005", "80", "tcp")
network_core.add_port_forward("web01", "192.168.100.52", "8005", "80", "udp")
def test_add_port_forward_rejects_mismatched_ranges(data_dirs):
with pytest.raises(network_core.NetworkError):
network_core.add_port_forward("web01", "192.168.100.51", "8000-8010", "80", "tcp")
def test_render_nft_rules_range(data_dirs):
network_core.add_port_forward("web01", "192.168.100.51", "10000-10002", "20000-20002", "udp")
rules = network_core.render_nft_rules()
assert 'iifname "eth0" udp dport 10000-10002 dnat to 192.168.100.51:20000-20002' in rules
assert "masquerade" in rules
def test_restore_network_noop_without_config(data_dirs, capsys):
assert restore_network.main() == 0
assert "nothing to restore" in capsys.readouterr().out
def test_restore_network_applies_rules(data_dirs):
data_dirs["network"].joinpath("port_forwards.json").write_text(json.dumps([
{"id": "1", "vm_name": "web01", "guest_ip": "192.168.100.51", "external_port_start": 2222, "external_port_end": 2222, "guest_port_start": 22, "guest_port_end": 22, "protocol": "tcp"},
]))
assert restore_network.main() == 0
assert "dport 2222 dnat to 192.168.100.51:22" in (data_dirs["nft"] / "virtuality.nft").read_text()
def test_version_tuple_ordering():
assert update_core.version_tuple("0.9.6") < update_core.version_tuple("0.10.0")
assert update_core.version_tuple("v1.0") == update_core.version_tuple("1.0.0")
def test_missing_versions():
manifest = {"versions": [{"version": v} for v in ("0.9.5", "0.9.6", "0.10.0", "0.10.1")]}
missed = update_core.missing_versions("0.9.6", "0.10.1", manifest)
assert [item["version"] for item in missed] == ["0.10.0", "0.10.1"]
def test_versions_manifest_matches_version_file():
root = Path(__file__).resolve().parents[1]
version = (root / "VERSION").read_text().strip()
manifest = json.loads((root / "updates" / "versions.json").read_text())
assert version in [item["version"] for item in manifest["versions"]]
def test_presenters_parse_virsh_tables():
import presenters
disks = presenters.parse_domblklist(" Type Device Target Source\n------------------------------------\n file disk vda /var/lib/virtuality/images/web01.qcow2\n file cdrom sda -\n")
assert [d["name"] for d in disks] == ["web01.qcow2", "Пусто"]
assert disks[1]["is_cdrom"]
nics = presenters.parse_domiflist(" Interface Type Source Model MAC\n---\n vnet0 network virtuality-nat virtio 52:54:00:aa:bb:cc\n")
assert nics[0]["mac"] == "52:54:00:aa:bb:cc"
info = presenters.parse_dominfo("State: shut off\nCPU(s): 4\nMax memory: 8388608 KiB\nUsed memory: 8388608 KiB\nAutostart: disable\n")
assert (info["vcpus"], info["memory_mb"], info["autostart"]) == (4, 8192, False)
assert presenters.vm_state("shut off")["label"] == "Выключена"
assert presenters.format_mb(1536) == "1.5 ГБ"
assert presenters.plural(22, "ядро", "ядра", "ядер") == "ядра"
def test_update_not_offered_when_local_is_newer(tmp_path, monkeypatch, data_dirs):
import subprocess
def git(*args, cwd):
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
remote, local = tmp_path / "remote", tmp_path / "local"
remote.mkdir()
git("init", "-q", "-b", "main", cwd=remote)
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "base", cwd=remote)
git("clone", "-q", str(remote), str(local), cwd=tmp_path)
(local / "VERSION").write_text("0.10.0\n")
git("add", "VERSION", cwd=local)
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "newer local build", cwd=local)
monkeypatch.setattr(update_core, "SOURCE_DIR", local)
assert update_core.check_updates(fetch=True)["has_update"] is False
git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "upstream", cwd=remote)
git("reset", "-q", "--hard", "origin/main", cwd=local)
assert update_core.check_updates(fetch=True)["has_update"] is True
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
def git(*args, cwd):
subprocess.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", *args], cwd=cwd, check=True, capture_output=True)
remote, local = tmp_path / "remote", tmp_path / "local"
remote.mkdir()
git("init", "-q", "-b", "main", cwd=remote)
(remote / "VERSION").write_text("0.10.0\n")
git("add", "VERSION", cwd=remote)
git("commit", "-q", "-m", "0.10.0", cwd=remote)
git("clone", "-q", str(remote), str(local), cwd=tmp_path)
monkeypatch.setattr(update_core, "SOURCE_DIR", local)
monkeypatch.setattr(update_core, "NODE_ENV", tmp_path / "web.env")
monkeypatch.delenv("VIRTUALITY_UPDATE_CHANNEL", raising=False)
assert update_core.update_channel() == "stable"
# Work on main without a release: stable follows main until the first tag exists.
(remote / "VERSION").write_text("0.11.0\n")
git("commit", "-q", "-am", "0.11.0", cwd=remote)
data = update_core.check_updates(fetch=True)
assert data["has_update"] and data["target"] == "main"
# With a release tag, stable offers the tag and ignores newer commits on main.
git("tag", "v0.11.0", cwd=remote)
git("commit", "-q", "--allow-empty", "-m", "work in progress", cwd=remote)
data = update_core.check_updates(fetch=True)
assert data["channel"] == "stable" and data["target"] == "v0.11.0" and data["latest_version"] == "0.11.0" and data["has_update"]
git("checkout", "-q", "v0.11.0", cwd=local)
assert update_core.check_updates(fetch=True)["has_update"] is False
# The early-access channel sees every change on main.
(tmp_path / "web.env").write_text("VIRTUALITY_UPDATE_CHANNEL=main\n")
data = update_core.check_updates(fetch=True)
assert data["channel"] == "main" and data["target"] == "main" and data["has_update"]
@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
+638
Просмотреть файл
@@ -0,0 +1,638 @@
"""Скачивание образов по ссылке и из каталога: парсер контрольных сумм,
проверка ссылок, фоновая загрузка с локального http-сервера, отмена."""
import hashlib
import lzma
import socket
import ssl
import threading
import time
import urllib.error
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import unquote
import pytest
import app # noqa: F401 — подключает features.downloads к приложению
import catalog
import core
import features.downloads as downloads
FIXTURES = Path(__file__).resolve().parent / "fixtures"
MB = 1024 * 1024
SLOW_TOTAL = 4 * MB
CHUNKED_TOTAL = 40 * 64 * 1024
class Handler(SimpleHTTPRequestHandler):
"""Раздаёт файлы из каталога www; /slow.iso отдаёт 4 МБ по 64 КБ с паузами, /redirect.iso — перенаправляет,
/chunked.iso — 2,5 МБ с паузами без заголовка Content-Length (размер заранее неизвестен)."""
root = ""
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=self.root, **kwargs)
def log_message(self, *args): # noqa: D102 — тише в тестах
pass
def do_GET(self):
if self.path == "/redirect.iso":
self.send_response(302)
self.send_header("Location", "/small.iso")
self.end_headers()
return
if self.path in ("/slow.iso", "/chunked.iso"):
total = SLOW_TOTAL if self.path == "/slow.iso" else CHUNKED_TOTAL
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
if self.path == "/slow.iso":
self.send_header("Content-Length", str(total))
self.end_headers()
try:
for _ in range(total // (64 * 1024)):
self.wfile.write(b"\0" * 64 * 1024)
self.wfile.flush()
time.sleep(0.05)
except (BrokenPipeError, ConnectionResetError):
pass
return
super().do_GET()
@pytest.fixture()
def www(tmp_path):
root = tmp_path / "www"
root.mkdir()
(root / "small.iso").write_bytes(b"ISO" * 5000)
handler = type("TestHandler", (Handler,), {"root": str(root)})
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield SimpleNamespace(root=root, url=f"http://127.0.0.1:{server.server_port}")
server.shutdown()
server.server_close()
def wait_for(operation_id: str, timeout: float = 20.0) -> dict:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
operation = core.read_operation(operation_id)
if operation and operation.get("status") in ("success", "error"):
return operation
time.sleep(0.05)
raise AssertionError("операция не завершилась вовремя")
def wait_until(predicate, timeout: float = 10.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.05)
raise AssertionError("условие не наступило вовремя")
def message_of(operation_id: str) -> str:
return str((core.read_operation(operation_id) or {}).get("message", ""))
def seed_running_download(kind: str, filename: str, **meta) -> dict:
"""Операция «скачивается» без потока — как после перезапуска панели."""
target = downloads.target_path(kind, filename)
operation = core.new_operation("download", f"Скачивание {filename}", download_kind=kind, url=f"http://127.0.0.1:9/{filename}", filename=filename, target_path=str(target), cancel_requested=False, **meta)
core.update_operation(operation, status="running")
return operation
# ---------------------------------------------------------------- parsers & formatting
def test_parse_checksums_ubuntu_star_format():
text = (FIXTURES / "SHA256SUMS").read_text()
assert downloads.parse_checksums(text, "resolute-server-cloudimg-amd64.img") == "5861314d7fccb39c2192173240eab44fa35ca66426201ca2acd0630a6258dd51"
assert downloads.parse_checksums(text, "resolute-server-cloudimg-arm64.img").startswith("f6916295")
assert downloads.parse_checksums(text, "missing.img") is None
def test_parse_checksums_debian_two_spaces_and_comments():
text = (FIXTURES / "SHA512SUMS").read_text()
assert downloads.parse_checksums(text, "debian-13-genericcloud-amd64.qcow2").startswith("1412d62c2d55")
assert downloads.parse_checksums(text, "debian-13-genericcloud-arm64.qcow2").startswith("27c03aab0b5e")
def test_parse_checksums_alpine_bare_hash_and_bsd_style():
bare = (FIXTURES / "nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512").read_text()
assert downloads.parse_checksums(bare, "anything.qcow2") == bare.strip().lower()
bsd = "SHA256 (disk.qcow2) = ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789\n"
assert downloads.parse_checksums(bsd, "disk.qcow2") == "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
assert downloads.parse_checksums(bsd, "other.qcow2") is None
assert downloads.parse_checksums("not a checksum file\n", "disk.qcow2") is None
def test_human_size_and_eta():
assert downloads.human_size(0) == "0 Б"
assert downloads.human_size(1023) == "1023 Б"
assert downloads.human_size(1536) == "1,5 КБ"
assert downloads.human_size(245 * MB) == "245 МБ"
assert downloads.human_size(int(3.1 * 1024 * MB)) == "3,1 ГБ"
assert downloads.human_size(10 * MB) == "10 МБ"
assert downloads.human_eta(45) == "45 с"
assert downloads.human_eta(244) == "4 мин"
assert downloads.human_eta(3700) == "1 ч 2 мин"
assert downloads.human_eta(7200) == "2 ч"
def test_progress_message():
assert downloads.progress_message(245 * MB, int(3.1 * 1024 * MB), 12 * MB) == "245 МБ из 3,1 ГБ · 12 МБ/с · осталось 4 мин"
assert downloads.progress_message(245 * MB, None, 12 * MB) == "245 МБ · 12 МБ/с"
assert downloads.progress_message(0, 100 * MB, None) == "0 Б из 100 МБ"
# ---------------------------------------------------------------- validation
@pytest.mark.parametrize("url", ["", "ftp://example.org/x.iso", "file:///etc/passwd", "javascript:alert(1)", "http://", "http:///x.iso", "http://example.org/a b.iso", "http://example.org:abc/x.iso", "http://[::1/x.iso"])
def test_validate_url_rejects(url):
with pytest.raises(downloads.DownloadError):
downloads.validate_url(url)
def test_encode_url_handles_cyrillic_and_keeps_encoded_links():
encoded = downloads.encode_url("http://пример.рф:8080/папка/образ диска.iso?имя=ы#x")
assert encoded == "http://xn--e1afmkfd.xn--p1ai:8080/%D0%BF%D0%B0%D0%BF%D0%BA%D0%B0/%D0%BE%D0%B1%D1%80%D0%B0%D0%B7%20%D0%B4%D0%B8%D1%81%D0%BA%D0%B0.iso?%D0%B8%D0%BC%D1%8F=%D1%8B"
assert downloads.encode_url(encoded) == encoded
for same in ("https://user:pw@h.example/a%20b/x.iso?a=1&b=%2F", "http://[::1]:9/x.iso", "http://127.0.0.1/x.iso"):
assert downloads.encode_url(same) == same
assert downloads.validate_url("https://пример.рф/образ.iso") == "https://пример.рф/образ.iso"
def test_describe_network_error_is_friendly():
describe = downloads.describe_network_error
assert describe(urllib.error.HTTPError("u", 404, "Not Found", {}, None)) == "Файл по ссылке не найден (ошибка 404)"
assert describe(urllib.error.HTTPError("u", 403, "Forbidden", {}, None)) == "Сервер запретил скачивание (ошибка 403)"
assert describe(urllib.error.HTTPError("u", 418, "Teapot", {}, None)) == "Сервер ответил ошибкой 418"
assert describe(urllib.error.URLError(socket.gaierror(-2, "Name or service not known"))) == "Не удалось найти сервер по этой ссылке — проверьте адрес"
assert describe(urllib.error.URLError(ConnectionRefusedError(111, "Connection refused"))) == "Сервер отказал в подключении"
assert describe(urllib.error.URLError(ssl.SSLCertVerificationError("CERTIFICATE_VERIFY_FAILED"))) == "У сервера проблема с сертификатом безопасности"
assert describe(urllib.error.URLError(TimeoutError())).startswith("Сервер не отвечает")
assert describe(UnicodeEncodeError("ascii", "образ", 0, 5, "ordinal not in range(128)")) == "Ссылка содержит символы, которые сервер не понимает"
assert describe(ConnectionResetError(104, "reset")) == "Сервер разорвал соединение"
assert describe(RuntimeError("boom")) == "boom"
def test_filename_from_url_sanitises_and_checks_suffix():
assert downloads.filename_from_url("https://h.example/a%20b%20(1).ISO?x=1#frag", "iso") == "a-b-_1.iso"
assert downloads.filename_from_url("https://h.example/dir/debian-13.qcow2", "disk") == "debian-13.qcow2"
assert downloads.filename_from_url("https://h.example/rpi/2026-01-01-raspios.img.xz", "disk") == "2026-01-01-raspios.img.xz"
assert downloads.filename_from_url("https://h.example/../../etc.iso", "iso") == "etc.iso"
for url, kind in (("https://h.example/", "iso"), ("https://h.example/setup.exe", "iso"), ("https://h.example/x.iso", "disk"), ("https://h.example/x.qcow2", "iso")):
with pytest.raises(downloads.DownloadError):
downloads.filename_from_url(url, kind)
def test_start_download_refuses_existing_file(data_dirs):
(data_dirs["iso"] / "small.iso").write_bytes(b"x")
with pytest.raises(downloads.DownloadError, match="уже есть"):
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
def test_start_download_refuses_when_extracted_image_exists(data_dirs):
# Из pi.img.xz после скачивания остаётся pi.img (или pi.qcow2 после конвертации) — это тот же образ.
(data_dirs["disk_images"] / "pi.img").write_bytes(b"x")
with pytest.raises(downloads.DownloadError, match="pi.img уже есть"):
downloads.start_download("http://127.0.0.1:9/pi.img.xz", "disk")
(data_dirs["disk_images"] / "pi.img").unlink()
(data_dirs["disk_images"] / "pi.qcow2").write_bytes(b"x")
with pytest.raises(downloads.DownloadError, match="pi.qcow2 уже есть"):
downloads.start_download("http://127.0.0.1:9/pi.img", "disk")
def test_start_download_refuses_when_already_downloading(data_dirs, monkeypatch):
seed_running_download("iso", "small.iso")
with pytest.raises(downloads.DownloadError, match="уже скачивается"):
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
# проверка идёт по всем операциям, а не по последним 50
for index in range(60):
core.new_operation("vm_create", f"Создание VM {index}")
with pytest.raises(downloads.DownloadError, match="уже скачивается"):
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
def test_start_download_checks_free_space(data_dirs, monkeypatch):
monkeypatch.setattr(downloads.shutil, "disk_usage", lambda path: SimpleNamespace(free=1000, total=1000, used=0))
with pytest.raises(downloads.DownloadError, match="мало места"):
downloads.start_download("http://127.0.0.1:9/small.iso", "iso")
def test_image_candidates():
assert downloads.image_candidates("a.img.xz") == ["a.img.xz", "a.img", "a.qcow2"]
assert downloads.image_candidates("a.img") == ["a.img", "a.qcow2"]
assert downloads.image_candidates("a.qcow2") == ["a.qcow2"]
# ---------------------------------------------------------------- background download
def test_download_iso_success_refreshes_pool(data_dirs, www, monkeypatch):
calls = []
monkeypatch.setattr(downloads, "run_cmd", lambda cmd, timeout=12: calls.append(cmd) or {"ok": True, "stdout": "", "stderr": ""})
operation = downloads.start_download(f"{www.url}/small.iso", "iso")
assert operation["type"] == "download" and operation["download_kind"] == "iso"
done = wait_for(operation["id"])
assert done["status"] == "success", done
assert done["progress"] == 100
assert done["total_bytes"] == 15000 and done["downloaded_bytes"] == 15000
assert (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000
assert not (data_dirs["iso"] / ".small.iso.part").exists()
assert ["virsh", "pool-refresh", "virtuality-iso"] in calls
assert "Готово" in done["message"]
wait_until(lambda: not downloads.is_live(operation["id"])) # поток снимает отметку сразу после завершения
def test_download_follows_redirect(data_dirs, www):
operation = downloads.start_download(f"{www.url}/redirect.iso", "iso")
done = wait_for(operation["id"])
assert done["status"] == "success", done
assert (data_dirs["iso"] / "redirect.iso").stat().st_size == 15000
assert "Перенаправление" in done["log_tail"]
def test_download_with_cyrillic_file_name_in_url(data_dirs, www):
(www.root / "образ.iso").write_bytes(b"cyrillic" * 10)
operation = downloads.start_download(f"{www.url}/образ.iso", "iso")
assert operation["filename"] == "virtuality-iso.iso"
done = wait_for(operation["id"])
assert done["status"] == "success", done
assert (data_dirs["iso"] / "virtuality-iso.iso").read_bytes() == b"cyrillic" * 10
assert "Перенаправление" not in done["log_tail"]
def test_download_missing_file_fails(data_dirs, www):
operation = downloads.start_download(f"{www.url}/nope.iso", "iso")
done = wait_for(operation["id"])
assert done["status"] == "error"
assert done["message"] == "Файл по ссылке не найден (ошибка 404)"
assert "HTTP Error 404" in done["log_tail"] # исходный текст остаётся в журнале
assert not list(data_dirs["iso"].iterdir())
def test_download_refuses_when_disk_fills_up(data_dirs, www, monkeypatch):
real = downloads.free_space_ok
seen = []
def fake(directory, needed):
seen.append(needed)
return real(directory, needed) if len(seen) == 1 else (False, 42)
monkeypatch.setattr(downloads, "free_space_ok", fake)
operation = downloads.start_download(f"{www.url}/small.iso", "iso")
done = wait_for(operation["id"])
assert done["status"] == "error"
assert "мало места" in done["message"] and "42 Б" in done["message"]
assert not list(data_dirs["iso"].iterdir())
def test_download_without_content_length_stops_when_disk_fills_up(data_dirs, www, monkeypatch):
real = downloads.free_space_ok
seen = []
def fake(directory, needed):
seen.append(needed)
return real(directory, needed) if len(seen) < 3 else (False, 42)
monkeypatch.setattr(downloads, "free_space_ok", fake)
operation = downloads.start_download(f"{www.url}/chunked.iso", "iso")
done = wait_for(operation["id"])
assert done["status"] == "error"
assert "закончилось место" in done["message"] and "42 Б" in done["message"]
assert done["total_bytes"] is None and seen == [None, None, None]
assert not (data_dirs["iso"] / ".chunked.iso.part").exists()
assert not list(data_dirs["iso"].iterdir())
def test_download_cancel(data_dirs, www, logged_in):
operation = downloads.start_download(f"{www.url}/slow.iso", "iso")
wait_until(lambda: (core.read_operation(operation["id"]) or {}).get("status") == "running")
wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists())
response = logged_in.post(f"/api/operations/{operation['id']}/cancel")
assert response.status_code == 200 and response.json()["ok"] is True
done = wait_for(operation["id"])
assert done["status"] == "error"
assert done["message"] == "Скачивание отменено"
assert done["cancelled"] is True and done["cancel_requested"] is True
assert not (data_dirs["iso"] / ".slow.iso.part").exists()
assert not (data_dirs["iso"] / "slow.iso").exists()
# The worker thread releases its markers right after it finishes the operation.
wait_until(lambda: not downloads.cancel_requested(operation["id"]) and not downloads.is_live(operation["id"]))
def test_cancel_flag_on_disk_is_honoured_and_not_overwritten(data_dirs, www):
# Отмену могла принять другая копия панели: поток видит флаг в JSON операции раз в секунду.
operation = downloads.start_download(f"{www.url}/slow.iso", "iso")
wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists())
fresh = core.read_operation(operation["id"])
fresh.pop("log_tail", None)
core.update_operation(fresh, cancel_requested=True, message="Останавливаем скачивание…")
done = wait_for(operation["id"])
assert done["status"] == "error" and done["cancelled"] is True and done["cancel_requested"] is True
assert not (data_dirs["iso"] / ".slow.iso.part").exists()
def test_second_download_of_same_file_does_not_touch_the_first(data_dirs, www):
first = downloads.start_download(f"{www.url}/slow.iso", "iso")
part = data_dirs["iso"] / ".slow.iso.part"
wait_until(part.exists)
# Второй запуск, проскочивший проверку «уже скачивается» (например, из другого процесса): операцию создаём руками.
second = core.new_operation("download", "Скачивание slow.iso", download_kind="iso", url=f"{www.url}/slow.iso", filename="slow.iso", target_path=first["target_path"], cancel_requested=False)
core.update_operation(second, status="running")
downloads.download_worker(second)
done = core.read_operation(second["id"])
assert done["status"] == "error" and "уже скачивается" in done["message"]
assert part.exists() # чужой файл не тронут
assert wait_for(first["id"])["status"] == "success"
assert (data_dirs["iso"] / "slow.iso").stat().st_size == SLOW_TOTAL
def test_leftover_part_file_without_owner_does_not_block_download(data_dirs, www):
part = data_dirs["iso"] / ".small.iso.part"
part.write_bytes(b"half of a crashed download")
operation = downloads.start_download(f"{www.url}/small.iso", "iso")
done = wait_for(operation["id"])
assert done["status"] == "success", done
assert (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000
assert not part.exists()
def test_stale_downloads_are_reaped_on_startup(data_dirs, www, monkeypatch):
stale = seed_running_download("iso", "small.iso")
part = downloads.part_path(Path(stale["target_path"]))
part.write_bytes(b"half")
(data_dirs["disk_images"] / ".orphan.img.part").write_bytes(b"x")
live = downloads.start_download(f"{www.url}/slow.iso", "iso")
wait_until(lambda: (data_dirs["iso"] / ".slow.iso.part").exists())
assert downloads.reap_stale_downloads() == 1
closed = core.read_operation(stale["id"])
assert closed["status"] == "error" and closed["interrupted"] is True and "перезапущена" in closed["message"]
assert not part.exists() and not (data_dirs["disk_images"] / ".orphan.img.part").exists()
assert (data_dirs["iso"] / ".slow.iso.part").exists() # живое скачивание не трогаем
assert wait_for(live["id"])["status"] == "success"
# после закрытия зависшей операции тот же файл можно скачать снова
done = wait_for(downloads.start_download(f"{www.url}/small.iso", "iso")["id"])
assert done["status"] == "success" and (data_dirs["iso"] / "small.iso").read_bytes() == b"ISO" * 5000
assert downloads.close_stale_downloads() is None
def test_cancel_of_stale_download_finishes_it_immediately(data_dirs, logged_in):
stale = seed_running_download("disk", "y.qcow2")
part = downloads.part_path(Path(stale["target_path"]))
part.write_bytes(b"x")
response = logged_in.post(f"/api/operations/{stale['id']}/cancel")
operation = response.json()["operation"]
assert response.status_code == 200 and operation["status"] == "error" and operation["cancelled"] is True
assert operation["message"] == "Скачивание отменено" and operation["cancel_requested"] is True
assert not part.exists()
assert not downloads.cancel_requested(stale["id"])
assert logged_in.post(f"/api/operations/{stale['id']}/cancel").status_code == 409
def test_catalog_download_verifies_checksum(data_dirs, www):
payload = b"QCOW2 image bytes" * 100
(www.root / "disk.qcow2").write_bytes(payload)
(www.root / "SHA256SUMS").write_text(f"{hashlib.sha256(payload).hexdigest()} *disk.qcow2\n")
operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256", catalog_id="test")
done = wait_for(operation["id"])
assert done["status"] == "success", done
assert "совпала" in done["log_tail"]
assert (data_dirs["disk_images"] / "disk.qcow2").read_bytes() == payload
def test_catalog_download_checksum_mismatch_deletes_file(data_dirs, www):
(www.root / "disk.qcow2").write_bytes(b"corrupted" * 100)
(www.root / "SHA512SUMS").write_text(f"{'0' * 128} disk.qcow2\n")
operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA512SUMS", algo="sha512")
done = wait_for(operation["id"])
assert done["status"] == "error"
assert "Контрольная сумма не совпала" in done["message"]
assert not list(data_dirs["disk_images"].iterdir())
def test_catalog_download_fails_without_sums_entry(data_dirs, www):
(www.root / "disk.qcow2").write_bytes(b"data")
(www.root / "SHA256SUMS").write_text(f"{'a' * 64} *other.qcow2\n")
operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256")
done = wait_for(operation["id"])
assert done["status"] == "error"
assert "нет записи" in done["message"]
assert not list(data_dirs["disk_images"].iterdir())
def test_cancel_during_checksum_verification_removes_file(data_dirs, www, logged_in, monkeypatch):
payload = b"QCOW2 image bytes" * 100
(www.root / "disk.qcow2").write_bytes(payload)
(www.root / "SHA256SUMS").write_text(f"{hashlib.sha256(payload).hexdigest()} *disk.qcow2\n")
def slow_digest(path, algo, on_progress=None):
for _ in range(200):
time.sleep(0.05)
if on_progress:
on_progress(1)
return hashlib.sha256(payload).hexdigest()
monkeypatch.setattr(downloads, "file_digest", slow_digest)
operation = downloads.start_download(f"{www.url}/disk.qcow2", "disk", sums_url=f"{www.url}/SHA256SUMS", algo="sha256")
wait_until(lambda: "Проверяем контрольную сумму" in message_of(operation["id"]))
assert logged_in.post(f"/api/operations/{operation['id']}/cancel").status_code == 200
done = wait_for(operation["id"])
assert done["status"] == "error" and done["cancelled"] is True and done["message"] == "Скачивание отменено"
assert "во время проверки" in done["log_tail"]
assert not list(data_dirs["disk_images"].iterdir())
def test_disk_download_extracts_xz_and_starts_conversion(data_dirs, www, monkeypatch):
raw = bytes(range(256)) * 256
(www.root / "pi.img.xz").write_bytes(lzma.compress(raw))
started = []
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path) or {"id": "conv-1"})
operation = downloads.start_download(f"{www.url}/pi.img.xz", "disk")
done = wait_for(operation["id"])
assert done["status"] == "success", done
assert (data_dirs["disk_images"] / "pi.img").read_bytes() == raw
assert not (data_dirs["disk_images"] / "pi.img.xz").exists()
assert started == [data_dirs["disk_images"] / "pi.img"]
assert done["convert_operation_id"] == "conv-1"
assert "qcow2" in done["message"]
def test_cancel_during_unpack_removes_archive_and_partial_image(data_dirs, www, logged_in, monkeypatch):
raw = bytes(range(256)) * 4096 # 1 МБ, распаковка кусками по 16 КБ с паузой — успеваем отменить
(www.root / "pi.img.xz").write_bytes(lzma.compress(raw))
real = downloads.extract_xz_image
def slow_extract(compressed, should_stop=None):
def stop():
time.sleep(0.05)
return bool(should_stop and should_stop())
return real(compressed, stop)
started = []
monkeypatch.setattr(downloads, "extract_xz_image", slow_extract)
monkeypatch.setattr(downloads, "CHUNK_SIZE", 16 * 1024)
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path))
operation = downloads.start_download(f"{www.url}/pi.img.xz", "disk")
wait_until(lambda: "Распаковываем" in message_of(operation["id"]))
assert logged_in.post(f"/api/operations/{operation['id']}/cancel").status_code == 200
done = wait_for(operation["id"])
assert done["status"] == "error" and done["cancelled"] is True and done["message"] == "Скачивание отменено"
assert "Распаковка остановлена" in done["log_tail"]
assert not list(data_dirs["disk_images"].iterdir()) and started == []
def test_corrupt_xz_archive_is_removed_and_can_be_downloaded_again(data_dirs, www, monkeypatch):
(www.root / "bad.img.xz").write_bytes(b"definitely not an xz archive" * 100)
started = []
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: started.append(path))
done = wait_for(downloads.start_download(f"{www.url}/bad.img.xz", "disk")["id"])
assert done["status"] == "error"
assert done["message"] == "Не удалось распаковать скачанный архив — файл повреждён и удалён. Попробуйте скачать ещё раз"
assert "Ошибка распаковки" in done["log_tail"]
assert not list(data_dirs["disk_images"].iterdir()) and started == []
# архив убран, поэтому повторное скачивание не упирается в «уже есть на сервере»
assert wait_for(downloads.start_download(f"{www.url}/bad.img.xz", "disk")["id"])["status"] == "error"
def test_extract_xz_image_stops_and_cleans_up(data_dirs):
raw = bytes(range(256)) * 1024
compressed = data_dirs["disk_images"] / "pi.img.xz"
compressed.write_bytes(lzma.compress(raw))
assert downloads.extract_xz_image(compressed).read_bytes() == raw
(data_dirs["disk_images"] / "pi.img").unlink()
with pytest.raises(downloads.DownloadCancelled):
downloads.extract_xz_image(compressed, lambda: True)
assert sorted(item.name for item in data_dirs["disk_images"].iterdir()) == ["pi.img.xz"]
def test_qcow2_download_needs_no_conversion(data_dirs, www, monkeypatch):
(www.root / "ready.qcow2").write_bytes(b"q" * 10)
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None)
done = wait_for(downloads.start_download(f"{www.url}/ready.qcow2", "disk")["id"])
assert done["status"] == "success" and "convert_operation_id" not in done
assert done["result_path"] == str(data_dirs["disk_images"] / "ready.qcow2")
# ---------------------------------------------------------------- routes
def test_url_route_starts_download(logged_in, data_dirs, www, monkeypatch):
# The worker is started by hand below: a 15 KB file would otherwise finish before the checks.
pending = {}
monkeypatch.setattr(downloads, "run_operation", lambda operation, worker: pending.update(operation=operation, worker=worker))
response = logged_in.post("/downloads/url", data={"url": f"{www.url}/small.iso", "kind": "iso"}, follow_redirects=False)
assert response.status_code == 303
location = unquote(response.headers["location"])
assert location.startswith("/iso?download_message=") and "small.iso" in location
running = downloads.active_downloads("iso")
assert len(running) == 1 and running[0]["filename"] == "small.iso"
core.run_operation(pending["operation"], pending["worker"])
assert wait_for(running[0]["id"])["status"] == "success"
assert (data_dirs["iso"] / "small.iso").exists()
def test_url_route_rejects_bad_links(logged_in, data_dirs):
response = logged_in.post("/downloads/url", data={"url": "ftp://example.org/x.iso", "kind": "iso"}, follow_redirects=False)
assert response.status_code == 303
assert "download_error=" in response.headers["location"] and response.headers["location"].startswith("/iso?")
response = logged_in.post("/downloads/url", data={"url": "https://example.org/setup.exe", "kind": "disk"}, follow_redirects=False)
assert response.headers["location"].startswith("/disk-images?download_error=")
assert not core.running_operations("download") and not downloads.unfinished_downloads()
def test_url_route_requires_login(client):
assert client.post("/downloads/url", data={"url": "https://example.org/x.iso"}, follow_redirects=False).headers["location"] == "/login"
assert client.post("/api/operations/x/cancel").status_code == 401
def test_catalog_route(logged_in, data_dirs, www, monkeypatch):
payload = b"debian" * 1000
(www.root / "debian-13-genericcloud-amd64.qcow2").write_bytes(payload)
(www.root / "SHA512SUMS").write_text(f"{hashlib.sha512(payload).hexdigest()} debian-13-genericcloud-amd64.qcow2\n")
entry = {"id": "debian-13", "title": "Debian 13", "hint": "x", "login": "debian", "arches": {"x86_64": {"url": f"{www.url}/debian-13-genericcloud-amd64.qcow2", "sums": f"{www.url}/SHA512SUMS", "algo": "sha512"}}}
monkeypatch.setattr(catalog, "CLOUD_IMAGES", [entry])
monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64")
monkeypatch.setattr(app, "start_disk_convert_operation", lambda path: None)
assert unquote(logged_in.post("/downloads/catalog/nope", follow_redirects=False).headers["location"]).startswith("/disk-images?download_error=Такого образа нет")
# The worker is started by hand below: a 6 KB file would otherwise finish before the page is rendered.
pending = {}
monkeypatch.setattr(downloads, "run_operation", lambda operation, worker: pending.update(operation=operation, worker=worker))
response = logged_in.post("/downloads/catalog/debian-13", follow_redirects=False)
assert response.status_code == 303 and "download_message=" in response.headers["location"]
page = logged_in.get("/disk-images").text
assert "Скачивается" in page
running = downloads.active_downloads("disk")
assert running[0]["catalog_id"] == "debian-13"
core.run_operation(pending["operation"], pending["worker"])
assert wait_for(running[0]["id"])["status"] == "success"
assert (data_dirs["disk_images"] / "debian-13-genericcloud-amd64.qcow2").read_bytes() == payload
page = logged_in.get("/disk-images").text
assert "Загружен" in page and "/vm/create?image=debian-13-genericcloud-amd64.qcow2" in page
# второй раз — файл уже есть
response = logged_in.post("/downloads/catalog/debian-13", follow_redirects=False)
assert "download_error=" in response.headers["location"]
def test_catalog_route_skips_missing_arch(logged_in, data_dirs, monkeypatch):
monkeypatch.setattr(catalog, "CLOUD_IMAGES", [{"id": "only-arm", "title": "ARM only", "arches": {"aarch64": {"url": "https://example.org/a.qcow2"}}}])
monkeypatch.setattr(downloads, "host_arch", lambda: "x86_64")
location = unquote(logged_in.post("/downloads/catalog/only-arm", follow_redirects=False).headers["location"])
assert "нет версии для этого сервера (x86_64)" in location
assert downloads.catalog_entries() == []
assert "готовых образов в каталоге нет" in logged_in.get("/disk-images").text
def test_cancel_api_rules(logged_in, data_dirs):
assert logged_in.post("/api/operations/00000000-0000-0000-0000-000000000000/cancel").status_code == 404
other = core.new_operation("vm_create", "Создание VM x")
response = logged_in.post(f"/api/operations/{other['id']}/cancel")
assert response.status_code == 400 and "только скачивание" in response.json()["error"]
finished = core.new_operation("download", "Скачивание x", download_kind="iso", filename="x.iso")
core.finish_operation(finished, True, "Готово")
assert logged_in.post(f"/api/operations/{finished['id']}/cancel").status_code == 409
# операция без потока и без файла (панель перезапускалась) закрывается сразу
running = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2")
core.update_operation(running, status="running")
response = logged_in.post(f"/api/operations/{running['id']}/cancel")
operation = response.json()["operation"]
assert response.status_code == 200 and operation["cancel_requested"] is True and operation["status"] == "error" and operation["cancelled"] is True
assert not downloads.cancel_requested(running["id"])
def test_cancel_form_redirects_to_page(logged_in, data_dirs, www):
stale = core.new_operation("download", "Скачивание y", download_kind="disk", filename="y.qcow2")
core.update_operation(stale, status="running")
response = logged_in.post(f"/downloads/{stale['id']}/cancel", follow_redirects=False)
assert response.status_code == 303
assert unquote(response.headers["location"]).startswith("/disk-images?download_message=Скачивание y.qcow2 отменено")
assert core.read_operation(stale["id"])["status"] == "error"
live = downloads.start_download(f"{www.url}/slow.iso", "iso")
response = logged_in.post(f"/downloads/{live['id']}/cancel", follow_redirects=False)
assert unquote(response.headers["location"]).startswith("/iso?download_message=Останавливаем скачивание slow.iso")
assert wait_for(live["id"])["cancelled"] is True
assert unquote(logged_in.post("/downloads/nope/cancel", follow_redirects=False).headers["location"]).startswith("/iso?download_error=")
def test_pages_show_download_cards(logged_in, data_dirs, monkeypatch):
reads = []
real = downloads.unfinished_downloads
monkeypatch.setattr(downloads, "unfinished_downloads", lambda: reads.append(1) or real())
iso_page = logged_in.get("/iso").text
assert "Скачать по ссылке" in iso_page and 'name="kind" value="iso"' in iso_page
assert "Скачиваем на сервер" not in iso_page
assert "не нужно сначала загружать его на компьютер" in iso_page
disk_page = logged_in.get("/disk-images").text
assert "Каталог готовых систем" in disk_page and "Рекомендуем" in disk_page and 'name="kind" value="disk"' in disk_page
assert "подходящие этому серверу" in disk_page
assert len(reads) == 2 # каталог операций читается один раз на страницу, а не в каждой вставке
running = core.new_operation("download", "Скачивание big.iso", download_kind="iso", filename="big.iso", url="https://example.org/big.iso", total_bytes=None)
core.update_operation(running, status="running", progress=0, message="Подключаемся к серверу…")
iso_page = logged_in.get("/iso?download_message=Скачиваем").text
assert "Скачиваем на сервер" in iso_page and "big.iso" in iso_page and "indeterminate" in iso_page
assert f"/downloads/{running['id']}/cancel" in iso_page and "Скачиваем</span>" in iso_page
# отмена — не ошибка: без тоста при отправке формы, опрос показывает её как предупреждение
assert 'data-toast="Скачивание отменено"' not in iso_page and "op.cancelled" in iso_page
assert "big.iso" not in logged_in.get("/disk-images").text
+152
Просмотреть файл
@@ -0,0 +1,152 @@
"""Quick machines from cloud images (cloud-init), OS types for installers, fixed NAT addresses."""
import shlex
import app
import cloudinit
import conftest
import network_core
from test_audit_fixes import find, recording
def create(logged_in, monkeypatch, form):
captured = {}
monkeypatch.setattr(app, "start_background_operation", lambda operation, cmd: captured.update(operation=operation, cmd=cmd))
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
response = logged_in.post("/vm/create", data=form, follow_redirects=False)
return response, captured
def cloud_form(image, **extra):
form = {"name": "cloud-vm", "memory": 2048, "vcpus": 2, "disk_size": 20, "disk_image_path": str(image), "source_type": "disk_image", "network_mode": "nat", "cloud_init": "1", "ci_user": "viktor", "ci_password": "secret123"}
form.update(extra)
return form
# ---------------------------------------------------------------- cloud-init seed
def test_user_data_has_user_password_and_keys():
text = cloudinit.build_user_data("web", "viktor", "$6$abc", ["ssh-ed25519 AAAAC3 me@laptop"])
assert "hostname: web" in text and "- name: viktor" in text and "passwd: '$6$abc'" in text
assert "- ssh-ed25519 AAAAC3 me@laptop" in text and "ssh_pwauth: true" in text and "growpart" in text
assert "packages: [qemu-guest-agent]" in text and "package_update: true" in text
assert "ssh_pwauth: false" in cloudinit.build_user_data("web", "viktor", "", ["ssh-ed25519 AAAAC3 me"])
def test_ssh_keys_and_users_are_validated():
assert cloudinit.parse_ssh_keys("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 me\n\nssh-rsa AAAAB3 x\n") == ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 me", "ssh-rsa AAAAB3 x"]
assert cloudinit.parse_ssh_keys("-----BEGIN OPENSSH PRIVATE KEY-----") is None
assert cloudinit.parse_ssh_keys("") == []
assert cloudinit.valid_user("viktor") and cloudinit.valid_user("web_admin-2")
assert not cloudinit.valid_user("root") and not cloudinit.valid_user("Viktor") and not cloudinit.valid_user("")
def test_password_hash_uses_sha512_crypt():
digest = cloudinit.hash_password("secret123")
assert digest.startswith("$6$") and len(digest) > 60
def test_default_login_comes_from_catalog():
assert cloudinit.default_user("resolute-server-cloudimg-amd64.img") == "ubuntu"
assert cloudinit.default_user("debian-13-genericcloud-arm64.qcow2") == "debian"
assert cloudinit.default_user("my-own-image.qcow2") == "admin"
# ---------------------------------------------------------------- creating from a cloud image
def test_cloud_image_vm_gets_seed_resize_and_fixed_address(logged_in, data_dirs, monkeypatch):
image = data_dirs["disk_images"] / "resolute-server-cloudimg-amd64.img"
image.write_bytes(b"QFI\xfb")
monkeypatch.setattr(cloudinit, "hash_password", lambda password: "$6$fake$hash")
calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
response, captured = create(logged_in, monkeypatch, cloud_form(image, disk_size=60))
assert response.status_code == 303, response.text[:400]
script = captured["cmd"][-1]
assert "qemu-img convert -p -f qcow2 -O qcow2" in script
assert f"qemu-img resize {shlex.quote(str(data_dirs['images'] / 'cloud-vm.qcow2'))} 60G" in script
assert "--import" in script and "--osinfo ubuntu24.04" in script and "--cloud-init user-data=" in script and "bus=virtio" in script
operation = captured["operation"]
assert operation["cloud_init_user"] == "viktor" and operation["nat_ip"] == "192.168.100.50"
assert "Вход: viktor по паролю" in operation["success_message"] and "192.168.100.50" in operation["success_message"]
mac = network_core.stable_mac("cloud-vm")
assert f"mac={mac}" in operation["network"] and mac.startswith("52:54:00:")
reservation = find(calls, "virsh", "net-update", "virtuality-nat", "add-last", "ip-dhcp-host")[0]
assert f"mac='{mac}' name='cloud-vm' ip='192.168.100.50'" in reservation[5] and "--live" in reservation and "--config" in reservation
user_data = open(operation["seed_file"]).read()
assert "- name: viktor" in user_data and "$6$fake$hash" in user_data and "hostname: cloud-vm" in user_data
cloudinit.remove_seed(operation["seed_file"])
def test_cloud_image_smaller_disk_is_not_resized(logged_in, data_dirs, monkeypatch):
image = data_dirs["disk_images"] / "cloud.qcow2"
image.write_bytes(b"qcow")
monkeypatch.setattr(cloudinit, "hash_password", lambda password: "$6$fake$hash")
response, captured = create(logged_in, monkeypatch, cloud_form(image, disk_size=20))
assert response.status_code == 303
assert "qemu-img resize" not in captured["cmd"][-1] and "--osinfo detect=on,require=off" in captured["cmd"][-1]
cloudinit.remove_seed(captured["operation"]["seed_file"])
def test_cloud_init_can_be_skipped(logged_in, data_dirs, monkeypatch):
image = data_dirs["disk_images"] / "cloud.qcow2"
image.write_bytes(b"qcow")
response, captured = create(logged_in, monkeypatch, cloud_form(image, cloud_init="0", ci_user="", ci_password=""))
assert response.status_code == 303 and "--cloud-init" not in captured["cmd"][-1] and captured["operation"]["seed_file"] == ""
def test_cloud_init_validation(logged_in, data_dirs, monkeypatch):
image = data_dirs["disk_images"] / "cloud.qcow2"
image.write_bytes(b"qcow")
assert create(logged_in, monkeypatch, cloud_form(image, ci_password="123"))[0].status_code == 400
assert create(logged_in, monkeypatch, cloud_form(image, ci_user="Root User"))[0].status_code == 400
assert create(logged_in, monkeypatch, cloud_form(image, ci_user="root"))[0].status_code == 400
assert create(logged_in, monkeypatch, cloud_form(image, ci_ssh_key="not a key"))[0].status_code == 400
monkeypatch.setattr(cloudinit, "hash_password", lambda password: "$6$fake$hash")
ok, captured = create(logged_in, monkeypatch, cloud_form(image, ci_password="", ci_ssh_key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA me@pc"))
assert ok.status_code == 303 and "по SSH-ключу" in captured["operation"]["success_message"]
cloudinit.remove_seed(captured["operation"]["seed_file"])
# ---------------------------------------------------------------- installers by OS type
def iso_form(iso, **extra):
form = {"name": "new-vm", "memory": 4096, "vcpus": 2, "disk_size": 64, "iso_path": str(iso), "source_type": "iso", "network_mode": "nat"}
form.update(extra)
return form
def test_windows11_gets_uefi_tpm_sata_and_e1000e(logged_in, data_dirs, monkeypatch):
iso = data_dirs["iso"] / "win11.iso"
iso.write_bytes(b"iso")
response, captured = create(logged_in, monkeypatch, iso_form(iso, os_type="windows11"))
assert response.status_code == 303
cmd = captured["cmd"]
assert "--osinfo" in cmd and cmd[cmd.index("--osinfo") + 1] == "win11"
assert cmd[cmd.index("--boot") + 1] == "uefi,cdrom,hd"
assert "--tpm" in cmd and "backend.version=2.0" in cmd[cmd.index("--tpm") + 1]
assert "bus=sata" in cmd[cmd.index("--disk") + 1] and "model=e1000e" in cmd[cmd.index("--network") + 1]
assert "--channel" not in cmd # Windows has no guest agent without extra drivers
def test_linux_installer_detects_os_and_uses_virtio(logged_in, data_dirs, monkeypatch):
iso = data_dirs["iso"] / "debian.iso"
iso.write_bytes(b"iso")
response, captured = create(logged_in, monkeypatch, iso_form(iso))
cmd = captured["cmd"]
assert cmd[cmd.index("--osinfo") + 1] == "detect=on,require=off" and cmd[cmd.index("--boot") + 1] == "cdrom,hd"
assert "bus=virtio" in cmd[cmd.index("--disk") + 1] and "model=virtio" in cmd[cmd.index("--network") + 1] and "--tpm" not in cmd
assert cmd[cmd.index("--channel") + 1] == "unix,target.type=virtio,target.name=org.qemu.guest_agent.0"
assert create(logged_in, monkeypatch, iso_form(iso, os_type="bsd"))[0].status_code == 400
# ---------------------------------------------------------------- fixed addresses
def test_reservation_is_reused_and_released(data_dirs, monkeypatch):
calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
assert network_core.reserve_nat_address("web01", "52:54:00:aa:bb:cc") == "192.168.100.51"
assert not find(calls, "virsh", "net-update")
network_core.release_nat_address("web01")
deleted = find(calls, "virsh", "net-update", "virtuality-nat", "delete")[0]
assert "name='web01' ip='192.168.100.51'" in deleted[5]
def test_vm_delete_releases_address(logged_in, data_dirs, monkeypatch):
calls = recording(monkeypatch, app, [])
net_calls = recording(monkeypatch, network_core, [(["virsh", "net-dumpxml"], conftest.fake_result((conftest.FIXTURES / "net-dumpxml.xml").read_text()))])
assert logged_in.post("/vm/web01/delete", follow_redirects=False).status_code == 303
assert find(calls, "virsh", "undefine") and find(net_calls, "virsh", "net-update", "virtuality-nat", "delete")
+88
Просмотреть файл
@@ -0,0 +1,88 @@
import nodectl
from test_setup import prepare
def ready(data_dirs, monkeypatch, ctl=True):
prepare(data_dirs, monkeypatch)
nodectl.mark_wizard_done()
monkeypatch.setattr(nodectl, "ctl_available", lambda: ctl)
nodectl.NODE_ENV = data_dirs["config"] / "web.env"
nodectl.NODE_ENV.write_text("VIRTUALITY_WEB_PORT=8088\nVIRTUALITY_TLS=0\nVIRTUALITY_AUTO_UPDATE=1\nVIRTUALITY_UPDATE_CHANNEL=stable\n")
def test_settings_page_renders(logged_in, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch)
html = logged_in.get("/settings").text
for text in ("Доступ к панели", "Сеть машин", "Хранилище", "Часовой пояс", "Обновления", "Перезагрузить", "http://panel.test:8088"):
assert text in html, text
def test_settings_page_without_ctl_disables_actions(logged_in, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch, ctl=False)
html = logged_in.get("/settings").text
assert "virtuality-ctl не найдена" in html and "disabled" in html
def test_settings_requires_login(client, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch)
assert client.get("/settings", follow_redirects=False).status_code == 303
def test_access_change_applies_and_shows_new_url(logged_in, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch)
calls = []
monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
response = logged_in.post("/settings/access", data={"tls": "1", "port": "8088"})
assert response.status_code == 200 and "https://panel.test:8443" in response.text
assert calls == [("set", "VIRTUALITY_TLS=1", "VIRTUALITY_WEB_PORT=8088")]
assert logged_in.post("/settings/access", data={"tls": "0", "port": "22"}).status_code == 400
unchanged = logged_in.post("/settings/access", data={"tls": "0", "port": "8088"}, follow_redirects=False)
assert unchanged.status_code == 303 and "message=" in unchanged.headers["location"]
def test_password_change(logged_in, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch)
seen = {}
monkeypatch.setattr(nodectl, "ctl", lambda *args, **kw: seen.update(args=args, stdin=kw.get("stdin")) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
assert logged_in.post("/settings/password", data={"password": "abc", "password2": "abd"}).status_code == 400
assert logged_in.post("/settings/password", data={"password": "abc", "password2": "abc"}).status_code == 400
response = logged_in.post("/settings/password", data={"password": "secret1", "password2": "secret1"}, follow_redirects=False)
assert response.status_code == 303 and seen["args"] == ("passwd", "tester") and seen["stdin"] == "secret1\n"
def test_timezone_and_updates(logged_in, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch)
calls = []
monkeypatch.setattr(nodectl, "ctl", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
assert logged_in.post("/settings/timezone", data={"timezone": "Europe/Moscow"}, follow_redirects=False).status_code == 303
assert logged_in.post("/settings/timezone", data={"timezone": "../etc"}).status_code == 400
assert logged_in.post("/settings/updates", data={"channel": "main"}, follow_redirects=False).status_code == 303
assert calls == [("timezone", "Europe/Moscow"), ("set", "VIRTUALITY_AUTO_UPDATE=0", "VIRTUALITY_UPDATE_CHANNEL=main")]
def test_network_bridge_and_revert(logged_in, data_dirs, monkeypatch):
ready(data_dirs, monkeypatch)
calls = []
monkeypatch.setattr(nodectl, "enable_bridge", lambda iface, mode="dhcp": calls.append(("bridge", iface, mode)) or (True, "ok"))
monkeypatch.setattr(nodectl, "revert_bridge", lambda: calls.append(("revert",)) or (True, "Возвращаем прежнюю сеть"))
response = logged_in.post("/settings/network", data={"mode": "bridge"})
assert response.status_code == 200 and "Перенастраиваем сеть" in response.text and 'data-next-url="/settings"' in response.text
assert logged_in.post("/settings/network/revert", follow_redirects=False).headers["location"].startswith("/settings?message=")
assert calls == [("bridge", "enp3s0", "dhcp"), ("revert",)]
def test_storage_power_and_downloads(logged_in, data_dirs, monkeypatch, tmp_path):
ready(data_dirs, monkeypatch)
monkeypatch.setattr(nodectl, "use_disk_for_storage", lambda path: (path == "/dev/sdb", "Диск подключён" if path == "/dev/sdb" else "Нельзя"))
assert "message=" in logged_in.post("/settings/storage", data={"disk": "/dev/sdb"}, follow_redirects=False).headers["location"]
assert "error=" in logged_in.post("/settings/storage", data={"disk": "/dev/sda"}, follow_redirects=False).headers["location"]
monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
assert "Перезагружаем сервер" in logged_in.post("/settings/power", data={"action": "reboot"}).text
assert logged_in.post("/settings/power", data={"action": "halt"}).status_code == 400
archive = tmp_path / "config-1.tar.gz"
archive.write_bytes(b"\x1f\x8b" + b"0" * 16)
monkeypatch.setattr(nodectl, "ctl", lambda *args, **kw: {"ok": True, "stdout": str(archive), "stderr": "", "code": 0, "cmd": ""})
response = logged_in.post("/settings/backup-config")
assert response.status_code == 200 and response.headers["content-disposition"].endswith('"config-1.tar.gz"') and response.content.startswith(b"\x1f\x8b")
assert logged_in.post("/settings/support-bundle").status_code == 200
+101
Просмотреть файл
@@ -0,0 +1,101 @@
import json
import app
import nodectl
from features import setup
def prepare(data_dirs, monkeypatch, stage="done"):
config = data_dirs["config"]
monkeypatch.setattr(nodectl, "CONFIG_DIR", config)
monkeypatch.setattr(nodectl, "SETUP_STATE", config / "setup.json")
monkeypatch.setattr(nodectl, "SETUP_DONE", config / "setup_done")
monkeypatch.setattr(nodectl, "WIZARD_FILE", config / "wizard.json")
monkeypatch.setattr(nodectl, "FIRSTBOOT_LOG", config / "firstboot.log")
monkeypatch.setattr(nodectl, "ctl_available", lambda: False)
(config / "setup.json").write_text(json.dumps({"stage": stage, "message": "Устанавливаем…", "steps": [{"id": "panel", "title": "Панель", "status": "done"}, {"id": "virt", "title": "KVM", "status": "running" if stage != "done" else "done"}]}))
monkeypatch.setattr(nodectl, "network_facts", lambda: {"interface": "enp3s0", "gateway": "192.168.1.1", "address": "192.168.1.10", "prefix": 24, "mac": "aa:bb:cc:dd:ee:ff", "wireless": False, "private": True, "is_vps": False, "virt": "none", "bridge_present": False, "on_bridge": False, "recommended": "bridge", "revert_armed": False, "bridge_possible": True})
monkeypatch.setattr(nodectl, "spare_disks", lambda: [])
monkeypatch.setattr(nodectl, "timezones", lambda: {"current": "UTC", "popular": ["Europe/Moscow", "UTC"], "all": ["Europe/Moscow", "UTC"]})
def test_dashboard_redirects_to_wizard_until_done(logged_in, data_dirs, monkeypatch):
prepare(data_dirs, monkeypatch)
response = logged_in.get("/", follow_redirects=False)
assert response.status_code == 303 and response.headers["location"] == "/setup"
nodectl.mark_wizard_done()
assert logged_in.get("/", follow_redirects=False).status_code == 200
def test_dashboard_without_installer_state_has_no_wizard(logged_in, data_dirs, monkeypatch):
prepare(data_dirs, monkeypatch)
nodectl.SETUP_STATE.unlink()
assert logged_in.get("/", follow_redirects=False).status_code == 200
def test_install_step_shown_while_installing(logged_in, data_dirs, monkeypatch):
prepare(data_dirs, monkeypatch, stage="installing")
assert logged_in.get("/setup", follow_redirects=False).headers["location"] == "/setup/install"
html = logged_in.get("/setup/install").text
assert "Настраиваем сервер" in html and "Устанавливаем…" in html
# other steps are locked until the installation finishes
assert logged_in.get("/setup/welcome", follow_redirects=False).headers["location"] == "/setup/install"
state = logged_in.get("/api/setup/state").json()
assert state["ok"] and state["state"]["installed"] is False
def test_wizard_steps_render_and_store_choices(logged_in, data_dirs, monkeypatch):
prepare(data_dirs, monkeypatch)
monkeypatch.setattr(nodectl, "set_timezone", lambda tz: (True, tz))
for step in setup.STEP_IDS[1:]:
assert logged_in.get(f"/setup/{step}").status_code == 200, step
assert logged_in.post("/setup/welcome", data={"timezone": "Europe/Moscow"}, follow_redirects=False).headers["location"] == "/setup/account"
assert logged_in.post("/setup/account", data={"password": "", "password2": ""}, follow_redirects=False).headers["location"] == "/setup/access"
assert logged_in.post("/setup/account", data={"password": "abcdef", "password2": "other"}).status_code == 400
assert logged_in.post("/setup/access", data={"tls": "0", "port": "8090"}, follow_redirects=False).headers["location"] == "/setup/network"
assert logged_in.post("/setup/access", data={"tls": "1", "port": "22"}).status_code == 400
monkeypatch.setattr(app.network_core, "create_nat_network", lambda: {})
assert logged_in.post("/setup/network", data={"mode": "nat"}, follow_redirects=False).headers["location"] == "/setup/storage"
assert logged_in.post("/setup/storage", data={"disk": "keep"}, follow_redirects=False).headers["location"] == "/setup/updates"
assert logged_in.post("/setup/updates", data={"auto_update": "0", "channel": "stable"}, follow_redirects=False).headers["location"] == "/setup/finish"
choices = nodectl.wizard_choices()
assert choices["timezone"] == "Europe/Moscow" and choices["port"] == "8090" and choices["tls"] == "0" and choices["network"] == "nat" and choices["auto_update"] == "0"
finish = logged_in.get("/setup/finish").text
assert "http://" in finish and "Europe/Moscow" in finish and "Вручную" in finish
def test_finish_marks_done_without_ctl(logged_in, data_dirs, monkeypatch):
prepare(data_dirs, monkeypatch)
response = logged_in.post("/setup/finish", follow_redirects=False)
assert response.status_code == 303 and response.headers["location"].startswith("/?message=")
assert nodectl.wizard_done()
def test_finish_applies_settings_through_ctl(logged_in, data_dirs, monkeypatch):
prepare(data_dirs, monkeypatch)
calls = []
monkeypatch.setattr(nodectl, "ctl_available", lambda: True)
monkeypatch.setattr(nodectl, "ctl_detached", lambda *args, **kw: calls.append(args) or {"ok": True, "stdout": "", "stderr": "", "code": 0, "cmd": ""})
nodectl.save_wizard_choices(tls="1", port="8088", auto_update="1", channel="stable")
response = logged_in.post("/setup/finish", follow_redirects=False)
assert response.status_code == 200 and "https://panel.test:8443" in response.text
assert calls and calls[0][0] == "set" and "VIRTUALITY_TLS=1" in calls[0]
def test_bridge_mode_starts_network_change(logged_in, data_dirs, monkeypatch):
prepare(data_dirs, monkeypatch)
calls = []
monkeypatch.setattr(nodectl, "enable_bridge", lambda iface, mode="dhcp": calls.append((iface, mode)) or (True, "ok"))
response = logged_in.post("/setup/network", data={"mode": "bridge"})
assert response.status_code == 200 and "Перенастраиваем сеть" in response.text
assert calls == [("enp3s0", "dhcp")]
monkeypatch.setattr(nodectl, "confirm_bridge", lambda: (True, "Сеть подтверждена"))
assert logged_in.post("/setup/network/confirm").json()["ok"] is True
assert nodectl.wizard_choices()["bridge_pending"] is False
def test_restart_wizard_from_settings(logged_in, data_dirs, monkeypatch):
prepare(data_dirs, monkeypatch)
nodectl.mark_wizard_done()
assert logged_in.post("/setup/restart", follow_redirects=False).headers["location"] == "/setup/welcome"
assert not nodectl.wizard_done()
+286
Просмотреть файл
@@ -0,0 +1,286 @@
"""Снимки (точки восстановления) машины: парсеры и маршруты /vm/{name}/snapshots."""
from datetime import datetime
from pathlib import Path
from urllib.parse import unquote
import pytest
import conftest
import core
from features import snapshots
FIXTURES = Path(__file__).resolve().parent / "fixtures"
# ---------------------------------------------------------------- parsers
def test_parse_snapshot_list_fixture():
rows = snapshots.parse_snapshot_list((FIXTURES / "snapshot-list.txt").read_text())
assert [row["name"] for row in rows] == ["before-upd", "clean"] # новые сверху
assert rows[0] == {"name": "before-upd", "created_at": "2026-09-25 10:12:03", "tz": "+0000", "state": "shutoff"}
assert rows[1]["state"] == "running"
def test_parse_snapshot_list_without_timezone_and_garbage():
text = " Name Creation Time State\n------\n snap1 2026-01-02 03:04:05 running\nerror: something\n\n"
rows = snapshots.parse_snapshot_list(text)
assert rows == [{"name": "snap1", "created_at": "2026-01-02 03:04:05", "tz": "", "state": "running"}]
assert snapshots.parse_snapshot_list("") == []
def test_parse_snapshot_xml():
meta = snapshots.parse_snapshot_xml(conftest.fake_snapshot_xml("clean"))
assert meta["description"] == "Чистая система после установки"
assert meta["state"] == "running" and meta["with_memory"] is True
assert snapshots.parse_snapshot_xml(conftest.fake_snapshot_xml("before-upd"))["with_memory"] is False
assert snapshots.parse_snapshot_xml("<broken") == {}
def test_inspect_domain_xml_qcow2_fixture():
info = snapshots.inspect_domain_xml((FIXTURES / "dumpxml-web01.xml").read_text())
assert info["disks"] == [{"target": "vda", "format": "qcow2"}] # cdrom пропущен
assert info["unsupported"] == [] and info["uefi"] is False
def test_inspect_domain_xml_raw_and_uefi():
xml = """<domain type='kvm'><name>x</name>
<os firmware='efi'><type arch='x86_64'>hvm</type><loader readonly='yes' type='pflash'>/usr/share/OVMF/OVMF_CODE.fd</loader><nvram>/var/lib/libvirt/qemu/nvram/x.fd</nvram></os>
<devices>
<disk type='file' device='disk'><driver name='qemu' type='raw'/><source file='/x.img'/><target dev='vda' bus='virtio'/></disk>
<disk type='file' device='disk'><driver name='qemu' type='qcow2'/><source file='/y.qcow2'/><target dev='vdb' bus='virtio'/></disk>
</devices></domain>"""
info = snapshots.inspect_domain_xml(xml)
assert info["unsupported"] == [{"target": "vda", "format": "raw"}]
assert info["uefi"] is True
assert snapshots.inspect_domain_xml("<nope") == {"disks": [], "unsupported": [], "uefi": False}
def test_format_when():
now = datetime(2026, 9, 25, 12, 0, 0)
assert snapshots.format_when("2026-09-25 10:12:03", now) == "Сегодня, 10:12"
assert snapshots.format_when("2026-09-24 09:00:41", now) == "Вчера, 09:00"
assert snapshots.format_when("2026-03-08 18:30:00", now) == "8 марта 2026, 18:30"
assert snapshots.format_when("garbage", now) == "garbage"
def test_explain_error_translates_known_libvirt_messages():
text = snapshots.explain_error("error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
assert text.startswith("Для машин с UEFI-прошивкой") and "libvirt:" in text
assert snapshots.explain_error("error: internal snapshot for disk vda unsupported for storage type raw").startswith("Внутренние снимки работают только с дисками qcow2")
assert snapshots.explain_error("error: Timed out during operation: cannot acquire state change lock").startswith("Машина занята")
assert snapshots.explain_error("error: something odd\nsecond line") == "something odd"
assert "не объяснил" in snapshots.explain_error("")
def test_next_snapshot_name_and_description():
now = datetime(2026, 9, 25, 10, 12)
assert snapshots.next_snapshot_name([], now) == "snap-20260925-1012"
assert snapshots.next_snapshot_name(["snap-20260925-1012"], now) == "snap-20260925-1012-2"
assert snapshots.next_snapshot_name(["snap-20260925-1012", "snap-20260925-1012-2"], now) == "snap-20260925-1012-3"
assert core.valid_label(snapshots.next_snapshot_name([], now))
assert snapshots.clean_description(" --force\x00 перед\nобновлением ") == "force перед обновлением"
assert len(snapshots.clean_description("x" * 500)) == snapshots.DESCRIPTION_MAX
def test_snapshot_state_labels():
assert snapshots.snapshot_state("running") == {"label": "Работала", "tone": "success"}
assert snapshots.snapshot_state("shutoff")["label"] == "Была выключена"
assert snapshots.snapshot_state("weird") == {"label": "weird", "tone": "neutral"}
# ---------------------------------------------------------------- helpers for routes
@pytest.fixture()
def recorder(monkeypatch):
"""run_cmd, который записывает команды virsh и выполняет операции сразу (без потоков)."""
calls: list[list[str]] = []
def run(cmd, timeout=12, **kwargs):
calls.append(list(cmd))
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
monkeypatch.setattr(snapshots, "run_operation", lambda operation, worker: worker(operation))
return calls
def find(calls, *prefix):
return [cmd for cmd in calls if cmd[: len(prefix)] == list(prefix)]
# ---------------------------------------------------------------- routes
def test_snapshots_page_requires_login(client):
response = client.get("/vm/web01/snapshots", follow_redirects=False)
assert response.status_code == 303 and response.headers["location"] == "/login"
def test_snapshots_page_renders_list(logged_in):
html = logged_in.get("/vm/web01/snapshots").text
assert "Перед обновлением ядра" in html and "Чистая система после установки" in html
assert "Текущий" in html and "Была выключена" in html and "Работала" in html
assert 'action="/vm/web01/snapshots/before-upd/revert"' in html
assert 'action="/vm/web01/snapshots/clean/delete"' in html
assert "2 из 8" in html
assert "snap-" in html # предложенное имя
assert "shut off" not in html
def test_snapshots_page_unknown_vm_redirects_home(logged_in):
response = logged_in.get("/vm/ghost/snapshots", follow_redirects=False)
assert response.status_code == 303 and response.headers["location"] == "/"
assert logged_in.get("/vm/-bad/snapshots", follow_redirects=False).status_code == 400
def test_vm_page_links_to_snapshots_tab(logged_in):
assert 'href="/vm/web01/snapshots"' in logged_in.get("/vm/web01").text
def test_create_snapshot_runs_virsh_and_reports_operation(logged_in, data_dirs, recorder):
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "Перед экспериментом"}, follow_redirects=False)
assert response.status_code == 303
location = response.headers["location"]
assert location.startswith("/vm/web01/snapshots?op=")
cmd = find(recorder, "virsh", "snapshot-create-as")[0]
assert cmd[2:6] == ["--domain", "web01", "--name", cmd[5]] and cmd[5].startswith("snap-")
assert cmd[6:] == ["--description", "Перед экспериментом", "--atomic"]
operation = core.read_operation(location.split("op=", 1)[1])
assert operation["status"] == "success" and operation["type"] == "snapshot_create" and operation["vm_name"] == "web01"
assert "создан" in operation["message"]
html = logged_in.get(location).text
assert "создан" in html
def test_create_snapshot_without_description_omits_option(logged_in, data_dirs, recorder):
logged_in.post("/vm/web01/snapshots/create", data={"description": " "}, follow_redirects=False)
cmd = find(recorder, "virsh", "snapshot-create-as")[0]
assert "--description" not in cmd and cmd[-1] == "--atomic"
def test_create_snapshot_limit(logged_in, data_dirs, recorder, monkeypatch):
rows = "\n".join(f" snap-{i:02d} 2026-09-{i + 1:02d} 10:00:00 +0000 shutoff" for i in range(8))
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "snapshot-list"]:
return conftest.fake_result(" Name Creation Time State\n---\n" + rows)
return original(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "x"}, follow_redirects=False)
assert response.status_code == 303
assert "максимум" in unquote(response.headers["location"])
assert not find(recorder, "virsh", "snapshot-create-as")
html = logged_in.get("/vm/web01/snapshots").text
assert "8 из 8" in html and "максимум" in html
def test_create_snapshot_rejects_raw_disk(logged_in, data_dirs, recorder, monkeypatch):
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "dumpxml"]:
return conftest.fake_result("<domain><devices><disk device='disk'><driver type='raw'/><target dev='vda'/></disk></devices></domain>")
return original(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
response = logged_in.post("/vm/web01/snapshots/create", data={"description": "x"}, follow_redirects=False)
assert "qcow2" in unquote(response.headers["location"])
assert not find(recorder, "virsh", "snapshot-create-as")
def test_create_snapshot_surfaces_libvirt_error(logged_in, data_dirs, recorder, monkeypatch):
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "snapshot-create-as"]:
return conftest.fake_result(ok=False, stderr="error: unsupported configuration: internal snapshots of a VM with pflash based firmware are not supported")
return original(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
response = logged_in.post("/vm/web01/snapshots/create", data={}, follow_redirects=False)
location = response.headers["location"]
operation = core.read_operation(location.split("op=", 1)[1])
assert operation["status"] == "error"
assert operation["message"].startswith("Для машин с UEFI-прошивкой") and "pflash" in operation["message"]
assert "pflash" in operation["log_tail"]
html = logged_in.get(location).text
assert "Для машин с UEFI-прошивкой" in html
def test_create_snapshot_waits_for_active_operation(logged_in, data_dirs, recorder):
core.new_operation("snapshot_create", "Снимок машины web01", vm_name="web01", snapshot="snap-x")
response = logged_in.post("/vm/web01/snapshots/create", data={}, follow_redirects=False)
assert "предыдущей операции" in unquote(response.headers["location"])
assert not find(recorder, "virsh", "snapshot-create-as")
html = logged_in.get("/vm/web01/snapshots").text
assert 'data-watch-operation=' in html and "/api/operations/" in html
def test_revert_snapshot(logged_in, data_dirs, recorder):
response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False)
assert response.status_code == 303
assert find(recorder, "virsh", "snapshot-revert") == [["virsh", "snapshot-revert", "--domain", "web01", "--snapshotname", "clean"]]
operation = core.read_operation(response.headers["location"].split("op=", 1)[1])
assert operation["status"] == "success" and "возвращена" in operation["message"] and "работает" in operation["message"]
def test_revert_retries_with_force(logged_in, data_dirs, recorder, monkeypatch):
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "snapshot-revert"] and "--force" not in cmd:
recorder.append(list(cmd))
return conftest.fake_result(ok=False, stderr="error: revert requires force: snapshot was created with a different configuration")
return original(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False)
reverts = find(recorder, "virsh", "snapshot-revert")
assert len(reverts) == 2 and reverts[1][-1] == "--force"
assert core.read_operation(response.headers["location"].split("op=", 1)[1])["status"] == "success"
def test_revert_reports_error(logged_in, data_dirs, recorder, monkeypatch):
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "snapshot-revert"]:
return conftest.fake_result(ok=False, stderr="error: Domain snapshot not found: no domain snapshot with matching name 'clean'")
return original(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
response = logged_in.post("/vm/web01/snapshots/clean/revert", follow_redirects=False)
operation = core.read_operation(response.headers["location"].split("op=", 1)[1])
assert operation["status"] == "error" and operation["message"].startswith("Такого снимка уже нет")
def test_delete_snapshot(logged_in, data_dirs, recorder):
response = logged_in.post("/vm/web01/snapshots/before-upd/delete", follow_redirects=False)
assert response.status_code == 303
assert "удалён" in unquote(response.headers["location"])
assert find(recorder, "virsh", "snapshot-delete") == [["virsh", "snapshot-delete", "--domain", "web01", "--snapshotname", "before-upd"]]
def test_delete_snapshot_error(logged_in, data_dirs, recorder, monkeypatch):
original = snapshots.run_cmd
def run(cmd, timeout=12, **kwargs):
if cmd[:2] == ["virsh", "snapshot-delete"]:
return conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock")
return original(cmd, timeout, **kwargs)
monkeypatch.setattr(snapshots, "run_cmd", run)
response = logged_in.post("/vm/web01/snapshots/before-upd/delete", follow_redirects=False)
assert "snapshot_error=" in response.headers["location"] and "занята" in unquote(response.headers["location"])
def test_snapshot_routes_validate_names(logged_in, data_dirs, recorder):
bad = logged_in.post("/vm/web01/snapshots/-bad/revert", follow_redirects=False)
assert bad.status_code == 303 and "snapshot_error=" in bad.headers["location"]
assert logged_in.post("/vm/web01/snapshots/..%2Fx/delete", follow_redirects=False).status_code in (400, 404)
assert logged_in.post("/vm/-bad/snapshots/create", data={}, follow_redirects=False).status_code == 400
assert not find(recorder, "virsh", "snapshot-delete") and not find(recorder, "virsh", "snapshot-revert")
def test_snapshot_routes_require_login(client):
for path in ("/vm/web01/snapshots/create", "/vm/web01/snapshots/clean/revert", "/vm/web01/snapshots/clean/delete"):
response = client.post(path, follow_redirects=False)
assert response.status_code == 303 and response.headers["location"] == "/login"
-93
Просмотреть файл
@@ -1,93 +0,0 @@
import app
def test_valid_vm_name():
assert app.valid_vm_name("ubuntu-test")
assert app.valid_vm_name("vm1.local_x")
assert not app.valid_vm_name("")
assert not app.valid_vm_name("a") # минимум 2 символа
assert not app.valid_vm_name("-starts-with-dash")
assert not app.valid_vm_name("has space")
assert not app.valid_vm_name("x" * 64)
def test_valid_snapshot_name():
assert app.valid_snapshot_name("before-update")
assert app.valid_snapshot_name("s1")
assert app.valid_snapshot_name("a") # один символ допустим
assert not app.valid_snapshot_name("")
assert not app.valid_snapshot_name("bad name")
assert not app.valid_snapshot_name(".hidden")
def test_safe_iso_filename():
assert app.safe_iso_filename("ubuntu-24.04.iso") == "ubuntu-24.04.iso"
assert app.safe_iso_filename("my image.iso") == "my-image.iso"
assert app.safe_iso_filename("../../etc/passwd") is None
assert app.safe_iso_filename("evil.qcow2") is None
assert app.safe_iso_filename("") is None
def test_iso_path_traversal_guard(tmp_path, monkeypatch):
monkeypatch.setattr(app, "ISO_DIR", tmp_path)
assert app.iso_path_by_name("ok.iso") == tmp_path / "ok.iso"
# Пути с каталогами санитизируются до basename и остаются внутри ISO_DIR
assert app.iso_path_by_name("../escape.iso") == tmp_path / "escape.iso"
assert app.iso_path_by_name("nested/evil.iso") == tmp_path / "evil.iso"
assert app.iso_path_by_name("не-ascii.iso") != tmp_path / "не-ascii.iso" # кириллица заменяется
assert app.iso_path_by_name("no-extension") is None
def test_progress_parsers():
assert app.wget_progress(10, " 512000K .......... 42% 10.5M 2m30s") == 42
assert app.wget_progress(50, "no percent here") == 50
assert app.wget_progress(10, "100%") == 99 # качаем — не показываем 100 до mv
assert app.qemu_convert_progress(0, " (42.00/100%)") == 42
assert app.qemu_convert_progress(77, " (42.00/100%)") == 77 # прогресс не откатывается
assert app.progress_from_line(10, "Allocating disk...") == 30
assert app.progress_from_line(50, "Creating domain...") == 75
def test_snapshot_list_parser(monkeypatch):
sample = """ Name Creation Time State
--------------------------------------------------------
before-update 2026-08-15 12:10:33 +0300 running
clean 2026-08-14 09:01:02 +0300 shutoff
"""
monkeypatch.setattr(app, "run_cmd", lambda cmd, timeout=12: {"ok": True, "stdout": sample, "stderr": "", "code": 0, "cmd": ""})
snaps = app.list_vm_snapshots("vm1")
assert [s["name"] for s in snaps] == ["before-update", "clean"]
assert snaps[0]["state"] == "running"
assert snaps[0]["created"] == "2026-08-15 12:10:33 +0300"
def test_snapshot_action_rejects_bad_name():
ok, message = app.snapshot_action("vm1", "create", "bad name")
assert not ok
assert "Имя снапшота" in message
def test_vm_templates_flag(tmp_path, monkeypatch):
monkeypatch.setattr(app, "VM_TEMPLATES_FILE", tmp_path / "vm_templates.json")
assert app.load_vm_templates() == []
app.set_vm_template("vm1", True)
app.set_vm_template("vm2", True)
assert app.is_vm_template("vm1")
assert sorted(app.load_vm_templates()) == ["vm1", "vm2"]
app.set_vm_template("vm1", False)
assert not app.is_vm_template("vm1")
def test_ttl_cache(monkeypatch):
calls = []
def producer():
calls.append(1)
return len(calls)
app.cache_invalidate()
assert app.cached("k1", 60, producer) == 1
assert app.cached("k1", 60, producer) == 1
assert len(calls) == 1
app.cache_invalidate("k1")
assert app.cached("k1", 60, producer) == 2
+441
Просмотреть файл
@@ -0,0 +1,441 @@
"""vmops: увеличение диска, клонирование, пауза и спящий режим, заметки, живая нагрузка."""
import threading
from pathlib import Path
from urllib.parse import unquote
import pytest
import conftest
import core
import presenters
from features import vmops
FIXTURES = Path(__file__).resolve().parent / "fixtures"
DOMSTATS = (FIXTURES / "domstats.txt").read_text()
GIB = 1024 ** 3
class Recorder:
"""Записывает команды; отвечает по overrides {(префикс,): результат|функция}, иначе как fake_run_cmd."""
def __init__(self, overrides=None):
self.calls: list[list[str]] = []
self.overrides = overrides or {}
def __call__(self, cmd, timeout=12, **kwargs):
self.calls.append(list(cmd))
for prefix, result in self.overrides.items():
if tuple(cmd[: len(prefix)]) == prefix:
return result(cmd) if callable(result) else result
return conftest.fake_run_cmd(cmd, timeout, **kwargs)
def find(self, *prefix):
return [call for call in self.calls if tuple(call[: len(prefix)]) == prefix]
@pytest.fixture()
def record(data_dirs, monkeypatch):
"""Подменяет run_cmd и в core (vmops), и в app (обзор, страница машины).
Свободное место тоже подменено: временная папка тестов бывает маленькой (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
@pytest.fixture()
def inline_operations(monkeypatch):
"""Фоновые операции выполняются сразу, в этом же потоке."""
def run_now(operation, worker):
core.update_operation(operation, status="running")
worker(operation)
monkeypatch.setattr(vmops, "run_operation", run_now)
def shut_off(record):
record.overrides[("virsh", "domstate")] = conftest.fake_result("shut off")
def location(response) -> str:
assert response.status_code == 303, response.text[:300]
return unquote(response.headers["location"])
# ---------------------------------------------------------------- parsers
def test_parse_domstats_fixture():
stats = vmops.parse_domstats(DOMSTATS)
assert list(stats) == ["web01"]
assert stats["web01"]["cpu.time"] == "48123456789"
assert stats["web01"]["balloon.rss"] == "1534208"
assert stats["web01"]["vcpu.current"] == "2"
def test_parse_domstats_several_domains():
text = DOMSTATS + "Domain: 'db01'\n\nDomain: 'win 11'\n cpu.time=5\n vcpu.current=4\n"
stats = vmops.parse_domstats(text)
assert set(stats) == {"web01", "db01", "win 11"}
assert stats["db01"] == {}
assert stats["win 11"] == {"cpu.time": "5", "vcpu.current": "4"}
def test_compute_stats_cpu_percent_from_two_samples():
samples: dict = {}
first = vmops.compute_stats(vmops.parse_domstats(DOMSTATS), 100.0, samples)
assert first["web01"]["cpu_pct"] is None # первый замер — сравнивать не с чем
assert first["web01"]["mem_used_mb"] == 1498 and first["web01"]["mem_total_mb"] == 2048
later = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=49123456789") # +1 с процессорного времени
second = vmops.compute_stats(vmops.parse_domstats(later), 101.0, samples)
assert second["web01"]["cpu_pct"] == 50.0 # 1 с на 2 ядра за 1 с стены
burst = DOMSTATS.replace("cpu.time=48123456789", "cpu.time=99123456789")
third = vmops.compute_stats(vmops.parse_domstats(burst), 101.5, samples)
assert third["web01"]["cpu_pct"] == 100.0 # не больше 100 %
def test_compute_stats_skips_shut_off_and_forgets_stale():
samples = {"old": (1.0, 5)}
stats = vmops.compute_stats({"db01": {}, "web01": {"cpu.time": "10", "balloon.current": "1024", "vcpu.current": "1"}}, 2.0, samples)
assert set(stats) == {"web01"}
assert stats["web01"]["mem_used_mb"] == 1 and stats["web01"]["mem_total_mb"] == 1
assert set(samples) == {"web01"}
def test_parse_qemu_img_info():
info = vmops.parse_qemu_img_info((FIXTURES / "qemu-img-info.json").read_text())
assert info == {"virtual_size": 42949672960, "actual_size": 6443499520, "format": "qcow2"}
assert vmops.parse_qemu_img_info("not json") == {}
assert vmops.parse_qemu_img_info("[1, 2]") == {}
def test_parse_virsh_list_titles():
titles = presenters.parse_virsh_list_titles((FIXTURES / "list-title.txt").read_text())
assert titles == {"web01": "Сайт компании", "win11": "Бухгалтерия 1С"}
assert presenters.parse_virsh_list_titles(" Id Name State\n---\n 1 web01 running") == {}
assert presenters.parse_virsh_list_titles("") == {}
def test_parse_desc_output():
assert vmops.parse_desc_output(conftest.fake_result("Сайт компании\n")) == "Сайт компании"
assert vmops.parse_desc_output(conftest.fake_result("No title for domain: web01")) == ""
assert vmops.parse_desc_output(conftest.fake_result(ok=False, stderr="error: failed to get domain")) == ""
def test_has_managed_save():
assert vmops.has_managed_save(conftest.DOMINFO + "Managed save: yes\n")
assert not vmops.has_managed_save(conftest.DOMINFO + "Managed save: no\n")
assert not vmops.has_managed_save(conftest.DOMINFO)
def test_clean_notes():
assert vmops.clean_title(" Сайт \n компании ") == "Сайт компании"
assert len(vmops.clean_title("x" * 200)) == vmops.TITLE_MAX
assert vmops.clean_description("строка 1\r\nстрока 2 \r\n\r\n") == "строка 1\nстрока 2"
def test_clone_names_and_targets(data_dirs):
assert vmops.suggest_clone_name("web01", {"web01"}) == "web01-copy"
assert vmops.suggest_clone_name("web01", {"web01", "web01-copy", "web01-copy2"}) == "web01-copy3"
disks = [
{"device": "disk", "source": "/var/lib/virtuality/images/web01.qcow2"},
{"device": "cdrom", "source": ""},
{"device": "disk", "source": "/var/lib/virtuality/images/data.img"},
]
targets = vmops.clone_targets("web02", disks)
assert targets == [data_dirs["images"] / "web02.qcow2", data_dirs["images"] / "web02-disk2.img"]
def test_explain_error():
assert "снимки" in vmops.explain_error("qemu-img: Can't resize an image which has snapshots", "x")
assert vmops.explain_error("", "запасной текст") == "запасной текст"
# ---------------------------------------------------------------- live stats
def test_live_stats_requires_auth(client):
assert client.get("/live/stats").status_code == 401
def test_live_stats_json(logged_in, record):
first = logged_in.get("/live/stats").json()
assert first["ok"] is True
assert first["stats"]["web01"]["cpu_pct"] is None
assert first["stats"]["web01"]["mem_used_mb"] == 1498 and first["stats"]["web01"]["mem_total_mb"] == 2048
second = logged_in.get("/live/stats").json()
assert second["stats"]["web01"]["cpu_pct"] == 0.0 # cpu.time в подделке не растёт
assert record.find("virsh", "domstats") == [["virsh", "domstats", "--cpu-total", "--balloon", "--vcpu", "web01"]] * 2 # только активные машины, один вызов на запрос
def test_live_stats_without_active_vms(logged_in, record):
record.overrides[("virsh", "list")] = conftest.fake_result("")
assert logged_in.get("/live/stats").json()["stats"] == {}
assert record.find("virsh", "domstats") == []
# ---------------------------------------------------------------- pages
def test_dashboard_shows_title_and_meters(logged_in, record):
html = logged_in.get("/").text
assert "Сайт компании" in html
assert 'data-live-stats="web01"' in html and 'data-live-stats="db01"' in html
assert "/vm/web01/ops/pause" in html and "/vm/web01/ops/hibernate" in html
assert "/vm/web01/clone" in html and "/vm/db01/clone" in html
assert record.find("virsh", "list", "--all", "--title")
def test_dashboard_paused_vm_offers_resume(logged_in, record):
record.overrides[("virsh", "list")] = conftest.fake_result(" Id Name State\n-----------------------\n 2 web01 paused\n - db01 shut off")
html = logged_in.get("/").text
assert 'action="/vm/web01/ops/resume"' in html
assert 'action="/vm/web01/start"' not in html
assert 'action="/vm/db01/start"' in html
def test_vm_page_shows_disks_notes_and_ops(logged_in, record):
html = logged_in.get("/vm/web01").text
assert "40 ГБ" in html and "занято 6.0 ГБ" in html
assert 'action="/vm/web01/disks/vda/resize"' in html and 'min="41"' in html
assert 'action="/vm/web01/disks/sda/resize"' not in html # привод не увеличивают
assert 'value="Сайт компании"' in html and "Nginx + база. Бэкап по пятницам." in html
assert "Приостановить" in html and "Спящий режим" in html and "Клонировать" in html
assert 'data-live-stats="web01"' in html # карточка «Нагрузка сейчас»
assert record.find("qemu-img", "info") == [["qemu-img", "info", "--output=json", "-U", str(core.IMAGES_DIR / "web01.qcow2")]]
def test_vm_page_paused_and_saved_states(logged_in, record):
record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: paused"))
html = logged_in.get("/vm/web01").text
assert 'action="/vm/web01/ops/resume"' in html and "Продолжить" in html
record.overrides[("virsh", "dominfo")] = conftest.fake_result(conftest.DOMINFO.replace("State: running", "State: shut off") + "Managed save: yes\n")
html = logged_in.get("/vm/web01").text
assert "Машина в спящем режиме" in html and 'action="/vm/web01/ops/forget-save"' in html
assert 'data-live-stats="web01"' not in html # выключенная машина — нагрузки нет
def test_vm_page_messages(logged_in, record):
html = logged_in.get("/vm/web01?disk_message=Диск+увеличен&notes_error=Не+вышло").text
assert "Диск увеличен" in html and "Не вышло" in html
# ---------------------------------------------------------------- disk resize
def test_disk_resize_shut_off_uses_qemu_img(logged_in, record):
shut_off(record)
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)
assert location(response).startswith("/vm/web01?disk_message=Диск web01.qcow2 увеличен до 50 ГБ")
assert location(response).endswith("#storage")
assert record.find("qemu-img", "resize") == [["qemu-img", "resize", str(core.IMAGES_DIR / "web01.qcow2"), "50G"]]
assert record.find("virsh", "blockresize") == []
def test_disk_resize_running_uses_blockresize(logged_in, record):
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "41"}, follow_redirects=False)
assert "disk_message" in location(response)
assert record.find("virsh", "blockresize") == [["virsh", "blockresize", "web01", "vda", "41G"]]
assert record.find("qemu-img", "resize") == []
def test_disk_resize_only_grows(logged_in, record):
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "40"}, follow_redirects=False)
assert "не меньше 41 ГБ" in location(response)
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "999999"}, follow_redirects=False)
assert "Слишком большой" in location(response)
assert record.find("virsh", "blockresize") == [] and record.find("qemu-img", "resize") == []
def test_disk_resize_rejects_unknown_or_cdrom(logged_in, record):
assert "Такого диска" in location(logged_in.post("/vm/web01/disks/vdz/resize", data={"size_gb": "50"}, follow_redirects=False))
assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/sda/resize", data={"size_gb": "50"}, follow_redirects=False))
assert logged_in.post("/vm/web01/disks/../etc/resize", data={"size_gb": "50"}, follow_redirects=False).status_code in (400, 404)
assert logged_in.post("/vm/-bad/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False).status_code == 400
def test_disk_resize_outside_storage_refused(logged_in, record):
record.overrides[("virsh", "domblklist")] = conftest.fake_result((FIXTURES / "domblklist-details.txt").read_text().replace("/var/lib/virtuality/images", "/root"))
assert "нельзя увеличить" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False))
def test_disk_resize_checks_free_space(logged_in, record, monkeypatch):
monkeypatch.setattr(vmops, "free_space", lambda path: 2 * GIB)
response = logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False)
assert "свободно только 2.0 ГБ" in location(response)
assert record.find("virsh", "blockresize") == []
def test_disk_resize_reports_tool_error(logged_in, record):
record.overrides[("virsh", "blockresize")] = conftest.fake_result(ok=False, stderr="error: internal error: Can't resize an image which has snapshots")
assert "удалите снимки" in location(logged_in.post("/vm/web01/disks/vda/resize", data={"size_gb": "50"}, follow_redirects=False))
# ---------------------------------------------------------------- pause / resume / hibernate
def test_ops_pause_and_resume(logged_in, record):
response = logged_in.post("/vm/web01/ops/pause", data={"next": "/"}, follow_redirects=False)
assert location(response).startswith("/?ops_message=Машина web01 приостановлена")
assert record.find("virsh", "suspend") == [["virsh", "suspend", "web01"]]
response = logged_in.post("/vm/web01/ops/resume", follow_redirects=False)
assert "ops_error=Продолжить можно только приостановленную" in location(response)
record.overrides[("virsh", "domstate")] = conftest.fake_result("paused")
response = logged_in.post("/vm/web01/ops/resume", data={"next": "http://evil.example/"}, follow_redirects=False)
assert location(response).startswith("/vm/web01?ops_message=") # чужой next не используется
assert record.find("virsh", "resume") == [["virsh", "resume", "web01"]]
def test_ops_validation(logged_in, record):
assert logged_in.post("/vm/web01/ops/explode", follow_redirects=False).status_code == 400
assert logged_in.post("/vm/-bad/ops/pause", follow_redirects=False).status_code == 400
assert location(logged_in.post("/vm/ghost/ops/pause", follow_redirects=False)) == "/"
shut_off(record)
assert "ops_error=Приостановить можно только работающую" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False))
record.overrides[("virsh", "suspend")] = conftest.fake_result(ok=False, stderr="error: Timed out during operation: cannot acquire state change lock")
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
assert "занята другой операцией" in location(logged_in.post("/vm/web01/ops/pause", follow_redirects=False))
def test_ops_requires_login(client):
assert client.post("/vm/web01/ops/pause", follow_redirects=False).headers["location"] == "/login"
def test_hibernate_runs_operation(logged_in, record, inline_operations, data_dirs):
response = logged_in.post("/vm/web01/ops/hibernate", data={"next": "/"}, follow_redirects=False)
assert location(response).startswith("/?ops_message=Машина web01 засыпает")
assert record.find("virsh", "managedsave") == [["virsh", "managedsave", "web01"]]
operations = core.list_operations(5)
assert operations[0]["type"] == "vm_hibernate" and operations[0]["status"] == "success" and operations[0]["vm_name"] == "web01"
def test_hibernate_blocked(logged_in, record, monkeypatch):
shut_off(record)
assert "ops_error=В спящий режим можно" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False))
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
monkeypatch.setattr(vmops, "free_space", lambda path: 0)
assert "свободного места" in location(logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False))
assert record.find("virsh", "managedsave") == []
def test_hibernate_failure_is_reported(logged_in, record, inline_operations):
record.overrides[("virsh", "managedsave")] = conftest.fake_result(ok=False, stderr="error: Requested operation is not valid: domain is not running")
logged_in.post("/vm/web01/ops/hibernate", follow_redirects=False)
operation = core.list_operations(1)[0]
assert operation["status"] == "error" and "выключена" in operation["message"]
def test_forget_saved_state(logged_in, record):
shut_off(record)
response = logged_in.post("/vm/web01/ops/forget-save", follow_redirects=False)
assert "ops_message=Сохранённое состояние удалено" in location(response)
assert record.find("virsh", "managedsave-remove") == [["virsh", "managedsave-remove", "web01"]]
# ---------------------------------------------------------------- notes
def test_notes_save_running(logged_in, record):
response = logged_in.post("/vm/web01/notes/save", data={"title": " Сайт компании ", "description": "строка 1\r\nстрока 2"}, follow_redirects=False)
assert location(response) == "/vm/web01?notes_message=Заметки сохранены"
assert record.find("virsh", "desc") == [
["virsh", "desc", "web01", "--title", "--new-desc=Сайт компании", "--config", "--live"],
["virsh", "desc", "web01", "--new-desc=строка 1\nстрока 2", "--config", "--live"],
]
def test_notes_save_shut_off_and_dashes(logged_in, record):
shut_off(record)
logged_in.post("/vm/web01/notes/save", data={"title": "--edit", "description": ""}, follow_redirects=False)
calls = record.find("virsh", "desc")
assert calls[0] == ["virsh", "desc", "web01", "--title", "--new-desc=--edit", "--config"] # текст одним аргументом, без --live
assert calls[1] == ["virsh", "desc", "web01", "--new-desc=", "--config"] # пустое описание очищает
def test_notes_save_error(logged_in, record):
record.overrides[("virsh", "desc")] = conftest.fake_result(ok=False, stderr="error: failed to get domain 'web01'")
assert "notes_error=" in location(logged_in.post("/vm/web01/notes/save", data={"title": "x"}, follow_redirects=False))
assert logged_in.post("/vm/-bad/notes/save", data={"title": "x"}, follow_redirects=False).status_code == 400
# ---------------------------------------------------------------- clone
def test_clone_page_blocked_while_running(logged_in, record):
response = logged_in.get("/vm/web01/clone")
assert response.status_code == 200
assert "Сначала выключите машину" in response.text
assert 'action="/vm/web01/shutdown"' in response.text
assert "disabled" in response.text
def test_clone_page_ready(logged_in, record):
shut_off(record)
html = logged_in.get("/vm/web01/clone").text
assert "Сначала выключите" not in html
assert 'value="web01-copy"' in html
assert "6.0 ГБ" in html # нужно места — реальный размер диска
assert "web01.qcow2" in html
def test_clone_validation(logged_in, record):
shut_off(record)
assert "Имя копии" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "bad name!"}, follow_redirects=False))
assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "db01"}, follow_redirects=False))
assert "уже есть" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "WEB01"}, follow_redirects=False))
record.overrides[("virsh", "domstate")] = conftest.fake_result("running")
assert "Сначала выключите" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False))
assert record.find("virt-clone") == []
assert logged_in.post("/vm/-bad/clone/start", data={"new_name": "web02"}, follow_redirects=False).status_code == 400
def test_clone_checks_space_and_existing_file(logged_in, record, data_dirs, monkeypatch):
shut_off(record)
(data_dirs["images"] / "web02.qcow2").write_bytes(b"x")
assert "уже есть в хранилище" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False))
monkeypatch.setattr(vmops, "free_space", lambda path: GIB)
assert "Не хватает места" in location(logged_in.post("/vm/web01/clone/start", data={"new_name": "web03"}, follow_redirects=False))
def test_clone_starts_operation(logged_in, record, inline_operations, data_dirs):
shut_off(record)
response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)
assert location(response).startswith("/operations/")
operation = core.read_operation(location(response).split("/")[-1])
assert operation["status"] == "success" and operation["type"] == "vm_clone"
assert operation["vm_name"] == "web02" and operation["source_vm"] == "web01"
assert "web02 создана" in operation["message"]
assert record.find("virt-clone") == [["virt-clone", "--original", "web01", "--name", "web02", "--file", str(data_dirs["images"] / "web02.qcow2")]]
assert record.find("virsh", "pool-refresh") == [["virsh", "pool-refresh", "virtuality-images"]]
assert "virt-clone --original web01" in operation["log_tail"]
def test_clone_failure_is_reported(logged_in, record, inline_operations):
shut_off(record)
record.overrides[("virt-clone",)] = conftest.fake_result(ok=False, stderr="ERROR Storage volume already exists")
response = logged_in.post("/vm/web01/clone/start", data={"new_name": "web02"}, follow_redirects=False)
operation = core.read_operation(location(response).split("/")[-1])
assert operation["status"] == "error" and "уже есть" in operation["message"]
assert record.find("virsh", "pool-refresh") == []
def test_clone_progress_watcher(data_dirs):
target = data_dirs["images"] / "copy.qcow2"
target.write_bytes(b"\0" * (512 * 1024))
operation = core.new_operation("vm_clone", "test", vm_name="copy", source_vm="web01")
class OneTick:
def __init__(self):
self.ticks = 0
def wait(self, timeout):
self.ticks += 1
return self.ticks > 1
vmops._watch_clone_progress(operation, [target], 1024 * 1024, OneTick())
fresh = core.read_operation(operation["id"])
assert 3 <= fresh["progress"] <= 95 and "Копируем диски" in fresh["message"]
assert vmops._allocated_bytes(data_dirs["images"] / "missing.qcow2") == 0
def test_clone_worker_stops_watcher(data_dirs, monkeypatch):
"""Даже если virt-clone упал, поток прогресса останавливается."""
monkeypatch.setattr(core, "run_cmd", lambda cmd, timeout=12: conftest.fake_result(ok=False, stderr="boom"))
operation = core.new_operation("vm_clone", "test", vm_name="web02", source_vm="web01", targets=[str(data_dirs["images"] / "web02.qcow2")], expected_bytes=10)
before = threading.active_count()
vmops.clone_worker(operation)
assert threading.active_count() <= before
assert core.read_operation(operation["id"])["status"] == "error"
+65
Просмотреть файл
@@ -1,5 +1,70 @@
{
"versions": [
{
"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",
"title": "Мастер настройки, снимки, резервные копии и быстрые машины из готовых образов",
"summary": "Virtuality стала полноценной заменой Proxmox для дома: после установки с ISO панель открывается сразу, а мастер настройки проводит через HTTPS, пароль, сеть машин, хранилище и обновления. Добавлены снимки, резервные копии, клонирование, увеличение диска, спящий режим, скачивание образов по ссылке и из каталога, создание машины из облачного образа с пользователем и паролем, установка Windows 11 с UEFI и TPM. Исправлено больше двадцати ошибок, найденных аудитом.",
"features": [
"Мастер первичной настройки: часовой пояс, пароль, HTTPS, сеть NAT или локальная сеть (с автооткатом), отдельный диск под машины, обновления",
"Первая загрузка образа: панель ставится первой и показывает ход установки в браузере; при ошибке установка продолжается сама",
"Страница «Настройки»: всё то же, что в мастере, плюс перезагрузка и выключение сервера, архив настроек, отчёт для поддержки",
"HTTPS для панели с сертификатом сервера и перенаправлением с обычного адреса",
"Снимки машин: создать, вернуться, удалить — до 8 на машину",
"Резервные копии машин: создание, восстановление в новую или ту же машину, удаление",
"Клонирование, увеличение диска, пауза и спящий режим, заметки, живая нагрузка процессора и памяти",
"Скачивание ISO и образов по ссылке и из каталога: Ubuntu 26.04/24.04, Debian 13, Alpine — с проверкой контрольных сумм",
"Машина из готового образа за минуту: пользователь, пароль и SSH-ключ через cloud-init, диск нужного размера",
"Тип системы при установке с ISO: Linux, Windows 11 (UEFI + TPM), Windows 10, другая",
"Постоянный адрес машины в сети NAT — пробросы портов не ломаются после перезагрузки",
"Стабильный канал обновлений: только выпущенные версии; ранний доступ — по желанию",
"Консоль машин доступна только через панель: VNC больше не открыт в сеть",
"Операции, прерванные перезапуском панели, больше не блокируют машину",
"Исправлены: формат облачных образов .img, CD-ROM, удаление UEFI-машин, правила firewall удалённых пробросов, русская локаль virsh, чужой IP у остановленных машин",
"Убраны Cockpit, консольный dashboard, эмуляция ARM64 на x86, загрузка по PXE и устаревшие скрипты",
"Команда virtuality-ctl: статус, настройки, пароль, часовой пояс, мост, диск, резервная копия настроек, откат, удаление",
"Версия обновлена до 0.11.0"
]
},
{
"version": "0.10.0",
"date": "2026-09-25",
"title": "Новый интерфейс, установочный образ и подготовка к продакшену",
"summary": "Панель полностью перерисована: светлая и тёмная темы, понятный русский язык вместо технических терминов, мастер создания машины из трёх шагов, открытие доступа из интернета в пару кликов. Добавлен установочный ISO-образ Virtuality на базе Ubuntu Server 26.04 LTS. Исправлены ошибки создания VM, автозапуска и обновлений, усилена безопасность.",
"features": [
"Новый дизайн панели: светлая и тёмная темы, адаптивная вёрстка для телефона",
"Главная страница: загрузка процессора, памяти и хранилища, карточки машин с быстрыми действиями",
"Мастер создания машины: готовые конфигурации «Мини», «Стандарт», «Мощная»",
"Страница машины: вкладки, переключатель автозапуска, порядок загрузки кнопками",
"Раздел «Сеть и доступ»: открытие SSH, сайта, RDP и других сервисов из списка",
"Загрузка образов перетаскиванием с прогрессом, скоростью и оставшимся временем",
"Подтверждения опасных действий и уведомления о результате",
"Раздел «Помощь» с инструкциями для частых задач",
"Установочный ISO на базе Ubuntu Server 26.04 LTS: image/build-iso.sh",
"Установщик ноды работает на Ubuntu 24.04, 26.04 и Debian 13",
"Исправлено падение создания VM из-за отсутствующего импорта platform",
"Исправлено: центр обновлений и автообновление предлагали откат на старую версию",
"Проверка пароля работает на Python 3.13+ без модулей crypt и spwd",
"Ограничение попыток входа, срок жизни сессии, защита от cross-origin POST",
"Правила проброса портов восстанавливаются после перезагрузки сервера",
"Настройки ноды сохраняются в /var/lib/virtuality/config/web.env и не сбрасываются обновлением",
"Публичный /healthz для мониторинга, автотесты и GitHub Actions",
"Версия обновлена до 0.10.0"
]
},
{
"version": "0.8.61",
"date": "2026-05-07",
Разница между файлами не показана из-за своего большого размера Загрузить разницу
+120
Просмотреть файл
@@ -0,0 +1,120 @@
"""Linux password verification for the Virtuality panel.
The stdlib `crypt` and `spwd` modules are deprecated in Python 3.11+ and
removed in 3.13 (Debian 13, Ubuntu 25.04+). This module uses them when they
exist and otherwise falls back to libcrypt via ctypes and /etc/shadow.
"""
import ctypes
import ctypes.util
import secrets
import threading
import time
import warnings
from pathlib import Path
SHADOW_FILE = Path("/etc/shadow")
LOCKED_HASHES = {"", "!", "*", "!!"}
_crypt_lock = threading.Lock()
_libcrypt = None
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
import crypt as _stdlib_crypt # type: ignore[import-not-found]
except ImportError:
_stdlib_crypt = None
try:
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
import spwd as _stdlib_spwd # type: ignore[import-not-found]
except ImportError:
_stdlib_spwd = None
def _load_libcrypt():
global _libcrypt
if _libcrypt is None:
name = ctypes.util.find_library("crypt") or "libcrypt.so.1"
lib = ctypes.CDLL(name)
lib.crypt.restype = ctypes.c_char_p
lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
_libcrypt = lib
return _libcrypt
def crypt_hash(password: str, salt: str) -> str | None:
# crypt(3) returns a pointer to a static buffer, so calls are serialized.
with _crypt_lock:
if _stdlib_crypt is not None:
return _stdlib_crypt.crypt(password, salt)
try:
result = _load_libcrypt().crypt(password.encode(), salt.encode())
except OSError:
return None
return result.decode() if result else None
def shadow_hash(username: str) -> str | None:
if _stdlib_spwd is not None:
try:
return _stdlib_spwd.getspnam(username).sp_pwdp
except (KeyError, PermissionError):
return None
try:
for line in SHADOW_FILE.read_text().splitlines():
parts = line.split(":")
if len(parts) > 1 and parts[0] == username:
return parts[1]
except OSError:
return None
return None
def verify_password(username: str, password: str) -> bool:
if not username or not password:
return False
stored_hash = shadow_hash(username)
if stored_hash is None or stored_hash in LOCKED_HASHES or stored_hash.startswith("!"):
return False
computed = crypt_hash(password, stored_hash)
if not computed:
return False
return secrets.compare_digest(computed, stored_hash)
class LoginThrottle:
"""In-memory brute-force protection keyed by client address."""
def __init__(self, max_failures: int = 5, window_seconds: int = 300, lockout_seconds: int = 300):
self.max_failures = max_failures
self.window_seconds = window_seconds
self.lockout_seconds = lockout_seconds
self._failures: dict[str, list[float]] = {}
self._locked_until: dict[str, float] = {}
self._lock = threading.Lock()
def retry_after(self, key: str) -> int:
with self._lock:
until = self._locked_until.get(key, 0.0)
remaining = until - time.monotonic()
if remaining <= 0:
self._locked_until.pop(key, None)
return 0
return int(remaining) + 1
def record_failure(self, key: str) -> None:
now = time.monotonic()
with self._lock:
recent = [ts for ts in self._failures.get(key, []) if now - ts < self.window_seconds]
recent.append(now)
if len(recent) >= self.max_failures:
self._locked_until[key] = now + self.lockout_seconds
recent = []
self._failures[key] = recent
def record_success(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
self._locked_until.pop(key, None)
-209
Просмотреть файл
@@ -1,209 +0,0 @@
#!/usr/bin/env python3
"""Бэкапы дисков VM: сжатые qcow2-копии в /var/lib/virtuality/backups.
Без внешних зависимостей — модуль используется и веб-панелью, и ночным
systemd-таймером (запуск: python3 backup_core.py --scheduled).
"""
import json
import re
import subprocess
import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import Any
BACKUPS_DIR = Path("/var/lib/virtuality/backups")
SCHEDULE_FILE = Path("/var/lib/virtuality/config/backup_schedule.json")
SCHEDULE_LOCK = threading.Lock()
LOG_FILE = Path("/var/log/virtuality/backup.log")
DEFAULT_KEEP = 5
def valid_vm_name(name: str) -> bool:
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,62}", name or ""))
def valid_backup_filename(name: str) -> bool:
return bool(re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,120}\.qcow2", name or ""))
def backup_dir_for(vm_name: str) -> Path | None:
if not valid_vm_name(vm_name):
return None
path = (BACKUPS_DIR / vm_name).resolve()
if BACKUPS_DIR.resolve() not in path.parents:
return None
return path
def backup_path(vm_name: str, filename: str) -> Path | None:
vm_dir = backup_dir_for(vm_name)
if vm_dir is None or not valid_backup_filename(filename):
return None
path = (vm_dir / filename).resolve()
if vm_dir not in path.parents:
return None
return path
def new_backup_path(vm_name: str) -> Path | None:
vm_dir = backup_dir_for(vm_name)
if vm_dir is None:
return None
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return vm_dir / f"{vm_name}_{stamp}.qcow2"
def list_backups() -> list[dict[str, Any]]:
backups = []
if not BACKUPS_DIR.exists():
return backups
for vm_dir in sorted(BACKUPS_DIR.iterdir()):
if not vm_dir.is_dir() or not valid_vm_name(vm_dir.name):
continue
for item in sorted(vm_dir.glob("*.qcow2"), reverse=True):
try:
stat = item.stat()
except OSError:
continue
backups.append({
"vm": vm_dir.name,
"name": item.name,
"path": str(item),
"size": f"{stat.st_size / 1024 / 1024:.0f} MB",
"created": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"),
})
return backups
def rotate_backups(vm_name: str, keep: int) -> list[str]:
"""Удаляет старые бэкапы сверх keep. Возвращает имена удалённых файлов."""
vm_dir = backup_dir_for(vm_name)
if vm_dir is None or not vm_dir.exists() or keep < 1:
return []
files = sorted(vm_dir.glob("*.qcow2"), key=lambda p: p.stat().st_mtime, reverse=True)
removed = []
for stale in files[keep:]:
try:
stale.unlink()
removed.append(stale.name)
except OSError:
pass
return removed
def load_schedule() -> dict[str, Any]:
try:
data = json.loads(SCHEDULE_FILE.read_text())
if isinstance(data, dict):
return {
"enabled": bool(data.get("enabled", False)),
"vms": [name for name in data.get("vms", []) if isinstance(name, str) and valid_vm_name(name)],
"keep": max(1, min(30, int(data.get("keep", DEFAULT_KEEP)))),
}
except Exception:
pass
return {"enabled": False, "vms": [], "keep": DEFAULT_KEEP}
def save_schedule(enabled: bool, vms: list[str], keep: int) -> dict[str, Any]:
config = {
"enabled": bool(enabled),
"vms": sorted({name for name in vms if valid_vm_name(name)}),
"keep": max(1, min(30, int(keep))),
}
with SCHEDULE_LOCK:
SCHEDULE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp_path = SCHEDULE_FILE.with_suffix(".json.tmp")
tmp_path.write_text(json.dumps(config, ensure_ascii=False, indent=2))
tmp_path.replace(SCHEDULE_FILE)
return config
def build_backup_script(vm_name: str, disk_path: str, target: Path) -> str:
"""Скрипт бэкапа: пауза работающей VM на время копии, сжатый qcow2, resume.
Для выключенной VM virsh suspend/resume тихо провалятся — это нормально.
"""
part = f"{target}.part"
return (
"set -uo pipefail; "
f"mkdir -p {target.parent}; "
f"virsh suspend {vm_name} >/dev/null 2>&1 && RESUME=1 || RESUME=0; "
f"qemu-img convert -p -c -O qcow2 {disk_path} {part}; STATUS=$?; "
f"[ \"$RESUME\" = 1 ] && virsh resume {vm_name} >/dev/null 2>&1; "
f"[ $STATUS -ne 0 ] && rm -f {part} && exit $STATUS; "
f"mv {part} {target}"
)
def build_restore_script(disk_path: str, backup_file: Path) -> str:
part = f"{disk_path}.restore"
return (
"set -euo pipefail; "
f"qemu-img convert -p -O qcow2 {backup_file} {part}; "
f"mv {part} {disk_path}"
)
# ---- CLI-режим для ночного systemd-таймера ----
def _log(message: str) -> None:
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with LOG_FILE.open("a", encoding="utf-8") as handle:
handle.write(f"[{stamp}] {message}\n")
def _run(cmd: list[str], timeout: int = 60) -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
def first_disk_of(vm_name: str) -> str | None:
result = _run(["virsh", "domblklist", vm_name, "--details"])
if result.returncode != 0:
return None
for line in result.stdout.splitlines()[2:]:
parts = line.split()
if len(parts) >= 4 and parts[1] == "disk" and parts[3].endswith(".qcow2"):
return parts[3]
return None
def run_scheduled_backups() -> int:
config = load_schedule()
if not config["enabled"]:
_log("Расписание выключено — выходим.")
return 0
if not config["vms"]:
_log("Расписание включено, но список VM пуст.")
return 0
failures = 0
for vm_name in config["vms"]:
disk = first_disk_of(vm_name)
if not disk:
_log(f"{vm_name}: qcow2-диск не найден, пропуск.")
failures += 1
continue
target = new_backup_path(vm_name)
if target is None:
_log(f"{vm_name}: некорректное имя VM, пропуск.")
failures += 1
continue
_log(f"{vm_name}: бэкап {disk} → {target}")
result = subprocess.run(["bash", "-c", build_backup_script(vm_name, disk, target)], capture_output=True, text=True, check=False)
if result.returncode == 0:
removed = rotate_backups(vm_name, config["keep"])
_log(f"{vm_name}: готово" + (f", ротация удалила: {', '.join(removed)}" if removed else ""))
else:
failures += 1
_log(f"{vm_name}: ошибка бэкапа (код {result.returncode}): {result.stderr.strip()[:400]}")
return 1 if failures else 0
if __name__ == "__main__":
if "--scheduled" in sys.argv:
sys.exit(run_scheduled_backups())
print("Использование: backup_core.py --scheduled", file=sys.stderr)
sys.exit(2)
+91
Просмотреть файл
@@ -0,0 +1,91 @@
"""Catalog of ready-to-run cloud images for the quick VM flow.
Each entry points to the vendor's official cloud image. Images boot in seconds
and are configured through cloud-init (user, password, SSH key) — no installer.
"""
from typing import Any
# osinfo ids must exist in osinfo-db shipped with Ubuntu 24.04+/Debian 13.
CLOUD_IMAGES: list[dict[str, Any]] = [
{
"id": "ubuntu-26.04",
"title": "Ubuntu Server 26.04 LTS",
"vendor": "Ubuntu",
"hint": "Самая свежая LTS, поддержка до 2031 года. Рекомендуем.",
"osinfo": "ubuntu24.04",
"recommended": True,
"login": "ubuntu",
"arches": {
"x86_64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"},
"aarch64": {"url": "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/resolute/current/SHA256SUMS", "algo": "sha256"},
},
},
{
"id": "ubuntu-24.04",
"title": "Ubuntu Server 24.04 LTS",
"vendor": "Ubuntu",
"hint": "Проверенная LTS, поддержка до 2029 года.",
"osinfo": "ubuntu24.04",
"login": "ubuntu",
"arches": {
"x86_64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"},
"aarch64": {"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img", "sums": "https://cloud-images.ubuntu.com/noble/current/SHA256SUMS", "algo": "sha256"},
},
},
{
"id": "debian-13",
"title": "Debian 13",
"vendor": "Debian",
"hint": "Лёгкий и стабильный. Хорош для домашних сервисов.",
"osinfo": "debian13",
"login": "debian",
"arches": {
"x86_64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"},
"aarch64": {"url": "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-arm64.qcow2", "sums": "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS", "algo": "sha512"},
},
},
{
"id": "alpine-3.23",
"title": "Alpine Linux 3.23",
"vendor": "Alpine",
"hint": "Очень маленький: 200 МБ на диске, стартует мгновенно.",
"osinfo": "alpinelinux3.21",
"login": "alpine",
"arches": {
"x86_64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-x86_64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True},
"aarch64": {"url": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2", "sums": "https://dl-cdn.alpinelinux.org/alpine/v3.23/releases/cloud/nocloud_alpine-3.23.0-aarch64-uefi-cloudinit-r0.qcow2.sha512", "algo": "sha512", "uefi": True},
},
},
]
# OS families for the "install from ISO" flow. virt-install picks the right
# virtual hardware from osinfo: Windows gets SATA/e1000e (works without extra
# drivers), win11 additionally gets UEFI + TPM.
OS_TYPES: list[dict[str, Any]] = [
{"id": "linux", "title": "Linux", "hint": "Ubuntu, Debian, Fedora и другие", "osinfo": "detect=on,require=off"},
{"id": "windows11", "title": "Windows 11 / Server 2025", "hint": "Нужны UEFI и TPM — включим сами", "osinfo": "win11"},
{"id": "windows10", "title": "Windows 10 / Server 2019–2022", "hint": "Классическая загрузка", "osinfo": "win10"},
{"id": "other", "title": "Другая система", "hint": "FreeBSD, старые ОС, экзотика", "osinfo": "generic"},
]
def cloud_image(image_id: str) -> dict[str, Any] | None:
return next((item for item in CLOUD_IMAGES if item["id"] == image_id), None)
def os_type(type_id: str) -> dict[str, Any]:
return next((item for item in OS_TYPES if item["id"] == type_id), OS_TYPES[0])
def image_filename(entry: dict[str, Any], arch: str) -> str:
url = entry["arches"][arch]["url"]
return url.rsplit("/", 1)[-1]
def entry_for_filename(name: str) -> dict[str, Any] | None:
"""Catalog entry whose download has this file name (any architecture)."""
for entry in CLOUD_IMAGES:
for arch in entry["arches"].values():
if arch["url"].rsplit("/", 1)[-1] == name:
return entry
return None
-162
Просмотреть файл
@@ -1,162 +0,0 @@
#!/usr/bin/env python3
"""Каталог cloud-образов и генерация cloud-init seed для быстрых VM."""
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any
CLOUD_IMAGES_DIR = Path("/var/lib/virtuality/cloud-images")
# Официальные cloud-образы. Для каждого дистрибутива — обе архитектуры,
# чтобы Raspberry/Orange Pi качали arm64 без ручного выбора URL.
CATALOG: list[dict[str, str]] = [
{
"key": "ubuntu-24.04-x86_64",
"label": "Ubuntu Server 24.04 LTS",
"arch": "x86_64",
"filename": "ubuntu-24.04-x86_64.qcow2",
"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
"default_user": "ubuntu",
},
{
"key": "ubuntu-24.04-aarch64",
"label": "Ubuntu Server 24.04 LTS",
"arch": "aarch64",
"filename": "ubuntu-24.04-aarch64.qcow2",
"url": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img",
"default_user": "ubuntu",
},
{
"key": "ubuntu-22.04-x86_64",
"label": "Ubuntu Server 22.04 LTS",
"arch": "x86_64",
"filename": "ubuntu-22.04-x86_64.qcow2",
"url": "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img",
"default_user": "ubuntu",
},
{
"key": "ubuntu-22.04-aarch64",
"label": "Ubuntu Server 22.04 LTS",
"arch": "aarch64",
"filename": "ubuntu-22.04-aarch64.qcow2",
"url": "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img",
"default_user": "ubuntu",
},
{
"key": "debian-12-x86_64",
"label": "Debian 12 Bookworm",
"arch": "x86_64",
"filename": "debian-12-x86_64.qcow2",
"url": "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2",
"default_user": "debian",
},
{
"key": "debian-12-aarch64",
"label": "Debian 12 Bookworm",
"arch": "aarch64",
"filename": "debian-12-aarch64.qcow2",
"url": "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-arm64.qcow2",
"default_user": "debian",
},
]
def catalog_entry(key: str) -> dict[str, str] | None:
for entry in CATALOG:
if entry["key"] == key:
return entry
return None
def catalog_for_arch(host_arch: str) -> list[dict[str, Any]]:
"""Каталог с отметкой native — образы родной архитектуры хоста показываются первыми."""
native = "aarch64" if host_arch in ("aarch64", "arm64") else "x86_64"
entries = [dict(entry, native=entry["arch"] == native, downloaded=(CLOUD_IMAGES_DIR / entry["filename"]).exists()) for entry in CATALOG]
return sorted(entries, key=lambda item: (not item["native"], item["label"]))
def safe_image_filename(filename: str) -> str | None:
name = Path(filename or "").name.strip()
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,180}\.(qcow2|img)", name):
return None
return name
def image_path_by_name(name: str) -> Path | None:
safe_name = safe_image_filename(name)
if not safe_name:
return None
path = (CLOUD_IMAGES_DIR / safe_name).resolve()
if CLOUD_IMAGES_DIR.resolve() not in path.parents:
return None
return path
def list_images() -> list[dict[str, str]]:
CLOUD_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
files = []
for item in sorted(CLOUD_IMAGES_DIR.glob("*")):
if item.suffix.lower() not in (".qcow2", ".img") or item.name.endswith(".part"):
continue
try:
stat = item.stat()
size_mb = stat.st_size / 1024 / 1024
updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
except OSError:
size_mb = 0
updated = "unknown"
known = next((entry for entry in CATALOG if entry["filename"] == item.name), None)
files.append({
"name": item.name,
"path": str(item),
"size": f"{size_mb:.0f} MB",
"updated": updated,
"label": known["label"] if known else item.stem,
"arch": known["arch"] if known else ("aarch64" if "arm64" in item.name or "aarch64" in item.name else "x86_64"),
"default_user": known["default_user"] if known else "user",
})
return files
def valid_cloud_username(value: str) -> bool:
return bool(re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", value or ""))
def valid_ssh_key(value: str) -> bool:
if not value:
return True
return bool(re.fullmatch(r"(ssh-(rsa|ed25519|dss)|ecdsa-sha2-[a-z0-9-]+) [A-Za-z0-9+/=]+( [^\r\n]{0,256})?", value.strip()))
def build_user_data(hostname: str, username: str, password: str, ssh_key: str) -> str:
lines = [
"#cloud-config",
f"hostname: {hostname}",
"manage_etc_hosts: true",
f"ssh_pwauth: {'true' if password else 'false'}",
"users:",
f" - name: {username}",
" groups: [sudo]",
" shell: /bin/bash",
' sudo: "ALL=(ALL) NOPASSWD:ALL"',
" lock_passwd: false",
]
if ssh_key:
lines.append(" ssh_authorized_keys:")
lines.append(f" - {ssh_key.strip()}")
if password:
lines += [
"chpasswd:",
" expire: false",
" users:",
f" - name: {username}",
f" password: {json.dumps(password, ensure_ascii=False)}",
" type: text",
]
lines.append("package_update: false")
return "\n".join(lines) + "\n"
def build_meta_data(name: str) -> str:
return f"instance-id: virtuality-{name}\nlocal-hostname: {name}\n"

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